@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,121 @@
1
+ import type { GamePatch } from "./switches.js";
2
+ /** Money as the admin page shows it: a count, the XNO text and the US dollars as text. */
3
+ export type Money = {
4
+ count: number;
5
+ xno: string;
6
+ usd: string;
7
+ };
8
+ export type OrderStatus = "open" | "paid" | "expired" | "paid_late";
9
+ export type AdminOrder = {
10
+ id: string;
11
+ /** The player's Nano address. */
12
+ player: string;
13
+ lookId: string;
14
+ /** What the player reads for the look (the handler fills it from `lookName`). */
15
+ look: string;
16
+ usd: string;
17
+ xno: string;
18
+ status: OrderStatus | string;
19
+ createdAt: number;
20
+ expiresAt: number;
21
+ paidAt: number | null;
22
+ payer: string | null;
23
+ hash: string | null;
24
+ };
25
+ export type AdminPlayer = {
26
+ player: string;
27
+ looks: {
28
+ id: string;
29
+ name: string;
30
+ at: number;
31
+ }[];
32
+ };
33
+ /** The numbers of the orders and the looks that players own. */
34
+ export type StoreSummary = {
35
+ counts: {
36
+ open: number;
37
+ paid: number;
38
+ expired: number;
39
+ paid_late: number;
40
+ };
41
+ totals: {
42
+ paid: Money;
43
+ paidLate: Money;
44
+ };
45
+ /** Newest first, at most 500. `look` is filled by the handler. */
46
+ orders: Omit<AdminOrder, "look">[];
47
+ /** Players and the looks that a paid order gave them. `name` is filled by the handler. */
48
+ players: {
49
+ player: string;
50
+ looks: {
51
+ id: string;
52
+ at: number;
53
+ }[];
54
+ }[];
55
+ /** The price of XNO in US dollars that the game uses, and where it came from. */
56
+ rate: {
57
+ usd: number;
58
+ sources: string[];
59
+ at: number;
60
+ } | null;
61
+ };
62
+ /**
63
+ * One admin sign-in, as the panel lists it: when, the rough country (two letters, or null when the game did not know it) and a short browser name ("Chrome on macOS").
64
+ * Nothing else: no address, no IP, no browser text. The game writes these when it makes an admin login; the package only reads them.
65
+ */
66
+ export type AdminLogin = {
67
+ at: number;
68
+ country: string | null;
69
+ browser: string;
70
+ };
71
+ /** How many sign-ins the panel lists. */
72
+ export declare const ADMIN_LOGINS_SHOWN = 10;
73
+ /**
74
+ * The game's database, as the handler needs it. An adapter writes ONLY what these calls say: the two settings and, when the game turns it on, the deletion of the admin sessions
75
+ * (`signOutEverywhere`). Nothing else.
76
+ * (There is no call that writes a look, a price or an order: the Shelf gives text to copy, and a change of looks is a change of the repository.)
77
+ */
78
+ export interface AdminStore {
79
+ /** The settings that /admin has saved. A value that is not a known word is ignored by the handler, so an adapter returns what it holds. */
80
+ readSettings(): Promise<{
81
+ character?: unknown;
82
+ signin?: unknown;
83
+ }>;
84
+ /** Saves a checked patch: all of it or none of it. */
85
+ saveSettings(patch: GamePatch, now: number): Promise<void>;
86
+ summary(now: number): Promise<StoreSummary>;
87
+ /**
88
+ * Optional. The last sign-ins to the admin, newest first, at most `limit`, with only the three things of `AdminLogin`. With it the summary lists them (the "Sign-ins" card).
89
+ * An adapter that has it must never return more than those three things for a sign-in.
90
+ */
91
+ recentLogins?(limit: number): Promise<AdminLogin[]>;
92
+ /**
93
+ * Optional. Deletes EVERY admin session (the admin login of the owner, in every browser, this one too) and no other session; returns how many it deleted. With it the handler has
94
+ * the route `POST {base}/signout-all` and the panel has "Sign out everywhere". An adapter must delete only sessions of the ADMIN scope.
95
+ */
96
+ signOutEverywhere?(): Promise<number>;
97
+ }
98
+ /** What the game says about itself on the Status card: only the game knows its address, its test mode and what is still off. */
99
+ export type AdminStatus = {
100
+ address: string | null;
101
+ testMode: boolean;
102
+ /** Whether Turnstile (the bot check) is on. */
103
+ turnstile?: boolean;
104
+ /** The names of the Nano nodes that check payments. */
105
+ nodes?: string[];
106
+ /** "owner/repo" on GitHub, for the link to looks.json. */
107
+ repo?: string | null;
108
+ /** Plain sentences about what is still off. */
109
+ warnings: string[];
110
+ };
111
+ /** The whole of GET summary. */
112
+ export type AdminSummary = AdminStatus & Omit<StoreSummary, "orders" | "players"> & {
113
+ orders: AdminOrder[];
114
+ players: AdminPlayer[];
115
+ /** Whether the game gave a payment check (POST check). The panel shows "Check payments now" only then. */
116
+ canCheck: boolean;
117
+ /** The last admin sign-ins (newest first), when the store can list them. */
118
+ logins?: AdminLogin[];
119
+ /** Whether the route "sign out everywhere" exists (the store can do it). */
120
+ canSignOutEverywhere?: boolean;
121
+ };
@@ -0,0 +1,3 @@
1
+ // What a game gives the admin handler: a store (an adapter for its database) and a status. The package has no database of its own.
2
+ /** How many sign-ins the panel lists. */
3
+ export const ADMIN_LOGINS_SHOWN = 10;
@@ -0,0 +1,39 @@
1
+ import type { AdminOrder, Money, StoreSummary } from "./store.js";
2
+ /** An order as an adapter reads it: the amount is the raw amount (digits), the times are ms. */
3
+ export type OrderRow = {
4
+ id: string;
5
+ player: string;
6
+ lookId: string;
7
+ usd: string;
8
+ amountRaw: string;
9
+ status: string;
10
+ createdAt: number;
11
+ expiresAt: number;
12
+ paidAt: number | null;
13
+ payer: string | null;
14
+ hash: string | null;
15
+ };
16
+ export declare const ORDERS_SHOWN = 500;
17
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
18
+ export declare function moneyOf(rows: readonly {
19
+ amountRaw: string;
20
+ usd: string;
21
+ }[]): Money;
22
+ /** Where an order counts: an open order past its expiry counts as expired. */
23
+ export declare const statusOf: (status: string, expiresAt: number, now: number) => string;
24
+ /** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
25
+ export declare function shapeOrders(rows: readonly OrderRow[], now: number): Omit<AdminOrder, "look">[];
26
+ /** The looks that players own, by player (players and their looks in a steady order). */
27
+ export declare function shapePlayers(owned: readonly {
28
+ player: string;
29
+ lookId: string;
30
+ at: number;
31
+ }[]): StoreSummary["players"];
32
+ /** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
33
+ export declare function summarize(rows: readonly OrderRow[], owned: readonly {
34
+ player: string;
35
+ lookId: string;
36
+ at: number;
37
+ }[], rate: StoreSummary["rate"], now: number): StoreSummary;
38
+ /** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
39
+ export declare function parseRate(value: unknown, at: unknown): StoreSummary["rate"];
@@ -0,0 +1,85 @@
1
+ // The numbers of the summary from the rows of the orders, the same for every adapter: the counts by status, the totals and the shape of an order.
2
+ // An adapter reads its rows; this file turns them into a `StoreSummary`.
3
+ import { isRawText, xnoText } from "./amount.js";
4
+ export const ORDERS_SHOWN = 500;
5
+ /** Cents from a dollar text like "0.25". Anything that is not plain dollars (a sign, an exponent, hex) counts as 0: a bad row must not change a total. */
6
+ const toCents = (usd) => {
7
+ if (typeof usd !== "string" || !/^\d{1,9}(\.\d{1,8})?$/.test(usd))
8
+ return 0;
9
+ return Math.round(Number(usd) * 100);
10
+ };
11
+ /** Money from rows that are all of one status: the count, the XNO text of the raw sum and the dollars (summed in cents, so 0.1 + 0.2 is 0.30). */
12
+ export function moneyOf(rows) {
13
+ let raw = 0n;
14
+ let cents = 0;
15
+ for (const r of rows) {
16
+ if (isRawText(r.amountRaw))
17
+ raw += BigInt(r.amountRaw);
18
+ cents += toCents(r.usd);
19
+ }
20
+ return { count: rows.length, xno: xnoText(raw.toString()), usd: (cents / 100).toFixed(2) };
21
+ }
22
+ /** Where an order counts: an open order past its expiry counts as expired. */
23
+ export const statusOf = (status, expiresAt, now) => (status === "open" && expiresAt < now ? "expired" : status);
24
+ /** The orders as the page lists them: newest first, at most ORDERS_SHOWN, an open one past its expiry read as "expired". */
25
+ export function shapeOrders(rows, now) {
26
+ return [...rows]
27
+ .sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : 1))
28
+ .slice(0, ORDERS_SHOWN)
29
+ .map((o) => ({
30
+ id: o.id,
31
+ player: o.player,
32
+ lookId: o.lookId,
33
+ usd: o.usd,
34
+ xno: isRawText(o.amountRaw) ? xnoText(o.amountRaw) : "0",
35
+ status: statusOf(o.status, o.expiresAt, now),
36
+ createdAt: o.createdAt,
37
+ expiresAt: o.expiresAt,
38
+ paidAt: o.paidAt,
39
+ payer: o.payer,
40
+ hash: o.hash,
41
+ }));
42
+ }
43
+ /** The looks that players own, by player (players and their looks in a steady order). */
44
+ export function shapePlayers(owned) {
45
+ const players = new Map();
46
+ for (const o of [...owned].sort((a, b) => (a.player < b.player ? -1 : a.player > b.player ? 1 : a.at - b.at))) {
47
+ const p = players.get(o.player) ?? { player: o.player, looks: [] };
48
+ p.looks.push({ id: o.lookId, at: o.at });
49
+ players.set(o.player, p);
50
+ }
51
+ return [...players.values()];
52
+ }
53
+ /** The whole summary from ALL the rows (for a store that reads the rows itself: Firestore, memory). */
54
+ export function summarize(rows, owned, rate, now) {
55
+ const counts = { open: 0, paid: 0, expired: 0, paid_late: 0 };
56
+ for (const o of rows) {
57
+ const key = statusOf(o.status, o.expiresAt, now);
58
+ if (Object.hasOwn(counts, key))
59
+ counts[key]++;
60
+ }
61
+ return {
62
+ counts,
63
+ totals: { paid: moneyOf(rows.filter((o) => o.status === "paid")), paidLate: moneyOf(rows.filter((o) => o.status === "paid_late")) },
64
+ orders: shapeOrders(rows, now),
65
+ players: shapePlayers(owned),
66
+ rate,
67
+ };
68
+ }
69
+ /** The `rate` row of the meta table: JSON { usd, sources } kept with the time. Anything that is not that is no rate. */
70
+ export function parseRate(value, at) {
71
+ if (typeof value !== "string" || typeof at !== "number")
72
+ return null;
73
+ try {
74
+ const v = JSON.parse(value);
75
+ if (!v || typeof v !== "object")
76
+ return null;
77
+ const { usd, sources } = v;
78
+ if (typeof usd !== "number" || !Number.isFinite(usd) || !Array.isArray(sources) || !sources.every((s) => typeof s === "string"))
79
+ return null;
80
+ return { usd, sources: sources, at };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
@@ -0,0 +1,58 @@
1
+ /** Which character the player is: Nanoterra's island character (the one the player has there) or the game's own. */
2
+ export type Character = "nanoterra" | "own";
3
+ /** How the game signs a Nanoterra player in: the code exchange, a signed ticket (a game with no server), or not at all. */
4
+ export type Signin = "exchange" | "ticket" | "none";
5
+ export type GameSettings = {
6
+ character: Character;
7
+ signin: Signin;
8
+ };
9
+ /** What a press can set: sign-in is on ("exchange": a game with a server) or off ("none"). */
10
+ export type GamePatch = {
11
+ character?: Character;
12
+ signin?: "exchange" | "none";
13
+ };
14
+ /** The settings as they are now, and which ones /admin has set (the others still have the game's default). */
15
+ export type GameState = GameSettings & {
16
+ fromAdmin: {
17
+ character: boolean;
18
+ signin: boolean;
19
+ };
20
+ };
21
+ export declare const isCharacter: (v: unknown) => v is Character;
22
+ export declare const isSigninChoice: (v: unknown) => v is "exchange" | "none";
23
+ export declare const isSignin: (v: unknown) => v is Signin;
24
+ /** The sign-in switch is on for any way but "none". */
25
+ export declare const signinOn: (s: Signin) => boolean;
26
+ /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
27
+ export declare function parsePatch(body: unknown): {
28
+ ok: true;
29
+ patch: GamePatch;
30
+ } | {
31
+ ok: false;
32
+ error: string;
33
+ };
34
+ /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
35
+ export declare function mergeSettings(stored: {
36
+ character?: unknown;
37
+ signin?: unknown;
38
+ }, defaults: GameSettings): GameState;
39
+ /** One switch of the card: its title, and one plain sentence for each state. */
40
+ export type SwitchId = "character" | "signin";
41
+ export declare const SWITCHES: Record<SwitchId, {
42
+ title: string;
43
+ on: string;
44
+ off: string;
45
+ }>;
46
+ /**
47
+ * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
48
+ * card says what a player gets: the island's first character, every player, a player who signed in from Nanoterra before included ("Off means Off"). That is the GAME's page
49
+ * to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
50
+ */
51
+ export declare const CHARACTER_NEEDS_SIGNIN = "This needs \u201CPlayers can sign in with Nanoterra\u201D to be On: only a player who signs in from Nanoterra brings a character.";
52
+ export declare const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before included.";
53
+ /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
54
+ export declare const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
55
+ export declare const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
56
+ /** The state of a switch from the settings, and the patch that a press sends (the other state). */
57
+ export declare const stateOf: (id: SwitchId, s: GameSettings) => boolean;
58
+ export declare const pressOf: (id: SwitchId, s: GameSettings) => GamePatch;
@@ -0,0 +1,61 @@
1
+ // The two switches of the "Game" card in /admin, and the words that say what each does. The handler checks a press with `parsePatch`; the panel
2
+ // draws the card from `SWITCHES`. A switch changes ONLY these two settings; never a look, a price or an order.
3
+ export const isCharacter = (v) => v === "nanoterra" || v === "own";
4
+ export const isSigninChoice = (v) => v === "exchange" || v === "none";
5
+ export const isSignin = (v) => v === "exchange" || v === "ticket" || v === "none";
6
+ /** The sign-in switch is on for any way but "none". */
7
+ export const signinOn = (s) => s !== "none";
8
+ /** A request body for a press: only the two known words, at least one. Anything else is refused, with the reason in words. */
9
+ export function parsePatch(body) {
10
+ if (!body || typeof body !== "object" || Array.isArray(body))
11
+ return { ok: false, error: 'Send an object with "character" or "signin".' };
12
+ const patch = {};
13
+ for (const [key, value] of Object.entries(body)) {
14
+ if (key === "character" && isCharacter(value))
15
+ patch.character = value;
16
+ else if (key === "signin" && isSigninChoice(value))
17
+ patch.signin = value;
18
+ else if (key === "character")
19
+ return { ok: false, error: '"character" must be "nanoterra" or "own".' };
20
+ else if (key === "signin")
21
+ return { ok: false, error: '"signin" must be "exchange" or "none".' };
22
+ else
23
+ return { ok: false, error: `"${key}" is not a game switch.` };
24
+ }
25
+ if (!Object.keys(patch).length)
26
+ return { ok: false, error: 'Send "character" or "signin".' };
27
+ return { ok: true, patch };
28
+ }
29
+ /** The settings from what the store kept and the game's defaults. A stored value that is not one of the known words is ignored. */
30
+ export function mergeSettings(stored, defaults) {
31
+ return {
32
+ character: isCharacter(stored.character) ? stored.character : defaults.character,
33
+ signin: isSigninChoice(stored.signin) ? stored.signin : defaults.signin,
34
+ fromAdmin: { character: isCharacter(stored.character), signin: isSigninChoice(stored.signin) },
35
+ };
36
+ }
37
+ export const SWITCHES = {
38
+ character: {
39
+ title: "Players use their Nanoterra character",
40
+ on: "On: a player who signs in from Nanoterra plays as the character they picked there. Anyone else plays as the island's first character.",
41
+ off: "Off: every player plays as this game's own character.",
42
+ },
43
+ signin: {
44
+ title: "Players can sign in with Nanoterra",
45
+ on: "On: this game's listing file says players can sign in with Nanoterra, and the game accepts that sign-in.",
46
+ off: "Off: the listing file says no sign-in, and the game refuses it: a link made before still signs nobody in.",
47
+ },
48
+ };
49
+ /**
50
+ * The character comes through the sign-in: only a player who signs in from Nanoterra brings one. So the character switch is shown with its dependency. With sign-in Off the
51
+ * card says what a player gets: the island's first character, every player, a player who signed in from Nanoterra before included ("Off means Off"). That is the GAME's page
52
+ * to do (the starter's src/client/ui/player.ts draws no Nanoterra character while sign-in is Off); the card only says it.
53
+ */
54
+ export const CHARACTER_NEEDS_SIGNIN = "This needs “Players can sign in with Nanoterra” to be On: only a player who signs in from Nanoterra brings a character.";
55
+ export const CHARACTER_SIGNIN_OFF = "Sign-in from Nanoterra is Off, so every player plays as the island's first character, a player who signed in from Nanoterra before included.";
56
+ /** What to tell the creator under the sign-in switch about Nanoterra's review. A game cannot see that review: it says so. */
57
+ export const REVIEW_NOTE = "Your choice is saved here at once. The sign-in word is part of your listing file, and Nanoterra's review decides what Nanoterra shows for a game that is listed. This page cannot see that review. Check it on Nanoterra's launch page (step 6).";
58
+ export const REVIEW_LINK = "https://nano133.com/build/nanoterra/launch";
59
+ /** The state of a switch from the settings, and the patch that a press sends (the other state). */
60
+ export const stateOf = (id, s) => (id === "character" ? s.character === "nanoterra" : signinOn(s.signin));
61
+ export const pressOf = (id, s) => (id === "character" ? { character: s.character === "nanoterra" ? "own" : "nanoterra" } : { signin: signinOn(s.signin) ? "none" : "exchange" });
@@ -27,11 +27,13 @@ export const bee = {
27
27
  addFace(c);
28
28
  if (!c.headOnly) {
29
29
  c.addBody(G.cone8, mtx(0, 0.72, -0.5, -Math.PI / 2 + 0.25, 0, 0, 0.1, 0.32, 0.1), "stripe", "hips");
30
- // The wings: a bone at each shoulder blade; a long wing and a short one on it.
31
- for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]]) {
32
- c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
33
- c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
34
- c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
30
+ // The wings: a bone at each shoulder blade; a long wing and a short one on it. A back item covers them (`hideWings`): no wing, no bone.
31
+ if (!c.hideWings) {
32
+ for (const [s, name] of [[1, "wing_L"], [-1, "wing_R"]]) {
33
+ c.b.bone(name, "chest", [s * 0.12, 1.4, -0.3]);
34
+ c.add(SPH_S, mtx(s * 0.3, 1.78, -0.4, 0.2, 0, s * 1.15, 0.4, 0.04, 0.22), "wing", name);
35
+ c.add(SPH_S, mtx(s * 0.36, 1.52, -0.42, 0.15, 0, s * 0.8, 0.28, 0.035, 0.15), "wing", name);
36
+ }
35
37
  }
36
38
  }
37
39
  limbs(c, bee.limbs);
@@ -13,5 +13,12 @@ export type BaseSpec = {
13
13
  z?: number;
14
14
  s: readonly [number, number, number];
15
15
  };
16
+ /** The hand socket's place from the middle of the drawn right hand, when it is not that middle (sockets.ts). */
17
+ handAt?: readonly [number, number, number];
18
+ /** The back socket: its height (left out: the chest bone's, 1.12) and its depth (left out: the torso's radius there, behind). */
19
+ back?: {
20
+ y: number;
21
+ z?: number;
22
+ };
16
23
  build(c: Ctx): void;
17
24
  };
@@ -2,6 +2,8 @@ import * as THREE from "three";
2
2
  import { type Look } from "./look.js";
3
3
  import { type Motion } from "./pose.js";
4
4
  import { type CharColors, type LookRecord } from "./record.js";
5
+ import type { WornAdapter } from "./ctx.js";
6
+ import { type SocketName } from "./sockets.js";
5
7
  /** The character's height in metres at the record's normal size: from the soles to the top of the hair (the Classic base). Another base, hair or size is not this tall: measure it with a Box3. */
6
8
  export declare const HEIGHT = 2.29;
7
9
  /** The radius of the Classic's trunk in metres at the record's normal size: the collider's radius. The hands hang to 0.6 m from the middle. */
@@ -19,6 +21,8 @@ export type CharacterOptions = {
19
21
  quality?: Quality;
20
22
  /** Starts with the walk's swing and bob off, as the island does for a player who asks for less motion. */
21
23
  reducedMotion?: boolean;
24
+ /** Worn things (ctx.ts `WornAdapter`): shapes merged into the body, or a model on a socket. Change them with `setWorn`. */
25
+ worn?: readonly WornAdapter[];
22
26
  };
23
27
  export interface Character {
24
28
  /**
@@ -48,6 +52,17 @@ export interface Character {
48
52
  setLook(next: LookRecord | Look): void;
49
53
  /** Paints these colours over the record's, or none (`null`): the record's own come back. It builds no geometry. */
50
54
  setColors(colors: Partial<CharColors> | null): void;
55
+ /**
56
+ * An attach point: "head" (at the base's hat frame, scaled with the head), "hand" (in the right hand, on grip_R) or "back" (on the chest,
57
+ * behind). Add your own object to it and it follows the bone. It is the same object for the character's whole life: a new base, hair or
58
+ * clothes (a rebuild) moves it, with what hangs on it, to the new bones and to the new base's frame. An unknown name throws.
59
+ */
60
+ socket(name: SocketName): THREE.Object3D;
61
+ /**
62
+ * Changes the worn things. Shapes that merge (`add`) and the hair's fit build new geometry; a thing that only hangs a model on a socket
63
+ * builds none. The objects of the things that are no longer worn come off their socket (the game frees them: the character never does).
64
+ */
65
+ setWorn(worn: readonly WornAdapter[]): void;
51
66
  /**
52
67
  * Frees the geometries, materials and textures that the character made, and takes `object` out of its parent. Later calls to the other methods do
53
68
  * nothing.
@@ -6,10 +6,12 @@ import { assertLook, recordProblem } from "./look.js";
6
6
  import { buildWith, paintCharacter } from "./make.js";
7
7
  import { makePose, MAX_DT } from "./pose.js";
8
8
  import { clean, DEFAULT_RECORD, resolve, toLegacy, withLegacy } from "./record.js";
9
+ import { isSocketName, placeSocket, SOCKETS } from "./sockets.js";
9
10
  import { followCanvas, inkMaterial, makeRamp, toonMaterial } from "./toon.js";
10
11
  // createCharacter: the character as ONE object for a plain Three.js game. It builds the player's record (record.ts) with the kit (make.ts), binds it
11
12
  // to the island's skeleton (assemble.ts), gives it the toon material and the ink line (toon.ts) and plays the island's idle, walk, run, jump and wave
12
- // on it (pose.ts, solver.ts) from the numbers the game gives each frame. The body only: a worn item is the host's (ctx.ts `WornAdapter`).
13
+ // on it (pose.ts, solver.ts) from the numbers the game gives each frame. A worn item is the host's (ctx.ts `WornAdapter`): its shapes merge into the
14
+ // body, or its own model hangs on one of the three sockets (sockets.ts), which stay on the new bones after a rebuild.
13
15
  // The blob shadow is a part of the body (a disc on the skeleton's shadow bone): it lies on the ground under the character in a jump and shrinks.
14
16
  /** The character's height in metres at the record's normal size: from the soles to the top of the hair (the Classic base). Another base, hair or size is not this tall: measure it with a Box3. */
15
17
  export const HEIGHT = 2.29;
@@ -32,6 +34,26 @@ function checkColors(colors) {
32
34
  }
33
35
  return out;
34
36
  }
37
+ /** A worn thing that changes the body's geometry: its own shapes, or what it does to the hair, the badge, the tail or the wings. */
38
+ const merges = (w) => !!(w.add || w.fit || w.hidesBadge || w.hideTail || w.hideWings);
39
+ function checkWorn(worn) {
40
+ if (!Array.isArray(worn))
41
+ throw new TypeError("@nano133/nanoterra: worn must be a list of worn things");
42
+ worn.forEach((w, i) => {
43
+ const v = w;
44
+ if (typeof v !== "object" || v === null)
45
+ throw new TypeError(`@nano133/nanoterra: worn[${i}] is not an object`);
46
+ if (v.add !== undefined && typeof v.add !== "function")
47
+ throw new TypeError(`@nano133/nanoterra: worn[${i}].add must be a function`);
48
+ if (v.socket !== undefined && !isSocketName(v.socket))
49
+ throw new TypeError(`@nano133/nanoterra: worn[${i}].socket must be one of ${SOCKETS.join(", ")}, not ${JSON.stringify(v.socket)}`);
50
+ if ((v.object === undefined) !== (v.socket === undefined))
51
+ throw new TypeError(`@nano133/nanoterra: worn[${i}] needs both a socket and an object, or neither`);
52
+ if (v.object !== undefined && !v.object?.isObject3D)
53
+ throw new TypeError(`@nano133/nanoterra: worn[${i}].object must be a THREE.Object3D`);
54
+ });
55
+ return [...worn];
56
+ }
35
57
  const frozen = (rec) => Object.freeze({ ...clean(rec) });
36
58
  /** A record from a record or five colours; `from`: the record the five colours go into. Throws a TypeError that says what is wrong. */
37
59
  function recordOf(next, from) {
@@ -55,6 +77,13 @@ export function createCharacter(options = {}) {
55
77
  }
56
78
  let record = opts.record !== undefined ? recordOf(opts.record, null) : opts.look !== undefined ? recordOf(opts.look, null) : { ...DEFAULT_RECORD };
57
79
  let over = opts.colors === undefined ? null : checkColors(opts.colors);
80
+ let worn = checkWorn(opts.worn ?? []);
81
+ /** The three sockets: made once, moved to the new bones on every rebuild. */
82
+ const sockets = Object.fromEntries(SOCKETS.map((n) => {
83
+ const g = new THREE.Group();
84
+ g.name = `socket-${n}`;
85
+ return [n, g];
86
+ }));
58
87
  const ramp = makeRamp();
59
88
  const toon = toonMaterial(ramp);
60
89
  const ink = quality === "high" ? inkMaterial() : null;
@@ -67,7 +96,10 @@ export function createCharacter(options = {}) {
67
96
  const unit = () => (HEIGHTS[record.height] ?? 1) * (object.scale.y || 1);
68
97
  const colorsNow = () => ({ ...resolve(record).colors, ...over });
69
98
  function build() {
70
- const made = buildWith(BASE_SPECS[resolve(record).base], resolve(record));
99
+ const look = resolve(record);
100
+ const spec = BASE_SPECS[look.base];
101
+ const merged = worn.filter(merges);
102
+ const made = buildWith(spec, look, merged.length ? { worn: merged } : {});
71
103
  paintCharacter(made, colorsNow());
72
104
  const a = assemble(made, { toon, ink: ink ?? toon });
73
105
  a.mesh.name = "character-toon";
@@ -82,10 +114,19 @@ export function createCharacter(options = {}) {
82
114
  a.group.scale.setScalar(HEIGHTS[record.height] ?? 1);
83
115
  object.add(a.group);
84
116
  const pose = makePose(a.list, a.bones, unit);
117
+ for (const n of SOCKETS)
118
+ placeSocket(sockets[n], a.bones, spec, look, n);
85
119
  return { made, a, pose };
86
120
  }
87
121
  let rig = build();
88
122
  rig.pose.rest(reducedMotion);
123
+ /** Hangs each worn model on its socket (in the order of the list). */
124
+ const hang = () => {
125
+ for (const w of worn)
126
+ if (w.object && w.socket)
127
+ sockets[w.socket].add(w.object);
128
+ };
129
+ hang();
89
130
  /** Frees a rig's geometry and skeleton. A rig that is replaced also leaves the object; a character that is disposed keeps its (dead) meshes, as it always did. */
90
131
  const free = (r, replaced) => {
91
132
  if (replaced)
@@ -150,11 +191,37 @@ export function createCharacter(options = {}) {
150
191
  over = colors === null ? null : checkColors(colors);
151
192
  paintCharacter(rig.made, colorsNow());
152
193
  },
194
+ socket(name) {
195
+ if (!isSocketName(name))
196
+ throw new TypeError(`@nano133/nanoterra: there is no socket ${JSON.stringify(name)} (the sockets are ${SOCKETS.join(", ")})`);
197
+ return sockets[name];
198
+ },
199
+ setWorn(next) {
200
+ if (disposed)
201
+ return;
202
+ const to = checkWorn(next);
203
+ const rebuild = worn.some(merges) || to.some(merges);
204
+ for (const w of worn)
205
+ if (w.object && !to.some((x) => x.object === w.object))
206
+ w.object.removeFromParent();
207
+ worn = to;
208
+ if (rebuild) {
209
+ const was = rig;
210
+ rig = build();
211
+ Object.assign(rig.pose.state, was.pose.state);
212
+ rig.pose.rest(reducedMotion);
213
+ free(was, true);
214
+ }
215
+ hang();
216
+ },
153
217
  dispose() {
154
218
  if (disposed)
155
219
  return;
156
220
  disposed = true;
157
221
  object.removeFromParent();
222
+ // The sockets leave the dead bones; what hangs on them is the game's.
223
+ for (const n of SOCKETS)
224
+ sockets[n].removeFromParent();
158
225
  free(rig, false);
159
226
  toon.dispose();
160
227
  ink?.dispose();
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
  import type { Builder, Paint, Weights } from "./build.js";
3
3
  import type { Limbs } from "./limbs.js";
4
4
  import type { CharLook } from "./record.js";
5
+ import type { SocketName } from "./sockets.js";
5
6
  /** A head as an ellipsoid (its middle and its radii in the avatar's space, at rest). A face, a hair and a hat sit on it. `flat`: a flat screen face at this z. */
6
7
  export type HeadSpec = {
7
8
  c: readonly [number, number, number];
@@ -53,13 +54,20 @@ export type HatPart = {
53
54
  * island's adapter (the shop's hats, scarves, packs, tops, buddies and blades, each fitted to every base) builds these; a game may bring its own.
54
55
  * `add` puts the thing's shapes and bones into the build after the body, in order, through the same context as the body's (`c.add`, `c.addHead`,
55
56
  * `c.b.bone`). A head item also says what it does to the hair (`fit`); the first that does decides. `hidesBadge`: the chest badge would poke through it.
56
- * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail.
57
+ * `hideTail`: a back item (a cape, a pack, a plush) or a pinafore covers a base's tail. `hideWings`: a back item covers a base's wings (the Bee's).
58
+ *
59
+ * A thing with its own model (a .glb, say) names a `socket` ("head", "hand" or "back", sockets.ts) and gives its `object`: `createCharacter`
60
+ * hangs the object on that socket (its own draws: 1 or 2 more for each thing) and still reads `fit`, `hidesBadge`, `hideTail` and `hideWings`. The game
61
+ * owns the object: the character never frees it. `add` is then optional (a thing can have both: merged shapes and a model).
57
62
  */
58
63
  export type WornAdapter = {
59
64
  fit?: HairFit;
60
65
  hidesBadge?: boolean;
61
66
  hideTail?: boolean;
62
- add(c: Ctx): void;
67
+ hideWings?: boolean;
68
+ add?(c: Ctx): void;
69
+ socket?: SocketName;
70
+ object?: THREE.Object3D;
63
71
  };
64
72
  /** A worn thing, by its older name (the same type as `WornAdapter`). */
65
73
  export type WornPart = WornAdapter;
@@ -81,6 +89,8 @@ export type Ctx = {
81
89
  hidesBadge: boolean;
82
90
  /** A worn back item covers the base's tail (a Critter's). */
83
91
  hideTail: boolean;
92
+ /** A worn back item covers the base's wings (a Bee's). */
93
+ hideWings: boolean;
84
94
  /** The base's arms and legs (a worn cuff follows the arm). */
85
95
  limbs: Limbs;
86
96
  /** Only the head and what sits on it (the pictures of faces and hats). */
@@ -34,3 +34,5 @@ export * from "./edit.js";
34
34
  export * from "./look.js";
35
35
  export { createCharacter, HEIGHT, RADIUS, type Character, type CharacterOptions, type Quality } from "./create.js";
36
36
  export { WAVE_SECONDS, MAX_DT, type Motion } from "./pose.js";
37
+ export { createLookPreview, type LookPreview, type LookPreviewOptions, type PreviewBody } from "./preview.js";
38
+ export { SOCKETS, HAND_HOLD, socketFrame, placeSocket, isSocketName, type SocketName, type SocketFrame, type SocketLook } from "./sockets.js";
@@ -23,3 +23,5 @@ export * from "./edit.js";
23
23
  export * from "./look.js";
24
24
  export { createCharacter, HEIGHT, RADIUS } from "./create.js";
25
25
  export { WAVE_SECONDS, MAX_DT } from "./pose.js";
26
+ export { createLookPreview } from "./preview.js";
27
+ export { SOCKETS, HAND_HOLD, socketFrame, placeSocket, isSocketName } from "./sockets.js";
@@ -32,6 +32,7 @@ export function makeContext(spec, look, b, opts = {}) {
32
32
  fit: worn.find((w) => w.fit)?.fit ?? NO_FIT,
33
33
  hidesBadge: worn.some((w) => w.hidesBadge),
34
34
  hideTail: worn.some((w) => w.hideTail),
35
+ hideWings: worn.some((w) => w.hideWings),
35
36
  limbs: spec.limbs,
36
37
  headOnly,
37
38
  add,
@@ -55,7 +56,7 @@ export function buildWith(spec, look, opts = {}) {
55
56
  c.addBody(new THREE.CircleGeometry(0.62, 20), mtx(0, 0.02, 0, -Math.PI / 2), "shadow", "shadow", false);
56
57
  }
57
58
  for (const w of wornOf(opts))
58
- w.add(c);
59
+ w.add?.(c);
59
60
  return { ...b.finish(), toonTris: b.p.length / 9, inkTris: b.ip.length / 9, look };
60
61
  }
61
62
  /** Paints (or paints again) the character's colours: a colour change needs no new geometry. */