@fungi.computer/sprite 0.0.0-stage → 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ # Sprite (`@fungi.computer/sprite`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sprite presents a character document through a host-installed renderer, so a
4
+ game or app can show an animated avatar without writing its own rendering layer.
5
+ The host chooses local characters, renderer adapters, network origins, and asset
6
+ limits. Each mounted player owns its current source and presentation lifetime.
7
+
8
+ ## Install or set up
9
+
10
+ ```sh
11
+ npm install @fungi.computer/sprite
12
+ ```
13
+
14
+ The package ships ESM only and depends on Effect, Zod, and
15
+ [Cairn](../cairn/README.md) at runtime. React is an optional peer dependency,
16
+ needed only for the `./react` entrypoint. The core entrypoint,
17
+ `@fungi.computer/sprite`, exports the renderer-neutral player API.
18
+ `@fungi.computer/sprite/react` exports the React provider and component.
19
+ `@fungi.computer/sprite/video` exports the optional video renderer. `./avatar`
20
+ supplies avatar descriptors and catalog-aware validation. `./ink` and
21
+ `./mycelium` mount decorative drawings independently of the character player.
22
+ Zdog support ships as the separate [Sprite Zdog](../sprite-zdog/README.md)
23
+ package.
24
+
25
+ ## Quick start
26
+
27
+ This illustrative React example configures the host's asset policy and lazy
28
+ video renderer, then presents a character document:
29
+
30
+ ```tsx
31
+ import { createSprite } from "@fungi.computer/sprite";
32
+ import { Sprite, SpriteProvider } from "@fungi.computer/sprite/react";
33
+
34
+ const service = createSprite({
35
+ assets: {
36
+ baseURL: window.location.href,
37
+ allowedOrigins: [window.location.origin],
38
+ maxBytes: 2_000_000,
39
+ maxTotalBytes: 4_000_000,
40
+ maxRequests: 4,
41
+ },
42
+ renderers: {
43
+ video: () => import("@fungi.computer/sprite/video"),
44
+ },
45
+ });
46
+
47
+ function Character() {
48
+ return (
49
+ <SpriteProvider service={service}>
50
+ <Sprite
51
+ src="/characters/my-avatar.json"
52
+ activity="idle"
53
+ role="img"
54
+ aria-label="My avatar"
55
+ />
56
+ </SpriteProvider>
57
+ );
58
+ }
59
+ ```
60
+
61
+ The package tests exercise player updates, React lifetime, and asset admission.
62
+ See [`src/player.test.ts`](src/player.test.ts),
63
+ [`src/react.test.tsx`](src/react.test.tsx), and
64
+ [`src/assets.test.ts`](src/assets.test.ts).
65
+
66
+ ## Core concepts
67
+
68
+ A character document has `version: 1`, a name, a renderer name, optional
69
+ controls and poster, and renderer-specific `data`. The shared schema validates
70
+ that envelope. The selected adapter validates its own data. A source is either a
71
+ document URL or `{ local: "key" }` for a character installed by the host.
72
+
73
+ `createSprite` creates a stable host service. The host supplies local
74
+ characters, renderer loaders, and asset policy. A player selects one source and
75
+ receives activity, expression, appearance, and render-style updates without
76
+ replacing its host element. The React `Sprite` owns one player and disposes it
77
+ when it unmounts. The imperative player exposes `set`, `pause`, `subscribe`, and
78
+ asynchronous `dispose`.
79
+
80
+ `renderStyle` applies one of the built-in SVG filters: `plain`, `pencil`,
81
+ `manga`, `riso`, `halftone`, `blueprint`, `crt`, or `watercolor`. Each player
82
+ owns its filter instance. Animated styles pause when the player is paused, is
83
+ offscreen, the page is hidden, or reduced motion is requested.
84
+
85
+ Remote documents and nested assets pass through one reader. The host sets
86
+ allowed origins, the maximum bytes per response, total bytes per selection, and
87
+ request count. Local characters need no remote asset policy. Video uses the
88
+ configured `video` renderer and reads clip assets through that same path.
89
+
90
+ The React `Sprite` takes `src`, optional `activity`, `expression`, `appearance`
91
+ and `renderStyle`, plus ordinary attributes for its host `div`. Pass `paused` to
92
+ suspend it and `onState` to observe player states. A direct `service` prop
93
+ overrides `SpriteProvider`; one of them is required.
94
+
95
+ ### Saved avatars
96
+
97
+ `@fungi.computer/sprite/avatar` defines the v1 descriptor: `v`, `character`,
98
+ `appearance`, `expression` and `renderStyle`. `avatarEnvelopeSchema` checks only
99
+ that envelope. Use `createAvatarSchema(catalog)` or `parseAvatar(data, catalog)`
100
+ to also validate the selected character's controls and expressions. Sprite Zdog
101
+ supplies a validator bound to its built-in catalog.
102
+
103
+ ### Decorative drawings
104
+
105
+ `mountInkDrawing(host, { art, palette, ...options })` from `./ink` paints traced
106
+ art with Canvas 2D. The palette is a callback; call the returned `redraw()`
107
+ after a theme change and `destroy()` when the host leaves.
108
+
109
+ `mountMycelium(host, { pixi, palette, ...options })` from `./mycelium` returns a
110
+ Promise for a growing field with `pulse()` and `destroy()`. The host supplies
111
+ Pixi 8 and the palette. Both drawings respect reduced motion and release their
112
+ canvas and observers through `destroy()`.
113
+
114
+ ## API reference
115
+
116
+ See the generated [Sprite API reference](docs/api/README.md). Import the
117
+ renderer-neutral player API from `@fungi.computer/sprite`, the React provider
118
+ and component from `@fungi.computer/sprite/react`, and the video adapter from
119
+ `@fungi.computer/sprite/video`. The reference also covers `./avatar`; the
120
+ decorative `./ink` and `./mycelium` contracts are described above.
121
+
122
+ ## Examples and checks
123
+
124
+ The video workbench story presents a character with multiple clips and changes
125
+ the activity to select a clip. Start the local Ladle workbench from the
126
+ repository root with:
127
+
128
+ ```sh
129
+ pnpm --filter @fungi.computer/sprite exec ladle serve
130
+ ```
131
+
132
+ Run the package tests with:
133
+
134
+ ```sh
135
+ pnpm --filter @fungi.computer/sprite test
136
+ ```
137
+
138
+ ## Limits and errors
139
+
140
+ Sprite validates the shared document envelope, not renderer-specific data. The
141
+ host must configure the renderer named by a served document and review its asset
142
+ policy. Without an asset policy, remote origins are denied. Video codec support
143
+ depends on the browser and the encoded file.
144
+
145
+ The built-in render styles draw through an SVG filter. Reduced-motion
146
+ preferences, pause state, offscreen status, and page visibility suspend styles
147
+ that animate. A still frame remains. `poster` supplies a loading image, not a
148
+ replacement for host asset limits.
149
+
150
+ ## Contribute and license
151
+
152
+ Sprite is MIT licensed. See [`LICENSE`](LICENSE) and
153
+ [`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES).
@@ -0,0 +1,20 @@
1
+ /** Host-owned admission and resource limits for published avatar artwork. */
2
+ export type AssetPolicy = {
3
+ readonly baseURL: string;
4
+ readonly allowedOrigins: readonly string[];
5
+ /** Maximum bytes admitted for one response. */
6
+ readonly maxBytes: number;
7
+ /** Cumulative bytes admitted across one character selection. */
8
+ readonly maxTotalBytes: number;
9
+ /** Number of requests admitted for one character selection. */
10
+ readonly maxRequests: number;
11
+ readonly fetch?: typeof fetch;
12
+ };
13
+ /** Decoded transport bytes; adapters use these instead of opening their own URLs. */
14
+ export type Asset = {
15
+ readonly url: string;
16
+ readonly bytes: Uint8Array<ArrayBuffer>;
17
+ readonly contentType: string;
18
+ };
19
+ /** One policy-enforced path for documents and their nested native resources. */
20
+ export declare function createAssetReader(policy: AssetPolicy): () => (reference: string, signal: AbortSignal, base?: string) => Promise<Asset>;
package/dist/assets.js ADDED
@@ -0,0 +1,83 @@
1
+ /** One policy-enforced path for documents and their nested native resources. */
2
+ export function createAssetReader(policy) {
3
+ for (const [name, limit] of Object.entries({
4
+ maxBytes: policy.maxBytes,
5
+ maxTotalBytes: policy.maxTotalBytes,
6
+ maxRequests: policy.maxRequests,
7
+ })) {
8
+ if (!Number.isSafeInteger(limit) || limit <= 0)
9
+ throw new Error(`Avatar ${name} must be a positive safe integer`);
10
+ }
11
+ const origins = new Set(policy.allowedOrigins.map((origin) => new URL(origin).origin));
12
+ const request = policy.fetch ?? globalThis.fetch;
13
+ return () => {
14
+ let totalBytes = 0;
15
+ let requests = 0;
16
+ return async (reference, signal, base = policy.baseURL) => {
17
+ const url = new URL(reference, base);
18
+ if (!["http:", "https:"].includes(url.protocol) ||
19
+ !origins.has(url.origin) ||
20
+ url.username ||
21
+ url.password) {
22
+ throw new Error(`Avatar asset is not admitted: ${url.origin}`);
23
+ }
24
+ requests += 1;
25
+ if (requests > policy.maxRequests)
26
+ throw new Error("Avatar asset request limit reached");
27
+ // Reject redirects before a browser can contact an unapproved destination.
28
+ const response = await request(url.href, { signal, redirect: "error" });
29
+ if (!response.ok)
30
+ throw new Error(`Avatar asset request failed: ${String(response.status)}`);
31
+ const declaredSize = Number(response.headers.get("content-length"));
32
+ if (declaredSize > policy.maxBytes ||
33
+ declaredSize > policy.maxTotalBytes - totalBytes) {
34
+ await response.body?.cancel();
35
+ throw new Error("Avatar asset exceeds the byte limit");
36
+ }
37
+ const reader = response.body?.getReader();
38
+ if (!reader)
39
+ throw new Error("Avatar asset has no response body");
40
+ const chunks = [];
41
+ let size = 0;
42
+ try {
43
+ for (;;) {
44
+ signal.throwIfAborted();
45
+ const chunk = await reader.read();
46
+ if (chunk.done)
47
+ break;
48
+ size += chunk.value.byteLength;
49
+ if (size > policy.maxBytes)
50
+ throw new Error("Avatar asset exceeds the byte limit");
51
+ if (chunk.value.byteLength > policy.maxTotalBytes - totalBytes)
52
+ throw new Error("Avatar selection exceeds the total byte limit");
53
+ totalBytes += chunk.value.byteLength;
54
+ chunks.push(chunk.value);
55
+ }
56
+ }
57
+ catch (error) {
58
+ const cancellation = await reader.cancel().then(() => ({ kind: "cancelled" }), (cause) => ({
59
+ kind: "failed",
60
+ error: cause instanceof Error ? cause : new Error(String(cause)),
61
+ }));
62
+ if (cancellation.kind === "failed") {
63
+ throw new AggregateError([error, cancellation.error], "Avatar asset read and cancellation failed", { cause: error });
64
+ }
65
+ throw error;
66
+ }
67
+ finally {
68
+ reader.releaseLock();
69
+ }
70
+ const bytes = new Uint8Array(size);
71
+ let offset = 0;
72
+ for (const chunk of chunks) {
73
+ bytes.set(chunk, offset);
74
+ offset += chunk.byteLength;
75
+ }
76
+ return {
77
+ url: url.href,
78
+ bytes,
79
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
80
+ };
81
+ };
82
+ };
83
+ }
@@ -0,0 +1,152 @@
1
+ import * as z from "zod";
2
+ import type { Control } from "./controls.js";
3
+ /**
4
+ * The closed v1 catalog of avatar characters. Adding a character is a new
5
+ * major version; this list is not meant to grow silently.
6
+ */
7
+ export declare const avatarCharacters: readonly ["mushroom", "cat", "burger"];
8
+ /** One character in the closed v1 avatar catalog. */
9
+ export type AvatarCharacter = (typeof avatarCharacters)[number];
10
+ /** A saved `agentColors` preference: shift an Agent's identity, or copy it. */
11
+ export declare const agentColorsSchema: z.ZodEnum<{
12
+ shift: "shift";
13
+ keep: "keep";
14
+ }>;
15
+ /** Whether an Agent created from a member's avatar wears a shifted identity. */
16
+ export type AgentColors = z.infer<typeof agentColorsSchema>;
17
+ /**
18
+ * The Avatar v1 envelope: a closed character, up to 16 appearance values, a
19
+ * resting expression, and a render style. It is a complete snapshot, not a
20
+ * reference into character data, so it survives a later change to the
21
+ * catalog. This checks shape only; it does not know a character's real
22
+ * controls or expressions, so a bound catalog cannot validate against it by
23
+ * name alone. `createAvatarSchema`/`parseAvatar` build the catalog-refined
24
+ * schema a caller should actually parse untrusted data with; `sprite-zdog`'s
25
+ * `avatarSchema` is that schema, bound to the built-in catalog.
26
+ */
27
+ export declare const avatarEnvelopeSchema: z.ZodObject<{
28
+ v: z.ZodLiteral<1>;
29
+ character: z.ZodEnum<{
30
+ mushroom: "mushroom";
31
+ cat: "cat";
32
+ burger: "burger";
33
+ }>;
34
+ appearance: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
35
+ expression: z.ZodString;
36
+ renderStyle: z.ZodEnum<{
37
+ plain: "plain";
38
+ pencil: "pencil";
39
+ manga: "manga";
40
+ riso: "riso";
41
+ halftone: "halftone";
42
+ blueprint: "blueprint";
43
+ crt: "crt";
44
+ watercolor: "watercolor";
45
+ }>;
46
+ }, z.core.$strict>;
47
+ /** A parsed, envelope-valid Avatar v1 descriptor. */
48
+ export type Avatar = z.infer<typeof avatarEnvelopeSchema>;
49
+ /** Pure per-character facts a catalog owner (e.g. `sprite-zdog`) supplies. */
50
+ export type AvatarCatalogEntry = {
51
+ /** Every control the character's document declares. */
52
+ readonly controls: Readonly<Record<string, Control>>;
53
+ /** Every expression name the character's document declares. */
54
+ readonly expressions: readonly string[];
55
+ /** Controls that carry the character's identity colour, in priority order. */
56
+ readonly identitySlots: readonly string[];
57
+ };
58
+ /** Pure character facts for every avatar character, owned by its renderer. */
59
+ export type AvatarCatalog = Readonly<Record<AvatarCharacter, AvatarCatalogEntry>>;
60
+ /**
61
+ * Build the full avatar schema for a catalog: the envelope above, refined so
62
+ * every appearance key is one of the character's controls, every value is
63
+ * typed by that control, and the expression is one the character declares.
64
+ */
65
+ export declare function createAvatarSchema(catalog: AvatarCatalog): z.ZodObject<{
66
+ v: z.ZodLiteral<1>;
67
+ character: z.ZodEnum<{
68
+ mushroom: "mushroom";
69
+ cat: "cat";
70
+ burger: "burger";
71
+ }>;
72
+ appearance: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
73
+ expression: z.ZodString;
74
+ renderStyle: z.ZodEnum<{
75
+ plain: "plain";
76
+ pencil: "pencil";
77
+ manga: "manga";
78
+ riso: "riso";
79
+ halftone: "halftone";
80
+ blueprint: "blueprint";
81
+ crt: "crt";
82
+ watercolor: "watercolor";
83
+ }>;
84
+ }, z.core.$strict>;
85
+ /** Parse an unknown value into a catalog-valid Avatar v1 descriptor. */
86
+ export declare function parseAvatar(data: unknown, catalog: AvatarCatalog): Avatar;
87
+ /** A hexadecimal colour decomposed into hue (degrees), saturation, lightness. */
88
+ export type Hsl = {
89
+ readonly h: number;
90
+ readonly s: number;
91
+ readonly l: number;
92
+ };
93
+ /** Encode HSL (hue in degrees, saturation/lightness in percent) as `#rrggbb`. */
94
+ export declare function hexFromHsl(hue: number, saturation: number, lightness: number): string;
95
+ /** Decode a `#rrggbb` colour into HSL (hue in degrees, saturation/lightness in percent). */
96
+ export declare function hslFromHex(hex: unknown): Hsl;
97
+ /** Shortest angular distance between two hues in degrees, 0 to 180. */
98
+ export declare function hueDistance(a: number, b: number): number;
99
+ /** Lower bound (degrees) every colour-shift step must clear, each direction. */
100
+ export declare const AGENT_HUE_MIN_SHIFT = 40;
101
+ /**
102
+ * A deterministic FNV-1a hash: the same seed always lands on the same value.
103
+ * A copy of `@fungi.computer/caps`'s `seedHash` so this package stays free of
104
+ * a React-package dependency; both must keep computing the same value.
105
+ */
106
+ export declare function seedHash(seed: string): number;
107
+ /**
108
+ * The hue-rotation step for `seed`, chosen against `hex`'s own realised
109
+ * result, not the idealised `hue + step` arithmetic: `shiftHue` rounds to a
110
+ * `#rrggbb` colour, and re-decoding that colour can land a few tenths of a
111
+ * degree short of an exact-degree step, which the 40-degree guarantee must
112
+ * still hold against. A step is only a candidate when rotating `hex` by it
113
+ * and reading the result's hue back out clears 40 degrees from `hex`'s own
114
+ * hue. When `avoid` carries other agents' identity hues, the candidate
115
+ * farthest from all of them (by minimum distance, scored on the same
116
+ * realised hue) is preferred; ties keep whichever candidate is nearest the
117
+ * seed's own deterministic default step, so an unconflicted seed keeps its
118
+ * usual look.
119
+ */
120
+ export declare function agentHueStep(hex: string, seed: string, avoid?: readonly number[]): number;
121
+ /** Rotate one hex colour's hue by `step` degrees, keeping its saturation and lightness. */
122
+ export declare function shiftHue(hex: string, step: number): string;
123
+ /**
124
+ * The mushroom's two identity controls for one seed: its cap and cap spots
125
+ * take a hue from the seed, so an Agent wears the same cap in every list it
126
+ * appears in. Kept byte-identical to the Sprite seen before avatars existed.
127
+ */
128
+ export type AgentAppearance = Readonly<{
129
+ /** Cap colour as a hexadecimal CSS colour. */
130
+ cap: string;
131
+ /** Cap spot colour as a hexadecimal CSS colour. */
132
+ capSpot: string;
133
+ }>;
134
+ /** The pre-avatar mushroom identity colours for `seed`. */
135
+ export declare function agentAppearance(seed: string): AgentAppearance;
136
+ /**
137
+ * The avatar a Sprite presents when none is stored: the mushroom, in the same
138
+ * seeded cap colour Agents have always worn, at rest, undecorated. Every
139
+ * reader falls back to this so an existing Agent's look never changes.
140
+ */
141
+ export declare function legacyAvatar(seed: string): Avatar;
142
+ /**
143
+ * Derive an Agent's avatar from its creator's, for a catalog's character.
144
+ * `"keep"` hands back the member's look unchanged (their Agent is a visible
145
+ * copy, told apart only by name). `"shift"` resolves the member's full
146
+ * appearance against the catalog and rotates only the character's identity
147
+ * slots by one shared hue step, so accessories, face and drawing style carry
148
+ * over untouched. `avoid` lists hues already worn by the creator's other
149
+ * Agents so a new one does not land beside them. An achromatic primary
150
+ * identity colour has no hue to rotate, so its resolved appearance is kept.
151
+ */
152
+ export declare function deriveAgentAvatar(member: Avatar, agentColors: AgentColors, seed: string, catalog: AvatarCatalog, avoid?: readonly number[]): Avatar;
package/dist/avatar.js ADDED
@@ -0,0 +1,228 @@
1
+ import * as z from "zod";
2
+ import { resolveAppearance } from "./controls.js";
3
+ import { renderStyleSchema } from "./render-style.js";
4
+ /**
5
+ * The closed v1 catalog of avatar characters. Adding a character is a new
6
+ * major version; this list is not meant to grow silently.
7
+ */
8
+ export const avatarCharacters = ["mushroom", "cat", "burger"];
9
+ /** A saved `agentColors` preference: shift an Agent's identity, or copy it. */
10
+ export const agentColorsSchema = z.enum(["shift", "keep"]);
11
+ const hexColor = z.string().regex(/^#[0-9a-f]{6}$/i);
12
+ /** Appearance values a v1 avatar may carry: a hex colour, a number, or a flag. */
13
+ const avatarAppearanceValue = z.union([hexColor, z.number(), z.boolean()]);
14
+ const MAX_APPEARANCE_KEYS = 16;
15
+ /**
16
+ * The Avatar v1 envelope: a closed character, up to 16 appearance values, a
17
+ * resting expression, and a render style. It is a complete snapshot, not a
18
+ * reference into character data, so it survives a later change to the
19
+ * catalog. This checks shape only; it does not know a character's real
20
+ * controls or expressions, so a bound catalog cannot validate against it by
21
+ * name alone. `createAvatarSchema`/`parseAvatar` build the catalog-refined
22
+ * schema a caller should actually parse untrusted data with; `sprite-zdog`'s
23
+ * `avatarSchema` is that schema, bound to the built-in catalog.
24
+ */
25
+ export const avatarEnvelopeSchema = z.strictObject({
26
+ v: z.literal(1),
27
+ character: z.enum(avatarCharacters),
28
+ appearance: z
29
+ .record(z.string().min(1), avatarAppearanceValue)
30
+ .refine((appearance) => Object.keys(appearance).length <= MAX_APPEARANCE_KEYS, {
31
+ message: `Avatar appearance cannot exceed ${String(MAX_APPEARANCE_KEYS)} keys`,
32
+ }),
33
+ expression: z.string().min(1),
34
+ renderStyle: renderStyleSchema,
35
+ });
36
+ /**
37
+ * Build the full avatar schema for a catalog: the envelope above, refined so
38
+ * every appearance key is one of the character's controls, every value is
39
+ * typed by that control, and the expression is one the character declares.
40
+ */
41
+ export function createAvatarSchema(catalog) {
42
+ return avatarEnvelopeSchema.superRefine((avatar, context) => {
43
+ const entry = catalog[avatar.character];
44
+ try {
45
+ resolveAppearance(entry.controls, avatar.appearance);
46
+ }
47
+ catch (error) {
48
+ context.addIssue({
49
+ code: "custom",
50
+ path: ["appearance"],
51
+ message: error instanceof Error ? error.message : String(error),
52
+ });
53
+ }
54
+ if (!entry.expressions.includes(avatar.expression)) {
55
+ context.addIssue({
56
+ code: "custom",
57
+ path: ["expression"],
58
+ message: `Unknown avatar expression for ${avatar.character}: ${avatar.expression}`,
59
+ });
60
+ }
61
+ });
62
+ }
63
+ /** Parse an unknown value into a catalog-valid Avatar v1 descriptor. */
64
+ export function parseAvatar(data, catalog) {
65
+ return createAvatarSchema(catalog).parse(data);
66
+ }
67
+ /** Encode HSL (hue in degrees, saturation/lightness in percent) as `#rrggbb`. */
68
+ export function hexFromHsl(hue, saturation, lightness) {
69
+ const s = saturation / 100;
70
+ const l = lightness / 100;
71
+ const a = s * Math.min(l, 1 - l);
72
+ const channel = (offset) => {
73
+ const k = (offset + hue / 30) % 12;
74
+ const value = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
75
+ return Math.round(255 * value)
76
+ .toString(16)
77
+ .padStart(2, "0");
78
+ };
79
+ return `#${channel(0)}${channel(8)}${channel(4)}`;
80
+ }
81
+ /** Decode a `#rrggbb` colour into HSL (hue in degrees, saturation/lightness in percent). */
82
+ export function hslFromHex(hex) {
83
+ const parsed = hexColor.parse(hex);
84
+ const r = Number.parseInt(parsed.slice(1, 3), 16) / 255;
85
+ const g = Number.parseInt(parsed.slice(3, 5), 16) / 255;
86
+ const b = Number.parseInt(parsed.slice(5, 7), 16) / 255;
87
+ const max = Math.max(r, g, b);
88
+ const min = Math.min(r, g, b);
89
+ const l = (max + min) / 2;
90
+ const delta = max - min;
91
+ if (delta === 0)
92
+ return { h: 0, s: 0, l: l * 100 };
93
+ const s = delta / (1 - Math.abs(2 * l - 1));
94
+ let h;
95
+ if (max === r)
96
+ h = (((g - b) / delta) % 6) * 60;
97
+ else if (max === g)
98
+ h = ((b - r) / delta + 2) * 60;
99
+ else
100
+ h = ((r - g) / delta + 4) * 60;
101
+ if (h < 0)
102
+ h += 360;
103
+ return { h, s: s * 100, l: l * 100 };
104
+ }
105
+ const normalizeHue = (hue) => ((hue % 360) + 360) % 360;
106
+ /** Shortest angular distance between two hues in degrees, 0 to 180. */
107
+ export function hueDistance(a, b) {
108
+ const diff = Math.abs(normalizeHue(a) - normalizeHue(b)) % 360;
109
+ return Math.min(diff, 360 - diff);
110
+ }
111
+ /** Lower bound (degrees) every colour-shift step must clear, each direction. */
112
+ export const AGENT_HUE_MIN_SHIFT = 40;
113
+ /** `agentHueStep`'s deterministic step range has exactly this many values. */
114
+ const SHIFT_RANGE = 281;
115
+ /**
116
+ * A deterministic FNV-1a hash: the same seed always lands on the same value.
117
+ * A copy of `@fungi.computer/caps`'s `seedHash` so this package stays free of
118
+ * a React-package dependency; both must keep computing the same value.
119
+ */
120
+ export function seedHash(seed) {
121
+ let hash = 0x811c9dc5;
122
+ for (const unit of seed) {
123
+ hash ^= unit.codePointAt(0) ?? 0;
124
+ hash = Math.imul(hash, 0x01000193) >>> 0;
125
+ }
126
+ return hash;
127
+ }
128
+ /**
129
+ * The hue-rotation step for `seed`, chosen against `hex`'s own realised
130
+ * result, not the idealised `hue + step` arithmetic: `shiftHue` rounds to a
131
+ * `#rrggbb` colour, and re-decoding that colour can land a few tenths of a
132
+ * degree short of an exact-degree step, which the 40-degree guarantee must
133
+ * still hold against. A step is only a candidate when rotating `hex` by it
134
+ * and reading the result's hue back out clears 40 degrees from `hex`'s own
135
+ * hue. When `avoid` carries other agents' identity hues, the candidate
136
+ * farthest from all of them (by minimum distance, scored on the same
137
+ * realised hue) is preferred; ties keep whichever candidate is nearest the
138
+ * seed's own deterministic default step, so an unconflicted seed keeps its
139
+ * usual look.
140
+ */
141
+ export function agentHueStep(hex, seed, avoid = []) {
142
+ const memberHue = hslFromHex(hex).h;
143
+ const fallbackStep = AGENT_HUE_MIN_SHIFT + (seedHash(seed) % SHIFT_RANGE);
144
+ const blocked = avoid.map(normalizeHue);
145
+ const realizedHue = (step) => hslFromHex(shiftHue(hex, step)).h;
146
+ const clearsMember = (step) => hueDistance(realizedHue(step), memberHue) >= AGENT_HUE_MIN_SHIFT;
147
+ const avoidScore = (step) => blocked.length === 0
148
+ ? 0
149
+ : Math.min(...blocked.map((hue) => hueDistance(realizedHue(step), hue)));
150
+ let bestStep;
151
+ let bestScore = -1;
152
+ let bestDistanceToFallback = Infinity;
153
+ for (let step = AGENT_HUE_MIN_SHIFT; step <= 360 - AGENT_HUE_MIN_SHIFT; step += 1) {
154
+ if (!clearsMember(step))
155
+ continue;
156
+ const score = avoidScore(step);
157
+ const distanceToFallback = Math.abs(step - fallbackStep);
158
+ if (bestStep === undefined ||
159
+ score > bestScore ||
160
+ (score === bestScore && distanceToFallback < bestDistanceToFallback)) {
161
+ bestStep = step;
162
+ bestScore = score;
163
+ bestDistanceToFallback = distanceToFallback;
164
+ }
165
+ }
166
+ if (bestStep === undefined)
167
+ throw new Error(`No colour-shift step clears ${String(AGENT_HUE_MIN_SHIFT)} degrees for ${hex}`);
168
+ return bestStep;
169
+ }
170
+ /** Rotate one hex colour's hue by `step` degrees, keeping its saturation and lightness. */
171
+ export function shiftHue(hex, step) {
172
+ const { h, s, l } = hslFromHex(hex);
173
+ return hexFromHsl(normalizeHue(h + step), s, l);
174
+ }
175
+ /** The pre-avatar mushroom identity colours for `seed`. */
176
+ export function agentAppearance(seed) {
177
+ const hue = seedHash(seed) % 360;
178
+ return { cap: hexFromHsl(hue, 68, 72), capSpot: hexFromHsl(hue, 70, 92) };
179
+ }
180
+ /**
181
+ * The avatar a Sprite presents when none is stored: the mushroom, in the same
182
+ * seeded cap colour Agents have always worn, at rest, undecorated. Every
183
+ * reader falls back to this so an existing Agent's look never changes.
184
+ */
185
+ export function legacyAvatar(seed) {
186
+ return {
187
+ v: 1,
188
+ character: "mushroom",
189
+ appearance: agentAppearance(seed),
190
+ expression: "normal",
191
+ renderStyle: "plain",
192
+ };
193
+ }
194
+ /**
195
+ * Derive an Agent's avatar from its creator's, for a catalog's character.
196
+ * `"keep"` hands back the member's look unchanged (their Agent is a visible
197
+ * copy, told apart only by name). `"shift"` resolves the member's full
198
+ * appearance against the catalog and rotates only the character's identity
199
+ * slots by one shared hue step, so accessories, face and drawing style carry
200
+ * over untouched. `avoid` lists hues already worn by the creator's other
201
+ * Agents so a new one does not land beside them. An achromatic primary
202
+ * identity colour has no hue to rotate, so its resolved appearance is kept.
203
+ */
204
+ export function deriveAgentAvatar(member, agentColors, seed, catalog, avoid = []) {
205
+ if (agentColors === "keep")
206
+ return { ...member };
207
+ const characterEntry = catalog[member.character];
208
+ const resolved = resolveAppearance(characterEntry.controls, member.appearance);
209
+ // Every identity slot whose control is a colour, with its resolved hex
210
+ // value already parsed (not asserted): the control's declared type is the
211
+ // contract, not the resolved value's runtime shape.
212
+ const identityHues = characterEntry.identitySlots.flatMap((slot) => {
213
+ const control = characterEntry.controls[slot];
214
+ return control?.type === "color"
215
+ ? [{ slot, hex: hexColor.parse(resolved[slot]) }]
216
+ : [];
217
+ });
218
+ const primary = identityHues[0];
219
+ if (!primary || hslFromHex(primary.hex).s === 0)
220
+ return { ...member, appearance: resolved };
221
+ const step = agentHueStep(primary.hex, seed, avoid);
222
+ const shiftedBySlot = new Map(identityHues.map(({ slot, hex }) => [slot, shiftHue(hex, step)]));
223
+ const appearance = Object.fromEntries(Object.entries(resolved).map(([key, value]) => [
224
+ key,
225
+ shiftedBySlot.get(key) ?? value,
226
+ ]));
227
+ return { ...member, appearance };
228
+ }