import { maximumWeightMatching } from './maximum-weight-matching.ts'; type OptimizableBoardEffect = { id?: string; type: string; value: number | null; trigger?: string | null; }; type OptimizableBoardNode = { characterIds: readonly string[]; type: string; effect: OptimizableBoardEffect | null; }; type OptimizableBoardPosition = { groupId: string; x: number; y: number }; export type BoardConnectionOptimizerSource = { members: readonly { id: string; positionGroupId: string }[]; models: Readonly>; nodeGroups: Readonly>; }; export type OptimizableConnectionCard = { id: string; awakening: number; connect: readonly { level: number; effectPermilUp: number; extent: readonly { x: number; y: number }[]; }[]; }; export type BoardConnectionAssignment = { memberId: string; socketId: string; cardId: string; weight: number; }; export type BoardConnectionOptimizationResult = { placements: Record>; assignments: BoardConnectionAssignment[]; totalWeight: number; soloSongBonusPercent: number; groupSongBonusPercent: number; }; export type BoardConnectionOptimizationOptions = { targetMemberId?: string; requireSoloSongBonus?: boolean; requireGroupSongBonus?: boolean; placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number; prioritizePlacementWeight?: boolean; groupSingerMemberIds?: readonly string[]; }; function nodeForMember( source: BoardConnectionOptimizerSource, groupId: string, memberId: string ): OptimizableBoardNode | null { const nodes = source.nodeGroups[groupId] ?? []; return nodes.find((node) => node.characterIds.includes(memberId)) ?? nodes.find((node) => node.characterIds.length === 0) ?? null; } function effectValue(effect: OptimizableBoardEffect | null): number { if (!effect || effect.value === null || effect.trigger === 'work') return 0; if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') return 0; // Song bonuses are contextual and are valued only when the user requests // the corresponding solo/group target below. if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return 0; const value = Math.max(0, Number(effect.value) || 0); return effect.type.startsWith('all_parameter_up') ? value * 3 : value; } function selectedEffect(card: OptimizableConnectionCard) { const level = card.awakening >= 5 ? 2 : 1; return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null; } function songBonusModes( effect: OptimizableBoardEffect | null, ownerId: string, targetMemberId: string, groupSingerMemberIds?: readonly string[] ): ('solo' | 'group')[] { if (!effect?.id || effect.type !== 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return []; const mode = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1]; if (mode === 'all') return ['solo', 'group']; if (mode === 'solo' && ownerId === targetMemberId) return ['solo']; // A unit song bonus may be supplied by another participating singer's board. if (mode === 'group' && (!groupSingerMemberIds || groupSingerMemberIds.includes(ownerId))) return ['group']; return []; } /** * Reassigns owned connection cards across active connection sockets without * changing the supplied node activation state. */ export function optimizeBoardConnectionCards( source: BoardConnectionOptimizerSource, memberBoards: Readonly>, ownedCards: readonly OptimizableConnectionCard[], options: BoardConnectionOptimizationOptions = {} ): BoardConnectionOptimizationResult { const targetMemberId = options.targetMemberId ?? ''; let baseSoloBonus = 0; let baseGroupBonus = 0; const slots = source.members.flatMap((member) => { const positions = source.models[member.positionGroupId] ?? []; const centerId = positions.find((position) => position.x === 0 && position.y === 0)?.groupId; const active = new Set(memberBoards[member.id] ?? []); if (centerId) active.add(centerId); for (const position of positions) { if (!active.has(position.groupId)) continue; const node = nodeForMember(source, position.groupId, member.id); const value = Math.max(0, Number(node?.effect?.value) || 0); for (const mode of songBonusModes(node?.effect ?? null, member.id, targetMemberId, options.groupSingerMemberIds)) { if (mode === 'solo') baseSoloBonus += value; else baseGroupBonus += value; } } return positions .filter((position) => active.has(position.groupId) && nodeForMember(source, position.groupId, member.id)?.type === 'connection') .map((socket) => ({ member, socket, positions, active })); }); const cards = ownedCards.filter((card) => selectedEffect(card)); const emptyResult = { placements: {}, assignments: [], totalWeight: 0, soloSongBonusPercent: Math.min(10, baseSoloBonus / 10), groupSongBonusPercent: Math.min(10, baseGroupBonus / 10) }; if (cards.length === 0 || slots.length === 0) return emptyResult; const soloBoosts = cards.map(() => slots.map(() => 0)); const groupBoosts = cards.map(() => slots.map(() => 0)); const genericWeights = cards.map((card, cardIndex) => slots.map(({ member, socket, positions, active }, slotIndex) => { const effect = selectedEffect(card); if (!effect) return 0; const positionByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position])); const coveredValue = effect.extent.reduce((total, offset) => { const position = positionByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`); if (!position || !active.has(position.groupId)) return total; const nodeEffect = nodeForMember(source, position.groupId, member.id)?.effect ?? null; const value = effectValue(nodeEffect); const boost = Math.max(0, Number(nodeEffect?.value) || 0) * Math.max(0, effect.effectPermilUp); for (const mode of songBonusModes(nodeEffect, member.id, targetMemberId, options.groupSingerMemberIds)) { if (mode === 'solo') soloBoosts[cardIndex][slotIndex] += boost; else groupBoosts[cardIndex][slotIndex] += boost; } return total + value; }, 0); const defaultWeight = coveredValue * Math.max(0, effect.effectPermilUp); return Math.max(0, options.placementWeight?.(card, member.id, socket.groupId) ?? defaultWeight); })); const priorityUnits = cards.map((card) => slots.map(({ member, socket }) => Math.max(0, Math.round((options.placementPriorityWeight?.(card, member.id, socket.groupId) ?? 0) * 1000)) )); const maximumAssignments = Math.min(cards.length, slots.length); const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments; const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) => priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight )); const priorityScale = Math.max(1, ...weights.flatMap((row) => row)) * Math.max(cards.length, slots.length) + 1; const ratios = options.requireSoloSongBonus && options.requireGroupSongBonus ? [1 / 256, 1 / 16, 1 / 4, 1, 4, 16, 256] : [1]; const priorityMultipliers = options.requireSoloSongBonus || options.requireGroupSongBonus ? [0, 1 / 1_000_000, 1 / 10_000, 1 / 100, 1, 100] : [0]; const candidates = priorityMultipliers.flatMap((priorityMultiplier) => ratios.map((ratio) => { const prioritized = weights.map((row, cardIndex) => row.map((weight, slotIndex) => weight + priorityScale * ( (options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0) + (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0) ) * priorityMultiplier)); const matches = maximumWeightMatching(prioritized); const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0); const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0); const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0); const feasible = (!options.requireSoloSongBonus || soloBonus >= 100) && (!options.requireGroupSongBonus || groupBonus >= 100); const fulfillment = Math.min( options.requireSoloSongBonus ? soloBonus / 100 : Number.POSITIVE_INFINITY, options.requireGroupSongBonus ? groupBonus / 100 : Number.POSITIVE_INFINITY ); const totalFulfillment = (options.requireSoloSongBonus ? Math.min(1, soloBonus / 100) : 0) + (options.requireGroupSongBonus ? Math.min(1, groupBonus / 100) : 0); return { matches, genericWeight, soloBonus, groupBonus, feasible, fulfillment, totalFulfillment }; })); const best = candidates.sort((left, right) => options.prioritizePlacementWeight ? right.genericWeight - left.genericWeight || Number(right.feasible) - Number(left.feasible) || right.fulfillment - left.fulfillment || right.totalFulfillment - left.totalFulfillment : Number(right.feasible) - Number(left.feasible) || right.fulfillment - left.fulfillment || right.totalFulfillment - left.totalFulfillment || right.genericWeight - left.genericWeight)[0]; const matches = best.matches; const placements: Record> = {}; const assignments = matches.map((match) => { const card = cards[match.rowIndex]; const slot = slots[match.columnIndex]; (placements[slot.member.id] ??= {})[slot.socket.groupId] = card.id; return { memberId: slot.member.id, socketId: slot.socket.groupId, cardId: card.id, weight: genericWeights[match.rowIndex][match.columnIndex] }; }); return { placements, assignments, totalWeight: assignments.reduce((total, assignment) => total + assignment.weight, 0), soloSongBonusPercent: Math.min(10, best.soloBonus / 10), groupSongBonusPercent: Math.min(10, best.groupBonus / 10) }; }