diff --git a/src/lib/engine/exact-optimizer.test.ts b/src/lib/engine/exact-optimizer.test.ts index 7aaad8e..507fda9 100644 --- a/src/lib/engine/exact-optimizer.test.ts +++ b/src/lib/engine/exact-optimizer.test.ts @@ -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); +}); diff --git a/src/lib/engine/exact-optimizer.ts b/src/lib/engine/exact-optimizer.ts index 129a5fe..23fbea9 100644 --- a/src/lib/engine/exact-optimizer.ts +++ b/src/lib/engine/exact-optimizer.ts @@ -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, 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 | 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((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; diff --git a/src/lib/engine/score.ts b/src/lib/engine/score.ts index 8c307d3..b3f43d6 100644 --- a/src/lib/engine/score.ts +++ b/src/lib/engine/score.ts @@ -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); }