feat: compare scores with and without special-start timing bonus
Publish GitHub Pages output / publish (push) Successful in 31s
Publish GitHub Pages output / publish (push) Successful in 31s
This commit is contained in:
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-26T12:00:00Z
|
updatedAt: 2026-09-27T00:00:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026년 9월 27일
|
||||||
|
|
||||||
|
### 스페셜 경계 노트 점수 비교
|
||||||
|
|
||||||
|
`IMPROVED` 배치 분석에 밀어치기 적용·미적용 최고점을 함께 표시합니다. 스페셜 시작과 같은 시각의 노트가 새 스페셜 효과를 받는지에 따른 차이를 비교할 수 있으며, 액티브 미발동 설정도 두 점수에 반영합니다.
|
||||||
|
|
||||||
## 2026년 9월 26일
|
## 2026년 9월 26일
|
||||||
|
|
||||||
### Watcha Gatcha!!!!!!!! 채보 추가
|
### Watcha Gatcha!!!!!!!! 채보 추가
|
||||||
|
|||||||
+14
-4
@@ -74,10 +74,13 @@ function specialIsActive(
|
|||||||
function specialSupportAt(
|
function specialSupportAt(
|
||||||
cardsInOrder: readonly OptimizerCard[],
|
cardsInOrder: readonly OptimizerCard[],
|
||||||
chart: ExactSongChart,
|
chart: ExactSongChart,
|
||||||
timeSeconds: number
|
timeSeconds: number,
|
||||||
|
excludeStart = false
|
||||||
): number {
|
): number {
|
||||||
return cardsInOrder.reduce((sum, card, slot) =>
|
return cardsInOrder.reduce((sum, card, slot) =>
|
||||||
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0);
|
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) &&
|
||||||
|
!(excludeStart && Math.abs(timeSeconds - chart.specialStartSeconds[slot]!) < 1e-9)
|
||||||
|
? card.special.scoreUpPercent : 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function activationRateBonusAt(
|
function activationRateBonusAt(
|
||||||
@@ -96,9 +99,10 @@ function activeCandidates(
|
|||||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||||
chart: ExactSongChart,
|
chart: ExactSongChart,
|
||||||
timeSeconds: number,
|
timeSeconds: number,
|
||||||
excludedActiveWindows: ExactScoreOptions['excludedActiveWindows']
|
excludedActiveWindows: ExactScoreOptions['excludedActiveWindows'],
|
||||||
|
excludeSpecialStart = false
|
||||||
): ActiveCandidate[] {
|
): ActiveCandidate[] {
|
||||||
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
|
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds, excludeSpecialStart);
|
||||||
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
|
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
|
||||||
const reduction = reductions[card.id] ?? 0;
|
const reduction = reductions[card.id] ?? 0;
|
||||||
const attempt = activeAttempt(card, reduction, timeSeconds);
|
const attempt = activeAttempt(card, reduction, timeSeconds);
|
||||||
@@ -149,6 +153,7 @@ export function calculateExactScore(
|
|||||||
): ExactScoreResult {
|
): ExactScoreResult {
|
||||||
const directBonus = options.directSongScoreBonusPercent ?? 0;
|
const directBonus = options.directSongScoreBonusPercent ?? 0;
|
||||||
const scoreNotes = chart.notes;
|
const scoreNotes = chart.notes;
|
||||||
|
let specialStartBonus = 0;
|
||||||
const notePoints = scoreNotes.map((note, index) => {
|
const notePoints = scoreNotes.map((note, index) => {
|
||||||
const combo = index + 1;
|
const combo = index + 1;
|
||||||
const baseNoteScore = note.type === 'mid'
|
const baseNoteScore = note.type === 'mid'
|
||||||
@@ -159,6 +164,10 @@ export function calculateExactScore(
|
|||||||
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)) {
|
||||||
|
const withoutStart = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows, true);
|
||||||
|
specialStartBonus += theoreticalMaxScore - finalNoteScore(rawBaselineScore * (1 + (withoutStart[0]?.boost ?? 0)), directBonus);
|
||||||
|
}
|
||||||
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
|
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +183,7 @@ export function calculateExactScore(
|
|||||||
return {
|
return {
|
||||||
expectedScore: rawExpected,
|
expectedScore: rawExpected,
|
||||||
theoreticalMaxScore: rawMaximum,
|
theoreticalMaxScore: rawMaximum,
|
||||||
|
withoutSpecialStartBonusScore: rawMaximum - specialStartBonus,
|
||||||
baselineScore: rawBaseline,
|
baselineScore: rawBaseline,
|
||||||
perfectNoteScore: options.perfectNoteScore,
|
perfectNoteScore: options.perfectNoteScore,
|
||||||
scoreBySecond,
|
scoreBySecond,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { calculateExactScore } from './score.ts';
|
||||||
|
import type { OptimizerCard, ExactSongChart } from './types.ts';
|
||||||
|
|
||||||
|
test('스페셜 시작 보너스만 제외하고 기존 스페셜, 종료 경계 및 액티브 실패는 유지한다', () => {
|
||||||
|
const cards: OptimizerCard[] = [
|
||||||
|
{ id: 'a', name: 'a', active: { cooldownSeconds: 1, durationSeconds: 10, scoreUpPercent: 100 }, special: { durationSeconds: 3, scoreUpPercent: 100 } },
|
||||||
|
{ id: 'b', name: 'b', active: { cooldownSeconds: 999, durationSeconds: 1, scoreUpPercent: 100 }, special: { durationSeconds: 1, scoreUpPercent: 50 } }
|
||||||
|
];
|
||||||
|
const chart: ExactSongChart = { songId: 'test', difficulty: 'hard', durationSeconds: 5, scoreRatio: 1,
|
||||||
|
specialStartSeconds: [1, 2], notes: [1, 2, 2.5, 3, 4].map(timeSeconds => ({ timeSeconds, type: 'regular', isFlick: false })) };
|
||||||
|
const score = calculateExactScore(cards, {}, chart, { perfectNoteScore: 100 });
|
||||||
|
assert.equal(score.theoreticalMaxScore, 1500);
|
||||||
|
assert.equal(score.withoutSpecialStartBonusScore, 1350);
|
||||||
|
const excluded = calculateExactScore(cards, {}, chart, { perfectNoteScore: 100, excludedActiveWindows: [{cardId: 'a', startSeconds: 1}] });
|
||||||
|
assert.equal(excluded.theoreticalMaxScore, 1300);
|
||||||
|
assert.equal(excluded.withoutSpecialStartBonusScore, 1250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PSYCHO Hard 첨부 편성의 밀어치기 적용 및 미적용 점수를 재현한다', async () => {
|
||||||
|
const chart = JSON.parse(await readFile(new URL('../../../static/data/charts/m0121.hard.json', import.meta.url), 'utf8'));
|
||||||
|
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.withoutSpecialStartBonusScore, 4043015);
|
||||||
|
});
|
||||||
@@ -111,6 +111,8 @@ export type NoteScorePoint = {
|
|||||||
export type ExactScoreResult = {
|
export type ExactScoreResult = {
|
||||||
expectedScore: number;
|
expectedScore: number;
|
||||||
theoreticalMaxScore: number;
|
theoreticalMaxScore: number;
|
||||||
|
/** 스페셜 시작과 같은 시각의 노트에 새 스페셜 점수 효과를 적용하지 않은 최고점. */
|
||||||
|
withoutSpecialStartBonusScore?: number;
|
||||||
baselineScore: number;
|
baselineScore: number;
|
||||||
perfectNoteScore: number;
|
perfectNoteScore: number;
|
||||||
scoreBySecond: readonly number[];
|
scoreBySecond: readonly number[];
|
||||||
|
|||||||
@@ -1198,9 +1198,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="score-grid">
|
<div class="score-grid">
|
||||||
<div class="score-summary"><span>예상 점수</span><strong>{displayedResult!.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
<div class="score-summary"><span>예상 점수</span><strong>{displayedResult!.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||||
<div class="score-summary"><span>이론상 고점</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
<div class="score-summary"><span>최고점 · 밀어치기 적용</span><strong>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||||
|
{#if displayedResult!.withoutSpecialStartBonusScore !== undefined}
|
||||||
|
<div class="score-summary"><span>최고점 · 밀어치기 미적용</span><strong>{displayedResult!.withoutSpecialStartBonusScore!.toLocaleString('ko-KR')}</strong></div>
|
||||||
|
{/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>
|
||||||
{#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>
|
||||||
@@ -1475,7 +1479,7 @@
|
|||||||
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
||||||
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 13px; }
|
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 13px; }
|
||||||
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
|
.score-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 14px; }
|
||||||
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||||
.score-summary span { color: #a9b1be; font-size: 14px; }
|
.score-summary span { color: #a9b1be; font-size: 14px; }
|
||||||
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user