카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다. 준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다. 업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
1 parent
f009a7fd6b
commit
da40ec4238
18 files changed
+1623
-467
No files matched your search
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -18,6 +19,34 @@ type CandidateProfile = {
|
||||
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[][] = [];
|
||||
@@ -59,26 +88,26 @@ function comboBonus(combo: number): number {
|
||||
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
|
||||
}
|
||||
|
||||
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
|
||||
return Float64Array.from(chart.notes, (note, index) => {
|
||||
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(index + 1));
|
||||
return base * (1 + comboBonus);
|
||||
});
|
||||
}
|
||||
|
||||
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
|
||||
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map<string, CandidateProfile> {
|
||||
const profiles = new Map<string, CandidateProfile>();
|
||||
const noteSupport = Float64Array.from(chart.notes, (note) => specialScoreSupportForNote(order, chart, note));
|
||||
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(chart.notes.length);
|
||||
const probability = new Float64Array(chart.notes.length);
|
||||
const boost = new Float64Array(groups.length);
|
||||
const probability = new Float64Array(groups.length);
|
||||
const probabilityByAttempt = new Map<number, number>();
|
||||
chart.notes.forEach((note, noteIndex) => {
|
||||
groups.forEach(({ note }, noteIndex) => {
|
||||
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
|
||||
if (attempt <= 0) return;
|
||||
const activationTime = attempt * interval;
|
||||
@@ -176,21 +205,100 @@ export function buildExactSkillWindows(
|
||||
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
|
||||
directBonus: number,
|
||||
counts: Float64Array,
|
||||
selected: CandidateProfile[],
|
||||
boosts: Float64Array,
|
||||
probabilities: Float64Array
|
||||
): { expectedScore: number; theoreticalMaxScore: number } {
|
||||
const selected = cards.map((card) => {
|
||||
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}% 감소 프로필이 없습니다.`);
|
||||
return profile;
|
||||
selected[index] = profile;
|
||||
});
|
||||
const boosts = new Float64Array(cards.length);
|
||||
const probabilities = new Float64Array(cards.length);
|
||||
let expectedScore = 0;
|
||||
let theoreticalMaxScore = 0;
|
||||
|
||||
@@ -215,8 +323,9 @@ function scoreAssignment(
|
||||
expectedBoost += previousFailed * probabilities[position] * boosts[position];
|
||||
previousFailed *= 1 - probabilities[position];
|
||||
}
|
||||
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus);
|
||||
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus);
|
||||
// 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 };
|
||||
}
|
||||
@@ -235,12 +344,21 @@ export function rankExactCooldownCombinations(
|
||||
chart: ExactSongChart,
|
||||
options: ExactOptimizeOptions,
|
||||
limit = 5
|
||||
): ExactOptimizationResult[] {
|
||||
) : 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 base = baseScores(chart, options.perfectNoteScore);
|
||||
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;
|
||||
@@ -248,30 +366,107 @@ export function rankExactCooldownCombinations(
|
||||
order: OptimizerCard[];
|
||||
reductions: Record<string, CooldownReduction>;
|
||||
theoreticalMaxScore: number;
|
||||
expectedScore: 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'
|
||||
? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore
|
||||
: right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) ||
|
||||
? 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 profiles = makeProfiles(order, chart);
|
||||
for (const reductions of reductionAssignments) {
|
||||
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
||||
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
|
||||
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 || compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate);
|
||||
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 });
|
||||
}
|
||||
|
||||
return [...bestByAssignment.values()].sort(compare).slice(0, Math.max(1, Math.floor(limit))).map((candidate) => ({
|
||||
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),
|
||||
|
||||
Reference in new issue
Block a user