pi-roundtable 0.22.5 → 0.23.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 (129) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +35 -9
  3. package/README.zh-TW.md +51 -13
  4. package/docs/migrating-0.8.md +2 -2
  5. package/docs/plugins.md +350 -28
  6. package/examples/discord-adapter.ts +9 -1
  7. package/examples/seeds.test.ts +2 -2
  8. package/examples/seeds.ts +1 -2
  9. package/package.json +6 -2
  10. package/src/characters/catalog.ts +1055 -0
  11. package/src/characters/characters.css +68 -0
  12. package/src/characters/colors.ts +108 -0
  13. package/src/characters/compose.ts +281 -0
  14. package/src/characters/css.ts +63 -0
  15. package/src/characters/index.ts +33 -0
  16. package/src/characters/parse.ts +196 -0
  17. package/src/characters/parts-face.ts +354 -0
  18. package/src/characters/parts-hair.ts +56 -0
  19. package/src/characters/parts-hats.ts +57 -0
  20. package/src/characters/parts-outfits.ts +81 -0
  21. package/src/characters/parts-props.ts +137 -0
  22. package/src/characters/spec.ts +224 -0
  23. package/src/characters/suggest.ts +101 -0
  24. package/src/cli/checks/characters.ts +23 -0
  25. package/src/cli/checks/images.ts +5 -3
  26. package/src/cli/doctor.ts +2 -0
  27. package/src/core/agents/agent-claim.ts +30 -11
  28. package/src/core/agents/agent-dashboard.ts +1 -1
  29. package/src/core/agents/agent-messages.ts +43 -15
  30. package/src/core/agents/agent-prompt.ts +2 -4
  31. package/src/core/agents/agent-rules.ts +42 -3
  32. package/src/core/agents/agent-schema.ts +37 -15
  33. package/src/core/agents/agent-store.ts +57 -13
  34. package/src/core/agents/agent-team-fixture.ts +90 -14
  35. package/src/core/agents/agent-team.ts +83 -50
  36. package/src/core/agents/agent-tools.ts +94 -41
  37. package/src/core/agents/avatar-studio.ts +40 -4
  38. package/src/core/agents/character-png.ts +40 -0
  39. package/src/core/agents/group-messages.ts +7 -4
  40. package/src/core/agents/group-turns.ts +27 -14
  41. package/src/core/agents/team-editing.ts +99 -58
  42. package/src/core/agents/team-keys.ts +177 -11
  43. package/src/core/agents/team-lifecycle.ts +151 -84
  44. package/src/core/agents/team-options.ts +13 -6
  45. package/src/core/agents/team-status.ts +31 -13
  46. package/src/core/agents/team-text.ts +13 -3
  47. package/src/core/agents/team-turn-types.ts +25 -7
  48. package/src/core/agents/team-turns.ts +143 -28
  49. package/src/core/attachments/attachment-fetcher.ts +162 -64
  50. package/src/core/attachments/prompt-block.ts +7 -1
  51. package/src/core/attachments/tool-attachment.ts +20 -1
  52. package/src/core/attachments/turn-attachments.ts +18 -2
  53. package/src/core/builtin/agent-server.ts +103 -40
  54. package/src/core/builtin/discord.ts +4 -17
  55. package/src/core/builtin/modules.ts +6 -5
  56. package/src/core/builtin/threads.ts +37 -0
  57. package/src/core/config/agent-team.ts +43 -0
  58. package/src/core/config/config.ts +50 -59
  59. package/src/core/config/limits.ts +93 -0
  60. package/src/core/config/ops.ts +34 -0
  61. package/src/core/config/schema.ts +26 -0
  62. package/src/core/contract/channels.ts +20 -0
  63. package/src/core/contract/surface.ts +77 -7
  64. package/src/core/define-roundtable.ts +70 -42
  65. package/src/core/discord/agent-commands.ts +108 -86
  66. package/src/core/discord/agent-panel.ts +11 -2
  67. package/src/core/discord/connection.ts +1 -1
  68. package/src/core/discord/discord-surface.ts +10 -1
  69. package/src/core/discord/dispatch-thread-host.ts +1 -1
  70. package/src/core/domain/conversation.ts +24 -0
  71. package/src/core/holds.ts +221 -15
  72. package/src/core/host-registration.ts +2 -1
  73. package/src/core/http/listeners.ts +63 -3
  74. package/src/core/i18n/agent-panel.ts +14 -21
  75. package/src/core/interactions/prompts.ts +12 -0
  76. package/src/core/modes/autonomy.ts +23 -0
  77. package/src/core/modes/conversation-modes.ts +213 -0
  78. package/src/core/modes/modes-plugin.ts +24 -0
  79. package/src/core/modules/delegation/delegator.ts +4 -4
  80. package/src/core/modules/host-shell/shell-arithmetic.ts +81 -0
  81. package/src/core/modules/host-shell/shell-lexer-types.ts +53 -0
  82. package/src/core/modules/host-shell/shell-lexer.ts +493 -0
  83. package/src/core/modules/host-shell/shell-paths.ts +85 -4
  84. package/src/core/modules/host-shell/shell-policy.ts +341 -332
  85. package/src/core/modules/host-shell/shell-programs.ts +298 -0
  86. package/src/core/modules/host-shell/shell-quoting.ts +237 -0
  87. package/src/core/modules/host-shell/shell-scripts.ts +114 -0
  88. package/src/core/modules/host-shell/shell-wrappers.ts +104 -0
  89. package/src/core/modules/schedules/precheck-tools.ts +17 -0
  90. package/src/core/modules/schedules/schedules.ts +9 -3
  91. package/src/core/plugin.ts +8 -1
  92. package/src/core/presence/direct-channels.ts +30 -0
  93. package/src/core/presentation/reply-body.ts +25 -0
  94. package/src/core/registry/contributions.ts +31 -7
  95. package/src/core/registry/providers.ts +1 -1
  96. package/src/core/routing/conversation-turns.ts +2 -15
  97. package/src/core/routing/surface-port.ts +5 -1
  98. package/src/core/runtime/extensions/approval-card.ts +1 -5
  99. package/src/core/runtime/extensions/approval-fields.ts +3 -7
  100. package/src/core/runtime/extensions/confirmation-gate-extension.ts +13 -2
  101. package/src/core/runtime/extensions/confirmation-gate.ts +41 -40
  102. package/src/core/runtime/extensions/gate-refusals.ts +23 -0
  103. package/src/core/runtime/extensions/held-calls.ts +16 -1
  104. package/src/core/runtime/extensions/hold-describer.ts +58 -8
  105. package/src/core/runtime/extensions/hold-lookups.ts +19 -19
  106. package/src/core/runtime/extensions/yolo.ts +105 -0
  107. package/src/core/runtime/pi-agent-runtime.ts +3 -5
  108. package/src/core/runtime/runtime-plugin.ts +42 -1
  109. package/src/core/runtime/runtime-types.ts +11 -0
  110. package/src/core/runtime/turn-budget.ts +32 -5
  111. package/src/core/runtime/turn-limits.ts +14 -3
  112. package/src/core/services.ts +61 -9
  113. package/src/core/shared/attachment-reader.ts +6 -0
  114. package/src/core/shared/unix-server.ts +10 -1
  115. package/src/core/testing/modules.ts +12 -8
  116. package/src/core/testing/surface-threads.ts +58 -0
  117. package/src/core/testing/thread-host.ts +17 -5
  118. package/src/core/threads/dispatch-threads.ts +299 -0
  119. package/src/core/threads/host-threads.ts +19 -0
  120. package/src/core/tool-set.snapshot.json +5 -0
  121. package/src/index.ts +27 -1
  122. package/src/kit/characters.ts +5 -0
  123. package/src/kit/index.ts +2 -0
  124. package/src/kit/shell.ts +1 -0
  125. package/src/kit/support.ts +1 -1
  126. package/src/kit/threads.ts +1 -1
  127. package/src/testing.ts +4 -0
  128. package/templates/agents.ts +1 -1
  129. package/src/core/discord/dispatch-threads.ts +0 -194
@@ -0,0 +1,196 @@
1
+ import {
2
+ ACCESSORY_CHOICES,
3
+ BEARD_CHOICES,
4
+ BUILDS,
5
+ CATALOG,
6
+ CLASS_IDS,
7
+ HAIR_IDS,
8
+ HAT_IDS,
9
+ OUTFIT_IDS,
10
+ PROP_IDS,
11
+ SKIN_INDEXES,
12
+ } from "./catalog.ts";
13
+ import {
14
+ type CharacterColors,
15
+ CharacterError,
16
+ type CharacterSpec,
17
+ checkHex,
18
+ checkOneOf,
19
+ isRecord,
20
+ paletteOf,
21
+ presetSpec,
22
+ } from "./spec.ts";
23
+
24
+ const SPEC_KEYS = new Set([
25
+ "v",
26
+ "class",
27
+ "outfit",
28
+ "hat",
29
+ "prop",
30
+ "hair",
31
+ "beard",
32
+ "accessory",
33
+ "build",
34
+ "skin",
35
+ "hairColor",
36
+ "colors",
37
+ "palette",
38
+ ]);
39
+ const REQUIRED_KEYS = [...SPEC_KEYS].filter((key) => key !== "palette");
40
+ const COLOR_KEYS = ["primary", "secondary", "trim"] as const;
41
+
42
+ function requireField(object: Record<string, unknown>, field: string): void {
43
+ if (!Object.hasOwn(object, field))
44
+ throw new CharacterError(field, "is required");
45
+ }
46
+
47
+ /**
48
+ * A complete spec from outside the process (an API body, what the page received), checked
49
+ * strictly: an unknown key or id, a missing field, or a color that is not `#rrggbb` throws a
50
+ * `CharacterError` naming the field. Colors are lowercased.
51
+ */
52
+ export function parseCharacter(value: unknown): CharacterSpec {
53
+ if (!isRecord(value))
54
+ throw new CharacterError("character", "must be an object");
55
+ for (const key of Object.keys(value))
56
+ if (!SPEC_KEYS.has(key))
57
+ throw new CharacterError(key, "is not a known field");
58
+ for (const key of REQUIRED_KEYS) requireField(value, key);
59
+ if (value.v !== 1) throw new CharacterError("v", "must be 1");
60
+ const colorsIn = value.colors;
61
+ if (!isRecord(colorsIn))
62
+ throw new CharacterError("colors", "must be an object");
63
+ for (const key of Object.keys(colorsIn))
64
+ if (!(COLOR_KEYS as readonly string[]).includes(key))
65
+ throw new CharacterError(`colors.${key}`, "is not a known color");
66
+ for (const key of COLOR_KEYS) requireField(colorsIn, key);
67
+ const colors: CharacterColors = {
68
+ primary: checkHex(colorsIn.primary, "colors.primary"),
69
+ secondary: checkHex(colorsIn.secondary, "colors.secondary"),
70
+ trim: checkHex(colorsIn.trim, "colors.trim"),
71
+ };
72
+ const palette = paletteOf(colors);
73
+ if (value.palette !== undefined) {
74
+ const named = checkOneOf(
75
+ Object.keys(CATALOG.palettes),
76
+ value.palette,
77
+ "palette",
78
+ );
79
+ if (named !== palette)
80
+ throw new CharacterError("palette", "does not match the colors");
81
+ }
82
+ return {
83
+ v: 1,
84
+ class: checkOneOf(CLASS_IDS, value.class, "class"),
85
+ outfit: checkOneOf(OUTFIT_IDS, value.outfit, "outfit"),
86
+ hat: checkOneOf(HAT_IDS, value.hat, "hat"),
87
+ prop: checkOneOf(PROP_IDS, value.prop, "prop"),
88
+ hair: checkOneOf(HAIR_IDS, value.hair, "hair"),
89
+ beard: checkOneOf(BEARD_CHOICES, value.beard, "beard"),
90
+ accessory: checkOneOf(ACCESSORY_CHOICES, value.accessory, "accessory"),
91
+ build: checkOneOf(BUILDS, value.build, "build"),
92
+ skin: checkOneOf(SKIN_INDEXES, value.skin, "skin"),
93
+ hairColor: checkHex(value.hairColor, "hairColor"),
94
+ colors,
95
+ ...(palette ? { palette } : {}),
96
+ };
97
+ }
98
+
99
+ /** The value if `check` accepts it, else `fallback`. */
100
+ function lenient<T>(check: () => T, fallback: T): T {
101
+ try {
102
+ return check();
103
+ } catch (error) {
104
+ if (error instanceof CharacterError) return fallback;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * A spec from a stored row, read leniently so an old row, or one a newer version wrote, never
111
+ * throws: a field that is not valid here (an id this version does not know, a bad color) takes
112
+ * the class preset's value, and an unknown key is ignored. `undefined` when the value is not an
113
+ * object or names no known class.
114
+ */
115
+ export function readCharacter(value: unknown): CharacterSpec | undefined {
116
+ if (!isRecord(value)) return undefined;
117
+ const id = lenient(
118
+ () => checkOneOf(CLASS_IDS, value.class, "class"),
119
+ undefined,
120
+ );
121
+ if (!id) return undefined;
122
+ const spec = presetSpec(id);
123
+ const take = <T>(check: () => T, set: (found: T) => void) => {
124
+ const none = Symbol("none");
125
+ const found = lenient<T | typeof none>(check, none);
126
+ if (found !== none) set(found as T);
127
+ };
128
+ take(
129
+ () => checkOneOf(OUTFIT_IDS, value.outfit, "outfit"),
130
+ (found) => {
131
+ spec.outfit = found;
132
+ },
133
+ );
134
+ take(
135
+ () => checkOneOf(HAT_IDS, value.hat, "hat"),
136
+ (found) => {
137
+ spec.hat = found;
138
+ },
139
+ );
140
+ take(
141
+ () => checkOneOf(PROP_IDS, value.prop, "prop"),
142
+ (found) => {
143
+ spec.prop = found;
144
+ },
145
+ );
146
+ take(
147
+ () => checkOneOf(HAIR_IDS, value.hair, "hair"),
148
+ (found) => {
149
+ spec.hair = found;
150
+ },
151
+ );
152
+ take(
153
+ () => checkOneOf(BEARD_CHOICES, value.beard, "beard"),
154
+ (found) => {
155
+ spec.beard = found;
156
+ },
157
+ );
158
+ take(
159
+ () => checkOneOf(ACCESSORY_CHOICES, value.accessory, "accessory"),
160
+ (found) => {
161
+ spec.accessory = found;
162
+ },
163
+ );
164
+ take(
165
+ () => checkOneOf(BUILDS, value.build, "build"),
166
+ (found) => {
167
+ spec.build = found;
168
+ },
169
+ );
170
+ take(
171
+ () => checkOneOf(SKIN_INDEXES, value.skin, "skin"),
172
+ (found) => {
173
+ spec.skin = found;
174
+ },
175
+ );
176
+ take(
177
+ () => checkHex(value.hairColor, "hairColor"),
178
+ (found) => {
179
+ spec.hairColor = found;
180
+ },
181
+ );
182
+ if (isRecord(value.colors)) {
183
+ const colors = value.colors;
184
+ for (const key of COLOR_KEYS)
185
+ take(
186
+ () => checkHex(colors[key], `colors.${key}`),
187
+ (found) => {
188
+ spec.colors[key] = found;
189
+ },
190
+ );
191
+ }
192
+ const palette = paletteOf(spec.colors);
193
+ if (palette) spec.palette = palette;
194
+ else delete spec.palette;
195
+ return spec;
196
+ }
@@ -0,0 +1,354 @@
1
+ import type { AvatarState, Expression } from "./catalog.ts";
2
+
3
+ type EyeShape =
4
+ | "open"
5
+ | "sparkle"
6
+ | "wide"
7
+ | "arc"
8
+ | "closed"
9
+ | "soft"
10
+ | "narrow"
11
+ | "xd"
12
+ | "wink";
13
+ type MouthShape =
14
+ | "smile"
15
+ | "smileS"
16
+ | "grin"
17
+ | "o"
18
+ | "flatR"
19
+ | "firm"
20
+ | "wobble"
21
+ | "smirk"
22
+ | "tongue"
23
+ | "yawn"
24
+ | "tilt";
25
+ type Extra = "blush" | "sweat" | "tear" | "sparkle" | "dots" | "burst";
26
+
27
+ interface ExpressionRow {
28
+ eye: EyeShape;
29
+ look: readonly [number, number];
30
+ /** Left and right brow: [raise, tilt]; a tilt above 0 is the inner end down (angry), below 0 up (worried). */
31
+ bl: readonly [number, number];
32
+ br: readonly [number, number];
33
+ mouth: MouthShape;
34
+ fx: readonly Extra[];
35
+ big?: 1;
36
+ }
37
+
38
+ const EXPRESSION_ROWS: Record<Expression, ExpressionRow> = {
39
+ neutral: {
40
+ eye: "open",
41
+ look: [0, 0],
42
+ bl: [0, 0],
43
+ br: [0, 0],
44
+ mouth: "smile",
45
+ fx: [],
46
+ },
47
+ happy: {
48
+ eye: "sparkle",
49
+ look: [0, 0],
50
+ bl: [-2, 0],
51
+ br: [-2, 0],
52
+ mouth: "grin",
53
+ fx: ["blush"],
54
+ },
55
+ thinking: {
56
+ eye: "open",
57
+ look: [2.2, -2.4],
58
+ bl: [-0.5, 0],
59
+ br: [-4, -8],
60
+ mouth: "flatR",
61
+ fx: ["dots"],
62
+ },
63
+ concerned: {
64
+ eye: "open",
65
+ look: [0, 0.6],
66
+ bl: [-1, -13],
67
+ br: [-1, -13],
68
+ mouth: "wobble",
69
+ fx: [],
70
+ },
71
+ apologetic: {
72
+ eye: "soft",
73
+ look: [0, 0],
74
+ bl: [-1, -20],
75
+ br: [-1, -20],
76
+ mouth: "wobble",
77
+ fx: ["sweat", "blush"],
78
+ },
79
+ surprised: {
80
+ eye: "wide",
81
+ look: [0, 0],
82
+ bl: [-4, -4],
83
+ br: [-4, 4],
84
+ mouth: "o",
85
+ fx: ["burst"],
86
+ },
87
+ proud: {
88
+ eye: "arc",
89
+ look: [0, 0],
90
+ bl: [-3, 0],
91
+ br: [-3, 0],
92
+ mouth: "smirk",
93
+ fx: ["sparkle"],
94
+ },
95
+ amused: {
96
+ eye: "xd",
97
+ look: [0, 0],
98
+ bl: [-2, 0],
99
+ br: [-2, 0],
100
+ mouth: "grin",
101
+ fx: ["tear"],
102
+ },
103
+ curious: {
104
+ eye: "open",
105
+ look: [2.6, -0.4],
106
+ bl: [-4, -6],
107
+ br: [0, 0],
108
+ mouth: "tilt",
109
+ fx: [],
110
+ big: 1,
111
+ },
112
+ sympathetic: {
113
+ eye: "soft",
114
+ look: [0, 1],
115
+ bl: [-1, -10],
116
+ br: [-1, -10],
117
+ mouth: "smileS",
118
+ fx: ["blush"],
119
+ },
120
+ playful: {
121
+ eye: "wink",
122
+ look: [0, 0],
123
+ bl: [-3, -2],
124
+ br: [1, 0],
125
+ mouth: "tongue",
126
+ fx: ["blush"],
127
+ },
128
+ determined: {
129
+ eye: "narrow",
130
+ look: [0, 0],
131
+ bl: [1, 15],
132
+ br: [1, 15],
133
+ mouth: "firm",
134
+ fx: [],
135
+ },
136
+ sleepy: {
137
+ eye: "closed",
138
+ look: [0, 0],
139
+ bl: [2, 6],
140
+ br: [2, 6],
141
+ mouth: "yawn",
142
+ fx: [],
143
+ },
144
+ };
145
+
146
+ const f1 = (n: number): number => +n.toFixed(2);
147
+
148
+ /** Geometry of the chibi face (the lab's direction A). */
149
+ const FACE = {
150
+ ex: [39, 61] as const,
151
+ ey: 52,
152
+ rx: 5.2,
153
+ ry: 6.4,
154
+ sw: 2.5,
155
+ msw: 2.2,
156
+ bl: 8.6,
157
+ bw: 2.2,
158
+ bdy: -9.8,
159
+ mx: 50,
160
+ my: 64,
161
+ ms: 1,
162
+ bx: 6.5,
163
+ by: 8,
164
+ br: 4,
165
+ fx: [76, 40] as const,
166
+ fs: 1,
167
+ hw: 28,
168
+ by0: 42,
169
+ };
170
+ const OVERLAY = {
171
+ badge: [17, 19] as const,
172
+ badgeR: 9.5,
173
+ orbit: [50, 50, 45] as const,
174
+ zz: [73, 28] as const,
175
+ };
176
+
177
+ /** Where the animation turns each part; characters.css carries them. */
178
+ export const PIVOTS = {
179
+ eyes: `50px ${FACE.ey}px`,
180
+ badge: `${OVERLAY.badge[0]}px ${OVERLAY.badge[1]}px`,
181
+ orbit: `${OVERLAY.orbit[0]}px ${OVERLAY.orbit[1]}px`,
182
+ character: "50px 94px",
183
+ } as const;
184
+
185
+ export const INK = "#2b2440";
186
+ const TONGUE = "#ef7b8a";
187
+
188
+ function eyeShape(shape: EyeShape, side: 0 | 1, ink: string): string {
189
+ const { rx, ry, sw } = FACE;
190
+ const open = (k = 1) =>
191
+ `<ellipse rx="${f1(rx * k)}" ry="${f1(ry * k)}" fill="${ink}"/><circle cx="${f1(-rx * 0.3)}" cy="${f1(-ry * 0.36)}" r="${f1(rx * 0.4)}" fill="#fff"/><circle cx="${f1(rx * 0.38)}" cy="${f1(ry * 0.36)}" r="${f1(rx * 0.18)}" fill="#fff"/>`;
192
+ const arc = `<path d="M${f1(-rx * 1.05)} ${f1(ry * 0.35)}Q0 ${f1(-ry * 1.15)} ${f1(rx * 1.05)} ${f1(ry * 0.35)}" fill="none" stroke="${ink}" stroke-width="${sw}" stroke-linecap="round"/>`;
193
+ switch (shape) {
194
+ case "open":
195
+ return open();
196
+ case "sparkle":
197
+ return `${open(1.1)}<circle cx="${f1(-rx * 0.1)}" cy="${f1(ry * 0.5)}" r="${f1(rx * 0.14)}" fill="#fff" opacity=".8"/>`;
198
+ case "wide":
199
+ return `<circle r="${f1(ry * 1.12)}" fill="#fff" stroke="${ink}" stroke-width="${f1(sw * 0.7)}"/><circle r="${f1(ry * 0.5)}" fill="${ink}"/>`;
200
+ case "arc":
201
+ return arc;
202
+ case "closed":
203
+ return `<path d="M${f1(-rx)} ${f1(-ry * 0.1)}Q0 ${f1(ry * 0.85)} ${f1(rx)} ${f1(-ry * 0.1)}" fill="none" stroke="${ink}" stroke-width="${sw}" stroke-linecap="round"/>`;
204
+ case "soft":
205
+ return `<path d="M${f1(-rx)} ${f1(-ry * 0.25)}H${f1(rx)}A${f1(rx)} ${f1(ry * 1.05)} 0 0 1 ${f1(-rx)} ${f1(-ry * 0.25)}Z" fill="${ink}"/><circle cx="${f1(-rx * 0.25)}" cy="${f1(ry * 0.25)}" r="${f1(rx * 0.34)}" fill="#fff"/>`;
206
+ case "narrow":
207
+ return `<ellipse rx="${f1(rx * 0.98)}" ry="${f1(ry * 0.6)}" fill="${ink}"/><circle cx="${f1(-rx * 0.3)}" cy="${f1(-ry * 0.15)}" r="${f1(rx * 0.26)}" fill="#fff"/>`;
208
+ case "xd": {
209
+ const s = side ? -1 : 1;
210
+ return `<path d="M${f1(-s * rx * 0.85)} ${f1(-ry * 0.75)}L${f1(s * rx * 0.6)} 0L${f1(-s * rx * 0.85)} ${f1(ry * 0.75)}" fill="none" stroke="${ink}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/>`;
211
+ }
212
+ case "wink":
213
+ return side ? arc : open();
214
+ }
215
+ }
216
+
217
+ function mouthShape(shape: MouthShape, ink: string): string {
218
+ const stroke = `fill="none" stroke="${ink}" stroke-width="${FACE.msw}" stroke-linecap="round" stroke-linejoin="round"`;
219
+ switch (shape) {
220
+ case "smile":
221
+ return `<path d="M-6 0Q0 5 6 0" ${stroke}/>`;
222
+ case "smileS":
223
+ return `<path d="M-4.2 0.4Q0 3.8 4.2 0.4" ${stroke}/>`;
224
+ case "grin":
225
+ return `<path d="M-7 -1Q0 3 7 -1Q5.5 8 0 8Q-5.5 8 -7 -1Z" fill="${ink}"/><ellipse cy="6.3" rx="2.9" ry="1.5" fill="${TONGUE}"/>`;
226
+ case "o":
227
+ return `<ellipse cy="3" rx="2.9" ry="3.5" fill="${ink}"/>`;
228
+ case "flatR":
229
+ return `<path d="M-3.5 2.4L4.5 0.4" ${stroke}/>`;
230
+ case "firm":
231
+ return `<path d="M-5 1.2H4.2L5.6 -0.4" ${stroke}/>`;
232
+ case "wobble":
233
+ return `<path d="M-6 3.2q1.5 -3 3 0t3 0t3 0t3 0" ${stroke}/>`;
234
+ case "smirk":
235
+ return `<path d="M-6 2.4Q0 4 6.4 -1.6" ${stroke}/>`;
236
+ case "tongue":
237
+ return `<path d="M-6.5 -0.5Q0 4.5 6.5 -0.5Z" fill="${ink}"/><path d="M-2.6 2.2Q0 10 2.8 2.4Z" fill="${TONGUE}"/>`;
238
+ case "yawn":
239
+ return `<ellipse cy="3.4" rx="3.4" ry="4.2" fill="${ink}"/><ellipse cy="5.6" rx="2" ry="1.4" fill="${TONGUE}"/>`;
240
+ case "tilt":
241
+ return `<ellipse cx="3" cy="2" rx="1.9" ry="2.4" fill="${ink}"/>`;
242
+ }
243
+ }
244
+
245
+ export interface FaceColors {
246
+ /** Eyes and mouth. */
247
+ ink: string;
248
+ /** Brows (hair mixed toward ink). */
249
+ brow: string;
250
+ /** The thinking dots and the burst (primary mixed toward ink). */
251
+ dot: string;
252
+ }
253
+
254
+ export interface Face {
255
+ eyes: string;
256
+ brows: string;
257
+ mouth: string;
258
+ fx: string;
259
+ }
260
+
261
+ const star = (x: number, y: number, r: number, color: string): string =>
262
+ `<path d="M${x} ${y - r}Q${x + r * 0.22} ${y - r * 0.22} ${x + r} ${y}Q${x + r * 0.22} ${y + r * 0.22} ${x} ${y + r}Q${x - r * 0.22} ${y + r * 0.22} ${x - r} ${y}Q${x - r * 0.22} ${y - r * 0.22} ${x} ${y - r}Z" fill="${color}"/>`;
263
+ const drop = (x: number, y: number, s: number, color: string): string =>
264
+ `<path d="M${x} ${y - 3.6 * s}Q${x + 3 * s} ${y + 0.2 * s} ${x} ${y + 2.8 * s}Q${x - 3 * s} ${y + 0.2 * s} ${x} ${y - 3.6 * s}Z" fill="${color}"/>`;
265
+
266
+ export function faceParts(colors: FaceColors, expression: Expression): Face {
267
+ const c = FACE;
268
+ const row = EXPRESSION_ROWS[expression];
269
+ const [lx, ly] = row.look;
270
+ const eye = (cx: number, side: 0 | 1) => {
271
+ const big = row.big && !side;
272
+ return `<g transform="translate(${f1(cx + lx)} ${f1(c.ey + ly)})${big ? " scale(1.12)" : ""}">${eyeShape(row.eye, side, colors.ink)}</g>`;
273
+ };
274
+ const eyes = `<g class="eyes">${eye(c.ex[0], 0)}${eye(c.ex[1], 1)}</g>`;
275
+ const brow = (
276
+ cx: number,
277
+ [dy, tilt]: readonly [number, number],
278
+ side: 0 | 1,
279
+ ) =>
280
+ `<path d="M${-c.bl / 2} 0H${c.bl / 2}" transform="translate(${cx} ${f1(c.ey + c.bdy + dy)}) rotate(${side ? -tilt : tilt})" stroke="${colors.brow}" stroke-width="${c.bw}" stroke-linecap="round" fill="none"/>`;
281
+ const brows = `<g class="brows">${brow(c.ex[0], row.bl, 0)}${brow(c.ex[1], row.br, 1)}</g>`;
282
+ const mouth = `<g class="mouth" transform="translate(${c.mx} ${c.my}) scale(${c.ms})">${mouthShape(row.mouth, colors.ink)}</g>`;
283
+ const [fx, fy] = c.fx;
284
+ const blush = (cx: number) =>
285
+ `<ellipse cx="${cx}" cy="${c.ey + c.by}" rx="${c.br}" ry="${f1(c.br * 0.62)}" fill="#ff7a8e" opacity=".5"/>`;
286
+ const extra: Record<Extra, string> = {
287
+ blush: blush(c.ex[0] - c.bx) + blush(c.ex[1] + c.bx),
288
+ sweat: drop(fx, fy, c.fs, "#7fd0ff"),
289
+ tear: drop(c.ex[1] + c.rx + 1.5, c.ey + c.ry * 0.9, c.fs * 0.8, "#7fd0ff"),
290
+ sparkle:
291
+ star(fx, fy - 1, 4.4 * c.fs, "#ffd84a") +
292
+ star(fx + 5 * c.fs, fy + 5 * c.fs, 2 * c.fs, "#fff1a8"),
293
+ dots: [1, 1.5, 2.1]
294
+ .map(
295
+ (r, i) =>
296
+ `<circle cx="${f1(fx - 1 + i * 4.2 * c.fs)}" cy="${f1(fy - 2 - i * 3.6 * c.fs)}" r="${f1(r * c.fs)}" fill="${colors.dot}"/>`,
297
+ )
298
+ .join(""),
299
+ burst: [-1, 1]
300
+ .map(
301
+ (s) =>
302
+ `<path d="M${50 + s * (c.hw + 1)} ${c.by0 - 3}l${s * 4.5} -3M${50 + s * (c.hw + 2)} ${c.by0 + 3}l${s * 5.5} 0M${50 + s * (c.hw + 1)} ${c.by0 + 9}l${s * 4.5} 3" stroke="${colors.dot}" stroke-width="1.7" stroke-linecap="round"/>`,
303
+ )
304
+ .join(""),
305
+ };
306
+ return {
307
+ eyes,
308
+ brows,
309
+ mouth,
310
+ fx: `<g class="fx">${row.fx.map((name) => extra[name]).join("")}</g>`,
311
+ };
312
+ }
313
+
314
+ /** Badge colors; the "!" "✓" and "×" are drawn paths, not text. */
315
+ export function overlays(
316
+ primary: string,
317
+ zzColor: string,
318
+ only: AvatarState | null,
319
+ ): string {
320
+ const [bx, by] = OVERLAY.badge;
321
+ const r = OVERLAY.badgeR;
322
+ const k = r / 8;
323
+ const disc = (cls: string, color: string, inner: string) =>
324
+ `<g class="ov ${cls}"><circle cx="${bx}" cy="${by}" r="${r}" fill="${color}" stroke="#fff" stroke-width="1.6"/>${inner}</g>`;
325
+ const wait = disc(
326
+ "o-wait",
327
+ "#f2a81d",
328
+ `<path d="M${bx} ${by - 4.2 * k}V${by + 0.6 * k}" stroke="#fff" stroke-width="${f1(2.2 * k)}" stroke-linecap="round"/><circle cx="${bx}" cy="${f1(by + 3.6 * k)}" r="${f1(1.25 * k)}" fill="#fff"/>`,
329
+ );
330
+ const done = disc(
331
+ "o-done",
332
+ "#2fb36b",
333
+ `<path d="M${f1(bx - 3.6 * k)} ${f1(by + 0.2 * k)}L${f1(bx - 1 * k)} ${f1(by + 3 * k)}L${f1(bx + 3.8 * k)} ${f1(by - 2.8 * k)}" fill="none" stroke="#fff" stroke-width="${f1(2.2 * k)}" stroke-linecap="round" stroke-linejoin="round"/>`,
334
+ );
335
+ const err = disc(
336
+ "o-err",
337
+ "#e5484d",
338
+ `<path d="M${f1(bx - 3 * k)} ${f1(by - 3 * k)}L${f1(bx + 3 * k)} ${f1(by + 3 * k)}M${f1(bx + 3 * k)} ${f1(by - 3 * k)}L${f1(bx - 3 * k)} ${f1(by + 3 * k)}" stroke="#fff" stroke-width="${f1(2.2 * k)}" stroke-linecap="round"/>`,
339
+ );
340
+ const [ox, oy, orad] = OVERLAY.orbit;
341
+ const orbit = `<g class="ov o-think"><circle cx="${ox}" cy="${f1(oy - orad)}" r="3.3" fill="${primary}" stroke="#fff" stroke-width="1.3"/></g>`;
342
+ const [zx, zy] = OVERLAY.zz;
343
+ const z = (x: number, y: number, w: number, sw: number) =>
344
+ `<path d="M${x} ${y}H${x + w}L${x} ${y + w * 1.15}H${x + w}" fill="none" stroke="${zzColor}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/>`;
345
+ const zz = `<g class="ov o-zz">${z(zx, zy, 7, 2.2)}${z(zx + 8, zy - 9, 5, 1.8)}</g>`;
346
+ const pick: Partial<Record<AvatarState, string>> = {
347
+ thinking: orbit,
348
+ waiting: wait,
349
+ done,
350
+ error: err,
351
+ sleepy: zz,
352
+ };
353
+ return `<g class="fg">${only ? (pick[only] ?? "") : orbit + wait + done + err + zz}</g>`;
354
+ }
@@ -0,0 +1,56 @@
1
+ import type { AccessoryId, BeardId, HairId } from "./catalog.ts";
2
+ import type { Part, Tones } from "./colors.ts";
3
+
4
+ /** Hair is two layers: behind the head, and the fringe over the forehead. */
5
+ export interface HairLayers {
6
+ back: string;
7
+ front: string;
8
+ }
9
+
10
+ const FRINGE = "M24 47C23 29 77 29 76 47C70 39 61 35 50 35C39 35 30 39 24 47Z";
11
+ export const HAIR: Record<HairId, (v: Tones) => HairLayers> = {
12
+ none: () => ({ back: "", front: "" }),
13
+ short: (v) => ({ back: "", front: `<path d="${FRINGE}" fill="${v.hr}"/>` }),
14
+ bob: (v) => ({
15
+ back: `<path d="M19 46C17 66 23 77 33 77H67C77 77 83 66 81 46C78 26 22 26 19 46Z" fill="${v.hr}"/>`,
16
+ front: `<path d="M23 50C22 28 78 28 77 50C68 38 58 36 50 41C42 36 32 38 23 50Z" fill="${v.hr}"/>`,
17
+ }),
18
+ long: (v) => ({
19
+ back: `<path d="M18 48C14 72 20 92 34 92H66C80 92 86 72 82 48C80 26 20 26 18 48Z" fill="${v.hr}"/>`,
20
+ front: `<path d="M23 52C22 28 78 28 77 52C70 38 62 36 56 38Q44 44 36 46Q28 48 23 52Z" fill="${v.hr}"/>`,
21
+ }),
22
+ bun: (v) => ({
23
+ back: `<circle cx="50" cy="21" r="9.5" fill="${v.hr}"/>`,
24
+ front: `<path d="${FRINGE}" fill="${v.hr}"/>`,
25
+ }),
26
+ curly: (v) => ({
27
+ back: "",
28
+ front: `<g fill="${v.hr}"><circle cx="30" cy="38" r="9"/><circle cx="41" cy="30" r="9.5"/><circle cx="55" cy="29" r="9.5"/><circle cx="68" cy="35" r="9"/><circle cx="23" cy="50" r="6"/><circle cx="77" cy="50" r="6"/></g>`,
29
+ }),
30
+ ponytail: (v) => ({
31
+ back: `<path d="M70 40C92 42 94 66 82 76C86 62 80 54 68 52Z" fill="${v.hr}"/>`,
32
+ front: `<path d="${FRINGE}" fill="${v.hr}"/>`,
33
+ }),
34
+ braid: (v) => ({
35
+ back: `<path d="M24 46C14 60 20 76 28 90L34 88C28 76 28 62 32 52Z" fill="${v.hr}"/><circle cx="28" cy="90" r="3.6" fill="${v.p}"/>`,
36
+ front: `<path d="${FRINGE}" fill="${v.hr}"/>`,
37
+ }),
38
+ };
39
+
40
+ export const BEARDS: Record<BeardId, Part> = {
41
+ full: (v) =>
42
+ `<path d="M23.4 47C22 59 36 59 50 59.4C64 59 78 59 76.6 47C81 66 66 88 50 91.5C34 88 19 66 23.4 47Z" fill="${v.hr}"/><path d="M50 62V88" stroke="${v.hd}" stroke-width="1.2" opacity=".5"/><path d="M36 60Q50 66 64 60" fill="none" stroke="${v.hd}" stroke-width="1.2" opacity=".5"/>`,
43
+ mustache: (v) =>
44
+ `<path d="M38 60C42 54 48 58 50 60C52 58 58 54 62 60C58 62 53 60 50 61.4C47 60 42 62 38 60Z" fill="${v.hr}"/>`,
45
+ goatee: (v) =>
46
+ `<path d="M44 68C46 66 54 66 56 68C56 76 52 78 50 78C48 78 44 76 44 68Z" fill="${v.hr}"/>`,
47
+ };
48
+
49
+ export const ACCESSORIES: Record<AccessoryId, Part> = {
50
+ glasses: (v) =>
51
+ `<g fill="none" stroke="${v.ink}" stroke-width="1.7"><circle cx="39" cy="52" r="8.4"/><circle cx="61" cy="52" r="8.4"/><path d="M47.4 51Q50 49 52.6 51M30.6 50L25 47M69.4 50L75 47"/></g><path d="M33 47L37 45M56 47L60 45" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity=".7"/>`,
52
+ freckles: (v) =>
53
+ `<g fill="${v.kd}"><circle cx="32.5" cy="60" r="1"/><circle cx="36" cy="62" r="1"/><circle cx="39.5" cy="60.4" r="1"/><circle cx="60.5" cy="60.4" r="1"/><circle cx="64" cy="62" r="1"/><circle cx="67.5" cy="60" r="1"/></g>`,
54
+ scar: (v) =>
55
+ `<path d="M64 44L70 57M62.4 51H67M65 47.6H69" stroke="${v.kd}" stroke-width="1.5" stroke-linecap="round" opacity=".85"/><path d="M64 44L70 57" stroke="#e9a0a0" stroke-width=".8" opacity=".7"/>`,
56
+ };
@@ -0,0 +1,57 @@
1
+ import type { HatId } from "./catalog.ts";
2
+ import type { Part } from "./colors.ts";
3
+ import { star5 } from "./parts-outfits.ts";
4
+
5
+ const STEEL = "#c7cfdd";
6
+ const STEELD = "#9aa5ba";
7
+
8
+ export const HATS: Record<HatId, Part> = {
9
+ none: () => "",
10
+ wizard: (v) =>
11
+ `<path d="M30 31C32 20 42 11 60 3C57 13 62 22 70 31Z" fill="${v.p}"/><path d="M30 31C32 20 42 11 60 3" fill="none" stroke="#fff" stroke-width="1.6" opacity=".35"/><ellipse cx="50" cy="30" rx="37" ry="7.6" fill="${v.pd}"/><path d="M30.6 27.6Q50 33 69.4 27.6L70 31Q50 36.5 30 31Z" fill="${v.t}"/>` +
12
+ star5(49, 16, v.t),
13
+ helm: (v) =>
14
+ `<path d="M50 24C42 6 66 -1 80 12C68 9 60 15 57 25Z" fill="${v.p}"/><path d="M23 47C21 23 79 23 77 47L72 39Q50 31 28 39Z" fill="${STEEL}"/><path d="M26 36Q50 24 74 36" fill="none" stroke="#fff" stroke-width="2" opacity=".55"/><path d="M28 39Q50 31 72 39" fill="none" stroke="${v.t}" stroke-width="3"/><circle cx="50" cy="26.5" r="3.4" fill="${v.t}"/>`,
15
+ mortar: (v) =>
16
+ `<path d="M30 29V37Q50 43 70 37V29Z" fill="${v.pd}"/><path d="M13 24L50 12L87 24L50 36Z" fill="${v.p}"/><path d="M13 24L50 36L87 24" fill="none" stroke="${v.pd}" stroke-width="1.6"/><path d="M50 24L82 26V38" fill="none" stroke="${v.t}" stroke-width="1.8" stroke-linejoin="round"/><rect x="79.6" y="37" width="4.8" height="7" rx="2" fill="${v.t}"/><circle cx="50" cy="24" r="2.4" fill="${v.t}"/>`,
17
+ goggcap: (v) =>
18
+ `<path d="M23 41C23 21 77 21 77 41Q50 33 23 41Z" fill="${v.p}"/><path d="M54 36Q78 32 85 41Q70 43 51 40Z" fill="${v.pd}"/><path d="M23 37Q50 28 77 37V40Q50 31 23 40Z" fill="${v.pd}"/><circle cx="40" cy="27.5" r="7" fill="#bfeaf6" stroke="${v.t}" stroke-width="2.6"/><circle cx="60" cy="27.5" r="7" fill="#bfeaf6" stroke="${v.t}" stroke-width="2.6"/><path d="M36.5 25Q39 23 42 24.5M56.5 25Q59 23 62 24.5" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/>`,
19
+ hood: (v) =>
20
+ `<path d="M21 53C20 26 80 26 79 53C76 40 66 33.5 50 33.5C34 33.5 24 40 21 53Z" fill="${v.p}"/><path d="M21 53C20 26 80 26 79 53" fill="none" stroke="${v.pd}" stroke-width="2"/><path d="M70 33Q90 24 93 8Q76 12 66 30Z" fill="${v.t}"/><path d="M72 29Q84 20 91 10" fill="none" stroke="${v.td}" stroke-width="1.2"/>`,
21
+ beret: (v) =>
22
+ `<path d="M19 39C16 14 58 4 77 19C86 27 83 40 70 38Q46 32 24 43Z" fill="${v.p}"/><path d="M22 40Q46 31 72 38" fill="none" stroke="${v.t}" stroke-width="2.6" stroke-linecap="round"/><path d="M66 20Q82 10 91 4Q90 18 76 28Z" fill="${v.s}" stroke="${v.t}" stroke-width="1.2"/><path d="M68 22Q80 14 90 6" fill="none" stroke="${v.td}" stroke-width="1"/>`,
23
+ goggles: (v) =>
24
+ `<path d="M22 41Q50 28 78 41V45Q50 33 22 45Z" fill="#5a4636"/><circle cx="39" cy="32.4" r="8" fill="#bfeaf6" stroke="${v.t}" stroke-width="3"/><circle cx="61" cy="32.4" r="8" fill="#bfeaf6" stroke="${v.t}" stroke-width="3"/><path d="M34.5 29.5Q38 26.5 42 28.5M56.5 29.5Q60 26.5 64 28.5" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/><rect x="45" y="30" width="10" height="3.4" fill="${v.t}"/>`,
25
+ coif: (v) =>
26
+ `<path d="M19 55C17 22 83 22 81 55C77 38 65 32 50 32C35 32 23 38 19 55Z" fill="#fff" stroke="#d9deeb" stroke-width="1.2"/><path d="M24 44Q50 30 76 44" fill="none" stroke="${v.t}" stroke-width="2.4"/><path d="M48.4 18H51.6V23H56.4V26H51.6V32H48.4V26H43.6V23H48.4Z" fill="${v.p}" transform="translate(0 4)"/>`,
27
+ tallcap: (v) =>
28
+ `<path d="M31 33V12Q50 6 69 12V33Z" fill="${v.pd}"/><ellipse cx="50" cy="12" rx="19" ry="4.4" fill="${v.p}"/><ellipse cx="50" cy="33" rx="30" ry="5.4" fill="${v.pd}"/><path d="M31 27H69V31H31Z" fill="${v.t}"/><rect x="46" y="25.4" width="8" height="7" rx="1.4" fill="none" stroke="${v.td}" stroke-width="1.6"/>`,
29
+ tricorn: (v) =>
30
+ `<path d="M10 36L33 16Q50 10 67 16L90 36Q50 26 10 36Z" fill="${v.pd}"/><path d="M10 36Q50 26 90 36" fill="none" stroke="${v.t}" stroke-width="2.8"/><circle cx="50" cy="22" r="3.6" fill="${v.p}" stroke="${v.t}" stroke-width="1.4"/>`,
31
+ turban: (v) =>
32
+ `<path d="M21 46C19 18 81 18 79 46C70 34 30 34 21 46Z" fill="${v.pl}"/><path d="M26 38Q50 22 74 38M23 44Q50 28 77 44" fill="none" stroke="${v.p}" stroke-width="2.4"/><path d="M50 22l2.6 4 4.6.6-3.4 3.2.9 4.6-4.7-2.4-4.7 2.4.9-4.6-3.4-3.2 4.6-.6z" fill="${v.t}" stroke="${v.td}" stroke-width="1"/>`,
33
+ softcap: (v) =>
34
+ `<path d="M23 41C20 17 76 12 78 38Q50 32 23 41Z" fill="${v.pl}"/><path d="M23 41Q50 32 78 38" fill="none" stroke="${v.p}" stroke-width="3"/><path d="M68 28Q80 20 88 6Q90 22 78 34Z" fill="${v.s}" stroke="${v.td}" stroke-width="1.2"/><path d="M70 28Q80 20 87 8" fill="none" stroke="${v.td}" stroke-width="1"/>`,
35
+ hardhat: (v) =>
36
+ `<path d="M25 41C24 11 76 11 75 41Z" fill="${v.t}"/><rect x="21" y="37" width="58" height="6.4" rx="3.2" fill="${v.td}"/><path d="M50 15V37M40 18V37M60 18V37" stroke="${v.td}" stroke-width="2" opacity=".5"/><circle cx="50" cy="24" r="5" fill="#fff" stroke="${v.ink}" stroke-width="1.6"/><circle cx="50" cy="24" r="2" fill="#ffe68a"/>`,
37
+ wreath: (v) =>
38
+ `<g fill="#4fae6c"><ellipse cx="28" cy="40" rx="6" ry="3.2" transform="rotate(60 28 40)"/><ellipse cx="31" cy="31" rx="6" ry="3.2" transform="rotate(30 31 31)"/><ellipse cx="40" cy="26" rx="6" ry="3.2" transform="rotate(8 40 26)"/><ellipse cx="50" cy="24" rx="6" ry="3.2"/><ellipse cx="60" cy="26" rx="6" ry="3.2" transform="rotate(-8 60 26)"/><ellipse cx="69" cy="31" rx="6" ry="3.2" transform="rotate(-30 69 31)"/><ellipse cx="72" cy="40" rx="6" ry="3.2" transform="rotate(-60 72 40)"/></g><g fill="#2f8a50"><ellipse cx="36" cy="28" rx="5" ry="2.6" transform="rotate(-20 36 28)"/><ellipse cx="64" cy="28" rx="5" ry="2.6" transform="rotate(20 64 28)"/></g><circle cx="50" cy="24" r="2.6" fill="${v.p}"/><circle cx="31" cy="32" r="2.2" fill="${v.t}"/><circle cx="69" cy="32" r="2.2" fill="${v.t}"/>`,
39
+ toque: (v) =>
40
+ `<path d="M31 37V30C22 28 21 12 36 12C38 5 62 5 64 12C79 12 78 28 69 30V37Z" fill="#fff" stroke="#d9deeb" stroke-width="1.2"/><rect x="31" y="32" width="38" height="6" rx="2" fill="${v.s}" stroke="#d9deeb" stroke-width="1"/><path d="M42 14Q43 24 41 30M58 14Q57 24 59 30" fill="none" stroke="#e1e5f0" stroke-width="1.4"/>`,
41
+ kettle: (v) =>
42
+ `<path d="M28 33C28 8 72 8 72 33Z" fill="${STEEL}"/><ellipse cx="50" cy="33" rx="38" ry="6.4" fill="${STEELD}"/><path d="M34 22Q50 12 66 22" fill="none" stroke="#fff" stroke-width="2" opacity=".5"/><circle cx="50" cy="17" r="2.4" fill="${v.t}"/><path d="M28 27H72" stroke="${v.p}" stroke-width="3"/>`,
43
+ cavalier: (v) =>
44
+ `<path d="M62 20Q80 4 92 6Q88 24 70 30Z" fill="${v.s}" stroke="${v.t}" stroke-width="1.4"/><path d="M30 33C30 14 70 14 70 33Z" fill="${v.p}"/><path d="M10 35Q50 18 90 35Q50 42 10 35Z" fill="${v.pd}"/><path d="M30 31Q50 36 70 31" fill="none" stroke="${v.t}" stroke-width="2.6"/>`,
45
+ cowl: (v) =>
46
+ `<path d="M19 54C16 30 38 14 58 3C58 18 82 32 81 54C75 41 63 34 50 34C37 34 25 41 19 54Z" fill="${v.pd}"/><path d="M26 44Q50 30 74 44" fill="none" stroke="${v.ink}" stroke-width="2" opacity=".3"/>`,
47
+ headband: (v) =>
48
+ `<path d="M22 39Q50 29 78 39V44.5Q50 34.5 22 44.5Z" fill="${v.p}"/><path d="M76 40Q86 38 90 46M76 42Q88 46 88 56" fill="none" stroke="${v.p}" stroke-width="3.2" stroke-linecap="round"/><circle cx="50" cy="31.5" r="2.4" fill="${v.t}"/>`,
49
+ fcap: (v) =>
50
+ `<path d="M23 42C22 20 78 18 77 40Q50 31 23 42Z" fill="${v.pd}"/><path d="M23 42Q50 31 77 40" fill="none" stroke="${v.t}" stroke-width="2.4"/><path d="M60 26Q78 22 94 8Q86 28 70 34Z" fill="${v.s}" stroke="${v.td}" stroke-width="1.2"/><path d="M62 28Q80 22 92 10" fill="none" stroke="${v.td}" stroke-width="1"/>`,
51
+ sailor: (v) =>
52
+ `<path d="M26 38C26 18 74 18 74 38Z" fill="#fff" stroke="#d9deeb" stroke-width="1.2"/><rect x="25" y="34" width="50" height="7" rx="3.4" fill="${v.pd}"/><path d="M50 14V22" stroke="${v.p}" stroke-width="3.4" stroke-linecap="round"/><path d="M26 28H74" stroke="${v.p}" stroke-width="2" opacity=".6"/>`,
53
+ jester: (v) =>
54
+ `<path d="M26 40C14 30 12 14 18 6C28 16 32 26 42 33Z" fill="${v.p}"/><path d="M74 40C86 30 88 14 82 6C72 16 68 26 58 33Z" fill="${v.s}" stroke="${v.sd}" stroke-width="1"/><path d="M26 40C26 28 74 28 74 40Q50 34 26 40Z" fill="${v.pd}"/><circle cx="18" cy="6" r="4" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><circle cx="82" cy="6" r="4" fill="${v.t}" stroke="${v.td}" stroke-width="1"/><circle cx="50" cy="31" r="3.4" fill="${v.t}"/>`,
55
+ winghelm: (v) =>
56
+ `<path d="M24 36Q6 31 3 14Q18 14 30 27Z" fill="${v.s}" stroke="${v.t}" stroke-width="1.6"/><path d="M76 36Q94 31 97 14Q82 14 70 27Z" fill="${v.s}" stroke="${v.t}" stroke-width="1.6"/><path d="M23 47C21 23 79 23 77 47L72 39Q50 31 28 39Z" fill="#e6eaf3"/><path d="M28 39Q50 31 72 39" fill="none" stroke="${v.t}" stroke-width="3"/><path d="M26 36Q50 24 74 36" fill="none" stroke="#fff" stroke-width="2" opacity=".7"/><path d="M50 26l2.4 4.4 4.8.8-3.4 3.4.8 4.8-4.6-2.2-4.6 2.2.8-4.8-3.4-3.4 4.8-.8z" fill="${v.t}" transform="translate(0 -1)"/>`,
57
+ };