1389 lines
44 KiB
TypeScript
1389 lines
44 KiB
TypeScript
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<string, WorkbookVisual>;
|
|
|
|
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<string, string> = {
|
|
"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<string, string> = {
|
|
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<T>(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<SheetMatrix> {
|
|
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 (/^<!doctype html/iu.test(body.trim()))
|
|
throw new Error(`${source.characterName}: ${sheet} did not return CSV`);
|
|
return parseCsv(body);
|
|
}
|
|
|
|
const assetFingerprintCache = new Map<string, Promise<Uint8Array | null>>();
|
|
|
|
async function imageFingerprint(input: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
|
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<Uint8Array | null> {
|
|
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<T extends ImportCatalogItem>(
|
|
fingerprint: Uint8Array,
|
|
items: T[],
|
|
): Promise<T | null> {
|
|
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<string, string> {
|
|
return Object.fromEntries(
|
|
[...tag.matchAll(/([\w:.-]+)="([^"]*)"/gu)].map((match) => [
|
|
match[1],
|
|
xmlValue(match[2]),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function archiveText(
|
|
files: Record<string, Uint8Array>,
|
|
fileName: string,
|
|
): string | null {
|
|
const value = files[fileName];
|
|
return value ? new TextDecoder().decode(value) : null;
|
|
}
|
|
|
|
function relationshipMap(xml: string): Map<string, string> {
|
|
return new Map(
|
|
[...xml.matchAll(/<Relationship\b[^>]*>/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<WorkbookVisuals> {
|
|
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<string, Promise<WorkbookVisual>>();
|
|
|
|
for (const sheetTag of workbook.match(/<sheet\b[^>]*>/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 = /<drawing\b[^>]*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(
|
|
/<xdr:(?:one|two)CellAnchor>([\s\S]*?)<\/xdr:(?:one|two)CellAnchor>/gu,
|
|
)) {
|
|
const anchor = anchorMatch[1];
|
|
const column = Number(/<xdr:col>(\d+)<\/xdr:col>/u.exec(anchor)?.[1]);
|
|
const row = Number(/<xdr:row>(\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<string, string> = {
|
|
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<typeof member> =>
|
|
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<GuideImportDraft> {
|
|
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;
|
|
}
|