feat: support build profile endpoints
Build and Deploy / build-and-deploy (push) Successful in 1m10s
Build and Deploy / build-and-deploy (push) Successful in 1m10s
This commit is contained in:
@@ -6,6 +6,8 @@ export const cardErrorLocs = {
|
|||||||
"Unsupported character, avatar store doesn't have this character",
|
"Unsupported character, avatar store doesn't have this character",
|
||||||
uidFormat: "Invalid UID format",
|
uidFormat: "Invalid UID format",
|
||||||
notFound: "UID not found on enka.network",
|
notFound: "UID not found on enka.network",
|
||||||
|
profileNotFound: "Profile not found on enka.network",
|
||||||
|
buildNotFound: "Build not found on enka.network",
|
||||||
maintenance: "enka.network is under maintenance",
|
maintenance: "enka.network is under maintenance",
|
||||||
ratelimit: "Too many requests to enka.network, please try again later",
|
ratelimit: "Too many requests to enka.network, please try again later",
|
||||||
internal: "Internal server error",
|
internal: "Internal server error",
|
||||||
@@ -16,6 +18,8 @@ export const cardErrorLocs = {
|
|||||||
unsupported: "ไม่รองรับตัวละครนี้",
|
unsupported: "ไม่รองรับตัวละครนี้",
|
||||||
uidFormat: "รูปแบบ UID ไม่ถูกต้อง",
|
uidFormat: "รูปแบบ UID ไม่ถูกต้อง",
|
||||||
notFound: "ไม่พบ UID บน enka.network",
|
notFound: "ไม่พบ UID บน enka.network",
|
||||||
|
profileNotFound: "ไม่พบโปรไฟล์บน enka.network",
|
||||||
|
buildNotFound: "ไม่พบบิลด์บน enka.network",
|
||||||
maintenance: "enka.network กำลังปิดปรับปรุง",
|
maintenance: "enka.network กำลังปิดปรับปรุง",
|
||||||
ratelimit: "เรียก API enka.network มากเกินไป กรุณาลองใหม่ภายหลัง",
|
ratelimit: "เรียก API enka.network มากเกินไป กรุณาลองใหม่ภายหลัง",
|
||||||
internal: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
internal: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
||||||
|
|||||||
+299
-69
@@ -2,6 +2,7 @@ import { feature } from "bun:bundle";
|
|||||||
|
|
||||||
import { file, hash, inspect } from "bun";
|
import { file, hash, inspect } from "bun";
|
||||||
import { redirect, status, t } from "elysia";
|
import { redirect, status, t } from "elysia";
|
||||||
|
import type { ApiResponse, ResolvedPlayerInfo } from "enka.ts";
|
||||||
import {
|
import {
|
||||||
enka,
|
enka,
|
||||||
InternalError,
|
InternalError,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
MaintenanceError,
|
MaintenanceError,
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
RatelimitError,
|
RatelimitError,
|
||||||
|
resolveAvatarInfo,
|
||||||
TTLCache,
|
TTLCache,
|
||||||
} from "enka.ts";
|
} from "enka.ts";
|
||||||
import type { ImageResponse } from "takumi-js/response";
|
import type { ImageResponse } from "takumi-js/response";
|
||||||
@@ -17,19 +19,19 @@ import ListDiffExample from "@/games/genshin/assets/listoriginal.webp" with { ty
|
|||||||
import DiffPageExample from "@/games/genshin/assets/ororo.png" with { type: "file" };
|
import DiffPageExample from "@/games/genshin/assets/ororo.png" with { type: "file" };
|
||||||
import { assets, batchStore } from "@/games/genshin/data/assets";
|
import { assets, batchStore } from "@/games/genshin/data/assets";
|
||||||
import { cardErrorLocs } from "@/games/genshin/data/locs";
|
import { cardErrorLocs } from "@/games/genshin/data/locs";
|
||||||
|
import { resolveShowcaseChar } from "@/games/genshin/data/matcher";
|
||||||
import { mockData } from "@/games/genshin/data/mock";
|
import { mockData } from "@/games/genshin/data/mock";
|
||||||
import type { NumberLike } from "@/games/genshin/types/enka";
|
import type { NumberLike } from "@/games/genshin/types/enka";
|
||||||
import { Locale } from "@/games/genshin/types/enka";
|
import { Locale } from "@/games/genshin/types/enka";
|
||||||
import type { App } from "@/index";
|
import type { App } from "@/index";
|
||||||
import { Deduplicator } from "@/lib/dedupe";
|
import { Deduplicator } from "@/lib/dedupe";
|
||||||
import { createLogger } from "@/lib/logger";
|
import { createLogger } from "@/lib/logger";
|
||||||
import { throwNotOk } from "@/lib/util";
|
import { ResponseNotOkError, throwNotOk } from "@/lib/util";
|
||||||
import { ErrorPage } from "@/v1/error";
|
import { ErrorPage } from "@/v1/error";
|
||||||
|
|
||||||
import DiffPage from "../../../hmr.html";
|
import DiffPage from "../../../hmr.html";
|
||||||
import {
|
import {
|
||||||
CharacterPage,
|
CharacterPage,
|
||||||
NotInShowcaseError,
|
|
||||||
PrivateShowcaseError,
|
PrivateShowcaseError,
|
||||||
UnsupportedCharacterError,
|
UnsupportedCharacterError,
|
||||||
} from "./render/character";
|
} from "./render/character";
|
||||||
@@ -40,6 +42,90 @@ const deduper = new Deduplicator();
|
|||||||
|
|
||||||
var purging = 0;
|
var purging = 0;
|
||||||
|
|
||||||
|
const characterCardQuery = t.Partial(
|
||||||
|
t.Object(
|
||||||
|
{
|
||||||
|
error: t.Enum(
|
||||||
|
{ http: "http", card: "card" },
|
||||||
|
{
|
||||||
|
default: "http",
|
||||||
|
description: "Type of error to return when card fails to render.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
lang: Locale,
|
||||||
|
h: t.Number({
|
||||||
|
default: 1024,
|
||||||
|
description: "Height of card.",
|
||||||
|
}),
|
||||||
|
w: t.Number({
|
||||||
|
description:
|
||||||
|
"Target width of card, used to calculate height. Has higher priority than `h`.",
|
||||||
|
}),
|
||||||
|
debug: t.Enum(
|
||||||
|
{ none: "none", render: "render", dump: "dump" },
|
||||||
|
{
|
||||||
|
description: "Return debug information instead of card.",
|
||||||
|
default: "none",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
substat: t.Boolean({
|
||||||
|
description: "Add substats breakdown bar under the card.",
|
||||||
|
default: false,
|
||||||
|
}),
|
||||||
|
quality: t.Boolean({
|
||||||
|
description: "Add roll quality under artifact substats.",
|
||||||
|
default: false,
|
||||||
|
}),
|
||||||
|
format: t.Enum(
|
||||||
|
{
|
||||||
|
jpeg: "jpeg",
|
||||||
|
webp: "webp",
|
||||||
|
png: "png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
default: "webp",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ additionalProperties: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function profilePlayerInfo(
|
||||||
|
username: string,
|
||||||
|
avatar: {
|
||||||
|
avatar_id: string;
|
||||||
|
avatar_data: ApiResponse.AvatarInfo;
|
||||||
|
},
|
||||||
|
): Promise<ResolvedPlayerInfo> {
|
||||||
|
const avatarInfo = await resolveAvatarInfo(avatar.avatar_data);
|
||||||
|
return {
|
||||||
|
nickname: username,
|
||||||
|
level: 0,
|
||||||
|
nameCardId: 210001,
|
||||||
|
finishAchievementNum: 0,
|
||||||
|
profilePicture: { id: Number(avatar.avatar_id) },
|
||||||
|
showAvatarInfoList: [
|
||||||
|
{
|
||||||
|
avatarId: avatarInfo.avatar.avatarId,
|
||||||
|
level: Number(avatarInfo.propMap?.[4001].val ?? 1),
|
||||||
|
costumeId: avatarInfo.costumeId,
|
||||||
|
energyType: {
|
||||||
|
None: 0,
|
||||||
|
Fire: 1,
|
||||||
|
Water: 2,
|
||||||
|
Grass: 3,
|
||||||
|
Electric: 4,
|
||||||
|
Ice: 5,
|
||||||
|
Wind: 7,
|
||||||
|
Rock: 8,
|
||||||
|
}[avatarInfo.avatar.element],
|
||||||
|
avatar: avatarInfo.avatar,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as ResolvedPlayerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (Date.now() - purging < 60000) return;
|
if (Date.now() - purging < 60000) return;
|
||||||
purging = Date.now();
|
purging = Date.now();
|
||||||
@@ -310,9 +396,17 @@ export function card(app: App) {
|
|||||||
return typeof cached === "string" ? cached : cached.clone();
|
return typeof cached === "string" ? cached : cached.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await CharacterPage({
|
if (!data.avatarInfoList) return error(400, errorLoc.private);
|
||||||
data,
|
const avatarInfo = resolveShowcaseChar(
|
||||||
charId,
|
charId,
|
||||||
|
data.avatarInfoList,
|
||||||
|
);
|
||||||
|
if (!avatarInfo) return error(400, errorLoc.notInShowcase);
|
||||||
|
|
||||||
|
const res = await CharacterPage({
|
||||||
|
avatarInfo,
|
||||||
|
playerInfo: data.playerInfo,
|
||||||
|
uid: data.uid,
|
||||||
avatarStore,
|
avatarStore,
|
||||||
weaponStore,
|
weaponStore,
|
||||||
relicStore,
|
relicStore,
|
||||||
@@ -342,10 +436,10 @@ export function card(app: App) {
|
|||||||
return error(429, errorLoc.ratelimit);
|
return error(429, errorLoc.ratelimit);
|
||||||
else if (err instanceof InternalError)
|
else if (err instanceof InternalError)
|
||||||
return status(502, errorLoc.internal);
|
return status(502, errorLoc.internal);
|
||||||
|
else if (err instanceof ResponseNotOkError && err.status === 404)
|
||||||
|
return error(404, errorLoc.profileNotFound);
|
||||||
else if (err instanceof PrivateShowcaseError)
|
else if (err instanceof PrivateShowcaseError)
|
||||||
return error(400, errorLoc.private);
|
return error(400, errorLoc.private);
|
||||||
else if (err instanceof NotInShowcaseError)
|
|
||||||
return error(400, errorLoc.notInShowcase);
|
|
||||||
else if (err instanceof UnsupportedCharacterError)
|
else if (err instanceof UnsupportedCharacterError)
|
||||||
return error(400, errorLoc.unsupported);
|
return error(400, errorLoc.unsupported);
|
||||||
log.error`${err}`;
|
log.error`${err}`;
|
||||||
@@ -370,55 +464,7 @@ export function card(app: App) {
|
|||||||
description: "Character ID",
|
description: "Character ID",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
query: t.Partial(
|
query: characterCardQuery,
|
||||||
t.Object(
|
|
||||||
{
|
|
||||||
error: t.Enum(
|
|
||||||
{ http: "http", card: "card" },
|
|
||||||
{
|
|
||||||
default: "http",
|
|
||||||
description:
|
|
||||||
"Type of error to return when card fails to render.",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
lang: Locale,
|
|
||||||
h: t.Number({
|
|
||||||
default: 1024,
|
|
||||||
description: "Height of card.",
|
|
||||||
}),
|
|
||||||
w: t.Number({
|
|
||||||
description:
|
|
||||||
"Target width of card, used to calculate height. Has higher priority than `h`.",
|
|
||||||
}),
|
|
||||||
debug: t.Enum(
|
|
||||||
{ none: "none", render: "render", dump: "dump" },
|
|
||||||
{
|
|
||||||
description: "Return debug information instead of card.",
|
|
||||||
default: "none",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
substat: t.Boolean({
|
|
||||||
description: "Add substats breakdown bar under the card.",
|
|
||||||
default: false,
|
|
||||||
}),
|
|
||||||
quality: t.Boolean({
|
|
||||||
description: "Add roll quality under artifact substats.",
|
|
||||||
default: false,
|
|
||||||
}),
|
|
||||||
format: t.Enum(
|
|
||||||
{
|
|
||||||
jpeg: "jpeg",
|
|
||||||
webp: "webp",
|
|
||||||
png: "png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
default: "webp",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ additionalProperties: true },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
detail: {
|
detail: {
|
||||||
tags: ["Card"],
|
tags: ["Card"],
|
||||||
description:
|
description:
|
||||||
@@ -438,7 +484,9 @@ export function card(app: App) {
|
|||||||
afterResponse: ({ log }) => log`Rendering completed`,
|
afterResponse: ({ log }) => log`Rendering completed`,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get("/enka/:username", async ({ params: { username } }) => {
|
.get("/enka/:username", async ({ params: { username }, query: { lang } }) => {
|
||||||
|
const errorLoc = cardErrorLocs[lang === "th" ? "th" : "en"];
|
||||||
|
try {
|
||||||
const cacheKey = `enka-redir::${username}`;
|
const cacheKey = `enka-redir::${username}`;
|
||||||
if (feature("API")) {
|
if (feature("API")) {
|
||||||
const cached = cache.get<Response>(cacheKey);
|
const cached = cache.get<Response>(cacheKey);
|
||||||
@@ -461,8 +509,8 @@ export function card(app: App) {
|
|||||||
.sort((a, b) => Number(a.order) - Number(b.order))
|
.sort((a, b) => Number(a.order) - Number(b.order))
|
||||||
.filter((e) => e.hoyo_type === 0),
|
.filter((e) => e.hoyo_type === 0),
|
||||||
);
|
);
|
||||||
if (!hoyo) return status(404, "this needs to be changed.");
|
if (!hoyo) return status(404, errorLoc.profileNotFound);
|
||||||
const builds = await fetch(
|
const buildsArr = fetch(
|
||||||
`https://enka.network/api/profile/${username}/hoyos/${hoyo.hash}/builds/`,
|
`https://enka.network/api/profile/${username}/hoyos/${hoyo.hash}/builds/`,
|
||||||
)
|
)
|
||||||
.then(throwNotOk)
|
.then(throwNotOk)
|
||||||
@@ -471,12 +519,10 @@ export function card(app: App) {
|
|||||||
r.json() as Promise<
|
r.json() as Promise<
|
||||||
Record<string, { id: number; order: NumberLike }[]>
|
Record<string, { id: number; order: NumberLike }[]>
|
||||||
>,
|
>,
|
||||||
)
|
);
|
||||||
.then((e) =>
|
const builds = await buildsArr.then((e) =>
|
||||||
((
|
((
|
||||||
pair:
|
pair: [string, { id: number; order: `${number}` }[]] | undefined,
|
||||||
| [string, { id: number; order: `${number}` }[]]
|
|
||||||
| undefined,
|
|
||||||
) =>
|
) =>
|
||||||
pair
|
pair
|
||||||
? {
|
? {
|
||||||
@@ -487,24 +533,208 @@ export function card(app: App) {
|
|||||||
}
|
}
|
||||||
: undefined)(Object.entries(e).shift()),
|
: undefined)(Object.entries(e).shift()),
|
||||||
);
|
);
|
||||||
if (!builds?.id) return status(404, "this needs to be changed.");
|
if (!builds?.id) return status(404, errorLoc.buildNotFound);
|
||||||
|
|
||||||
const res = redirect(
|
const res = redirect(
|
||||||
`/v1/card/enka/${username}/${hoyo.hash}/${builds.char}/${builds.id}`,
|
`/v1/card/enka/${username}/${hoyo.hash}/${builds.char}/${builds.id}`,
|
||||||
);
|
);
|
||||||
queueMicrotask(() => {
|
queueMicrotask(async () => {
|
||||||
|
cache.set(cacheKey, res, 1000 * 60); // cache for 1 minute
|
||||||
cache.set(
|
cache.set(
|
||||||
`enka-redir::${username}::${hoyo.hash}`,
|
`enka-redir::${username}::${hoyo.hash}`,
|
||||||
builds,
|
await buildsArr,
|
||||||
1000 * 120,
|
1000 * 120,
|
||||||
);
|
);
|
||||||
cache.set(cacheKey, res, 1000 * 60); // cache for 1 minute
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.clone();
|
return res.clone();
|
||||||
})
|
} catch (err) {
|
||||||
.get("/enka/:username/:hash/:char/:build", () => {
|
if (err instanceof ResponseNotOkError && err.status === 404)
|
||||||
|
return status(404, errorLoc.profileNotFound);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
query: t.Partial(
|
||||||
|
t.Object({
|
||||||
|
lang: Locale,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/enka/:username/:hash/:char/:build",
|
||||||
|
async ({
|
||||||
|
params: { username, hash: buildHash, char, build },
|
||||||
|
query: {
|
||||||
|
debug,
|
||||||
|
h: heightRaw = 1024,
|
||||||
|
w,
|
||||||
|
error: errorType,
|
||||||
|
lang,
|
||||||
|
substat: enableSubstat,
|
||||||
|
quality: enableRollQuality,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
log,
|
||||||
|
}) => {
|
||||||
|
log`Rendering ${username}/${buildHash}/${char}/${build}`;
|
||||||
|
const error =
|
||||||
|
errorType === "card"
|
||||||
|
? (_: number, message: string) => ErrorPage(message)
|
||||||
|
: status;
|
||||||
|
const errorLoc = cardErrorLocs[lang === "th" ? "th" : "en"];
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
[avatarStore, weaponStore, relicStore, locs, affixes],
|
||||||
|
builds,
|
||||||
|
] = await Promise.all([
|
||||||
|
deduper.acquire("stores", () =>
|
||||||
|
batchStore(
|
||||||
|
"avatars",
|
||||||
|
"weapons",
|
||||||
|
"relics",
|
||||||
|
"locs",
|
||||||
|
"affixes",
|
||||||
|
).finally(() => log`Stores loaded`),
|
||||||
|
),
|
||||||
|
cache.get(`enka-redir::${username}::${buildHash}`)
|
||||||
|
? Promise.resolve(
|
||||||
|
cache.get(
|
||||||
|
`enka-redir::${username}::${buildHash}`,
|
||||||
|
) as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
avatar_id: string;
|
||||||
|
avatar_data: ApiResponse.AvatarInfo;
|
||||||
|
live: boolean;
|
||||||
|
public: boolean;
|
||||||
|
image?: string;
|
||||||
|
hoyo_type: number;
|
||||||
|
hoyo: string;
|
||||||
|
owner: string;
|
||||||
|
}[]
|
||||||
|
>,
|
||||||
|
)
|
||||||
|
: fetch(
|
||||||
|
`https://enka.network/api/profile/${username}/hoyos/${buildHash}/builds/`,
|
||||||
|
)
|
||||||
|
.then(throwNotOk)
|
||||||
|
.then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!(char in builds)) return error(404, errorLoc.buildNotFound);
|
||||||
|
const { [char]: avatars } = builds;
|
||||||
|
|
||||||
|
const avatar = avatars.find(
|
||||||
|
(e: (typeof avatars)[number]) =>
|
||||||
|
`${e.id}` === build && e.hoyo_type === 0,
|
||||||
|
);
|
||||||
|
if (!avatar) return error(404, errorLoc.buildNotFound);
|
||||||
|
|
||||||
|
const [avatarInfo, playerInfo] = await Promise.all([
|
||||||
|
resolveAvatarInfo(avatar.avatar_data),
|
||||||
|
profilePlayerInfo(username, avatar),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const height =
|
||||||
|
typeof w === "number" ? w / (1218 / 508) : heightRaw;
|
||||||
|
|
||||||
|
const cacheKey = hash(
|
||||||
|
JSON.stringify({
|
||||||
|
username,
|
||||||
|
hash: buildHash,
|
||||||
|
char,
|
||||||
|
build,
|
||||||
|
lang,
|
||||||
|
height,
|
||||||
|
enableSubstat,
|
||||||
|
enableRollQuality,
|
||||||
|
debug,
|
||||||
|
format,
|
||||||
|
}),
|
||||||
|
).toString(36);
|
||||||
|
if (feature("API")) {
|
||||||
|
const cached = cache.get<ImageResponse | string>(cacheKey);
|
||||||
|
if (cached)
|
||||||
|
return typeof cached === "string" ? cached : cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await CharacterPage({
|
||||||
|
avatarInfo,
|
||||||
|
playerInfo,
|
||||||
|
uid: username,
|
||||||
|
avatarStore,
|
||||||
|
weaponStore,
|
||||||
|
relicStore,
|
||||||
|
locs,
|
||||||
|
affixes,
|
||||||
|
lang,
|
||||||
|
height,
|
||||||
|
debug,
|
||||||
|
enableSubstat,
|
||||||
|
enableRollQuality,
|
||||||
|
format,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
queueMicrotask(() => {
|
||||||
|
cache.set(cacheKey, res, 1000 * 60 * 60 * 24);
|
||||||
|
});
|
||||||
|
|
||||||
|
return typeof res === "string" ? res : res.clone();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InvalidFormatError)
|
||||||
|
return error(400, errorLoc.uidFormat);
|
||||||
|
else if (err instanceof NotFoundError)
|
||||||
|
return error(404, errorLoc.buildNotFound);
|
||||||
|
else if (err instanceof MaintenanceError)
|
||||||
|
return error(424, errorLoc.maintenance);
|
||||||
|
else if (err instanceof RatelimitError)
|
||||||
|
return error(429, errorLoc.ratelimit);
|
||||||
|
else if (err instanceof InternalError)
|
||||||
|
return status(502, errorLoc.internal);
|
||||||
|
else if (err instanceof PrivateShowcaseError)
|
||||||
|
return error(400, errorLoc.private);
|
||||||
|
else if (err instanceof UnsupportedCharacterError)
|
||||||
|
return error(400, errorLoc.unsupported);
|
||||||
|
log.error`${err}`;
|
||||||
|
try {
|
||||||
|
return error(
|
||||||
|
500,
|
||||||
|
`Internal Error:\n${(err as { message: string })?.message || inspect(err)}` as "",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({
|
||||||
|
username: t.String(),
|
||||||
|
hash: t.String(),
|
||||||
|
char: t.String(),
|
||||||
|
build: t.String(),
|
||||||
|
}),
|
||||||
|
query: characterCardQuery,
|
||||||
|
detail: {
|
||||||
|
tags: ["Card"],
|
||||||
|
description:
|
||||||
|
"Generate a Genshin Impact enka.network build card image from a profile build.",
|
||||||
|
},
|
||||||
|
error({ code, error, query: { error: errorType } }) {
|
||||||
|
if (code === "VALIDATION") {
|
||||||
|
const { summary } = JSON.parse(error.message);
|
||||||
|
try {
|
||||||
|
if (errorType === "http") throw "";
|
||||||
|
return ErrorPage(`Validation Error:\n${summary}`);
|
||||||
|
} catch {
|
||||||
|
return status(500, summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterResponse: ({ log }) => log`Rendering completed`,
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
"/health/enka",
|
"/health/enka",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { file } from "bun";
|
import { file } from "bun";
|
||||||
|
import type { ResolvedAvatarInfo, ResolvedPlayerInfo } from "enka.ts";
|
||||||
import type { ResolvedRoot } from "enka.ts";
|
|
||||||
import { EquipType } from "enka.ts";
|
import { EquipType } from "enka.ts";
|
||||||
import { ImageResponse } from "takumi-js/response";
|
import { ImageResponse } from "takumi-js/response";
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ import { star } from "@/games/genshin/assets/stars";
|
|||||||
import { TalentSvg } from "@/games/genshin/assets/talent";
|
import { TalentSvg } from "@/games/genshin/assets/talent";
|
||||||
import { WeaponGlazeSvg } from "@/games/genshin/assets/weapon-glaze";
|
import { WeaponGlazeSvg } from "@/games/genshin/assets/weapon-glaze";
|
||||||
import { assets, getAvatar } from "@/games/genshin/data/assets";
|
import { assets, getAvatar } from "@/games/genshin/data/assets";
|
||||||
import { resolveShowcaseChar } from "@/games/genshin/data/matcher";
|
|
||||||
import { buildDisplayRows } from "@/games/genshin/data/rows";
|
import { buildDisplayRows } from "@/games/genshin/data/rows";
|
||||||
import type { RollSummary } from "@/games/genshin/data/substat";
|
import type { RollSummary } from "@/games/genshin/data/substat";
|
||||||
import { buildRollSummary } from "@/games/genshin/data/substat";
|
import { buildRollSummary } from "@/games/genshin/data/substat";
|
||||||
@@ -41,12 +39,6 @@ export class PrivateShowcaseError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotInShowcaseError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("NOT_IN_SHOWCASE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnsupportedCharacterError extends Error {
|
export class UnsupportedCharacterError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("UNSUPPORTED_CHARACTER");
|
super("UNSUPPORTED_CHARACTER");
|
||||||
@@ -54,8 +46,9 @@ export class UnsupportedCharacterError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CharacterPageOptions = {
|
type CharacterPageOptions = {
|
||||||
data: ResolvedRoot;
|
avatarInfo: ResolvedAvatarInfo;
|
||||||
charId: string;
|
playerInfo: ResolvedPlayerInfo;
|
||||||
|
uid: string;
|
||||||
avatarStore: AvatarStore;
|
avatarStore: AvatarStore;
|
||||||
weaponStore: WeaponStore;
|
weaponStore: WeaponStore;
|
||||||
relicStore: RelicStore;
|
relicStore: RelicStore;
|
||||||
@@ -71,8 +64,9 @@ type CharacterPageOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function CharacterPage({
|
export async function CharacterPage({
|
||||||
data,
|
avatarInfo: char,
|
||||||
charId,
|
playerInfo,
|
||||||
|
uid,
|
||||||
avatarStore,
|
avatarStore,
|
||||||
weaponStore,
|
weaponStore,
|
||||||
relicStore,
|
relicStore,
|
||||||
@@ -88,13 +82,10 @@ export async function CharacterPage({
|
|||||||
}: CharacterPageOptions) {
|
}: CharacterPageOptions) {
|
||||||
const enkaLoc = locs[lang ?? "en"];
|
const enkaLoc = locs[lang ?? "en"];
|
||||||
|
|
||||||
if (typeof data.avatarInfoList === "undefined")
|
if (typeof playerInfo.showAvatarInfoList === "undefined")
|
||||||
throw new PrivateShowcaseError();
|
throw new PrivateShowcaseError();
|
||||||
|
|
||||||
const char = resolveShowcaseChar(charId, data.avatarInfoList);
|
const avatar = getAvatar(avatarStore, playerInfo, char.avatar.avatarId);
|
||||||
if (!char) throw new NotInShowcaseError();
|
|
||||||
|
|
||||||
const avatar = getAvatar(avatarStore, data.playerInfo, char.avatar.avatarId);
|
|
||||||
if (!avatar) throw new UnsupportedCharacterError();
|
if (!avatar) throw new UnsupportedCharacterError();
|
||||||
|
|
||||||
if (!char.equipList) throw new Error("INTERNAL: No equipList provided");
|
if (!char.equipList) throw new Error("INTERNAL: No equipList provided");
|
||||||
@@ -109,10 +100,7 @@ export async function CharacterPage({
|
|||||||
const ascension = char.propMap?.[1002].val ?? 1;
|
const ascension = char.propMap?.[1002].val ?? 1;
|
||||||
const promoteMaxLevelMap = [20, 40, 50, 60, 70, 80, 90, 100];
|
const promoteMaxLevelMap = [20, 40, 50, 60, 70, 80, 90, 100];
|
||||||
const charLvlMax =
|
const charLvlMax =
|
||||||
ascension * 10 +
|
ascension * 10 + (ascension > 0 ? 10 : 0) + 20 + (charLvl > 90 ? 10 : 0);
|
||||||
(ascension > 0 ? 10 : 0) +
|
|
||||||
20 +
|
|
||||||
(charLvl > 90 ? 10 : 0);
|
|
||||||
|
|
||||||
const statRows = buildDisplayRows(char, enkaLoc);
|
const statRows = buildDisplayRows(char, enkaLoc);
|
||||||
|
|
||||||
@@ -128,7 +116,7 @@ export async function CharacterPage({
|
|||||||
}))
|
}))
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
set: a.flat.setId!,
|
set: a.flat.setId! ?? a.flat.setNameTextMapHash,
|
||||||
stars: a.flat.rankLevel,
|
stars: a.flat.rankLevel,
|
||||||
level: a.reliquary!.level - 1,
|
level: a.reliquary!.level - 1,
|
||||||
appendPropIdList,
|
appendPropIdList,
|
||||||
@@ -156,7 +144,7 @@ export async function CharacterPage({
|
|||||||
const artifactSets = Object.entries(setCounts)
|
const artifactSets = Object.entries(setCounts)
|
||||||
.filter(([, count]) => count >= 2)
|
.filter(([, count]) => count >= 2)
|
||||||
.map(([set, count]) => ({
|
.map(([set, count]) => ({
|
||||||
set: enkaLoc[relicStore.Sets[set].Name],
|
set: enkaLoc[relicStore.Sets[set]?.Name] ?? enkaLoc[set] ?? set,
|
||||||
count,
|
count,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -172,13 +160,17 @@ export async function CharacterPage({
|
|||||||
const resources = await Promise.all([
|
const resources = await Promise.all([
|
||||||
assets
|
assets
|
||||||
.load(
|
.load(
|
||||||
weapon.data.promoteLevel >= 2 ? weapon.store.AwakenIcon : weapon.store.Icon,
|
weapon.data.promoteLevel >= 2
|
||||||
|
? weapon.store.AwakenIcon
|
||||||
|
: weapon.store.Icon,
|
||||||
)
|
)
|
||||||
.then((data) => ({ src: "/weapon", data })),
|
.then((data) => ({ src: "/weapon", data })),
|
||||||
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(async (path) => ({
|
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
|
||||||
|
async (path) => ({
|
||||||
src: path,
|
src: path,
|
||||||
data: await assets.load(path),
|
data: await assets.load(path),
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
...artifacts
|
...artifacts
|
||||||
.filter((x): x is NonNullable<(typeof artifacts)[number]> => !!x)
|
.filter((x): x is NonNullable<(typeof artifacts)[number]> => !!x)
|
||||||
.map(async (art) => ({
|
.map(async (art) => ({
|
||||||
@@ -212,7 +204,7 @@ export async function CharacterPage({
|
|||||||
}).then((r) => r.canvas.toBuffer("image/webp")),
|
}).then((r) => r.canvas.toBuffer("image/webp")),
|
||||||
)
|
)
|
||||||
.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
|
.then((data) => ({ src: `/bg-${avatar.Element}`, data })),
|
||||||
] satisfies Promise<{ src: string; data: ArrayBuffer | Buffer; }>[]);
|
] satisfies Promise<{ src: string; data: ArrayBuffer | Buffer }>[]);
|
||||||
|
|
||||||
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
|
log`Image resources loaded\n${resources.map((r) => `- ${r.src} ${(r.data.byteLength / 1000).toFixed(2)}Kb`).join("\n")}`;
|
||||||
|
|
||||||
@@ -224,7 +216,10 @@ export async function CharacterPage({
|
|||||||
charLvlMax,
|
charLvlMax,
|
||||||
cache: assets.health,
|
cache: assets.health,
|
||||||
assets: Object.fromEntries(
|
assets: Object.fromEntries(
|
||||||
resources.map((e) => [e.src, `${(e.data.byteLength / 1000).toFixed(2)}Kb`]),
|
resources.map((e) => [
|
||||||
|
e.src,
|
||||||
|
`${(e.data.byteLength / 1000).toFixed(2)}Kb`,
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
char,
|
char,
|
||||||
weapon,
|
weapon,
|
||||||
@@ -245,11 +240,17 @@ export async function CharacterPage({
|
|||||||
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
|
tw={`flex relative text-white rounded-[.5em] overflow-hidden aspect-[1218/508] h-[${height}px]`}
|
||||||
>
|
>
|
||||||
<div tw="flex aspect-[1218/508] h-full">
|
<div tw="flex aspect-[1218/508] h-full">
|
||||||
<img src={`/bg-${avatar.Element}`} alt="background" tw="w-full h-full object-cover" />
|
<img
|
||||||
|
src={`/bg-${avatar.Element}`}
|
||||||
|
alt="background"
|
||||||
|
tw="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
|
tw="absolute top-0 bottom-0 left-0 w-[30.4em] p-[1.6em] flex flex-col"
|
||||||
style={{ textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)" }}
|
style={{
|
||||||
|
textShadow: "0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/avatar"
|
src="/avatar"
|
||||||
@@ -266,11 +267,16 @@ export async function CharacterPage({
|
|||||||
<div tw="text-[120%] pb-[.5%] flex items-center gap-[.5em]">
|
<div tw="text-[120%] pb-[.5%] flex items-center gap-[.5em]">
|
||||||
{char.avatar.name}
|
{char.avatar.name}
|
||||||
<div tw="text-[0.77em] opacity-80 flex items-center gap-[.7em] pl-[.28em] pt-[.5em]">
|
<div tw="text-[0.77em] opacity-80 flex items-center gap-[.7em] pl-[.28em] pt-[.5em]">
|
||||||
<svg width="16" height="16" viewBox="0 0 100 100" tw="mt-[.2em] h-[.7em] w-[.7em]">
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
tw="mt-[.2em] h-[.7em] w-[.7em]"
|
||||||
|
>
|
||||||
<title>doritos</title>
|
<title>doritos</title>
|
||||||
<polygon points="50,10 90,90 10,90" fill="currentColor" />
|
<polygon points="50,10 90,90 10,90" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
{data.playerInfo.nickname}
|
{playerInfo.nickname}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex text-[102%] pt-[.3em]">
|
<div tw="flex text-[102%] pt-[.3em]">
|
||||||
@@ -278,7 +284,11 @@ export async function CharacterPage({
|
|||||||
<span tw="opacity-60 pl-[.3em]">{charLvlMax}</span>
|
<span tw="opacity-60 pl-[.3em]">{charLvlMax}</span>
|
||||||
</div>
|
</div>
|
||||||
<div tw="text-[110%] flex gap-[.3em] items-end pt-[.35em]">
|
<div tw="text-[110%] flex gap-[.3em] items-end pt-[.35em]">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" tw="h-[1.2em] mb-[.2em]" viewBox="0 0 14 14">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
tw="h-[1.2em] mb-[.2em]"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
>
|
||||||
<title>fren</title>
|
<title>fren</title>
|
||||||
<path
|
<path
|
||||||
fill="white"
|
fill="white"
|
||||||
@@ -288,11 +298,18 @@ export async function CharacterPage({
|
|||||||
{char.fetterInfo?.expLevel ?? 0}
|
{char.fetterInfo?.expLevel ?? 0}
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex grow" />
|
<div tw="flex grow" />
|
||||||
<div tw="flex align-start flex-col text-[1.12em]" className={`${avatar.Element} locked`}>
|
<div
|
||||||
|
tw="flex align-start flex-col text-[1.12em]"
|
||||||
|
className={`${avatar.Element} locked`}
|
||||||
|
>
|
||||||
{avatar.Consts.filter((c) => c !== "None").map((c, i) => (
|
{avatar.Consts.filter((c) => c !== "None").map((c, i) => (
|
||||||
<div
|
<div
|
||||||
key={c}
|
key={c}
|
||||||
style={{ position: "relative", display: "flex", margin: "-.55em 0 0em -.6em" }}
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
margin: "-.55em 0 0em -.6em",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ConstsSvg locked={i >= (char.talentIdList?.length || 0)} />
|
<ConstsSvg locked={i >= (char.talentIdList?.length || 0)} />
|
||||||
<img
|
<img
|
||||||
@@ -315,26 +332,39 @@ export async function CharacterPage({
|
|||||||
fontSize: "1.05em",
|
fontSize: "1.05em",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatar.SkillOrder.map((s) => Object.entries(avatar.Skills).find((k) => k[0] === `${s}`)!).map(
|
{avatar.SkillOrder.map(
|
||||||
([key, src]) => {
|
(s) =>
|
||||||
|
Object.entries(avatar.Skills).find((k) => k[0] === `${s}`)!,
|
||||||
|
).map(([key, src]) => {
|
||||||
if (src === "None") return null;
|
if (src === "None") return null;
|
||||||
const extra = char.proudSkillExtraLevelMap?.[`${avatar.ProudMap[key]}`] ?? 0;
|
const extra =
|
||||||
|
char.proudSkillExtraLevelMap?.[`${avatar.ProudMap[key]}`] ??
|
||||||
|
0;
|
||||||
const talentLevel = (char.skillLevelMap?.[key] ?? 0) + extra;
|
const talentLevel = (char.skillLevelMap?.[key] ?? 0) + extra;
|
||||||
return (
|
return (
|
||||||
<div key={key} tw="mt-[.6em] relative flex">
|
<div key={key} tw="mt-[.6em] relative flex">
|
||||||
<TalentSvg tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black mb-[.5em] -mt-[.25em]`} />
|
<TalentSvg
|
||||||
<img src={src} alt={`Talent ${key}`} tw="w-[2.5em] absolute left-[.15em] top-[.05em] m-[.4em_0_0_.4em]" />
|
tw={`Talents ${avatar.Element} w-[3.6em] opacity-60 text-black mb-[.5em] -mt-[.25em]`}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`Talent ${key}`}
|
||||||
|
tw="w-[2.5em] absolute left-[.15em] top-[.05em] m-[.4em_0_0_.4em]"
|
||||||
|
/>
|
||||||
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
|
<div tw="absolute -bottom-[.1em] w-full flex justify-center">
|
||||||
<div tw={`flex ${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.85em] rounded-[1.4em]`}>
|
<div
|
||||||
|
tw={`flex ${extra ? "bg-[#45deffcc]" : "bg-[#323232b3]"} pt-[5%] pb-[2%] text-[.85em] rounded-[1.4em]`}
|
||||||
|
>
|
||||||
<span tw="px-[.6em]">{talentLevel}</span>
|
<span tw="px-[.6em]">{talentLevel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
})}
|
||||||
)}
|
</div>
|
||||||
|
<div tw="flex mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">
|
||||||
|
{uid}
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex mb-[-1em] ml-[-.3em] mt-[.3em] text-[.96em] opacity-80">{data.uid}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div tw="absolute top-0 bottom-0 right-[24.2em] w-[25.5em] p-[1.5em_.7em_.7em] flex flex-col text-[1.01em]">
|
<div tw="absolute top-0 bottom-0 right-[24.2em] w-[25.5em] p-[1.5em_.7em_.7em] flex flex-col text-[1.01em]">
|
||||||
<div tw="flex flex-start" className="Weapon">
|
<div tw="flex flex-start" className="Weapon">
|
||||||
@@ -353,32 +383,54 @@ export async function CharacterPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div tw="absolute w-full top-[91%] flex justify-center">
|
<div tw="absolute w-full top-[91%] flex justify-center">
|
||||||
{Array.from({ length: weapon.store.Rarity }, () => star(".8em"))}
|
{Array.from({ length: weapon.store.Rarity }, () =>
|
||||||
|
star(".8em"),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div tw="px-[5%] grow text-white self-center flex flex-col">
|
<div tw="px-[5%] grow text-white self-center flex flex-col">
|
||||||
<div tw="flex text-[108%] leading-[1.3]" style={{ textShadow: "0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)" }}>
|
<div
|
||||||
|
tw="flex text-[108%] leading-[1.3]"
|
||||||
|
style={{
|
||||||
|
textShadow:
|
||||||
|
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>{enkaLoc[weapon.store.NameTextMapHash]}</span>
|
<span>{enkaLoc[weapon.store.NameTextMapHash]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div tw="pt-[3%] pb-[2%] flex gap-[.7em]">
|
<div tw="pt-[3%] pb-[2%] flex gap-[.7em]">
|
||||||
{weapon.flat.weaponStats?.map((s) => (
|
{weapon.flat.weaponStats?.map((s) => (
|
||||||
<div key={s.appendPropId} tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]">
|
<div
|
||||||
<EnkaIcon icon={s.appendPropId as keyof typeof library} tw="h-[1.1em]" />
|
key={s.appendPropId}
|
||||||
|
tw="rounded-[.2em] leading-none bg-white/15 px-[.34em] py-[0.25em] flex items-center gap-[.7em] text-[1.03em]"
|
||||||
|
>
|
||||||
|
<EnkaIcon
|
||||||
|
icon={s.appendPropId as keyof typeof library}
|
||||||
|
tw="h-[1.1em]"
|
||||||
|
/>
|
||||||
<div tw="pt-[.1em]">
|
<div tw="pt-[.1em]">
|
||||||
{s.statValue}
|
{s.statValue}
|
||||||
{(PercentageFightProp as string[]).includes(s.appendPropId) ? "%" : ""}
|
{(PercentageFightProp as string[]).includes(
|
||||||
|
s.appendPropId,
|
||||||
|
)
|
||||||
|
? "%"
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex gap-[.85em]">
|
<div tw="flex gap-[.85em]">
|
||||||
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]">
|
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em] text-[wheat]">
|
||||||
<div tw="pt-[.1em]">R{Object.values(weapon.data.affixMap || { _: 0 })[0] + 1}</div>
|
<div tw="pt-[.1em]">
|
||||||
|
R{Object.values(weapon.data.affixMap || { _: 0 })[0] + 1}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]">
|
<div tw="rounded-[.2em] leading-none bg-black/50 px-[.34em] py-[0.175em] flex items-center gap-[.7em] text-[1.03em]">
|
||||||
<div tw="pt-[.1em] flex items-center">
|
<div tw="pt-[.1em] flex items-center">
|
||||||
<span tw="pr-[.1em]">Lv.</span> {weapon.data.level}/
|
<span tw="pr-[.1em]">Lv.</span> {weapon.data.level}/
|
||||||
<div tw="opacity-60 pl-[.25em]">{promoteMaxLevelMap[weapon.data.promoteLevel ?? 0]}</div>
|
<div tw="opacity-60 pl-[.25em]">
|
||||||
|
{promoteMaxLevelMap[weapon.data.promoteLevel ?? 0]}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,7 +460,9 @@ export async function CharacterPage({
|
|||||||
<div tw="flex grow justify-end">
|
<div tw="flex grow justify-end">
|
||||||
<div tw="flex">
|
<div tw="flex">
|
||||||
{Math.round(r.base).toLocaleString()}
|
{Math.round(r.base).toLocaleString()}
|
||||||
<div tw="flex text-[#82ff9a] pl-1">+{Math.round(r.delta).toLocaleString()}</div>
|
<div tw="flex text-[#82ff9a] pl-1">
|
||||||
|
+{Math.round(r.delta).toLocaleString()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -416,7 +470,13 @@ export async function CharacterPage({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{artifactSets.length && (
|
{artifactSets.length && (
|
||||||
<div tw="flex w-full gap-[.5em]" style={{ textShadow: "0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)" }}>
|
<div
|
||||||
|
tw="flex w-full gap-[.5em]"
|
||||||
|
style={{
|
||||||
|
textShadow:
|
||||||
|
"0 .08em .1em rgba(0,0,0,.4),0 .1em .3em rgba(0,0,0,.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div tw="w-[2em] h-[2em] shrink-0 text-[1.15em] bg-[#00000030] flex justify-center items-center font-[shicon] leading-[2.3em] rounded-[.2em]">
|
<div tw="w-[2em] h-[2em] shrink-0 text-[1.15em] bg-[#00000030] flex justify-center items-center font-[shicon] leading-[2.3em] rounded-[.2em]">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -439,26 +499,51 @@ export async function CharacterPage({
|
|||||||
<div tw="absolute top-0 bottom-0 right-0 w-[25.7em] p-[.7em] flex flex-col justify-between">
|
<div tw="absolute top-0 bottom-0 right-0 w-[25.7em] p-[.7em] flex flex-col justify-between">
|
||||||
{artifacts.map((a, index) =>
|
{artifacts.map((a, index) =>
|
||||||
a ? (
|
a ? (
|
||||||
<div key={artifactSlots[index]} tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.5em]">
|
<div
|
||||||
<img src={a.icon} alt="artifact" tw="absolute top-[48%] -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90" />
|
key={artifactSlots[index]}
|
||||||
|
tw="h-[18%] bg-[#0000004f] rounded-[.3em] relative text-white overflow-hidden flex justify-between gap-[.5em]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={a.icon}
|
||||||
|
alt="artifact"
|
||||||
|
tw="absolute top-[48%] -left-[8%] w-[45%] -translate-y-1/2 -translate-x-[10%] object-contain aspect-square scale-90"
|
||||||
|
/>
|
||||||
<div tw="h-full w-[30%] text-[105%] text-right shrink-0 flex flex-col justify-center mr-[.25em]">
|
<div tw="h-full w-[30%] text-[105%] text-right shrink-0 flex flex-col justify-center mr-[.25em]">
|
||||||
<EnkaIcon icon={a.main.mainPropId} tw="h-[1em] w-[1em] ml-auto max-h-[22%] mt-[.5em]" />
|
<EnkaIcon
|
||||||
<div tw="text-[1.1em] pt-[2%] ml-auto" style={{ textShadow: "0 .07em .1em black" }}>
|
icon={a.main.mainPropId}
|
||||||
{PercentageFightProp.includes(a.main.mainPropId as (typeof PercentageFightProp)[number])
|
tw="h-[1em] w-[1em] ml-auto max-h-[22%] mt-[.5em]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
tw="text-[1.1em] pt-[2%] ml-auto"
|
||||||
|
style={{ textShadow: "0 .07em .1em black" }}
|
||||||
|
>
|
||||||
|
{PercentageFightProp.includes(
|
||||||
|
a.main
|
||||||
|
.mainPropId as (typeof PercentageFightProp)[number],
|
||||||
|
)
|
||||||
? `${a.main.statValue.toFixed(1)}%`
|
? `${a.main.statValue.toFixed(1)}%`
|
||||||
: a.main.statValue.toLocaleString()}
|
: a.main.statValue.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div tw="flex grow max-h-[20%] items-center justify-end">
|
<div tw="flex grow max-h-[20%] items-center justify-end">
|
||||||
<div>{Array.from({ length: a.stars ?? 0 }, () => star(".6em"))}</div>
|
<div>
|
||||||
|
{Array.from({ length: a.stars ?? 0 }, () =>
|
||||||
|
star(".6em"),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div tw="w-[2em] flex justify-end">
|
<div tw="w-[2em] flex justify-end">
|
||||||
<div tw="text-[75%] leading-none bg-black/50 px-[.25em] py-[.2em] rounded-[.2em] -mb-[.2em]">+{a.level}</div>
|
<div tw="text-[75%] leading-none bg-black/50 px-[.25em] py-[.2em] rounded-[.2em] -mb-[.2em]">
|
||||||
|
+{a.level}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr tw="border-none h-[84%] w-[2px] mt-[2%] bg-white/60" />
|
<hr tw="border-none h-[84%] w-[2px] mt-[2%] bg-white/60" />
|
||||||
<div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full">
|
<div tw="flex flex-col flex-wrap mt-[.35em] mb-[.35em] mr-[.35em] w-full">
|
||||||
{a.sub.map((s, i) => (
|
{a.sub.map((s, i) => (
|
||||||
<div key={`${s.appendPropId}-${s.statValue}`} tw={`flex items-center pl-[.7em] relative gap-[.5em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""} ${i > 1 ? "pl-[.3em]" : ""}`}>
|
<div
|
||||||
|
key={`${s.appendPropId}-${s.statValue}`}
|
||||||
|
tw={`flex items-center pl-[.7em] relative gap-[.5em] w-1/2 h-1/2 ${i === 3 ? "opacity-80" : ""} ${i > 1 ? "pl-[.3em]" : ""}`}
|
||||||
|
>
|
||||||
{enableRollQuality && (
|
{enableRollQuality && (
|
||||||
<div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5">
|
<div tw="absolute top-0 m-[.1em] left-[-.25em] bottom-0 right-[.25em] bg-gradient-to-t from-transparent to-black/25 rounded-[.2em] flex gap-0.5">
|
||||||
{s.props.map((p) => (
|
{s.props.map((p) => (
|
||||||
@@ -472,15 +557,22 @@ export async function CharacterPage({
|
|||||||
maxWidth: `${(p.Efficiency / s.props.length) * 100}%`,
|
maxWidth: `${(p.Efficiency / s.props.length) * 100}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array(p.Position).fill(<div tw="flex bg-[#fff8] w-1 h-1 mr-[3px] mb-[3px] rounded-full" />)}
|
{Array(p.Position).fill(
|
||||||
|
<div tw="flex bg-[#fff8] w-1 h-1 mr-[3px] mb-[3px] rounded-full" />,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<EnkaIcon icon={s.appendPropId} tw="h-[1.1em] w-[1.1em]" />
|
<EnkaIcon
|
||||||
|
icon={s.appendPropId}
|
||||||
|
tw="h-[1.1em] w-[1.1em]"
|
||||||
|
/>
|
||||||
<div tw="flex text-[1.05em]">
|
<div tw="flex text-[1.05em]">
|
||||||
+
|
+
|
||||||
{PercentageFightProp.includes(s.appendPropId as (typeof PercentageFightProp)[number])
|
{PercentageFightProp.includes(
|
||||||
|
s.appendPropId as (typeof PercentageFightProp)[number],
|
||||||
|
)
|
||||||
? `${s.statValue.toFixed(1)}%`
|
? `${s.statValue.toFixed(1)}%`
|
||||||
: s.statValue.toLocaleString()}
|
: s.statValue.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
@@ -489,7 +581,10 @@ export async function CharacterPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div key={artifactSlots[index]} tw="flex h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20" />
|
<div
|
||||||
|
key={artifactSlots[index]}
|
||||||
|
tw="flex h-[18%] bg-[#0000004f] rounded-[.3em] opacity-20"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -547,7 +642,10 @@ function SubstatDisplay({
|
|||||||
>
|
>
|
||||||
<div tw="text-white w-full flex items-center flex-wrap justify-end bg-black/25">
|
<div tw="text-white w-full flex items-center flex-wrap justify-end bg-black/25">
|
||||||
{substat.map((s) => (
|
{substat.map((s) => (
|
||||||
<div key={s.id} tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap">
|
<div
|
||||||
|
key={s.id}
|
||||||
|
tw="mr-[1.2em] flex gap-[.2em] items-center whitespace-nowrap"
|
||||||
|
>
|
||||||
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
|
<div tw="ml-[.2em] mb-[.1em] text-[.75em] flex flex-col items-center font-[Open_Sans]">
|
||||||
{s.efficiency}
|
{s.efficiency}
|
||||||
<div tw="flex w-full h-[1px] bg-white"></div>
|
<div tw="flex w-full h-[1px] bg-white"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user