Skip to content
171 linesCodeBlameRaw
1/**
2 * An agent's face (docs.g1t.sh/guides/agents/, "Its face"): the little bot
3 * every agent wears when it has no uploaded picture. A face is a `look`,
4 * seven choices the agent's owner can make; an agent with no look of its
5 * own wears the one drawn from its `avatar_seed`, so every agent has a
6 * stable face from the moment it is made, and keeps it through a rename.
7 *
8 * The site draws the face (apps/web/app/components/agent-face.tsx) and the
9 * agents service keeps it (the `look` column, JSON). No value imports, so
10 * services test it under Node as it is. Wire shapes are snake_case.
11 */
12
13export const FACE_SHAPES = ["round", "square", "squircle", "blob", "hex"] as const;
14/** Ten named colours, each with a light and a dark rendering that read on both themes. */
15export const FACE_COLORS = ["lavender", "mint", "peach", "sky", "pink", "lemon", "teal", "coral", "moss", "slate"] as const;
16export const FACE_EYES = ["dots", "round", "wide", "happy", "visor", "sleepy"] as const;
17export const FACE_MOUTHS = ["smile", "grin", "flat", "open", "none"] as const;
18export const FACE_ANTENNAS = ["none", "single", "double", "bulb"] as const;
19export const FACE_ACCESSORIES = ["none", "headphones", "bow", "glasses", "hat", "spark"] as const;
20export const FACE_PATTERNS = ["none", "stripe", "dots", "gradient"] as const;
21
22export type FaceShape = (typeof FACE_SHAPES)[number];
23export type FaceColor = (typeof FACE_COLORS)[number];
24export type FaceEyes = (typeof FACE_EYES)[number];
25export type FaceMouth = (typeof FACE_MOUTHS)[number];
26export type FaceAntenna = (typeof FACE_ANTENNAS)[number];
27export type FaceAccessory = (typeof FACE_ACCESSORIES)[number];
28export type FacePattern = (typeof FACE_PATTERNS)[number];
29
30/** What an agent's face is made of. Every field is one of its listed choices. */
31export type AgentLook = {
32 shape: FaceShape;
33 color: FaceColor;
34 eyes: FaceEyes;
35 mouth: FaceMouth;
36 antenna: FaceAntenna;
37 accessory: FaceAccessory;
38 pattern: FacePattern;
39};
40
41/** Each part of a look and its choices, in the order the Face editor shows them. */
42export const LOOK_OPTIONS: { [K in keyof AgentLook]: readonly AgentLook[K][] } = {
43 shape: FACE_SHAPES,
44 color: FACE_COLORS,
45 eyes: FACE_EYES,
46 mouth: FACE_MOUTHS,
47 antenna: FACE_ANTENNAS,
48 accessory: FACE_ACCESSORIES,
49 pattern: FACE_PATTERNS,
50};
51
52export const LOOK_KEYS = Object.keys(LOOK_OPTIONS) as (keyof AgentLook)[];
53
54/** FNV-1a, 32 bits: a seed as a number. */
55function fnv(text: string): number {
56 let h = 0x811c9dc5;
57 for (let i = 0; i < text.length; i++) {
58 h ^= text.charCodeAt(i);
59 h = Math.imul(h, 0x01000193);
60 }
61 return h >>> 0;
62}
63
64/** One choice for a part, from the seed and the part's name: parts vary independently. */
65function pick<T>(seed: string, part: string, options: readonly T[], weights?: readonly number[]): T {
66 const roll = fnv(`${part}:${seed}`) >>> 8;
67 if (!weights) return options[roll % options.length]!;
68 const total = weights.reduce((sum, w) => sum + w, 0);
69 let at = roll % total;
70 for (let i = 0; i < options.length; i++) {
71 at -= weights[i]!;
72 if (at < 0) return options[i]!;
73 }
74 return options[options.length - 1]!;
75}
76
77/**
78 * The face an agent wears when it has chosen none: the same for the same
79 * seed everywhere, and different from its neighbours' as far as seven
80 * parts allow. Plainer choices (no antenna, no accessory, no pattern) are
81 * weighted so faces read as a set rather than a costume party, and `none`
82 * never lands on both the eyes' expression and the mouth.
83 */
84export function lookFromSeed(seed: string): AgentLook {
85 const s = seed || "agent";
86 const look: AgentLook = {
87 shape: pick(s, "shape", FACE_SHAPES, [3, 2, 4, 2, 2]),
88 color: pick(s, "color", FACE_COLORS),
89 eyes: pick(s, "eyes", FACE_EYES, [3, 3, 2, 2, 2, 1]),
90 mouth: pick(s, "mouth", FACE_MOUTHS, [4, 2, 2, 2, 1]),
91 antenna: pick(s, "antenna", FACE_ANTENNAS, [4, 3, 2, 2]),
92 accessory: pick(s, "accessory", FACE_ACCESSORIES, [7, 1, 1, 1, 1, 1]),
93 pattern: pick(s, "pattern", FACE_PATTERNS, [6, 1, 1, 2]),
94 };
95 if (look.mouth === "none" && look.eyes === "visor") look.mouth = "flat";
96 return look;
97}
98
99/**
100 * A look as it was sent or stored, checked: every part present and one of
101 * its choices. Null when it is not one (a service refuses it, a page draws
102 * the seed's face instead).
103 */
104export function readLook(raw: unknown): AgentLook | null {
105 const value = typeof raw === "string" ? parse(raw) : raw;
106 if (!value || typeof value !== "object" || Array.isArray(value)) return null;
107 const given = value as Record<string, unknown>;
108 const look: Partial<AgentLook> = {};
109 for (const key of LOOK_KEYS) {
110 const options = LOOK_OPTIONS[key] as readonly string[];
111 const choice = given[key];
112 if (typeof choice !== "string" || !options.includes(choice)) return null;
113 (look as Record<string, string>)[key] = choice;
114 }
115 return look as AgentLook;
116}
117
118function parse(text: string): unknown {
119 try {
120 return JSON.parse(text);
121 } catch {
122 return null;
123 }
124}
125
126/** Whether two looks are the same face. */
127export function sameLook(a: AgentLook | null | undefined, b: AgentLook | null | undefined): boolean {
128 if (!a || !b) return a == b;
129 return LOOK_KEYS.every((key) => a[key] === b[key]);
130}
131
132/** The face an agent shows: its own look, else the one its seed draws. */
133export function lookOf(agent: { look?: AgentLook | null; avatar_seed?: string | null; id?: string | null; handle?: string | null; name?: string | null }): AgentLook {
134 return agent.look ?? lookFromSeed(agent.avatar_seed || agent.id || agent.handle || agent.name || "agent");
135}
136
137/**
138 * A random look, for the Shuffle button: `random` is `Math.random` unless
139 * a test hands in its own. Every part is drawn evenly, so shuffling shows
140 * the whole range.
141 */
142export function randomLook(random: () => number = Math.random): AgentLook {
143 const draw = <T>(options: readonly T[]): T => options[Math.min(options.length - 1, Math.floor(random() * options.length))]!;
144 return {
145 shape: draw(FACE_SHAPES),
146 color: draw(FACE_COLORS),
147 eyes: draw(FACE_EYES),
148 mouth: draw(FACE_MOUTHS),
149 antenna: draw(FACE_ANTENNAS),
150 accessory: draw(FACE_ACCESSORIES),
151 pattern: draw(FACE_PATTERNS),
152 };
153}
154
155/**
156 * What a personality preset suggests for a face that has not been chosen:
157 * a precise voice looks precise. Only a suggestion, applied by the Face
158 * editor when the person has made no choice; it never changes a look an
159 * agent already has.
160 */
161export const PRESET_EXPRESSIONS: Record<string, Partial<AgentLook>> = {
162 crisp: { eyes: "dots", mouth: "flat" },
163 friendly: { eyes: "happy", mouth: "smile" },
164 socratic: { eyes: "round", mouth: "open" },
165 terse: { eyes: "visor", mouth: "flat" },
166};
167
168/** The seed's face with the preset's expression laid over it, for an agent that has chosen no look. */
169export function suggestedLook(seed: string, preset: string | null | undefined): AgentLook {
170 return { ...lookFromSeed(seed), ...(preset ? (PRESET_EXPRESSIONS[preset] ?? {}) : {}) };
171}