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
+7 -1
View File
@@ -1,7 +1,13 @@
--- ---
updatedAt: 2026-09-28T00:00:00Z updatedAt: 2026-09-29T00:00:00Z
--- ---
## 2026년 9월 29일
### 밀어치기 자동 판정 제외
`FIX` 롱노트 중간·마지막 자동 판정을 밀어치기 대상에서 제외했습니다. 스페셜 시작과 정확히 겹친 자동 판정은 새 스페셜 효과를 받지 않으며, 최고점·최적 배치·달성 확률에 동일하게 반영합니다.
## 2026년 9월 28일 ## 2026년 9월 28일
### 도메인 이전 안내 제거 ### 도메인 이전 안내 제거
+5 -2
View File
@@ -125,6 +125,7 @@ function flattenEvents(chartData) {
const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock]; const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock];
return block.map((note, index) => ({ return block.map((note, index) => ({
...note, ...note,
isHoldEnd: index > 0 && index === block.length - 1,
// 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다. // 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다.
type: (index === 0 || index === block.length - 1) && type: (index === 0 || index === block.length - 1) &&
note.note_type !== 'mid' && note.note_type !== 'mid' &&
@@ -141,13 +142,15 @@ function flattenEvents(chartData) {
...pink.map((note) => ({ ...pink.map((note) => ({
...note, ...note,
type: note.note_type ?? 'regular', type: note.note_type ?? 'regular',
is_flick: !isHoldEndFlick(note) is_flick: !isHoldEndFlick(note),
isHoldEnd: isHoldEndFlick(note)
})) }))
] ]
.map((note) => ({ .map((note) => ({
timeSeconds: note.time_seconds, timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular', type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true isFlick: note.is_flick === true,
...(note.isHoldEnd ? { isHoldEnd: true } : {})
})) }))
.filter((note) => Number.isFinite(note.timeSeconds)) .filter((note) => Number.isFinite(note.timeSeconds))
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1)); .sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
+1 -1
View File
@@ -156,7 +156,7 @@ export function parseSusTimeline(text) {
for (let beat = (Math.floor(start.beat * 2 + 1e-9) + 1) / 2; beat < end.beat - 1e-9; beat += 0.5) { for (let beat = (Math.floor(start.beat * 2 + 1e-9) + 1) / 2; beat < end.beat - 1e-9; beat += 0.5) {
notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false }); notes.push({ timeSeconds: secondsAt(beat), type: 'mid', isFlick: false });
} }
notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false }); notes.push({ timeSeconds: secondsAt(end.beat), type: 'regular', isFlick: false, isHoldEnd: true });
} }
// Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment. // Visible relay ticks are score judgements even if the editor stream contains an unmatched visual segment.
for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false }); for (const relay of visibleRelays) notes.push({ timeSeconds: secondsAt(relay.beat), type: 'mid', isFlick: false });
+1 -1
View File
@@ -34,7 +34,7 @@ test('끝 플릭을 중복 집계하지 않고 중간 판정을 곡의 반박
{ timeSeconds:0.5,type:'mid',isFlick:false }, { timeSeconds:0.5,type:'mid',isFlick:false },
{ timeSeconds:0.75,type:'mid',isFlick:false }, { timeSeconds:0.75,type:'mid',isFlick:false },
{ timeSeconds:1,type:'mid',isFlick:false }, { timeSeconds:1,type:'mid',isFlick:false },
{ timeSeconds:1.25,type:'regular',isFlick:false }, { timeSeconds:1.25,type:'regular',isFlick:false,isHoldEnd:true },
{ timeSeconds:2,type:'regular',isFlick:true }, { timeSeconds:2,type:'regular',isFlick:true },
{ timeSeconds:3.75,type:'regular',isFlick:false } { timeSeconds:3.75,type:'regular',isFlick:false }
]); ]);
+2 -2
View File
@@ -1,5 +1,5 @@
import { effectiveActiveProbability } from './active-probability.ts'; 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 { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.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> { function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
const profiles = new 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); const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => { order.forEach((card, cardIndex) => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { effectiveActiveProbability } from './active-probability.ts'; import { effectiveActiveProbability } from './active-probability.ts';
import { specialScoreSupportForNote } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts'; import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import type { import type {
@@ -76,7 +77,7 @@ function prepareModel(
scoreNotes(chart).forEach((note, noteIndex) => { scoreNotes(chart).forEach((note, noteIndex) => {
const candidates: NoteCandidate[] = []; const candidates: NoteCandidate[] = [];
const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent'); const specialSupport = specialScoreSupportForNote(cards, chart, note);
cards.forEach((card, cardIndex) => { cards.forEach((card, cardIndex) => {
const reduction = reductions[card.id] ?? 0; const reduction = reductions[card.id] ?? 0;
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
+7 -2
View File
@@ -94,6 +94,10 @@ function activationRateBonusAt(
: 0), 0); : 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( function activeCandidates(
cardsInOrder: readonly OptimizerCard[], cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>, reductions: Readonly<Record<string, CooldownReduction>>,
@@ -160,11 +164,12 @@ export function calculateExactScore(
? Math.ceil(options.perfectNoteScore * 0.1) ? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1); : options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo)); 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 baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus); const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), 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); const withoutStart = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows, true);
specialStartBonus += theoreticalMaxScore - finalNoteScore(rawBaselineScore * (1 + (withoutStart[0]?.boost ?? 0)), directBonus); 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 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 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}); 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); 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; timeSeconds: number;
type: 'regular' | 'mid'; type: 'regular' | 'mid';
isFlick: boolean; isFlick: boolean;
/** 롱노트 종료 자동 판정. 점수 가중치는 regular와 같지만 밀어치기는 불가능하다. */
isHoldEnd?: boolean;
}; };
export type ExactSongChart = { export type ExactSongChart = {
+1 -1
View File
@@ -1204,7 +1204,7 @@
{/if} {/if}
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div> </div>
<p class="probability-guide">두 최고점은 모두 PERFECT 기준입니다. 밀어치기 적용은 스페셜 시작과 같은 시각의 노트를 발동 이후에 처리하여 새 스페셜 효과를 받는 경우, 미적용은 해당 노트가 새 효과를 받지 않는 경우입니다. 일부만 적용되면 두 점수 사이가 됩니다. 예상 점수·그래프·달성 확률은 밀어치기 적용 기준입니다.</p> <p class="probability-guide">두 최고점은 모두 PERFECT 기준입니다. 밀어치기 적용은 스페셜 시작과 같은 시각의 수동 판정 노트를 발동 이후에 처리하여 새 스페셜 효과를 받는 경우, 미적용은 해당 노트가 새 효과를 받지 않는 경우입니다. 롱노트 중간·마지막 자동 판정은 밀어치기가 불가능하므로 두 경우 모두 시작 경계의 새 스페셜 효과를 받지 않습니다. 일부 수동 판정만 밀어치기하면 두 점수 사이가 됩니다. 예상 점수·그래프·달성 확률은 밀어치기 적용 기준입니다.</p>
{#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)} {#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
<div class="probability-grid"> <div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div> <div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More