refactor: split game renderers into scoped modules

This commit is contained in:
2026-05-19 10:02:29 +07:00 Unverified
parent e47b25b89c
commit 99361e8455
49 changed files with 284 additions and 90 deletions
+143
View File
@@ -0,0 +1,143 @@
import { inspect } from "bun";
import { status, t } from "elysia";
import {
fetchEnka,
InternalError,
InvalidFormatError,
MaintenanceError,
NotFoundError,
RatelimitError,
} from "enka.ts";
import type { App } from "@/index";
import { Deduplicator } from "@/lib/dedupe";
import { createLogger } from "@/lib/logger";
import { ErrorPage } from "@/v1/error";
import { EndfieldTemplate } from "./render/template";
const deduper = new Deduplicator();
const endfieldErrorLocs = {
invalidUid: "Invalid UID format",
notFound: "UID not found on enka.network",
maintenance: "enka.network is under maintenance",
ratelimit: "Too many requests to enka.network, please try again later",
internal: "Internal server error",
} as const;
function normalizeError(errorType: "http" | "card") {
return errorType === "card"
? (_: number, message: string) => ErrorPage(message)
: status;
}
export function endfieldCard(app: App) {
return app.group("/card/endfield", (app) =>
app
.derive(() => ({
log: createLogger(),
}))
.head(
"/:uid/preload",
async ({ params: { uid }, log }) => {
log`Preloading ${uid}`;
try {
await deduper.acquire(`endfield-${uid}`, () =>
fetchEnka(uid, false).finally(() => log`Enka data loaded`),
);
return "OK";
} catch {
return status(502, "Data Preload Error");
}
},
{
params: t.Object({ uid: t.String() }),
detail: {
tags: ["Endfield"],
description: "Preload data required to render an Endfield template card.",
},
afterResponse: ({ log }) => log`Data preload complete`,
},
)
.get(
"/:uid",
async ({
params: { uid },
query: { debug, error: errorType = "http" },
log,
}) => {
const error = normalizeError(errorType);
try {
const data = await deduper.acquire(`endfield-${uid}`, () =>
fetchEnka(uid, false).finally(() => log`Enka data loaded`),
);
if (debug) return { data };
return EndfieldTemplate({
uid,
nickname: data.playerInfo.nickname,
level: data.playerInfo.level,
signature: data.playerInfo.signature,
source: "enka.network",
log,
});
} catch (err) {
if (err instanceof InvalidFormatError)
return error(400, endfieldErrorLocs.invalidUid);
if (err instanceof NotFoundError)
return error(404, endfieldErrorLocs.notFound);
if (err instanceof MaintenanceError)
return error(424, endfieldErrorLocs.maintenance);
if (err instanceof RatelimitError)
return error(429, endfieldErrorLocs.ratelimit);
if (err instanceof InternalError)
return status(502, endfieldErrorLocs.internal);
console.error(err);
try {
return error(
500,
`Internal Error:\n${(err as { message?: string })?.message || inspect(err)}` as "",
);
} catch {
throw err;
}
}
},
{
params: t.Object({
uid: t.String({
examples: ["775956514"],
description: "Player UID",
}),
}),
query: t.Partial(
t.Object(
{
error: t.Enum(
{ http: "http", card: "card" },
{
default: "http",
description: "Type of error to return when card fails to render.",
},
),
debug: t.Boolean({
description: "Return debug information instead of card.",
default: false,
}),
},
{ additionalProperties: true },
),
),
detail: {
tags: ["Endfield"],
description: "Generate a placeholder Endfield profile card image from a UID.",
},
afterResponse: ({ log }) => log`Rendering completed`,
},
),
);
}
+92
View File
@@ -0,0 +1,92 @@
import type { Logger } from "@/lib/logger";
import { CardResponse } from "@/v1/response";
type EndfieldTemplateProps = {
uid: string;
nickname?: string;
level?: number;
signature?: string;
source: string;
log: Logger;
};
export function EndfieldTemplate({
uid,
nickname,
level,
signature,
source,
log,
}: EndfieldTemplateProps) {
return CardResponse(
<div tw="flex h-full w-full bg-[#07131c] text-white relative overflow-hidden font-[Open_Sans]">
<div tw="absolute inset-0 bg-[radial-gradient(circle_at_top_left,#1d4f6d,transparent_35%),radial-gradient(circle_at_bottom_right,#113047,transparent_30%),linear-gradient(135deg,#081018,#0d1f2e_45%,#091723)]" />
<div tw="absolute inset-[2.2em] rounded-[1.4em] border border-white/12 bg-white/6 backdrop-blur-[6px]" />
<div tw="relative z-10 flex flex-col justify-between w-full p-[2.4em]">
<div tw="flex items-start justify-between gap-[2em]">
<div tw="flex flex-col gap-[.55em] max-w-[65%]">
<div tw="text-[.95em] tracking-[.28em] uppercase text-[#7ed8ff] opacity-90">
Endfield Prototype
</div>
<div tw="text-[2.6em] leading-none font-semibold">
{nickname || "Unknown Operator"}
</div>
<div tw="text-[1.05em] text-white/70">UID {uid}</div>
</div>
<div tw="flex flex-col items-end gap-[.5em] text-right shrink-0">
<div tw="rounded-full border border-[#7ed8ff]/35 bg-[#7ed8ff]/12 px-[1em] py-[.45em] text-[.95em] text-[#b6ecff]">
Template Only
</div>
<div tw="text-[.95em] text-white/65">Data source: {source}</div>
</div>
</div>
<div tw="grid grid-cols-3 gap-[1em] text-[1.02em]">
<div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em]">
<div tw="text-white/55 uppercase tracking-[.18em] text-[.72em] mb-[.6em]">
Progress
</div>
<div tw="text-[2.1em] leading-none font-semibold">
{typeof level === "number" ? level : "--"}
</div>
<div tw="text-white/60 mt-[.35em]">Level</div>
</div>
<div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em]">
<div tw="text-white/55 uppercase tracking-[.18em] text-[.72em] mb-[.6em]">
Renderer
</div>
<div tw="text-[1.55em] leading-[1.15] font-semibold">
Route + Card Shell
</div>
<div tw="text-white/60 mt-[.35em]">Ready for game-specific layout work</div>
</div>
<div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em]">
<div tw="text-white/55 uppercase tracking-[.18em] text-[.72em] mb-[.6em]">
Next
</div>
<div tw="text-[1.55em] leading-[1.15] font-semibold">
Character Grid
</div>
<div tw="text-white/60 mt-[.35em]">Replace placeholder sections with Endfield data</div>
</div>
</div>
<div tw="rounded-[1em] border border-white/10 bg-black/20 p-[1.2em] min-h-[6.5em]">
<div tw="text-white/55 uppercase tracking-[.18em] text-[.72em] mb-[.8em]">
Signature
</div>
<div tw="text-[1.05em] leading-[1.45] text-white/82 whitespace-pre-wrap">
{signature?.trim() || "No signature available in the fetched payload yet."}
</div>
</div>
</div>
</div>,
[],
{ width: 1228, height: 512 },
log,
);
}
@@ -1,7 +1,7 @@
import { createCanvas, loadImage } from "@napi-rs/canvas"; import { createCanvas, loadImage } from "@napi-rs/canvas";
import { file } from "bun"; import { file } from "bun";
import overlayImage from "@/assets/overlay.jpg"; import overlayImage from "@/games/genshin/assets/overlay.jpg";
const elementColors: Record<string, string> = { const elementColors: Record<string, string> = {
Rock: "#bb9f4b", Rock: "#bb9f4b",

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before

Width:  |  Height:  |  Size: 577 KiB

After

Width:  |  Height:  |  Size: 577 KiB

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Before

Width:  |  Height:  |  Size: 840 B

After

Width:  |  Height:  |  Size: 840 B

@@ -13,13 +13,13 @@ import type {
EnkaNamecards, EnkaNamecards,
EnkaPfps, EnkaPfps,
RelicStore, RelicStore,
WeaponStore} from "@/type/enka"; WeaponStore} from "@/games/genshin/types/enka";
import { import {
ElementList ElementList
} from "@/type/enka"; } from "@/games/genshin/types/enka";
import { Deduplicator } from "./dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { createLogger } from "./logger"; import { createLogger } from "@/lib/logger";
export const cache = new TTLCache(); export const cache = new TTLCache();
const log = createLogger("ASSET"); const log = createLogger("ASSET");
@@ -1,8 +1,8 @@
import type { ResolvedAvatarInfo } from "enka.ts"; import type { ResolvedAvatarInfo } from "enka.ts";
import { AvatarElement } from "enka.ts"; import { AvatarElement } from "enka.ts";
import type { EnkaLocsLanguage } from "@/type/enka"; import type { EnkaLocsLanguage } from "@/games/genshin/types/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap"; import { FightPropMapEnum, PercentageFightProp } from "@/games/genshin/types/fightPropMap";
// Types // Types
interface DisplayRow { interface DisplayRow {
@@ -1,5 +1,5 @@
import type { Affix } from "@/type/enka"; import type { Affix } from "@/games/genshin/types/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap"; import { FightPropMapEnum, PercentageFightProp } from "@/games/genshin/types/fightPropMap";
export type RollSummary = { export type RollSummary = {
id: string; id: string;
+23 -23
View File
@@ -14,34 +14,34 @@ import {
} from "enka.ts"; } from "enka.ts";
import { ImageResponse } from "takumi-js/response"; import { ImageResponse } from "takumi-js/response";
import { generateBackground } from "@/assets/background"; import { generateBackground } from "@/games/genshin/assets/background";
import { ConstsSvg } from "@/assets/consts"; import { ConstsSvg } from "@/games/genshin/assets/consts";
import { featherImage } from "@/assets/feather"; import { featherImage } from "@/games/genshin/assets/feather";
import { font, iconFont, sansFont } from "@/assets/fonts"; import { font, iconFont, sansFont } from "@/assets/fonts";
import { EnkaIcon } from "@/assets/icons"; import { EnkaIcon } from "@/games/genshin/assets/icons";
import type { library } from "@/assets/icons-library"; import type { library } from "@/games/genshin/assets/icons-library";
import ListDiffExample from "@/assets/listoriginal.webp" with { type: "file" }; import ListDiffExample from "@/games/genshin/assets/listoriginal.webp" with { type: "file" };
import DiffPageExample from "@/assets/ororo.png" with { type: "file" }; import DiffPageExample from "@/games/genshin/assets/ororo.png" with { type: "file" };
import shadeSvg from "@/assets/shade.svg" with { type: "file" }; import shadeSvg from "@/games/genshin/assets/shade.svg" with { type: "file" };
import { star } from "@/assets/stars"; import { star } from "@/games/genshin/assets/stars";
import { TalentSvg } from "@/assets/talent"; import { TalentSvg } from "@/games/genshin/assets/talent";
import { WeaponGlazeSvg } from "@/assets/weapon-glaze"; import { WeaponGlazeSvg } from "@/games/genshin/assets/weapon-glaze";
import { assets, batchStore, getAvatar } from "@/games/genshin/data/assets";
import { cardErrorLocs } from "@/games/genshin/data/locs";
import { resolveShowcaseChar } from "@/games/genshin/data/matcher";
import { mockData } from "@/games/genshin/data/mock";
import { buildDisplayRows } from "@/games/genshin/data/rows";
import type { RollSummary } from "@/games/genshin/data/substat";
import { buildRollSummary } from "@/games/genshin/data/substat";
import { Locale, SubstatOrder } from "@/games/genshin/types/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/games/genshin/types/fightPropMap";
import type { App } from "@/index"; import type { App } from "@/index";
import { assets, batchStore, getAvatar } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe"; import { Deduplicator } from "@/lib/dedupe";
import { cardErrorLocs } from "@/lib/locs";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { resolveShowcaseChar } from "@/lib/matcher";
import { mockData } from "@/lib/mock";
import { buildDisplayRows } from "@/lib/rows";
import type { RollSummary } from "@/lib/substat";
import { buildRollSummary } from "@/lib/substat";
import { Locale, SubstatOrder } from "@/type/enka";
import { FightPropMapEnum, PercentageFightProp } from "@/type/fightPropMap";
import DiffPage from "../../hmr.html"; import DiffPage from "../../../hmr.html";
import { ErrorPage } from "./error"; import { ErrorPage } from "@/v1/error";
import { ListPage } from "./list"; import { ListPage } from "./render/list";
const cache = new TTLCache(); const cache = new TTLCache();
const deduper = new Deduplicator(); const deduper = new Deduplicator();
@@ -8,19 +8,19 @@ import {
listNcFrame, listNcFrame,
listRtop, listRtop,
smug, smug,
} from "@/assets"; } from "@/games/genshin/assets";
import { assets, getAvatar } from "@/lib/assets"; import { assets, getAvatar } from "@/games/genshin/data/assets";
import type { Logger } from "@/lib/logger"; import { CharacterNamecardMap } from "@/games/genshin/data/namecards";
import { CharacterNamecardMap } from "@/lib/namecards";
import { arrRandom } from "@/lib/util";
import { import {
ElementList, ElementList,
type AvatarStore, type AvatarStore,
type EnkaLocsLanguage, type EnkaLocsLanguage,
type EnkaNamecards, type EnkaNamecards,
} from "@/type/enka"; } from "@/games/genshin/types/enka";
import type { Logger } from "@/lib/logger";
import { arrRandom } from "@/lib/util";
import { CardResponse } from "./response"; import { CardResponse } from "@/v1/response";
export async function ListPage( export async function ListPage(
{ playerInfo: p, uid, avatarInfoList }: ResolvedRoot, { playerInfo: p, uid, avatarInfoList }: ResolvedRoot,
-45
View File
@@ -1,45 +0,0 @@
import type { App } from "@";
import { status } from "elysia";
import { TTLCache, fetchEnka } from "enka.ts";
import { batchStore } from "@/lib/assets";
import { Deduplicator } from "@/lib/dedupe";
import { createLogger } from "@/lib/logger";
const uidRegex = /^\d{10}$/;
const cache = new TTLCache();
const deduper = new Deduplicator();
export function endfieldCard<A extends App>(app: A) {
return app.group("/endfield/:uid", (app) =>
app
.derive(() => ({
log: createLogger(),
}))
.head("/preload", async ({ params: { uid }, log }) => {
log`Preloading ${uid}`;
try {
await Promise.all([
deduper.acquire("stores", () =>
batchStore(
"avatars",
"locs",
"relics",
"weapons",
"pfps",
"affixes",
).finally(() => log`Stores loaded`),
),
deduper.acquire(`enka-${uid}`, () =>
fetchEnka(uid, false).finally(() => log`Enka data loaded`),
),
]);
return "OK";
} catch {
return status(502, "Data Preload Error");
}
}),
);
}
+6 -2
View File
@@ -1,11 +1,15 @@
import type { App } from "@"; import type { App } from "@";
import { t } from "elysia"; import { t } from "elysia";
import { card } from "./card"; // import { endfieldCard } from "@/games/endfield";
import { card } from "@/games/genshin";
export function v1routes(app: App) { export function v1routes(app: App) {
app.group("/v1", (app) => app.group("/v1", (app) =>
app.use(card).get( app
.use(card)
// .use(endfieldCard)
.get(
"/status/:status", "/status/:status",
async ({ params: { status }, set }) => { async ({ params: { status }, set }) => {
const res = await fetch( const res = await fetch(
+1 -1
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import { file, hash } from "bun"; import { file, hash } from "bun";
import { AssetManager } from "@/lib/assets"; import { AssetManager } from "@/games/genshin/data/assets";
describe("AssetManager", () => { describe("AssetManager", () => {
let origCwd: string; let origCwd: string;
+2 -2
View File
@@ -2,7 +2,7 @@ import { join } from "node:path";
import { $, write } from "bun"; import { $, write } from "bun";
import { libraryRaw } from "@/assets/icons"; import { libraryRaw } from "@/games/genshin/assets/icons";
function toJsx( function toJsx(
name: string, name: string,
@@ -44,7 +44,7 @@ function toJsx(
`; `;
} }
const target = join(__dirname, "../src/assets/icons-library.tsx"); const target = join(__dirname, "../src/games/genshin/assets/icons-library.tsx");
write( write(
target, target,
`import { SVGProps } from "react"; `import { SVGProps } from "react";
+1 -1
View File
@@ -30,7 +30,7 @@ fetch("https://gi.yatta.moe/api/v2/en/avatar")
).filter((e) => e[1]), ).filter((e) => e[1]),
); );
log`crawling complete`; log`crawling complete`;
const target = join(__dirname, "../src/lib/namecards.ts"); const target = join(__dirname, "../src/games/genshin/data/namecards.ts");
await write( await write(
target, target,
`// GENERATED BY util/genNamecardRelation\n\nexport const CharacterNamecardMap: Record<string, number> = ${JSON.stringify(map)}`, `// GENERATED BY util/genNamecardRelation\n\nexport const CharacterNamecardMap: Record<string, number> = ${JSON.stringify(map)}`,