feat(avatar): add the oo1 avatar value for customizable Oo looks (#135)
A bot's look is stored in its `color` string, which today only holds a colour, an optional classic shape index or an uploaded picture. The customizable avatars (species, eye styling, accessories, temperament) need a richer value that still travels everywhere `color` already goes: bot rows, group members, mentions and the inbox. - `@rakazo/core` gains `oo-avatar`: an `oo1:` key=value codec that writes only non-default fields, reads every legacy value unchanged, rejects malformed values, and exposes `ooAvatarColor` for plain CSS consumers. - The rig's eye anchors keep the Oo trademark (big eye = small eye × φ), and lenses scale with their eye so glasses stay asymmetric; the shared asymmetry ratio now comes from core. - The API rejects malformed `oo1:` values on bot create and update. - `parseBotAvatar` reads through the codec, so an `oo1:` value already renders with its colour and shape before the new renderer lands. - The agent cursor and the mobile avatar used the raw value as a CSS colour and broke on `#hex::shape_N`; they now use the decoded colour. Claude-Session: https://claude.ai/code/session_01EKuME5CgL1ddGGN7LGvtBj Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ad054a7586
commit
d6dd2bef5e
@ -112,6 +112,7 @@ import {
|
|||||||
expandSkillReferencesInPrompt,
|
expandSkillReferencesInPrompt,
|
||||||
hasMixedOneShotSchedule,
|
hasMixedOneShotSchedule,
|
||||||
isOneShotRoutineCrons,
|
isOneShotRoutineCrons,
|
||||||
|
isValidAvatarValue,
|
||||||
nextCronDateAcrossStrict,
|
nextCronDateAcrossStrict,
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
import type { PrismaClient, ThreadEvents } from "@rakazo/db";
|
||||||
@ -1199,6 +1200,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
create: authed.bots.create.handler(async ({ context, input }) => {
|
create: authed.bots.create.handler(async ({ context, input }) => {
|
||||||
assertBuiltinToolNames(input.allowedBuiltinTools);
|
assertBuiltinToolNames(input.allowedBuiltinTools);
|
||||||
|
assertAvatarValue(input.color);
|
||||||
try {
|
try {
|
||||||
return await repos.createBot(context.actor, input);
|
return await repos.createBot(context.actor, input);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -1353,6 +1355,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
update: authed.bots.update.handler(async ({ context, input }) => {
|
update: authed.bots.update.handler(async ({ context, input }) => {
|
||||||
const existing = await repos.getBot(context.actor, input.botId);
|
const existing = await repos.getBot(context.actor, input.botId);
|
||||||
assertBuiltinToolNames(input.allowedBuiltinTools);
|
assertBuiltinToolNames(input.allowedBuiltinTools);
|
||||||
|
assertAvatarValue(input.color);
|
||||||
if (input.sectionId) {
|
if (input.sectionId) {
|
||||||
const section = await deps.prisma.botSection.findFirst({
|
const section = await deps.prisma.botSection.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@ -5830,6 +5833,12 @@ function assertBuiltinToolNames(names: readonly string[] | undefined) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertAvatarValue(value: string | undefined) {
|
||||||
|
if (value !== undefined && !isValidAvatarValue(value)) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", { message: "Invalid avatar" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function duplicateBotName(name: string) {
|
function duplicateBotName(name: string) {
|
||||||
return `${name.slice(0, 75)} copy`;
|
return `${name.slice(0, 75)} copy`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import type { AvatarStyle } from "@rakazo/contracts";
|
import type { AvatarStyle } from "@rakazo/contracts";
|
||||||
import { ACTIVE_RUN_STATUSES, avatarIdentitySeed, organicAvatarPath } from "@rakazo/core";
|
import {
|
||||||
|
ACTIVE_RUN_STATUSES,
|
||||||
|
avatarIdentitySeed,
|
||||||
|
DEFAULT_OO_AVATAR,
|
||||||
|
ooAvatarColor,
|
||||||
|
organicAvatarPath,
|
||||||
|
} from "@rakazo/core";
|
||||||
import { memo, useEffect } from "react";
|
import { memo, useEffect } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@ -38,6 +44,7 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
|
const isWorking = ACTIVE_RUN_STATUSES.some((activeStatus) => activeStatus === status);
|
||||||
const { avatarStyle } = useAvatarStyle();
|
const { avatarStyle } = useAvatarStyle();
|
||||||
|
const fill = ooAvatarColor(color) || DEFAULT_OO_AVATAR.color;
|
||||||
const visorW = Math.round(size * 0.68);
|
const visorW = Math.round(size * 0.68);
|
||||||
const visorH = Math.round(size * 0.44);
|
const visorH = Math.round(size * 0.44);
|
||||||
const eyeW = Math.max(3, Math.round(size * 0.11));
|
const eyeW = Math.max(3, Math.round(size * 0.11));
|
||||||
@ -46,14 +53,14 @@ export const BotAvatar = memo(function BotAvatar({
|
|||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size }}>
|
<View style={{ width: size, height: size }}>
|
||||||
{(variant ?? avatarStyle) === "organic" ? (
|
{(variant ?? avatarStyle) === "organic" ? (
|
||||||
<OrganicAvatar color={color} identity={identity} size={size} isWorking={isWorking} />
|
<OrganicAvatar color={fill} identity={identity} size={size} isWorking={isWorking} />
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
borderRadius: size / 2,
|
borderRadius: size / 2,
|
||||||
backgroundColor: color,
|
backgroundColor: fill,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { screenPointInViewer } from "@rakazo/core";
|
import { ooAvatarColor, screenPointInViewer } from "@rakazo/core";
|
||||||
import { Keyboard } from "lucide-react";
|
import { Keyboard } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@ -36,6 +36,7 @@ export function AgentCursor({
|
|||||||
const cursorRef = useRef<HTMLDivElement>(null);
|
const cursorRef = useRef<HTMLDivElement>(null);
|
||||||
const rippleRef = useRef<HTMLSpanElement>(null);
|
const rippleRef = useRef<HTMLSpanElement>(null);
|
||||||
const [typing, setTyping] = useState(false);
|
const [typing, setTyping] = useState(false);
|
||||||
|
const tint = ooAvatarColor(color) || undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const layer = layerRef.current;
|
const layer = layerRef.current;
|
||||||
@ -135,7 +136,7 @@ export function AgentCursor({
|
|||||||
ref={cursorRef}
|
ref={cursorRef}
|
||||||
data-testid="agent-cursor"
|
data-testid="agent-cursor"
|
||||||
className="absolute top-0 left-0 opacity-0 transition-opacity duration-300 will-change-transform"
|
className="absolute top-0 left-0 opacity-0 transition-opacity duration-300 will-change-transform"
|
||||||
style={{ color }}
|
style={{ color: tint }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
ref={rippleRef}
|
ref={rippleRef}
|
||||||
@ -155,7 +156,7 @@ export function AgentCursor({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="absolute top-5 left-3.5 flex max-w-40 items-center gap-1 rounded-full bg-popover px-2 py-0.5 text-ui-label font-medium whitespace-nowrap text-popover-foreground shadow-sm ring-1 ring-border">
|
<span className="absolute top-5 left-3.5 flex max-w-40 items-center gap-1 rounded-full bg-popover px-2 py-0.5 text-ui-label font-medium whitespace-nowrap text-popover-foreground shadow-sm ring-1 ring-border">
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-current" style={{ color }} />
|
<span className="size-1.5 shrink-0 rounded-full bg-current" style={{ color: tint }} />
|
||||||
<span className="truncate">{name}</span>
|
<span className="truncate">{name}</span>
|
||||||
{typing ? <Keyboard strokeWidth={1.8} className="size-3 shrink-0" /> : null}
|
{typing ? <Keyboard strokeWidth={1.8} className="size-3 shrink-0" /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -32,6 +32,7 @@ export * from "./messaging-prompts.js";
|
|||||||
export * from "./model-oauth.js";
|
export * from "./model-oauth.js";
|
||||||
export * from "./model-probe.js";
|
export * from "./model-probe.js";
|
||||||
export * from "./model-providers.js";
|
export * from "./model-providers.js";
|
||||||
|
export * from "./oo-avatar.js";
|
||||||
export * from "./quiet-hours.js";
|
export * from "./quiet-hours.js";
|
||||||
export * from "./response-bytes.js";
|
export * from "./response-bytes.js";
|
||||||
export * from "./run-state.js";
|
export * from "./run-state.js";
|
||||||
|
|||||||
118
packages/core/src/oo-avatar.test.ts
Normal file
118
packages/core/src/oo-avatar.test.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_OO_AVATAR,
|
||||||
|
decodeOoAvatar,
|
||||||
|
encodeOoAvatar,
|
||||||
|
isValidAvatarValue,
|
||||||
|
OO_AVATAR_MAX_LENGTH,
|
||||||
|
OO_EYE_ASYMMETRY_MIN_RATIO,
|
||||||
|
OO_RIG_EYES,
|
||||||
|
type OoAvatar,
|
||||||
|
ooAvatarColor,
|
||||||
|
ooLensRadius,
|
||||||
|
} from "./oo-avatar.js";
|
||||||
|
|
||||||
|
const styled: OoAvatar = {
|
||||||
|
species: "cat",
|
||||||
|
color: "#FF781C",
|
||||||
|
shape: null,
|
||||||
|
arms: "paw",
|
||||||
|
lashes: "full",
|
||||||
|
liner: "wing",
|
||||||
|
eyeshadow: "rose",
|
||||||
|
iris: "green",
|
||||||
|
sparkle: "heart",
|
||||||
|
brows: "fine",
|
||||||
|
cheeks: "freckles",
|
||||||
|
eyewear: "round",
|
||||||
|
head: "chef",
|
||||||
|
neck: "bell",
|
||||||
|
energy: 1.3,
|
||||||
|
playful: 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("oo avatar value", () => {
|
||||||
|
it("round-trips a fully styled look", () => {
|
||||||
|
const value = encodeOoAvatar(styled);
|
||||||
|
expect(value).toBe(
|
||||||
|
"oo1:c=FF781C;sp=cat;ar=paw;la=full;li=wing;es=rose;ir=green;sk=heart;br=fine;ck=freckles;ew=round;hd=chef;nk=bell;en=1.30;pl=0.80",
|
||||||
|
);
|
||||||
|
expect(decodeOoAvatar(value)).toEqual(styled);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes only the colour for the default look", () => {
|
||||||
|
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "#a97efe" })).toBe("oo1:c=A97EFE");
|
||||||
|
expect(encodeOoAvatar({ ...DEFAULT_OO_AVATAR, shape: 3 })).toBe("oo1:c=8B5CF6;sh=3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps temperament and falls back to the default colour when encoding", () => {
|
||||||
|
const value = encodeOoAvatar({ ...DEFAULT_OO_AVATAR, color: "violet", energy: 9, playful: -1 });
|
||||||
|
expect(value).toBe("oo1:c=8B5CF6;en=1.60;pl=0.00");
|
||||||
|
expect(decodeOoAvatar(value)).toMatchObject({ energy: 1.6, playful: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads legacy colour and shape values unchanged", () => {
|
||||||
|
expect(decodeOoAvatar("#8b5cf6::shape_3")).toEqual({
|
||||||
|
...DEFAULT_OO_AVATAR,
|
||||||
|
color: "#8B5CF6",
|
||||||
|
shape: 3,
|
||||||
|
});
|
||||||
|
expect(decodeOoAvatar("#10B981::shape_11")?.shape).toBe(3);
|
||||||
|
expect(decodeOoAvatar("#10B981::shape_x")?.shape).toBe(0);
|
||||||
|
expect(decodeOoAvatar("#fff")).toMatchObject({ color: "#FFFFFF", shape: null });
|
||||||
|
expect(decodeOoAvatar("violet")).toMatchObject({ color: "violet", shape: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats uploaded pictures as no look", () => {
|
||||||
|
expect(decodeOoAvatar("data:image/webp;base64,AAAA")).toBeNull();
|
||||||
|
expect(ooAvatarColor("data:image/webp;base64,AAAA")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed oo1 values", () => {
|
||||||
|
for (const value of [
|
||||||
|
"oo1:",
|
||||||
|
"oo1:sp=cat",
|
||||||
|
"oo1:c=XYZXYZ",
|
||||||
|
"oo1:c=FF781C;sp=dragon",
|
||||||
|
"oo1:c=FF781C;sh=8",
|
||||||
|
"oo1:c=FF781C;en=3",
|
||||||
|
"oo1:c=FF781C;pl=1.5",
|
||||||
|
"oo1:c=FF781C;broken",
|
||||||
|
`oo1:c=FF781C;${"x=1;".repeat(OO_AVATAR_MAX_LENGTH)}`,
|
||||||
|
]) {
|
||||||
|
expect(decodeOoAvatar(value), value).toBeNull();
|
||||||
|
expect(isValidAvatarValue(value), value).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores keys from newer clients", () => {
|
||||||
|
expect(decodeOoAvatar("oo1:c=FF781C;sp=dog;zz=new")).toMatchObject({
|
||||||
|
species: "dog",
|
||||||
|
color: "#FF781C",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts every legacy value for persistence", () => {
|
||||||
|
expect(isValidAvatarValue("#8B5CF6::shape_0")).toBe(true);
|
||||||
|
expect(isValidAvatarValue("data:image/png;base64,AAAA")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a plain colour for CSS consumers", () => {
|
||||||
|
expect(ooAvatarColor("oo1:c=FF781C;sp=cat")).toBe("#FF781C");
|
||||||
|
expect(ooAvatarColor("#8B5CF6::shape_2")).toBe("#8B5CF6");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("oo rig brand lock", () => {
|
||||||
|
it("keeps the big eye φ times the small eye", () => {
|
||||||
|
const ratio = OO_RIG_EYES.big.r / OO_RIG_EYES.small.r;
|
||||||
|
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
|
||||||
|
expect(ratio).toBeCloseTo(1.618, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps glasses as asymmetric as the eyes", () => {
|
||||||
|
const ratio = ooLensRadius("big") / ooLensRadius("small");
|
||||||
|
expect(ratio).toBeGreaterThanOrEqual(OO_EYE_ASYMMETRY_MIN_RATIO);
|
||||||
|
expect(ooLensRadius("big")).toBeGreaterThan(OO_RIG_EYES.big.r);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
packages/core/src/oo-avatar.ts
Normal file
257
packages/core/src/oo-avatar.ts
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* The Oo avatar value: one string that describes how a bot looks.
|
||||||
|
*
|
||||||
|
* It travels everywhere a bot's `color` already travels (bot rows, group members,
|
||||||
|
* mentions, inbox), so every surface can draw the full look without new fields.
|
||||||
|
*
|
||||||
|
* Formats, newest first:
|
||||||
|
* - `oo1:c=A97EFE;sp=cat;ew=round` — the full look; only non-default fields are written.
|
||||||
|
* - `#8B5CF6::shape_3` — legacy colour + classic shape index.
|
||||||
|
* - `#8B5CF6` or a palette id such as `violet` — legacy colour; shape follows the identity.
|
||||||
|
* - `data:image/...` — an uploaded picture, not an Oo look.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const OO_SPECIES = [
|
||||||
|
"classic",
|
||||||
|
"cat",
|
||||||
|
"dog",
|
||||||
|
"bunny",
|
||||||
|
"bear",
|
||||||
|
"robot",
|
||||||
|
"ghost",
|
||||||
|
"frog",
|
||||||
|
"penguin",
|
||||||
|
] as const;
|
||||||
|
export const OO_ARMS = ["glass", "stub", "paw", "robot", "none"] as const;
|
||||||
|
export const OO_LASHES = ["none", "soft", "full", "flick"] as const;
|
||||||
|
export const OO_LINERS = ["none", "wing"] as const;
|
||||||
|
export const OO_EYESHADOWS = ["none", "rose", "lilac", "gold", "mint"] as const;
|
||||||
|
export const OO_IRISES = ["classic", "brown", "blue", "green", "violet"] as const;
|
||||||
|
export const OO_SPARKLES = ["dot", "star", "heart"] as const;
|
||||||
|
export const OO_BROWS = ["auto", "fine", "bushy"] as const;
|
||||||
|
export const OO_CHEEKS = ["none", "pink", "peach", "freckles"] as const;
|
||||||
|
export const OO_EYEWEAR = ["none", "round", "nerd", "sun", "heart", "monocle", "reading"] as const;
|
||||||
|
export const OO_HEADWEAR = [
|
||||||
|
"none",
|
||||||
|
"chef",
|
||||||
|
"crown",
|
||||||
|
"headphones",
|
||||||
|
"bow",
|
||||||
|
"beanie",
|
||||||
|
"flower",
|
||||||
|
"party",
|
||||||
|
] as const;
|
||||||
|
export const OO_NECKWEAR = ["none", "bowtie", "scarf", "bell"] as const;
|
||||||
|
|
||||||
|
/** Classic silhouettes, in the order of the legacy `::shape_N` index. */
|
||||||
|
export const OO_CLASSIC_SHAPE_COUNT = 8;
|
||||||
|
export const OO_ENERGY_RANGE = { min: 0.5, max: 1.6 } as const;
|
||||||
|
|
||||||
|
export type OoSpecies = (typeof OO_SPECIES)[number];
|
||||||
|
export type OoArms = (typeof OO_ARMS)[number];
|
||||||
|
export type OoLashes = (typeof OO_LASHES)[number];
|
||||||
|
export type OoLiner = (typeof OO_LINERS)[number];
|
||||||
|
export type OoEyeshadow = (typeof OO_EYESHADOWS)[number];
|
||||||
|
export type OoIris = (typeof OO_IRISES)[number];
|
||||||
|
export type OoSparkle = (typeof OO_SPARKLES)[number];
|
||||||
|
export type OoBrows = (typeof OO_BROWS)[number];
|
||||||
|
export type OoCheeks = (typeof OO_CHEEKS)[number];
|
||||||
|
export type OoEyewear = (typeof OO_EYEWEAR)[number];
|
||||||
|
export type OoHeadwear = (typeof OO_HEADWEAR)[number];
|
||||||
|
export type OoNeckwear = (typeof OO_NECKWEAR)[number];
|
||||||
|
|
||||||
|
export interface OoAvatar {
|
||||||
|
species: OoSpecies;
|
||||||
|
/** `#RRGGBB` for `oo1:` values; legacy values keep whatever colour they stored. */
|
||||||
|
color: string;
|
||||||
|
/** Classic silhouette index; `null` lets the identity pick one. */
|
||||||
|
shape: number | null;
|
||||||
|
arms: OoArms;
|
||||||
|
lashes: OoLashes;
|
||||||
|
liner: OoLiner;
|
||||||
|
eyeshadow: OoEyeshadow;
|
||||||
|
iris: OoIris;
|
||||||
|
sparkle: OoSparkle;
|
||||||
|
brows: OoBrows;
|
||||||
|
cheeks: OoCheeks;
|
||||||
|
eyewear: OoEyewear;
|
||||||
|
head: OoHeadwear;
|
||||||
|
neck: OoNeckwear;
|
||||||
|
/** Tempo and bounce of every animation. */
|
||||||
|
energy: number;
|
||||||
|
/** 0–1: how often playful extras (ear twitch, tongue, tail wag) happen. */
|
||||||
|
playful: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OO_AVATAR: OoAvatar = {
|
||||||
|
species: "classic",
|
||||||
|
color: "#8B5CF6",
|
||||||
|
shape: null,
|
||||||
|
arms: "glass",
|
||||||
|
lashes: "none",
|
||||||
|
liner: "none",
|
||||||
|
eyeshadow: "none",
|
||||||
|
iris: "classic",
|
||||||
|
sparkle: "dot",
|
||||||
|
brows: "auto",
|
||||||
|
cheeks: "none",
|
||||||
|
eyewear: "none",
|
||||||
|
head: "none",
|
||||||
|
neck: "none",
|
||||||
|
energy: 1,
|
||||||
|
playful: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OO_AVATAR_PREFIX = "oo1:";
|
||||||
|
/** Upper bound for an encoded `oo1:` value; every field written stays far below it. */
|
||||||
|
export const OO_AVATAR_MAX_LENGTH = 256;
|
||||||
|
|
||||||
|
type EnumField = Exclude<keyof OoAvatar, "color" | "shape" | "energy" | "playful">;
|
||||||
|
|
||||||
|
const ENUM_FIELDS: readonly { field: EnumField; key: string; values: readonly string[] }[] = [
|
||||||
|
{ field: "species", key: "sp", values: OO_SPECIES },
|
||||||
|
{ field: "arms", key: "ar", values: OO_ARMS },
|
||||||
|
{ field: "lashes", key: "la", values: OO_LASHES },
|
||||||
|
{ field: "liner", key: "li", values: OO_LINERS },
|
||||||
|
{ field: "eyeshadow", key: "es", values: OO_EYESHADOWS },
|
||||||
|
{ field: "iris", key: "ir", values: OO_IRISES },
|
||||||
|
{ field: "sparkle", key: "sk", values: OO_SPARKLES },
|
||||||
|
{ field: "brows", key: "br", values: OO_BROWS },
|
||||||
|
{ field: "cheeks", key: "ck", values: OO_CHEEKS },
|
||||||
|
{ field: "eyewear", key: "ew", values: OO_EYEWEAR },
|
||||||
|
{ field: "head", key: "hd", values: OO_HEADWEAR },
|
||||||
|
{ field: "neck", key: "nk", values: OO_NECKWEAR },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEX = /^#?([0-9a-f]{6})$/i;
|
||||||
|
const SHORT_HEX = /^#([0-9a-f]{3})$/i;
|
||||||
|
|
||||||
|
function normalizeHex(value: string): string | null {
|
||||||
|
const short = SHORT_HEX.exec(value);
|
||||||
|
if (short?.[1]) {
|
||||||
|
const [r, g, b] = short[1];
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`.toUpperCase();
|
||||||
|
}
|
||||||
|
const full = HEX.exec(value);
|
||||||
|
return full?.[1] ? `#${full[1].toUpperCase()}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundTo(value: number, step: number): number {
|
||||||
|
return Math.round(value / step) * step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a look as an `oo1:` value. Colours that are not hex fall back to the default. */
|
||||||
|
export function encodeOoAvatar(avatar: OoAvatar): string {
|
||||||
|
const color = normalizeHex(avatar.color) ?? DEFAULT_OO_AVATAR.color;
|
||||||
|
const parts = [`c=${color.slice(1)}`];
|
||||||
|
for (const { field, key } of ENUM_FIELDS) {
|
||||||
|
if (avatar[field] !== DEFAULT_OO_AVATAR[field]) parts.push(`${key}=${avatar[field]}`);
|
||||||
|
}
|
||||||
|
if (avatar.shape !== null) {
|
||||||
|
parts.push(`sh=${clamp(Math.trunc(avatar.shape), 0, OO_CLASSIC_SHAPE_COUNT - 1)}`);
|
||||||
|
}
|
||||||
|
const energy = roundTo(clamp(avatar.energy, OO_ENERGY_RANGE.min, OO_ENERGY_RANGE.max), 0.05);
|
||||||
|
if (energy !== DEFAULT_OO_AVATAR.energy) parts.push(`en=${energy.toFixed(2)}`);
|
||||||
|
const playful = roundTo(clamp(avatar.playful, 0, 1), 0.05);
|
||||||
|
if (playful !== DEFAULT_OO_AVATAR.playful) parts.push(`pl=${playful.toFixed(2)}`);
|
||||||
|
return OO_AVATAR_PREFIX + parts.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeV1(body: string): OoAvatar | null {
|
||||||
|
const avatar: OoAvatar = { ...DEFAULT_OO_AVATAR };
|
||||||
|
let hasColor = false;
|
||||||
|
for (const pair of body.split(";")) {
|
||||||
|
if (!pair) continue;
|
||||||
|
const eq = pair.indexOf("=");
|
||||||
|
if (eq <= 0) return null;
|
||||||
|
const key = pair.slice(0, eq);
|
||||||
|
const value = pair.slice(eq + 1);
|
||||||
|
if (key === "c") {
|
||||||
|
const color = normalizeHex(value);
|
||||||
|
if (!color) return null;
|
||||||
|
avatar.color = color;
|
||||||
|
hasColor = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "sh") {
|
||||||
|
if (!/^\d$/.test(value) || Number(value) >= OO_CLASSIC_SHAPE_COUNT) return null;
|
||||||
|
avatar.shape = Number(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "en" || key === "pl") {
|
||||||
|
if (!/^\d(?:\.\d{1,2})?$/.test(value)) return null;
|
||||||
|
const number = Number(value);
|
||||||
|
if (key === "en") {
|
||||||
|
if (number < OO_ENERGY_RANGE.min || number > OO_ENERGY_RANGE.max) return null;
|
||||||
|
avatar.energy = number;
|
||||||
|
} else {
|
||||||
|
if (number > 1) return null;
|
||||||
|
avatar.playful = number;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const spec = ENUM_FIELDS.find((entry) => entry.key === key);
|
||||||
|
// Unknown keys come from newer clients; keep the rest of the look.
|
||||||
|
if (!spec) continue;
|
||||||
|
if (!spec.values.includes(value)) return null;
|
||||||
|
(avatar as unknown as Record<string, string>)[spec.field] = value;
|
||||||
|
}
|
||||||
|
return hasColor ? avatar : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads any stored avatar value as a look. Returns `null` for uploaded pictures and
|
||||||
|
* for malformed `oo1:` values.
|
||||||
|
*/
|
||||||
|
export function decodeOoAvatar(value: string): OoAvatar | null {
|
||||||
|
if (value.startsWith("data:")) return null;
|
||||||
|
if (value.startsWith(OO_AVATAR_PREFIX)) {
|
||||||
|
if (value.length > OO_AVATAR_MAX_LENGTH) return null;
|
||||||
|
return decodeV1(value.slice(OO_AVATAR_PREFIX.length));
|
||||||
|
}
|
||||||
|
const [color = "", shapePart] = value.split("::shape_");
|
||||||
|
let shape: number | null = null;
|
||||||
|
if (shapePart !== undefined) {
|
||||||
|
shape = /^\d+$/.test(shapePart) ? Number(shapePart) % OO_CLASSIC_SHAPE_COUNT : 0;
|
||||||
|
if (!Number.isSafeInteger(shape)) shape = 0;
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_OO_AVATAR, color: normalizeHex(color) ?? color, shape };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a stored avatar value is acceptable to persist. */
|
||||||
|
export function isValidAvatarValue(value: string): boolean {
|
||||||
|
if (!value.startsWith(OO_AVATAR_PREFIX)) return true;
|
||||||
|
return decodeOoAvatar(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colour part of an avatar value, for places that need a plain CSS colour
|
||||||
|
* (cursors, rings, chips). Empty for pictures and unreadable values.
|
||||||
|
*/
|
||||||
|
export function ooAvatarColor(value: string): string {
|
||||||
|
return decodeOoAvatar(value)?.color ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oo eye anchors in the 200×200 avatar rig. Every species draws the eyes here, so the
|
||||||
|
* pair never moves when the body changes. The small eye is the big one divided by φ.
|
||||||
|
*/
|
||||||
|
export const OO_GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
|
||||||
|
export const OO_RIG_EYES = {
|
||||||
|
big: { cx: 80, cy: 108, r: 21 },
|
||||||
|
small: { cx: 126, cy: 111, r: 21 / OO_GOLDEN_RATIO },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Minimum big÷small ratio for eyes, lenses and closed-eye arcs (Oo trademark). */
|
||||||
|
export const OO_EYE_ASYMMETRY_MIN_RATIO = 1.45;
|
||||||
|
|
||||||
|
/** Lenses scale with the eye they sit on, so glasses keep the Oo asymmetry. */
|
||||||
|
export const OO_LENS_SCALE = 1.24;
|
||||||
|
|
||||||
|
export function ooLensRadius(eye: keyof typeof OO_RIG_EYES): number {
|
||||||
|
return OO_RIG_EYES[eye].r * OO_LENS_SCALE;
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { ACTIVE_RUN_STATUSES } from "@rakazo/core";
|
import { ACTIVE_RUN_STATUSES, decodeOoAvatar } from "@rakazo/core";
|
||||||
import { memo, useId, useMemo } from "react";
|
import { memo, useId, useMemo } from "react";
|
||||||
import { EvooMascot } from "./evoo-brand.js";
|
import { EvooMascot } from "./evoo-brand.js";
|
||||||
import { resolveEvooPose } from "./evoo-character/pose-map.js";
|
import { resolveEvooPose } from "./evoo-character/pose-map.js";
|
||||||
@ -251,19 +251,14 @@ export function parseBotAvatar(
|
|||||||
if (rawColor.startsWith("data:image/")) {
|
if (rawColor.startsWith("data:image/")) {
|
||||||
return { color: "#F97316", isImage: true, imageUrl: rawColor };
|
return { color: "#F97316", isImage: true, imageUrl: rawColor };
|
||||||
}
|
}
|
||||||
if (rawColor.includes("::shape_")) {
|
const look = decodeOoAvatar(rawColor);
|
||||||
const parts = rawColor.split("::shape_");
|
if (!look) return { color: "#F97316", isImage: false };
|
||||||
const rawShapeIdx = parts[1] ?? "0";
|
|
||||||
const parsedShapeIdx = /^\d+$/.test(rawShapeIdx) ? Number(rawShapeIdx) : 0;
|
|
||||||
const shapeIdx = Number.isSafeInteger(parsedShapeIdx) ? parsedShapeIdx : 0;
|
|
||||||
return {
|
return {
|
||||||
color: parts[0] || "#F97316",
|
color: look.color || "#F97316",
|
||||||
shapeIndex: shapeIdx % SHIPPED_SHAPE_KEYS.length,
|
shapeIndex: look.shape ?? undefined,
|
||||||
isImage: false,
|
isImage: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { color: rawColor, isImage: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotAvatarProps {
|
export interface BotAvatarProps {
|
||||||
color: string;
|
color: string;
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { OO_EYE_ASYMMETRY_MIN_RATIO } from "@rakazo/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* evOo brand lock — non-negotiable (Ingo).
|
* evOo brand lock — non-negotiable (Ingo).
|
||||||
*
|
*
|
||||||
@ -25,7 +27,7 @@ export const EVOO_PUPIL_BIG_R = 14.2;
|
|||||||
export const EVOO_PUPIL_SMALL_R = EVOO_PUPIL_BIG_R * 0.618034;
|
export const EVOO_PUPIL_SMALL_R = EVOO_PUPIL_BIG_R * 0.618034;
|
||||||
|
|
||||||
/** Minimum big÷small ratio — catches accidental symmetrization in tests/CI. */
|
/** Minimum big÷small ratio — catches accidental symmetrization in tests/CI. */
|
||||||
export const EVOO_EYE_ASYMMETRY_MIN_RATIO = 1.45;
|
export const EVOO_EYE_ASYMMETRY_MIN_RATIO = OO_EYE_ASYMMETRY_MIN_RATIO;
|
||||||
|
|
||||||
export const EVOO_BRAND_LOCK_RULES = [
|
export const EVOO_BRAND_LOCK_RULES = [
|
||||||
"Two eyes only: one big O, one small o — never equal radii.",
|
"Two eyes only: one big O, one small o — never equal radii.",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user