fix: preserve user figures in avatar imager

This commit is contained in:
Simo committed 2026-08-09 21:48:43 +02:00
1 parent 49185dd7a9
commit 4a6fcd050e
5 files changed
+12 -251

No files matched your search

-89
View File
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import {
convertFigurePart,
convertFigureString,
getNewFormatFigure,
} from "./figure-convert";
describe("convertFigurePart", () => {
it("converts head part", () => {
expect(convertFigurePart("hr-11782-40-40")).toBe("hd-180-1");
expect(convertFigurePart("hr-11782-41-41")).toBe("hd-180-2");
});
it("converts hair part", () => {
expect(convertFigurePart("hd-180-7-14")).toBe("hd-180-1");
expect(convertFigurePart("hd-180-8-14")).toBe("hd-180-2");
});
it("converts face part", () => {
expect(convertFigurePart("ch-11592-66")).toBe("ch-255-66");
});
it("converts shirt part", () => {
expect(convertFigurePart("lg-10726-79-1408")).toBe("lg-280-110");
});
it("converts shorts part", () => {
expect(convertFigurePart("sh-11764-1408")).toBe("sh-305-62");
});
it("converts shoes part", () => {
expect(convertFigurePart("ha-11958-70-1408")).toBe("ha-310-70");
});
it("converts waist part", () => {
expect(convertFigurePart("wa-2007-0")).toBe("wa-200-0");
});
it("returns original for unknown parts", () => {
expect(convertFigurePart("unknown-part")).toBe("unknown-part");
expect(convertFigurePart("hd-999-99")).toBe("hd-999-99");
});
});
describe("convertFigureString", () => {
it("converts full figure string from old to new format (with defaults: exclude shoes, waist, dedupe head)", () => {
const oldFigure =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0";
const newFigure = "hd-180-1.ch-255-66.lg-280-110.sh-305-62";
expect(convertFigureString(oldFigure)).toBe(newFigure);
});
it("includes all parts when options disabled", () => {
const oldFigure =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0";
const newFigure =
"hd-180-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62.ha-310-70.wa-200-0";
expect(
convertFigureString(oldFigure, {
excludeShoes: false,
excludeWaist: false,
dedupeHead: false,
}),
).toBe(newFigure);
});
it("handles empty string", () => {
expect(convertFigureString("")).toBe("");
});
it("handles already converted figure", () => {
const newFigure = "hd-180-1.ch-255-66.lg-280-110";
expect(convertFigureString(newFigure)).toBe(newFigure);
});
});
describe("getNewFormatFigure", () => {
it("converts figure using getNewFormatFigure with defaults", () => {
const oldFigure = "hr-11782-40-40.hd-180-7-14.ch-11592-66";
expect(getNewFormatFigure(oldFigure)).toBe("hd-180-1.ch-255-66");
});
it("allows custom options", () => {
const oldFigure = "hr-11782-40-40.hd-180-7-14.ch-11592-66.ha-11958-70-1408";
expect(getNewFormatFigure(oldFigure, { excludeShoes: false })).toBe(
"hd-180-1.ch-255-66.ha-310-70",
);
});
});
-155
View File
@@ -1,155 +0,0 @@
/**
* Figure string converter: old Habbo format -> epicnabbo.nl format.
*
* Old format uses long IDs (e.g., hr-11782-40-40, hd-180-7-14)
* New format uses short IDs (e.g., hd-180-1, ch-255-66)
*/
interface FigurePartMap {
[key: string]: string;
}
const HEAD_MAP: FigurePartMap = {
"hr-11782-40-40": "hd-180-1",
"hr-11782-41-41": "hd-180-2",
"hr-11782-42-42": "hd-180-3",
"hr-11782-43-43": "hd-180-4",
"hr-11782-44-44": "hd-180-5",
"hr-11782-45-45": "hd-180-6",
"hr-11782-46-46": "hd-180-7",
"hr-11782-47-47": "hd-180-8",
"hr-11782-48-48": "hd-180-9",
"hr-11782-49-49": "hd-180-10",
};
const HAIR_MAP: FigurePartMap = {
"hd-180-7-14": "hd-180-1",
"hd-180-8-14": "hd-180-2",
"hd-180-9-14": "hd-180-3",
"hd-180-10-14": "hd-180-4",
"hd-180-11-14": "hd-180-5",
"hd-180-12-14": "hd-180-6",
"hd-180-13-14": "hd-180-7",
"hd-180-14-14": "hd-180-8",
"hd-180-15-14": "hd-180-9",
"hd-180-16-14": "hd-180-10",
};
const FACE_MAP: FigurePartMap = {
"ch-11592-66": "ch-255-66",
"ch-11593-66": "ch-255-67",
"ch-11594-66": "ch-255-68",
"ch-11595-66": "ch-255-69",
};
const SHIRT_MAP: FigurePartMap = {
"lg-10726-79-1408": "lg-280-110",
"lg-10727-79-1408": "lg-280-111",
"lg-10728-79-1408": "lg-280-112",
"lg-10729-79-1408": "lg-280-113",
"lg-10730-79-1408": "lg-280-114",
};
const SHORTS_MAP: FigurePartMap = {
"sh-11764-1408": "sh-305-62",
"sh-11765-1408": "sh-305-63",
"sh-11766-1408": "sh-305-64",
"sh-11767-1408": "sh-305-65",
};
const SHOES_MAP: FigurePartMap = {
"ha-11958-70-1408": "ha-310-70",
"ha-11959-70-1408": "ha-310-71",
"ha-11960-70-1408": "ha-310-72",
};
const WAIST_MAP: FigurePartMap = {
"wa-2007-0": "wa-200-0",
};
function getMapForPrefix(prefix: string): FigurePartMap | null {
if (prefix === "hr") return HEAD_MAP;
if (prefix === "hd") return HAIR_MAP;
if (prefix === "ch") return FACE_MAP;
if (prefix === "lg") return SHIRT_MAP;
if (prefix === "sh") return SHORTS_MAP;
if (prefix === "ha") return SHOES_MAP;
if (prefix === "wa") return WAIST_MAP;
return null;
}
/**
* Convert a single figure part from old format to new format.
* Example: "hr-11782-40-40" -> "hd-180-1"
*/
export function convertFigurePart(part: string): string {
const dashIndex = part.indexOf("-");
if (dashIndex === -1) return part;
const prefix = part.slice(0, dashIndex);
const map = getMapForPrefix(prefix);
if (!map) return part;
return map[part] ?? part;
}
/**
* Convert a full figure string from old format to new format.
* Example: "hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0"
* -> "hd-180-1.hd-180-1.ch-255-66.lg-280-110.sh-305-62.ha-310-70.wa-200-0"
*/
export interface ConvertFigureOptions {
/** Exclude shoes (ha-) */
excludeShoes?: boolean;
/** Exclude waist (wa-) */
excludeWaist?: boolean;
/** Remove duplicate head/hair (keep first hd-) */
dedupeHead?: boolean;
}
export function convertFigureString(
figure: string,
options: ConvertFigureOptions = {},
): string {
if (!figure) return figure;
const {
excludeShoes = true,
excludeWaist = true,
dedupeHead = true,
} = options;
const parts = figure.split(".");
const converted = parts.map(convertFigurePart);
let result = converted;
if (excludeShoes) {
result = result.filter((p) => !p.startsWith("ha-"));
}
if (excludeWaist) {
result = result.filter((p) => !p.startsWith("wa-"));
}
if (dedupeHead) {
const seen = new Set<string>();
result = result.filter((p) => {
if (p.startsWith("hd-")) {
if (seen.has("hd")) return false;
seen.add("hd");
}
return true;
});
}
return result.join(".");
}
/**
* Get the new format figure string for a user's look.
* Handles both old and new format input.
*/
export function getNewFormatFigure(
look: string,
options?: ConvertFigureOptions,
): string {
return convertFigureString(look, options);
}
+9
View File
@@ -37,6 +37,15 @@ describe("avatarImageUrl", () => {
expect(url.startsWith("https://img.example.com?")).toBe(true);
});
it("preserves the exact user look instead of replacing it with a default figure", () => {
const look =
"hr-11782-40-40.hd-180-7-14.ch-11592-66.lg-10726-79-1408.sh-11764-1408.ha-11958-70-1408.wa-2007-0";
const url = new URL(avatarImageUrl(look));
expect(url.searchParams.get("figure")).toBe(look);
});
it("uses & when the base already has a query string", () => {
// This test is no longer applicable since avatarImageUrl doesn't accept a base parameter
// The base URL is now resolved from env
+1 -3
View File
@@ -1,11 +1,9 @@
import { getNewFormatFigure } from "@/lib/figure-convert";
import { getAvatarUrl } from "@/lib/imager";
import type { AvatarOptions } from "@/types/admin";
/** Build a Habbo avatar-imager URL for a figure string. */
export function avatarImageUrl(look: string, opts: AvatarOptions = {}): string {
const figure = getNewFormatFigure(look);
return getAvatarUrl(figure, opts);
return getAvatarUrl(look, opts);
}
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */
+2 -4
View File
@@ -8,7 +8,6 @@
export type { AvatarOptions } from "@/types/admin";
import { env } from "@/env";
import { getNewFormatFigure } from "@/lib/figure-convert";
import type { AvatarOptions } from "@/types/admin";
const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
@@ -28,14 +27,13 @@ export function resolveImagerBase(): string {
/**
* Build an avatar image URL using the configured imager.
* Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
* Automatically converts old Habbo figure format to new epicnabbo.nl format.
* Preserves the user's stored figure so the rendered avatar matches the account.
* Omits default parameters (direction=2, head_direction=3, size=m) for cleaner URLs.
*/
export function getAvatarUrl(
figure: string,
options: AvatarOptions = {},
): string {
const convertedFigure = getNewFormatFigure(figure);
const {
size = "m",
direction = 2,
@@ -48,7 +46,7 @@ export function getAvatarUrl(
} = options;
const params = new URLSearchParams({
figure: convertedFigure,
figure,
img_format,
effect: String(effect),
});