fix: prioritize theoretical max score

This commit is contained in:
2026-08-31 04:15:51 +09:00
parent a06cacb91c
commit d85a4ddfbf
3 changed files with 65 additions and 9 deletions
+47
View File
@@ -83,3 +83,50 @@ test('스페셜 발동 순서를 탐색해 가장 높은 액티브를 강한 구
assert.equal(firstActiveWinner?.cardId, 'c');
assert.ok(Math.abs((firstActiveWinner?.scoreUpPercent ?? 0) - 242) < 1e-9);
});
test('예상 점수보다 이론상 고점을 우선해 스페셜 순서를 선택한다', () => {
const formation: OptimizerCard[] = [
{
id: 'active',
name: 'active',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 10 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
},
{
id: 'score-support',
name: 'score-support',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 1, scoreUpPercent: 200 },
allowedCooldownReductions: [0]
},
{
id: 'rate-support',
name: 'rate-support',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 1, scoreUpPercent: 0, activationRateUpPercent: 900 },
allowedCooldownReductions: [0]
},
...['filler-a', 'filler-b'].map((id): OptimizerCard => ({
id,
name: id,
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
}))
];
const targetChart: ExactSongChart = {
songId: 'maximum-priority-test',
difficulty: 'expert',
durationSeconds: 2,
scoreRatio: 1,
notes: [{ timeSeconds: 1.1, type: 'regular', isFlick: false }],
specialStartSeconds: [0.5, null, null, null, null]
};
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
assert.equal(result.order[0], 'score-support');
assert.equal(result.theoreticalMaxScore, 400);
assert.equal(result.expectedScore, 130);
});
+17 -8
View File
@@ -1,5 +1,5 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore } from './score.ts';
import { calculateExactScore, finalNoteScore } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts';
@@ -180,7 +180,7 @@ function scoreAssignment(
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number
): number {
): { expectedScore: number; theoreticalMaxScore: number } {
const selected = cards.map((card) => {
const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`);
@@ -189,7 +189,8 @@ function scoreAssignment(
});
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
let rawScore = 0;
let expectedScore = 0;
let theoreticalMaxScore = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let count = 0;
@@ -212,9 +213,10 @@ function scoreAssignment(
expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position];
}
rawScore += base[noteIndex] * (1 + expectedBoost);
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus);
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus);
}
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4);
return { expectedScore, theoreticalMaxScore };
}
export function optimizeExactOrderAndCooldowns(
@@ -230,7 +232,8 @@ export function optimizeExactOrderAndCooldowns(
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY;
let bestTheoreticalMaxScore = Number.NEGATIVE_INFINITY;
let bestExpectedScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
@@ -240,8 +243,14 @@ export function optimizeExactOrderAndCooldowns(
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);
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) {
bestScore = score;
const isHigherMaximum = score.theoreticalMaxScore > bestTheoreticalMaxScore;
const isHigherExpectedTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore > bestExpectedScore;
const isCheaperTie = score.theoreticalMaxScore === bestTheoreticalMaxScore &&
score.expectedScore === bestExpectedScore && reductionTotal < bestReductionTotal;
if (isHigherMaximum || isHigherExpectedTie || isCheaperTie) {
bestTheoreticalMaxScore = score.theoreticalMaxScore;
bestExpectedScore = score.expectedScore;
bestOrder = order;
bestReductions = reductions;
bestReductionTotal = reductionTotal;
+1 -1
View File
@@ -108,7 +108,7 @@ function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number
return expectedBoost;
}
function finalNoteScore(score: number, percent: number): number {
export function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}