227 lines
10 KiB
TypeScript
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)
|
|
};
|
|
}
|