diff --git a/CHANGELOG.md b/CHANGELOG.md index 977ad17..83d75ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- `@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. - `@botharness/pixel-avatar` 0.6.0: drawn hair pieces. The `bangs`, `leftSideHair`, `rightSideHair` and `backHair` Custom Part slots replace one built-in hair piece each, with the built-in choice kept underneath. A `hairColor` cell at tone 0 is live hair, shaded like built-in hair, so `hairPieceStart` flattens the worn piece (including hair ties) and an unchanged copy renders identically. `withCustomPart` and `wornPart` generalise the headpiece helpers; asset version 3 now means a recipe wearing at least one Custom Part. Every earlier recipe renders unchanged. diff --git a/packages/avatar/README.md b/packages/avatar/README.md index 5faff0d..510f98e 100644 --- a/packages/avatar/README.md +++ b/packages/avatar/README.md @@ -25,5 +25,6 @@ const svg = pixelAvatarSvg(recipe); // 32×32 viewBox, crisp edges, no ids or sc - `REPLACE_PART_SLOTS`, `replacePartStart(recipe, slot)`: drawn `outfit`, `accessory`, `beard`, `glasses`, `nose`, `cheeks`, `petals` and `flowerBase` parts (front layer only). A drawn part replaces the built-in part pixel for pixel in the layer that part paints into, while the built-in choice stays saved underneath. `replacePartStart` flattens the worn built-in part for this recipe: pixels that are exactly an appearance color at a tone become that color slot, and the rest become fixed colors, so an unchanged copy renders identically. Drawn face parts never cover the speaking mouth, and drawn parts are hidden wherever the species or headwear hides the built-in part. A drawn accessory replacing a helmet or hood no longer hides the hair. - `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. - `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 166dae6..ecfeb3d 100644 --- a/packages/avatar/package.json +++ b/packages/avatar/package.json @@ -1,6 +1,6 @@ { "name": "@botharness/pixel-avatar", - "version": "0.8.0", + "version": "0.9.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 05ab2a4..d71285e 100644 --- a/packages/avatar/src/figure.ts +++ b/packages/avatar/src/figure.ts @@ -10,6 +10,8 @@ import { import { AVATAR_COLORS, AVATAR_PIECE_COLORS, + AVATAR_PIECE_COLORS_V4, + builtInHeadpiece, detailedRecipe, hiddenChoices, wornPart, @@ -376,6 +378,19 @@ function hair( } } +const STRANDS: Record, { rows: string[]; dx: number }> = { + ahoge: { rows: ['XX.', '.XX', '.XX'], dx: 0 }, + curl: { rows: ['.XX.', 'X..X', '..XX', '.X..'], dx: -1 }, + double: { rows: ['X...X', '.X.X.', '.X.X.'], dx: -2 }, +}; + +function strandMask(style: NonNullable, s: number): Mask { + const { rows, dx } = STRANDS[style]; + const top = 4 - rows.length; + const left = Math.round(C + s) + dx; + return (x, y) => rows[y - top]?.[x - left] === 'X'; +} + function detailedHair( recipe: Recipe, s: number, @@ -387,6 +402,7 @@ function detailedHair( bands?: boolean; sides?: [Mask, Mask]; pieces?: Record; + strand?: Mask; } { const of = (style: Recipe['hair'] | undefined) => hair({ ...recipe, hair: style ?? 'none' }, s, b); @@ -409,8 +425,14 @@ function detailedHair( ? bangs.front(x, y) || back.front(x, y) : Math.abs(x + 0.5 - C - s) < 7 && bangs.front(x, y); const behind = length === 0 ? back.back : lengthen(back.back); + const tuft = recipe.strand && recipe.bangs !== 'none' ? strandMask(recipe.strand, s) : undefined; + const strand: Mask | undefined = tuft + ? (x, y) => tuft(x, y) && !fringe(x, y) && !sideOf(-1)(x, y) && !sideOf(1)(x, y) + : undefined; return { - front: (x, y) => fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y), + front: (x, y) => + fringe(x, y) || sideOf(-1)(x, y) || sideOf(1)(x, y) || (strand?.(x, y) ?? false), + ...(strand ? { strand } : {}), sides: [sideOf(-1), sideOf(1)], pieces: { bangs: fringe, leftSideHair: sideOf(-1), rightSideHair: sideOf(1), backHair: behind }, back: behind, @@ -778,6 +800,70 @@ function accessory(recipe: Recipe, g: Grid, s: number, face: Mask): void { } } +/** + * A built-in headpiece: the row that meets the head goes behind the hair, the rest in front. + * Turned, the far side of a pair is hidden, like a turned ear. + */ +function drawHeadpiece( + style: NonNullable>, + recipe: Recipe, + behind: Grid, + front: Grid, + s: number, + far: number, +): void { + const cx = Math.round(C + s); + const pair = ( + rows: readonly string[], + left: number, + right: number, + y: number, + pal: Record, + mirror = true, + ) => { + const tips = rows.slice(0, -1); + if (far >= 0) { + sprite(behind, rows, cx + left, y, pal); + sprite(front, tips, cx + left, y, pal); + } + if (far <= 0) { + sprite(behind, rows, cx + right, y, pal, mirror); + sprite(front, tips, cx + right, y, pal, mirror); + } + }; + switch (style) { + case 'catears': + pair(['X...', 'XX..', 'XPX.', 'XXXX'], -9, 5, 2, { X: recipe.hairColor, P: '#f4a3b5' }); + return; + case 'bunnyears': + pair(['WW', 'WP', 'WP', 'WP', 'WW'], -6, 4, 0, { W: '#f4f1ec', P: '#f4a3b5' }); + return; + case 'horseears': + pair( + ['.X.', 'XPX', 'XPX', 'XXX'], + -9, + 6, + 1, + { X: shade(recipe.hairColor, 0.9), P: '#f4a3b5' }, + false, + ); + return; + case 'horns': + pair(['.H', 'HH', 'Hh'], -8, 6, 1, { H: '#5b3a6e', h: '#7d5694' }); + return; + case 'halo': { + const ring = (x: number, y: number) => + ellipse(C + s, 1.6, 6.5, 1.8)(x, y) && !ellipse(C + s, 1.6, 4.4, 0.8)(x, y); + paint(behind, (x, y) => ring(x, y) && y < 2, GOLD); + paint(front, (x, y) => ring(x, y) && y >= 2, GOLD); + return; + } + case 'wings': + pair(['W..', 'WW.', 'WwW', '.ww'], -13, 10, 7, { W: '#f4f1ec', w: '#cfd6e2' }); + return; + } +} + const POINTED_EARS: Partial< Record, { rows: string[]; rise: number }> > = { @@ -996,7 +1082,10 @@ function renderFigure( : { ...builtin, front: (x, y) => - piece('bangs')(x, y) || piece('leftSideHair')(x, y) || piece('rightSideHair')(x, y), + piece('bangs')(x, y) || + piece('leftSideHair')(x, y) || + piece('rightSideHair')(x, y) || + (builtin.strand?.(x, y) ?? false), sides: [piece('leftSideHair'), piece('rightSideHair')], back: piece('backHair'), ...(drawnHair.some((drawn) => drawn.slot === 'backHair') ? { ties: [] } : {}), @@ -1052,6 +1141,9 @@ function renderFigure( placeCells(back, inked('backHair'), false); overlay(back, 'petals'); if (headpiece) placeCells(back, headpiece.back); + const wornHead = flower ? undefined : builtInHeadpiece(recipe); + const headFront = blank(); + if (wornHead) drawHeadpiece(wornHead, recipe, back, headFront, s, turned ? d : 0); const head = blank(); paint(head, face, skin); @@ -1114,8 +1206,10 @@ function renderFigure( const shadeHair = (g: Grid) => { const out = g.map((r) => [...r]); const cells = g.flatMap((row, y) => row.flatMap((c, x) => (c === hairColor ? [[x, y]] : []))); - const xs = cells.map(([x]) => x!); - const ys = cells.map(([, y]) => y!); + const framed = cells.filter(([x, y]) => !builtin.strand?.(x!, y!)); + const bounds = framed.length ? framed : cells; + const xs = bounds.map(([x]) => x!); + const ys = bounds.map(([, y]) => y!); const left = Math.min(...xs); const right = Math.max(...xs); const top = Math.min(...ys); @@ -1155,7 +1249,7 @@ function renderFigure( if (g[y - k]?.[x] === hairColor) out[y - k]![x] = shade(hairColor, 0.72); const ring = mix(hairColor, WHITE, 0.45); for (let x = Math.round(C + s) - 7; x <= Math.round(C + s) + 3; x++) { - const first = g.findIndex((row) => row[x] === hairColor); + const first = g.findIndex((row, y) => row[x] === hairColor && !builtin.strand?.(x, y)); if (first < 0) continue; const y = first + 2 + Math.round(Math.abs(x + 0.5 - (C + s - 2)) / 4); if (g[y]?.[x] === hairColor && x % 3 !== 0) out[y]![x] = ring; @@ -1169,6 +1263,8 @@ function renderFigure( const crown = blank(); placeCells(crown, inked('all-front'), false); if (headpiece) placeCells(crown, headpiece.front); + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) if (headFront[y]![x]) crown[y]![x] = headFront[y]![x]; const figure = blank(); for (const layer of [back, body, shadeHair(head), drawnOver, crown]) for (let y = 0; y < N; y++) @@ -1321,22 +1417,63 @@ function renderFigure( } const full = clip(outline(figure)); - AVATAR_PIECE_COLORS.forEach((key, i) => { - const color = recipe[key]; - const mask = masks.sides?.[i]; - if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return; + const variants = new Map(); + const recolor = (color: string) => { + const cached = variants.get(color.toLowerCase()); + if (cached) return cached; const variant = blank(); - const plain = { ...recipe, hairColor: color }; - for (const k of AVATAR_PIECE_COLORS) delete plain[k]; + variants.set(color.toLowerCase(), variant); + const plain: Recipe = { ...recipe, hairColor: color }; + for (const k of [...AVATAR_PIECE_COLORS, ...AVATAR_PIECE_COLORS_V4]) delete plain[k]; for (const cell of renderFigure(plain, yawDeg, {}, omit).cells) variant[cell.y]![cell.x] = cell.c; - const near = (x: number, y: number) => - mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1); - for (let y = 0; y < N; y++) - for (let x = 0; x < N; x++) - if (full[y]![x] && near(x, y) && !eyes[y]![x] && !glasses[y]![x]) - full[y]![x] = variant[y]![x]; - }); + return variant; + }; + if (recipe.assetVersion === 4) { + const over = (x: number, y: number) => + !!crown[y]?.[x] || !!drawnOver[y]?.[x] || !!eyes[y]?.[x] || !!glasses[y]?.[x]; + const hairAt = (x: number, y: number) => head[y]?.[x] === hairColor && !over(x, y); + const backAt = (x: number, y: number) => + back[y]?.[x] !== undefined && figure[y]![x] === back[y]![x]; + const owners: [string | undefined, Mask][] = [ + [flower ? undefined : recipe.backHairColor, (x, y) => masks.back(x, y) && backAt(x, y)], + [ + recipe.leftSideHairColor, + (x, y) => !!masks.sides?.[0](x, y) && (hairAt(x, y) || backAt(x, y)), + ], + [ + recipe.rightSideHairColor, + (x, y) => !!masks.sides?.[1](x, y) && (hairAt(x, y) || backAt(x, y)), + ], + [ + recipe.bangsColor, + (x, y) => (builtin.pieces ? piece('bangs')(x, y) : false) && hairAt(x, y), + ], + [recipe.strandColor, (x, y) => (builtin.strand?.(x, y) ?? false) && hairAt(x, y)], + ]; + for (const [color, owns] of owners) { + if (color === undefined || color.toLowerCase() === hairColor.toLowerCase()) continue; + const variant = recolor(color); + const near = (x: number, y: number) => + owns(x - 1, y) || owns(x + 1, y) || owns(x, y - 1) || owns(x, y + 1); + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) + if (full[y]![x] && !over(x, y) && (owns(x, y) || (!figure[y]![x] && near(x, y)))) + full[y]![x] = variant[y]![x]; + } + } else + AVATAR_PIECE_COLORS.forEach((key, i) => { + const color = recipe[key]; + const mask = masks.sides?.[i]; + if (color === undefined || !mask || color.toLowerCase() === hairColor.toLowerCase()) return; + const variant = recolor(color); + const near = (x: number, y: number) => + mask(x, y) || mask(x - 1, y) || mask(x + 1, y) || mask(x, y - 1) || mask(x, y + 1); + for (let y = 0; y < N; y++) + for (let x = 0; x < N; x++) + if (full[y]![x] && near(x, y) && !eyes[y]![x] && !glasses[y]![x]) + full[y]![x] = variant[y]![x]; + }); const bodyCells = blank(); const headCells = blank(); for (let y = 0; y < N; y++) @@ -1392,6 +1529,44 @@ export function hairPieceStart( return { slot, front, back: [] }; } +function cellSource(recipe: PixelAvatarRecipe): (c: string) => readonly [PartCell[2], PartTone] { + return (c) => { + for (const key of AVATAR_COLORS) + for (const tone of [0, -1, 1, -2, 2] as const) + if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone]; + return [c as PartCell[2], 0]; + }; +} + +/** + * The headpiece as a Custom Part to start drawing from: the drawn headpiece worn, or the + * built-in headpiece flattened for this recipe in the front pose, its base on the back layer and + * the rest on the front layer, so an unchanged copy renders identically facing front. + */ +export function headpieceStart(recipe: PixelAvatarRecipe): { + slot: 'headpiece'; + front: PartCell[]; + back: PartCell[]; +} { + const worn = wornPart(recipe, 'headpiece'); + if (worn) return { slot: 'headpiece', front: [...worn.front], back: [...worn.back] }; + const style = builtInHeadpiece(recipe); + const behind = blank(); + const over = blank(); + if (style) drawHeadpiece(style, recipe, behind, over, 0, 0); + const source = cellSource(recipe); + const front: PartCell[] = []; + const back: PartCell[] = []; + for (let y = 0; y < 16; y++) + for (let x = 0; x < N; x++) { + const c = over[y]![x]; + const b = behind[y]![x]; + if (c) front.push([x, y, ...source(c)]); + if (b && b !== c) back.push([x, y, ...source(b)]); + } + return { slot: 'headpiece', front, back }; +} + /** * The part in a replacement slot as a Custom Part to start drawing from: the drawn part worn * there, or the built-in part flattened for this recipe in the front pose. Each pixel becomes a @@ -1417,12 +1592,7 @@ export function replacePartStart( : 'figure'; const shown = renderFigure(recipe, 0, {}, new Set()).grids[layer]; const without = renderFigure(recipe, 0, {}, new Set([slot])).grids[layer]; - const source = (c: string): readonly [PartCell[2], PartTone] => { - for (const key of AVATAR_COLORS) - for (const tone of [0, -1, 1, -2, 2] as const) - if (TONES[tone](recipe[key].toLowerCase()) === c) return [key, tone]; - return [c as PartCell[2], 0]; - }; + const source = cellSource(recipe); const front: PartCell[] = []; for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) { diff --git a/packages/avatar/src/index.ts b/packages/avatar/src/index.ts index 5962903..8020bb5 100644 --- a/packages/avatar/src/index.ts +++ b/packages/avatar/src/index.ts @@ -1,6 +1,7 @@ export type { PixelCell } from '@botharness/pixel-morph'; export { hairPieceStart, + headpieceStart, replacePartStart, partToneColor, pixelFigure, @@ -16,6 +17,9 @@ export { AVATAR_PARTS, AVATAR_PARTS_V2, AVATAR_PIECE_COLORS, + AVATAR_PIECE_COLORS_V4, + AVATAR_STRANDS, + AVATAR_HEADPIECES, AVATAR_PRESETS, AVATAR_RANGES, AVATAR_SPECIES, @@ -42,6 +46,13 @@ export { type PixelAvatarRecipeV1, type PixelAvatarRecipeV2, type PixelAvatarRecipeV3, + type PixelAvatarRecipeV4, + type AvatarPieceColorV4, + type AvatarStrand, + type AvatarHeadpiece, + withPieces, + withBuiltInHeadpiece, + builtInHeadpiece, withHeadpiece, withCustomPart, wornPart, diff --git a/packages/avatar/src/recipe.ts b/packages/avatar/src/recipe.ts index 5905978..af759be 100644 --- a/packages/avatar/src/recipe.ts +++ b/packages/avatar/src/recipe.ts @@ -165,6 +165,29 @@ export type AvatarSpecies = (typeof AVATAR_SPECIES)[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]; +/** + * Asset version 4 adds a color for every other hair piece; an absent piece uses `hairColor`. + * `strandColor` colors the single strand (`strand`). + */ +export const AVATAR_PIECE_COLORS_V4 = ['bangsColor', 'backHairColor', 'strandColor'] as const; +export type AvatarPieceColorV4 = (typeof AVATAR_PIECE_COLORS_V4)[number]; +/** A single strand of hair standing up from the crown (asset version 4). */ +export const AVATAR_STRANDS = ['ahoge', 'curl', 'double'] as const; +export type AvatarStrand = (typeof AVATAR_STRANDS)[number]; +/** + * Built-in headpieces (asset version 4), worn in the headpiece slot alongside an accessory and + * drawn partly behind the hair. The first five were accessories in earlier versions. + */ +export const AVATAR_HEADPIECES = [ + 'catears', + 'bunnyears', + 'horseears', + 'horns', + 'halo', + 'wings', +] as const; +export type AvatarHeadpiece = (typeof AVATAR_HEADPIECES)[number]; +type V4Key = AvatarPieceColorV4 | 'strand'; /** Part choices that exist only in asset version 2, added after every version 1 choice. */ export const AVATAR_PARTS_V2 = { @@ -193,7 +216,9 @@ export type PixelAvatarRecipeV1 = Meta & { assetVersion: 1 } & { } & { [P in AvatarRange]?: number } & { species?: never; rightSideHair?: never; -} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never }; +} & { [P in AvatarPieceColor | AvatarExtraPart]?: never } & { [P in CustomPartKey]?: never } & { + [P in V4Key]?: never; +}; /** * Asset version 2: a species, the full split hair and geometry, a separate right side hair @@ -208,7 +233,7 @@ export type PixelAvatarRecipeV2 = Meta & { assetVersion: 2 } & { rightSideHair: (typeof AVATAR_HAIR_PARTS)['sideHair'][number]; } & { [P in AvatarPieceColor]?: string } & { [P in AvatarExtraPart]?: (typeof AVATAR_EXTRA_PARTS)[P][number]; -} & { [P in CustomPartKey]?: never }; +} & { [P in CustomPartKey]?: never } & { [P in V4Key]?: never }; /** The recipe key that embeds the Custom Part worn in each slot. */ export const CUSTOM_PART_KEYS = { @@ -237,7 +262,26 @@ export type PixelAvatarRecipeV3 = Omit & { + assetVersion: 4; +} & { [P in Exclude]?: PixelCustomPart } & { + headpiece?: PixelCustomPart | AvatarHeadpiece; + strand?: AvatarStrand; +} & { [P in AvatarPieceColorV4]?: string }; + +export type PixelAvatarRecipe = + | PixelAvatarRecipeV1 + | PixelAvatarRecipeV2 + | PixelAvatarRecipeV3 + | PixelAvatarRecipeV4; export const DEFAULT_RECIPE: PixelAvatarRecipeV1 = { schemaVersion: 1, @@ -269,10 +313,27 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe const r = value as Record; const detailed = DETAIL_KEYS.filter((key) => Object.hasOwn(r, key)).length; if (detailed !== 0 && detailed !== DETAIL_KEYS.length) return false; + const v4 = r['assetVersion'] === 4; const v3 = r['assetVersion'] === 3; - const v2 = r['assetVersion'] === 2 || v3; - const worn = PART_KEY_ENTRIES.filter(([, key]) => Object.hasOwn(r, key)); - if (v3 ? worn.length === 0 : worn.length !== 0) return false; + const v2 = r['assetVersion'] === 2 || v3 || v4; + const builtInHead = v4 && typeof r['headpiece'] === 'string'; + if (builtInHead && !(AVATAR_HEADPIECES as readonly unknown[]).includes(r['headpiece'])) + return false; + const worn = PART_KEY_ENTRIES.filter( + ([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)); + if ( + newer.length !== 0 && + (!v4 || + !AVATAR_PIECE_COLORS_V4.every( + (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']))) + ) + return false; for (const [slot, key] of worn) { const part = r[key]; if (!isPixelCustomPart(part) || part.slot !== slot) return false; @@ -281,7 +342,9 @@ export function isPixelAvatarRecipe(value: unknown): value is PixelAvatarRecipe const extras = (Object.keys(AVATAR_EXTRA_PARTS) as AvatarExtraPart[]).filter((key) => Object.hasOwn(r, key), ); - const v2Keys = v2 ? 2 + pieces.length + extras.length + worn.length : 0; + const v2Keys = v2 + ? 2 + pieces.length + extras.length + worn.length + newer.length + (builtInHead ? 1 : 0) + : 0; if (Object.keys(r).length !== Object.keys(DEFAULT_RECIPE).length + detailed + v2Keys) return false; if ( @@ -332,11 +395,17 @@ export function canonicalRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); for (const key of Object.keys(AVATAR_EXTRA_PARTS) as AvatarExtraPart[]) if (recipe[key] !== undefined) canonical[key] = recipe[key]; - if (recipe.assetVersion === 3) + if (recipe.assetVersion === 3 || recipe.assetVersion === 4) for (const [, key] of PART_KEY_ENTRIES) { const part = recipe[key]; - if (part !== undefined) canonical[key] = canonicalCustomPart(part); + if (part !== undefined) + canonical[key] = typeof part === 'string' ? part : canonicalCustomPart(part); } + if (recipe.assetVersion === 4) { + if (recipe.strand !== undefined) canonical['strand'] = recipe.strand; + for (const key of AVATAR_PIECE_COLORS_V4) + if (recipe[key] !== undefined) canonical[key] = recipe[key].toLowerCase(); + } } return canonical as PixelAvatarRecipe; } @@ -349,9 +418,14 @@ const HAIR_PIECES = ['bangs', 'sideHair', 'rightSideHair', 'backHair'] as const; * the species or headwear changes back. */ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { + const strand = + recipe.strand && (recipe.species === 'flower' || recipe.bangs !== 'none') ? ['strand'] : []; + const bald = recipe.strand && recipe.bangs === 'none' ? ['strand'] : []; if (recipe.species === 'flower') return [ ...HAIR_PIECES, + ...strand, + ...(typeof recipe.headpiece === 'string' ? ['headpiece'] : []), 'outfit', 'accessory', 'eyes', @@ -366,8 +440,9 @@ export function hiddenChoices(recipe: PixelAvatarRecipe): readonly string[] { (recipe.accessory === 'helmet' || recipe.accessory === 'hood') && !wornPart(recipe, 'accessory') ) - hidden.push(...HAIR_PIECES); + hidden.push(...HAIR_PIECES, ...strand); if (recipe.species === 'dwarf') hidden.push('nose'); + if (!hidden.includes('strand')) hidden.push(...bald); return hidden; } @@ -377,6 +452,10 @@ 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: PixelAvatarRecipeV4, + species: AvatarSpecies, +): PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipeV3, species: AvatarSpecies, @@ -388,54 +467,104 @@ export function withSpecies( export function withSpecies( recipe: PixelAvatarRecipe, species: AvatarSpecies, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3; +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withSpecies( recipe: PixelAvatarRecipe, species: AvatarSpecies, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { const detailed = detailedRecipe(recipe); const previous = recipe.species ?? 'human'; const suggested = AVATAR_SPECIES_SWATCHES[previous].includes(recipe.skinColor.toLowerCase()); return { ...detailed, - assetVersion: recipe.assetVersion === 3 ? 3 : 2, + assetVersion: recipe.assetVersion === 1 ? 2 : recipe.assetVersion, species, rightSideHair: detailed.rightSideHair ?? detailed.sideHair!, skinColor: suggested && previous !== species ? AVATAR_SPECIES_SWATCHES[species][0]! : recipe.skinColor, - } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; + } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; } /** * Returns the recipe wearing `part` in `slot`, or with that slot's part taken off. The recipe - * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise. + * embeds its own copy; it is asset version 3 while it wears any part and version 2 otherwise, + * and a version 4 recipe stays version 4. */ +export function withCustomPart( + recipe: PixelAvatarRecipeV4, + slot: PartSlot, + part: PixelCustomPart | undefined, +): PixelAvatarRecipeV4; +export function withCustomPart( + recipe: PixelAvatarRecipe, + slot: PartSlot, + part: PixelCustomPart | undefined, +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4; export function withCustomPart( recipe: PixelAvatarRecipe, slot: PartSlot, part: PixelCustomPart | undefined, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { const base: Record = { ...(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; const wearing = PART_KEY_ENTRIES.some(([, k]) => base[k] !== undefined); return { ...base, assetVersion: wearing ? 3 : 2 } as PixelAvatarRecipeV2 | PixelAvatarRecipeV3; } +const MOVED_HEADPIECES: readonly string[] = AVATAR_HEADPIECES; + +/** + * Returns the recipe as asset version 4, keeping every choice. An accessory that is now a + * headpiece moves into the empty headpiece slot, freeing the accessory slot; call this only when + * the Human edits, so saved recipes keep rendering as they were. + */ +export function withPieces(recipe: PixelAvatarRecipe): PixelAvatarRecipeV4 { + if (recipe.assetVersion === 4) return recipe; + const base: Record = { + ...(recipe.assetVersion === 1 ? withSpecies(recipe, 'human') : recipe), + assetVersion: 4, + }; + if (base['headpiece'] === undefined && MOVED_HEADPIECES.includes(recipe.accessory)) { + base['headpiece'] = recipe.accessory; + base['accessory'] = 'none'; + } + return base as PixelAvatarRecipeV4; +} + +/** Returns the recipe as asset version 4 wearing a built-in headpiece, or none. */ +export function withBuiltInHeadpiece( + recipe: PixelAvatarRecipe, + headpiece: AvatarHeadpiece | undefined, +): PixelAvatarRecipeV4 { + const base: Record = { ...withPieces(recipe) }; + delete base['headpiece']; + if (headpiece !== undefined) base['headpiece'] = headpiece; + return base as PixelAvatarRecipeV4; +} + +/** The built-in headpiece worn, if any. */ +export function builtInHeadpiece(recipe: PixelAvatarRecipe): AvatarHeadpiece | undefined { + return typeof recipe.headpiece === 'string' ? recipe.headpiece : undefined; +} + /** `withCustomPart` for the headpiece slot. */ export function withHeadpiece( recipe: PixelAvatarRecipe, part: PixelCustomPart | undefined, -): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 { +): PixelAvatarRecipeV2 | PixelAvatarRecipeV3 | PixelAvatarRecipeV4 { return withCustomPart(recipe, 'headpiece', part); } /** The Custom Part worn in a slot, if any. */ export function wornPart(recipe: PixelAvatarRecipe, slot: PartSlot): PixelCustomPart | undefined { - return recipe.assetVersion === 3 ? recipe[CUSTOM_PART_KEYS[slot]] : undefined; + if (recipe.assetVersion !== 3 && recipe.assetVersion !== 4) return undefined; + const part = recipe[CUSTOM_PART_KEYS[slot]]; + return typeof part === 'string' ? undefined : part; } export function detailedRecipe(recipe: PixelAvatarRecipe): PixelAvatarRecipe { diff --git a/packages/avatar/test/fixtures/pieces-golden.json b/packages/avatar/test/fixtures/pieces-golden.json new file mode 100644 index 0000000..c2fe7c2 --- /dev/null +++ b/packages/avatar/test/fixtures/pieces-golden.json @@ -0,0 +1,82 @@ +{ + "headpiece-catears": { + "front": "9452c7b6cbba8a2e", + "turns": "d675a75f2aafb1c2", + "mouths": "9ed97f6b094b2333" + }, + "headpiece-bunnyears": { + "front": "13ec82d7ed4baeca", + "turns": "9d19fbd1b2718d3f", + "mouths": "6b0c082f0e540fdc" + }, + "headpiece-horseears": { + "front": "101cf7fad8ac9ef9", + "turns": "4d94765de79cab1e", + "mouths": "d9d03b7dd9f2b78b" + }, + "headpiece-horns": { + "front": "a22a9b6e8f4c86b2", + "turns": "0ebce5c641dfd4a7", + "mouths": "28fd225f08656ede" + }, + "headpiece-halo": { + "front": "954f158bc99d18ff", + "turns": "4b639936dfc72bc3", + "mouths": "8dd6aae7c1376d78" + }, + "headpiece-wings": { + "front": "63f0f10473e8bddb", + "turns": "77574a767656e3fc", + "mouths": "c5202a98f51fc4dd" + }, + "strand-ahoge": { + "front": "ee2fac34ba9d6c4d", + "turns": "af44e57eafef32a3", + "mouths": "4827a671aa7dfb51" + }, + "strand-ahoge-colored": { + "front": "a951e535b5043670", + "turns": "c27ead73fbd610a2", + "mouths": "dd96dad23ca750ae" + }, + "strand-curl": { + "front": "7dbaafca7c446eb2", + "turns": "3ecd3d16ae16f163", + "mouths": "81e738cb55a9869b" + }, + "strand-curl-colored": { + "front": "685d6f59cf552b73", + "turns": "c5fa19f6f0d5a2fa", + "mouths": "b073fb707a52afdb" + }, + "strand-double": { + "front": "644432ae0b738f7c", + "turns": "26a7e3608b524012", + "mouths": "8298ff3b447162fe" + }, + "strand-double-colored": { + "front": "9f74b6b27f2d5b5f", + "turns": "11b3672bc4e76e76", + "mouths": "0f918776fc684095" + }, + "pieces-all": { + "front": "eceba36d90e9a66d", + "turns": "3dbd9c63d96af99b", + "mouths": "4a0d9247130dfda9" + }, + "pieces-bangs": { + "front": "4c3d64e4fdd04102", + "turns": "5945eabddfdfbc39", + "mouths": "eb768b87bf6fea99" + }, + "pieces-back": { + "front": "667008479e90c145", + "turns": "768506047c44fc4d", + "mouths": "a78f2efd0d59344e" + }, + "migrated-ahoge-catears": { + "front": "b89b936c9253c4d3", + "turns": "eba896bae3422913", + "mouths": "efdcdcf1ad1cd4f4" + } +} diff --git a/packages/avatar/test/pieces.test.ts b/packages/avatar/test/pieces.test.ts new file mode 100644 index 0000000..7dcf7f5 --- /dev/null +++ b/packages/avatar/test/pieces.test.ts @@ -0,0 +1,240 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + AVATAR_HEADPIECES, + AVATAR_STRANDS, + AVATAR_TURNS, + DEFAULT_RECIPE, + builtInHeadpiece, + headpieceStart, + canonicalRecipe, + hiddenChoices, + isPixelAvatarRecipe, + pixelAvatarSvg, + pixelFigure, + withBuiltInHeadpiece, + withCustomPart, + withPieces, + withSpecies, + wornPart, + type PixelAvatarRecipe, + type PixelCustomPart, +} 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 human = withPieces(DEFAULT_RECIPE); +const long = { + ...human, + bangs: 'bob', + sideHair: 'long', + rightSideHair: 'long', + backHair: 'long', +} as const; +const crown: PixelCustomPart = { slot: 'headpiece', front: [[16, 1, '#efb93f', 0]], back: [] }; + +describe('every hair piece colored and built-in headpieces (asset version 4)', () => { + it('accepts version 4 fields only in version 4 and keeps earlier recipes valid', () => { + expect(human.assetVersion).toBe(4); + expect(isPixelAvatarRecipe(human)).toBe(true); + expect( + isPixelAvatarRecipe({ + ...long, + bangsColor: '#F06292', + backHairColor: '#3fc1b8', + strand: 'curl', + strandColor: '#e2b04a', + headpiece: 'wings', + accessory: 'bow', + }), + ).toBe(true); + expect(isPixelAvatarRecipe(withCustomPart(human, 'headpiece', crown))).toBe(true); + expect(isPixelAvatarRecipe({ ...human, headpiece: 'tiara' })).toBe(false); + expect(isPixelAvatarRecipe({ ...human, strand: 'mohawk' })).toBe(false); + expect(isPixelAvatarRecipe({ ...human, bangsColor: 'pink' })).toBe(false); + const v2 = withSpecies(DEFAULT_RECIPE, 'human'); + expect(isPixelAvatarRecipe({ ...v2, bangsColor: '#f06292' })).toBe(false); + expect(isPixelAvatarRecipe({ ...v2, strand: 'ahoge' })).toBe(false); + expect( + isPixelAvatarRecipe({ ...withCustomPart(v2, 'headpiece', crown), headpiece: 'halo' }), + ).toBe(false); + expect(canonicalRecipe({ ...human, bangsColor: '#F06292', headpiece: 'halo' })).toEqual({ + ...human, + bangsColor: '#f06292', + headpiece: 'halo', + }); + }); + + it('moves a headpiece accessory into the headpiece slot only through withPieces', () => { + const saved = { ...DEFAULT_RECIPE, accessory: 'catears' } as const; + expect(pixelAvatarSvg(saved)).toBe(pixelAvatarSvg({ ...DEFAULT_RECIPE, accessory: 'catears' })); + const edited = withPieces(saved); + expect(edited).toMatchObject({ assetVersion: 4, accessory: 'none', headpiece: 'catears' }); + expect(builtInHeadpiece(edited)).toBe('catears'); + expect(wornPart(edited, 'headpiece')).toBeUndefined(); + const both = { ...edited, accessory: 'bow' } as const; + expect(isPixelAvatarRecipe(both)).toBe(true); + expect(changed(withBuiltInHeadpiece(both, undefined), both).length).toBeGreaterThan(0); + expect(changed({ ...edited, accessory: 'none' }, both).length).toBeGreaterThan(0); + const drawn = withPieces({ + ...withCustomPart(DEFAULT_RECIPE, 'headpiece', crown), + accessory: 'horns', + }); + expect(drawn.accessory).toBe('horns'); + expect(wornPart(drawn, 'headpiece')).toEqual(crown); + expect(withCustomPart(both, 'headpiece', crown)).toMatchObject({ + assetVersion: 4, + headpiece: crown, + }); + expect(withCustomPart(both, 'headpiece', undefined).headpiece).toBeUndefined(); + expect(withCustomPart(human, 'bangs', undefined).assetVersion).toBe(4); + }); + + it('draws a headpiece base behind the hair and hides the far side when turned', () => { + const ears = withBuiltInHeadpiece(human, 'catears'); + const asAccessory = { ...human, accessory: 'catears' } as const; + const behind = changed(ears, asAccessory); + expect(behind.length).toBeGreaterThan(0); + expect(behind.every((key) => Number(key.split(',')[1]) <= 8)).toBe(true); + for (const yaw of [-30, 30]) { + const diff = changed(ears, human, yaw).map((key) => Number(key.split(',')[0])); + const sides = new Set(diff.map((x) => (x < 16 ? 'left' : 'right'))); + expect(sides.size).toBe(1); + expect([...sides][0]).toBe(yaw > 0 ? 'left' : 'right'); + } + expect( + new Set( + changed(withBuiltInHeadpiece(human, 'halo'), human, 30).map( + (k) => Number(k.split(',')[0]!) < 16, + ), + ), + ).toEqual(new Set([true, false])); + }); + + it('colors each hair piece on its own and leaves the rest of the face alone', () => { + const plain = cells(long); + for (const [key, rows] of [ + ['bangsColor', [0, 17]], + ['backHairColor', [0, 32]], + ['leftSideHairColor', [6, 32]], + ] as const) { + const recolored = { ...long, [key]: '#3fc1b8' } as PixelAvatarRecipe; + const diff = changed(long, recolored); + expect(diff.length).toBeGreaterThan(4); + for (const k of diff) { + const y = Number(k.split(',')[1]); + expect(y).toBeGreaterThanOrEqual(rows[0]); + expect(y).toBeLessThan(rows[1]); + } + if (key === 'leftSideHairColor') + expect(diff.every((k) => Number(k.split(',')[0]) < 16)).toBe(true); + expect(changed({ ...long, [key]: long.hairColor } as PixelAvatarRecipe, long)).toEqual([]); + } + expect(plain.size).toBeGreaterThan(0); + const strand = { ...human, strand: 'ahoge' } as const; + const tinted = changed(strand, { ...strand, strandColor: '#e2b04a' }); + expect(tinted.length).toBeGreaterThan(0); + expect(tinted.every((k) => Number(k.split(',')[1]) <= 5)).toBe(true); + expect(hiddenChoices({ ...withSpecies(strand, 'flower') })).toContain('strand'); + }); + + it('flattens a built-in headpiece into a Custom Part that renders identically facing front', () => { + for (const headpiece of AVATAR_HEADPIECES) { + const wearing = withBuiltInHeadpiece({ ...human, accessory: 'hairclip' }, headpiece); + const start = headpieceStart(wearing); + expect(start.front.length + start.back.length).toBeGreaterThan(0); + const copy = withCustomPart(wearing, 'headpiece', start); + expect(builtInHeadpiece(copy)).toBeUndefined(); + expect(changed(copy, wearing)).toEqual([]); + } + const ears = headpieceStart(withBuiltInHeadpiece(human, 'catears')); + expect(ears.front.some(([, , color]) => color === 'hairColor')).toBe(true); + expect(headpieceStart(withCustomPart(human, 'headpiece', crown))).toEqual(crown); + expect(headpieceStart(human)).toEqual({ slot: 'headpiece', front: [], back: [] }); + }); + + it('keeps piece colors to hair pixels: no-ops, headpieces, eyes and petals stay put', () => { + const sided = { ...long, leftSideHairColor: '#3fc1b8' } as const; + for (const yaw of [0, -30, 30]) + expect(changed(sided, { ...sided, bangsColor: sided.hairColor }, yaw)).toEqual([]); + const ears = withBuiltInHeadpiece({ ...human, bangs: 'curly' }, 'catears'); + const tinted = { ...ears, bangsColor: '#3fc1b8' }; + for (const key of ['8,3', '23,3', '7,4', '9,4', '22,4', '24,4']) + expect(cells(tinted).get(key)).toBe(cells(ears).get(key)); + const eyed = { + ...human, + bangs: 'hime', + sideHair: 'crop', + rightSideHair: 'crop', + backHair: 'long', + eyeColor: '#123456', + } as const; + for (const yaw of [-30, 30]) { + const head = pixelFigure({ ...eyed, bangsColor: '#3fc1b8' }, yaw).head; + const face = head.indexOf('bh-illustrated-face'); + expect(head.slice(0, face < 0 ? head.length : face)).not.toContain('#123456'); + } + const flower = withSpecies(human, 'flower'); + expect(changed(flower, { ...flower, backHairColor: '#3fc1b8' })).toEqual([]); + }); + + it('keeps withPieces idempotent and hides a strand without bangs', () => { + const picked = { ...human, accessory: 'catears' } as const; + expect(withPieces(picked)).toBe(picked); + const bald = { ...human, bangs: 'none', strand: 'ahoge' } as const; + expect(hiddenChoices(bald)).toContain('strand'); + expect(changed(bald, { ...bald, strand: undefined } as PixelAvatarRecipe)).toEqual([]); + const plain = { ...human, bangs: 'crop' } as const; + const tufted = { ...plain, strand: 'double' } as const; + expect( + changed(plain, tufted).every((key) => { + const [x, y] = key.split(',').map(Number); + return y! <= 4 && Math.abs(x! - 16) <= 4; + }), + ).toBe(true); + }); + + it('matches the version 4 golden output', () => { + const recipes: Record = {}; + for (const headpiece of AVATAR_HEADPIECES) + recipes[`headpiece-${headpiece}`] = withBuiltInHeadpiece( + { ...human, accessory: 'hairclip' }, + headpiece, + ); + for (const strand of AVATAR_STRANDS) { + recipes[`strand-${strand}`] = { ...human, strand }; + recipes[`strand-${strand}-colored`] = { ...human, strand, strandColor: '#e2b04a' }; + } + recipes['pieces-all'] = { + ...long, + bangsColor: '#f06292', + backHairColor: '#3fc1b8', + leftSideHairColor: '#e2b04a', + rightSideHairColor: '#5a7be0', + }; + recipes['pieces-bangs'] = { ...long, bangsColor: '#f06292' }; + recipes['pieces-back'] = { ...long, backHairColor: '#3fc1b8' }; + recipes['migrated-ahoge-catears'] = withPieces({ + ...DEFAULT_RECIPE, + hair: 'ahoge', + accessory: 'catears', + }); + const actual = Object.fromEntries(Object.entries(recipes).map(([k, r]) => [k, render(r)])); + const file = new URL('./fixtures/pieces-golden.json', import.meta.url); + if (process.env['BOTPIXEL_WRITE_PIECES_GOLDEN'] === '1') + writeFileSync(file, `${JSON.stringify(actual, null, 2)}\n`); + expect(actual).toEqual(JSON.parse(readFileSync(file, 'utf8'))); + }); +});