feat(guides): add team loadouts and element icons
CI / Verify (push) Successful in 1m7s
CI / Build immutable images and deploy (push) Successful in 3m7s

This commit is contained in:
2026-08-30 05:39:54 +00:00 Unverified
parent 3f480ac143
commit 22b09a6136
16 changed files with 3605 additions and 29 deletions
+23 -2
View File
@@ -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
View File
@@ -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,
};
}),
};
}
+3 -2
View File
@@ -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);
});
});
+4
View File
@@ -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 ตัว",