From b1d6724074250197bef54689c0588c7f330379f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:17:51 +0000 Subject: [PATCH 1/3] feat(avatar): cat, dog, fox, rabbit and bear species with fur patterns Animal species (asset version 4) draw a muzzle in a light fur tone, an animal nose and their own ears, with suggested fur colors; the skin color is the fur color. An optional pattern (tabby, spots, patches, colorpoint) is drawn in fur tones and follows recoloring, and a pattern Custom Part slot replaces it. Ear headpieces and accessories stay saved but hidden on an animal. Version 2 seeds and earlier recipes are unchanged. Released as 0.10.0. Refs: BotHarness/DeepSeekBot#1218 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- CHANGELOG.md | 1 + packages/avatar/README.md | 1 + packages/avatar/package.json | 2 +- packages/avatar/src/figure.ts | 90 ++++++++++- packages/avatar/src/index.ts | 6 + packages/avatar/src/part.ts | 2 + packages/avatar/src/recipe.ts | 62 ++++++-- packages/avatar/test/animals.test.ts | 148 ++++++++++++++++++ .../avatar/test/fixtures/animals-golden.json | 107 +++++++++++++ 9 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 packages/avatar/test/animals.test.ts create mode 100644 packages/avatar/test/fixtures/animals-golden.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d75ac..9285f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@botharness/pixel-avatar` 0.10.0: animal species. `cat`, `dog`, `fox`, `rabbit` and `bear` (`AVATAR_ANIMAL_SPECIES`, asset version 4 only) draw a muzzle in a light fur tone, an animal nose instead of the human nose, and their own ears, with suggested fur colors in `AVATAR_SPECIES_SWATCHES`. The skin color is the fur color. An optional `pattern` (`solid`, `tabby`, `spots`, `patches`, `colorpoint`) is drawn in fur tones, so it follows recoloring, and the new `pattern` Custom Part slot (`patternPart`) replaces it with a drawn one. On an animal, ear headpieces and ear accessories stay saved but are hidden (`hiddenChoices`). Version 2 seeds never pick an animal, so they are unchanged, as is every earlier recipe. - `@botharness/pixel-avatar` 0.9.0: asset version 4, every hair piece colored and built-in headpieces. `bangsColor`, `backHairColor` and `strandColor` join the side hair colors, each defaulting to `hairColor`. A new single `strand` (`ahoge`, `curl`, `double`) stands up from the crown. The headpiece slot holds a built-in headpiece (`catears`, `bunnyears`, `horseears`, `horns`, `halo`, `wings`) or a Custom Part, alongside any accessory. A headpiece's base sits behind the hair, the rest in front, and the far side hides when turned. `withPieces` raises a recipe to version 4 and moves a headpiece accessory into the empty headpiece slot; call it only when the Human edits. `withBuiltInHeadpiece` and `builtInHeadpiece` set and read the built-in headpiece, and `headpieceStart` flattens the worn headpiece into a Custom Part (base on the back layer) that renders identically facing front. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.8.0: drawing tool algorithms for Custom Parts. `partLinePoints` (Bresenham, with 0°/45°/90° snapping), `partRectPoints` (outline, with a square option), `gradientPartLayer` (a linear gradient over the start cell's 4-connected region that steps through tones with 4×4 or 2×2 ordered dithering via `ditherThreshold`), `noisePartLayer` (seeded ±1 tone texture) and `shadePartLayer` (moves each colored cell on a stroke one tone, once). Every tool mirrors across the centerline, and each produces ordinary cells, so a part's content and identity never depend on the tool or the seed. Rendering is unchanged. - `@botharness/pixel-avatar` 0.7.0: drawn replacement parts. The `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` Custom Part slots replace the built-in part in the same layer, and the built-in choice stays saved. `replacePartStart` flattens the worn part to appearance-color cells where exact and fixed colors elsewhere, so an unchanged copy renders identically. Drawn face parts never cover the pixels any speaking mouth uses, and hiding follows `hiddenChoices`. `MAX_PART_FIXED_COLORS` rises to 32, because a flattened outfit can use up to 17. Every earlier recipe renders unchanged. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 510f98e..8f99312 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -26,5 +26,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `emptyPartLayer`, `paintPartLayer`, `fillPartLayer`, `mirrorPartX`, `partLayer`, `partCells`, `createCustomPart`: drawing on dense layers, with pencil and eraser strokes, 4-connected flood fill and mirroring across the Avatar centerline. - `partLinePoints`, `partRectPoints`, `gradientPartLayer`, `ditherThreshold`, `noisePartLayer`, `shadePartLayer`: line (with 0°/45°/90° snap), rectangle (with square), tone gradient with 4×4 or 2×2 ordered dithering inside the start region, seeded ±1 tone noise, and a shade stroke that moves each colored cell one tone. All mirror, and all produce ordinary cells: the seed and the tool are not part of the part. - `AVATAR_PIECE_COLORS_V4`, `AVATAR_STRANDS`, `AVATAR_HEADPIECES`, `withPieces`, `withBuiltInHeadpiece`, `builtInHeadpiece`, `headpieceStart`: asset version 4. Bangs, back hair and a single `strand` each take an optional color (`bangsColor`, `backHairColor`, `strandColor`) that defaults to `hairColor`, like the side hair colors. The headpiece slot holds a built-in headpiece (ears, horns, halo or small head wings) or a Custom Part, worn together with any accessory. The base sits behind the hair and the rest in front, and the far side hides when the head turns. `withPieces` upgrades a recipe when the Human edits and moves an accessory that is now a headpiece into the empty headpiece slot. Saved recipes are never migrated on their own, so they keep rendering as before. `headpieceStart` flattens the worn headpiece into a Custom Part to start drawing from. +- `AVATAR_ANIMAL_SPECIES`, `AVATAR_PATTERNS`, `isAnimalSpecies`: animal species in asset version 4. Each has a muzzle, an animal nose and its own ears, uses the skin color as fur, and takes an optional fur `pattern` drawn in fur tones. A drawn `pattern` Custom Part replaces the built-in one. Ear headpieces and ear accessories are kept but hidden on an animal. - `faceCells`, `pixelSymbolCells`, `symbolArtCells`: pixels for [`@botharness/pixel-morph`](../morph). - `pixelAvatarSvg(recipe, { turns, classPrefix })`: optional pre-rendered head turns, and rig layer classes (`-body`, `-head`, `-face`, `-gaze`, `-blink`) for CSS animation. diff --git a/packages/avatar/package.json b/packages/avatar/package.json index ecfeb3d..3dbda7c 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.9.0", + "version": "0.10.0", "description": "Seeded, editable 32×32 chibi pixel avatars with state symbols that morph in and out", "keywords": [ "agent-ui", diff --git a/packages/avatar/src/figure.ts b/packages/avatar/src/figure.ts index d71285e..666bfae 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -13,8 +13,10 @@ import { AVATAR_PIECE_COLORS_V4, builtInHeadpiece, detailedRecipe, + isAnimalSpecies, hiddenChoices, wornPart, + type AvatarAnimalSpecies, type PixelAvatarRecipe, } from './recipe.js'; @@ -864,6 +866,46 @@ function drawHeadpiece( } } +const ANIMALS: Record< + AvatarAnimalSpecies, + { ears: string[]; x: number; y: number; inner: 'pink' | 'fur'; side?: boolean } +> = { + cat: { ears: ['E...', 'EE..', 'EiE.', 'EEEE'], x: -9, y: 2, inner: 'pink' }, + fox: { ears: ['D....', 'EE...', 'EiE..', 'EiiE.', 'EEEEE'], x: -10, y: 1, inner: 'fur' }, + rabbit: { ears: ['EE', 'Ei', 'Ei', 'Ei', 'Ei', 'EE'], x: -6, y: 0, inner: 'pink' }, + bear: { ears: ['.EE.', 'EiiE', 'EEEE'], x: -10, y: 3, inner: 'fur' }, + dog: { ears: ['EEE.', 'EEEE', 'EEEE', '.EEE', '..EE'], x: -13, y: 7, inner: 'fur', side: true }, +}; + +function patternMask(recipe: Recipe, s: number, fs: number): Mask { + const cx = C + s; + const mid = C + fs * 0.6; + switch (recipe.pattern) { + case 'tabby': + return (x, y) => + (y >= 5 && y <= 8 && Math.abs(x + 0.5 - cx) <= 3 && (x + 40 - Math.round(cx)) % 2 === 0) || + ((y === 17 || y === 19) && Math.abs(x + 0.5 - mid) >= 6.5); + case 'spots': + return (x, y) => + [ + [-7, 21], + [-6, 22], + [-5, 21], + [5, 21], + [6, 22], + [7, 21], + [-3, 13], + [3, 13], + ].some(([dx, dy]) => x === Math.round(cx) + dx! && y === dy); + case 'patches': + return (x, y) => ellipse(cx - 4, 14, 3.6, 3.2)(x, y) || ellipse(cx + 5, 9, 2.6, 2)(x, y); + case 'colorpoint': + return (x, y) => (x + 0.5 - cx) ** 2 / 64 + (y + 0.5 - 16.6) ** 2 / 49 > 0.62; + default: + return () => false; + } +} + const POINTED_EARS: Partial< Record, { rows: string[]; rise: number }> > = { @@ -998,11 +1040,17 @@ function renderFigure( } : front; const flower = recipe.species === 'flower'; + const animal = isAnimalSpecies(recipe.species) ? ANIMALS[recipe.species] : undefined; const hiddenNow = hiddenChoices(recipe); const drawnParts = new Map( REPLACE_PART_SLOTS.flatMap((slot) => { const part = wornPart(recipe, slot); - const shown = slot === 'petals' || slot === 'flowerBase' ? flower : !hiddenNow.includes(slot); + const shown = + slot === 'petals' || slot === 'flowerBase' + ? flower + : slot === 'pattern' + ? animal !== undefined + : !hiddenNow.includes(slot); return part && shown ? [[slot, part.front] as const] : []; }), ); @@ -1141,12 +1189,24 @@ function renderFigure( placeCells(back, inked('backHair'), false); overlay(back, 'petals'); if (headpiece) placeCells(back, headpiece.back); - const wornHead = flower ? undefined : builtInHeadpiece(recipe); + const wornHead = flower || hiddenNow.includes('headpiece') ? undefined : builtInHeadpiece(recipe); const headFront = blank(); if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0); const head = blank(); paint(head, face, skin); + if (animal) { + const pattern = patternMask(recipe, s, fs); + if (!skip('pattern')) + paint( + head, + (x, y) => face(x, y) && pattern(x, y), + TONES[recipe.pattern === 'patches' ? -2 : -1](skin), + ); + overlay(head, 'pattern'); + const muzzle = ellipse(C + fs * 0.6, 20.6 + (recipe.height ?? 0), 3.9, 2.6); + paint(head, (x, y) => face(x, y) && muzzle(x, y), TONES[2](skin)); + } const h = recipe.height ?? 0; if (recipe.beard && !flower && !skip('beard')) { const centre = C + fs * 0.6; @@ -1176,6 +1236,24 @@ function renderFigure( const pointed = recipe.species ? POINTED_EARS[recipe.species] : undefined; if (flower) { // A flower has petals instead of ears. + } else if (animal) { + paint(head, masks.front, hairColor); + const pal = { + E: recipe.pattern === 'colorpoint' || animal.side ? shade(skin, 0.72) : skin, + i: animal.inner === 'pink' ? '#f4a3b5' : shade(skin, 0.72), + D: shade(skin, 0.45), + }; + const cx = Math.round(C + s); + const sides = animal.side && turned ? [-d] : [-1, 1]; + for (const side of sides) + sprite( + head, + animal.ears, + side < 0 ? cx + animal.x : cx - animal.x - animal.ears[0]!.length, + animal.y, + pal, + side > 0, + ); } else if (pointed) { // Pointed ears are drawn over hair: the outer tip rises above the ear line. const pal = { E: skin, e: shade(skin, 0.86), i: shade(skin, 0.74) }; @@ -1198,10 +1276,11 @@ function renderFigure( E: skin, e: shade(skin, 0.8), }); - if (!pointed && !flower) paint(head, masks.front, hairColor); + if (!pointed && !flower && !animal) paint(head, masks.front, hairColor); for (const [x, y] of masks.ties ?? []) sprite(back, ['T'], Math.round(x), y, { T: shade(recipe.shirtColor, 0.8) }); - if (!flower && !skip('accessory')) accessory(recipe, head, s, face); + if (!flower && !skip('accessory') && !hiddenNow.includes('accessory')) + accessory(recipe, head, s, face); const shadeHair = (g: Grid) => { const out = g.map((r) => [...r]); @@ -1331,7 +1410,8 @@ function renderFigure( const cx = Math.round(C + fs * (turned ? 0.85 : 0.6)); if (flower) { // A flower's face is only its bead eyes and its mouth. - } else if (recipe.species === 'dwarf') + } else if (animal) sprite(features, ['NNN', '.N.'], cx - 1, 18 + h, { N: mix(INK, skin, 0.2) }); + else if (recipe.species === 'dwarf') sprite(features, ['.N.', 'NnN'], cx - 1, 17 + h, { N: shade(skin, 0.84), n: shade(skin, 0.72), diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 8020bb5..1d0361e 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -20,6 +20,12 @@ export { AVATAR_PIECE_COLORS_V4, AVATAR_STRANDS, AVATAR_HEADPIECES, + AVATAR_ANIMAL_SPECIES, + AVATAR_PATTERNS, + isAnimalSpecies, + type AvatarAnimalSpecies, + type AvatarSpeciesV4, + type AvatarPattern, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, diff --git a/packages/avatar/src/part.ts b/packages/avatar/src/part.ts index db8fff2..2faf292 100644 --- a/packages/avatar/src/part.ts +++ b/packages/avatar/src/part.ts @@ -21,6 +21,7 @@ export const PART_SLOTS = { cheeks: { width: 32, height: 32 }, petals: { width: 32, height: 32 }, flowerBase: { width: 32, height: 32 }, + pattern: { width: 32, height: 32 }, } as const; export type PartSlot = keyof typeof PART_SLOTS; /** @@ -44,6 +45,7 @@ export const REPLACE_PART_SLOTS = [ 'cheeks', 'petals', 'flowerBase', + 'pattern', ] as const; export type ReplacePartSlot = (typeof REPLACE_PART_SLOTS)[number]; export const isReplacePartSlot = (slot: PartSlot): slot is ReplacePartSlot => diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index af759be..df2a69d 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -162,6 +162,18 @@ export type AvatarColor = (typeof AVATAR_COLORS)[number]; */ export const AVATAR_SPECIES = ['human', 'goblin', 'elf', 'dwarf', 'orc', 'flower'] as const; export type AvatarSpecies = (typeof AVATAR_SPECIES)[number]; +/** + * Anthropomorphic animal species (asset version 4): a muzzle, animal ears and fur colors. The + * skin color is the fur color, and an optional `pattern` is drawn in its tones. + */ +export const AVATAR_ANIMAL_SPECIES = ['cat', 'dog', 'fox', 'rabbit', 'bear'] as const; +export type AvatarAnimalSpecies = (typeof AVATAR_ANIMAL_SPECIES)[number]; +export type AvatarSpeciesV4 = AvatarSpecies | AvatarAnimalSpecies; +export const isAnimalSpecies = (species: unknown): species is AvatarAnimalSpecies => + (AVATAR_ANIMAL_SPECIES as readonly unknown[]).includes(species); +/** Fur patterns for animal species, drawn in skin-color tones so they survive recoloring. */ +export const AVATAR_PATTERNS = ['solid', 'tabby', 'spots', 'patches', 'colorpoint'] as const; +export type AvatarPattern = (typeof AVATAR_PATTERNS)[number]; /** Optional per-piece hair colors (asset version 2); an absent piece uses `hairColor`. */ export const AVATAR_PIECE_COLORS = ['leftSideHairColor', 'rightSideHairColor'] as const; export type AvatarPieceColor = (typeof AVATAR_PIECE_COLORS)[number]; @@ -187,7 +199,7 @@ export const AVATAR_HEADPIECES = [ 'wings', ] as const; export type AvatarHeadpiece = (typeof AVATAR_HEADPIECES)[number]; -type V4Key = AvatarPieceColorV4 | 'strand'; +type V4Key = AvatarPieceColorV4 | 'strand' | 'pattern'; /** Part choices that exist only in asset version 2, added after every version 1 choice. */ export const AVATAR_PARTS_V2 = { @@ -250,6 +262,7 @@ export const CUSTOM_PART_KEYS = { cheeks: 'cheeksPart', petals: 'petalsPart', flowerBase: 'flowerBasePart', + pattern: 'patternPart', } as const satisfies Record; export type CustomPartKey = (typeof CUSTOM_PART_KEYS)[PartSlot]; const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPartKey][]; @@ -269,12 +282,14 @@ export type PixelAvatarRecipeV3 = Omit & { assetVersion: 4; } & { [P in Exclude]?: PixelCustomPart } & { headpiece?: PixelCustomPart | AvatarHeadpiece; strand?: AvatarStrand; + species: AvatarSpeciesV4; + pattern?: AvatarPattern; } & { [P in AvatarPieceColorV4]?: string }; export type PixelAvatarRecipe = @@ -323,7 +338,9 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe ([slot, key]) => Object.hasOwn(r, key) && !(slot === 'headpiece' && builtInHead), ); if (v3 ? worn.length === 0 : !v4 && worn.length !== 0) return false; - const newer = [...AVATAR_PIECE_COLORS_V4, 'strand'].filter((key) => Object.hasOwn(r, key)); + const newer = [...AVATAR_PIECE_COLORS_V4, 'strand', 'pattern'].filter((key) => + Object.hasOwn(r, key), + ); if ( newer.length !== 0 && (!v4 || @@ -331,7 +348,10 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe (key) => !Object.hasOwn(r, key) || (typeof r[key] === 'string' && /^#[\da-f]{6}$/iu.test(r[key])), ) || - (Object.hasOwn(r, 'strand') && !(AVATAR_STRANDS as readonly unknown[]).includes(r['strand']))) + (Object.hasOwn(r, 'strand') && + !(AVATAR_STRANDS as readonly unknown[]).includes(r['strand'])) || + (Object.hasOwn(r, 'pattern') && + !(AVATAR_PATTERNS as readonly unknown[]).includes(r['pattern']))) ) return false; for (const [slot, key] of worn) { @@ -350,7 +370,10 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe if ( v2 && (detailed === 0 || - !(AVATAR_SPECIES as readonly unknown[]).includes(r['species']) || + !( + (AVATAR_SPECIES as readonly unknown[]).includes(r['species']) || + (v4 && isAnimalSpecies(r['species'])) + ) || !(AVATAR_HAIR_PARTS.sideHair as readonly unknown[]).includes(r['rightSideHair']) || !pieces.every((key) => typeof r[key] === 'string' && /^#[\da-f]{6}$/iu.test(r[key])) || !extras.every((key) => (AVATAR_EXTRA_PARTS[key] as readonly unknown[]).includes(r[key]))) @@ -403,6 +426,7 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { } if (recipe.assetVersion === 4) { if (recipe.strand !== undefined) canonical['strand'] = recipe.strand; + if (recipe.pattern !== undefined) canonical['pattern'] = recipe.pattern; for (const key of AVATAR_PIECE_COLORS_V4) if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); } @@ -411,6 +435,7 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { } const HAIR_PIECES = ['bangs', 'sideHair', 'rightSideHair', 'backHair'] as const; +const EAR_PIECES: readonly string[] = ['catears', 'bunnyears', 'horseears']; /** * Saved choices the recipe keeps but does not draw: a flower shows petals and a stem instead of @@ -443,6 +468,12 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { hidden.push(...HAIR_PIECES, ...strand); if (recipe.species === 'dwarf') hidden.push('nose'); if (!hidden.includes('strand')) hidden.push(...bald); + if (isAnimalSpecies(recipe.species)) { + hidden.push('nose'); + if (EAR_PIECES.includes(builtInHeadpiece(recipe) ?? '')) hidden.push('headpiece'); + if (EAR_PIECES.includes(recipe.accessory) && !wornPart(recipe, 'accessory')) + hidden.push('accessory'); + } else if (recipe.pattern || wornPart(recipe, 'pattern')) hidden.push('pattern'); return hidden; } @@ -452,9 +483,13 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { * When the skin color is one of the previous species' suggested colors, it moves to the new * species' first suggestion; a custom color is kept. */ +export function withSpecies( + recipe: PixelAvatarRecipe, + species: AvatarAnimalSpecies, +): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV4, - species: AvatarSpecies, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV3, @@ -466,12 +501,14 @@ export function withSpecies( ): PixelAvatarRecipeV2; export function withSpecies( recipe: PixelAvatarRecipe, - species: AvatarSpecies, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withSpecies( - recipe: PixelAvatarRecipe, - species: AvatarSpecies, + source: PixelAvatarRecipe, + species: AvatarSpeciesV4, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { + const recipe: PixelAvatarRecipe = + isAnimalSpecies(species) && source.assetVersion !== 4 ? withPieces(source) : source; const detailed = detailedRecipe(recipe); const previous = recipe.species ?? 'human'; const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase()); @@ -714,13 +751,18 @@ export const seededRecipe: (seed: string) => PixelAvatarRecipe = createSeededRecipe('botharness-avatar'); /** Suggested body colors per species; any color remains allowed. */ -export const AVATAR_SPECIES_SWATCHES: Record = { +export const AVATAR_SPECIES_SWATCHES: Record = { human: AVATAR_SWATCHES.skinColor, goblin: ['#9cc464', '#7fae4f', '#6a9a45', '#b3cf7a', '#5c8a4a', '#8fa86a'], elf: ['#fbe7d6', '#f2d3c0', '#e2c9b0', '#d9c7e8', '#b9c6dd', '#8f7fa8'], dwarf: ['#f2c4a0', '#e9b08a', '#e0a87e', '#c68863', '#f0b8a0', '#9a6142'], orc: ['#8a9a5b', '#6f8a4e', '#7d8c6a', '#5f6f4a', '#9aa070', '#6a7a7a'], flower: ['#f6d36b', '#f2b84b', '#ffe3cf', '#e8a85a', '#c98b4a', '#f4f1ec'], + cat: ['#f2a65a', '#9aa3ad', '#3a3438', '#f4f1ec', '#c98b4a', '#e8d2b0'], + dog: ['#c98b4a', '#e8d2b0', '#8a5a36', '#f4f1ec', '#3a3438', '#d9a066'], + fox: ['#e0763a', '#c4452f', '#f2a65a', '#f4f1ec', '#8a5a36', '#9aa3ad'], + rabbit: ['#f4f1ec', '#d9c7b8', '#9aa3ad', '#8a5a36', '#e8d2b0', '#3a3438'], + bear: ['#8a5a36', '#5a3a2a', '#c98b4a', '#3a3438', '#f4f1ec', '#d9a066'], }; const preset = (parts: Partial): PixelAvatarRecipeV1 => ({ diff --git a/packages/avatar/test/animals.test.ts b/packages/avatar/test/animals.test.ts new file mode 100644 index 0000000..883e99c --- /dev/null +++ b/packages/avatar/test/animals.test.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + AVATAR_ANIMAL_SPECIES, + AVATAR_PATTERNS, + AVATAR_SPECIES_SWATCHES, + AVATAR_TURNS, + DEFAULT_RECIPE, + hiddenChoices, + isPixelAvatarRecipe, + pixelAvatarSvg, + pixelFigure, + replacePartStart, + seededRecipeV2, + withBuiltInHeadpiece, + withCustomPart, + withSpecies, + type PixelAvatarRecipe, +} from '../src/index.js'; + +const hash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16); +const render = (recipe: PixelAvatarRecipe) => ({ + front: hash(pixelAvatarSvg(recipe)), + turns: hash(pixelAvatarSvg(recipe, { turns: AVATAR_TURNS })), + mouths: hash(pixelFigure(recipe, 0, { mouthLayers: true }).head), +}); +const cells = (recipe: PixelAvatarRecipe, yaw = 0) => + new Map(pixelFigure(recipe, yaw).cells.map(({ x, y, c }) => [`${x},${y}`, c])); +const changed = (a: PixelAvatarRecipe, b: PixelAvatarRecipe, yaw = 0) => { + const [ca, cb] = [cells(a, yaw), cells(b, yaw)]; + return [...new Set([...ca.keys(), ...cb.keys()])].filter((key) => ca.get(key) !== cb.get(key)); +}; + +const cat = withSpecies(DEFAULT_RECIPE, 'cat'); + +describe('animal species with patterns (asset version 4)', () => { + it('accepts animal species and patterns only in version 4, with fur swatches', () => { + for (const species of AVATAR_ANIMAL_SPECIES) { + const animal = withSpecies(DEFAULT_RECIPE, species); + expect(animal).toMatchObject({ assetVersion: 4, species }); + expect(isPixelAvatarRecipe(animal)).toBe(true); + expect(animal.skinColor).toBe(AVATAR_SPECIES_SWATCHES[species][0]); + } + expect(isPixelAvatarRecipe({ ...cat, pattern: 'tabby' })).toBe(true); + expect(isPixelAvatarRecipe({ ...cat, pattern: 'stripes' })).toBe(false); + const v2 = withSpecies(DEFAULT_RECIPE, 'human'); + expect(isPixelAvatarRecipe({ ...v2, species: 'cat' })).toBe(false); + expect(isPixelAvatarRecipe({ ...v2, pattern: 'spots' })).toBe(false); + expect(withSpecies(cat, 'human').assetVersion).toBe(4); + const custom = { ...cat, skinColor: '#123456' }; + expect(withSpecies(custom, 'fox').skinColor).toBe('#123456'); + }); + + it('keeps version 2 seeds human-world and unchanged', () => { + for (const name of ['Ada', 'Grace', 'Linus', 'DeepSeekBot', 'Gimli', 'Daisy']) + expect(AVATAR_ANIMAL_SPECIES as readonly string[]).not.toContain( + seededRecipeV2(name).species, + ); + }); + + it('draws a muzzle and animal nose instead of the human nose, keeping every mouth layer', () => { + expect(hiddenChoices(cat)).toContain('nose'); + expect(changed({ ...cat, nose: 'dot' }, { ...cat, nose: 'line' })).toEqual([]); + const mouths = pixelFigure(cat, 0, { mouthLayers: true }).head; + for (const state of ['saved', 'closed', 'half-open', 'open']) + expect(mouths).toContain(`data-avatar-mouth="${state}"`); + for (const species of AVATAR_ANIMAL_SPECIES) + expect( + changed(withSpecies(DEFAULT_RECIPE, species), withSpecies(DEFAULT_RECIPE, 'human')).length, + ).toBeGreaterThan(10); + }); + + it('draws patterns in fur tones that follow recoloring', () => { + for (const pattern of AVATAR_PATTERNS.slice(1)) { + const patterned = { ...cat, bangs: 'none', pattern } as const; + const diff = changed(patterned, { ...patterned, pattern: 'solid' }); + expect(diff.length, pattern).toBeGreaterThan(0); + const recolored = { ...patterned, skinColor: '#9aa3ad' }; + const shown = cells(recolored); + expect(diff.some((key) => shown.get(key) !== cells(patterned).get(key))).toBe(true); + } + const drawn = withCustomPart({ ...cat, pattern: 'spots' }, 'pattern', { + slot: 'pattern', + front: [[10, 12, 'skinColor', -2]], + back: [], + }); + expect(changed(drawn, { ...cat, pattern: 'spots' }).length).toBeGreaterThan(0); + const start = replacePartStart({ ...cat, bangs: 'none', pattern: 'patches' }, 'pattern'); + expect(start.front.length).toBeGreaterThan(0); + const fur = start.front.filter(([, , color]) => color === 'skinColor'); + expect(fur.length / start.front.length).toBeGreaterThan(0.9); + const copy = withCustomPart({ ...cat, bangs: 'none', pattern: 'patches' }, 'pattern', start); + expect(changed(copy, { ...cat, bangs: 'none', pattern: 'patches' }).length).toBeLessThanOrEqual( + 2, + ); + const human = withSpecies(DEFAULT_RECIPE, 'human'); + expect(hiddenChoices({ ...withSpecies(cat, 'human'), pattern: 'tabby' })).toContain('pattern'); + expect(human.assetVersion).toBe(2); + }); + + it('hides animal-ear headpieces and accessories on an animal and keeps them saved', () => { + const eared = withBuiltInHeadpiece(cat, 'bunnyears'); + expect(hiddenChoices(eared)).toContain('headpiece'); + expect(changed(eared, cat)).toEqual([]); + expect(hiddenChoices(withBuiltInHeadpiece(cat, 'halo'))).not.toContain('headpiece'); + const accessory = { ...cat, accessory: 'catears' } as const; + expect(hiddenChoices(accessory)).toContain('accessory'); + expect(changed(accessory, cat)).toEqual([]); + expect(changed(withSpecies(eared, 'human'), withSpecies(cat, 'human')).length).toBeGreaterThan( + 0, + ); + }); + + it('matches the animal golden output', () => { + const recipes: Record = {}; + for (const species of AVATAR_ANIMAL_SPECIES) { + const animal = withSpecies(DEFAULT_RECIPE, species); + recipes[species] = animal; + recipes[`${species}-extremes`] = { + ...animal, + spacing: 1, + height: 1, + hairLength: 2, + head: 'long', + }; + recipes[`${species}-low`] = { + ...animal, + spacing: -1, + height: -1, + head: 'chubby', + bangs: 'none', + }; + } + for (const pattern of AVATAR_PATTERNS) + recipes[`cat-${pattern}`] = { ...cat, bangs: 'none', pattern }; + recipes['dog-patches-white'] = { + ...withSpecies(DEFAULT_RECIPE, 'dog'), + pattern: 'patches', + skinColor: '#f4f1ec', + }; + const actual = Object.fromEntries(Object.entries(recipes).map(([k, r]) => [k, render(r)])); + const file = new URL('./fixtures/animals-golden.json', import.meta.url); + if (process.env['BOTPIXEL_WRITE_ANIMALS_GOLDEN'] === '1') + writeFileSync(file, `${JSON.stringify(actual, null, 2)}\n`); + expect(actual).toEqual(JSON.parse(readFileSync(file, 'utf8'))); + }); +}); diff --git a/packages/avatar/test/fixtures/animals-golden.json b/packages/avatar/test/fixtures/animals-golden.json new file mode 100644 index 0000000..4040843 --- /dev/null +++ b/packages/avatar/test/fixtures/animals-golden.json @@ -0,0 +1,107 @@ +{ + "cat": { + "front": "1746196b257e0c26", + "turns": "082f84469b7efdce", + "mouths": "974cc7fe6e5f014f" + }, + "cat-extremes": { + "front": "1aa86d921f870f33", + "turns": "dc32e69c6380f9cb", + "mouths": "1c0511b09403446f" + }, + "cat-low": { + "front": "90ff246d12a67016", + "turns": "9a87b7d4a2d379ce", + "mouths": "9392b113e29c7eb7" + }, + "dog": { + "front": "40679d835f6df0d2", + "turns": "2622a6c4512cc5f2", + "mouths": "8d4f1ea52a8ccadd" + }, + "dog-extremes": { + "front": "f32d52fd83765cd6", + "turns": "21ae0d8b2f2e4df5", + "mouths": "e7360fe20ba78b0f" + }, + "dog-low": { + "front": "65855abc301db520", + "turns": "38f49f8f598fb9ac", + "mouths": "29a57cf8f002bc3b" + }, + "fox": { + "front": "8caee530e75218b8", + "turns": "67b8e0f47d0cac76", + "mouths": "9f5f55f450a3101a" + }, + "fox-extremes": { + "front": "ee51b9aef1b27ca6", + "turns": "7a074ef35a8773f5", + "mouths": "535cfa43d0be61b5" + }, + "fox-low": { + "front": "db923153d803e1b1", + "turns": "88da418d66f3dcc0", + "mouths": "f890ba290476f7e5" + }, + "rabbit": { + "front": "7c93fdfff6a87732", + "turns": "337df3e0d2048b36", + "mouths": "2531a70f7d6ca168" + }, + "rabbit-extremes": { + "front": "1a714e135a841398", + "turns": "b2f870a714773c93", + "mouths": "787517d264da774c" + }, + "rabbit-low": { + "front": "4af7021a27517af6", + "turns": "3d4c426e59a63cc8", + "mouths": "b3eb0e432d3c85ce" + }, + "bear": { + "front": "342c580fbc5c620c", + "turns": "b4c799a9f62032e3", + "mouths": "f44dc41e1e81bc19" + }, + "bear-extremes": { + "front": "ef5724370412be82", + "turns": "9166eb9d47a4f5c7", + "mouths": "aa0bbe7f5c331386" + }, + "bear-low": { + "front": "499221aea0371504", + "turns": "8c3fdca863c5a658", + "mouths": "ddd0a7cd753a449f" + }, + "cat-solid": { + "front": "2257574c59b69e17", + "turns": "023a29490766c341", + "mouths": "07ab85334c92a9e4" + }, + "cat-tabby": { + "front": "bdadada330d59f92", + "turns": "014166787970824c", + "mouths": "1e087ed18bb50df2" + }, + "cat-spots": { + "front": "527c98033263ce3f", + "turns": "48730fde58119ba4", + "mouths": "d158200113cedb08" + }, + "cat-patches": { + "front": "c54c0776f27fe489", + "turns": "008f51832b005fd3", + "mouths": "e592c0a67450eeb6" + }, + "cat-colorpoint": { + "front": "edab60cb2c002b97", + "turns": "eff2abfeeccb2944", + "mouths": "907f5adf508478a6" + }, + "dog-patches-white": { + "front": "ae5434da8e55f0cf", + "turns": "88d9d95269c54f9f", + "mouths": "1c69131bd84142a4" + } +} From 89848fc19d43ab4ee90a782b97cc5ebf4941f369 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:28:44 +0000 Subject: [PATCH 2/3] fix(avatar): keep the pattern slot out of version 3 and report a hidden pattern on a flower Refs: BotHarness/DeepSeekBot#1218 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- packages/avatar/src/recipe.ts | 12 +++++++++--- packages/avatar/test/animals.test.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index df2a69d..dfface6 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -273,7 +273,7 @@ const PART_KEY_ENTRIES = Object.entries(CUSTOM_PART_KEYS) as [PartSlot, CustomPa */ export type PixelAvatarRecipeV3 = Omit & { assetVersion: 3; -} & { [P in CustomPartKey]?: PixelCustomPart }; +} & { [P in Exclude]?: PixelCustomPart } & { patternPart?: never }; /** * Asset version 4: version 2 with a color for every hair piece, a single `strand`, and the @@ -338,6 +338,7 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe ([slot, key]) => Object.hasOwn(r, key) && !(slot === 'headpiece' && builtInHead), ); if (v3 ? worn.length === 0 : !v4 && worn.length !== 0) return false; + if (!v4 && Object.hasOwn(r, CUSTOM_PART_KEYS.pattern)) return false; const newer = [...AVATAR_PIECE_COLORS_V4, 'strand', 'pattern'].filter((key) => Object.hasOwn(r, key), ); @@ -459,6 +460,7 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { 'cheeks', 'glasses', ...(recipe.beard || wornPart(recipe, 'beard') ? ['beard'] : []), + ...(recipe.pattern || wornPart(recipe, 'pattern') ? ['pattern'] : []), ]; const hidden: string[] = []; if ( @@ -543,12 +545,16 @@ export function withCustomPart( part: PixelCustomPart | undefined, ): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { const base: Record = { - ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), + ...(slot === 'pattern' && part !== undefined + ? withPieces(recipe) + : recipe.assetVersion === 1 + ? withSpecies(recipe, 'human') + : recipe), }; const key = CUSTOM_PART_KEYS[slot]; delete base[key]; if (part !== undefined) base[key] = canonicalCustomPart({ ...part, slot }); - if (recipe.assetVersion === 4) return base as PixelAvatarRecipeV4; + if (base['assetVersion'] === 4) return base as PixelAvatarRecipeV4; const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== undefined); return { ...base, assetVersion: wearing ? 3 : 2 } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; } diff --git a/packages/avatar/test/animals.test.ts b/packages/avatar/test/animals.test.ts index 883e99c..c8a4480 100644 --- a/packages/avatar/test/animals.test.ts +++ b/packages/avatar/test/animals.test.ts @@ -112,6 +112,24 @@ describe('animal species with patterns (asset version 4)', () => { ); }); + it('keeps the pattern slot out of version 3 and reports a hidden pattern on a flower', () => { + const v3 = withCustomPart(withSpecies(DEFAULT_RECIPE, 'human'), 'bangs', { + slot: 'bangs', + front: [[16, 4, 'hairColor', 0]], + back: [], + }); + expect(v3.assetVersion).toBe(3); + const pattern = { + slot: 'pattern' as const, + front: [[10, 12, 'skinColor', -1] as const], + back: [], + }; + expect(isPixelAvatarRecipe({ ...v3, patternPart: pattern })).toBe(false); + expect(withCustomPart(v3, 'pattern', pattern).assetVersion).toBe(4); + const flower = withSpecies({ ...cat, pattern: 'spots' }, 'flower'); + expect(hiddenChoices(flower)).toContain('pattern'); + }); + it('matches the animal golden output', () => { const recipes: Record = {}; for (const species of AVATAR_ANIMAL_SPECIES) { From c29e3c58cde793abf3a62d24536c19d32e5cad7b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:32:05 +0000 Subject: [PATCH 3/3] fix(avatar): center the animal nose when turned, anchor tabby stripes to the face top Spots no longer sit under the eyes, and pattern 'solid' canonicalizes to no pattern. Golden entries cover turned animals. Refs: BotHarness/DeepSeekBot#1218 Agent-Task: claude/cloud/session_01MozvQ2eMvqX1MzSqm9G2o8 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MozvQ2eMvqX1MzSqm9G2o8 --- CHANGELOG.md | 2 +- packages/avatar/src/figure.ts | 23 ++++++++---- packages/avatar/src/recipe.ts | 3 +- packages/avatar/test/animals.test.ts | 17 +++++++++ .../avatar/test/fixtures/animals-golden.json | 37 ++++++++++++++++--- 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9285f1a..392da3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- `@botharness/pixel-avatar` 0.10.0: animal species. `cat`, `dog`, `fox`, `rabbit` and `bear` (`AVATAR_ANIMAL_SPECIES`, asset version 4 only) draw a muzzle in a light fur tone, an animal nose instead of the human nose, and their own ears, with suggested fur colors in `AVATAR_SPECIES_SWATCHES`. The skin color is the fur color. An optional `pattern` (`solid`, `tabby`, `spots`, `patches`, `colorpoint`) is drawn in fur tones, so it follows recoloring, and the new `pattern` Custom Part slot (`patternPart`) replaces it with a drawn one. On an animal, ear headpieces and ear accessories stay saved but are hidden (`hiddenChoices`). Version 2 seeds never pick an animal, so they are unchanged, as is every earlier recipe. +- `@botharness/pixel-avatar` 0.10.0: animal species. `cat`, `dog`, `fox`, `rabbit` and `bear` (`AVATAR_ANIMAL_SPECIES`, asset version 4 only) draw a muzzle in a light fur tone, an animal nose instead of the human nose, and their own ears, with suggested fur colors in `AVATAR_SPECIES_SWATCHES`. The skin color is the fur color. An optional `pattern` (`solid`, `tabby`, `spots`, `patches`, `colorpoint`) is drawn in fur tones, so it follows recoloring, and the new `pattern` Custom Part slot (`patternPart`, asset version 4 only, so `PART_SLOTS` and `REPLACE_PART_SLOTS` gain `pattern`) replaces it with a drawn one. `pattern: 'solid'` canonicalizes to no pattern. On an animal, ear headpieces and ear accessories stay saved but are hidden (`hiddenChoices`). Version 2 seeds never pick an animal, so they are unchanged, as is every earlier recipe. - `@botharness/pixel-avatar` 0.9.0: asset version 4, every hair piece colored and built-in headpieces. `bangsColor`, `backHairColor` and `strandColor` join the side hair colors, each defaulting to `hairColor`. A new single `strand` (`ahoge`, `curl`, `double`) stands up from the crown. The headpiece slot holds a built-in headpiece (`catears`, `bunnyears`, `horseears`, `horns`, `halo`, `wings`) or a Custom Part, alongside any accessory. A headpiece's base sits behind the hair, the rest in front, and the far side hides when turned. `withPieces` raises a recipe to version 4 and moves a headpiece accessory into the empty headpiece slot; call it only when the Human edits. `withBuiltInHeadpiece` and `builtInHeadpiece` set and read the built-in headpiece, and `headpieceStart` flattens the worn headpiece into a Custom Part (base on the back layer) that renders identically facing front. Every earlier recipe renders unchanged. - `@botharness/pixel-avatar` 0.8.0: drawing tool algorithms for Custom Parts. `partLinePoints` (Bresenham, with 0°/45°/90° snapping), `partRectPoints` (outline, with a square option), `gradientPartLayer` (a linear gradient over the start cell's 4-connected region that steps through tones with 4×4 or 2×2 ordered dithering via `ditherThreshold`), `noisePartLayer` (seeded ±1 tone texture) and `shadePartLayer` (moves each colored cell on a stroke one tone, once). Every tool mirrors across the centerline, and each produces ordinary cells, so a part's content and identity never depend on the tool or the seed. Rendering is unchanged. - `@botharness/pixel-avatar` 0.7.0: drawn replacement parts. The `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` Custom Part slots replace the built-in part in the same layer, and the built-in choice stays saved. `replacePartStart` flattens the worn part to appearance-color cells where exact and fixed colors elsewhere, so an unchanged copy renders identically. Drawn face parts never cover the pixels any speaking mouth uses, and hiding follows `hiddenChoices`. `MAX_PART_FIXED_COLORS` rises to 32, because a flattened outfit can use up to 17. Every earlier recipe renders unchanged. diff --git a/packages/avatar/src/figure.ts b/packages/avatar/src/figure.ts index 666bfae..d80dcfa 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -877,14 +877,20 @@ const ANIMALS: Record< dog: { ears: ['EEE.', 'EEEE', 'EEEE', '.EEE', '..EE'], x: -13, y: 7, inner: 'fur', side: true }, }; -function patternMask(recipe: Recipe, s: number, fs: number): Mask { +function patternMask(recipe: Recipe, s: number, fs: number, face: Mask): Mask { const cx = C + s; const mid = C + fs * 0.6; switch (recipe.pattern) { - case 'tabby': + case 'tabby': { + let top = 0; + while (top < N && !face(Math.round(cx), top)) top++; return (x, y) => - (y >= 5 && y <= 8 && Math.abs(x + 0.5 - cx) <= 3 && (x + 40 - Math.round(cx)) % 2 === 0) || + (y >= top && + y <= top + 3 && + Math.abs(x + 0.5 - cx) <= 3 && + (x + 40 - Math.round(cx)) % 2 === 0) || ((y === 17 || y === 19) && Math.abs(x + 0.5 - mid) >= 6.5); + } case 'spots': return (x, y) => [ @@ -894,8 +900,8 @@ function patternMask(recipe: Recipe, s: number, fs: number): Mask { [5, 21], [6, 22], [7, 21], - [-3, 13], - [3, 13], + [-2, 23], + [2, 23], ].some(([dx, dy]) => x === Math.round(cx) + dx! && y === dy); case 'patches': return (x, y) => ellipse(cx - 4, 14, 3.6, 3.2)(x, y) || ellipse(cx + 5, 9, 2.6, 2)(x, y); @@ -1196,7 +1202,7 @@ function renderFigure( const head = blank(); paint(head, face, skin); if (animal) { - const pattern = patternMask(recipe, s, fs); + const pattern = patternMask(recipe, s, fs, face); if (!skip('pattern')) paint( head, @@ -1410,7 +1416,10 @@ function renderFigure( const cx = Math.round(C + fs * (turned ? 0.85 : 0.6)); if (flower) { // A flower's face is only its bead eyes and its mouth. - } else if (animal) sprite(features, ['NNN', '.N.'], cx - 1, 18 + h, { N: mix(INK, skin, 0.2) }); + } else if (animal) + sprite(features, ['NNN', '.N.'], Math.round(C + fs * 0.6) - 1, 18 + h, { + N: mix(INK, skin, 0.2), + }); else if (recipe.species === 'dwarf') sprite(features, ['.N.', 'NnN'], cx - 1, 17 + h, { N: shade(skin, 0.84), diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index dfface6..c33b32a 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -427,7 +427,8 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { } if (recipe.assetVersion === 4) { if (recipe.strand !== undefined) canonical['strand'] = recipe.strand; - if (recipe.pattern !== undefined) canonical['pattern'] = recipe.pattern; + if (recipe.pattern !== undefined && recipe.pattern !== 'solid') + canonical['pattern'] = recipe.pattern; for (const key of AVATAR_PIECE_COLORS_V4) if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); } diff --git a/packages/avatar/test/animals.test.ts b/packages/avatar/test/animals.test.ts index c8a4480..5a3fc00 100644 --- a/packages/avatar/test/animals.test.ts +++ b/packages/avatar/test/animals.test.ts @@ -7,6 +7,7 @@ import { AVATAR_SPECIES_SWATCHES, AVATAR_TURNS, DEFAULT_RECIPE, + canonicalRecipe, hiddenChoices, isPixelAvatarRecipe, pixelAvatarSvg, @@ -130,6 +131,21 @@ describe('animal species with patterns (asset version 4)', () => { expect(hiddenChoices(flower)).toContain('pattern'); }); + it('centers the nose on the muzzle when turned and shows tabby on every head', () => { + for (const pose of ['left', 'right'] as const) { + const turned = { ...cat, pose } as const; + expect(changed(turned, { ...turned, nose: 'line' })).toEqual([]); + } + for (const head of ['round', 'oval', 'chubby', 'diamond'] as const) { + const bare = { ...cat, head, bangs: 'none' } as const; + const stripes = changed({ ...bare, pattern: 'tabby' }, bare).filter( + (key) => Number(key.split(',')[1]) <= 12, + ); + expect(stripes.length, head).toBeGreaterThan(0); + } + expect(canonicalRecipe({ ...cat, pattern: 'solid' })).toEqual(canonicalRecipe(cat)); + }); + it('matches the animal golden output', () => { const recipes: Record = {}; for (const species of AVATAR_ANIMAL_SPECIES) { @@ -142,6 +158,7 @@ describe('animal species with patterns (asset version 4)', () => { hairLength: 2, head: 'long', }; + recipes[`${species}-right`] = { ...animal, pose: 'right' }; recipes[`${species}-low`] = { ...animal, spacing: -1, diff --git a/packages/avatar/test/fixtures/animals-golden.json b/packages/avatar/test/fixtures/animals-golden.json index 4040843..3b9599a 100644 --- a/packages/avatar/test/fixtures/animals-golden.json +++ b/packages/avatar/test/fixtures/animals-golden.json @@ -9,6 +9,11 @@ "turns": "dc32e69c6380f9cb", "mouths": "1c0511b09403446f" }, + "cat-right": { + "front": "fa9da07c1c6cab67", + "turns": "f42d3fc8063e2dc7", + "mouths": "974cc7fe6e5f014f" + }, "cat-low": { "front": "90ff246d12a67016", "turns": "9a87b7d4a2d379ce", @@ -24,6 +29,11 @@ "turns": "21ae0d8b2f2e4df5", "mouths": "e7360fe20ba78b0f" }, + "dog-right": { + "front": "6436103b1c8482aa", + "turns": "fc20576ebc062f27", + "mouths": "8d4f1ea52a8ccadd" + }, "dog-low": { "front": "65855abc301db520", "turns": "38f49f8f598fb9ac", @@ -39,6 +49,11 @@ "turns": "7a074ef35a8773f5", "mouths": "535cfa43d0be61b5" }, + "fox-right": { + "front": "e332d5f08cefde9f", + "turns": "b22f4518779a8715", + "mouths": "9f5f55f450a3101a" + }, "fox-low": { "front": "db923153d803e1b1", "turns": "88da418d66f3dcc0", @@ -54,6 +69,11 @@ "turns": "b2f870a714773c93", "mouths": "787517d264da774c" }, + "rabbit-right": { + "front": "e704daacaa34cebf", + "turns": "9aa0ab07e9ea330f", + "mouths": "2531a70f7d6ca168" + }, "rabbit-low": { "front": "4af7021a27517af6", "turns": "3d4c426e59a63cc8", @@ -69,6 +89,11 @@ "turns": "9166eb9d47a4f5c7", "mouths": "aa0bbe7f5c331386" }, + "bear-right": { + "front": "eee130ab7857dea6", + "turns": "3421dc2ecc328618", + "mouths": "f44dc41e1e81bc19" + }, "bear-low": { "front": "499221aea0371504", "turns": "8c3fdca863c5a658", @@ -80,14 +105,14 @@ "mouths": "07ab85334c92a9e4" }, "cat-tabby": { - "front": "bdadada330d59f92", - "turns": "014166787970824c", - "mouths": "1e087ed18bb50df2" + "front": "8e46b43d7422fa91", + "turns": "c7a5dd1ff13a271d", + "mouths": "9898ff9db62607b7" }, "cat-spots": { - "front": "527c98033263ce3f", - "turns": "48730fde58119ba4", - "mouths": "d158200113cedb08" + "front": "2ccfb1a68b674e08", + "turns": "f8a4f892efce348f", + "mouths": "36330505f79d558d" }, "cat-patches": { "front": "c54c0776f27fe489",