feat(guides): add team loadouts and element icons
This commit is contained in:
@@ -41,11 +41,12 @@ describe("Lunaris character data", () => {
|
||||
|
||||
it("reads the supported weapon type from character details", () => {
|
||||
const detail = characterDetailSchema.parse({
|
||||
info: { weapon: "WEAPON_SWORD_ONE_HAND" },
|
||||
info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" },
|
||||
constellations: null,
|
||||
});
|
||||
|
||||
expect(detail.info?.weapon).toBe("WEAPON_SWORD_ONE_HAND");
|
||||
expect(detail.info?.element).toBe("Cryo");
|
||||
});
|
||||
|
||||
it("matches only weapons supported by the character", () => {
|
||||
|
||||
@@ -14,7 +14,10 @@ const constellationSchema = z.object({
|
||||
});
|
||||
|
||||
export const characterDetailSchema = z.object({
|
||||
info: z.object({ weapon: z.string().optional() }).nullish(),
|
||||
info: z.object({
|
||||
element: z.string().optional(),
|
||||
weapon: z.string().optional(),
|
||||
}).nullish(),
|
||||
constellations: z.record(z.string(), constellationSchema).nullish(),
|
||||
});
|
||||
|
||||
|
||||
+16
-7
@@ -78,7 +78,7 @@ async function mapConcurrent<T, R>(items: T[], task: (item: T) => Promise<R>): P
|
||||
|
||||
async function uploadAssets(
|
||||
jobId: string,
|
||||
assets: Array<{ source: string; target: string }>,
|
||||
assets: Array<{ source: string; target: string; type?: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const storage = await getMediaStorage();
|
||||
let next = 0;
|
||||
@@ -91,7 +91,7 @@ async function uploadAssets(
|
||||
const response = await fetch(asset.source, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
|
||||
if (response.status === 404) missing.add(asset.target);
|
||||
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
|
||||
else await storage.write(asset.target, response, { type: "image/webp", acl: "public-read" });
|
||||
else await storage.write(asset.target, response, { type: asset.type ?? "image/webp", acl: "public-read" });
|
||||
completed += 1;
|
||||
if (completed % 10 === 0 || completed === assets.length) {
|
||||
await getDb().update(catalogSyncJobs).set({ progress: completed }).where(eq(catalogSyncJobs.id, jobId));
|
||||
@@ -129,9 +129,11 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
|
||||
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
|
||||
const avatarIcon = webpName(avatarIconName(value.CardImg));
|
||||
const element = detail?.info?.element ?? value.element ?? null;
|
||||
return {
|
||||
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
|
||||
version, key, name: value.enName, element, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${avatarIcon}`,
|
||||
elementIconKey: element ? `catalog/${version}/elements/${element.toLowerCase()}.png` : null,
|
||||
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
|
||||
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
|
||||
};
|
||||
@@ -147,8 +149,15 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
|
||||
}));
|
||||
const uniqueAssets = new Map<string, { source: string; target: string }>();
|
||||
const uniqueAssets = new Map<string, { source: string; target: string; type?: string }>();
|
||||
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
for (const character of characters) {
|
||||
if (character.elementIconKey && character.element) uniqueAssets.set(character.elementIconKey, {
|
||||
source: `${API_ROOT}/assets/icons/${encodeURIComponent(character.element.toLowerCase())}.png`,
|
||||
target: character.elementIconKey,
|
||||
type: "image/png",
|
||||
});
|
||||
}
|
||||
for (const character of characters) {
|
||||
for (const constellation of character.constellations) {
|
||||
if (constellation.iconKey) uniqueAssets.set(constellation.iconKey, {
|
||||
@@ -159,13 +168,13 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
}
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({ ...item, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })) }));
|
||||
characters = characters.map((item) => ({ ...item, elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })) }));
|
||||
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
|
||||
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
|
||||
|
||||
const storage = await getMediaStorage();
|
||||
await Promise.all([
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
]);
|
||||
@@ -175,7 +184,7 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
|
||||
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
|
||||
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations })));
|
||||
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, elementIconKey: item.elementIconKey, constellations: item.constellations })));
|
||||
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
|
||||
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey })));
|
||||
await tx.update(catalogVersions).set({ active: false });
|
||||
|
||||
+23
-2
@@ -1,15 +1,17 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
artifactGroups,
|
||||
artifactProfiles,
|
||||
artifactRecommendations,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
constellationRecommendations,
|
||||
extraSectionRows,
|
||||
extraSections,
|
||||
@@ -143,12 +145,31 @@ export async function saveTeams(guideId: string, input: unknown) {
|
||||
const data = teamSectionSchema.parse(input);
|
||||
const [guide] = await getDb().select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
if (!guide) throw new Error("ไม่พบ Guide");
|
||||
const members = data.teams.flatMap((team) => team.members);
|
||||
const characterKeys = [...new Set(members.map((member) => member.characterKey))];
|
||||
const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))];
|
||||
const artifactKeys = [...new Set(members.map((member) => member.artifactKey).filter((key): key is string => Boolean(key)))];
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
characterKeys.length ? getDb().select().from(catalogCharacters).where(and(eq(catalogCharacters.version, guide.catalogVersion), inArray(catalogCharacters.key, characterKeys))) : [],
|
||||
weaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, guide.catalogVersion), inArray(catalogWeapons.key, weaponKeys))) : [],
|
||||
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, guide.catalogVersion), inArray(catalogArtifacts.key, artifactKeys))) : [],
|
||||
]);
|
||||
if (characters.length !== characterKeys.length) throw new Error("ไม่พบตัวละครบางรายการใน Catalog");
|
||||
if (weapons.length !== weaponKeys.length) throw new Error("ไม่พบอาวุธบางรายการใน Catalog");
|
||||
if (artifacts.length !== artifactKeys.length) throw new Error("ไม่พบ Artifact บางรายการใน Catalog");
|
||||
for (const member of members) {
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const weapon = weapons.find((item) => item.key === member.weaponKey);
|
||||
if (weapon && weapon.weaponType !== character?.weaponType) {
|
||||
throw new Error(`${weapon.name} ไม่รองรับ ${character?.name ?? "ตัวละครนี้"}`);
|
||||
}
|
||||
}
|
||||
return getDb().transaction(async (tx) => {
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId));
|
||||
for (const [sortOrder, team] of data.teams.entries()) {
|
||||
const [created] = await tx.insert(teamCompositions).values({ guideId, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
|
||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ teamId: created.id, position, catalogVersion: guide.catalogVersion, characterKey: member.characterKey, damage: member.damage.toString() })));
|
||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, catalogVersion: guide.catalogVersion, damage: member.damage.toString() })));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
+25
-5
@@ -27,6 +27,7 @@ function catalogCharacterWithUrls<T extends typeof catalogCharacters.$inferSelec
|
||||
return {
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
|
||||
constellations: item.constellations.map((constellation) => ({
|
||||
...constellation,
|
||||
iconUrl: constellation.iconKey ? publicMediaUrl(constellation.iconKey) : null,
|
||||
@@ -95,6 +96,12 @@ export async function getPublicGuide(slug: string) {
|
||||
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
|
||||
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) : [];
|
||||
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, and(eq(catalogCharacters.version, teamMembers.catalogVersion), eq(catalogCharacters.key, teamMembers.characterKey))).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
|
||||
const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
|
||||
const memberArtifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
|
||||
const [memberWeapons, memberArtifacts] = await Promise.all([
|
||||
memberWeaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, row.guide.catalogVersion), inArray(catalogWeapons.key, memberWeaponKeys))) : [],
|
||||
memberArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, memberArtifactKeys))) : [],
|
||||
]);
|
||||
const extraIds = sections.filter((section) => section.kind === "extra").map((section) => section.id);
|
||||
const extraRows = extraIds.length ? await getDb().select({ row: extraSectionRows, asset: media }).from(extraSectionRows).leftJoin(media, eq(media.id, extraSectionRows.mediaId)).where(inArray(extraSectionRows.sectionId, extraIds)).orderBy(asc(extraSectionRows.sortOrder)) : [];
|
||||
|
||||
@@ -115,7 +122,7 @@ export async function getPublicGuide(slug: string) {
|
||||
iconUrl: catalog?.iconKey ? publicMediaUrl(catalog.iconKey) : null,
|
||||
};
|
||||
}),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => ({ ...member, character: catalogCharacterWithUrls(character) })) })),
|
||||
teams: teams.map((team) => ({ ...team, members: members.filter(({ member }) => member.teamId === team.id).map(({ member, character }) => { const weapon = memberWeapons.find((item) => item.key === member.weaponKey); const artifact = memberArtifacts.find((item) => item.key === member.artifactKey); return { ...member, character: catalogCharacterWithUrls(character), weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null }; }) })),
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({ ...extraRow, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })) })),
|
||||
};
|
||||
}
|
||||
@@ -155,13 +162,26 @@ export async function getPublicTeamExport(teamId: string) {
|
||||
.where(eq(teamMembers.teamId, teamId))
|
||||
.orderBy(asc(teamMembers.position));
|
||||
|
||||
const weaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
|
||||
const artifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
|
||||
const [weapons, artifacts] = await Promise.all([
|
||||
weaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, row.guide.catalogVersion), inArray(catalogWeapons.key, weaponKeys))) : [],
|
||||
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, artifactKeys))) : [],
|
||||
]);
|
||||
|
||||
return {
|
||||
...row.team,
|
||||
guideName: row.guide.name,
|
||||
members: members.map(({ member, character }) => ({
|
||||
...member,
|
||||
character: catalogCharacterWithUrls(character),
|
||||
})),
|
||||
members: members.map(({ member, character }) => {
|
||||
const weapon = weapons.find((item) => item.key === member.weaponKey);
|
||||
const artifact = artifacts.find((item) => item.key === member.artifactKey);
|
||||
return {
|
||||
...member,
|
||||
character: catalogCharacterWithUrls(character),
|
||||
weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null,
|
||||
artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] };
|
||||
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
|
||||
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
|
||||
expect(teamSectionSchema.safeParse(base).success).toBe(true);
|
||||
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map((characterKey) => ({ characterKey, damage: 100 })) }] }).success).toBe(false);
|
||||
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map(member) }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -81,6 +81,10 @@ export const teamSectionSchema = z.strictObject({
|
||||
combo: z.string().trim().min(1).max(2_000),
|
||||
members: z.array(z.strictObject({
|
||||
characterKey: catalogKey,
|
||||
weaponKey: catalogKey.nullable(),
|
||||
artifactKey: catalogKey.nullable(),
|
||||
constellation: z.number().int().min(0).max(6),
|
||||
refinement: z.number().int().min(1).max(5),
|
||||
damage: z.coerce.number().finite().nonnegative().max(999_999_999_999),
|
||||
})).length(4).refine((members) => new Set(members.map((member) => member.characterKey)).size === 4, {
|
||||
message: "Team ต้องมีตัวละครไม่ซ้ำกัน 4 ตัว",
|
||||
|
||||
Reference in New Issue
Block a user