feat(guides) : add selectable target and team conditions
This commit is contained in:
+10
-6
@@ -20,6 +20,7 @@ import {
|
||||
media,
|
||||
outboxEvents,
|
||||
teamCompositions,
|
||||
teamConditions,
|
||||
teamMembers,
|
||||
weaponRecommendations,
|
||||
weaponConditions,
|
||||
@@ -204,7 +205,7 @@ export async function saveArtifacts(guideId: string, input: unknown) {
|
||||
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.insert(artifactProfiles).values({ guideId, sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note }).onConflictDoUpdate({ target: artifactProfiles.guideId, set: { sands: data.sands, goblet: data.goblet, circlet: data.circlet, substats: data.substats, targets: data.targets, talents: data.talents, note: data.note } });
|
||||
await tx.insert(artifactProfiles).values({ guideId, 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, targets: data.targetGroups[0]?.rows ?? [], targetConditions: data.targetGroups, talents: data.talents, note: data.note } });
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -227,7 +228,7 @@ 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 members = data.groups.flatMap((group) => group.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.flatMap((member) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)))];
|
||||
@@ -248,10 +249,13 @@ export async function saveTeams(guideId: string, input: unknown) {
|
||||
}
|
||||
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) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
|
||||
await tx.delete(teamConditions).where(eq(teamConditions.guideId, guideId));
|
||||
for (const [groupOrder, group] of data.groups.entries()) {
|
||||
const [condition] = await tx.insert(teamConditions).values({ guideId, note: group.note, sortOrder: groupOrder }).returning();
|
||||
for (const [sortOrder, team] of group.teams.entries()) {
|
||||
const [created] = await tx.insert(teamCompositions).values({ guideId, conditionId: condition.id, title: team.title, rotationSeconds: team.rotationSeconds.toString(), combo: team.combo, sortOrder }).returning();
|
||||
await tx.insert(teamMembers).values(team.members.map((member, position) => ({ ...member, teamId: created.id, position, damage: member.damage.toString() })));
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
glossaryAliases,
|
||||
media,
|
||||
teamCompositions,
|
||||
teamConditions,
|
||||
teamMembers,
|
||||
weaponRecommendations,
|
||||
weaponConditions,
|
||||
@@ -180,12 +181,13 @@ 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, teams, aliases, materials, poster, bleeding] = await Promise.all([
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teamConditionRows, 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(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(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)),
|
||||
getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
|
||||
getDb().select().from(glossaryAliases),
|
||||
materialKeys.length
|
||||
@@ -238,6 +240,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
extraArtifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, extraArtifactKeys)) : [],
|
||||
]);
|
||||
const extraCatalogs = { character: extraCharacters, weapon: extraWeapons, artifact: extraArtifacts };
|
||||
const hydratedTeams = 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); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) }));
|
||||
|
||||
return {
|
||||
...row.guide,
|
||||
@@ -250,7 +253,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
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 }; }) })),
|
||||
artifactProfile: profile[0] ?? null,
|
||||
artifactProfile: profile[0] ? { ...profile[0], targetConditions: profile[0].targetConditions.length ? profile[0].targetConditions : [{ note: null, rows: profile[0].targets }] } : null,
|
||||
constellationGroups: constellationGroups.map((group) => ({
|
||||
...group,
|
||||
rows: constellations.filter((recommendation) => recommendation.conditionId === group.id).map((recommendation) => {
|
||||
@@ -263,7 +266,8 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
};
|
||||
}),
|
||||
})),
|
||||
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); const secondArtifact = memberArtifacts.find((item) => item.key === member.secondArtifactKey); return { ...member, character: { ...catalogCharacterWithUrls(character), aliases: groupedAliases.character.get(character.key) ?? [] }, weapon: weapon ? { ...weapon, aliases: groupedAliases.weapon.get(weapon.key) ?? [], imageUrl: publicMediaUrl(weapon.imageKey) } : null, artifact: artifact ? { ...artifact, aliases: groupedAliases.artifact.get(artifact.key) ?? [], imageUrl: publicMediaUrl(artifact.imageKey) } : null, secondArtifact: secondArtifact ? { ...secondArtifact, aliases: groupedAliases.artifact.get(secondArtifact.key) ?? [], imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null }; }) })),
|
||||
teams: hydratedTeams,
|
||||
teamGroups: teamConditionRows.length ? teamConditionRows.map((condition) => ({ ...condition, teams: hydratedTeams.filter((team) => team.conditionId === condition.id) })) : [{ id: "team-condition-default", guideId: row.guide.id, note: null, sortOrder: 0, teams: hydratedTeams }],
|
||||
extras: sections.filter((section) => section.kind === "extra").map((section) => ({ ...section, rows: extraRows.filter(({ row: extraRow }) => extraRow.sectionId === section.id).map(({ row: extraRow, asset }) => ({
|
||||
...extraRow,
|
||||
catalogItems: extraRow.catalogItems.map((reference) => {
|
||||
|
||||
+13
-12
@@ -74,13 +74,13 @@ describe("structured guide validation", () => {
|
||||
});
|
||||
|
||||
it("limits artifact main stats to three per slot", () => {
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, goblet: [], circlet: [], substats: [], targetGroups: [], talents: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%"] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, sands: ["EM", "ER", "ATK%", "HP%"] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts flat artifact substats with the same icons as their percentage variants", () => {
|
||||
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targets: [], talents: [], note: null });
|
||||
const result = artifactSectionSchema.safeParse({ expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: ["ATK", "HP", "DEF"], targetGroups: [], talents: [], note: null });
|
||||
expect(result.success).toBe(true);
|
||||
expect(targetStatIconName("ATK")).toBe(recommendedStatIconName("ATK%"));
|
||||
expect(targetStatIconName("HP")).toBe(recommendedStatIconName("HP%"));
|
||||
@@ -95,7 +95,7 @@ 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: [], targets: [], talents: [], note: null };
|
||||
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);
|
||||
});
|
||||
@@ -104,30 +104,30 @@ describe("structured guide validation", () => {
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], talents: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({
|
||||
...base,
|
||||
targets: [
|
||||
targetGroups: [{ note: "ER build", rows: [
|
||||
{ stat: "ER", value: "100%" },
|
||||
{ stat: "CRIT RATE", value: "5%" },
|
||||
{ stat: "CRIT DAMAGE", value: "-%" },
|
||||
],
|
||||
] }],
|
||||
}).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, targets: [{ stat: "Energy Recharge", value: "100%" }] }).success).toBe(false);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, targetGroups: [{ note: null, rows: [{ stat: "Energy Recharge", value: "100%" }] }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires the fixed Late Game and Early Game artifact groups", () => {
|
||||
const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null };
|
||||
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);
|
||||
});
|
||||
|
||||
it("accepts only talent target levels from 1 through 10", () => {
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "10" }] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "NA", priority: 1, level: "11" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts talent comparison relations while keeping legacy talents valid", () => {
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targets: [], note: null };
|
||||
const base = { expectedVersion: 1, groups: artifactGroups, sands: [], goblet: [], circlet: [], substats: [], targetGroups: [], note: null };
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "gte" }] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "eq" }] }).success).toBe(true);
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1 }] }).success).toBe(true);
|
||||
@@ -148,14 +148,15 @@ describe("structured guide validation", () => {
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, secondArtifactKey: 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) }] };
|
||||
const teams = [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }];
|
||||
const base = { expectedVersion: 1, groups: [{ note: null, teams }] };
|
||||
expect(teamSectionSchema.safeParse(base).success).toBe(true);
|
||||
expect(teamSectionSchema.safeParse({ ...base, teams: [{ ...base.teams[0], members: ["1", "1", "3", "4"].map(member) }] }).success).toBe(false);
|
||||
expect(teamSectionSchema.safeParse({ ...base, groups: [{ note: null, teams: [{ ...teams[0], members: ["1", "1", "3", "4"].map(member) }] }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a second artifact set for a team member", () => {
|
||||
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: "artifact-1", secondArtifactKey: characterKey === "1" ? "artifact-2" : null, constellation: 0, refinement: 1, damage: 100 });
|
||||
const input = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
|
||||
const input = { expectedVersion: 1, groups: [{ note: null, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] }] };
|
||||
expect(teamSectionSchema.safeParse(input).success).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
+12
-6
@@ -95,10 +95,13 @@ export const artifactSectionSchema = z.strictObject({
|
||||
goblet: z.array(z.enum(mainStatOptions.goblet)).max(3),
|
||||
circlet: z.array(z.enum(mainStatOptions.circlet)).max(3),
|
||||
substats: z.array(z.enum(substatOptions)).max(substatOptions.length),
|
||||
targets: z.array(z.strictObject({
|
||||
stat: z.enum(targetStatOptions),
|
||||
value: z.string().trim().min(1).max(80),
|
||||
})).max(targetStatOptions.length),
|
||||
targetGroups: z.array(z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
rows: z.array(z.strictObject({
|
||||
stat: z.enum(targetStatOptions),
|
||||
value: z.string().trim().min(1).max(80),
|
||||
})).max(targetStatOptions.length),
|
||||
})).max(20),
|
||||
talents: z.array(z.strictObject({
|
||||
type: z.enum(["NA", "E", "Q"]),
|
||||
priority: z.number().int().min(1).max(3),
|
||||
@@ -128,7 +131,9 @@ export const constellationSectionSchema = z.strictObject({
|
||||
|
||||
export const teamSectionSchema = z.strictObject({
|
||||
expectedVersion: z.number().int().positive(),
|
||||
teams: z.array(z.strictObject({
|
||||
groups: z.array(z.strictObject({
|
||||
note: z.string().trim().max(2_000).nullable(),
|
||||
teams: z.array(z.strictObject({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
rotationSeconds: z.coerce.number().finite().positive().max(9999),
|
||||
combo: z.string().trim().min(1).max(2_000),
|
||||
@@ -145,7 +150,8 @@ export const teamSectionSchema = z.strictObject({
|
||||
}).refine((members) => members.some((member) => member.damage > 0), {
|
||||
message: "ความเสียหายรวมต้องมากกว่า 0",
|
||||
}),
|
||||
})).max(30),
|
||||
})).max(30),
|
||||
})).max(20),
|
||||
});
|
||||
|
||||
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลือกสีที่ถูกต้อง");
|
||||
|
||||
Reference in New Issue
Block a user