feat : update
CI / Verify (push) Successful in 1m14s
CI / Build immutable images and deploy (push) Successful in 3m18s

This commit is contained in:
2026-09-06 15:58:11 +07:00 Unverified
parent de76a27c86
commit 5f6b8c1059
11 changed files with 3934 additions and 21 deletions
+2 -2
View File
@@ -180,7 +180,7 @@ export function GuideOverviewForm({
y: typeof (draft.posterArtOffset as Record<string, unknown>).y === "number" ? (draft.posterArtOffset as Record<string, unknown>).y as number : 0, y: typeof (draft.posterArtOffset as Record<string, unknown>).y === "number" ? (draft.posterArtOffset as Record<string, unknown>).y as number : 0,
} }
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY }, : { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(200, draft.posterArtScale)) : guide.posterArtScale, posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(500, draft.posterArtScale)) : guide.posterArtScale,
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2 posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
? draft.posterTopColors as [string, string] ? draft.posterTopColors as [string, string]
: guide.posterTopColors, : guide.posterTopColors,
@@ -399,7 +399,7 @@ export function GuideOverviewForm({
</div> </div>
<div className="rounded-lg border p-3"> <div className="rounded-lg border p-3">
<div className="flex justify-between text-sm"><span>Character image zoom</span><span>{posterArtScale}%</span></div> <div className="flex justify-between text-sm"><span>Character image zoom</span><span>{posterArtScale}%</span></div>
<Input type="range" min={50} max={200} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" /> <Input type="range" min={50} max={500} value={posterArtScale} onChange={(event) => setPosterArtScale(Number(event.target.value))} aria-label="Character image zoom" />
</div> </div>
<FieldDescription> <FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
+7 -9
View File
@@ -23,6 +23,7 @@ type PosterCharacter = {
nameCardImageUrl: string | null; nameCardImageUrl: string | null;
baseStats: CatalogBaseStat[]; baseStats: CatalogBaseStat[];
ascension: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>; ascension: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
leveling: Array<Omit<CatalogAscensionGroup, "items"> & { items: PosterMaterial[] }>;
}; };
const statLabels: Record<string, string> = { const statLabels: Record<string, string> = {
@@ -43,10 +44,6 @@ function statValue(key: string, value: number | string): string {
return key.includes("%") ? `${formatted}%` : formatted; return key.includes("%") ? `${formatted}%` : formatted;
} }
function clampOffset(value: number): number {
return Math.max(-50, Math.min(50, value));
}
export function MaterialPoster({ export function MaterialPoster({
character, character,
characterArtUrl, characterArtUrl,
@@ -73,8 +70,9 @@ export function MaterialPoster({
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>; bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode }>;
}) { }) {
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null); const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
const stats = character.baseStats.slice(0, 4); const preferredStats = ["hp", "atk", "def", "em"];
const materials = character.ascension.flatMap((group) => group.items); const stats = preferredStats.map((key) => character.baseStats.find((stat) => stat.key.toLowerCase() === key));
const materials = [...character.ascension, ...character.leveling].flatMap((group) => group.items);
const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`; const background = `linear-gradient(180deg, ${topColors[0]} 0%, ${topColors[1]} 22%, rgba(0,0,0,0) 42%, rgba(0,0,0,0) 62%, ${bottomColors[0]} 88%, ${bottomColors[1]} 100%)`;
function handlePointerDown(event: PointerEvent<HTMLDivElement>) { function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
@@ -93,8 +91,8 @@ export function MaterialPoster({
if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return; if (!dragStart.current || dragStart.current.pointerId !== event.pointerId || !onArtworkMove) return;
const rect = event.currentTarget.getBoundingClientRect(); const rect = event.currentTarget.getBoundingClientRect();
onArtworkMove({ onArtworkMove({
x: clampOffset(dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100), x: dragStart.current.offsetX + ((event.clientX - dragStart.current.x) / rect.width) * 100,
y: clampOffset(dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100), y: dragStart.current.offsetY + ((event.clientY - dragStart.current.y) / rect.height) * 100,
}); });
} }
@@ -164,7 +162,7 @@ export function MaterialPoster({
<div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}> <div className="absolute inset-x-[4%] top-[5%]" style={{ fontFamily: "Kanit Poster", fontWeight: 800, fontStyle: "italic", textShadow: "-6px 14px 0 rgba(0,0,0,0.59)" }}>
<h2 className="text-[7cqw] leading-none">BASE STATS</h2> <h2 className="text-[7cqw] leading-none">BASE STATS</h2>
<div className="mt-[4%] flex w-[53%] flex-col gap-[1.5%]"> <div className="mt-[4%] flex w-[53%] flex-col gap-[2.5%]">
{Array.from({ length: 4 }, (_, index) => { {Array.from({ length: 4 }, (_, index) => {
const stat = stats[index]; const stat = stats[index];
return ( return (
+1
View File
@@ -457,6 +457,7 @@ export const catalogCharacters = catalogSchema.table(
constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(), constellations: jsonb("constellations").$type<CatalogConstellation[]>().default([]).notNull(),
talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(), talents: jsonb("talents").$type<CatalogTalent[]>().default([]).notNull(),
ascension: jsonb("ascension").$type<CatalogAscensionGroup[]>().default([]).notNull(), ascension: jsonb("ascension").$type<CatalogAscensionGroup[]>().default([]).notNull(),
leveling: jsonb("leveling").$type<CatalogAscensionGroup[]>().default([]).notNull(),
baseStats: jsonb("base_stats").$type<CatalogBaseStat[]>().default([]).notNull(), baseStats: jsonb("base_stats").$type<CatalogBaseStat[]>().default([]).notNull(),
}, },
(table) => [ (table) => [
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "catalog"."character" ADD COLUMN "leveling" jsonb DEFAULT '[]'::jsonb NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -246,6 +246,13 @@
"when": 1788683110679, "when": 1788683110679,
"tag": "0034_confused_storm", "tag": "0034_confused_storm",
"breakpoints": true "breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1788684897536,
"tag": "0035_condemned_meltdown",
"breakpoints": true
} }
] ]
} }
+32
View File
@@ -4,6 +4,7 @@ import {
avatarIconName, avatarIconName,
ascensionSnapshot, ascensionSnapshot,
baseStatsSnapshot, baseStatsSnapshot,
levelingSnapshot,
characterDetailSchema, characterDetailSchema,
cleanLunarisText, cleanLunarisText,
constellationSnapshot, constellationSnapshot,
@@ -80,6 +81,24 @@ describe("Lunaris character data", () => {
]); ]);
}); });
it("normalizes leveling materials by category", () => {
const detail = characterDetailSchema.parse({
leveling: {
crown: { "104319": 3 },
books: { "104365": 9, "104366": 63, "104367": 114 },
worldmonster: { "112146": 18, "112147": 66, "112148": 93 },
weekly: { "113088": 18 },
},
});
expect(levelingSnapshot(detail)).toEqual([
{ type: "crown", items: [{ key: "104319", amount: 3 }] },
{ type: "books", items: [{ key: "104365", amount: 9 }, { key: "104366", amount: 63 }, { key: "104367", amount: 114 }] },
{ type: "worldmonster", items: [{ key: "112146", amount: 18 }, { key: "112147", amount: 66 }, { key: "112148", amount: 93 }] },
{ type: "weekly", items: [{ key: "113088", amount: 18 }] },
]);
});
it("selects dynamic level 90 base stats", () => { it("selects dynamic level 90 base stats", () => {
const detail = characterDetailSchema.parse({ const detail = characterDetailSchema.parse({
attributes: [ attributes: [
@@ -96,6 +115,19 @@ describe("Lunaris character data", () => {
]); ]);
}); });
it("selects base stats nested under info", () => {
const detail = characterDetailSchema.parse({
info: { attributes: [{ level: 90, hp: 100, atk: 20, def: 30, EM: 40 }] },
});
expect(baseStatsSnapshot(detail)).toEqual([
{ key: "hp", value: 100 },
{ key: "atk", value: 20 },
{ key: "def", value: 30 },
{ key: "EM", value: 40 },
]);
});
it("reads the supported weapon type from character details", () => { it("reads the supported weapon type from character details", () => {
const detail = characterDetailSchema.parse({ const detail = characterDetailSchema.parse({
info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" }, info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" },
+17 -4
View File
@@ -24,6 +24,8 @@ export interface CatalogAscensionGroup {
items: CatalogAscensionMaterial[]; items: CatalogAscensionMaterial[];
} }
export type CatalogLevelingGroup = CatalogAscensionGroup;
export interface CatalogMaterial { export interface CatalogMaterial {
key: string; key: string;
name: string; name: string;
@@ -50,10 +52,13 @@ const skillSchema = z.object({
icon: z.string(), icon: z.string(),
}); });
const attributeSchema = z.record(z.string(), z.union([z.number(), z.string()]));
export const characterDetailSchema = z.object({ export const characterDetailSchema = z.object({
info: z.object({ info: z.object({
element: z.string().optional(), element: z.string().optional(),
weapon: z.string().optional(), weapon: z.string().optional(),
attributes: z.array(attributeSchema).nullish(),
}).nullish(), }).nullish(),
constellations: z.record(z.string(), constellationSchema).nullish(), constellations: z.record(z.string(), constellationSchema).nullish(),
skills: z.object({ skills: z.object({
@@ -65,9 +70,8 @@ export const characterDetailSchema = z.object({
z.string(), z.string(),
z.union([z.number().int(), z.record(z.string(), z.number().int())]), z.union([z.number().int(), z.record(z.string(), z.number().int())]),
).nullish(), ).nullish(),
attributes: z.array( leveling: z.record(z.string(), z.record(z.string(), z.number().int())).nullish(),
z.record(z.string(), z.union([z.number(), z.string()])), attributes: z.array(attributeSchema).nullish(),
).nullish(),
}); });
export function cleanLunarisText(value: string): string { export function cleanLunarisText(value: string): string {
@@ -150,10 +154,19 @@ export function ascensionSnapshot(
})); }));
} }
export function levelingSnapshot(
value: z.infer<typeof characterDetailSchema>,
): CatalogLevelingGroup[] {
return Object.entries(value.leveling ?? {}).map(([type, entries]) => ({
type,
items: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
}));
}
export function baseStatsSnapshot( export function baseStatsSnapshot(
value: z.infer<typeof characterDetailSchema>, value: z.infer<typeof characterDetailSchema>,
): CatalogBaseStat[] { ): CatalogBaseStat[] {
const level90 = value.attributes?.find((entry) => entry.level === 90); const level90 = (value.info?.attributes ?? value.attributes)?.find((entry) => entry.level === 90);
if (!level90) return []; if (!level90) return [];
return Object.entries(level90) return Object.entries(level90)
.filter(([key]) => key !== "level" && key !== "ascension") .filter(([key]) => key !== "level" && key !== "ascension")
+9 -1
View File
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { sql } from "drizzle-orm";
import * as z from "zod"; import * as z from "zod";
import { getDb } from "@/db/client"; import { getDb } from "@/db/client";
@@ -11,6 +12,7 @@ import {
} from "@/db/schema"; } from "@/db/schema";
import { import {
ascensionSnapshot, ascensionSnapshot,
levelingSnapshot,
avatarIconName, avatarIconName,
baseStatsSnapshot, baseStatsSnapshot,
characterDetailSchema, characterDetailSchema,
@@ -21,6 +23,7 @@ import {
} from "@/lib/catalog/lunaris"; } from "@/lib/catalog/lunaris";
import type { import type {
CatalogAscensionGroup, CatalogAscensionGroup,
CatalogLevelingGroup,
CatalogBaseStat, CatalogBaseStat,
CatalogConstellation, CatalogConstellation,
CatalogMaterial, CatalogMaterial,
@@ -98,6 +101,7 @@ interface SyncedCharacter {
constellations: CatalogConstellation[]; constellations: CatalogConstellation[];
talents: CatalogTalent[]; talents: CatalogTalent[];
ascension: CatalogAscensionGroup[]; ascension: CatalogAscensionGroup[];
leveling: CatalogLevelingGroup[];
baseStats: CatalogBaseStat[]; baseStats: CatalogBaseStat[];
} }
@@ -288,6 +292,7 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
) )
: [], : [],
ascension: detail ? ascensionSnapshot(detail) : [], ascension: detail ? ascensionSnapshot(detail) : [],
leveling: detail ? levelingSnapshot(detail) : [],
baseStats: detail ? baseStatsSnapshot(detail) : [], baseStats: detail ? baseStatsSnapshot(detail) : [],
}; };
}, },
@@ -324,6 +329,7 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
(icon) => `catalog/characters/talents/${webpName(icon)}`, (icon) => `catalog/characters/talents/${webpName(icon)}`,
), ),
ascension: ascensionSnapshot(detail), ascension: ascensionSnapshot(detail),
leveling: levelingSnapshot(detail),
baseStats: baseStatsSnapshot(detail), baseStats: baseStatsSnapshot(detail),
}; };
}, },
@@ -475,6 +481,7 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
constellations: item.constellations, constellations: item.constellations,
talents: item.talents, talents: item.talents,
ascension: item.ascension, ascension: item.ascension,
leveling: item.leveling,
baseStats: item.baseStats, baseStats: item.baseStats,
})), })),
), ),
@@ -535,6 +542,7 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
constellations: item.constellations, constellations: item.constellations,
talents: item.talents, talents: item.talents,
ascension: item.ascension, ascension: item.ascension,
leveling: item.leveling,
baseStats: item.baseStats, baseStats: item.baseStats,
})), })),
); );
@@ -643,7 +651,7 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
async function hasCatalogRecords(): Promise<boolean> { async function hasCatalogRecords(): Promise<boolean> {
const db = getDb(); const db = getDb();
const [character] = await db.select({ key: catalogCharacters.key }).from(catalogCharacters).limit(1); const [character] = await db.select({ key: catalogCharacters.key }).from(catalogCharacters).where(sql`${catalogCharacters.baseStats} <> '[]'::jsonb`).limit(1);
const [material] = await db.select({ key: catalogMaterials.key }).from(catalogMaterials).limit(1); const [material] = await db.select({ key: catalogMaterials.key }).from(catalogMaterials).limit(1);
const [weapon] = await db.select({ key: catalogWeapons.key }).from(catalogWeapons).limit(1); const [weapon] = await db.select({ key: catalogWeapons.key }).from(catalogWeapons).limit(1);
const [artifact] = await db.select({ key: catalogArtifacts.key }).from(catalogArtifacts).limit(1); const [artifact] = await db.select({ key: catalogArtifacts.key }).from(catalogArtifacts).limit(1);
+18 -2
View File
@@ -66,6 +66,22 @@ function catalogCharacterWithUrls(
}; };
}), }),
})), })),
leveling: item.leveling.map((group) => ({
...group,
items: group.items.map((entry) => {
const material = materialByKey.get(entry.key);
return {
...entry,
name: material?.name ?? entry.key,
description: material?.description ?? null,
rarity: material?.rarity ?? null,
type: material?.type ?? null,
imageUrl: material ? publicMediaUrl(material.imageKey) : null,
lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(entry.key)}`,
available: Boolean(material),
};
}),
})),
}; };
} }
@@ -125,7 +141,7 @@ export async function getGuideByCharacterKey(characterKey: string) {
.limit(1); .limit(1);
if (!row) return null; if (!row) return null;
const materialKeys = [...new Set( const materialKeys = [...new Set(
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), [...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)]; )];
const [materials, poster, bleeding] = await Promise.all([ const [materials, poster, bleeding] = await Promise.all([
materialKeys.length materialKeys.length
@@ -162,7 +178,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
.limit(1); .limit(1);
if (!row) return null; if (!row) return null;
const materialKeys = [...new Set( const materialKeys = [...new Set(
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), [...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)]; )];
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([ const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([
getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)),
+3 -3
View File
@@ -37,9 +37,9 @@ export const updateGuideSchema = z.strictObject({
overview: z.string().trim().min(1).max(500), overview: z.string().trim().min(1).max(500),
youtubeUrl: youtubeUrlSchema, youtubeUrl: youtubeUrlSchema,
posterArtMediaId: z.string().uuid().nullable().optional(), posterArtMediaId: z.string().uuid().nullable().optional(),
posterArtOffsetX: z.number().finite().min(-50).max(50).optional(), posterArtOffsetX: z.number().finite().min(-1000).max(1000).optional(),
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(), posterArtOffsetY: z.number().finite().min(-1000).max(1000).optional(),
posterArtScale: z.number().finite().min(50).max(200).optional(), posterArtScale: z.number().finite().min(50).max(500).optional(),
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(), posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(), posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
posterBleeding: z.array(z.strictObject({ posterBleeding: z.array(z.strictObject({