Files
buzz-sheet/lib/guides/team-tools.ts
T
gunshiz 9896e8a4ad
CI / Verify (push) Successful in 1m35s
CI / Build immutable images and deploy (push) Successful in 5m27s
feat(guides) : add safer publishing and discovery
2026-09-19 13:32:57 +07:00

287 lines
9.2 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: number;
refinement: number;
damage: string;
}
export interface TeamToolValue {
clientId: string;
title: string;
rotationSeconds: string;
combo: string;
members: TeamToolMember[];
}
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.number().int().min(0).max(6),
refinement: z.number().int().min(1).max(5),
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 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}`;
}