310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
|
import * as z from "zod";
|
|
|
|
export interface TeamToolMember {
|
|
characterKey: string;
|
|
weaponKey: string | null;
|
|
artifactKey: string | null;
|
|
secondArtifactKey: string | null;
|
|
constellation: string;
|
|
refinement: string;
|
|
damage: string;
|
|
}
|
|
|
|
export interface TeamToolValue {
|
|
clientId: string;
|
|
title: string;
|
|
rotationSeconds: string;
|
|
combo: string;
|
|
members: TeamToolMember[];
|
|
}
|
|
|
|
export function teamConstellationLabel(value: string): string {
|
|
return value === "0-6" ? "C0-C6" : `C${value}`;
|
|
}
|
|
|
|
export function teamRefinementLabel(value: string): string {
|
|
return value === "1-5" ? "R1-R5" : `R${value}`;
|
|
}
|
|
|
|
export const teamApplyFields = ["loadout", "damage", "rotation", "combo"] as const;
|
|
export type TeamApplyField = (typeof teamApplyFields)[number];
|
|
|
|
export const teamMemberApplyFields = [
|
|
"characterKey",
|
|
"weaponKey",
|
|
"artifactKey",
|
|
"secondArtifactKey",
|
|
"constellation",
|
|
"refinement",
|
|
"damage",
|
|
] as const;
|
|
export type TeamMemberApplyField = (typeof teamMemberApplyFields)[number];
|
|
export type TeamApplySelection =
|
|
| TeamApplyField
|
|
| `member:${number}:${TeamMemberApplyField}`;
|
|
|
|
export interface TeamPreset {
|
|
id: string;
|
|
name: string;
|
|
createdAt: string;
|
|
team: Omit<TeamToolValue, "clientId">;
|
|
}
|
|
|
|
const transferMemberSchema = z.strictObject({
|
|
characterKey: z.string().trim().min(1).max(64),
|
|
weaponKey: z.string().trim().min(1).max(64).nullable(),
|
|
artifactKey: z.string().trim().min(1).max(64).nullable(),
|
|
secondArtifactKey: z.string().trim().min(1).max(64).nullable(),
|
|
constellation: z.union([z.number().int().min(0).max(6), z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"])]).transform(String),
|
|
refinement: z.union([z.number().int().min(1).max(5), z.enum(["1", "2", "3", "4", "5", "1-5"])]).transform(String),
|
|
damage: z.string().refine((value) => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number >= 0 && number <= 999_999_999_999;
|
|
}),
|
|
});
|
|
|
|
const transferTeamSchema = z.strictObject({
|
|
title: z.string().trim().min(1).max(160),
|
|
rotationSeconds: z.string().refine((value) => {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number > 0 && number <= 9999;
|
|
}),
|
|
combo: z.string().trim().min(1).max(2_000),
|
|
members: z.array(transferMemberSchema).length(4)
|
|
.refine(
|
|
(members) => new Set(members.map((member) => member.characterKey)).size === 4,
|
|
)
|
|
.refine((members) => members.some((member) => Number(member.damage) > 0)),
|
|
});
|
|
|
|
const teamTransferSchema = z.strictObject({
|
|
type: z.literal("buzz-sheet.team"),
|
|
version: z.literal(1),
|
|
team: transferTeamSchema,
|
|
});
|
|
|
|
export function serializeTeamTransfer(team: TeamToolValue): string {
|
|
const payload = teamTransferSchema.parse({
|
|
type: "buzz-sheet.team",
|
|
version: 1,
|
|
team: {
|
|
title: team.title,
|
|
rotationSeconds: team.rotationSeconds,
|
|
combo: team.combo,
|
|
members: team.members,
|
|
},
|
|
});
|
|
return JSON.stringify(payload, null, 2);
|
|
}
|
|
|
|
export function teamTransferFileName(teamName: string): string {
|
|
const safeName = teamName
|
|
.replace(/[<>:"/\\|?*\u0000-\u001f]/gu, "-")
|
|
.replace(/[. ]+$/gu, "")
|
|
.trim();
|
|
return `${safeName || "team"}.json`;
|
|
}
|
|
|
|
export function parseTeamTransfer(serialized: string): Omit<TeamToolValue, "clientId"> {
|
|
const payload = teamTransferSchema.parse(JSON.parse(serialized) as unknown);
|
|
return {
|
|
...payload.team,
|
|
members: payload.team.members.map((member) => ({ ...member })),
|
|
};
|
|
}
|
|
|
|
export function cloneTeamImports(
|
|
teams: readonly Omit<TeamToolValue, "clientId">[],
|
|
createId: () => string,
|
|
): TeamToolValue[] {
|
|
return teams.map((team) => ({
|
|
clientId: createId(),
|
|
...team,
|
|
members: team.members.map((member) => ({ ...member })),
|
|
}));
|
|
}
|
|
|
|
export function filterTeamImportsByName<T extends { title: string }>(
|
|
currentTeams: readonly { title: string }[],
|
|
candidates: readonly T[],
|
|
ignoreSameName: boolean,
|
|
): T[] {
|
|
if (!ignoreSameName) return [...candidates];
|
|
const names = new Set(currentTeams.map((team) => team.title.trim().toLocaleLowerCase()));
|
|
return candidates.filter((team) => {
|
|
const name = team.title.trim().toLocaleLowerCase();
|
|
if (names.has(name)) return false;
|
|
names.add(name);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function findInvalidTeamCatalogReference(
|
|
team: Omit<TeamToolValue, "clientId">,
|
|
characters: readonly TeamToolCatalogItem[],
|
|
weapons: readonly TeamToolCatalogItem[],
|
|
artifacts: readonly TeamToolCatalogItem[],
|
|
): string | null {
|
|
const characterByKey = new Map(characters.map((item) => [item.key, item]));
|
|
const weaponByKey = new Map(weapons.map((item) => [item.key, item]));
|
|
const artifactByKey = new Map(artifacts.map((item) => [item.key, item]));
|
|
for (const member of team.members) {
|
|
const character = characterByKey.get(member.characterKey);
|
|
if (!character) return `ไม่พบตัวละคร ${member.characterKey} ใน Catalog`;
|
|
const weapon = member.weaponKey ? weaponByKey.get(member.weaponKey) : null;
|
|
if (member.weaponKey && !weapon) return `ไม่พบอาวุธ ${member.weaponKey} ใน Catalog`;
|
|
if (weapon && !supportsWeaponType(character.weaponType, weapon.weaponType)) {
|
|
return `${character.name} ไม่รองรับอาวุธ ${weapon.name}`;
|
|
}
|
|
for (const artifactKey of [member.artifactKey, member.secondArtifactKey]) {
|
|
if (artifactKey && !artifactByKey.has(artifactKey)) {
|
|
return `ไม่พบ Artifact ${artifactKey} ใน Catalog`;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface TeamToolCatalogItem {
|
|
key: string;
|
|
name: string;
|
|
weaponType?: string | null;
|
|
}
|
|
|
|
export function findUnsupportedTeamWeapon(
|
|
teams: readonly TeamToolValue[],
|
|
teamIds: ReadonlySet<string>,
|
|
characters: readonly TeamToolCatalogItem[],
|
|
weapons: readonly TeamToolCatalogItem[],
|
|
): { characterName: string; weaponName: string } | null {
|
|
const characterByKey = new Map(characters.map((item) => [item.key, item]));
|
|
const weaponByKey = new Map(weapons.map((item) => [item.key, item]));
|
|
|
|
for (const team of teams) {
|
|
if (!teamIds.has(team.clientId)) continue;
|
|
for (const member of team.members) {
|
|
if (!member.characterKey || !member.weaponKey) continue;
|
|
const character = characterByKey.get(member.characterKey);
|
|
const weapon = weaponByKey.get(member.weaponKey);
|
|
if (
|
|
character &&
|
|
weapon &&
|
|
!supportsWeaponType(character.weaponType, weapon.weaponType)
|
|
) {
|
|
return { characterName: character.name, weaponName: weapon.name };
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function cloneMember(member: TeamToolMember): TeamToolMember {
|
|
return { ...member };
|
|
}
|
|
|
|
function cloneTeam(team: TeamToolValue): TeamToolValue {
|
|
return { ...team, members: team.members.map(cloneMember) };
|
|
}
|
|
|
|
export function applyTeamFields(
|
|
teams: readonly TeamToolValue[],
|
|
sourceId: string,
|
|
targetIds: ReadonlySet<string>,
|
|
fields: readonly TeamApplySelection[],
|
|
): TeamToolValue[] {
|
|
const source = teams.find((team) => team.clientId === sourceId);
|
|
if (!source) return teams.map(cloneTeam);
|
|
const selected = new Set(fields);
|
|
return teams.map((team) => {
|
|
if (!targetIds.has(team.clientId) || team.clientId === sourceId) return cloneTeam(team);
|
|
const next = cloneTeam(team);
|
|
if (selected.has("loadout")) {
|
|
next.members = source.members.map((member, index) => ({
|
|
...cloneMember(member),
|
|
damage: next.members[index]?.damage ?? "0",
|
|
}));
|
|
}
|
|
if (selected.has("damage")) next.members = source.members.map(cloneMember);
|
|
for (const field of fields) {
|
|
const match = /^member:(\d+):(.+)$/.exec(field);
|
|
if (!match) continue;
|
|
const memberIndex = Number(match[1]);
|
|
const memberField = match[2] as TeamMemberApplyField;
|
|
const sourceMember = source.members[memberIndex];
|
|
const matchingMemberIndex = sourceMember?.characterKey
|
|
? next.members.findIndex(
|
|
(member) => member.characterKey === sourceMember.characterKey,
|
|
)
|
|
: -1;
|
|
const targetMemberIndex =
|
|
matchingMemberIndex === -1 ? memberIndex : matchingMemberIndex;
|
|
const targetMember = next.members[targetMemberIndex];
|
|
if (!sourceMember || !targetMember || !teamMemberApplyFields.includes(memberField)) continue;
|
|
next.members[targetMemberIndex] = {
|
|
...targetMember,
|
|
[memberField]: sourceMember[memberField],
|
|
};
|
|
}
|
|
if (selected.has("rotation")) next.rotationSeconds = source.rotationSeconds;
|
|
if (selected.has("combo")) next.combo = source.combo;
|
|
return next;
|
|
});
|
|
}
|
|
|
|
export function applyPresetFields(
|
|
teams: readonly TeamToolValue[],
|
|
preset: TeamPreset,
|
|
targetIds: ReadonlySet<string>,
|
|
fields: readonly TeamApplySelection[],
|
|
): TeamToolValue[] {
|
|
return applyTeamFields(
|
|
[
|
|
...teams,
|
|
{ clientId: preset.id, ...preset.team, members: preset.team.members.map(cloneMember) },
|
|
],
|
|
preset.id,
|
|
targetIds,
|
|
fields,
|
|
).slice(0, teams.length);
|
|
}
|
|
|
|
function presetFingerprint(team: Omit<TeamToolValue, "clientId">): string {
|
|
return JSON.stringify(team);
|
|
}
|
|
|
|
export function limitPresets(presets: readonly TeamPreset[], limit = 5): TeamPreset[] {
|
|
const seen = new Set<string>();
|
|
return presets.filter((preset) => {
|
|
const fingerprint = presetFingerprint(preset.team);
|
|
if (seen.has(fingerprint)) return false;
|
|
seen.add(fingerprint);
|
|
return true;
|
|
}).slice(0, limit);
|
|
}
|
|
|
|
export function parseTeamPresets(serialized: string | null): TeamPreset[] {
|
|
if (!serialized) return [];
|
|
try {
|
|
const value: unknown = JSON.parse(serialized);
|
|
if (!Array.isArray(value)) return [];
|
|
return limitPresets(value.filter((item): item is TeamPreset => {
|
|
if (!item || typeof item !== "object") return false;
|
|
const candidate = item as Record<string, unknown>;
|
|
return typeof candidate.id === "string" && typeof candidate.name === "string" &&
|
|
typeof candidate.createdAt === "string" && Boolean(candidate.team);
|
|
}));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function teamPresetStorageKey(guideId: string): string {
|
|
return `buzz-sheet:team-presets:${guideId}`;
|
|
}
|
|
|
|
export function teamPresetCookieKey(guideId: string): string {
|
|
return `buzz-sheet-team-presets-${guideId}`;
|
|
}
|