feat: add leader rating song recommendations
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { leaderRating, leaderRatingCharts, rankSongCharts, rankSongs, songRating } from './song-rating.ts';
|
||||
|
||||
test('레이팅은 5000점 단위로 올림하며 잘못된 점수를 거부한다', () => {
|
||||
assert.deepEqual([0, 1, 5000, 5001, 1234567].map(songRating), [0, 1, 1, 2, 247]);
|
||||
for (const score of [-1, NaN, Infinity]) assert.throws(() => songRating(score));
|
||||
});
|
||||
|
||||
test('리더 가창곡의 공개된 채보만 선택한다', () => {
|
||||
const charts = [
|
||||
{ songId: 'solo', title: 'Solo', difficulty: 'expert', singerCharacterIds: ['leader'] },
|
||||
{ songId: 'solo', title: 'Solo', difficulty: 'hard', singerCharacterIds: ['leader'] },
|
||||
{ songId: 'group', title: 'Group', difficulty: 'expert', singerCharacterIds: ['other', 'leader'], releaseTime: 200 },
|
||||
{ songId: 'other', title: 'Other', difficulty: 'expert', singerCharacterIds: ['other'] },
|
||||
{ songId: 'unknown', title: 'Unknown', difficulty: 'expert', singerCharacterIds: [] }
|
||||
];
|
||||
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'expert', 199), [charts[0]]);
|
||||
assert.deepEqual(leaderRatingCharts(charts, 'leader', 'both', 200), charts.slice(0, 3));
|
||||
assert.deepEqual(leaderRatingCharts(charts, '', 'both', 200), []);
|
||||
});
|
||||
|
||||
test('고점 순위와 상위 3곡 합계는 동일 곡의 난이도를 중복 집계하지 않는다', () => {
|
||||
const row = (songId: string, score: number, difficulty = 'expert') => ({
|
||||
songId, title: songId, difficulty, theoreticalMaxScore: score, expectedScore: score / 2
|
||||
});
|
||||
const results = [row('a', 5001), row('b', 10001), row('a', 20001, 'hard'), row('c', 9999), row('d', 5000)];
|
||||
assert.deepEqual(rankSongCharts(results).map((item) => [item.songId, item.difficulty]), [['a', 'hard'], ['b', 'expert'], ['c', 'expert'], ['a', 'expert'], ['d', 'expert']]);
|
||||
assert.deepEqual(rankSongs(results).map((item) => [item.songId, item.difficulty]), [['a', 'hard'], ['b', 'expert'], ['c', 'expert'], ['d', 'expert']]);
|
||||
assert.equal(leaderRating(results), 10);
|
||||
assert.equal(leaderRating([]), 0);
|
||||
assert.equal(results[0].theoreticalMaxScore, 5001);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { releasedSongCharts } from '../data/song-release.ts';
|
||||
|
||||
export type RatingChart = {
|
||||
songId: string;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
singerCharacterIds?: readonly string[];
|
||||
releaseTime?: number | null;
|
||||
};
|
||||
|
||||
// Source: https://dreams.wf-calc.net/wiki/live (2026-09-07).
|
||||
export function songRating(score: number): number {
|
||||
if (!Number.isFinite(score) || score < 0) throw new Error('레이팅 점수는 0 이상의 유한한 수여야 합니다.');
|
||||
return Math.ceil(score / 5000);
|
||||
}
|
||||
|
||||
export function leaderRatingCharts<T extends RatingChart>(charts: readonly T[], leaderId: string, difficulty: string, now: number): T[] {
|
||||
return releasedSongCharts(charts, now).filter((chart) =>
|
||||
Boolean(leaderId) && chart.singerCharacterIds?.includes(leaderId) &&
|
||||
(difficulty === 'both' ? chart.difficulty === 'hard' || chart.difficulty === 'expert' : chart.difficulty === difficulty));
|
||||
}
|
||||
|
||||
export type SongRatingScore = {
|
||||
songId: string;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
theoreticalMaxScore: number;
|
||||
expectedScore: number;
|
||||
};
|
||||
|
||||
export function rankSongCharts<T extends SongRatingScore>(results: readonly T[]): T[] {
|
||||
return [...results].sort((a, b) =>
|
||||
songRating(b.theoreticalMaxScore) - songRating(a.theoreticalMaxScore) ||
|
||||
b.theoreticalMaxScore - a.theoreticalMaxScore || b.expectedScore - a.expectedScore ||
|
||||
a.title.localeCompare(b.title, 'en') || a.songId.localeCompare(b.songId) || a.difficulty.localeCompare(b.difficulty));
|
||||
}
|
||||
|
||||
export function rankSongs<T extends SongRatingScore>(results: readonly T[]): T[] {
|
||||
const sorted = rankSongCharts(results);
|
||||
const seen = new Set<string>();
|
||||
return sorted.filter((row) => {
|
||||
if (seen.has(row.songId)) return false;
|
||||
seen.add(row.songId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function leaderRating(results: readonly SongRatingScore[]): number {
|
||||
return rankSongs(results).slice(0, 3).reduce((sum, row) => sum + songRating(row.theoreticalMaxScore), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user