Files
ProjectQuint/docs/profile-progression-design.md
T

22 KiB
Raw Blame History

ProjectQuint 로컬 프로필·성장 시스템 설계

상태: 핵심 저장·집계·프로필 탐색 구현 완료
기준일: 2026-07-22
적용 범위: 서버 없는 로컬 프로필, 4K/5K/6K/7K 및 scratch, 외부 BMS 팩·난이도표

1. 목표

ProjectQuint의 성장은 하나의 숫자로 모든 것을 표현하지 않는다. 플레이 누적, 실제 실력, 단위인정, 곡 수집과 꾸미기 보상을 독립된 축으로 관리한다.

  1. Profile Level: 얼마나 꾸준히 플레이했는지를 보여주는 활동 지표
  2. Rating: 난이도 상수가 있는 채보의 최고 플레이 30개로 계산하는 기록 지표
  3. Course Certification: 단위인정 코스를 통과했다는 범주형 증명
  4. Chart Mastery / Collection: 곡별 클리어·하드클리어·풀콤보·이론치 수집
  5. Achievements / Cosmetics: 장기 목표와 프로필 꾸미기 보상

Profile Level은 판정, 게이지, 점수에 어떠한 이점도 주지 않는다. 사용자가 직접 가져온 BMS 곡이나 채보도 레벨로 잠그지 않는다.

2. 경쟁작에서 확인한 성장 문법

osu!

  • 프로필 Level은 누적 total score에 기반한 활동량이고 플레이 능력에 영향을 주지 않는다.
  • 실력은 별도의 performance points(pp)로 표현하며 게임 모드마다 분리한다.
  • pp는 높은 성과부터 정렬한 뒤 0.95^(순위-1)로 기여도를 줄여, 낮은 성과를 무한히 쌓는 것보다 어려운 곡의 좋은 기록을 갱신하도록 유도한다.

ProjectQuint에 적용할 점: 활동 레벨과 실력 레이팅의 완전한 분리, 키 모드 분리, 상위 성과 가중 집계.

beatmania IIDX

  • 곡별 최고 EX SCORE, clear lamp, 단위인정 기록을 서로 다른 성과로 보존한다.
  • DJ RANK 계열 지표는 카테고리별로 높은 DJ POINT 기록을 모아서 실력 지표로 사용한다.
  • ASSIST/EASY/NORMAL/HARD/EX HARD/FULL COMBO처럼 클리어 상태가 수집 진행도를 만든다.
  • 단위인정에는 합격 여부와 최고 달성률이 별도로 남는다.

ProjectQuint에 적용할 점: 점수와 clear lamp를 분리하고, 단위인정을 숫자 레이팅에 합산하지 않으며, 팩·난이도표별 수집률을 제공한다.

SOUND VOLTEX

  • Skill Analyzer는 정해진 여러 곡을 연속으로 통과하는 인증이며, 획득한 가장 높은 Skill Level을 프로필에 표시한다.
  • 같은 실력을 숫자 하나로만 표현하지 않고 코스 칭호와 달성률을 함께 사용한다.

ProjectQuint에 적용할 점: AERY 등 난이도표 제공자의 단위인정을 독립된 인증 배지로 보존한다.

DJMAX RESPECT V

  • 플레이 누적형 Clear Pass와 프로필 카드·플레이트 등 코스메틱 보상을 사용한다.
  • 시즌·유료 부스터·곡 해금도 포함하지만 이는 서버 없는 범용 BMS 구동기와 맞지 않는다.

ProjectQuint에 적용할 점: 플레이트, 프레임, 칭호 같은 꾸미기 보상만 차용한다. 시즌 만료, FOMO, 유료 부스터, BMS 채보 잠금은 도입하지 않는다.

B30 계열 레이팅

  • Arcaea 커뮤니티가 분석한 Potential은 세밀한 chart constant와 플레이 성과를 결합하며, Best 30이 핵심 기록 풀이다.
  • CHUNITHM도 공식적으로 버전별 rating 대상곡 풀을 운영하고, 높은 곡별 성과를 모아 rating을 구성한다.

ProjectQuint에는 최근 기록을 강제로 섞어 rating이 내려가는 구조를 도입하지 않는다. 한 채보의 최고 성과 하나만 보존한 Best 30 산술평균을 사용한다.

3. Profile Level: 누적 활동 성장

3.1 XP 지급 조건

Valid Play만 XP를 지급한다.

  • 곡을 정상 완주했거나,
  • 게임오버 시점까지 active play time이 30초 이상이고 판정 단위가 20개 이상인 플레이
  • 로딩 취소, 시작 직후 retry/quit, 자동 프로파일링 플레이는 XP 0
  • pause와 loading 시간은 active play time에서 제외
  • 동일한 PlayEventId는 정확히 한 번만 반영

오프라인 코스메틱 수치이므로 사용자의 반복 연습을 억제하는 일일 제한은 두지 않는다. 대신 비정상적으로 짧은 재시작과 크래시 후 중복 반영만 방지한다.

3.2 플레이 XP 산식

BaseXP = clamp(
    20
    + floor(ActivePlaySeconds / 10) * 4
    + floor(JudgedUnits / 100) * 2,
    20,
    100)

ClearBonus = GameOver 0 / Normal Clear 20 / Hard Clear 30
PlayXP = BaseXP + ClearBonus + NewlyEarnedMilestoneXP

JudgedUnits에는 일반 노트와 롱노트 tick을 포함한다. 밀도가 높은 채보만으로 XP를 과도하게 얻지 않도록 노트 기여분과 전체 BaseXP를 제한한다. 채보 작성자가 임의로 적는 #PLAYLEVEL은 XP에 사용하지 않는다.

곡별 최초 마일스톤은 각각 한 번만 지급한다.

마일스톤 XP
최초 Valid Play 20
최초 Normal Clear 40
최초 Hard Clear 60
최초 Full Combo 80
최초 All Pure 이론치 120
단위인정 코스 최초 합격 250

3.3 레벨 곡선

리듬게임에 공통으로 쓰이는 하나의 XP 공식은 없다. osu!는 누적 total score에 cubic·exponential 항을 결합한 고유 공식을 사용하고, DJMAX를 비롯한 상업 게임 다수는 정확한 DJ LEVEL 산식을 공개하지 않는다. 따라서 아래 식은 업계 표준을 복제한 것이 아니라 ProjectQuint의 목표 플레이 횟수에 맞춘 투명한 v1 곡선이다.

현재 레벨 L에서 다음 레벨까지 필요한 XP와 누적 XP는 다음과 같다.

NextXP(L) = 40L + 30
TotalXP(L) = 20 * (L - 1)^2 + 50 * (L - 1)

NextXP가 레벨마다 선형으로 증가하므로 누적값은 2차식이 된다. 초반에는 한두 번의 플레이로 빠르게 오르고, 중후반에는 한 레벨에 여러 세션이 필요하게 만드는 일반적인 완만한 성장 형태다.

레벨 누적 XP 평균 100 XP 플레이 기준
2 70 약 1회
10 2,070 약 21회
30 18,270 약 183회
50 50,470 약 505회
100 200,970 약 2,010회

레벨 자체에는 상한을 두지 않는다. 초기 코스메틱 보상은 100레벨까지만 준비하고 이후에는 레벨 숫자와 XP 진행만 계속된다. 실제 플레이 로그 분포를 확보한 뒤 계수만 조정할 수 있도록 XpRulesVersion을 둔다. 이미 받은 XP는 규칙 변경으로 소급 삭감하지 않는다.

4. Rating: Best 30 플레이 기록

4.1 분리 단위

Rating은 별도의 스킬 트리나 스킬 시스템이 아니다. 난이도 상수가 부여된 채보를 높은 정확도로 클리어한 기록 중 가장 좋은 30개를 숫자로 요약한다.

Rating은 다음 조합마다 독립적으로 계산한다.

DifficultyTableId × KeyCount × ScratchMode

표시 단위는 4K, 5K, 6K, 7K, 1+4, 1+5, 1+6, 1+7이다. 1+7은 스크래치 1레인과 건반 7레인을 합친 전통적인 8레인 BMS 형태다. AERY·Stella 같은 난이도표는 서로 다른 난이도 체계이므로 숫자가 같더라도 하나의 Best 30에 섞지 않는다. 팩은 파일 묶음일 뿐 Rating 분리 축이 아니다. 같은 채보가 여러 난이도표에 등재되면 chart hash 하나에 난이도표별 최고 performance snapshot을 독립적으로 보존한다. 서로 다른 키 형태나 난이도표를 합친 Global Rating은 만들지 않는다.

  • 난이도표가 명시한 순서와 rating constant mapping이 있는 채보만 공식 Rating 대상
  • #PLAYLEVEL만 있는 채보는 UNRANKED로 기록·수집하되 Rating에서 제외
  • 레이더 특성치는 채보 성향이며 난이도 수치가 아니므로 레이팅에 직접 사용하지 않음
  • 특수/GIMMICK 레벨은 provider가 숫자 mapping을 제공할 때만 포함

4.2 채보 상수

난이도표 표기를 다음처럼 ChartConstant로 변환한다.

난이도 표기 ChartConstant
LEVEL 14 14.0
LEVEL 14+ 14.5
LEVEL 15 15.0
LEVEL 15+ 15.5
LEVEL 16 16.0
LEVEL 16+ 16.5

다른 난이도표의 ★, sl, st 등은 provider별 rating map에서 명시적으로 대응한다. 대응표가 없는 provider를 임의로 AERY 숫자에 끼워 맞추지 않는다.

4.3 한 플레이의 Play Rating

Rating 대상이 되려면 해당 플레이가 Normal 이상으로 clear되어야 한다. Game Over 기록은 점수와 collection에는 남지만 Rating 후보에서는 제외한다.

GaugeBonus = Hard Clear ? 0.50 : 0.00
PlayRating = ChartConstant × AccuracyFactor + GaugeBonus

AccuracyFactor는 아래 anchor 사이를 선형 보간한다.

정확도 계수
0% 0.00
80% 0.30
90% 0.60
95% 0.80
97% 0.88
98% 0.93
99% 0.97
99.5% 0.985
100% 1.00

0~80% 구간도 두 anchor 사이를 선형 보간하므로 경계에서 값이 갑자기 튀지 않는다. 계산은 double 정밀도로 유지하고 프로필·결과 화면에서만 소수점 셋째 자리까지 반올림한다.

예시:

채보 정확도 PlayRating
LEVEL 15 (15.0), Normal 98.00% 13.950
LEVEL 15 (15.0), Normal 99.00% 14.550
LEVEL 15 (15.0), Hard 99.00% 15.050
LEVEL 15+ (15.5), Normal 99.00% 15.035
LEVEL 16 (16.0), Normal 98.00% 14.880
LEVEL 16 (16.0), Hard 100.00% 16.500

Hard Clear는 같은 채보·정확도의 Normal Clear보다 정확히 +0.50 높은 PlayRating을 받는다. 이는 LEVEL N Hard Clear를 동일 정확도의 LEVEL N+ Normal Clear와 동급으로 취급한다는 명시적인 정책이다. 배율이 아니라 고정 가산값이므로 난이도에 따라 보너스가 변하지 않으며, Best 30을 모두 Hard Clear했을 때 최종 Rating 차이도 정확히 +0.50이다. Full Combo와 All Pure는 clear lamp와 collection에서 별도로 표현하고 Rating 보너스는 주지 않는다. Pure 보너스가 포함된 raw score도 길이가 긴 채보에 유리하므로 Rating에 직접 넣지 않는다.

4.4 Best 30 집계

같은 chart hash에서는 가장 높은 eligible PlayRating 하나만 사용한다. 이를 높은 순서로 정렬해 상위 30개를 단순 평균한다. 보유 기록이 30개 미만이면 비어 있는 slot은 0이다.

Rating = sum(BestPlayRating[0..29]) / 30

고정된 30 slot은 한 곡만 잘해서 곧바로 높은 Rating을 얻는 것을 막는다. 더 높은 PlayRating이 들어오면 기존 30위 기록을 교체하므로 같은 rules/table revision 안에서는 Rating이 절대 감소하지 않는다. UI에는 AERY 5K RATING 14.35 · B30 COMPLETE 또는 STELLA 1+7 RATING 13.80 · B12 / 30처럼 표시한다.

예를 들어 Best 30이 각각 다음 10곡씩이라면:

10 × (LEVEL 15.0, 99.00%) = 10 × 14.550
10 × (LEVEL 15.5, 98.00%) = 10 × 14.415
10 × (LEVEL 16.0, 97.00%) = 10 × 14.080

Rating = (145.50 + 144.15 + 140.80) / 30 = 14.3483...
표시값 = 14.348

초기 modifier 정책:

  • OFF, MIRROR: Rating 포함
  • RANDOM: 기록과 clear lamp는 보존하지만 Rating에서는 제외하고 Experimental로 표시
  • 단위인정: 기존 정책대로 OFF/MIRROR만 허용

RANDOM은 배치에 따라 실제 난이도가 달라지므로 chart feature 기반 보정이 검증되기 전까지 공식 수치에 섞지 않는다.

4.5 버전과 재계산

  • RatingRulesVersion과 difficulty table revision을 Rating 결과에 표시
  • 레이팅은 원본 플레이 기록에서 언제든 재계산 가능한 materialized view
  • 산식 또는 난이도표가 바뀌면 레이팅은 오르내릴 수 있지만 Profile XP와 해금 보상은 유지
  • 과거 산식의 숫자는 기록 화면에서 선택적으로 보존하되 현재 프로필에는 최신 산식만 표시

5. Course Certification

  • 난이도표 provider마다 최고 합격 단위, 최고 달성률, 최고 잔여 게이지, 합격 횟수, 시도 횟수, 사용 modifier를 저장한다.
  • 코스를 처음 합격하면 해당 DifficultyProviderId × CourseId의 Certification Badge를 획득한다. 배지는 별도 중복 저장하지 않고 영구 course clear 기록에서 파생하므로 프로필 export/import와 구버전 기록에도 자동 적용된다. 재합격은 정확도·트로피 기록만 갱신하고 같은 배지를 중복 지급하지 않는다.
  • 획득한 배지 중 하나를 Featured Badge로 선택할 수 있다. 선택한 course ID는 로컬 profile document에 저장하고 Home 프로필 카드와 Profile Overview에 표시한다. 보유하지 않은 배지는 선택할 수 없으며, 저장된 배지를 찾을 수 없을 때는 남아 있는 첫 배지를 안전한 표시값으로 사용한다.
  • 단위 배지는 Rating에 더하지 않는다. 숫자로 표현하기 어려운 연속 플레이 안정성을 증명하는 별도의 칭호다.
  • 같은 단위의 반복 합격은 XP farm이 되지 않으며 최초 합격 XP만 지급한다.
  • 현재 AeryCourseRecord 중심 구조는 장기적으로 CourseRecord와 provider ID 기반 구조로 일반화한다.

6. Chart Mastery와 Collection

곡별 성취 단계:

UNPLAYED < FAILED < NORMAL CLEAR < HARD CLEAR < FULL COMBO < ALL PURE
  • 화면에는 최고 lamp 하나를 표시하되, 저장은 ClearMilestoneFlags로 각 마일스톤을 보존한다.
  • Game Over 기록이 기존 clear를 덮어쓰지 않는다.
  • pack, difficulty provider, key mode별로 Played/Clear/Hard/FC/All Pure 수와 비율을 계산한다.
  • 같은 chart hash는 전역 수집에서 한 번만 세지만, 여러 pack에 포함된 경우 각 pack의 완성도에는 해당 pack entry로 표시할 수 있다.
  • pack을 분리하거나 삭제해도 chart hash 기반 기록은 보존하며, 다시 연결되면 복구한다.
  • Favorite은 프로필별 수집 데이터로 취급한다.

7. Achievements와 보상

초기 achievement 범주는 다음으로 제한한다.

  • 첫 플레이, 첫 clear, 첫 hard clear, 첫 full combo, 첫 이론치
  • 4K/5K/6K/7K 각 모드의 첫 clear와 누적 clear 수
  • 단위인정 최초 합격과 상위 단위 합격
  • pack별 25%/50%/75%/100% collection
  • 누적 100/500/1,000곡 플레이, 1,000 combo 등 명확한 장기 기록

보상 종류:

  • Profile Frame
  • Plate / Profile Card 배경
  • Title / Badge
  • Character 또는 character animation variant
  • Gear color palette / Note skin

보상은 전부 시각 요소다. 판정 폭, 게이지, 점수 배율, 키음 처리에는 영향을 주지 않는다. 기간 한정 시즌, 로그인 streak, daily quest는 도입하지 않는다.

레벨 보상 cadence는 5레벨마다 하나를 기본으로 하고, 10/25/50/75/100에서 큰 profile frame이나 character variant를 제공한다. 실제 asset이 없는 reward ID는 UI에 가짜 보상으로 노출하지 않고 Coming Soon placeholder도 만들지 않는다.

8. 로컬 데이터 설계

8.1 프로필 경계

처음에는 한 개의 로컬 프로필만 자동 생성하지만, 저장 경로는 처음부터 복수 프로필을 지원한다.

ProjectQuint/
  profiles.json
  profiles/{ProfileId}/
    profile.json
    progression.json
    records.json
    courses.json
    favorites.json
    pending-play-commit.json

팩 registry, difficulty table, audio/video cache와 장치 설정은 전역으로 유지한다. 플레이 기록, Favorite, course, XP, achievement와 장착한 cosmetic은 프로필에 귀속한다.

기존 전역 records.json, courses.json, favorites.json은 최초 기본 프로필로 한 번만 복사한 뒤 migration marker를 저장한다. 원본은 자동 삭제하지 않는다.

구현 상태: 기본 프로필 local-player에 대해 위 경계가 적용되었다. 시작 시 profile-storage-migration.json을 확인하고, marker가 없으면 전역 primary와 .bak을 대상 파일이 없는 경우에만 같은 볼륨의 임시 파일을 거쳐 원자 복사한다. 부분 완료 후 재실행해도 이미 만들어진 프로필 파일은 덮어쓰지 않으며, 모든 복사 단계가 끝난 뒤에만 marker를 저장한다.

8.2 ProfileDocument

SchemaVersion
ProfileId
DisplayName
CreatedUtcTicks
LastPlayedUtcTicks
TotalXp
XpRulesVersion
LifetimeStatsByMode[]
UnlockedRewardIds[]
AchievementStates[]
EquippedCosmetics
MigrationState

Level은 TotalXp에서 계산되는 파생값이며 저장값을 정본으로 삼지 않는다.

8.3 PlayResultEvent

완료된 한 플레이는 불변 event로 만든다.

EventId (UUID)
ChartId (content hash)
PackId
KeyCount / HasScratch
DifficultyProviderId / Revision / NormalizedDifficulty
ScoringRulesVersion / JudgementRulesVersion
LaneModifier / GaugeType
Completed / GameOver / ClearMilestones
Score / Accuracy / MaxCombo
Kool / PureKool / Cool / Good / Bad / Miss / Fast / Late
JudgedUnits / LongNoteTicks
ActivePlaySeconds
OccurredUtcTicks (표시용, 규칙 판정에 사용하지 않음)

현재 records.json v3는 BestScore, BestAccuracyPercent, BestMaxCombo를 각각 독립적으로 갱신하므로 서로 다른 플레이의 값이 합쳐질 수 있다. Rating에는 이를 사용할 수 없다. 다음 record schema에는 한 플레이의 값이 함께 들어 있는 BestPerformanceSnapshot과 PureKoolCount, JudgedUnits, ClearState, modifier, rules version을 추가해야 한다.

8.4 원자적 결과 commit

결과 저장 중 강제 종료되어 XP나 PlayCount가 두 번 오르는 것을 막는다.

  1. pending-play-commit.json에 PlayResultEvent를 원자 저장
  2. song record에 같은 EventId가 적용됐는지 확인 후 반영
  3. course record가 있으면 같은 방식으로 반영
  4. progression XP·achievement에 EventId가 적용됐는지 확인 후 반영
  5. 모든 저장이 성공한 뒤 pending commit 완료 처리
  6. 다음 실행 시 pending commit이 있으면 같은 EventId로 재개

각 repository는 EventId에 대해 idempotent해야 한다. 기존 AtomicJsonFile<T>의 primary/backup 복구 규칙을 그대로 사용한다.

Rating과 collection 통계는 records/course에서 재계산 가능한 cache로 취급한다. Profile XP receipt와 이미 해금된 cosmetic은 소급 재계산하지 않는 정본이다.

9. 화면 정보 구조

UI 전면 개편 단계에서 다음 정보 구조만 유지한다.

  • Home: Profile Lv., XP bar, 최근 플레이한 난이도표·키 형태의 Rating, 최고 단위 배지
  • Music Select: 곡별 최고 lamp, 최고 기록, 해당 플레이가 rating top 30에 포함되는지 표시
  • Result: 점수 결산 후 XP 획득, 새 record, rating 변화, 새 milestone을 순서대로 표시
  • Profile / Overview: 활동 레벨, 플레이 시간, 전체 collection, 최근 achievement
  • Profile / Rating: 위/아래 키로 난이도표 × 4K/5K/6K/7K 및 1+4/1+5/1+6/1+7 조합을 순환하며 Rating과 Best 30 확인
  • Profile / Courses: 획득한 난이도표별 Certification Badge와 전체 코스 이력·최고 달성률
  • Profile / Collection: pack·난이도·키 모드별 clear lamp 집계
  • Profile / Achievements: 획득 조건과 해금 cosmetic

PROFILE LV.와 AERY 5K RATING 또는 STELLA 1+7 RATING이라는 명칭을 명확히 사용해 활동 레벨과 Rating을 혼동하지 않게 한다.

구현 상태: Profile modal은 OVERVIEW / RATING / COLLECTION / COURSES 네 탭을 제공한다. Collection은 아직 스캔된 전체 팩의 완주율이 아니라 한 번 이상 기록된 고유 채보를 분모로 삼으며, 이를 UI에서 RECORDED CHARTS로 명시한다. 최종 clear lamp와 별개로 Full Combo 및 All Pure의 채보별 최초 달성 상태는 계속 보존한다. Courses는 현재 AERY 단위 기록을 공통 collection snapshot으로 투영하며, 이후 generic course provider도 같은 화면 모델에 연결할 수 있다. 레벨업 연출은 플레이 중 HUD에 띄우지 않고 Result 또는 Home 복귀 시에만 보여준다.

10. 초기 구현 순서

  1. 기록 schema v4와 PlayResultEvent, consistent best performance snapshot
  2. profile directory와 기존 전역 기록의 비파괴 migration
  3. pending commit coordinator와 EventId idempotency
  4. Profile XP, level curve, lifetime stats
  5. generic course certification과 collection materialized view
  6. 난이도표 × 키 형태별 Rating v3와 Best 30 상세 화면용 모델
  7. achievement catalog와 cosmetic inventory
  8. UI 전면 개편 시 Profile 화면과 Result 성장 연출 연결

11. 검증 기준

  • 동일 PlayEventId를 2회 적용해도 XP, PlayCount, achievement가 한 번만 증가한다.
  • 결과 저장 단계마다 강제 종료한 뒤 재실행해 records와 progression이 같은 event를 반영한다.
  • 같은 replay와 같은 규칙 버전은 항상 같은 PlayRating을 만든다.
  • 같은 채보·정확도에서 Hard Clear PlayRating은 Normal Clear보다 정확히 0.50 높다.
  • 새 기록 추가 또는 기존 기록 개선만으로 같은 rules/table revision의 Rating이 감소하지 않는다.
  • AERY와 다른 난이도표의 같은 숫자가 하나의 레이팅에 합산되지 않는다.
  • 4K/5K/6K/7K와 scratch 기록이 서로 섞이지 않는다.
  • pack 이동·이름 변경·재등록 후에도 같은 content hash 기록과 마일스톤이 복구된다.
  • RANDOM 플레이는 v1 공식 레이팅에 들어가지 않지만 점수·lamp·collection은 사라지지 않는다.
  • 점수 산식이 바뀌어도 과거 XP와 cosmetic은 유지되고 Rating만 최신 규칙으로 재계산된다.
  • primary JSON 손상 시 backup 복구가 되며 export/import 후 EventId가 중복 지급되지 않는다.
  • 프로필 초기화와 곡 기록 초기화는 서로 다른 명시적 확인 절차를 가진다.

12. 참고 자료