fix: exclude automatic hold judgments from special-start timing bonus
Publish GitHub Pages output / publish (push) Successful in 40s

This commit is contained in:
2026-09-29 10:25:51 +09:00
parent eaf4aeadc5
commit 5783072b12
826 changed files with 859 additions and 828 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore, finalNoteScore } from './score.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';
@@ -68,7 +68,7 @@ function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Arr
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
const noteSupport = Float64Array.from(chart.notes, (note) => specialRate(order, chart, note.timeSeconds, 'scoreUpPercent'));
const noteSupport = Float64Array.from(chart.notes, (note) => specialScoreSupportForNote(order, chart, note));
const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { specialScoreSupportForNote } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import type {
@@ -76,7 +77,7 @@ function prepareModel(
scoreNotes(chart).forEach((note, noteIndex) => {
const candidates: NoteCandidate[] = [];
const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent');
const specialSupport = specialScoreSupportForNote(cards, chart, note);
cards.forEach((card, cardIndex) => {
const reduction = reductions[card.id] ?? 0;
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
+7 -2
View File
@@ -94,6 +94,10 @@ function activationRateBonusAt(
: 0), 0);
}
export function specialScoreSupportForNote(cards: readonly OptimizerCard[], chart: ExactSongChart, note: ExactSongChart['notes'][number]): number {
return specialSupportAt(cards, chart, note.timeSeconds, note.type === 'mid' || note.isHoldEnd === true);
}
function activeCandidates(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
@@ -160,11 +164,12 @@ export function calculateExactScore(
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows);
const automatic = note.type === 'mid' || note.isHoldEnd === true;
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows, automatic);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
if (chart.specialStartSeconds.some((start) => start != null && Math.abs(start - note.timeSeconds) < 1e-9)) {
if (!automatic && chart.specialStartSeconds.some((start) => start != null && Math.abs(start - note.timeSeconds) < 1e-9)) {
const withoutStart = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows, true);
specialStartBonus += theoreticalMaxScore - finalNoteScore(rawBaselineScore * (1 + (withoutStart[0]?.boost ?? 0)), directBonus);
}
+15 -1
View File
@@ -24,6 +24,20 @@ test('PSYCHO Hard 첨부 편성의 밀어치기 적용 및 미적용 점수를
const rows = [[30,11,110,14,115], [27,9,125,10,160], [15,6,110,12,135], [19,7,110,10,160], [23,8,120,11,145]];
const cards: OptimizerCard[] = rows.map(([cooldownSeconds,durationSeconds,scoreUpPercent,sd,sb],i) => ({id: String(i),name: String(i),active:{cooldownSeconds,durationSeconds,scoreUpPercent},special:{durationSeconds:sd,scoreUpPercent:sb},scoreSupportPercent:57}));
const result = calculateExactScore(cards, {'0':4,'1':12,'2':12,'3':12,'4':12}, chart, {perfectNoteScore:396815/chart.scoreRatio,directSongScoreBonusPercent:10});
assert.equal(result.theoreticalMaxScore, 4067011);
assert.equal(result.theoreticalMaxScore, 4060353);
assert.equal(result.withoutSpecialStartBonusScore, 4043015);
});
test('자동 중간·종료 판정은 경계 스페셜을 받지 않고 수동 노트만 밀어치기로 증가한다', () => {
const card: OptimizerCard = {id:'a',name:'a',active:{cooldownSeconds:1,durationSeconds:10,scoreUpPercent:100,probabilityPercent:100},special:{durationSeconds:2,scoreUpPercent:100}};
const chart: ExactSongChart = {songId:'automatic',difficulty:'hard',durationSeconds:5,scoreRatio:1,specialStartSeconds:[2],notes:[
{timeSeconds:2,type:'mid',isFlick:false},
{timeSeconds:2,type:'regular',isFlick:false,isHoldEnd:true},
{timeSeconds:2,type:'regular',isFlick:false},
{timeSeconds:2.5,type:'regular',isFlick:false,isHoldEnd:true}
]};
const result=calculateExactScore([card],{},chart,{perfectNoteScore:100});
assert.deepEqual(result.notes.map(n=>n.theoreticalMaxScore),[20,200,300,300]);
assert.equal(result.theoreticalMaxScore,820);
assert.equal(result.withoutSpecialStartBonusScore,720);
});
+2
View File
@@ -85,6 +85,8 @@ export type ExactChartNote = {
timeSeconds: number;
type: 'regular' | 'mid';
isFlick: boolean;
/** 롱노트 종료 자동 판정. 점수 가중치는 regular와 같지만 밀어치기는 불가능하다. */
isHoldEnd?: boolean;
};
export type ExactSongChart = {
+1 -1
View File
@@ -1204,7 +1204,7 @@
{/if}
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div>
<p class="probability-guide">두 최고점은 모두 PERFECT 기준입니다. 밀어치기 적용은 스페셜 시작과 같은 시각의 노트를 발동 이후에 처리하여 새 스페셜 효과를 받는 경우, 미적용은 해당 노트가 새 효과를 받지 않는 경우입니다. 일부만 적용되면 두 점수 사이가 됩니다. 예상 점수·그래프·달성 확률은 밀어치기 적용 기준입니다.</p>
<p class="probability-guide">두 최고점은 모두 PERFECT 기준입니다. 밀어치기 적용은 스페셜 시작과 같은 시각의 수동 판정 노트를 발동 이후에 처리하여 새 스페셜 효과를 받는 경우, 미적용은 해당 노트가 새 효과를 받지 않는 경우입니다. 롱노트 중간·마지막 자동 판정은 밀어치기가 불가능하므로 두 경우 모두 시작 경계의 새 스페셜 효과를 받지 않습니다. 일부 수동 판정만 밀어치기하면 두 점수 사이가 됩니다. 예상 점수·그래프·달성 확률은 밀어치기 적용 기준입니다.</p>
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>