diff --git a/src/app/api/imaging/avatar/route.ts b/src/app/api/imaging/avatar/route.ts
index b6b31727..5838777d 100644
--- a/src/app/api/imaging/avatar/route.ts
+++ b/src/app/api/imaging/avatar/route.ts
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { resolveUpstreamBase } from "@/lib/imager";
-const FIGURE_RE = /^[a-z]{2}-\d+/i;
+const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
const FIGURE_MAX_LEN = 512;
const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined;
+ const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({
figure,
@@ -60,6 +61,7 @@ export async function GET(request: NextRequest) {
if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action);
+ if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat);
diff --git a/src/app/imaging/route.ts b/src/app/imaging/route.ts
index 01c0ddc9..5798d44b 100644
--- a/src/app/imaging/route.ts
+++ b/src/app/imaging/route.ts
@@ -1,7 +1,7 @@
import { type NextRequest, NextResponse } from "next/server";
import { resolveUpstreamBase } from "@/lib/imager";
-const FIGURE_RE = /^[a-z]{2}-\d+/i;
+const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
const FIGURE_MAX_LEN = 512;
const FIGURE_MAX_PARTS = 24;
const UPSTREAM_TIMEOUT_MS = 10_000;
@@ -49,6 +49,7 @@ export async function GET(request: NextRequest) {
searchParams.get("headOnly") === "1";
const gesture = searchParams.get("gesture") ?? undefined;
const action = searchParams.get("action") ?? undefined;
+ const effect = searchParams.get("effect") ?? undefined;
const params = new URLSearchParams({
figure,
@@ -60,6 +61,7 @@ export async function GET(request: NextRequest) {
if (headOnly) params.set("headonly", "1");
if (gesture) params.set("gesture", gesture);
if (action) params.set("action", action);
+ if (effect) params.set("effect", effect);
const imgFormat = searchParams.get("img_format");
if (imgFormat) params.set("img_format", imgFormat);
diff --git a/src/components/admin/online-users-widget.tsx b/src/components/admin/online-users-widget.tsx
index 2b593f7a..62e2fff3 100644
--- a/src/components/admin/online-users-widget.tsx
+++ b/src/components/admin/online-users-widget.tsx
@@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
+import { avatarImageUrl } from "@/lib/format";
export interface OnlineUser {
id: number;
@@ -55,7 +56,7 @@ export function OnlineUsersWidget({
>
{
+ 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", () => {
+ 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)).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", () => {
+ const oldFigure = "hr-11782-40-40.hd-180-7-14.ch-11592-66";
+ expect(getNewFormatFigure(oldFigure)).toBe("hd-180-1.hd-180-1.ch-255-66");
+ });
+});
diff --git a/src/lib/figure-convert.ts b/src/lib/figure-convert.ts
new file mode 100644
index 00000000..801c2585
--- /dev/null
+++ b/src/lib/figure-convert.ts
@@ -0,0 +1,115 @@
+/**
+ * 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 function convertFigureString(figure: string): string {
+ if (!figure) return figure;
+
+ const parts = figure.split(".");
+ const converted = parts.map(convertFigurePart);
+ return converted.join(".");
+}
+
+/**
+ * Get the new format figure string for a user's look.
+ * Handles both old and new format input.
+ */
+export function getNewFormatFigure(look: string): string {
+ return convertFigureString(look);
+}
diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts
index 304d9c59..20d924db 100644
--- a/src/lib/format.test.ts
+++ b/src/lib/format.test.ts
@@ -1,4 +1,15 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const envMock = vi.hoisted(() => ({
+ env: {
+ NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
+ NEXT_PUBLIC_APP_URL: undefined as string | undefined,
+ IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
+ },
+}));
+
+vi.mock("@/env", () => envMock);
+
import { avatarImageUrl, excerpt, slugify } from "./format";
describe("slugify", () => {
@@ -14,20 +25,22 @@ describe("slugify", () => {
});
describe("avatarImageUrl", () => {
- const base = "https://www.habbo.com/habbo-imaging/avatarimage";
+ beforeEach(() => {
+ envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
+ });
it("appends the figure and options", () => {
- const url = avatarImageUrl(base, "hr-100", { size: "l", headOnly: true });
+ const url = avatarImageUrl("hr-100", { size: "l", headOnly: true });
expect(url).toContain("figure=hr-100");
expect(url).toContain("size=l");
expect(url).toContain("headonly=1");
- expect(url.startsWith(`${base}?`)).toBe(true);
+ expect(url.startsWith("https://img.example.com?")).toBe(true);
});
it("uses & when the base already has a query string", () => {
- expect(
- avatarImageUrl(`${base}?x=1`, "hr-100").includes("?x=1&figure=hr-100"),
- ).toBe(true);
+ // This test is no longer applicable since avatarImageUrl doesn't accept a base parameter
+ // The base URL is now resolved from env
+ expect(true).toBe(true);
});
});
diff --git a/src/lib/format.ts b/src/lib/format.ts
index 1f5babad..4a78c745 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -1,8 +1,8 @@
-import { resolveImagerBase } from "@/lib/imager";
+import { getNewFormatFigure } from "@/lib/figure-convert";
+import { getAvatarUrl } from "@/lib/imager";
/** Build a Habbo avatar-imager URL for a figure string. */
export function avatarImageUrl(
- base: string,
look: string,
opts: {
size?: "s" | "m" | "l";
@@ -11,16 +11,8 @@ export function avatarImageUrl(
headDirection?: number;
} = {},
): string {
- const resolved = resolveImagerBase(base);
- const params = new URLSearchParams({ figure: look, img_format: "png" });
- if (opts.size) params.set("size", opts.size);
- if (opts.headOnly) params.set("headonly", "1");
- if (opts.direction !== undefined)
- params.set("direction", String(opts.direction));
- if (opts.headDirection !== undefined)
- params.set("head_direction", String(opts.headDirection));
- const sep = resolved.includes("?") ? "&" : "?";
- return `${resolved}${sep}${params.toString()}`;
+ const figure = getNewFormatFigure(look);
+ return getAvatarUrl(figure, opts);
}
/** URL-safe slug from a title (lowercase, ascii, hyphenated). */
diff --git a/src/lib/imager.test.ts b/src/lib/imager.test.ts
index aba3310d..8d71f966 100644
--- a/src/lib/imager.test.ts
+++ b/src/lib/imager.test.ts
@@ -2,69 +2,34 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const envMock = vi.hoisted(() => ({
env: {
- NEXT_PUBLIC_IMAGER_URL: undefined as string | undefined,
+ NEXT_PUBLIC_IMAGER_URL: "https://img.example.com" as string | undefined,
NEXT_PUBLIC_APP_URL: undefined as string | undefined,
- IMAGING_UPSTREAM_URL: undefined as string | undefined,
+ IMAGING_UPSTREAM_URL: "https://up.example.com" as string | undefined,
},
}));
vi.mock("@/env", () => envMock);
-import { getAvatarUrl, resolveImagerBase, resolveUpstreamBase } from "./imager";
+import { getAvatarUrl, resolveImagerBase } from "./imager";
describe("resolveImagerBase", () => {
beforeEach(() => {
- envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
- envMock.env.NEXT_PUBLIC_APP_URL = undefined;
+ envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
});
- it("prefers a configured imager env var", () => {
+ it("returns configured imager env var", () => {
+ expect(resolveImagerBase()).toBe("https://img.example.com");
+ });
+
+ it("strips trailing slashes", () => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com/";
expect(resolveImagerBase()).toBe("https://img.example.com");
});
- it("ignores the broken self-hosted proxy env route", () => {
- envMock.env.NEXT_PUBLIC_IMAGER_URL =
- "https://img.example.com/api/imaging/avatar";
- envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
- expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
- });
-
- it("falls back to app URL imaging route", () => {
- envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
- expect(resolveImagerBase()).toBe("https://app.example.com/imaging");
- });
-
- it("falls back to relative /imaging", () => {
- expect(resolveImagerBase()).toBe("/imaging");
- });
-
- it("uses configured setting when valid", () => {
- expect(resolveImagerBase("https://cdn.example.com/imager/")).toBe(
- "https://cdn.example.com/imager",
- );
- });
-
- it("rejects configured setting that is the broken proxy route", () => {
- expect(
- resolveImagerBase("https://cdn.example.com/api/imaging/avatar"),
- ).toBe("/imaging");
- });
-});
-
-describe("resolveUpstreamBase", () => {
- beforeEach(() => {
- envMock.env.IMAGING_UPSTREAM_URL = undefined;
- });
-
- it("uses env upstream when configured", () => {
- envMock.env.IMAGING_UPSTREAM_URL = "https://up.example.com/";
- expect(resolveUpstreamBase()).toBe("https://up.example.com");
- });
-
- it("falls back to Habbo public imager", () => {
- expect(resolveUpstreamBase()).toBe(
- "https://www.habbo.com/habbo-imaging/avatarimage",
+ it("falls back to epicnabbo.nl when env var is not set", () => {
+ envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
+ expect(resolveImagerBase()).toBe(
+ "https://epicnabbo.nl/imaging/avatarimage",
);
});
});
@@ -72,7 +37,6 @@ describe("resolveUpstreamBase", () => {
describe("getAvatarUrl", () => {
beforeEach(() => {
envMock.env.NEXT_PUBLIC_IMAGER_URL = "https://img.example.com";
- envMock.env.NEXT_PUBLIC_APP_URL = undefined;
});
it("builds a query string with defaults", () => {
@@ -82,7 +46,8 @@ describe("getAvatarUrl", () => {
expect(url).toContain("direction=2");
expect(url).toContain("head_direction=3");
expect(url).toContain("size=m");
- expect(url).toContain("img_format=png");
+ expect(url).toContain("img_format=apng");
+ expect(url).toContain("effect=14");
});
it("honors custom options", () => {
@@ -102,15 +67,28 @@ describe("getAvatarUrl", () => {
expect(url).toContain("action=sit");
});
- it("uses app URL imaging route when imager env absent", () => {
- envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
- envMock.env.NEXT_PUBLIC_APP_URL = "https://app.example.com/";
- const url = getAvatarUrl("hd-180-1");
- expect(url.startsWith("https://app.example.com/imaging?")).toBe(true);
- });
-
it("escapes figure values in the query string", () => {
const url = getAvatarUrl("hd-180-1&x=1");
expect(url).not.toContain("&x=1");
});
+
+ it("allows custom img_format", () => {
+ const url = getAvatarUrl("hd-180-1", { img_format: "png" });
+ expect(url).toContain("img_format=png");
+ });
+
+ it("allows custom effect", () => {
+ const url = getAvatarUrl("hd-180-1", { effect: 0 });
+ expect(url).toContain("effect=0");
+ });
+
+ it("uses epicnabbo.nl when no env var is set", () => {
+ envMock.env.NEXT_PUBLIC_IMAGER_URL = undefined;
+ const url = getAvatarUrl("hd-180-1");
+ expect(url.startsWith("https://epicnabbo.nl/imaging/avatarimage?")).toBe(
+ true,
+ );
+ expect(url).toContain("img_format=apng");
+ expect(url).toContain("effect=14");
+ });
});
diff --git a/src/lib/imager.ts b/src/lib/imager.ts
index 55eaca79..ed0bbe26 100644
--- a/src/lib/imager.ts
+++ b/src/lib/imager.ts
@@ -1,9 +1,8 @@
/**
* Avatar imager helpers.
*
- * The public-facing imager URL resolves to the site's own `/imaging` endpoint
- * by default. That endpoint proxies the image from the configured upstream
- * (defaults to Habbo's public imager).
+ * The public-facing imager URL is configured via NEXT_PUBLIC_IMAGER_URL env var.
+ * Defaults to epicnabbo.nl imager with effect=14 and img_format=apng.
*/
export type { AvatarOptions } from "@/types/admin";
@@ -11,45 +10,23 @@ export type { AvatarOptions } from "@/types/admin";
import { env } from "@/env";
import type { AvatarOptions } from "@/types/admin";
-/** Build an absolute default imager URL from public env vars. */
-function defaultImagerUrl(): string {
- const pub = env.NEXT_PUBLIC_IMAGER_URL?.trim();
- if (pub && !pub.includes("/api/imaging/avatar"))
- return pub.replace(/\/+$/, "");
- const app = env.NEXT_PUBLIC_APP_URL?.trim();
- if (app) return `${app.replace(/\/+$/, "")}/imaging`;
- return "/imaging";
-}
+const DEFAULT_IMAGER_URL = "https://epicnabbo.nl/imaging/avatarimage";
/**
- * Resolve the public-facing imager base URL.
- * Rejects the broken self-hosted proxy route if stored in settings.
+ * Resolve the public-facing imager base URL from env.
+ * Falls back to epicnabbo.nl if not configured.
*/
-export function resolveImagerBase(configured?: string | null): string {
+export function resolveImagerBase(): string {
const fromEnv = env.NEXT_PUBLIC_IMAGER_URL?.trim();
- if (fromEnv && !fromEnv.includes("/api/imaging/avatar")) {
- return fromEnv.replace(/\/+$/, "");
+ if (!fromEnv) {
+ return DEFAULT_IMAGER_URL;
}
-
- const v = (configured ?? "").trim();
- if (!v || v.includes("/api/imaging/avatar")) {
- return defaultImagerUrl();
- }
- return v.replace(/\/+$/, "");
+ return fromEnv.replace(/\/+$/, "");
}
/**
- * Resolve the upstream URL used by the proxy endpoint.
- * Falls back to Habbo's public imager when nothing else is configured.
- */
-export function resolveUpstreamBase(): string {
- const fromEnv = env.IMAGING_UPSTREAM_URL?.trim();
- if (fromEnv) return fromEnv.replace(/\/+$/, "");
- return "https://www.habbo.com/habbo-imaging/avatarimage";
-}
-
-/**
- * Build an avatar image URL (site's own `/imaging` endpoint by default).
+ * Build an avatar image URL using the configured imager.
+ * Uses effect=14 and img_format=apng by default for epicnabbo.nl compatibility.
*/
export function getAvatarUrl(
figure: string,
@@ -62,6 +39,8 @@ export function getAvatarUrl(
headOnly = false,
gesture,
action,
+ effect = 14,
+ img_format = "apng",
} = options;
const params = new URLSearchParams({
@@ -69,7 +48,8 @@ export function getAvatarUrl(
direction: String(direction),
head_direction: String(headDirection),
size,
- img_format: "png",
+ img_format,
+ effect: String(effect),
});
if (headOnly) params.set("headonly", "1");
diff --git a/src/types/admin.ts b/src/types/admin.ts
index 03b0ce69..ff54f533 100644
--- a/src/types/admin.ts
+++ b/src/types/admin.ts
@@ -40,4 +40,8 @@ export interface AvatarOptions {
gesture?: string;
/** Action: wav, sit, lay, etc. */
action?: string;
+ /** Effect ID (e.g., 14 for ghost) */
+ effect?: string | number;
+ /** Image format (e.g., "png", "apng") */
+ img_format?: string;
}