@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,40 @@
|
|
|
1
|
+
import type { AdminStore } from "../store.js";
|
|
2
|
+
export interface FirestoreDocLike {
|
|
3
|
+
get(): Promise<{
|
|
4
|
+
exists: boolean;
|
|
5
|
+
data(): Record<string, unknown> | undefined;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
export interface FirestoreQueryLike {
|
|
9
|
+
orderBy(field: string, direction?: "asc" | "desc"): FirestoreQueryLike;
|
|
10
|
+
limit(n: number): FirestoreQueryLike;
|
|
11
|
+
get(): Promise<{
|
|
12
|
+
docs: {
|
|
13
|
+
id: string;
|
|
14
|
+
data(): Record<string, unknown>;
|
|
15
|
+
}[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface FirestoreCollectionLike extends FirestoreQueryLike {
|
|
19
|
+
doc(id: string): FirestoreDocLike;
|
|
20
|
+
}
|
|
21
|
+
export interface FirestoreBatchLike {
|
|
22
|
+
set(doc: FirestoreDocLike, data: Record<string, unknown>, options?: {
|
|
23
|
+
merge?: boolean;
|
|
24
|
+
}): unknown;
|
|
25
|
+
commit(): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface FirestoreLike {
|
|
28
|
+
collection(name: string): FirestoreCollectionLike;
|
|
29
|
+
batch(): FirestoreBatchLike;
|
|
30
|
+
}
|
|
31
|
+
export declare const FIRESTORE_SCAN_LIMIT = 5000;
|
|
32
|
+
export declare const FIRESTORE_KEYS: {
|
|
33
|
+
readonly character: "setting:character";
|
|
34
|
+
readonly signin: "setting:signin";
|
|
35
|
+
};
|
|
36
|
+
export declare function createFirestoreStore(db: FirestoreLike, collections?: {
|
|
37
|
+
meta?: string;
|
|
38
|
+
orders?: string;
|
|
39
|
+
owned?: string;
|
|
40
|
+
}): AdminStore;
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { parseRate, summarize } from "../summary.js";
|
|
11
|
+
export const FIRESTORE_SCAN_LIMIT = 5000;
|
|
12
|
+
export const FIRESTORE_KEYS = { character: "setting:character", signin: "setting:signin" };
|
|
13
|
+
const text = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
14
|
+
const num = (v, fallback = 0) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
15
|
+
const orNull = (v) => (typeof v === "string" ? v : null);
|
|
16
|
+
export function createFirestoreStore(db, collections = {}) {
|
|
17
|
+
const meta = collections.meta ?? "meta";
|
|
18
|
+
const orders = collections.orders ?? "orders";
|
|
19
|
+
const owned = collections.owned ?? "owned";
|
|
20
|
+
const valueOf = async (key) => {
|
|
21
|
+
const snap = await db.collection(meta).doc(key).get();
|
|
22
|
+
return snap.exists ? snap.data() : undefined;
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
async readSettings() {
|
|
26
|
+
const [character, signin] = await Promise.all([valueOf(FIRESTORE_KEYS.character), valueOf(FIRESTORE_KEYS.signin)]);
|
|
27
|
+
return { character: character?.value, signin: signin?.value };
|
|
28
|
+
},
|
|
29
|
+
async saveSettings(patch, now) {
|
|
30
|
+
const batch = db.batch();
|
|
31
|
+
let n = 0;
|
|
32
|
+
if (patch.character)
|
|
33
|
+
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.character), { value: patch.character, at: now }), n++);
|
|
34
|
+
if (patch.signin)
|
|
35
|
+
(batch.set(db.collection(meta).doc(FIRESTORE_KEYS.signin), { value: patch.signin, at: now }), n++);
|
|
36
|
+
if (n)
|
|
37
|
+
await batch.commit();
|
|
38
|
+
},
|
|
39
|
+
async summary(now) {
|
|
40
|
+
const [orderDocs, ownedDocs, rate] = await Promise.all([
|
|
41
|
+
db.collection(orders).orderBy("createdAt", "desc").limit(FIRESTORE_SCAN_LIMIT).get(),
|
|
42
|
+
db.collection(owned).get(),
|
|
43
|
+
valueOf("rate"),
|
|
44
|
+
]);
|
|
45
|
+
const rows = orderDocs.docs.map((d) => {
|
|
46
|
+
const o = d.data();
|
|
47
|
+
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) };
|
|
48
|
+
});
|
|
49
|
+
const list = ownedDocs.docs.map((d) => ({ player: text(d.data().player), lookId: text(d.data().lookId), at: num(d.data().grantedAt) }));
|
|
50
|
+
return summarize(rows, list, parseRate(rate?.value, rate?.at), now);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const RAW_PER_XNO: bigint;
|
|
2
|
+
/** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
|
|
3
|
+
export declare function xnoText(raw: string): string;
|
|
4
|
+
/** 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). */
|
|
5
|
+
export declare const isRawText: (s: unknown) => s is string;
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
export const RAW_PER_XNO = 10n ** 30n;
|
|
3
|
+
/** A raw amount as XNO with every significant digit: "0.2512". Throws on text that is not a whole number. */
|
|
4
|
+
export function xnoText(raw) {
|
|
5
|
+
const n = BigInt(raw);
|
|
6
|
+
const whole = n / RAW_PER_XNO;
|
|
7
|
+
const frac = (n % RAW_PER_XNO).toString().padStart(30, "0").replace(/0+$/, "");
|
|
8
|
+
return frac ? `${whole}.${frac}` : `${whole}`;
|
|
9
|
+
}
|
|
10
|
+
/** 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). */
|
|
11
|
+
export const isRawText = (s) => typeof s === "string" && /^[0-9]{1,39}$/.test(s);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { AdminRoute } from "./handler.js";
|
|
2
|
+
import type { AdminStore } from "./store.js";
|
|
3
|
+
import { type OrderRow } from "./summary.js";
|
|
4
|
+
/**
|
|
5
|
+
* The kinds of visitor the game must be able to make:
|
|
6
|
+
* - signed_out no cookie at all
|
|
7
|
+
* - game_login a valid login made for the game's PLAYERS (the owner's own address, even), in the game's own cookie: it is not an admin login
|
|
8
|
+
* - game_token_as_admin the token of that same player login, put in the ADMIN cookie. The game must keep the two scopes apart in its store, not only in the cookie names
|
|
9
|
+
* - nanoterra_login an admin-scope login whose `via` is "nanoterra" (a sign-in from Nanoterra), for the owner's address
|
|
10
|
+
* - unknown_via_login an admin-scope login of the owner's whose `via` is a word the game does not know (like "ticket", or an empty one): it must fail closed
|
|
11
|
+
* - other_wallet a wallet login for an address that is not the owner's
|
|
12
|
+
* - no_address a wallet login while the game has NO owner address set (the game runs with none)
|
|
13
|
+
* - expired_login a wallet login of the owner's, made LESS than a day ago, that the game's own store has already expired (its own life is shorter than a day, or it ended).
|
|
14
|
+
* The guard's age check cannot refuse it, so only the game's store does: a game whose session code ignores its own expiry fails here
|
|
15
|
+
* - signed_out_login a wallet login of the owner's, made just now, that the game's own sign-out route has ended. A game that still reads it fails here
|
|
16
|
+
* - old_valid_row a wallet login of the owner's made more than one day ago whose stored row is STILL valid, so only the guard's own age check can refuse it. The suite sends it
|
|
17
|
+
* TWICE (the second time with `again: true`, the same login): a game that renews the creation time on every read fails here
|
|
18
|
+
* - owner a wallet login of the owner's, made just now
|
|
19
|
+
*/
|
|
20
|
+
export type Who = "signed_out" | "game_login" | "game_token_as_admin" | "nanoterra_login" | "unknown_via_login" | "other_wallet" | "no_address" | "expired_login" | "signed_out_login" | "old_valid_row" | "owner";
|
|
21
|
+
export type ConformanceRequest = {
|
|
22
|
+
method: string;
|
|
23
|
+
path: string;
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
body?: string;
|
|
26
|
+
/** True when the suite wants the SAME login as the last request of this kind (do not make a new one): a host must support it, or a session that renews itself on each read is not caught. */
|
|
27
|
+
again?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Set when the suite wants the login token of this kind sent in a place that is NOT the game's cookie, with NO cookie: "bearer" (`Authorization: Bearer <token>`), "query" (the token in the
|
|
30
|
+
* query as `token`, `t` and `access_token`) or "header" (`X-Auth-Token: <token>`). A host must support it, or a game that also reads a player's token from one of these places is not caught.
|
|
31
|
+
*/
|
|
32
|
+
tokenAt?: "bearer" | "query" | "header";
|
|
33
|
+
};
|
|
34
|
+
export type ConformanceHost = {
|
|
35
|
+
/** The handler's `routes`. */
|
|
36
|
+
routes: readonly AdminRoute[];
|
|
37
|
+
/** The handler's `basePath`. */
|
|
38
|
+
basePath: string;
|
|
39
|
+
/** The game's own origin, like "https://game.example": the suite sends it as the Origin of a good POST. */
|
|
40
|
+
origin: string;
|
|
41
|
+
/**
|
|
42
|
+
* Other routes that the game's OWN router serves under the base path (an old route, a debug route, a route kept for an old page): `{ method, path }`. The suite sends each
|
|
43
|
+
* as every kind of visitor who is not the owner and requires a refusal: a route that the game's router answers BEFORE the handler, with no guard, must show up here.
|
|
44
|
+
* List every one you know of; the suite cannot find the ones your router hides from it.
|
|
45
|
+
*/
|
|
46
|
+
otherRoutes?: readonly {
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
}[];
|
|
50
|
+
request(who: Who, req: ConformanceRequest): Promise<Response>;
|
|
51
|
+
/** Optional but recommended. Ends the login of the last request of this kind with the game's OWN sign-out (the real route): the suite then uses the same login again and requires a refusal. */
|
|
52
|
+
endLogin?(who: Who): Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
export type ConformanceCase = {
|
|
55
|
+
name: string;
|
|
56
|
+
run: () => Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
export declare function adminConformance(host: ConformanceHost): ConformanceCase[];
|
|
59
|
+
/** What a test gives to run the store contract: the store, and a way to put rows in the game's database the way the game's own code does (the store has no write for them). */
|
|
60
|
+
export type StoreHarness = {
|
|
61
|
+
store: AdminStore;
|
|
62
|
+
seed(data: {
|
|
63
|
+
orders?: OrderRow[];
|
|
64
|
+
owned?: {
|
|
65
|
+
player: string;
|
|
66
|
+
lookId: string;
|
|
67
|
+
at: number;
|
|
68
|
+
}[];
|
|
69
|
+
rate?: {
|
|
70
|
+
value: string;
|
|
71
|
+
at: number;
|
|
72
|
+
};
|
|
73
|
+
}): Promise<void>;
|
|
74
|
+
/** 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 two settings. Same rows, same text. */
|
|
75
|
+
snapshot(): Promise<string>;
|
|
76
|
+
/** Only for a store with `recentLogins` and `signOutEverywhere`: put admin sign-ins in the game's database (the store only reads them). */
|
|
77
|
+
seedLogins?(rows: {
|
|
78
|
+
at: number;
|
|
79
|
+
country: string | null;
|
|
80
|
+
browser: string;
|
|
81
|
+
}[]): Promise<void>;
|
|
82
|
+
/** Put this many admin sessions and this many player sessions in the game's database. */
|
|
83
|
+
seedSessions?(n: {
|
|
84
|
+
admin: number;
|
|
85
|
+
play: number;
|
|
86
|
+
}): Promise<void>;
|
|
87
|
+
/** How many admin sessions and how many player sessions the game's database holds now. */
|
|
88
|
+
sessionCounts?(): Promise<{
|
|
89
|
+
admin: number;
|
|
90
|
+
play: number;
|
|
91
|
+
}>;
|
|
92
|
+
/** Put this many admin sign-in requests and this many game sign-in requests that were started and NOT delivered in the game's database. */
|
|
93
|
+
seedRequests?(n: {
|
|
94
|
+
admin: number;
|
|
95
|
+
game: number;
|
|
96
|
+
}): Promise<void>;
|
|
97
|
+
/** How many admin and game sign-in requests are still not delivered. */
|
|
98
|
+
pendingRequests?(): Promise<{
|
|
99
|
+
admin: number;
|
|
100
|
+
game: number;
|
|
101
|
+
}>;
|
|
102
|
+
};
|
|
103
|
+
export declare function adminStoreConformance(make: () => Promise<StoreHarness>): ConformanceCase[];
|
|
104
|
+
export type SigninScope = "game" | "admin";
|
|
105
|
+
export type SigninHost = {
|
|
106
|
+
/** Starts a sign-in request for this scope in a NEW browser, and returns a handle (the host keeps that browser's credentials in it). */
|
|
107
|
+
start(scope: SigninScope): Promise<unknown>;
|
|
108
|
+
/** The owner's wallet signs the request (what the wallet app does). It MUST throw when the answer was refused: a signature that did not land is a fault of the test, never a pass. */
|
|
109
|
+
sign(request: unknown): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* The browser asks to finish the request AS `as` (the scope the check is made for), and says which logins it ended with.
|
|
112
|
+
* - "own": the browser that started it, with its own credentials;
|
|
113
|
+
* - "other": a different browser with no credentials of this request;
|
|
114
|
+
* - "retyped": the browser that started it, presenting its credentials as the OTHER scope would read them (the cookie copied to the other scope's name; a value that the other
|
|
115
|
+
* scope's hashing would match: the game's best attempt at a forged credential). It differs from "own" only when `as` is not the scope the request started for.
|
|
116
|
+
*/
|
|
117
|
+
finish(request: unknown, as: SigninScope, credentials: "own" | "other" | "retyped"): Promise<{
|
|
118
|
+
admin: boolean;
|
|
119
|
+
game: boolean;
|
|
120
|
+
}>;
|
|
121
|
+
/** Optional, but pass `{ signOutEverywhere: true }` to the contract when your admin has "Sign out everywhere". Signs out everywhere (from a fresh admin login of the owner). */
|
|
122
|
+
signOutEverywhere?(): Promise<void>;
|
|
123
|
+
/**
|
|
124
|
+
* Optional, recommended (pass `{ advance: true }` to the contract when the host has it: a host that then has none fails). Moves the game's clock forward by `ms` for every later call. The contract then repeats the cross-scope asks after the admin's window has ended, inside the
|
|
125
|
+
* game's hour of delivery: a scope check that only works while a request is fresh fails here. The contract runs this case last.
|
|
126
|
+
*/
|
|
127
|
+
advance?(ms: number): Promise<void>;
|
|
128
|
+
};
|
|
129
|
+
export declare function adminSigninConformance(host: SigninHost, options?: {
|
|
130
|
+
signOutEverywhere?: boolean;
|
|
131
|
+
advance?: boolean;
|
|
132
|
+
}): ConformanceCase[];
|