import { posix as path } from "node:path"; import { unzipSync } from "fflate"; import sharp from "sharp"; export type SheetMatrix = string[][]; export interface GuideImportSource { characterName: string; overview: string; workbookId: string; coverId: string; layout: "full" | "compact"; infoSheet: string; weaponMetric?: "overall-personal" | "personal-only" | "primary-only"; teamSheets: string[]; comboSheets: string[]; skipWrite?: boolean; } export interface ImportCatalogItem { key: string; name: string; imageUrl?: string; } export interface ImportCatalogCharacter extends ImportCatalogItem { constellations: Array<{ level: number; description: string }>; } export interface ImportCatalog { characters: ImportCatalogCharacter[]; weapons: ImportCatalogItem[]; artifacts: ImportCatalogItem[]; } interface WorkbookVisual { characterKey?: string; weaponKey?: string; artifactKey?: string; } type WorkbookVisuals = Map; export interface GuideImportDraft { source: GuideImportSource; characterKey: string; weapons: Array<{ weaponKey: string; refinement: number; overallPercent: number | null; personalPercent: number | null; note: string | null; }>; artifacts: { groups: Array<{ name: "Late Game" | "Early Game"; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: number; note: string | null; }>; }>; sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string; relationToNext?: "gt" | "gte" | "eq"; }>; note: string | null; }; constellationGroups: Array<{ note: string | null; rows: Array<{ level: number; detail: string; cumulativePercent: number; isGreat: boolean; }>; }>; teams: Array<{ title: string; rotationSeconds: number | null; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; secondArtifactKey: string | null; constellation: number; refinement: number; damage: number; }>; }>; extras: Array<{ title: string; rows: Array<{ heading: string; body: string; percent: number | null; }>; }>; skipped: string[]; } export const guideImportSources: GuideImportSource[] = [ { characterName: "Yumemizuki Mizuki", overview: "[7.1] Full Guide Mizuki", workbookId: "1bLEKBOTMZ6H3NmxEM7o32HQgNqcRBR0pUV-OIeACpXA", coverId: "01a0387a-4313-7d9a-b2ff-38e9356ccd47", layout: "full", infoSheet: "Mizuki Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "overall-personal", }, { characterName: "Odette", overview: "[7.0] Full Guide Odette", workbookId: "1I7iuPbigKD6OZ5t4M9-tE5AvP-BMAu7iJB54NilP6-o", coverId: "01a0040b-0f75-7b20-9a87-e6ec105bd803", layout: "full", infoSheet: "Odette Info", teamSheets: ["Team Calc"], comboSheets: ["Combo (คอมโบ)"], weaponMetric: "overall-personal", }, { characterName: "Yae Miko", overview: "[7.0] Full Guide Yae Miko", workbookId: "1kUIsa7UmNc4WbhgRvL99wr0i2pD1rezcr8nWndRibFc", coverId: "019f3848-2d0d-7295-9d3c-ae78e73752d4", layout: "full", infoSheet: "Yae Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "overall-personal", }, { characterName: "Mavuika", overview: "[6.7] Full Guide Mavuika", workbookId: "1DQIREJUi0RKqIgTDThCbgkgLrwPCqun6fL1D1TswjiU", coverId: "019eb0ef-0a48-7109-ad20-1d3d1d0685cc", layout: "compact", infoSheet: "Mavuika Info", teamSheets: ["คอมโบแบบลิง Team Calc"], comboSheets: ["Combo"], weaponMetric: "primary-only", }, { characterName: "Sandrone", overview: "[6.7] Full Guide Sandrone", workbookId: "1YqRlAlO_q5EyL38j4E5Z57qztxUBS5muEChS0qC0E8s", coverId: "019e78b4-9b68-7a07-b1ce-75aaf020b54e", layout: "compact", infoSheet: "Sandrone Info", teamSheets: ["Team Calc", "ชีต7"], comboSheets: [], weaponMetric: "overall-personal", }, { characterName: "Nicole", overview: "[6.6] Nicole Guide", workbookId: "1vacoQds_seHc-4d-4Nhy2LIwkbwrRaTkn3a1epdWDF8", coverId: "019e31ca-98ba-792c-bbe4-b35e2ccb685e", layout: "compact", infoSheet: "Nicole Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Lohen", overview: "[6.6] Mini Guide Lohen", workbookId: "1TH_5ve6i-COCe5thV37Z8ZpXiWWoJdklfsJHG9sNmf4", coverId: "019d86ab-bfd5-77ad-b15f-1f590724f932", layout: "compact", infoSheet: "Lohen Info", teamSheets: ["Team Calc", "ชีต7"], comboSheets: [], weaponMetric: "overall-personal", }, { characterName: "Nefer", overview: "[6.5] Nefer Guide", workbookId: "1bABpC7CTRRZaJ7EDZnzBcNjTTKSZhZQhR_Ee42I7mW0", coverId: "019d5413-370e-771a-b402-9d064f517e12", layout: "compact", infoSheet: "Nefer Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Linnea", overview: "[6.5] Linnea Guide", workbookId: "1GLRUNkJcWvLO6FdMt8wgazE1Yic7-mIryLnXIJczyNc", coverId: "019d07c6-80eb-7cc1-bc32-275125b0694e", layout: "compact", infoSheet: "Linnea Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Varka", overview: "[6.4] Varka Guide", workbookId: "1wxN1vfbpSqQagJexiT5K0gvioP8Rg_qOuBr514JlNcQ", coverId: "019d07c6-8614-7495-a7d7-dddfda786cf3", layout: "compact", infoSheet: "Varka Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Zibai", overview: "[6.3] Zibai Guide", workbookId: "1LZ0nN6tREKa32whGqHQSPTdM4KTX4CxA6sKY2PgzByA", coverId: "019d07c6-83d7-7f56-a1f6-8d500deb17bb", layout: "compact", infoSheet: "Zibai Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Ineffa", overview: "[6.3] Ineffa Guide", workbookId: "1ZvZ76gmczMs_L6gcXLW5TgffU0JjQrCqR42aNPELiFs", coverId: "019d07c6-8574-7e4b-8b9b-48e3c08be240", layout: "compact", infoSheet: "Ineffa Info", teamSheets: ["Team Calc", "Team Calc No Columbina"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Columbina", overview: "[6.3] Columbina Guide", workbookId: "12Q2CBMvGgldysO2IENh6O6TXoVL_iqv0VTSve0QHz98", coverId: "019d07c6-8792-7923-ac39-b2f16cebc40c", layout: "compact", infoSheet: "Columbina Info", teamSheets: [ "Columbina SubDPSSupport | Nodkr", "Columbina SubDPSSupport | Other", "Columbina MainDPS (On-field)", ], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Mona", overview: "[6.3] Mona ไสยเวทย์ Guide", workbookId: "1GlQWI7J79G6ezUoOwRWyn__TEdEzsd8oee3RGUYoZZA", coverId: "019d07c6-7fd4-7156-8551-e25cc335022f", layout: "compact", infoSheet: "Mona Info", teamSheets: ["Mona Support", "Mona On field"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Flins", overview: "[6.3] Flins Guide", workbookId: "19exQORI29IDvI29f064cgQgp4jtViY9Co0h-y2A0k9w", coverId: "019d07c6-8314-779f-bfda-4fe82129b14e", layout: "compact", infoSheet: "Flins Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, { characterName: "Fischl", overview: "[6.3] Fischl ไสยเวทย์ Guide", workbookId: "10e1fXcfubIqljb2FFfltM6sAKE6whcHujkSpipW-I7Y", coverId: "019d07c6-869e-72fd-a48c-3bd21688a8a9", layout: "compact", infoSheet: "Fischl Info", teamSheets: ["Team Calc"], comboSheets: [], weaponMetric: "primary-only", }, ]; const itemAliases: Record = { "4vv": "Viridescent Venerer", vv: "Viridescent Venerer", ttds: "Thrilling Tales of Dragon Slayers", redhorn: "Redhorn Stonethresher", wolfgrave: "Wolf's Gravestone", wolfgravestone: "Wolf's Gravestone", theunforge: "The Unforged", primodialjade: "Primordial Jade Winged-Spear", primordialjade: "Primordial Jade Winged-Spear", primordialjader1: "Primordial Jade Winged-Spear", primodialjadecutter: "Primordial Jade Cutter", kaguraverity: "Kagura's Verity", athounsandfloatingdreams: "A Thousand Floating Dreams", dawingfrost: "Dawning Frost", stellarwatch: "Starcaller's Watch", nocturnecurtaincall: "Nocturne's Curtain Call", thounsandblazingsuns: "A Thousand Blazing Suns", thousandblazingsun: "A Thousand Blazing Suns", thousandblazingsuns: "A Thousand Blazing Suns", mightywolf: "Gest of the Mighty Wolf", balladofthemightywolf: "Gest of the Mighty Wolf", daybreakchronicles: "The Daybreak Chronicles", eternalflow: "Tome of the Eternal Flow", hunterpath: "Hunter's Path", nightskyunveiling: "Night of the Sky's Unveiling", nightoftheskys: "Night of the Sky's Unveiling", marechaussehunter: "Marechaussee Hunter", carvedfromrisingwinds: "A Day Carved From Rising Winds", risingwinds: "A Day Carved From Rising Winds", desertpavillionchronicle: "Desert Pavilion Chronicle", aubadeofmorningstar: "Aubade of Morningstar and Moon", calamity: "Calamity Queller", beacon: "Beacon of the Reed Sea", floatingdreams: "A Thousand Floating Dreams", tidalshadow: "Tidal Shadow", crimsonwitch: "Crimson Witch of Flames", marechauseehunter: "Marechaussee Hunter", noblesse: "Noblesse Oblige", tenacity: "Tenacity of the Millelith", gildeddream: "Gilded Dreams", scarletproof: "Scarlet Proof", resolution: "Resolution of Sojourner", instructor: "Instructor", }; const characterAliases: Record = { mizuki: "Yumemizuki Mizuki", cryomc: "Traveler Cryo", anemomc: "Traveler Anemo", geomc: "Traveler Geo", dendromc: "Traveler Dendro", hydromc: "Traveler Hydro", pyromc: "Traveler Pyro", electromc: "Traveler Electro", }; function normalized(value: string): string { return value .normalize("NFKD") .toLowerCase() .replace(/\([^)]*\)/gu, " ") .replace(/\br(?:efinement\s*)?[1-5](?:-[1-5])?\b/giu, " ") .replace(/^\s*[24]\s+/u, "") .replace(/[^a-z0-9]+/gu, ""); } function cleanLabel(value: string): string { return value .split("\n")[0] .trim() .replace(/^\s*[24]\s+/u, ""); } function resolveItem( value: string, items: ImportCatalogItem[], ): ImportCatalogItem | null { const source = normalized(cleanLabel(value)); const alias = itemAliases[source]; const wanted = normalized(alias ?? source); const exact = items.find((item) => normalized(item.name) === wanted); if (exact) return exact; if (wanted.length < 5) return null; const prefix = items.filter((item) => { const candidate = normalized(item.name); return candidate.startsWith(wanted) || wanted.startsWith(candidate); }); return prefix.length === 1 ? prefix[0] : null; } function resolveCharacter( value: string, items: ImportCatalogCharacter[], ): ImportCatalogCharacter | null { const withoutBuild = cleanLabel(value) .replace(/\s+C[0-6](?:\s+R[1-5])?.*$/iu, "") .trim(); const source = normalized(withoutBuild); const wanted = normalized(characterAliases[source] ?? source); const exact = items.find((item) => normalized(item.name) === wanted); if (exact) return exact; if (wanted.length < 4) return null; const prefix = items.filter((item) => normalized(item.name).startsWith(wanted), ); return prefix.length === 1 ? prefix[0] : null; } function percent(value: string): number | null { if (/^\s*-\s*%?\s*$/u.test(value)) return 0; const match = /(-?\d+(?:\.\d+)?)\s*%/u.exec(value.replace(/,/gu, "")); return match ? Number(match[1]) : null; } function numeric(value: string): number | null { const cleaned = value.replace(/,/gu, "").trim(); if (!/^-?\d+(?:\.\d+)?$/u.test(cleaned)) return null; return Number(cleaned); } function refinement(value: string): number { const match = /\bR([1-5])(?:-[1-5])?\b/iu.exec(value); return match ? Number(match[1]) : 1; } function noteFromLabel(value: string): string | null { const lines = value .split("\n") .map((line) => line.trim()) .filter(Boolean); return lines.length > 1 ? lines.slice(1).join("\n") : null; } function unique(values: T[]): T[] { return [...new Set(values)]; } export function parseCsv(value: string): SheetMatrix { const rows: string[][] = []; let row: string[] = []; let field = ""; let quoted = false; for (let index = 0; index < value.length; index += 1) { const character = value[index]; if (quoted) { if (character === '"' && value[index + 1] === '"') { field += '"'; index += 1; } else if (character === '"') quoted = false; else field += character; } else if (character === '"') quoted = true; else if (character === ",") { row.push(field); field = ""; } else if (character === "\n") { row.push(field.replace(/\r$/u, "")); rows.push(row); row = []; field = ""; } else field += character; } if (field || row.length) { row.push(field.replace(/\r$/u, "")); rows.push(row); } return rows; } export async function fetchSheetCsv( source: GuideImportSource, sheet: string, range: string, ): Promise { const url = new URL( `https://docs.google.com/spreadsheets/d/${source.workbookId}/gviz/tq`, ); url.searchParams.set("tqx", "out:csv"); url.searchParams.set("headers", "0"); url.searchParams.set("sheet", sheet); url.searchParams.set("range", range); const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }); if (!response.ok) throw new Error( `${source.characterName}: ${sheet} returned ${response.status}`, ); const body = await response.text(); if (/^>(); async function imageFingerprint(input: Uint8Array | ArrayBuffer): Promise { return new Uint8Array( await sharp(Buffer.from(input instanceof ArrayBuffer ? new Uint8Array(input) : input)) .flatten({ background: "#000" }) .resize(16, 16, { fit: "fill" }) .raw() .toBuffer(), ); } function catalogFingerprint(item: ImportCatalogItem): Promise { if (!item.imageUrl) return Promise.resolve(null); const cached = assetFingerprintCache.get(item.imageUrl); if (cached) return cached; const loading = fetch(item.imageUrl, { signal: AbortSignal.timeout(30_000) }) .then(async (response) => response.ok ? imageFingerprint(await response.arrayBuffer()) : null, ) .catch(() => null); assetFingerprintCache.set(item.imageUrl, loading); return loading; } function fingerprintDistance(left: Uint8Array, right: Uint8Array): number { let total = 0; for (let index = 0; index < left.length; index += 1) { const difference = left[index] - right[index]; total += difference * difference; } return Math.sqrt(total / left.length); } async function matchCatalogImage( fingerprint: Uint8Array, items: T[], ): Promise { const candidates = ( await Promise.all( items.map(async (item) => ({ item, value: await catalogFingerprint(item) })), ) ) .filter( (candidate): candidate is { item: T; value: Uint8Array } => candidate.value !== null, ) .map((candidate) => ({ item: candidate.item, distance: fingerprintDistance(fingerprint, candidate.value), })) .sort((left, right) => left.distance - right.distance); const best = candidates[0]; const runnerUp = candidates[1]; return best && best.distance <= 25 && (!runnerUp || runnerUp.distance - best.distance >= 5) ? best.item : null; } function xmlValue(value: string): string { return value .replaceAll("&", "&") .replaceAll(""", '"') .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">"); } function xmlAttributes(tag: string): Record { return Object.fromEntries( [...tag.matchAll(/([\w:.-]+)="([^"]*)"/gu)].map((match) => [ match[1], xmlValue(match[2]), ]), ); } function archiveText( files: Record, fileName: string, ): string | null { const value = files[fileName]; return value ? new TextDecoder().decode(value) : null; } function relationshipMap(xml: string): Map { return new Map( [...xml.matchAll(/]*>/gu)].map((match) => { const attributes = xmlAttributes(match[0]); return [attributes.Id, attributes.Target]; }), ); } function relatedPath(source: string, target: string): string { return path.normalize(path.join(path.dirname(source), target)); } async function fetchWorkbookVisuals( source: GuideImportSource, catalog: ImportCatalog, ): Promise { if ( !source.teamSheets.length || !catalog.characters.some((item) => item.imageUrl) ) return new Map(); const response = await fetch( `https://docs.google.com/spreadsheets/d/${source.workbookId}/export?format=xlsx`, { signal: AbortSignal.timeout(60_000) }, ); if (!response.ok) throw new Error(`${source.characterName}: workbook returned ${response.status}`); const files = unzipSync(new Uint8Array(await response.arrayBuffer())); const workbookPath = "xl/workbook.xml"; const workbook = archiveText(files, workbookPath); const workbookRelationships = archiveText( files, "xl/_rels/workbook.xml.rels", ); if (!workbook || !workbookRelationships) return new Map(); const worksheetById = relationshipMap(workbookRelationships); const visuals: WorkbookVisuals = new Map(); const imageMatches = new Map>(); for (const sheetTag of workbook.match(/]*>/gu) ?? []) { const sheet = xmlAttributes(sheetTag); if (!source.teamSheets.includes(sheet.name)) continue; const worksheetTarget = worksheetById.get(sheet["r:id"]); if (!worksheetTarget) continue; const worksheetPath = relatedPath(workbookPath, worksheetTarget); const worksheet = archiveText(files, worksheetPath); const worksheetRelsPath = path.join( path.dirname(worksheetPath), "_rels", `${path.basename(worksheetPath)}.rels`, ); const worksheetRelationships = archiveText(files, worksheetRelsPath); if (!worksheet || !worksheetRelationships) continue; const worksheetRelationshipMap = relationshipMap(worksheetRelationships); const drawingId = /]*r:id="([^"]+)"/u.exec(worksheet)?.[1]; const drawingTarget = drawingId ? worksheetRelationshipMap.get(drawingId) : null; if (!drawingTarget) continue; const drawingPath = relatedPath(worksheetPath, drawingTarget); const drawing = archiveText(files, drawingPath); const drawingRelsPath = path.join( path.dirname(drawingPath), "_rels", `${path.basename(drawingPath)}.rels`, ); const drawingRelationships = archiveText(files, drawingRelsPath); if (!drawing || !drawingRelationships) continue; const drawingRelationshipMap = relationshipMap(drawingRelationships); for (const anchorMatch of drawing.matchAll( /([\s\S]*?)<\/xdr:(?:one|two)CellAnchor>/gu, )) { const anchor = anchorMatch[1]; const column = Number(/(\d+)<\/xdr:col>/u.exec(anchor)?.[1]); const row = Number(/(\d+)<\/xdr:row>/u.exec(anchor)?.[1]); const imageId = /r:embed="([^"]+)"/u.exec(anchor)?.[1]; const imageTarget = imageId ? drawingRelationshipMap.get(imageId) : null; if (!Number.isInteger(column) || !Number.isInteger(row) || !imageTarget) continue; const imagePath = relatedPath(drawingPath, imageTarget); const image = files[imagePath]; if (!image) continue; let matched = imageMatches.get(imagePath); if (!matched) { matched = imageFingerprint(image).then(async (fingerprint) => { const [character, weapon, artifact] = await Promise.all([ matchCatalogImage(fingerprint, catalog.characters), matchCatalogImage(fingerprint, catalog.weapons), matchCatalogImage(fingerprint, catalog.artifacts), ]); return { ...(character ? { characterKey: character.key } : {}), ...(weapon ? { weaponKey: weapon.key } : {}), ...(artifact ? { artifactKey: artifact.key } : {}), }; }); imageMatches.set(imagePath, matched); } visuals.set(`${sheet.name}\0${row}\0${column}`, await matched); } } return visuals; } function emptyDraft( source: GuideImportSource, characterKey: string, ): GuideImportDraft { return { source, characterKey, weapons: [], artifacts: { groups: [ { name: "Late Game", rows: [] }, { name: "Early Game", rows: [] }, ], sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null, }, constellationGroups: [], teams: [], extras: [], skipped: [], }; } function addWeapon( draft: GuideImportDraft, catalog: ImportCatalog, label: string, overall: string, personal: string, metric: GuideImportSource["weaponMetric"], ): void { if (!cleanLabel(label)) return; const primaryPercent = percent(overall); if (primaryPercent === null || /ชื่ออาวุธ/u.test(label)) return; const weapon = resolveItem(label, catalog.weapons); if (!weapon) { draft.skipped.push(`weapon not in catalog: ${cleanLabel(label)}`); return; } const secondary = percent(personal); const overallPercent = metric === "personal-only" ? null : primaryPercent; const personalPercent = metric === "personal-only" ? primaryPercent : metric === "overall-personal" ? secondary : null; const notes = [noteFromLabel(label)]; if (metric !== "overall-personal" && secondary !== null) notes.push(`ค่าคอลัมน์ที่สองจากชีต: ${secondary}%`); draft.weapons.push({ weaponKey: weapon.key, refinement: refinement(label), overallPercent, personalPercent, note: notes.filter(Boolean).join("\n") || null, }); } function compactWeaponMetric(matrix: SheetMatrix): GuideImportSource["weaponMetric"] { const weaponHeader = matrix .slice(0, 3) .flat() .find((cell) => /Weapons\s+อาวุธ/iu.test(cell)); if (!/ดาเมจส่วนตัว|personal\s*(?:damage|dmg)/iu.test(weaponHeader ?? "")) return "primary-only"; return matrix.some( (row) => percent(row[14] ?? "") !== null || percent(row[20] ?? "") !== null, ) ? "overall-personal" : "personal-only"; } function artifactParts(label: string): string[] { const cleaned = cleanLabel(label); if (/^(?:2\s*)?EM\s*\+\s*2\s*EM$/iu.test(cleaned)) return ["Gilded Dreams", "Wanderer's Troupe"]; if (/^(?:2\s*)?ATK\s*\+\s*2\s*ATK$/iu.test(cleaned)) return ["Gladiator's Finale", "Shimenawa's Reminiscence"]; if (/^Berserker\s*\+\s*2\s*ATK$/iu.test(cleaned)) return ["Berserker", "Gladiator's Finale"]; return cleaned .split(/\s*\+\s*/u) .map((item) => item.trim()) .filter(Boolean); } function addArtifact( draft: GuideImportDraft, catalog: ImportCatalog, groupIndex: 0 | 1, label: string, metric: string, ): void { if (!cleanLabel(label)) return; const value = percent(metric); if (value === null || /ชื่ออาร์ติแฟกต์/u.test(label)) return; const parts = artifactParts(label); if (!parts.length || parts.length > 2) { draft.skipped.push( `artifact combination cannot be represented: ${cleanLabel(label)}`, ); return; } const resolved = parts.map((part) => resolveItem(part, catalog.artifacts)); if (resolved.some((item) => !item)) { draft.skipped.push(`artifact not in catalog: ${cleanLabel(label)}`); return; } draft.artifacts.groups[groupIndex].rows.push({ firstArtifactKey: resolved[0]!.key, secondArtifactKey: resolved[1]?.key ?? null, percent: value, note: noteFromLabel(label), }); } const targetAliases: Record = { atk: "ATK", hp: "HP", def: "DEF", em: "EM", er: "ER", cr: "CRIT RATE", cdm: "CRIT DAMAGE", }; function targetName(value: string): string | null { const match = /\b(ATK|HP|DEF|EM|ER|CR|CDM)\b/iu.exec(value); return match ? targetAliases[match[1].toLowerCase()] : null; } function mainStat(value: string): string | null { const text = value.trim(); const map: Array<[RegExp, string]> = [ [/ชำนาญธาตุ|^EM$/iu, "EM"], [/ฟื้นฟูพลังงาน|^ER$/iu, "ER"], [/พลังโจมตี|^ATK%$/iu, "ATK%"], [/พลังชีวิต|^HP%$/iu, "HP%"], [/พลังป้องกัน|^DEF%$/iu, "DEF%"], [/อัตราคริ|แรงคริ|CRIT/iu, "CRIT Rate%/CRIT DMG%"], [/โบนัสการรักษา|Healing/iu, "Healing Bonus%"], ]; return map.find(([pattern]) => pattern.test(text))?.[1] ?? null; } function validSlotStat( slot: "sands" | "goblet" | "circlet", value: string, ): boolean { const options = { sands: ["EM", "ER", "ATK%", "HP%", "DEF%"], goblet: [ "EM", "ATK%", "DEF%", "HP%", "Pyro DMG%", "Hydro DMG%", "Cryo DMG%", "Electro DMG%", "Anemo DMG%", "Geo DMG%", "Dendro DMG%", "Physical DMG%", ], circlet: [ "HP%", "ATK%", "DEF%", "EM", "CRIT Rate%/CRIT DMG%", "Healing Bonus%", ], } as const; return (options[slot] as readonly string[]).includes(value); } function talentType(value: string): "NA" | "E" | "Q" | null { if (/การโจมตีปกติ|normal/iu.test(value)) return "NA"; if (/สกิลธาตุ|elemental skill/iu.test(value)) return "E"; if (/ท่าไม้ตาย|elemental burst/iu.test(value)) return "Q"; return null; } function talentLevel(value: string): string | undefined { const match = /(?:LV\.?\s*)?([1-9]|10)/iu.exec(value); return match?.[1]; } function parseFullInfo( matrix: SheetMatrix, draft: GuideImportDraft, catalog: ImportCatalog, ): void { for (const row of matrix) { for (const [labelColumn, metricStart, metricEnd] of [ [4, 20, 30], [34, 50, 60], ] as const) { const metrics = row .slice(metricStart, metricEnd) .filter((cell) => percent(cell) !== null); addWeapon( draft, catalog, row[labelColumn] ?? "", metrics[0] ?? "", metrics[1] ?? "", "overall-personal", ); } } let early = false; for (const row of matrix) { if (row.some((cell) => /ต้นเกม/u.test(cell))) early = true; addArtifact(draft, catalog, early ? 1 : 0, row[4] ?? "", row[16] ?? ""); } for (const row of matrix) { const slots = [22, 28, 34] as const; for (const [index, column] of slots.entries()) { const value = mainStat(row[column] ?? ""); if (!value) continue; const key = (["sands", "goblet", "circlet"] as const)[index]; if (validSlotStat(key, value)) draft.artifacts[key].push(value); } const target = targetName(row[22] ?? ""); if (target && row[28]?.trim()) draft.artifacts.targets.push({ stat: target, value: row[28].trim() }); } const allCells = matrix.flat(); const substatCell = allCells.find( (cell) => />/u.test(cell) && mainStat(cell), ); if (substatCell) draft.artifacts.substats = unique( substatCell .split(/\s*>\s*/u) .map(mainStat) .filter((item): item is string => Boolean(item)), ); for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) { for ( let column = 0; column < (matrix[rowIndex]?.length ?? 0); column += 1 ) { const type = talentType(matrix[rowIndex][column]); if (!type) continue; const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? ""); draft.artifacts.talents.push({ type, priority: draft.artifacts.talents.length + 1, ...(level ? { level } : {}), }); } } } function parseCompactInfo( matrix: SheetMatrix, draft: GuideImportDraft, catalog: ImportCatalog, ): void { const metric = compactWeaponMetric(matrix); const artifactStart = matrix.findIndex((row) => row.some((cell) => /Artifacts\s+อาร์ติแฟกต์/iu.test(cell)), ); for (const row of matrix.slice( 0, artifactStart >= 0 ? artifactStart : undefined, )) { addWeapon( draft, catalog, row[11] ?? "", row[13] ?? "", row[14] ?? "", metric, ); addWeapon( draft, catalog, row[17] ?? "", row[19] ?? "", row[20] ?? "", metric, ); } if (artifactStart >= 0) { const extraRows: GuideImportDraft["extras"][number]["rows"] = []; for (const row of matrix.slice(artifactStart)) { addArtifact(draft, catalog, 0, row[11] ?? "", row[13] ?? ""); addArtifact(draft, catalog, 1, row[17] ?? "", row[19] ?? ""); for (const [labelColumn, detailColumn] of [ [11, 13], [17, 19], ] as const) { const label = cleanLabel(row[labelColumn] ?? ""); const detail = row[detailColumn]?.trim() ?? ""; if ( label && detail && !/ชื่ออาร์ติแฟกต์/u.test(label) && percent(detail) === null ) { extraRows.push({ heading: label.slice(0, 200), body: detail.slice(0, 2_000), percent: null, }); } } } if (extraRows.length) draft.extras.push({ title: "คำแนะนำอาร์ติแฟกต์เพิ่มเติม", rows: extraRows.slice(0, 50), }); } const targetGroups: Array<{ descriptor: string; rows: Array<{ stat: string; value: string }>; }> = []; let current: { descriptor: string; rows: Array<{ stat: string; value: string }>; } | null = null; for (const row of matrix) { const stat = targetName(row[5] ?? ""); const value = row[6]?.trim(); if (!stat || !value) continue; if (!current || current.rows.some((item) => item.stat === stat)) { current = { descriptor: row[7]?.trim() ?? "", rows: [] }; targetGroups.push(current); } if (row[7]?.trim()) current.descriptor = row[7].trim(); current.rows.push({ stat, value }); } const primary = targetGroups.find((group) => /เลทเกม|AR\s*45/iu.test(group.descriptor)) ?? targetGroups.at(-1); if (primary) draft.artifacts.targets = primary.rows; const alternateRows = targetGroups .filter((group) => group !== primary) .map((group, index) => ({ heading: group.descriptor || `สแตททางเลือก ${index + 1}`, body: group.rows.map((item) => `${item.stat}: ${item.value}`).join("\n"), percent: null, })); if (alternateRows.length) draft.extras.push({ title: "สแตทสำหรับแนวทางอื่น", rows: alternateRows }); for (let rowIndex = 0; rowIndex < matrix.length; rowIndex += 1) { const row = matrix[rowIndex]; const bestIndex = row.findIndex((cell) => /ออฟหลักที่ดีที่สุด/iu.test(cell), ); if (bestIndex >= 0) { const values = row .slice(bestIndex + 1, bestIndex + 5) .map(mainStat) .filter((item): item is string => Boolean(item)); if (values[0] && validSlotStat("sands", values[0])) draft.artifacts.sands.push(values[0]); if (values[1] && validSlotStat("goblet", values[1])) draft.artifacts.goblet.push(values[1]); if (values[2] && validSlotStat("circlet", values[2])) draft.artifacts.circlet.push(values[2]); } const offIndex = row.findIndex((cell) => /ออฟรอง/u.test(cell)); if (offIndex >= 0) { const value = row.slice(offIndex + 1).find((cell) => />/u.test(cell)); if (value) draft.artifacts.substats = unique( value .split(/\s*>\s*/u) .map(mainStat) .filter((item): item is string => Boolean(item)), ); } for (let column = 0; column < row.length; column += 1) { const type = talentType(row[column]); if (!type) continue; const level = talentLevel(matrix[rowIndex - 1]?.[column] ?? ""); draft.artifacts.talents.push({ type, priority: draft.artifacts.talents.length + 1, ...(level ? { level } : {}), }); } } const constellationStart = matrix.findIndex((row) => row.some((cell) => /Constellation\s+กลุ่มดาว/iu.test(cell)), ); const talentStart = matrix.findIndex( (row, index) => index > constellationStart && row.some((cell) => /Talent\s+พรสวรรค์/iu.test(cell)), ); if (constellationStart >= 0) { const rows: GuideImportDraft["constellationGroups"][number]["rows"] = []; const character = catalog.characters.find( (item) => item.key === draft.characterKey, )!; for (const row of matrix.slice( constellationStart + 1, talentStart > 0 ? talentStart : undefined, )) { const levelCell = row.find((cell) => /^C[0-6]$/iu.test(cell.trim())); const explicitLevel = levelCell ? Number(/\d/u.exec(levelCell)?.[0]) : null; if (explicitLevel === 0) continue; const metrics = row .map(percent) .filter((item): item is number => item !== null); if (!metrics.length || rows.length >= 6) continue; const level = explicitLevel ?? rows.length + 1; if (level < 1 || level > 6 || rows.some((item) => item.level === level)) continue; const previous = rows.at(-1)?.cumulativePercent ?? 100; rows.push({ level, detail: character.constellations.find((item) => item.level === level) ?.description || `C${level}`, cumulativePercent: metrics[0], isGreat: metrics[0] - previous >= 10, }); } if (rows.length) draft.constellationGroups.push({ note: "C0 = 100%", rows }); } } function parseMember( value: string, catalog: ImportCatalog, ): { characterKey: string; constellation: number; refinement: number } | null { const character = resolveCharacter(value, catalog.characters); if (!character) return null; return { characterKey: character.key, constellation: Number(/\bC([0-6])\b/iu.exec(value)?.[1] ?? 0), refinement: refinement(value), }; } export function charactersFromCombo( value: string, catalog: ImportCatalog, ): ImportCatalogCharacter[] { const found: ImportCatalogCharacter[] = []; for (const segment of value.split(/\s*>\s*/u)) { const cleaned = segment.trim(); const matches = catalog.characters.filter((item) => { const full = item.name.toLowerCase(); const first = full.split(/\s+/u)[0]; const source = cleaned.toLowerCase(); return ( source.startsWith(full) || (first.length >= 3 && source.startsWith(first)) ); }); if ( matches.length === 1 && !found.some((item) => item.key === matches[0].key) ) found.push(matches[0]); } return found; } function parseTeams( matrix: SheetMatrix, draft: GuideImportDraft, catalog: ImportCatalog, sheetName: string, ): void { for (let rowIndex = 0; rowIndex < matrix.length - 4; rowIndex += 1) { const row = matrix[rowIndex]; for (let start = 0; start < row.length; start += 1) { const labels = [0, 3, 6, 9].map((offset) => row[start + offset] ?? ""); const firstMember = parseMember(labels[0], catalog); if (!firstMember) continue; const damageRow = matrix[rowIndex + 1] ?? []; const damages = [0, 3, 6, 9].map((offset) => numeric(damageRow[start + offset] ?? ""), ); if (damages.some((damage) => damage === null)) continue; const title = matrix[rowIndex - 1]?.[start]?.trim() || `${draft.source.characterName} Team`; const rotationRow = matrix .slice(rowIndex + 2, rowIndex + 5) .find((candidate) => /TOTAL DPS/iu.test(candidate[start] ?? "")); let rotation: number | null = rotationRow ? Number(/\(([\d.]+)s\)/iu.exec(rotationRow[start] ?? "")?.[1]) : null; const rotationIndex = rotationRow ? matrix.indexOf(rotationRow) : -1; const combo = rotationIndex >= 0 ? (matrix[rotationIndex + 1]?.[start]?.trim() ?? "") : ""; if ((!Number.isFinite(rotation) || rotation <= 0) && rotationRow) { const dps = rotationRow .slice(start, start + 13) .map(numeric) .find((item): item is number => item !== null && item > 0); if (dps) rotation = damages .map((damage) => damage ?? 0) .reduce((total, damage) => total + damage, 0) / dps; } if (!Number.isFinite(rotation) || rotation <= 0) rotation = null; const explicitMembers = labels .map((label) => parseMember(label, catalog)) .filter((member): member is NonNullable => Boolean(member), ); const comboCharacters = charactersFromCombo(combo, catalog); const members = [...explicitMembers]; for (const character of comboCharacters) { if (!members.some((member) => member.characterKey === character.key)) { members.push({ characterKey: character.key, constellation: 0, refinement: 1, }); } } if (members.length !== 4) { draft.skipped.push( `${sheetName}: team ${title} has ${members.length} resolvable members`, ); continue; } draft.teams.push({ title: title.slice(0, 160), rotationSeconds: rotation, combo: (combo || "-").slice(0, 2_000), members: members.map((member, index) => ({ ...member, weaponKey: null, artifactKey: null, secondArtifactKey: null, damage: damages[index]!, })), }); start += 9; } } } function parseDetailedTeamExtra( matrix: SheetMatrix, sheetName: string, ): GuideImportDraft["extras"][number] | null { const rows: GuideImportDraft["extras"][number]["rows"] = []; for (let rowIndex = 0; rowIndex < matrix.length - 6; rowIndex += 1) { const header = matrix[rowIndex]; for (let start = 0; start < header.length; start += 1) { if (!/ชื่อตัวละคร/u.test(header[start] ?? "")) continue; const title = matrix[rowIndex - 1]?.[start]?.trim(); if (!title) continue; const lines: string[] = []; for ( let memberRow = rowIndex + 1; memberRow <= rowIndex + 4; memberRow += 1 ) { const row = matrix[memberRow] ?? []; const character = row[start + 1]?.trim(); const damage = row[start + 2]?.trim(); if (!character || numeric(damage) === null) continue; const weapon = row[start + 3]?.trim(); const artifact = row[start + 4]?.trim(); lines.push( `${character}: ${damage}${weapon ? ` | ${weapon}` : ""}${artifact ? ` | ${artifact}` : ""}`, ); } if (lines.length === 4) rows.push({ heading: title.slice(0, 200), body: lines.join("\n").slice(0, 2_000), percent: null, }); } } return rows.length ? { title: `${sheetName} — ข้อมูลทีม`.slice(0, 160), rows: rows.slice(0, 50), } : null; } function parseExtra( matrix: SheetMatrix, title: string, ): GuideImportDraft["extras"][number] | null { const rows: GuideImportDraft["extras"][number]["rows"] = []; for (const row of matrix) { const values = row.map((cell) => cell.trim()).filter(Boolean); if (!values.length) continue; const metricIndex = values.findIndex((cell) => percent(cell) !== null); const heading = values[0]; if (/^(?:MVs%|DPS%|ความหมาย)$/iu.test(heading)) continue; const body = values .filter((_, index) => index !== metricIndex) .slice(1) .join(" — "); rows.push({ heading: heading.slice(0, 200), body: body.slice(0, 2_000), percent: metricIndex >= 0 ? percent(values[metricIndex]) : null, }); if (rows.length === 50) break; } return rows.length ? { title: title.slice(0, 160), rows } : null; } export async function buildGuideImportDraft( source: GuideImportSource, catalog: ImportCatalog, ): Promise { const character = resolveCharacter(source.characterName, catalog.characters); if (!character) throw new Error(`${source.characterName}: character not in catalog`); const draft = emptyDraft(source, character.key); if (source.skipWrite) return draft; if (source.layout === "full") { const [weapons, artifacts, constellations] = await Promise.all([ fetchSheetCsv(source, "Weapons (อาวุธ)", "A1:BK80"), fetchSheetCsv(source, "Artifacts & Stats", "A1:BK80"), fetchSheetCsv(source, "Constellations (กลุ่มดาว)", "A1:BK40"), ]); parseFullInfo(weapons, draft, catalog); const artifactDraft = emptyDraft(source, character.key); parseFullInfo(artifacts, artifactDraft, catalog); draft.artifacts = artifactDraft.artifacts; draft.skipped.push( ...artifactDraft.skipped.filter((item) => item.startsWith("artifact")), ); const rows: GuideImportDraft["constellationGroups"][number]["rows"] = []; for (const row of constellations) { const value = percent(row[1] ?? ""); if (value === null || rows.length >= 6) continue; const level = rows.length + 1; const previous = rows.at(-1)?.cumulativePercent ?? 100; rows.push({ level, detail: character.constellations.find((item) => item.level === level) ?.description || row[5] || `C${level}`, cumulativePercent: value, isGreat: value - previous >= 10, }); } if (rows.length) draft.constellationGroups.push({ note: "C0 = 100%", rows }); } else { const info = await fetchSheetCsv(source, source.infoSheet, "A1:U100"); parseCompactInfo(info, draft, catalog); } for (const sheet of source.teamSheets) { const matrix = await fetchSheetCsv(source, sheet, "A1:AN160"); parseTeams(matrix, draft, catalog, sheet); const detailed = parseDetailedTeamExtra(matrix, sheet); if (detailed) draft.extras.push(detailed); } for (const sheet of source.comboSheets) { const matrix = await fetchSheetCsv(source, sheet, "A1:BU100"); const extra = parseExtra(matrix, sheet); if (extra) draft.extras.push(extra); } draft.artifacts.sands = unique(draft.artifacts.sands).slice(0, 3); draft.artifacts.goblet = unique(draft.artifacts.goblet).slice(0, 3); draft.artifacts.circlet = unique(draft.artifacts.circlet).slice(0, 3); draft.artifacts.substats = unique(draft.artifacts.substats); draft.artifacts.targets = draft.artifacts.targets.filter( (target, index, rows) => rows.findIndex((item) => item.stat === target.stat) === index, ); draft.artifacts.talents = draft.artifacts.talents .filter( (talent, index, rows) => rows.findIndex((item) => item.type === talent.type) === index, ) .slice(0, 3) .map((talent, index) => ({ ...talent, priority: index + 1 })); draft.weapons = draft.weapons.slice(0, 100); draft.teams = draft.teams.slice(0, 30); draft.extras = draft.extras.slice(0, 20); return draft; }