Files
HolodoriCalc/src/lib/engine/board-connection-optimizer.ts
T
2026-08-21 19:06:55 +09:00

227 lines
10 KiB
TypeScript

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<Record<string, readonly OptimizableBoardPosition[]>>;
nodeGroups: Readonly<Record<string, readonly OptimizableBoardNode[]>>;
};
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<string, Record<string, string>>;
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<Record<string, readonly string[]>>,
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<string, Record<string, string>> = {};
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)
};
}