feat : update
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
avatarIconName,
|
||||
ascensionSnapshot,
|
||||
baseStatsSnapshot,
|
||||
characterDetailSchema,
|
||||
cleanLunarisText,
|
||||
constellationSnapshot,
|
||||
@@ -79,6 +80,22 @@ describe("Lunaris character data", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects dynamic level 90 base stats", () => {
|
||||
const detail = characterDetailSchema.parse({
|
||||
attributes: [
|
||||
{ level: 1, ascension: 0, hp: 1, atk: 2, def: 3 },
|
||||
{ level: 90, ascension: 6, hp: 12981, atk: 335, def: 787, "CRIT DMG%": "38.4" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(baseStatsSnapshot(detail)).toEqual([
|
||||
{ key: "hp", value: 12981 },
|
||||
{ key: "atk", value: 335 },
|
||||
{ key: "def", value: 787 },
|
||||
{ key: "CRIT DMG%", value: "38.4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the supported weapon type from character details", () => {
|
||||
const detail = characterDetailSchema.parse({
|
||||
info: { element: "Cryo", weapon: "WEAPON_SWORD_ONE_HAND" },
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface CatalogMaterial {
|
||||
imageKey: string;
|
||||
}
|
||||
|
||||
export interface CatalogBaseStat {
|
||||
key: string;
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
const constellationSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
@@ -60,6 +65,9 @@ export const characterDetailSchema = z.object({
|
||||
z.string(),
|
||||
z.union([z.number().int(), z.record(z.string(), z.number().int())]),
|
||||
).nullish(),
|
||||
attributes: z.array(
|
||||
z.record(z.string(), z.union([z.number(), z.string()])),
|
||||
).nullish(),
|
||||
});
|
||||
|
||||
export function cleanLunarisText(value: string): string {
|
||||
@@ -141,3 +149,13 @@ export function ascensionSnapshot(
|
||||
: Object.entries(entries).map(([key, amount]) => ({ key, amount })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function baseStatsSnapshot(
|
||||
value: z.infer<typeof characterDetailSchema>,
|
||||
): CatalogBaseStat[] {
|
||||
const level90 = value.attributes?.find((entry) => entry.level === 90);
|
||||
if (!level90) return [];
|
||||
return Object.entries(level90)
|
||||
.filter(([key]) => key !== "level" && key !== "ascension")
|
||||
.map(([key, stat]) => ({ key, value: stat }));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
ascensionSnapshot,
|
||||
avatarIconName,
|
||||
baseStatsSnapshot,
|
||||
characterDetailSchema,
|
||||
constellationSnapshot,
|
||||
nameCardImageName,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import type {
|
||||
CatalogAscensionGroup,
|
||||
CatalogBaseStat,
|
||||
CatalogConstellation,
|
||||
CatalogMaterial,
|
||||
CatalogTalent,
|
||||
@@ -95,6 +97,7 @@ interface SyncedCharacter {
|
||||
constellations: CatalogConstellation[];
|
||||
talents: CatalogTalent[];
|
||||
ascension: CatalogAscensionGroup[];
|
||||
baseStats: CatalogBaseStat[];
|
||||
}
|
||||
|
||||
async function getJson(url: string): Promise<unknown> {
|
||||
@@ -274,6 +277,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
)
|
||||
: [],
|
||||
ascension: detail ? ascensionSnapshot(detail) : [],
|
||||
baseStats: detail ? baseStatsSnapshot(detail) : [],
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -308,6 +312,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
(icon) => `catalog/characters/talents/${webpName(icon)}`,
|
||||
),
|
||||
ascension: ascensionSnapshot(detail),
|
||||
baseStats: baseStatsSnapshot(detail),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -456,6 +461,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
constellations: item.constellations,
|
||||
talents: item.talents,
|
||||
ascension: item.ascension,
|
||||
baseStats: item.baseStats,
|
||||
})),
|
||||
),
|
||||
{ type: "application/json", acl: "public-read" },
|
||||
@@ -514,6 +520,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
|
||||
constellations: item.constellations,
|
||||
talents: item.talents,
|
||||
ascension: item.ascension,
|
||||
baseStats: item.baseStats,
|
||||
})),
|
||||
);
|
||||
if (materials.length)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
characters,
|
||||
dataSources,
|
||||
dataSourceVersions,
|
||||
guides,
|
||||
media,
|
||||
pageDataSources,
|
||||
pages,
|
||||
@@ -308,7 +309,7 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
retained: boolean;
|
||||
}> {
|
||||
const db = getDb();
|
||||
const [referencedBlocks, portraitCharacters, [mediaRecord]] = await Promise.all([
|
||||
const [referencedBlocks, portraitCharacters, posterGuides, [mediaRecord]] = await Promise.all([
|
||||
db
|
||||
.select({ block: blocks, page: pages, character: characters })
|
||||
.from(blocks)
|
||||
@@ -318,6 +319,10 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
.select({ character: characters })
|
||||
.from(characters)
|
||||
.where(eq(characters.portraitMediaId, mediaId)),
|
||||
db
|
||||
.select({ guide: guides })
|
||||
.from(guides)
|
||||
.where(eq(guides.posterArtMediaId, mediaId)),
|
||||
db.select().from(media).where(eq(media.id, mediaId)).limit(1),
|
||||
]);
|
||||
|
||||
@@ -336,10 +341,12 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
||||
public:
|
||||
references.some(
|
||||
({ page, character }) => page.visible && character.visible,
|
||||
) || portraitCharacters.some(({ character }) => character.visible),
|
||||
) || portraitCharacters.some(({ character }) => character.visible) ||
|
||||
posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt),
|
||||
retained:
|
||||
references.length > 0 ||
|
||||
portraitCharacters.length > 0 ||
|
||||
posterGuides.length > 0 ||
|
||||
Boolean(
|
||||
mediaRecord &&
|
||||
(mediaRecord.currentReferenceCount > 0 ||
|
||||
|
||||
@@ -126,16 +126,40 @@ export async function updateGuideBasics(guideId: string, input: unknown) {
|
||||
const [cover] = await getDb().select().from(media).where(and(eq(media.id, data.coverMediaId), eq(media.status, "ready"))).limit(1);
|
||||
if (!cover) throw new Error("ไม่พบภาพ Guide ที่อัปโหลดแล้ว");
|
||||
return getDb().transaction(async (tx) => {
|
||||
const posterArtMediaId = data.posterArtMediaId === undefined
|
||||
? current.posterArtMediaId
|
||||
: data.posterArtMediaId;
|
||||
if (posterArtMediaId) {
|
||||
const [poster] = await tx
|
||||
.select({ id: media.id })
|
||||
.from(media)
|
||||
.where(and(eq(media.id, posterArtMediaId), eq(media.status, "ready")))
|
||||
.limit(1);
|
||||
if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว");
|
||||
}
|
||||
const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion);
|
||||
await tx.update(guides).set({
|
||||
overview: data.overview,
|
||||
coverMediaId: data.coverMediaId,
|
||||
...(data.youtubeUrl !== undefined ? { youtubeUrl: data.youtubeUrl } : {}),
|
||||
...(data.posterArtMediaId !== undefined ? { posterArtMediaId } : {}),
|
||||
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
||||
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
||||
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
||||
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
||||
}).where(eq(guides.id, guideId));
|
||||
if (current.coverMediaId !== data.coverMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId));
|
||||
}
|
||||
if (current.posterArtMediaId !== posterArtMediaId) {
|
||||
if (current.posterArtMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.posterArtMediaId));
|
||||
}
|
||||
if (posterArtMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, posterArtMediaId));
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -390,6 +414,9 @@ export async function permanentlyDeleteGuide(guideId: string) {
|
||||
if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร");
|
||||
await tx.delete(guides).where(eq(guides.id, guideId));
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId));
|
||||
if (guide.posterArtMediaId) {
|
||||
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId));
|
||||
}
|
||||
await emitGuideEvents(tx, guide.id, guide.version + 1);
|
||||
return { version: guide.version + 1 };
|
||||
});
|
||||
|
||||
+26
-3
@@ -114,14 +114,33 @@ export async function listPublicGuideCards() {
|
||||
|
||||
export async function getGuideByCharacterKey(characterKey: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, cover: media })
|
||||
.select({ guide: guides, cover: media, character: catalogCharacters })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.innerJoin(catalogCharacters, eq(catalogCharacters.key, guides.characterKey))
|
||||
.where(and(eq(guides.characterKey, characterKey), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const materialKeys = [...new Set(
|
||||
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
|
||||
)];
|
||||
const [materials, poster] = await Promise.all([
|
||||
materialKeys.length
|
||||
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
|
||||
: [],
|
||||
row.guide.posterArtMediaId
|
||||
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder));
|
||||
return { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), sections };
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
character: catalogCharacterWithUrls(row.character, materialByKey),
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
@@ -140,7 +159,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
const materialKeys = [...new Set(
|
||||
row.character.ascension.flatMap((group) => group.items.map((item) => item.key)),
|
||||
)];
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials] = await Promise.all([
|
||||
const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster] = 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)),
|
||||
@@ -151,6 +170,9 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
materialKeys.length
|
||||
? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys))
|
||||
: [],
|
||||
row.guide.posterArtMediaId
|
||||
? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const groupedAliases = aliasesByItem(aliases);
|
||||
const materialByKey = new Map(materials.map((item) => [item.key, item]));
|
||||
@@ -198,6 +220,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) {
|
||||
return {
|
||||
...row.guide,
|
||||
coverUrl: publicMediaUrl(row.cover.objectKey),
|
||||
posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null,
|
||||
character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] },
|
||||
elementIconUrls,
|
||||
sections,
|
||||
|
||||
@@ -36,6 +36,11 @@ export const updateGuideSchema = z.strictObject({
|
||||
coverMediaId: z.string().uuid(),
|
||||
overview: z.string().trim().min(1).max(500),
|
||||
youtubeUrl: youtubeUrlSchema,
|
||||
posterArtMediaId: z.string().uuid().nullable().optional(),
|
||||
posterArtOffsetX: z.number().finite().min(-50).max(50).optional(),
|
||||
posterArtOffsetY: z.number().finite().min(-50).max(50).optional(),
|
||||
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||
});
|
||||
|
||||
export const weaponSectionSchema = z.strictObject({
|
||||
|
||||
Reference in New Issue
Block a user