feat(weapons) : add conditions and unsaved changes warning
CI / Verify (push) Failing after 42s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-09-04 00:33:03 +07:00 Unverified
parent a4cc93c7f6
commit c9e0fe02e3
14 changed files with 201 additions and 56 deletions
+6 -2
View File
@@ -21,6 +21,7 @@ import {
teamCompositions,
teamMembers,
weaponRecommendations,
weaponConditions,
} from "@/db/schema";
import {
artifactSectionSchema,
@@ -105,8 +106,11 @@ export async function saveWeapons(guideId: string, input: unknown) {
return getDb().transaction(async (tx) => {
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
await tx.update(guideSections).set({ note: data.note }).where(and(eq(guideSections.guideId, guideId), eq(guideSections.slug, "weapon")));
await tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId));
if (data.rows.length) await tx.insert(weaponRecommendations).values(data.rows.map((row, sortOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, guideId, sortOrder })));
await tx.delete(weaponConditions).where(eq(weaponConditions.guideId, guideId));
for (const [sortOrder, group] of data.groups.entries()) {
const [created] = await tx.insert(weaponConditions).values({ guideId, note: group.note, sortOrder }).returning();
if (group.rows.length) await tx.insert(weaponRecommendations).values(group.rows.map((row, rowOrder) => ({ ...row, overallPercent: row.overallPercent?.toString() ?? null, personalPercent: row.personalPercent?.toString() ?? null, conditionId: created.id, sortOrder: rowOrder })));
}
return updated;
});
}
+6 -2
View File
@@ -20,6 +20,7 @@ import {
teamCompositions,
teamMembers,
weaponRecommendations,
weaponConditions,
} from "@/db/schema";
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
@@ -94,15 +95,17 @@ export async function getPublicGuide(slug: string) {
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
const [sections, weaponRows, groups, profile, constellationGroups, teams] = await Promise.all([
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams] = 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({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(eq(weaponRecommendations.guideId, row.guide.id)).orderBy(asc(weaponRecommendations.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(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)),
]);
const weaponRows = weaponConditionsRows.length ? await getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(inArray(weaponRecommendations.conditionId, weaponConditionsRows.map((condition) => condition.id))).orderBy(asc(weaponRecommendations.sortOrder)) : [];
const constellations = constellationGroups.length ? await getDb().select().from(constellationRecommendations).where(inArray(constellationRecommendations.conditionId, constellationGroups.map((group) => group.id))).orderBy(asc(constellationRecommendations.level)) : [];
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey)).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
@@ -123,6 +126,7 @@ export async function getPublicGuide(slug: string) {
character: catalogCharacterWithUrls(row.character),
sections,
weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })),
weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, 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, imageUrl: publicMediaUrl(artifact.imageKey) }, second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null }; }) })),
artifactProfile: profile[0] ?? null,
constellationGroups: constellationGroups.map((group) => ({
+8 -8
View File
@@ -26,25 +26,25 @@ describe("structured guide validation", () => {
});
it("requires at least one weapon percentage", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: null, personalPercent: null, note: null }] }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: 104, personalPercent: null, note: "Best free-to-play option" }] }] }).success).toBe(true);
});
it("preserves negative weapon percentages", () => {
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] });
const result = weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ weaponKey: "1", refinement: 5, overallPercent: -12.5, personalPercent: null, note: null }] }] });
expect(result.success).toBe(true);
if (result.success) expect(result.data.rows[0].overallPercent).toBe(-12.5);
if (result.success) expect(result.data.groups[0].rows[0].overallPercent).toBe(-12.5);
});
it("limits an optional note on each weapon", () => {
const row = { weaponKey: "1", refinement: 1, overallPercent: 100, personalPercent: null };
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, note: null }] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, rows: [{ ...row, note: "x".repeat(2_001) }] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, note: null }] }] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: null, groups: [{ note: null, rows: [{ ...row, note: "x".repeat(2_001) }] }] }).success).toBe(false);
});
it("limits weapon and constellation header notes", () => {
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", rows: [] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), rows: [] }).success).toBe(false);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
expect(weaponSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "Header note", groups: [] }).success).toBe(true);
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, note: "x".repeat(2_001), groups: [] }).success).toBe(false);
});
+11 -8
View File
@@ -34,15 +34,18 @@ export const updateGuideSchema = z.strictObject({
export const weaponSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
note: z.string().trim().max(2_000).nullable(),
rows: z.array(z.strictObject({
weaponKey: catalogKey,
refinement: z.number().int().min(1).max(5),
overallPercent: percent.nullable(),
personalPercent: percent.nullable(),
groups: z.array(z.strictObject({
note: z.string().trim().max(2_000).nullable(),
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
})).max(100),
rows: z.array(z.strictObject({
weaponKey: catalogKey,
refinement: z.number().int().min(1).max(5),
overallPercent: percent.nullable(),
personalPercent: percent.nullable(),
note: z.string().trim().max(2_000).nullable(),
}).refine((row) => row.overallPercent !== null || row.personalPercent !== null, {
message: "อาวุธต้องมีเปอร์เซ็นต์อย่างน้อยหนึ่งค่า",
})).max(100),
})).max(20),
});
export const mainStatOptions = {
+3 -1
View File
@@ -41,7 +41,9 @@ function issueContext(section: GuideValidationSection, path: readonly PropertyKe
const memberIndex = indexAfter(path, "members");
const sectionIndex = indexAfter(path, "sections");
if (section === "weapon" && rowIndex !== null) return `อาวุธอันดับ ${rowIndex + 1}`;
if (section === "weapon" && groupIndex !== null) {
return rowIndex === null ? `Condition ${groupIndex + 1}` : `Condition ${groupIndex + 1} · อาวุธอันดับ ${rowIndex + 1}`;
}
if (section === "artifact" && groupIndex !== null) {
const group = groupIndex === 0 ? "Late Game" : "Early Game";
return rowIndex === null ? `Artifact ${group}` : `Artifact ${group} อันดับ ${rowIndex + 1}`;