feat(artifact) : add tier-list conditions
CI / Build immutable images and deploy (push) Successful in 6m36s

This commit is contained in:
2026-09-17 17:02:46 +07:00 Unverified
parent 884c659517
commit 13b6c646fe
12 changed files with 3015 additions and 196 deletions
+11 -6
View File
@@ -5,6 +5,7 @@ import { and, asc, eq, inArray, sql } from "drizzle-orm";
import { getDb } from "@/db";
import {
artifactGroups,
artifactConditions,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
@@ -96,9 +97,10 @@ export async function createGuide(input: unknown) {
sortOrder: order.maximum + 10,
}).returning();
await tx.insert(guideSections).values(DEFAULT_SECTIONS.map((section) => ({ ...section, guideId: guide.id })));
const [condition] = await tx.insert(artifactConditions).values({ guideId: guide.id, sortOrder: 0 }).returning();
await tx.insert(artifactGroups).values([
{ guideId: guide.id, name: "Late Game", sortOrder: 0 },
{ guideId: guide.id, name: "Early Game", sortOrder: 1 },
{ conditionId: condition.id, name: "Late Game", sortOrder: 0 },
{ conditionId: condition.id, name: "Early Game", sortOrder: 1 },
]);
await tx.insert(artifactProfiles).values({ guideId: guide.id });
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, parsed.coverMediaId));
@@ -213,10 +215,13 @@ export async function saveArtifacts(guideId: string, input: unknown, details?: G
if (!guide) throw new Error("ไม่พบ Guide");
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion, false, "artifact", details);
await tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
await tx.delete(artifactConditions).where(eq(artifactConditions.guideId, guideId));
for (const [conditionOrder, condition] of data.groups.entries()) {
const [createdCondition] = await tx.insert(artifactConditions).values({ guideId, name: condition.name, note: condition.note, sortOrder: conditionOrder }).returning();
for (const [sortOrder, group] of condition.tiers.entries()) {
const [created] = await tx.insert(artifactGroups).values({ conditionId: createdCondition.id, name: group.name, sortOrder }).returning();
if (group.rows.length) await tx.insert(artifactRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, percent: row.percent.toString(), groupId: created.id, sortOrder: rowOrder })));
}
}
await tx.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, recommendedConditions: data.recommendedGroups ?? [{ name: null, note: null, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats }], targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
return updated;
+5 -3
View File
@@ -6,6 +6,7 @@ import * as z from "zod";
import { getDb } from "@/db";
import {
artifactGroups,
artifactConditions,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
@@ -329,10 +330,10 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
const materialKeys = [...new Set(
[...row.character.ascension, ...row.character.leveling].flatMap((group) => group.items.map((item) => item.key)),
)];
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([
const [sections, weaponConditionsRows, artifactConditionRows, profile, constellationGroups, teamConditionRows, teams, aliases, materials, poster, bleeding] = await runWithConcurrency([
() => 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(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)),
() => getDb().select().from(artifactConditions).where(eq(artifactConditions.guideId, row.guide.id)).orderBy(asc(artifactConditions.sortOrder)),
() => getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, row.guide.id)).limit(1),
() => getDb().select().from(constellationConditions).where(eq(constellationConditions.guideId, row.guide.id)).orderBy(asc(constellationConditions.sortOrder)),
() => getDb().select().from(teamConditions).where(eq(teamConditions.guideId, row.guide.id)).orderBy(asc(teamConditions.sortOrder)),
@@ -350,6 +351,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
: Promise.resolve([]),
() => getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)),
]);
const groups = artifactConditionRows.length ? await getDb().select().from(artifactGroups).where(inArray(artifactGroups.conditionId, artifactConditionRows.map((condition) => condition.id))).orderBy(asc(artifactGroups.sortOrder)) : [];
const groupedAliases = aliasesByItem(aliases);
const materialByKey = new Map(materials.map((item) => [item.key, item]));
@@ -404,7 +406,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } })) })),
artifactGroups: groups.map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactGroups: artifactConditionRows.map((condition) => ({ ...condition, tiers: groups.filter((group) => group.conditionId === condition.id).map((group) => ({ ...group, rows: artifactRows.filter(({ recommendation }) => recommendation.groupId === group.id).map(({ recommendation, artifact }) => { const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); return { ...recommendation, first: { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, aliases: groupedAliases.artifact.get(second.key) ?? [], imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })) })),
artifactProfile: profile[0] ? { ...profile[0], recommendedConditions: profile[0].recommendedConditions.length ? profile[0].recommendedConditions : [{ name: null, note: null, sands: profile[0].sands, goblet: profile[0].goblet, circlet: profile[0].circlet, substats: profile[0].substats }], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ name: null, note: null, rows: profile[0].targets }] } : null,
constellationGroups: constellationGroups.map((group) => ({
...group,
+18 -5
View File
@@ -21,10 +21,11 @@ import {
} from "@/lib/guides/stats";
describe("structured guide validation", () => {
const artifactGroups = [
const artifactTiers = [
{ name: "Late Game", rows: [] },
{ name: "Early Game", rows: [] },
] as const;
const artifactGroups = [{ name: null, note: null, tiers: artifactTiers }] as const;
it("allows creating a guide without an overview", () => {
expect(createGuideSchema.safeParse({ characterKey: "1", coverMediaId: "550e8400-e29b-41d4-a716-446655440000", overview: "" }).success).toBe(true);
});
@@ -120,8 +121,8 @@ describe("structured guide validation", () => {
it("limits an optional note on each artifact recommendation", () => {
const row = { firstArtifactKey: "1", secondArtifactKey: null, percent: 100 };
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactGroups[1]] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactGroups[1]] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: null }] }, artifactTiers[1]] }] }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [{ name: "Late Game", rows: [{ ...row, note: "x".repeat(2_001) }] }, artifactTiers[1]] }] }).success).toBe(false);
});
it("accepts fixed target stats with text values", () => {
@@ -140,8 +141,20 @@ describe("structured guide validation", () => {
it("requires the fixed Late Game and Early Game artifact groups", () => {
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
expect(artifactSectionSchema.safeParse({ ...base, groups: artifactGroups }).success).toBe(true);
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups].reverse() }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [...artifactGroups, { name: "Other", rows: [] }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [...artifactTiers].reverse() }] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: null, note: null, tiers: [...artifactTiers, { name: "Other", rows: [] }] }] }).success).toBe(false);
});
it("supports named artifact tier-list conditions", () => {
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
const result = artifactSectionSchema.parse({ ...base, groups: [
{ name: " On field ", note: "Best damage", tiers: artifactTiers },
{ name: "", note: null, tiers: artifactTiers },
] });
expect(result.groups.map((group) => group.name)).toEqual(["On field", null]);
expect(artifactSectionSchema.safeParse({ ...base, groups: [] }).success).toBe(false);
expect(artifactSectionSchema.safeParse({ ...base, groups: [{ name: "x".repeat(121), note: null, tiers: artifactTiers }] }).success).toBe(false);
});
it("accepts only talent target levels from 1 through 10", () => {
+8 -4
View File
@@ -89,10 +89,14 @@ const artifactGroupSchema = (name: "Late Game" | "Early Game") => z.strictObject
export const artifactSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
groups: z.tuple([
artifactGroupSchema("Late Game"),
artifactGroupSchema("Early Game"),
]),
groups: z.array(z.strictObject({
name: conditionName,
note: z.string().trim().max(2_000).nullable(),
tiers: z.tuple([
artifactGroupSchema("Late Game"),
artifactGroupSchema("Early Game"),
]),
})).min(1).max(20),
sands: z.array(z.enum(mainStatOptions.sands)).max(3),
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),