@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +269 -12
  3. package/ai/SPEC.md +7 -2
  4. package/dist/admin/adapters/d1.d.ts +27 -0
  5. package/dist/admin/adapters/d1.js +73 -0
  6. package/dist/admin/adapters/firestore.d.ts +40 -0
  7. package/dist/admin/adapters/firestore.js +53 -0
  8. package/dist/admin/amount.d.ts +5 -0
  9. package/dist/admin/amount.js +11 -0
  10. package/dist/admin/conformance.d.ts +132 -0
  11. package/dist/admin/conformance.js +467 -0
  12. package/dist/admin/guard.d.ts +38 -0
  13. package/dist/admin/guard.js +57 -0
  14. package/dist/admin/handler.d.ts +63 -0
  15. package/dist/admin/handler.js +170 -0
  16. package/dist/admin/http.d.ts +17 -0
  17. package/dist/admin/http.js +76 -0
  18. package/dist/admin/index.d.ts +13 -0
  19. package/dist/admin/index.js +14 -0
  20. package/dist/admin/memory.d.ts +32 -0
  21. package/dist/admin/memory.js +40 -0
  22. package/dist/admin/panel/api.d.ts +11 -0
  23. package/dist/admin/panel/api.js +33 -0
  24. package/dist/admin/panel/dom.d.ts +7 -0
  25. package/dist/admin/panel/dom.js +33 -0
  26. package/dist/admin/panel/gameCard.d.ts +4 -0
  27. package/dist/admin/panel/gameCard.js +56 -0
  28. package/dist/admin/panel/index.d.ts +10 -0
  29. package/dist/admin/panel/index.js +13 -0
  30. package/dist/admin/panel/mount.d.ts +30 -0
  31. package/dist/admin/panel/mount.js +147 -0
  32. package/dist/admin/panel/shelf.d.ts +24 -0
  33. package/dist/admin/panel/shelf.js +271 -0
  34. package/dist/admin/panel/shelfData.d.ts +37 -0
  35. package/dist/admin/panel/shelfData.js +68 -0
  36. package/dist/admin/panel/signins.d.ts +6 -0
  37. package/dist/admin/panel/signins.js +69 -0
  38. package/dist/admin/store.d.ts +121 -0
  39. package/dist/admin/store.js +3 -0
  40. package/dist/admin/summary.d.ts +39 -0
  41. package/dist/admin/summary.js +85 -0
  42. package/dist/admin/switches.d.ts +58 -0
  43. package/dist/admin/switches.js +61 -0
  44. package/dist/character/bases/bee.js +7 -5
  45. package/dist/character/bases/spec.d.ts +7 -0
  46. package/dist/character/create.d.ts +15 -0
  47. package/dist/character/create.js +69 -2
  48. package/dist/character/ctx.d.ts +12 -2
  49. package/dist/character/index.d.ts +2 -0
  50. package/dist/character/index.js +2 -0
  51. package/dist/character/make.js +2 -1
  52. package/dist/character/preview.d.ts +32 -0
  53. package/dist/character/preview.js +112 -0
  54. package/dist/character/sockets.d.ts +37 -0
  55. package/dist/character/sockets.js +66 -0
  56. package/dist/looks/change.d.ts +8 -4
  57. package/dist/looks/change.js +25 -3
  58. package/dist/looks/index.d.ts +3 -3
  59. package/dist/looks/index.js +2 -2
  60. package/dist/looks/json.d.ts +2 -2
  61. package/dist/looks/json.js +6 -4
  62. package/dist/looks/look.d.ts +29 -3
  63. package/dist/looks/look.js +60 -4
  64. package/dist/model/check.d.ts +59 -0
  65. package/dist/model/check.js +681 -0
  66. package/dist/model/index.d.ts +2 -0
  67. package/dist/model/index.js +5 -0
  68. package/dist/model/limits.d.ts +103 -0
  69. package/dist/model/limits.js +132 -0
  70. package/package.json +11 -3
  71. package/src/admin/adapters/d1.ts +94 -0
  72. package/src/admin/adapters/firestore.ts +78 -0
  73. package/src/admin/amount.ts +14 -0
  74. package/src/admin/conformance.ts +605 -0
  75. package/src/admin/guard.ts +86 -0
  76. package/src/admin/handler.ts +200 -0
  77. package/src/admin/http.ts +74 -0
  78. package/src/admin/index.ts +21 -0
  79. package/src/admin/memory.ts +59 -0
  80. package/src/admin/panel/api.ts +37 -0
  81. package/src/admin/panel/dom.ts +31 -0
  82. package/src/admin/panel/gameCard.ts +63 -0
  83. package/src/admin/panel/index.ts +18 -0
  84. package/src/admin/panel/mount.ts +227 -0
  85. package/src/admin/panel/shelf.ts +361 -0
  86. package/src/admin/panel/shelfData.ts +87 -0
  87. package/src/admin/panel/signins.ts +68 -0
  88. package/src/admin/panel/style.css +551 -0
  89. package/src/admin/store.ts +98 -0
  90. package/src/admin/summary.ts +103 -0
  91. package/src/admin/switches.ts +74 -0
  92. package/src/character/bases/bee.ts +7 -5
  93. package/src/character/bases/spec.ts +4 -0
  94. package/src/character/create.ts +73 -2
  95. package/src/character/ctx.ts +17 -2
  96. package/src/character/index.ts +2 -0
  97. package/src/character/make.ts +2 -1
  98. package/src/character/preview.ts +148 -0
  99. package/src/character/sockets.ts +81 -0
  100. package/src/looks/change.ts +25 -6
  101. package/src/looks/index.ts +3 -3
  102. package/src/looks/json.ts +6 -4
  103. package/src/looks/look.ts +66 -5
  104. package/src/model/check.ts +624 -0
  105. package/src/model/index.ts +5 -0
  106. package/src/model/limits.ts +193 -0
@@ -0,0 +1,2 @@
1
+ export { checkModel, modelSummary, sniffTexture, type ModelCheck, type ModelFacts, type ModelProblem, type ModelRule, type TextureFact, type Vec3 } from "./check.js";
2
+ export { BACK_ITEM, COSMETIC_LIMITS, HAND_ITEM, HEAD_ITEM, ISLAND_MODEL, PLAIN_EXTENSIONS, type MarkerRule, type ModelLimits, type ZoneBox, type ZoneDisc } from "./limits.js";
@@ -0,0 +1,5 @@
1
+ // "@nano133/nanoterra/model": the 3D model check. Pure: no Three.js, no network, no DOM. It reads a .glb from its bytes and says what is
2
+ // wrong with it (with a fix for each), what it measured and how to make it lighter, against a preset of limits: an island model, or a
3
+ // cosmetic for one of the character's three sockets (head, hand, back).
4
+ export { checkModel, modelSummary, sniffTexture } from "./check.js";
5
+ export { BACK_ITEM, COSMETIC_LIMITS, HAND_ITEM, HEAD_ITEM, ISLAND_MODEL, PLAIN_EXTENSIONS } from "./limits.js";
@@ -0,0 +1,103 @@
1
+ /** A named node the model must (or may) have. `empty`: a node with no mesh (a point, as `nano_door`); `mesh`: a node with a mesh that is never drawn (a collider). */
2
+ export type MarkerRule = {
3
+ name: string;
4
+ required: boolean;
5
+ kind: "empty" | "mesh"; /** A `mesh` marker's own triangle limit (it is not counted in `maxTriangles`). */
6
+ maxTriangles?: number; /** What the marker is for, in plain words, for a fix line. */
7
+ purpose: string;
8
+ };
9
+ export type ZoneBox = {
10
+ width: number;
11
+ depth: number;
12
+ height: number;
13
+ below: number;
14
+ };
15
+ export type ZoneDisc = {
16
+ radius: number;
17
+ height: number;
18
+ below: number;
19
+ };
20
+ export type ModelLimits = {
21
+ /** What the model is, for a fix line ("island model"). */
22
+ what: string;
23
+ /** The whole .glb, in bytes. */
24
+ maxBytes: number;
25
+ /** The JSON chunk, in bytes (the model's description; the data is in the BIN chunk). */
26
+ maxJsonBytes: number;
27
+ /** Triangles drawn, every mesh instance counted, the `mesh` markers left out. */
28
+ maxTriangles: number;
29
+ /** Draw calls: each part (glTF primitive) of each drawn mesh instance. Left out: no limit. */
30
+ maxDraws?: number;
31
+ /** Images in the file. */
32
+ maxTextures: number;
33
+ /** The longest side of each image, in pixels. */
34
+ maxTextureSide: number;
35
+ /**
36
+ * The build zone, in metres, in the model's own axes (glTF: +Y up). A box: x from -width/2 to width/2, z from -depth/2
37
+ * to depth/2. A disc: every point at most `radius` from the y axis (x and z together). Both: y from -below to height.
38
+ * The model's origin is the middle of the plot, on the ground.
39
+ */
40
+ zone: ZoneBox | ZoneDisc;
41
+ markers: readonly MarkerRule[];
42
+ /**
43
+ * A marker whose origin is the attach point (a hand item's `grip_R`): when the file has it, the zone is measured from it, not from the
44
+ * file's origin. It should be one of `markers` too (so a misspelt name gets a fix line).
45
+ */
46
+ anchor?: string;
47
+ /** The glTF extensions the file may use (plain data only). Every other one is refused. */
48
+ extensions: readonly string[];
49
+ /** Animations, skins and morph targets: refused unless allowed. When allowed, the zone is checked in the rest pose only. */
50
+ allowAnimations: boolean;
51
+ allowSkins: boolean;
52
+ allowMorphTargets: boolean;
53
+ /** Caps on the description, so a hostile file costs little to read. */
54
+ maxNodes: number;
55
+ maxMeshes: number;
56
+ maxPrimitives: number;
57
+ maxAccessors: number;
58
+ maxBufferViews: number;
59
+ maxMaterials: number;
60
+ /** The node tree's depth. */
61
+ maxDepth: number;
62
+ /** Vertex reads for the exact zone check (every rotated instance reads its vertices); past it, the box of each instance is used (larger, never smaller). */
63
+ maxVertexWork: number;
64
+ /** The whole check ends by then (ms). */
65
+ maxMs: number;
66
+ };
67
+ /** The texture compression and material extensions three.js reads as plain data. */
68
+ export declare const PLAIN_EXTENSIONS: readonly ["KHR_texture_transform", "KHR_texture_basisu", "EXT_texture_webp", "KHR_mesh_quantization", "KHR_materials_emissive_strength", "KHR_materials_unlit", "KHR_materials_ior", "KHR_materials_specular", "KHR_materials_clearcoat", "KHR_materials_sheen", "KHR_materials_transmission", "KHR_materials_volume", "KHR_materials_iridescence", "KHR_materials_anisotropy", "KHR_materials_dispersion", "KHR_xmp_json_ld"];
69
+ /**
70
+ * A creator's island model (the creator islands gameplan, phase 2; Wes approved it on 2026-10-27): the entry of their
71
+ * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 10 m radius (20 m across)
72
+ * and 18 m high on the plot's middle: the plot's free middle (PLOT_FREE_R in app/nanoterra/wheel/plots.ts; Wes chose the
73
+ * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
74
+ * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
75
+ */
76
+ export declare const ISLAND_MODEL: ModelLimits;
77
+ /**
78
+ * A worn thing on the character's head socket (@nano133/nanoterra/character, `socket("head")`; Wes approved the cosmetic limits on
79
+ * 2026-10-28). The origin is the socket's point, the middle of the hat frame of the Classic's head, in metres for a character
80
+ * 2.29 m tall (the socket scales it to every other head). The zone is 0.9 x 0.9 m and 0.8 m high, from 0.1 m below the origin.
81
+ * 3,000 triangles, 1 texture of at most 512 px, 2 draws, 300 KB. Rigid: no skin, no animation (it rides its bone).
82
+ */
83
+ export declare const HEAD_ITEM: ModelLimits;
84
+ /**
85
+ * A worn thing in the character's right hand (`socket("hand")`). Its attach point is its `grip_R` marker (an empty node where the right
86
+ * hand holds it), else the file's origin; a `grip_L` marker (where a left hand would hold it) is allowed. The zone is 0.96 x 0.68 m and
87
+ * 1.6 m long, from 1.17 m below the grip to 0.43 m above it: a tool held near its top, as a shovel by its T-handle (Wes chose this zone on
88
+ * 2026-10-28 over the first 0.4 x 0.4 m from 0.3 m below, which the example shovel did not fit). 3,000 triangles, 1 texture of at most
89
+ * 512 px, 2 draws, 300 KB.
90
+ */
91
+ export declare const HAND_ITEM: ModelLimits;
92
+ /**
93
+ * A worn thing on the character's back (`socket("back")`). The origin is the socket's point, on the back of the torso at the chest's
94
+ * height; the side that touches the body is at the origin and the bulk goes back (-Z). The zone is 1.0 m wide, 0.6 m deep (its middle at
95
+ * the origin) and 1.2 m high, from 0.6 m below. 4,000 triangles, 1 texture of at most 512 px, 2 draws, 400 KB.
96
+ */
97
+ export declare const BACK_ITEM: ModelLimits;
98
+ /** The cosmetic preset of each socket. */
99
+ export declare const COSMETIC_LIMITS: {
100
+ readonly head: ModelLimits;
101
+ readonly hand: ModelLimits;
102
+ readonly back: ModelLimits;
103
+ };
@@ -0,0 +1,132 @@
1
+ // The limits a 3D model is checked against (@nano133/nanoterra/model; the README's "Model check"; on nano133.com
2
+ // docs/agents/launch.md, "The island model"). The checker (check.ts) is general: it takes one of these objects, so each
3
+ // kind of model (an island, a cosmetic on each socket) is a preset here with the same code. Pure data: no network, no DOM.
4
+ /** The texture compression and material extensions three.js reads as plain data. */
5
+ export const PLAIN_EXTENSIONS = [
6
+ "KHR_texture_transform",
7
+ "KHR_texture_basisu",
8
+ "EXT_texture_webp",
9
+ "KHR_mesh_quantization",
10
+ "KHR_materials_emissive_strength",
11
+ "KHR_materials_unlit",
12
+ "KHR_materials_ior",
13
+ "KHR_materials_specular",
14
+ "KHR_materials_clearcoat",
15
+ "KHR_materials_sheen",
16
+ "KHR_materials_transmission",
17
+ "KHR_materials_volume",
18
+ "KHR_materials_iridescence",
19
+ "KHR_materials_anisotropy",
20
+ "KHR_materials_dispersion",
21
+ "KHR_xmp_json_ld",
22
+ ];
23
+ /**
24
+ * A creator's island model (the creator islands gameplan, phase 2; Wes approved it on 2026-10-27): the entry of their
25
+ * game on their plot. 5 MB, 50,000 triangles, 8 textures of at most 1,024 px, inside a disc of 10 m radius (20 m across)
26
+ * and 18 m high on the plot's middle: the plot's free middle (PLOT_FREE_R in app/nanoterra/wheel/plots.ts; Wes chose the
27
+ * disc over a 24 x 24 m box on 2026-10-28, as the box reached the gates' standing places); an empty `nano_door` where Play stands, an optional empty `nano_spawn` (the arrival point) and an
28
+ * optional simple `nano_collide` mesh. No lights, no scripts, nothing outside the file. A still model for now.
29
+ */
30
+ export const ISLAND_MODEL = {
31
+ what: "island model",
32
+ maxBytes: 5 * 1024 * 1024,
33
+ maxJsonBytes: 1024 * 1024,
34
+ maxTriangles: 50_000,
35
+ maxTextures: 8,
36
+ maxTextureSide: 1024,
37
+ zone: { radius: 10, height: 18, below: 0 },
38
+ markers: [
39
+ { name: "nano_door", required: true, kind: "empty", purpose: "where Play stands" },
40
+ { name: "nano_spawn", required: false, kind: "empty", purpose: "where players arrive" },
41
+ { name: "nano_collide", required: false, kind: "mesh", maxTriangles: 5_000, purpose: "the simple shape players walk on" },
42
+ ],
43
+ extensions: PLAIN_EXTENSIONS,
44
+ allowAnimations: false,
45
+ allowSkins: false,
46
+ allowMorphTargets: false,
47
+ maxNodes: 4096,
48
+ maxMeshes: 1024,
49
+ maxPrimitives: 4096,
50
+ maxAccessors: 16_384,
51
+ maxBufferViews: 16_384,
52
+ maxMaterials: 256,
53
+ maxDepth: 64,
54
+ maxVertexWork: 4_000_000,
55
+ maxMs: 2_000,
56
+ };
57
+ /**
58
+ * The caps on the description of a cosmetic: a worn thing is small, so a file far past these is not one (and costs little to refuse).
59
+ */
60
+ const COSMETIC_CAPS = {
61
+ maxJsonBytes: 256 * 1024,
62
+ extensions: PLAIN_EXTENSIONS,
63
+ allowAnimations: false,
64
+ allowSkins: false,
65
+ allowMorphTargets: false,
66
+ maxNodes: 256,
67
+ maxMeshes: 32,
68
+ maxPrimitives: 64,
69
+ maxAccessors: 1024,
70
+ maxBufferViews: 1024,
71
+ maxMaterials: 16,
72
+ maxDepth: 32,
73
+ maxVertexWork: 500_000,
74
+ maxMs: 1_000,
75
+ };
76
+ /**
77
+ * A worn thing on the character's head socket (@nano133/nanoterra/character, `socket("head")`; Wes approved the cosmetic limits on
78
+ * 2026-10-28). The origin is the socket's point, the middle of the hat frame of the Classic's head, in metres for a character
79
+ * 2.29 m tall (the socket scales it to every other head). The zone is 0.9 x 0.9 m and 0.8 m high, from 0.1 m below the origin.
80
+ * 3,000 triangles, 1 texture of at most 512 px, 2 draws, 300 KB. Rigid: no skin, no animation (it rides its bone).
81
+ */
82
+ export const HEAD_ITEM = {
83
+ ...COSMETIC_CAPS,
84
+ what: "head item",
85
+ maxBytes: 300 * 1024,
86
+ maxTriangles: 3_000,
87
+ maxDraws: 2,
88
+ maxTextures: 1,
89
+ maxTextureSide: 512,
90
+ zone: { width: 0.9, depth: 0.9, height: 0.7, below: 0.1 },
91
+ markers: [],
92
+ };
93
+ /**
94
+ * A worn thing in the character's right hand (`socket("hand")`). Its attach point is its `grip_R` marker (an empty node where the right
95
+ * hand holds it), else the file's origin; a `grip_L` marker (where a left hand would hold it) is allowed. The zone is 0.96 x 0.68 m and
96
+ * 1.6 m long, from 1.17 m below the grip to 0.43 m above it: a tool held near its top, as a shovel by its T-handle (Wes chose this zone on
97
+ * 2026-10-28 over the first 0.4 x 0.4 m from 0.3 m below, which the example shovel did not fit). 3,000 triangles, 1 texture of at most
98
+ * 512 px, 2 draws, 300 KB.
99
+ */
100
+ export const HAND_ITEM = {
101
+ ...COSMETIC_CAPS,
102
+ what: "hand item",
103
+ maxBytes: 300 * 1024,
104
+ maxTriangles: 3_000,
105
+ maxDraws: 2,
106
+ maxTextures: 1,
107
+ maxTextureSide: 512,
108
+ zone: { width: 0.96, depth: 0.68, height: 0.43, below: 1.17 },
109
+ markers: [
110
+ { name: "grip_R", required: false, kind: "empty", purpose: "where the right hand holds it" },
111
+ { name: "grip_L", required: false, kind: "empty", purpose: "where a left hand would hold it" },
112
+ ],
113
+ anchor: "grip_R",
114
+ };
115
+ /**
116
+ * A worn thing on the character's back (`socket("back")`). The origin is the socket's point, on the back of the torso at the chest's
117
+ * height; the side that touches the body is at the origin and the bulk goes back (-Z). The zone is 1.0 m wide, 0.6 m deep (its middle at
118
+ * the origin) and 1.2 m high, from 0.6 m below. 4,000 triangles, 1 texture of at most 512 px, 2 draws, 400 KB.
119
+ */
120
+ export const BACK_ITEM = {
121
+ ...COSMETIC_CAPS,
122
+ what: "back item",
123
+ maxBytes: 400 * 1024,
124
+ maxTriangles: 4_000,
125
+ maxDraws: 2,
126
+ maxTextures: 1,
127
+ maxTextureSide: 512,
128
+ zone: { width: 1.0, depth: 0.6, height: 0.6, below: 0.6 },
129
+ markers: [],
130
+ };
131
+ /** The cosmetic preset of each socket. */
132
+ export const COSMETIC_LIMITS = { head: HEAD_ITEM, hand: HAND_ITEM, back: BACK_ITEM };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nano133/nanoterra",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Nanoterra for a creator's game, in one package: the player's sign-in ticket, the player's character (a toon body on the island's skeleton) and the looks ladder.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,14 @@
10
10
  "./character": { "types": "./dist/character/index.d.ts", "import": "./dist/character/index.js" },
11
11
  "./character/record": { "types": "./dist/character/pure.d.ts", "import": "./dist/character/pure.js" },
12
12
  "./ticket": { "types": "./dist/ticket/index.d.ts", "import": "./dist/ticket/index.js" },
13
- "./looks": { "types": "./dist/looks/index.d.ts", "import": "./dist/looks/index.js" }
13
+ "./looks": { "types": "./dist/looks/index.d.ts", "import": "./dist/looks/index.js" },
14
+ "./model": { "types": "./dist/model/index.d.ts", "import": "./dist/model/index.js" },
15
+ "./admin": { "types": "./dist/admin/index.d.ts", "import": "./dist/admin/index.js" },
16
+ "./admin/conformance": { "types": "./dist/admin/conformance.d.ts", "import": "./dist/admin/conformance.js" },
17
+ "./admin/d1": { "types": "./dist/admin/adapters/d1.d.ts", "import": "./dist/admin/adapters/d1.js" },
18
+ "./admin/firestore": { "types": "./dist/admin/adapters/firestore.d.ts", "import": "./dist/admin/adapters/firestore.js" },
19
+ "./admin/panel": { "types": "./dist/admin/panel/index.d.ts", "import": "./dist/admin/panel/index.js" },
20
+ "./admin/style.css": "./src/admin/panel/style.css"
14
21
  },
15
22
  "sideEffects": false,
16
23
  "files": ["dist", "src", "README.md", "CHANGELOG.md", "LICENSE", "ai"],
@@ -25,6 +32,7 @@
25
32
  "devDependencies": {
26
33
  "@types/node": "^26.6.3",
27
34
  "@types/three": "^0.186.0",
35
+ "happy-dom": "^20.14.6",
28
36
  "three": "^0.186.0",
29
37
  "tsx": "^4.23.15",
30
38
  "typescript": "5.9"
@@ -37,5 +45,5 @@
37
45
  },
38
46
  "homepage": "https://nano133.com/build/nanoterra",
39
47
  "license": "MIT",
40
- "keywords": ["nano", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
48
+ "keywords": ["nano", "gltf", "glb", "model check", "xno", "nanoterra", "sign in", "ticket", "character", "avatar", "three", "three.js", "toon", "looks", "cosmetics"]
41
49
  }
@@ -0,0 +1,94 @@
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 two settings (`setting:character`, `setting:signin` in `meta`); everything else it does is a read, except when the game turns the sign-in list on
3
+ // (`logins: true`): then the one other write is `DELETE FROM sessions WHERE scope = 'admin'` ("sign out everywhere").
4
+ //
5
+ // import { createD1Store } from "@nano133/nanoterra/admin/d1";
6
+ // const store = createD1Store(env.DB);
7
+
8
+ import { ORDERS_SHOWN, moneyOf, parseRate, shapeOrders, shapePlayers, type OrderRow } from "../summary.js";
9
+ import type { AdminStore, StoreSummary } from "../store.js";
10
+ import type { GamePatch } from "../switches.js";
11
+
12
+ /** The part of D1 that the adapter uses. Cloudflare's `D1Database` fits it. */
13
+ export interface D1StatementLike {
14
+ bind(...values: unknown[]): D1StatementLike;
15
+ all<T = unknown>(): Promise<{ results: T[] }>;
16
+ first<T = unknown>(): Promise<T | null>;
17
+ run(): Promise<unknown>;
18
+ }
19
+ export interface D1Like {
20
+ prepare(query: string): D1StatementLike;
21
+ batch(statements: D1StatementLike[]): Promise<unknown[]>;
22
+ }
23
+
24
+ export const D1_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
25
+
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
+
28
+ /**
29
+ * The admin store for D1. With `logins: true` it also lists the last admin sign-ins (the table `admin_logins`: at, country, browser) and can sign the owner out everywhere
30
+ * (`DELETE FROM sessions WHERE scope = 'admin'`, and `UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0` so that a request signed a moment ago
31
+ * cannot make a new login afterwards, and a wallet app that is answered later is told that the request has ended): the game's `sessions` and `signin_requests` tables must have a `scope` column ('admin' for an admin login or request) and the game must write
32
+ * `admin_logins` when it makes an admin login. Without the option the store has neither call, so a game that has not made those tables is not broken by it.
33
+ */
34
+ export function createD1Store(db: D1Like, options: { logins?: boolean } = {}): AdminStore {
35
+ const store: AdminStore = {
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;
38
+ const row = (key: string) => rows.find((r) => r.key === key)?.value;
39
+ return { character: row(D1_KEYS.character), signin: row(D1_KEYS.signin) };
40
+ },
41
+
42
+ async saveSettings(patch: GamePatch, now: number) {
43
+ const write = (key: string, value: string) =>
44
+ db.prepare("INSERT INTO meta (key, value, at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, at = excluded.at").bind(key, value, now);
45
+ const batch = [];
46
+ if (patch.character) batch.push(write(D1_KEYS.character, patch.character));
47
+ if (patch.signin) batch.push(write(D1_KEYS.signin, patch.signin));
48
+ if (batch.length) await db.batch(batch);
49
+ },
50
+
51
+ async summary(now: number): Promise<StoreSummary> {
52
+ // The game starter's own queries: the counts and the totals are over ALL orders, the list is the newest ORDERS_SHOWN.
53
+ const orders = (
54
+ await db
55
+ .prepare("SELECT id, player, look_id, usd, amount, status, created_at, expires_at, paid_at, payer, hash FROM orders ORDER BY created_at DESC LIMIT ?")
56
+ .bind(ORDERS_SHOWN)
57
+ .all<Row>()
58
+ ).results;
59
+ const owned = (await db.prepare("SELECT player, look_id, granted_at FROM owned ORDER BY player, granted_at").all<{ player: string; look_id: string; granted_at: number }>()).results;
60
+ const sums = (await db.prepare("SELECT status, amount, usd FROM orders WHERE status IN ('paid', 'paid_late')").all<{ status: string; amount: string; usd: string }>()).results;
61
+ const grouped = (await db.prepare("SELECT status, expires_at < ? AS past, COUNT(*) AS n FROM orders GROUP BY status, past").bind(now).all<{ status: string; past: number; n: number }>()).results;
62
+ const rate = await db.prepare("SELECT value, at FROM meta WHERE key = 'rate'").first<{ value: string; at: number }>();
63
+
64
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
65
+ for (const c of grouped) {
66
+ const key = c.status === "open" && c.past ? "expired" : c.status;
67
+ if (Object.hasOwn(counts, key)) counts[key as keyof typeof counts] += c.n;
68
+ }
69
+ const money = (status: string) => moneyOf(sums.filter((s) => s.status === status).map((s) => ({ amountRaw: s.amount, usd: s.usd })));
70
+ const rows: OrderRow[] = orders.map((o) => ({ id: o.id, player: o.player, lookId: o.look_id, usd: o.usd, amountRaw: o.amount, status: o.status, createdAt: o.created_at, expiresAt: o.expires_at, paidAt: o.paid_at, payer: o.payer, hash: o.hash }));
71
+ return {
72
+ counts,
73
+ totals: { paid: money("paid"), paidLate: money("paid_late") },
74
+ orders: shapeOrders(rows, now),
75
+ players: shapePlayers(owned.map((o) => ({ player: o.player, lookId: o.look_id, at: o.granted_at }))),
76
+ rate: parseRate(rate?.value, rate?.at),
77
+ };
78
+ },
79
+ };
80
+ if (options.logins) {
81
+ store.recentLogins = async (limit) => {
82
+ const n = Math.min(Math.max(Math.floor(Number(limit)) || 0, 0), 50);
83
+ const rows = (await db.prepare("SELECT at, country, browser FROM admin_logins ORDER BY at DESC, id DESC LIMIT ?").bind(n).all<{ at: number; country: string | null; browser: string }>()).results;
84
+ return rows.map((r) => ({ at: r.at, country: r.country, browser: r.browser }));
85
+ };
86
+ // One batch: the admin sessions are deleted, AND every admin sign-in request that was started but not delivered is marked delivered, so a request that the owner signed a moment
87
+ // ago cannot make a new admin login after "Sign out everywhere" (its check would answer "used").
88
+ store.signOutEverywhere = async () => {
89
+ const done = (await db.batch([db.prepare("DELETE FROM sessions WHERE scope = 'admin'"), db.prepare("UPDATE signin_requests SET delivered = 1, expires_at = 0 WHERE scope = 'admin' AND delivered = 0")])) as ({ meta?: { changes?: number } } | undefined)[];
90
+ return done[0]?.meta?.changes ?? 0;
91
+ };
92
+ }
93
+ return store;
94
+ }
@@ -0,0 +1,78 @@
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 two settings; everything else it does is a read.
3
+ //
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 })
6
+ // orders/{id} { player, lookId, usd, amount, status, createdAt, expiresAt, paidAt, payer, hash } `amount` is the raw amount as text
7
+ // owned/{player__lookId} { player, lookId, orderId, grantedAt }
8
+ //
9
+ // The summary reads the newest 5000 orders (the counts and the totals are over those) and every owned look: for a small game. A big game should keep running totals.
10
+
11
+ import { parseRate, summarize, type OrderRow } from "../summary.js";
12
+ import type { AdminStore, StoreSummary } from "../store.js";
13
+ import type { GamePatch } from "../switches.js";
14
+
15
+ export interface FirestoreDocLike {
16
+ get(): Promise<{ exists: boolean; data(): Record<string, unknown> | undefined }>;
17
+ }
18
+ export interface FirestoreQueryLike {
19
+ orderBy(field: string, direction?: "asc" | "desc"): FirestoreQueryLike;
20
+ limit(n: number): FirestoreQueryLike;
21
+ get(): Promise<{ docs: { id: string; data(): Record<string, unknown> }[] }>;
22
+ }
23
+ export interface FirestoreCollectionLike extends FirestoreQueryLike {
24
+ doc(id: string): FirestoreDocLike;
25
+ }
26
+ export interface FirestoreBatchLike {
27
+ set(doc: FirestoreDocLike, data: Record<string, unknown>, options?: { merge?: boolean }): unknown;
28
+ commit(): Promise<unknown>;
29
+ }
30
+ export interface FirestoreLike {
31
+ collection(name: string): FirestoreCollectionLike;
32
+ batch(): FirestoreBatchLike;
33
+ }
34
+
35
+ export const FIRESTORE_SCAN_LIMIT = 5000;
36
+ export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" } as const;
37
+
38
+ const text = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
39
+ const num = (v: unknown, fallback = 0): number => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
40
+ const orNull = (v: unknown): string | null => (typeof v === "string" ? v : null);
41
+
42
+ export function createFirestoreStore(db: FirestoreLike, collections: { meta?: string; orders?: string; owned?: string } = {}): AdminStore {
43
+ const meta = collections.meta ?? "meta";
44
+ const orders = collections.orders ?? "orders";
45
+ const owned = collections.owned ?? "owned";
46
+ const valueOf = async (key: string) => {
47
+ const snap = await db.collection(meta).doc(key).get();
48
+ return snap.exists ? snap.data() : undefined;
49
+ };
50
+ return {
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 };
54
+ },
55
+
56
+ async saveSettings(patch: GamePatch, now: number) {
57
+ const batch = db.batch();
58
+ let n = 0;
59
+ if (patch.character) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
60
+ if (patch.signin) (batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
61
+ if (n) await batch.commit();
62
+ },
63
+
64
+ async summary(now: number): Promise<StoreSummary> {
65
+ const [orderDocs, ownedDocs, rate] = await Promise.all([
66
+ db.collection(orders).orderBy("createdAt", "desc").limit(FIRESTORE_SCAN_LIMIT).get(),
67
+ db.collection(owned).get(),
68
+ valueOf("rate"),
69
+ ]);
70
+ const rows: OrderRow[] = orderDocs.docs.map((d) => {
71
+ const o = d.data();
72
+ return { id: d.id, player: text(o.player), lookId: text(o.lookId), usd: text(o.usd, "0"), amountRaw: text(o.amount, "0"), status: text(o.status, "open"), createdAt: num(o.createdAt), expiresAt: num(o.expiresAt), paidAt: typeof o.paidAt === "number" ? o.paidAt : null, payer: orNull(o.payer), hash: orNull(o.hash) };
73
+ });
74
+ const list = ownedDocs.docs.map((d) => ({ player: text(d.data().player), lookId: text(d.data().lookId), at: num(d.data().grantedAt) }));
75
+ return summarize(rows, list, parseRate(rate?.value, rate?.at), now);
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,14 @@
1
+ // Nano amounts for the summary. 1 XNO is 10^30 raw, more than a JavaScript number holds exactly, so amounts are decimal strings and the sums are BigInt.
2
+
3
+ export const RAW_PER_XNO = 10n ** 30n;
4
+
5
+ /** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
6
+ export function xnoText(raw: string): string {
7
+ const n = BigInt(raw);
8
+ const whole = n / RAW_PER_XNO;
9
+ const frac = (n % RAW_PER_XNO).toString().padStart(30, "0").replace(/0+$/, "");
10
+ return frac ? `${whole}.${frac}` : `${whole}`;
11
+ }
12
+
13
+ /** A raw amount that can be added: digits only, at most 39 of them. Anything else is not counted (a bad row must not break the summary). */
14
+ export const isRawText = (s: unknown): s is string => typeof s === "string" && /^[0-9]{1,39}$/.test(s);