feat: release version 1.0.7
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user