feat : add metrial fetcher

This commit is contained in:
2026-09-06 13:59:32 +07:00 Unverified
parent 2808a27406
commit 37d6d6703f
8 changed files with 3827 additions and 8 deletions
+20 -1
View File
@@ -16,7 +16,11 @@ import {
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris"; import type {
CatalogAscensionGroup,
CatalogConstellation,
CatalogTalent,
} from "@/lib/catalog/lunaris";
import type { ExtraSheet } from "@/lib/guides/extra-sheet"; import type { ExtraSheet } from "@/lib/guides/extra-sheet";
const authSchema = pgSchema("auth"); const authSchema = pgSchema("auth");
@@ -451,6 +455,7 @@ export const catalogCharacters = catalogSchema.table(
nameCardImageKey: text("name_card_image_key"), nameCardImageKey: text("name_card_image_key"),
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(),
}, },
(table) => [ (table) => [
primaryKey({ columns: [table.key] }), primaryKey({ columns: [table.key] }),
@@ -458,6 +463,20 @@ export const catalogCharacters = catalogSchema.table(
], ],
); );
export const catalogMaterials = catalogSchema.table(
"material",
{
key: varchar("key", { length: 64 }).primaryKey(),
name: text("name").notNull(),
description: text("description"),
rarity: integer("rarity"),
type: varchar("type", { length: 64 }),
imageKey: text("image_key").notNull(),
...timestamps,
},
(table) => [index("catalog_material_name_idx").on(table.name)],
);
export const catalogWeapons = catalogSchema.table( export const catalogWeapons = catalogSchema.table(
"weapon", "weapon",
{ {
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "catalog"."material" (
"key" varchar(64) PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"description" text,
"rarity" integer,
"type" varchar(64),
"image_key" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "catalog"."character" ADD COLUMN "ascension" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
CREATE INDEX "catalog_material_name_idx" ON "catalog"."material" USING btree ("name");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -218,6 +218,13 @@
"when": 1788605973095, "when": 1788605973095,
"tag": "0030_third_maddog", "tag": "0030_third_maddog",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1788673251760,
"tag": "0031_watery_sumo",
"breakpoints": true
} }
] ]
} }
+21
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
avatarIconName, avatarIconName,
ascensionSnapshot,
characterDetailSchema, characterDetailSchema,
cleanLunarisText, cleanLunarisText,
constellationSnapshot, constellationSnapshot,
@@ -58,6 +59,26 @@ describe("Lunaris character data", () => {
expect(constellationSnapshot(detail, (icon) => icon)).toEqual([]); expect(constellationSnapshot(detail, (icon) => icon)).toEqual([]);
}); });
it("normalizes scalar and grouped ascension materials", () => {
const detail = characterDetailSchema.parse({
ascension: {
"202": 7049900,
exp: { "104001": 2, "104003": 418 },
},
});
expect(ascensionSnapshot(detail)).toEqual([
{ type: "202", items: [{ key: "202", amount: 7049900 }] },
{
type: "exp",
items: [
{ key: "104001", amount: 2 },
{ key: "104003", amount: 418 },
],
},
]);
});
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" },
+35
View File
@@ -14,6 +14,25 @@ export interface CatalogTalent {
iconKey: string | null; iconKey: string | null;
} }
export interface CatalogAscensionMaterial {
key: string;
amount: number;
}
export interface CatalogAscensionGroup {
type: string;
items: CatalogAscensionMaterial[];
}
export interface CatalogMaterial {
key: string;
name: string;
description: string | null;
rarity: number | null;
type: string | null;
imageKey: string;
}
const constellationSchema = z.object({ const constellationSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -37,6 +56,10 @@ export const characterDetailSchema = z.object({
elementalskill: skillSchema.optional(), elementalskill: skillSchema.optional(),
elementalburst: skillSchema.optional(), elementalburst: skillSchema.optional(),
}).nullish(), }).nullish(),
ascension: z.record(
z.string(),
z.union([z.number().int(), z.record(z.string(), z.number().int())]),
).nullish(),
}); });
export function cleanLunarisText(value: string): string { export function cleanLunarisText(value: string): string {
@@ -106,3 +129,15 @@ export function talentSnapshot(
iconKey: iconKey(talent.icon), iconKey: iconKey(talent.icon),
}] : []); }] : []);
} }
export function ascensionSnapshot(
value: z.infer<typeof characterDetailSchema>,
): CatalogAscensionGroup[] {
return Object.entries(value.ascension ?? {}).map(([type, entries]) => ({
type,
items:
typeof entries === "number"
? [{ key: type, amount: entries }]
: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
}));
}
+53 -2
View File
@@ -6,9 +6,11 @@ import { getDb } from "@/db/client";
import { import {
catalogArtifacts, catalogArtifacts,
catalogCharacters, catalogCharacters,
catalogMaterials,
catalogWeapons, catalogWeapons,
} from "@/db/schema"; } from "@/db/schema";
import { import {
ascensionSnapshot,
avatarIconName, avatarIconName,
characterDetailSchema, characterDetailSchema,
constellationSnapshot, constellationSnapshot,
@@ -17,7 +19,9 @@ import {
talentSnapshot, talentSnapshot,
} from "@/lib/catalog/lunaris"; } from "@/lib/catalog/lunaris";
import type { import type {
CatalogAscensionGroup,
CatalogConstellation, CatalogConstellation,
CatalogMaterial,
CatalogTalent, CatalogTalent,
} from "@/lib/catalog/lunaris"; } from "@/lib/catalog/lunaris";
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
@@ -63,6 +67,16 @@ const artifactSchema = z.record(
qualityType: z.string().optional(), qualityType: z.string().optional(),
}), }),
); );
const materialSchema = z.record(
z.string(),
z.object({
enName: z.string(),
enDescription: z.string().optional(),
icon: z.string(),
qualityType: z.string().optional(),
type: z.string().optional(),
}),
);
interface SyncedCharacter { interface SyncedCharacter {
key: string; key: string;
@@ -80,6 +94,7 @@ interface SyncedCharacter {
localImage: string | null; localImage: string | null;
constellations: CatalogConstellation[]; constellations: CatalogConstellation[];
talents: CatalogTalent[]; talents: CatalogTalent[];
ascension: CatalogAscensionGroup[];
} }
async function getJson(url: string): Promise<unknown> { async function getJson(url: string): Promise<unknown> {
@@ -107,6 +122,10 @@ function webpName(value: string): string {
return value.endsWith(".webp") ? value : `${value}.webp`; return value.endsWith(".webp") ? value : `${value}.webp`;
} }
function pngName(value: string): string {
return value.endsWith(".png") ? value : `${value}.png`;
}
function rarity(value: string | undefined): number | null { function rarity(value: string | undefined): number | null {
if (!value) return null; if (!value) return null;
return ( return (
@@ -186,14 +205,26 @@ async function uploadAssets(
} }
async function syncCatalogVersion(version: string): Promise<void> { async function syncCatalogVersion(version: string): Promise<void> {
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([ const [charactersRaw, weaponsRaw, artifactsRaw, materialsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`),
getJson(`${API_ROOT}/${version}/artifactlist.json`), getJson(`${API_ROOT}/${version}/artifactlist.json`),
getJson(`${API_ROOT}/${version}/materiallist.json`),
]); ]);
const characterData = characterSchema.parse(charactersRaw); const characterData = characterSchema.parse(charactersRaw);
const weaponData = weaponSchema.parse(weaponsRaw); const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw); const artifactData = artifactSchema.parse(artifactsRaw);
const materialData = materialSchema.parse(materialsRaw);
const materials: CatalogMaterial[] = Object.entries(materialData).map(
([key, value]) => ({
key,
name: value.enName,
description: value.enDescription ?? null,
rarity: rarity(value.qualityType),
type: value.type ?? null,
imageKey: `catalog/materials/${pngName(value.icon)}`,
}),
);
const remoteCharacters: SyncedCharacter[] = await mapConcurrent( const remoteCharacters: SyncedCharacter[] = await mapConcurrent(
Object.entries(characterData).filter( Object.entries(characterData).filter(
@@ -207,7 +238,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
const detailRaw = await getOptionalJson( const detailRaw = await getOptionalJson(
`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`, `${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`,
); );
const detail = detailRaw const detail = detailRaw
? characterDetailSchema.parse(detailRaw) ? characterDetailSchema.parse(detailRaw)
: null; : null;
const avatarIcon = webpName(avatarIconName(value.CardImg)); const avatarIcon = webpName(avatarIconName(value.CardImg));
@@ -242,6 +273,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
(icon) => `catalog/characters/talents/${webpName(icon)}`, (icon) => `catalog/characters/talents/${webpName(icon)}`,
) )
: [], : [],
ascension: detail ? ascensionSnapshot(detail) : [],
}; };
}, },
); );
@@ -275,6 +307,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
detail, detail,
(icon) => `catalog/characters/talents/${webpName(icon)}`, (icon) => `catalog/characters/talents/${webpName(icon)}`,
), ),
ascension: ascensionSnapshot(detail),
}; };
}, },
); );
@@ -339,6 +372,14 @@ async function syncCatalogVersion(version: string): Promise<void> {
source: item.sourceImage, source: item.sourceImage,
target: item.imageKey, target: item.imageKey,
}); });
for (const material of materials) {
const source = materialData[material.key].icon;
uniqueAssets.set(material.imageKey, {
source: `${API_ROOT}/assets/items/${encodeURIComponent(pngName(source))}`,
target: material.imageKey,
type: "image/png",
});
}
for (const character of characters) { for (const character of characters) {
if (character.elementIconKey && character.element) if (character.elementIconKey && character.element)
uniqueAssets.set(character.elementIconKey, { uniqueAssets.set(character.elementIconKey, {
@@ -414,6 +455,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
nameCardImageKey: item.nameCardImageKey, nameCardImageKey: item.nameCardImageKey,
constellations: item.constellations, constellations: item.constellations,
talents: item.talents, talents: item.talents,
ascension: item.ascension,
})), })),
), ),
{ type: "application/json", acl: "public-read" }, { type: "application/json", acl: "public-read" },
@@ -443,10 +485,16 @@ async function syncCatalogVersion(version: string): Promise<void> {
), ),
{ type: "application/json", acl: "public-read" }, { type: "application/json", acl: "public-read" },
), ),
storage.write(
"catalog/materials.json",
JSON.stringify(materials),
{ type: "application/json", acl: "public-read" },
),
]); ]);
await getDb().transaction(async (tx) => { await getDb().transaction(async (tx) => {
await tx.delete(catalogCharacters); await tx.delete(catalogCharacters);
await tx.delete(catalogMaterials);
await tx.delete(catalogWeapons); await tx.delete(catalogWeapons);
await tx.delete(catalogArtifacts); await tx.delete(catalogArtifacts);
if (characters.length) if (characters.length)
@@ -465,8 +513,11 @@ async function syncCatalogVersion(version: string): Promise<void> {
nameCardImageKey: item.nameCardImageKey, nameCardImageKey: item.nameCardImageKey,
constellations: item.constellations, constellations: item.constellations,
talents: item.talents, talents: item.talents,
ascension: item.ascension,
})), })),
); );
if (materials.length)
await tx.insert(catalogMaterials).values(materials);
if (weapons.length) if (weapons.length)
await tx await tx
.insert(catalogWeapons) .insert(catalogWeapons)
+35 -5
View File
@@ -10,6 +10,7 @@ import {
artifactRecommendations, artifactRecommendations,
catalogArtifacts, catalogArtifacts,
catalogCharacters, catalogCharacters,
catalogMaterials,
catalogWeapons, catalogWeapons,
constellationConditions, constellationConditions,
constellationRecommendations, constellationRecommendations,
@@ -28,7 +29,10 @@ import { aliasesByItem } from "@/lib/glossary";
const catalogVersionSchema = z.object({ version: z.string() }); const catalogVersionSchema = z.object({ version: z.string() });
function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) { function catalogCharacterWithUrls(
item: typeof catalogCharacters.$inferSelect,
materialByKey = new Map<string, typeof catalogMaterials.$inferSelect>(),
) {
return { return {
...item, ...item,
imageUrl: publicMediaUrl(item.imageKey), imageUrl: publicMediaUrl(item.imageKey),
@@ -43,28 +47,47 @@ function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
...talent, ...talent,
iconUrl: talent.iconKey ? publicMediaUrl(talent.iconKey) : null, iconUrl: talent.iconKey ? publicMediaUrl(talent.iconKey) : null,
})), })),
ascension: item.ascension.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),
};
}),
})),
}; };
} }
export async function getActiveCatalog() { export async function getActiveCatalog() {
const db = getDb(); const db = getDb();
const [characters, weapons, artifacts, aliases] = await Promise.all([ const [characters, weapons, artifacts, materials, aliases] = await Promise.all([
db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)), db.select().from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)), db.select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)), db.select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
db.select().from(catalogMaterials).orderBy(asc(catalogMaterials.name)),
db.select().from(glossaryAliases), db.select().from(glossaryAliases),
]); ]);
if (!characters.length) return null; if (!characters.length) return null;
const groupedAliases = aliasesByItem(aliases); const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
const storage = await getMediaStorage(); const storage = await getMediaStorage();
const { version } = catalogVersionSchema.parse( const { version } = catalogVersionSchema.parse(
await storage.file("catalog/current.json").json(), await storage.file("catalog/current.json").json(),
); );
return { return {
version, version,
characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item), aliases: groupedAliases.character.get(item.key) ?? [] })), characters: characters.map((item) => ({ ...catalogCharacterWithUrls(item, materialByKey), aliases: groupedAliases.character.get(item.key) ?? [] })),
weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), weapons: weapons.map((item) => ({ ...item, aliases: groupedAliases.weapon.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })), artifacts: artifacts.map((item) => ({ ...item, aliases: groupedAliases.artifact.get(item.key) ?? [], imageUrl: publicMediaUrl(item.imageKey) })),
materials: materials.map((item) => ({ ...item, imageUrl: publicMediaUrl(item.imageKey), lunarisUrl: `https://lunaris.moe/material/${encodeURIComponent(item.key)}` })),
}; };
} }
@@ -114,7 +137,10 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
)) ))
.limit(1); .limit(1);
if (!row) return null; if (!row) return null;
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases] = await Promise.all([ const materialKeys = [...new Set(
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
)];
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials] = 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)),
getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)), getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)),
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
@@ -122,8 +148,12 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)), getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)), getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
getDb().select().from(glossaryAliases), getDb().select().from(glossaryAliases),
materialKeys.length
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
: [],
]); ]);
const groupedAliases = aliasesByItem(aliases); const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
const elementIconUrls = Object.fromEntries( const elementIconUrls = Object.fromEntries(
(await getDb() (await getDb()
@@ -168,7 +198,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
return { return {
...row.guide, ...row.guide,
coverUrl: publicMediaUrl(row.cover.objectKey), coverUrl: publicMediaUrl(row.cover.objectKey),
character: { ...catalogCharacterWithUrls(row.character), aliases: groupedAliases.character.get(row.character.key) ?? [] }, character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
elementIconUrls, elementIconUrls,
sections, sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })), weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),