| 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 | |
| 13 | export 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. */ |
| 15 | export const FACE_COLORS = ["lavender", "mint", "peach", "sky", "pink", "lemon", "teal", "coral", "moss", "slate"] as const; |
| 16 | export const FACE_EYES = ["dots", "round", "wide", "happy", "visor", "sleepy"] as const; |
| 17 | export const FACE_MOUTHS = ["smile", "grin", "flat", "open", "none"] as const; |
| 18 | export const FACE_ANTENNAS = ["none", "single", "double", "bulb"] as const; |
| 19 | export const FACE_ACCESSORIES = ["none", "headphones", "bow", "glasses", "hat", "spark"] as const; |
| 20 | export const FACE_PATTERNS = ["none", "stripe", "dots", "gradient"] as const; |
| 21 | |
| 22 | export type FaceShape = (typeof FACE_SHAPES)[number]; |
| 23 | export type FaceColor = (typeof FACE_COLORS)[number]; |
| 24 | export type FaceEyes = (typeof FACE_EYES)[number]; |
| 25 | export type FaceMouth = (typeof FACE_MOUTHS)[number]; |
| 26 | export type FaceAntenna = (typeof FACE_ANTENNAS)[number]; |
| 27 | export type FaceAccessory = (typeof FACE_ACCESSORIES)[number]; |
| 28 | export type FacePattern = (typeof FACE_PATTERNS)[number]; |
| 29 | |
| 30 | /** What an agent's face is made of. Every field is one of its listed choices. */ |
| 31 | export 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. */ |
| 42 | export 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 | |
| 52 | export const LOOK_KEYS = Object.keys(LOOK_OPTIONS) as (keyof AgentLook)[]; |
| 53 | |
| 54 | /** FNV-1a, 32 bits: a seed as a number. */ |
| 55 | function 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. */ |
| 65 | function 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 | */ |
| 84 | export 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 | */ |
| 104 | export 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 | |
| 118 | function 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. */ |
| 127 | export 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. */ |
| 133 | export 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 | */ |
| 142 | export 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 | */ |
| 161 | export 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. */ |
| 169 | export function suggestedLook(seed: string, preset: string | null | undefined): AgentLook { |
| 170 | return { ...lookFromSeed(seed), ...(preset ? (PRESET_EXPRESSIONS[preset] ?? {}) : {}) }; |
| 171 | } |