Publish GitHub Pages output / publish (push) Successful in 34s
카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다. 준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다. 업데이트 문구를 한 항목으로 통합합니다.
478 lines
23 KiB
TypeScript
478 lines
23 KiB
TypeScript
import { rankExactCooldownCombinations as rankExpectedCooldownCombinations } from './exact-optimizer-expected.ts';
|
|
import { effectiveActiveProbability } from './active-probability.ts';
|
|
import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts';
|
|
import { passiveScoreSupportByCard } from './passive-support.ts';
|
|
import { effectiveCooldownSeconds } from './timeline.ts';
|
|
import { COOLDOWN_REDUCTIONS } from './types.ts';
|
|
import type {
|
|
CooldownReduction,
|
|
EffectiveSkillWindow,
|
|
ExactOptimizationResult,
|
|
ExactOptimizeOptions,
|
|
ExactSongChart,
|
|
OptimizerCard,
|
|
SkillWindow
|
|
} from './types.ts';
|
|
|
|
type CandidateProfile = {
|
|
boost: Float64Array;
|
|
probability: Float64Array;
|
|
};
|
|
|
|
type NoteGroup = { note: ExactSongChart['notes'][number]; comboBonus: number; count: number };
|
|
|
|
/** Keep exact timing, note semantics and the original combo tier. */
|
|
export function groupExactOptimizationNotes(chart: ExactSongChart): NoteGroup[] {
|
|
const groups = new Map<string, NoteGroup>();
|
|
chart.notes.forEach((note, index) => {
|
|
const bonus = comboBonus(index + 1);
|
|
const key = `${note.timeSeconds}|${note.type === 'mid'}|${Boolean(note.isFlick)}|${Boolean(note.isHoldEnd)}|${bonus}`;
|
|
const group = groups.get(key);
|
|
if (group) group.count += 1;
|
|
else groups.set(key, { note, comboBonus: bonus, count: 1 });
|
|
});
|
|
return [...groups.values()];
|
|
}
|
|
|
|
function optimizationNotes(cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions): NoteGroup[] {
|
|
// Grouped integer additions are exact only within Number's integer precision.
|
|
// For unusually large/invalid input, retain every note in its original order.
|
|
const supportMagnitude = cards.reduce((sum, card) => sum + Math.abs(card.special.scoreUpPercent) +
|
|
Math.abs(card.scoreSupportPercent ?? 0) + (card.passiveScoreSupportEffects ?? []).reduce((n, effect) => n + Math.abs(effect.percent), 0), 0);
|
|
const boostMagnitude = Math.max(...cards.map((card) => Math.abs(card.active.scoreUpPercent))) / 100 * (1 + supportMagnitude / 100);
|
|
const totalMagnitude = ((Math.abs(options.perfectNoteScore) + 1) * 1.155 * (1 + boostMagnitude) *
|
|
Math.abs(1 + (options.directSongScoreBonusPercent ?? 0) / 100) + 1) * chart.notes.length;
|
|
return Number.isFinite(totalMagnitude) && totalMagnitude < Number.MAX_SAFE_INTEGER / 2
|
|
? groupExactOptimizationNotes(chart)
|
|
: chart.notes.map((note, index) => ({ note, comboBonus: comboBonus(index + 1), count: 1 }));
|
|
}
|
|
|
|
function permutations<T>(items: readonly T[]): T[][] {
|
|
if (items.length <= 1) return [Array.from(items)];
|
|
const result: T[][] = [];
|
|
for (let index = 0; index < items.length; index += 1) {
|
|
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
|
|
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function assignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
|
|
let result: Array<Record<string, CooldownReduction>> = [{}];
|
|
for (const card of cards) {
|
|
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
|
|
result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction })));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
|
|
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
|
|
}
|
|
|
|
function specialRate(
|
|
order: readonly OptimizerCard[],
|
|
chart: ExactSongChart,
|
|
time: number,
|
|
field: 'scoreUpPercent' | 'activationRateUpPercent'
|
|
): number {
|
|
let total = 0;
|
|
for (let slot = 0; slot < order.length; slot += 1) {
|
|
const card = order[slot];
|
|
if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function comboBonus(combo: number): number {
|
|
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
|
|
}
|
|
|
|
function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array {
|
|
return Float64Array.from(groups, ({ note, comboBonus }) => {
|
|
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
|
|
return base * (1 + comboBonus);
|
|
});
|
|
}
|
|
|
|
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map<string, CandidateProfile> {
|
|
const profiles = new Map<string, CandidateProfile>();
|
|
const noteSupport = Float64Array.from(groups, ({ note }) => specialScoreSupportForNote(order, chart, note));
|
|
const passiveSupport = passiveScoreSupportByCard(order);
|
|
|
|
order.forEach((card, cardIndex) => {
|
|
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
|
|
reductions.forEach((reduction) => {
|
|
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
|
|
const boost = new Float64Array(groups.length);
|
|
const probability = new Float64Array(groups.length);
|
|
const probabilityByAttempt = new Map<number, number>();
|
|
groups.forEach(({ note }, noteIndex) => {
|
|
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
|
|
if (attempt <= 0) return;
|
|
const activationTime = attempt * interval;
|
|
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
|
|
let effectiveProbability = probabilityByAttempt.get(attempt);
|
|
if (effectiveProbability === undefined) {
|
|
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
|
|
effectiveProbability = effectiveActiveProbability(card, activationBonus);
|
|
probabilityByAttempt.set(attempt, effectiveProbability);
|
|
}
|
|
boost[noteIndex] = card.active.scoreUpPercent / 100 *
|
|
(1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100);
|
|
probability[noteIndex] = effectiveProbability;
|
|
});
|
|
profiles.set(`${card.id}:${reduction}`, { boost, probability });
|
|
});
|
|
});
|
|
return profiles;
|
|
}
|
|
|
|
export function buildExactSkillWindows(
|
|
order: readonly OptimizerCard[],
|
|
reductions: Readonly<Record<string, CooldownReduction>>,
|
|
chart: ExactSongChart,
|
|
excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = []
|
|
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
|
|
const activeWindows = order.flatMap((card) => {
|
|
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
|
|
const windows: SkillWindow[] = [];
|
|
for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) {
|
|
const startSeconds = activation * interval;
|
|
windows.push({
|
|
cardId: card.id,
|
|
startSeconds,
|
|
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds),
|
|
scoreUpPercent: card.active.scoreUpPercent
|
|
});
|
|
}
|
|
return windows;
|
|
});
|
|
const specialWindows = order.flatMap((card, slot) => {
|
|
const startSeconds = chart.specialStartSeconds[slot];
|
|
if (startSeconds === null || startSeconds === undefined) return [];
|
|
return [{
|
|
cardId: card.id,
|
|
startSeconds,
|
|
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
|
|
scoreUpPercent: card.special.scoreUpPercent
|
|
}];
|
|
});
|
|
const passiveSupport = passiveScoreSupportByCard(order);
|
|
const orderIndex = new Map(order.map((card, index) => [card.id, index]));
|
|
const boundaries = Array.from(new Set([
|
|
...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]),
|
|
...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds])
|
|
])).sort((left, right) => left - right);
|
|
const effectiveActiveWindows: EffectiveSkillWindow[] = [];
|
|
|
|
for (let index = 0; index < boundaries.length - 1; index += 1) {
|
|
const startSeconds = boundaries[index];
|
|
const endSeconds = boundaries[index + 1];
|
|
if (endSeconds - startSeconds < 0.01) continue;
|
|
const midpoint = (startSeconds + endSeconds) / 2;
|
|
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
|
|
const active = activeWindows
|
|
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds &&
|
|
!excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9))
|
|
.map((window) => {
|
|
const cardIndex = orderIndex.get(window.cardId) ?? 0;
|
|
const card = order[cardIndex];
|
|
return {
|
|
window,
|
|
effectivePercent: window.scoreUpPercent *
|
|
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
|
|
};
|
|
})
|
|
.sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99));
|
|
const winner = active[0];
|
|
if (!winner) continue;
|
|
const previous = effectiveActiveWindows.at(-1);
|
|
if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) {
|
|
previous.endSeconds = endSeconds;
|
|
previous.overlapCount = Math.max(previous.overlapCount, active.length);
|
|
} else {
|
|
effectiveActiveWindows.push({
|
|
...winner.window,
|
|
startSeconds,
|
|
endSeconds,
|
|
baseScoreUpPercent: winner.window.scoreUpPercent,
|
|
scoreUpPercent: winner.effectivePercent,
|
|
overlapCount: active.length
|
|
});
|
|
}
|
|
}
|
|
return { activeWindows, effectiveActiveWindows, specialWindows };
|
|
}
|
|
|
|
|
|
/** Share prefix maxima across all cooldown assignments; round each note exactly as before. */
|
|
function scoreMaximumAssignments(
|
|
cards: readonly OptimizerCard[], profiles: ReadonlyMap<string, CandidateProfile>,
|
|
base: Float64Array, directBonus: number, counts: Float64Array
|
|
): Float64Array | null {
|
|
if (!Number.isFinite(directBonus) || directBonus < -100 || base.some(value => !Number.isFinite(value) || value < 0)) return null;
|
|
const choices = cards.map(card => (card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS)
|
|
.map(reduction => profiles.get(card.id + ':' + reduction)!));
|
|
if (choices.some(row => row.some(profile => !profile || profile.boost.some(value => !Number.isFinite(value))))) return null;
|
|
const widths = choices.map(row => row.length);
|
|
const sizes = [1]; for (const width of widths) sizes.push(sizes.at(-1)! * width);
|
|
const totals = new Float64Array(sizes.at(-1)!);
|
|
const prefix = sizes.slice(0, -1).map(size => new Float64Array(size));
|
|
const offsets = [1]; for (const width of widths) offsets.push(offsets.at(-1)! + width);
|
|
// Notes with the same rounded scores for every selectable profile make identical contributions.
|
|
const states = new Map<string, { scores: number[]; count: number }>();
|
|
let magnitude = 0;
|
|
for (let noteIndex = 0; noteIndex < base.length; noteIndex++) {
|
|
const baseline = finalNoteScore(base[noteIndex], directBonus);
|
|
const scores = [baseline];
|
|
for (const row of choices) for (const profile of row) {
|
|
scores.push(finalNoteScore(base[noteIndex] * (1 + Math.max(0, profile.boost[noteIndex])), directBonus));
|
|
}
|
|
const largest = Math.max(...scores);
|
|
magnitude += largest * counts[noteIndex];
|
|
if (!Number.isSafeInteger(largest) || magnitude >= Number.MAX_SAFE_INTEGER / 2) return null;
|
|
const key = scores.join(','); const previous = states.get(key);
|
|
if (previous) previous.count += counts[noteIndex]; else states.set(key, { scores, count: counts[noteIndex] });
|
|
}
|
|
for (const { scores, count } of states.values()) {
|
|
prefix[0][0] = scores[0];
|
|
for (let member = 0; member < cards.length - 1; member++) {
|
|
const previous = prefix[member], next = prefix[member + 1], width = widths[member], offset = offsets[member];
|
|
let index = 0;
|
|
for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) {
|
|
next[index++] = Math.max(previous[p], scores[offset + choice]);
|
|
}
|
|
}
|
|
const member = cards.length - 1, previous = prefix[member], width = widths[member], offset = offsets[member];
|
|
let index = 0;
|
|
for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) {
|
|
totals[index++] += Math.max(previous[p], scores[offset + choice]) * count;
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
function scoreMaximumAssignment(
|
|
cards: readonly OptimizerCard[],
|
|
reductions: Readonly<Record<string, CooldownReduction>>,
|
|
profiles: ReadonlyMap<string, CandidateProfile>,
|
|
base: Float64Array,
|
|
directBonus: number,
|
|
counts: Float64Array,
|
|
selected: CandidateProfile[]
|
|
): number {
|
|
cards.forEach((card, index) => {
|
|
const profile = profiles.get(`${card.id}:${reductions[card.id] ?? 0}`);
|
|
if (!profile) throw new Error(`${card.name}의 빈도 조합 프로파일이 없습니다.`);
|
|
selected[index] = profile;
|
|
});
|
|
let total = 0;
|
|
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
|
|
let largest = 0;
|
|
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
|
|
const boost = selected[memberIndex].boost[noteIndex];
|
|
if (boost <= largest) continue;
|
|
// Invalid numerical inputs retain the original combined evaluator.
|
|
if (!Number.isFinite(boost)) return NaN;
|
|
largest = boost;
|
|
}
|
|
total += finalNoteScore(base[noteIndex] * (1 + largest), directBonus) * counts[noteIndex];
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function scoreAssignment(
|
|
cards: readonly OptimizerCard[],
|
|
reductions: Readonly<Record<string, CooldownReduction>>,
|
|
profiles: ReadonlyMap<string, CandidateProfile>,
|
|
base: Float64Array,
|
|
directBonus: number,
|
|
counts: Float64Array,
|
|
selected: CandidateProfile[],
|
|
boosts: Float64Array,
|
|
probabilities: Float64Array
|
|
): { expectedScore: number; theoreticalMaxScore: number } {
|
|
cards.forEach((card, index) => {
|
|
const reduction = reductions[card.id] ?? 0;
|
|
const profile = profiles.get(`${card.id}:${reduction}`);
|
|
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
|
|
selected[index] = profile;
|
|
});
|
|
let expectedScore = 0;
|
|
let theoreticalMaxScore = 0;
|
|
|
|
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
|
|
let count = 0;
|
|
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
|
|
const boost = selected[memberIndex].boost[noteIndex];
|
|
if (boost <= 0) continue;
|
|
let position = count;
|
|
while (position > 0 && boosts[position - 1] < boost) {
|
|
boosts[position] = boosts[position - 1];
|
|
probabilities[position] = probabilities[position - 1];
|
|
position -= 1;
|
|
}
|
|
boosts[position] = boost;
|
|
probabilities[position] = selected[memberIndex].probability[noteIndex];
|
|
count += 1;
|
|
}
|
|
let expectedBoost = 0;
|
|
let previousFailed = 1;
|
|
for (let position = 0; position < count; position += 1) {
|
|
expectedBoost += previousFailed * probabilities[position] * boosts[position];
|
|
previousFailed *= 1 - probabilities[position];
|
|
}
|
|
// Round each original note before applying the group's multiplicity.
|
|
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus) * counts[noteIndex];
|
|
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus) * counts[noteIndex];
|
|
}
|
|
return { expectedScore, theoreticalMaxScore };
|
|
}
|
|
|
|
export function optimizeExactOrderAndCooldowns(
|
|
cards: readonly OptimizerCard[],
|
|
chart: ExactSongChart,
|
|
options: ExactOptimizeOptions
|
|
): ExactOptimizationResult {
|
|
return rankExactCooldownCombinations(cards, chart, options, 1)[0];
|
|
}
|
|
|
|
/** Keep the best special order for each distinct per-card cooldown assignment. */
|
|
export function rankExactCooldownCombinations(
|
|
cards: readonly OptimizerCard[],
|
|
chart: ExactSongChart,
|
|
options: ExactOptimizeOptions,
|
|
limit = 5
|
|
) : ExactOptimizationResult[] {
|
|
if (options.objective === 'expected') return rankExpectedCooldownCombinations(cards, chart, options, limit);
|
|
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
|
|
throw new Error('서로 다른 카드 5장이 필요합니다.');
|
|
}
|
|
const reductionAssignments = assignments(cards);
|
|
const reductionTotals = new Map(reductionAssignments.map((reductions) => [reductions,
|
|
Object.values(reductions).reduce<number>((sum, value) => sum + value, 0)]));
|
|
const groups = optimizationNotes(cards, chart, options);
|
|
const base = baseScores(groups, options.perfectNoteScore);
|
|
const counts = Float64Array.from(groups, (group) => group.count);
|
|
// Local to this call: callbacks may recursively invoke the optimizer.
|
|
const selected: CandidateProfile[] = [];
|
|
const boosts = new Float64Array(cards.length);
|
|
const probabilities = new Float64Array(cards.length);
|
|
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
|
|
const totalCandidates = orderCount * reductionAssignments.length;
|
|
let evaluatedCandidates = 0;
|
|
type Candidate = {
|
|
order: OptimizerCard[];
|
|
reductions: Record<string, CooldownReduction>;
|
|
theoreticalMaxScore: number;
|
|
expectedScore: number | undefined;
|
|
reductionTotal: number;
|
|
ties?: Candidate[];
|
|
};
|
|
const bestByAssignment = new Map<Record<string, CooldownReduction>, Candidate>();
|
|
const maximumObjective = true;
|
|
// Keep a bounded number of order profiles for deferred expected-score ties.
|
|
const profileCache = new Map<readonly OptimizerCard[], Map<string, CandidateProfile>>();
|
|
let activeOrder: readonly OptimizerCard[] | null = null;
|
|
let activeProfiles: Map<string, CandidateProfile> | null = null;
|
|
const profilesFor = (order: readonly OptimizerCard[]): Map<string, CandidateProfile> => {
|
|
if (!maximumObjective) return makeProfiles(order, chart, groups);
|
|
let profiles = profileCache.get(order);
|
|
if (profiles) profileCache.delete(order);
|
|
else profiles = makeProfiles(order, chart, groups);
|
|
profileCache.set(order, profiles);
|
|
if (profileCache.size > 8) profileCache.delete(profileCache.keys().next().value!);
|
|
return profiles;
|
|
};
|
|
const expectedFor = (candidate: Candidate): number => {
|
|
if (candidate.expectedScore === undefined) {
|
|
const profiles = candidate.order === activeOrder && activeProfiles ? activeProfiles : profilesFor(candidate.order);
|
|
candidate.expectedScore = scoreAssignment(candidate.order, candidate.reductions, profiles, base,
|
|
options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities).expectedScore;
|
|
}
|
|
return candidate.expectedScore;
|
|
};
|
|
const compare = (left: Candidate, right: Candidate): number =>
|
|
(options.objective === 'expected'
|
|
? expectedFor(right) - expectedFor(left) || right.theoreticalMaxScore - left.theoreticalMaxScore
|
|
: right.theoreticalMaxScore - left.theoreticalMaxScore || expectedFor(right) - expectedFor(left)) ||
|
|
left.reductionTotal - right.reductionTotal;
|
|
|
|
const maximumCache = new Map<string, Float64Array>();
|
|
for (const order of permutations(cards)) {
|
|
const passive = passiveScoreSupportByCard(order);
|
|
const supportById = new Map(order.map((card, index) => [card.id, passive[index]]));
|
|
const inputs = [
|
|
...order.flatMap((card) => [card.special.durationSeconds, card.special.scoreUpPercent]),
|
|
...cards.flatMap((card) => [card.active.cooldownSeconds, card.active.durationSeconds,
|
|
card.active.scoreUpPercent, card.scoreSupportPercent ?? 0, supportById.get(card.id)!]),
|
|
options.directSongScoreBonusPercent ?? 0
|
|
];
|
|
const cacheable = inputs.every(Number.isFinite) && cards.every((card) => typeof card.id === 'string') &&
|
|
chart.specialStartSeconds.every((start) => start === null || start === undefined || Number.isFinite(start));
|
|
const signature = cacheable ? JSON.stringify([inputs, chart.specialStartSeconds]) : null;
|
|
let maxima = signature === null ? undefined : maximumCache.get(signature);
|
|
if (signature !== null && !maxima) {
|
|
maxima = new Float64Array(reductionAssignments.length).fill(NaN);
|
|
maximumCache.set(signature, maxima);
|
|
}
|
|
const profiles = profilesFor(order);
|
|
activeOrder = order;
|
|
activeProfiles = profiles;
|
|
if (!maxima || Number.isNaN(maxima[0])) {
|
|
const shared = scoreMaximumAssignments(cards, profiles, base, options.directSongScoreBonusPercent ?? 0, counts);
|
|
if (shared) { maxima = shared; if (signature !== null) maximumCache.set(signature, shared); }
|
|
}
|
|
for (let assignmentIndex = 0; assignmentIndex < reductionAssignments.length; assignmentIndex += 1) {
|
|
const reductions = reductionAssignments[assignmentIndex];
|
|
let maximum = maxima?.[assignmentIndex];
|
|
if (maximum === undefined || Number.isNaN(maximum)) {
|
|
maximum = scoreMaximumAssignment(order, reductions, profiles, base,
|
|
options.directSongScoreBonusPercent ?? 0, counts, selected);
|
|
if (maxima && Number.isFinite(maximum)) maxima[assignmentIndex] = maximum;
|
|
}
|
|
let score: { theoreticalMaxScore: number; expectedScore: number | undefined } = maximumObjective
|
|
? { theoreticalMaxScore: maximum, expectedScore: undefined }
|
|
: scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0,
|
|
counts, selected, boosts, probabilities);
|
|
if (!Number.isFinite(score.theoreticalMaxScore)) score = scoreAssignment(order, reductions, profiles, base,
|
|
options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities);
|
|
const reductionTotal = reductionTotals.get(reductions)!;
|
|
const candidate = { ...score, order, reductions, reductionTotal };
|
|
const previous = bestByAssignment.get(reductions);
|
|
if (previous && (!Number.isFinite(candidate.theoreticalMaxScore) || !Number.isFinite(previous.theoreticalMaxScore))) {
|
|
if (compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate);
|
|
} else if (!previous || candidate.theoreticalMaxScore > previous.theoreticalMaxScore) {
|
|
bestByAssignment.set(reductions, candidate);
|
|
} else if (candidate.theoreticalMaxScore === previous.theoreticalMaxScore) {
|
|
// Resolve expected-score ties only if this assignment can reach the requested ranks.
|
|
(previous.ties ??= []).push(candidate);
|
|
}
|
|
evaluatedCandidates += 1;
|
|
}
|
|
options.onProgress?.({ evaluatedCandidates, totalCandidates });
|
|
}
|
|
|
|
const resultCount = Math.max(1, Math.floor(limit));
|
|
let ranked = [...bestByAssignment.values()];
|
|
if (Number.isFinite(resultCount) && ranked.every(candidate => Number.isFinite(candidate.theoreticalMaxScore))) {
|
|
const maxima = ranked.map(candidate => candidate.theoreticalMaxScore).sort((left, right) => right - left);
|
|
const cutoff = maxima[Math.min(maxima.length, resultCount) - 1];
|
|
ranked = ranked.filter(candidate => candidate.theoreticalMaxScore >= cutoff);
|
|
}
|
|
ranked = ranked.map(candidate => {
|
|
let best = candidate;
|
|
for (const tied of candidate.ties ?? []) if (compare(tied, best) < 0) best = tied;
|
|
return best;
|
|
});
|
|
return ranked.sort(compare).slice(0, resultCount).map((candidate) => ({
|
|
...calculateExactScore(candidate.order, candidate.reductions, chart, options),
|
|
order: candidate.order.map((card) => card.id),
|
|
formationOrder: cards.map((card) => card.id),
|
|
cooldownReductions: { ...candidate.reductions },
|
|
...buildExactSkillWindows(candidate.order, candidate.reductions, chart),
|
|
evaluatedCandidates
|
|
}));
|
|
}
|