feat: apply feedback updates and streamline online delivery
Publish GitHub Pages output / publish (push) Successful in 34s

This commit is contained in:
2026-09-01 22:21:45 +09:00
parent 98d5575463
commit c0f4060dec
36 changed files with 447 additions and 471 deletions
+8 -5
View File
@@ -1,8 +1,11 @@
# Repository instructions
## Online and offline parity
## Online-only release policy
- Every user-facing feature, fix, data update, and behavior change must be included in both the online app and the offline app.
- Keep shared behavior in `src/` whenever possible. If an online-only dependency is introduced, add or update the corresponding offline alias or adapter under `offline/`.
- Treat `npm run build:offline` as a required verification step for every repository change, in addition to the relevant tests, checks, and online build.
- Do not commit generated `offline-dist/` or `release/` artifacts unless the user explicitly requests packaged artifacts; these directories are ignored build outputs.
- The supported product and release target is the online app, including the GitHub Pages deployment.
- Do not run `npm run build:offline` or `npm run package:offline` during implementation, verification, commits, releases, or deployments.
- Do not create, commit, upload, or publish standalone offline builds, `offline-dist/` output, or offline ZIP artifacts.
- Online/offline feature parity is no longer required. Legacy offline-related source may remain only where the online Pages build still depends on shared adapters.
- Verify repository changes with the relevant tests, `npm run check`, `npm run build`, and `npm run build:pages` when preparing a deployment.
- Do not bump the package version, create version-specific release notes, create tags, or publish GitHub Releases for routine online commits and deployments unless the user explicitly requests them.
- Use `UPDATES.md` as the single user-facing update history and include only changes that affect service users.
+6 -20
View File
@@ -2,7 +2,7 @@
홀로라이브 드림즈의 카드와 멤버 성장 상태를 관리하고, 악곡별 최적 배치와 카드 조합을 분석하는 비공식 도구입니다.
[최신 버전 다운로드](https://github.com/DD4SN/HolodoriCalc/releases/latest) · 한국어 전용 · PC 환경 권장
[온라인 서비스 이용](https://doricalc-holo.live/) · 한국어 전용 · PC 환경 권장
![홀로도리 계산기 홈 화면](docs/images/home.png)
@@ -16,18 +16,12 @@
- 고정 멤버를 기준으로 남은 자리에 들어갈 카드 조합 탐색
- 액티브·스페셜 발동 구간과 노트 분포 시각화
- 사용자 데이터 백업과 복원
- 인터넷 연결 없이 실행 가능한 오프라인 버전 제공
## 다운로드 및 실행
## 서비스 이용
1. [최신 Release](https://github.com/DD4SN/HolodoriCalc/releases/latest)를 엽니다.
2. `Assets`에서 `HolodoriCalc-offline-v버전.zip`을 내려받습니다.
3. ZIP 파일을 원하는 폴더에 완전히 압축 해제합니다.
4. 압축을 해제한 폴더의 `index.html`을 실행합니다.
[홀로도리 계산기 온라인 서비스](https://doricalc-holo.live/)에 접속해 사용합니다. 별도의 설치나 다운로드는 필요하지 않습니다.
> GitHub가 자동으로 제공하는 `Source code (zip)`은 실행용 배포 파일이 아닙니다. 반드시 `HolodoriCalc-offline-...zip`을 받아 주세요.
Chrome 또는 Edge의 최신 버전을 권장합니다. 일부 브라우저는 로컬 HTML의 저장소나 네트워크 접근을 제한할 수 있습니다.
Chrome 또는 Edge의 최신 버전을 권장합니다.
## 처음 사용하는 순서
@@ -117,9 +111,7 @@ Chrome 또는 Edge의 최신 버전을 권장합니다. 일부 브라우저는
## 업데이트
홈 화면에서 현재 버전과 GitHub의 최신 Release를 비교합니다. 새 버전이 있으면 오프라인 ZIP 다운로드 버튼이 표시됩니다.
업데이트는 자동으로 파일을 교체하지 않습니다. 새 ZIP을 내려받아 별도 폴더에 압축을 해제한 뒤 `index.html`을 실행해 주세요. 업데이트 전에 사용자 데이터 백업을 권장합니다.
검증이 끝난 업데이트는 온라인 서비스에 배포됩니다. 중요한 변경 전에는 사용자 데이터 백업을 권장합니다.
## 참고
@@ -166,13 +158,7 @@ npm run dev
```bash
npm run check
npm run build
npm run build:offline
```
릴리즈용 오프라인 ZIP 생성:
```bash
npm run package:offline
npm run build:pages
```
### Production 실행
+33 -1
View File
@@ -1,5 +1,37 @@
---
updatedAt: 2026-08-31T18:26:57+09:00
updatedAt: 2026-09-01T22:16:07+09:00
---
## 2026년 9월 1일
### 실측 기반 점수 보정 확대
`DATA` ALiCE&u, Ahoy!! We are Houshou Pirates, Alien Alien, Asuenokyokaisen, Boom! Boom! Tropica Vacation, UNDEAD, Zenryoku-Jumping!의 Expert 실측값을 반영했습니다.
### 보드 재료 합계 표시
`NEW` 활성화한 보드 노드에 필요한 노드 포인트와 색상별 큐브·코어 큐브 총합을 확인할 수 있습니다.
### 모바일 보드 탐색 개선
`IMPROVED` 모바일에서도 이전·다음 버튼으로 멤버 보드를 빠르게 이동할 수 있습니다.
### 연결 카드 배치 확인 추가
`IMPROVED` 이미 배치된 카드를 이동하거나 다른 카드를 덮어쓸 때 확인 메시지를 표시합니다.
### 이벤트 Pt 조합 후보 확대
`IMPROVED` 이벤트 Pt 최적화에서 모든 4성 카드를 조합 후보로 포함합니다.
### 카드 한글 별명 검색 추가
`IMPROVED` IRyS는 `아이리스`, AZKi는 `아즈키`로 검색할 수 있습니다.
### 초록 노드 초기화 안내 개선
`IMPROVED` 종합력 손실이 없는 노드를 `0 · 영향 없음`으로 표시하고, 기수 보너스 상한 초과 등 손실이 0이 되는 이유를 안내합니다.
---
## 2026년 8월 31일
+56
View File
@@ -81,6 +81,62 @@
"measurements": [
{ "overallPower": 348760, "directSongScoreBonusPercent": 4, "normalPerfectScore": 998 }
]
},
{
"songId": "m0047",
"difficulty": "expert",
"scoreRatio": 290.2132653061224,
"measurements": [
{ "overallPower": 284409, "directSongScoreBonusPercent": 0, "normalPerfectScore": 980 }
]
},
{
"songId": "m0010",
"difficulty": "expert",
"scoreRatio": 252.13563829787233,
"measurements": [
{ "overallPower": 284409, "directSongScoreBonusPercent": 0, "normalPerfectScore": 1128 }
]
},
{
"songId": "m0108",
"difficulty": "expert",
"scoreRatio": 373.73061760841,
"measurements": [
{ "overallPower": 284409, "directSongScoreBonusPercent": 0, "normalPerfectScore": 761 }
]
},
{
"songId": "m0040",
"difficulty": "expert",
"scoreRatio": 280.4822485207101,
"measurements": [
{ "overallPower": 284409, "directSongScoreBonusPercent": 0, "normalPerfectScore": 1014 }
]
},
{
"songId": "m0309",
"difficulty": "expert",
"scoreRatio": 316.33724653148346,
"measurements": [
{ "overallPower": 296408, "directSongScoreBonusPercent": 0, "normalPerfectScore": 937 }
]
},
{
"songId": "m0312",
"difficulty": "expert",
"scoreRatio": 292.9459119496856,
"measurements": [
{ "overallPower": 296408, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1113 }
]
},
{
"songId": "m0035",
"difficulty": "expert",
"scoreRatio": 195.1219628964692,
"measurements": [
{ "overallPower": 296408, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1671 }
]
}
]
}
+94
View File
@@ -0,0 +1,94 @@
# 제보 응답 검토 프로세스
홀로도리 계산기의 Google Form 세 종류를 매일 한 번 확인한다.
- 실측값 제보: `https://docs.google.com/forms/d/1bShzsxNN-iTk0uU3RfMfmLkYXYmUTmIWMmCtQmfl_Ik`
- 버그 제보: `https://docs.google.com/forms/d/1z9c0c1crbCO6avXaYwrHZjhw2ib3zgAmW4ijJDTo4DM`
- 기능 건의: `https://docs.google.com/forms/d/16NcTst7Rpa9XzqYIfPa4t9JF2X0CMfYzEbZrT8PLqco`
응답은 다음 Google Sheets에서 읽는다. 세 파일 모두 Google Drive 연결 계정에서 접근 가능해야 한다.
- 실측값 응답: `https://docs.google.com/spreadsheets/d/16152xv_1vWnVLSdjE3dCg_jL8fwdwwCUpYMi6fWCg1Q/edit?gid=452258967#gid=452258967`
- 버그 응답: `https://docs.google.com/spreadsheets/d/1XspUqVqHGJmaxQr4s8Uek9pIQMS-Vr3H71H3S82ZMvY/edit?gid=581733973#gid=581733973`
- 기능 건의 응답: `https://docs.google.com/spreadsheets/d/1GroTH_5KtrMQqRt8zvtLcrop-hwD_FVo6w7ZxVS00Qw/edit?gid=1159328779#gid=1159328779`
## 공통 처리
1. `data/feedback-review-state.local.json`의 마지막 처리 시각 이후 응답만 가져온다. 이 파일은 `data/*` 규칙으로 Git에서 제외된다.
2. Form 응답 시각과 행 번호 또는 안정적인 응답 식별자를 함께 기록해 재실행 때 중복 처리하지 않는다.
3. 개인정보와 첨부 원본은 사용자에게 전달하는 요약에 넣지 않는다. 재현과 판단에 필요한 내용만 최소한으로 적는다.
4. 접근 실패나 열 형식 변경이 있으면 데이터를 추정하지 않고 사용자에게 막힌 지점을 알린다.
5. 처리가 끝난 응답까지만 상태 파일의 워터마크를 전진시킨다.
## 실측값 제보
다음 조건을 모두 만족한 제보만 반영 후보로 취급한다.
- 악곡 ID와 난이도가 현재 카탈로그에 존재한다.
- 종합력, 악곡 직접 스코어 보너스, 일반 PERFECT 1노트 점수가 양의 유한 숫자다.
- 스크린샷 등 제보 근거에서 입력 숫자를 교차 확인할 수 있다.
- 동일 조건의 기존 실측과 충돌하지 않는다. 충돌하면 어느 쪽도 자동 반영하지 않고 사용자에게 알린다.
- 보정 비율로 모든 해당 악곡 실측값의 1노트 점수를 정확히 재현한다.
한 실측값으로 새 보정 비율을 만드는 경우 기존 데이터와 같은 식을 사용한다.
```text
scoreRatio = overallPower × (1 + directSongScoreBonusPercent / 100) / normalPerfectScore
```
승인 가능한 제보는 `data/score-calibrations.json`에 측정값을 추가하거나 보정 항목을 만든 뒤 아래 순서로 검증한다.
```powershell
npm run data:validate-score-calibrations
npm run data:apply-score-calibrations
npm run test:engine
npm run check
npm run build
```
어느 단계든 실패하면 변경을 서비스 반영 완료로 표시하지 않는다. 검증이 모두 통과하면 변경 파일과 근거, 검산 결과를 사용자에게 요약한다. 자동 커밋·푸시·배포는 하지 않는다.
## 버그 제보
신규 버그를 재현 가능성, 영향 범위, 중복 여부로 묶는다. 재현할 수 있고 수정 범위가 명확한 버그는 원인을 확인한 뒤 최소 변경으로 직접 수정하고 회귀 테스트를 추가한다. 수정 후 공통 검증 절차를 모두 통과해야만 수정 완료로 보고한다.
다음 경우에는 코드를 변경하지 않고 조사 결과와 필요한 판단을 사용자에게 알린다.
- 재현 정보가 부족하거나 원인을 신뢰성 있게 특정할 수 없다.
- 여러 기능의 동작 정책, 데이터 모델 또는 대규모 구조 변경이 필요하다.
- 외부 서비스 변경, 데이터 마이그레이션, 보안·개인정보 판단이 필요하다.
- 기존 사용자 변경과 충돌해 안전하게 분리할 수 없다.
우선순위는 다음처럼 분류한다.
- 긴급: 데이터 손실, 계산 결과의 중대한 오류, 서비스 사용 불가
- 높음: 주요 기능 실패 또는 넓은 사용자 영향
- 보통: 우회 가능한 기능 오류
- 낮음: 표시 문제, 경미한 불편, 재현 정보 부족
## 기능 건의
유사 건의를 하나로 묶고 사용자 문제, 기대 효과, 예상 영향 범위를 판단한다. 요구사항이 명확하고 기존 설계 안에서 작고 되돌릴 수 있게 구현 가능한 기능은 직접 구현하고 회귀 테스트와 공통 검증 절차를 실행한다.
UI 전면 변경, 새로운 제품 정책, 데이터 구조 변경, 외부 의존성 도입처럼 선택에 따라 결과가 크게 달라지는 기능은 구현안과 예상 변경 범위를 먼저 사용자에게 보고하고 승인을 기다린다.
## 코드 변경 공통 검증
버그 수정 또는 기능 구현 후 다음 명령을 순서대로 실행한다.
```powershell
npm run test:engine
npm run check
npm run build
```
변경과 직접 관련된 추가 테스트가 있으면 함께 실행한다. 어느 단계든 실패하면 완료로 보고하지 않으며, 자동으로 커밋·푸시·배포하지 않는다.
## 매일 알림 형식
- 신규 응답 수: 실측값 / 버그 / 기능
- 실측값: 반영 완료, 보류, 충돌, 검증 실패 각각의 건수와 근거
- 버그: 우선순위별 제목, 재현 결과, 수정·보류 상태와 검증 결과
- 기능: 묶음별 제안, 자동 구현·승인 대기 상태와 검증 결과
- 필요한 사용자 판단: 선택이 필요한 항목만 별도 표시
- 신규 응답이 없으면 짧게 `신규 제보 없음`으로 보고
+1 -1
View File
@@ -44,7 +44,7 @@ npm run deploy:pages
`.gitea/workflows/deploy-pages.yml`은 `main` 갱신, 수동 실행, 5분 간격 예약 실행에서 공개 가능 여부를 확인합니다. 공개 시각 전이면 정상적으로 배포를 건너뛰고, 승인 버전 불일치나 날짜 누락 같은 정책 오류는 실패로 처리합니다. 검증을 통과하면 정적 사이트를 만든 뒤 생성 파일만 GitHub Pages 저장소 최상위에 커밋합니다.
이 워크플로는 더 이상 사용하지 않는 서버용 온라인 빌드를 만들지 않습니다. 오프라인 빌드도 로컬 회귀 테스트용으로만 유지하며 GitHub Pages 배포에서는 `build:pages`만 실행합니다.
이 워크플로는 서버용 빌드나 별도의 오프라인 배포물을 만들지 않고 GitHub Pages용 `build:pages`만 실행합니다.
사전 준비:
+2 -3
View File
@@ -9,12 +9,10 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 5173",
"build": "vite build",
"build:offline": "svelte-kit sync && vite build --config vite.offline.config.ts && node scripts/finalize-offline-build.mjs",
"check:pages-data": "node scripts/verify-pages-public-data.mjs",
"build:pages": "svelte-kit sync && npm run check:pages-data && vite build --config vite.pages.config.ts && node scripts/finalize-offline-build.mjs pages-dist",
"build:pages": "svelte-kit sync && npm run check:pages-data && vite build --config vite.pages.config.ts && node scripts/finalize-pages-build.mjs",
"deploy:pages": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/deploy-pages.ps1",
"publish:pages": "node scripts/publish-pages.mjs",
"package:offline": "npm run build:offline && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-offline.ps1",
"preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:engine": "node --test src/lib/engine/*.test.ts src/lib/analytics/*.test.ts",
@@ -22,6 +20,7 @@
"data:sync": "node scripts/collect-holodoridb.mjs",
"data:import-songs": "node scripts/import-legacy-song-data.mjs",
"data:collect-charts": "node scripts/collect-exact-charts.mjs",
"data:validate-score-calibrations": "node scripts/validate-score-calibrations.mjs",
"data:apply-score-calibrations": "node scripts/apply-score-calibrations.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
+4 -1
View File
@@ -1,5 +1,6 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { assertValidScoreCalibrations } from './score-calibration-validation.mjs';
const calibrationPath = path.resolve('data/score-calibrations.json');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
@@ -9,6 +10,8 @@ const calibrations = JSON.parse(await readFile(calibrationPath, 'utf8'));
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
const catalogRows = Array.isArray(catalog) ? catalog : catalog.charts;
await assertValidScoreCalibrations(calibrations, { chartDirectory });
for (const calibration of calibrations.charts) {
const difficulty = calibration.difficulty.toLowerCase();
const catalogRow = catalogRows.find((row) =>
@@ -25,7 +28,7 @@ for (const calibration of calibrations.charts) {
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
chart.scoreRatio = calibration.scoreRatio;
chart.scoreCalibrationSource = 'measured';
await writeFile(chartPath, `${JSON.stringify(chart)}\n`);
await writeFile(chartPath, JSON.stringify(chart));
}
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
+1 -2
View File
@@ -53,8 +53,7 @@ try {
return
}
$package = Get-Content -LiteralPath (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
& git commit -m ("deploy: HolodoriCalc v{0}" -f $package.version)
& git commit -m 'deploy: update HolodoriCalc'
if ($LASTEXITCODE -ne 0) { throw 'Pages commit failed.' }
& git push origin HEAD:main
if ($LASTEXITCODE -ne 0) { throw 'Pages push failed.' }
@@ -1,12 +1,10 @@
import { readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
const output = path.resolve(process.argv[2] ?? 'offline-dist');
const output = path.resolve('pages-dist');
const htmlPath = path.join(output, 'index.html');
const scriptPath = path.join(output, 'assets', 'app.js');
const stylePath = path.join(output, 'assets', 'app.css');
const packageJson = JSON.parse(await readFile(path.resolve('package.json'), 'utf8'));
const isPagesBuild = path.basename(output) === 'pages-dist';
let html = await readFile(htmlPath, 'utf8');
html = html.replace(/<script type="module" crossorigin src=/g, '<script defer src=');
@@ -20,7 +18,6 @@ await writeFile(scriptPath, script);
let style = await readFile(stylePath, 'utf8');
style = style.replaceAll('/icons/', '../icons/');
style += `
/* file://에서는 CSS 마스크 대신 읽기 쉬운 텍스트 뱃지를 사용한다. */
.description-stat-icon { display: none !important; }
.stat-badge .stat-icon,
.filter-options label .stat-icon {
@@ -44,22 +41,11 @@ await writeFile(stylePath, style);
// 채보는 app.js에 포함되므로 public 디렉터리에서 복사된 중복 파일은 제거한다.
await rm(path.join(output, 'data'), { recursive: true, force: true });
await writeFile(path.join(output, 'VERSION.txt'), `${packageJson.version}\n`);
await writeFile(path.join(output, 'README.txt'), isPagesBuild
? `홀로도리 계산기 ${packageJson.version}
await rm(path.join(output, 'VERSION.txt'), { force: true });
await writeFile(path.join(output, 'README.txt'), `홀로도리 계산기
서비스: https://doricalc-holo.live/
이 파일들은 GitHub Pages 배포용으로 자동 생성되었습니다.
계산과 사용자 데이터 저장은 사용자의 브라우저에서 처리됩니다.
`
: `홀로도리 계산기 ${packageJson.version}
1. 압축파일의 내용을 모두 한 폴더에 풀어 주세요.
2. index.html을 Chrome 또는 Edge로 열어 주세요.
3. assets와 icons 폴더는 index.html과 같은 위치에 그대로 두어야 합니다.
계산과 데이터 저장은 사용자의 브라우저에서 처리됩니다.
새 버전으로 교체하기 전에는 사용자 데이터 화면에서 백업 파일을 저장해 주세요.
`);
-19
View File
@@ -1,19 +0,0 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$package = Get-Content -LiteralPath (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
$source = Join-Path $projectRoot 'offline-dist'
$releaseDirectory = Join-Path $projectRoot 'release'
$archive = Join-Path $releaseDirectory ("HolodoriCalc-offline-v{0}.zip" -f $package.version)
if (-not (Test-Path -LiteralPath $source)) {
throw '오프라인 빌드 결과가 없습니다.'
}
New-Item -ItemType Directory -Path $releaseDirectory -Force | Out-Null
if (Test-Path -LiteralPath $archive) {
Remove-Item -LiteralPath $archive -Force
}
Compress-Archive -Path (Join-Path $source '*') -DestinationPath $archive -CompressionLevel Optimal
Write-Output $archive
+2 -3
View File
@@ -1,4 +1,4 @@
import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { cp, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
@@ -63,8 +63,7 @@ try {
if (diffCode === 0) {
process.stdout.write('GitHub Pages output is already up to date.\n');
} else if (diffCode === 1) {
const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8'));
await requireGit(['commit', '-m', `deploy: HolodoriCalc v${packageJson.version}`], { cwd: checkout, env: gitEnvironment });
await requireGit(['commit', '-m', 'deploy: update HolodoriCalc'], { cwd: checkout, env: gitEnvironment });
await requireGit(['push', 'origin', 'HEAD:main'], { cwd: checkout, env: gitEnvironment });
} else {
throw new Error(`git diff failed with exit code ${diffCode}.`);
+80
View File
@@ -0,0 +1,80 @@
import { access } from 'node:fs/promises';
import path from 'node:path';
import { finalNoteScore } from '../src/lib/engine/score.ts';
const DIFFICULTIES = new Set(['easy', 'normal', 'hard', 'expert']);
function assertFiniteNumber(value, label, { minimum = -Infinity, maximum = Infinity } = {}) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) {
throw new Error(`${label} 값이 유효하지 않습니다: ${JSON.stringify(value)}`);
}
}
export async function assertValidScoreCalibrations(calibrations, options = {}) {
const chartDirectory = options.chartDirectory ?? path.resolve('static/data/charts');
if (calibrations?.schemaVersion !== 1 || !Array.isArray(calibrations.charts)) {
throw new Error('score-calibrations.json은 schemaVersion 1과 charts 배열이 필요합니다.');
}
const chartKeys = new Set();
for (const [chartIndex, calibration] of calibrations.charts.entries()) {
const prefix = `charts[${chartIndex}]`;
if (typeof calibration.songId !== 'string' || !/^m\d{4}$/.test(calibration.songId)) {
throw new Error(`${prefix}.songId 형식이 올바르지 않습니다: ${JSON.stringify(calibration.songId)}`);
}
const difficulty = typeof calibration.difficulty === 'string'
? calibration.difficulty.toLowerCase()
: '';
if (!DIFFICULTIES.has(difficulty)) {
throw new Error(`${prefix}.difficulty가 올바르지 않습니다: ${JSON.stringify(calibration.difficulty)}`);
}
const chartKey = `${calibration.songId}.${difficulty}`;
if (chartKeys.has(chartKey)) throw new Error(`중복 실측 보정 항목입니다: ${chartKey}`);
chartKeys.add(chartKey);
assertFiniteNumber(calibration.scoreRatio, `${prefix}.scoreRatio`, { minimum: Number.EPSILON });
if (!Array.isArray(calibration.measurements) || calibration.measurements.length === 0) {
throw new Error(`${prefix}.measurements에는 한 개 이상의 실측값이 필요합니다.`);
}
const measurementKeys = new Set();
for (const [measurementIndex, measurement] of calibration.measurements.entries()) {
const measurementPrefix = `${prefix}.measurements[${measurementIndex}]`;
assertFiniteNumber(measurement.overallPower, `${measurementPrefix}.overallPower`, { minimum: 1 });
assertFiniteNumber(
measurement.directSongScoreBonusPercent,
`${measurementPrefix}.directSongScoreBonusPercent`,
{ minimum: 0, maximum: 100 }
);
assertFiniteNumber(measurement.normalPerfectScore, `${measurementPrefix}.normalPerfectScore`, { minimum: 1 });
const measurementKey = [
measurement.overallPower,
measurement.directSongScoreBonusPercent,
measurement.normalPerfectScore
].join(':');
if (measurementKeys.has(measurementKey)) {
throw new Error(`${chartKey}에 중복 실측값이 있습니다: ${measurementKey}`);
}
measurementKeys.add(measurementKey);
const reproducedScore = finalNoteScore(
measurement.overallPower / calibration.scoreRatio,
measurement.directSongScoreBonusPercent
);
if (reproducedScore !== measurement.normalPerfectScore) {
throw new Error(
`${chartKey} 실측값을 재현하지 못했습니다: 기대 ${measurement.normalPerfectScore}, 계산 ${reproducedScore}`
);
}
}
if (difficulty === 'hard' || difficulty === 'expert') {
await access(path.join(chartDirectory, `${chartKey}.json`));
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { assertValidScoreCalibrations } from './score-calibration-validation.mjs';
const calibrationPath = path.resolve('data/score-calibrations.json');
const calibrations = JSON.parse(await readFile(calibrationPath, 'utf8'));
await assertValidScoreCalibrations(calibrations);
console.log(`${calibrations.charts.length}개 악곡 실측 보정과 원본 실측값을 검산했습니다.`);
+1 -74
View File
@@ -1,23 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dev } from '$app/environment';
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
import { versionTagIsNewer } from '$lib/release-notes/version';
import { parseUpdateHistoryDocument } from '$lib/update-history-document.ts';
let previewUpdate = false;
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
onMount(() => {
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
void releaseNotes.load();
});
$: latestRelease = $releaseNotes.releases[0] ?? null;
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
function updatedDateTime(value: string | null): string {
if (!value) return '시각 미등록';
const date = new Date(value);
@@ -32,91 +18,32 @@
}).format(date);
}
function publishedDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
}
</script>
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
<aside class="update-notice" aria-live="polite">
<div class="notice-header">
<div class="version-summary">
<span class="label">최근 업데이트</span>
<strong><time datetime={updateHistoryDocument.updatedAt ?? undefined}>{updatedDateTime(updateHistoryDocument.updatedAt)}</time></strong>
{#if previewUpdate}
<span class="status"><b>업데이트 알림 미리보기</b></span>
{:else if updateAvailable}
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
{/if}
</div>
<a class="action" href="/updates">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
</div>
{#if updateAvailable && latestRelease}
<section class="latest-note" aria-labelledby="latest-release-title">
<header>
<div>
<span class="release-tag">{latestRelease.tagName}</span>
<h2 id="latest-release-title">{latestRelease.name}</h2>
</div>
<time datetime={latestRelease.publishedAt}>{publishedDate(latestRelease.publishedAt)}</time>
</header>
{#if latestRelease.body.trim()}
<div class="markdown-body">{@html renderReleaseMarkdown(latestRelease.body)}</div>
{:else}
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
{/if}
</section>
{/if}
</aside>
<style>
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 6%); }
.notice-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px; }
.version-summary { display: flex; align-items: baseline; min-width: 0; gap: 10px; }
.label { color: #7f8998; font-size: 13px; font-weight: 800; }
strong { color: #eef1f5; font-size: 15px; }
.status { color: #a4adba; font-size: 14px; }
.status b { color: #f0a1c3; }
.action { flex: 0 0 auto; color: #aab3c0; font-size: 14px; font-weight: 800; text-decoration: none; }
.available .action { color: #f2a6c6; }
.action span { margin-left: 4px; }
.action:hover { color: #ffd0e2; }
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
.latest-note { padding: 24px 26px 28px; border-top: 1px solid rgb(237 139 181 / 20%); background: rgb(13 16 22 / 38%); }
.latest-note > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid rgb(255 255 255 / 8%); }
.release-tag { color: #f0a1c3; font-size: 13px; font-weight: 900; }
.latest-note h2 { margin: 6px 0 0; color: #edf0f5; font-size: 22px; letter-spacing: -.025em; }
.latest-note time { flex: 0 0 auto; color: #858f9e; font-size: 13px; }
.markdown-body { padding-top: 18px; color: #b7bfca; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body :global(:first-child) { margin-top: 0; }
.markdown-body :global(:last-child) { margin-bottom: 0; }
.markdown-body :global(h1), .markdown-body :global(h2), .markdown-body :global(h3), .markdown-body :global(h4) { margin: 1.35em 0 .5em; color: #eef1f5; line-height: 1.35; }
.markdown-body :global(h1) { font-size: 22px; }
.markdown-body :global(h2) { font-size: 19px; }
.markdown-body :global(h3) { font-size: 17px; }
.markdown-body :global(p), .markdown-body :global(ul), .markdown-body :global(ol), .markdown-body :global(blockquote), .markdown-body :global(pre) { margin: .75em 0; }
.markdown-body :global(ul), .markdown-body :global(ol) { padding-left: 1.5em; }
.markdown-body :global(a) { color: #f0a1c3; text-underline-offset: 3px; }
.markdown-body :global(code) { padding: 2px 5px; border-radius: 5px; color: #e8b3ca; background: #202631; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; }
.markdown-body :global(pre) { padding: 14px; border: 1px solid #303846; border-radius: 9px; background: #10141a; overflow-x: auto; }
.markdown-body :global(pre code) { padding: 0; color: #c9d0da; background: transparent; }
.markdown-body :global(blockquote) { padding-left: 14px; border-left: 3px solid #596475; color: #929cab; }
.markdown-body :global(img) { max-width: 100%; border-radius: 9px; }
.no-notes { margin: 18px 0 0; color: #818b99; font-size: 14px; }
@media (max-width: 680px) {
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
.version-summary { align-items: flex-start; flex-wrap: wrap; }
.latest-note { padding: 22px 18px 24px; }
.latest-note > header { flex-direction: column; gap: 8px; }
}
</style>
+14 -14
View File
@@ -264,8 +264,8 @@
"difficulty": "expert",
"difficultyLevel": 25,
"durationSeconds": 108,
"scoreRatio": 265.0822413749041,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 252.13563829787233,
"scoreCalibrationSource": "measured",
"totalNotes": 877,
"singerType": "solo",
"singerCharacterIds": [
@@ -744,8 +744,8 @@
"difficulty": "expert",
"difficultyLevel": 24,
"durationSeconds": 97,
"scoreRatio": 192.63348422390104,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 195.1219628964692,
"scoreCalibrationSource": "measured",
"totalNotes": 596,
"singerType": "solo",
"singerCharacterIds": [
@@ -840,8 +840,8 @@
"difficulty": "expert",
"difficultyLevel": 25,
"durationSeconds": 133,
"scoreRatio": 280.8271276035182,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 280.4822485207101,
"scoreCalibrationSource": "measured",
"totalNotes": 1022,
"singerType": "solo",
"singerCharacterIds": [
@@ -1000,8 +1000,8 @@
"difficulty": "expert",
"difficultyLevel": 24,
"durationSeconds": 130,
"scoreRatio": 279.0075534874758,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 290.2132653061224,
"scoreCalibrationSource": "measured",
"totalNotes": 947,
"singerType": "solo",
"singerCharacterIds": [
@@ -2538,8 +2538,8 @@
"difficulty": "expert",
"difficultyLevel": 26,
"durationSeconds": 139,
"scoreRatio": 379.73397776840375,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 373.73061760841,
"scoreCalibrationSource": "measured",
"totalNotes": 1159,
"singerType": "solo",
"singerCharacterIds": [
@@ -5252,8 +5252,8 @@
"difficulty": "expert",
"difficultyLevel": 27,
"durationSeconds": 136,
"scoreRatio": 313.7465654376337,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 316.33724653148346,
"scoreCalibrationSource": "measured",
"totalNotes": 1088,
"singerType": "group",
"singerCharacterIds": [
@@ -5356,8 +5356,8 @@
"difficulty": "expert",
"difficultyLevel": 26,
"durationSeconds": 104,
"scoreRatio": 305.22427442474174,
"scoreCalibrationSource": "local-estimated-v1",
"scoreRatio": 292.9459119496856,
"scoreCalibrationSource": "measured",
"totalNotes": 921,
"singerType": "solo",
"singerCharacterIds": [
+15
View File
@@ -0,0 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { cardMatchesSearch } from './card-search.ts';
test('IRyS와 AZKi를 한글 별칭으로 검색한다', () => {
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '아이리스'), true);
assert.equal(cardMatchesSearch({ name: 'AZKi' }, '아즈키'), true);
});
test('기존 영문 이름과 버전 검색을 유지한다', () => {
assert.equal(cardMatchesSearch({ name: 'IRyS', versionName: '수영복' }, 'irys'), true);
assert.equal(cardMatchesSearch({ name: 'IRyS', versionName: '수영복' }, '수영복'), true);
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '이리스'), false);
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '아즈키'), false);
});
+24
View File
@@ -0,0 +1,24 @@
export type SearchableCard = {
name: string;
versionName?: string | null;
};
const memberAliases: Readonly<Record<string, readonly string[]>> = {
irys: ['아이리스'],
azki: ['아즈키']
};
function normalizeSearchText(value: string): string {
return value.trim().toLocaleLowerCase('ko-KR').replace(/\s+/g, ' ');
}
export function cardMatchesSearch(card: SearchableCard, query: string): boolean {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return true;
const aliases = memberAliases[normalizeSearchText(card.name)] ?? [];
const nativeText = normalizeSearchText([card.name, card.versionName ?? ''].join(' '));
return (
nativeText.includes(normalizedQuery) ||
aliases.some((alias) => normalizeSearchText(alias).startsWith(normalizedQuery))
);
}
+10 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { events } from '../data/events.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventPointIndex } from './event-optimization.ts';
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventOptimizationCardEligible, eventPointIndex } from './event-optimization.ts';
const currentEvent = events[0];
@@ -11,6 +11,15 @@ test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () =>
assert.equal(activeEventAt(events, Date.parse('2026-09-06T11:00:00.000Z')), null);
});
test('이벤트 최적화는 모든 4·5성과 보너스 멤버의 저등급 카드를 후보로 사용한다', () => {
const bonusCharacters = new Set(['bonus']);
assert.equal(eventOptimizationCardEligible({ id: 'five', characterId: 'other', rarity: 5 }, bonusCharacters), true);
assert.equal(eventOptimizationCardEligible({ id: 'four', characterId: 'other', rarity: 4 }, bonusCharacters), true);
assert.equal(eventOptimizationCardEligible({ id: 'bonus-three', characterId: 'bonus', rarity: 3 }, bonusCharacters), true);
assert.equal(eventOptimizationCardEligible({ id: 'regular-three', characterId: 'other', rarity: 3 }, bonusCharacters), false);
});
test('편성·신규 카드·개화 보너스를 카드별로 합산한다', () => {
const cards = [
{ id: currentEvent.featuredCardIds[0], characterId: currentEvent.bonusCharacterIds[0], rarity: 5 },
+4
View File
@@ -25,6 +25,10 @@ export type EventBonusBreakdown = {
totalPercent: number;
};
export function eventOptimizationCardEligible(card: EventCard, bonusCharacterIds: ReadonlySet<string>): boolean {
return card.rarity === 5 || card.rarity === 4 || bonusCharacterIds.has(card.characterId);
}
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null {
return events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt)) ?? null;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './active-probability.ts';
export * from './card-adapter.ts';
export * from './card-search.ts';
export * from './combination-search.ts';
export * from './awakening-requirements.ts';
export * from './board-effects.ts';
@@ -1,67 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderReleaseMarkdown } from './markdown.ts';
import { ReleaseNoteService, type ReleaseNoteConfig } from './release-note-service.ts';
import { versionTagIsNewer, versionTagMatches } from './version.ts';
const config: ReleaseNoteConfig = {
owner: 'owner',
repo: 'repo',
includePrereleases: false,
perPage: 30,
timeoutMs: 100
};
function response(payload: unknown, ok = true): Response {
return { ok, json: async () => payload } as Response;
}
test('정식 릴리즈만 최신순으로 변환하고 앱 실행 중 결과를 재사용한다', async () => {
let requests = 0;
const service = new ReleaseNoteService(config, async () => {
requests += 1;
return response([
{ id: 1, tag_name: 'v1.0.0', name: null, published_at: '2026-01-01T00:00:00Z', body: null, html_url: 'https://example.com/1', draft: false, prerelease: false },
{ id: 3, tag_name: 'v1.2.0-beta', name: 'beta', published_at: '2026-03-01T00:00:00Z', body: '', html_url: 'https://example.com/3', draft: false, prerelease: true },
{ id: 2, tag_name: 'v1.1.0', name: 'one one', published_at: '2026-02-01T00:00:00Z', body: '# 변경', html_url: 'https://example.com/2', draft: false, prerelease: false },
{ id: 4, tag_name: 'v9.0.0', name: 'draft', published_at: '2026-04-01T00:00:00Z', body: '', html_url: 'https://example.com/4', draft: true, prerelease: false }
]);
});
const first = await service.load({ online: true });
const second = await service.load({ online: true });
assert.equal(requests, 1);
assert.deepEqual(first, second);
assert.deepEqual(first?.map((release) => release.tagName), ['v1.1.0', 'v1.0.0']);
assert.equal(first?.[1].name, 'v1.0.0');
assert.equal(first?.[1].body, '');
});
test('오프라인 및 API 실패는 예외 없이 unavailable 값으로 캐시한다', async () => {
let requests = 0;
const service = new ReleaseNoteService(config, async () => {
requests += 1;
return response({}, false);
});
assert.equal(await service.load({ online: false }), null);
assert.equal(await service.load({ online: true }), null);
assert.equal(requests, 0);
});
test('현재 버전 표시는 v 접두사와 빌드 메타데이터를 무시한다', () => {
assert.equal(versionTagMatches('v1.0.4', '1.0.4'), true);
assert.equal(versionTagMatches('1.0.4+offline', 'v1.0.4'), true);
assert.equal(versionTagMatches('v1.0.3', '1.0.4'), false);
assert.equal(versionTagIsNewer('v1.1.0', '1.0.4'), true);
assert.equal(versionTagIsNewer('v1.0.4', '1.0.4'), false);
assert.equal(versionTagIsNewer('v1.0.3', '1.0.4'), false);
});
test('릴리즈 Markdown의 원문 HTML과 위험한 링크를 실행 가능한 태그로 만들지 않는다', () => {
const rendered = renderReleaseMarkdown('<script>alert(1)</script> [위험](javascript:alert(1))');
assert.equal(rendered.includes('<script>'), false);
assert.equal(rendered.includes('href="javascript:'), false);
assert.match(rendered, /&lt;script&gt;/);
});
@@ -1,137 +0,0 @@
export type ReleaseNote = {
id: number;
tagName: string;
name: string;
publishedAt: string;
body: string;
url: string;
};
export type ReleaseNoteConfig = {
owner: string;
repo: string;
includePrereleases: boolean;
perPage: number;
timeoutMs: number;
};
type GitHubRelease = {
id?: unknown;
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
draft?: unknown;
prerelease?: unknown;
};
type LoadOptions = {
online?: boolean;
};
export const RELEASE_NOTE_CONFIG: ReleaseNoteConfig = {
owner: 'DD4SN',
repo: 'HolodoriCalc',
// 정식 릴리즈만 노출한다. 베타 릴리즈도 보여주려면 true로 변경한다.
includePrereleases: false,
perPage: 30,
timeoutMs: 6_000
};
export const RELEASES_PAGE_URL = `https://github.com/${RELEASE_NOTE_CONFIG.owner}/${RELEASE_NOTE_CONFIG.repo}/releases`;
function browserIsOnline(): boolean {
return typeof navigator !== 'undefined'
&& navigator.onLine
&& typeof location !== 'undefined'
&& location.protocol !== 'file:';
}
function toReleaseNote(value: GitHubRelease): ReleaseNote | null {
if (typeof value.id !== 'number'
|| typeof value.tag_name !== 'string'
|| typeof value.published_at !== 'string'
|| typeof value.html_url !== 'string') return null;
return {
id: value.id,
tagName: value.tag_name,
name: typeof value.name === 'string' && value.name.trim() ? value.name : value.tag_name,
publishedAt: value.published_at,
body: typeof value.body === 'string' ? value.body : '',
url: value.html_url
};
}
export class ReleaseNoteService {
private readonly config: ReleaseNoteConfig;
private readonly fetcher: typeof fetch | undefined;
private cached: ReleaseNote[] | null | undefined;
private pending: Promise<ReleaseNote[] | null> | null = null;
constructor(
config: ReleaseNoteConfig = RELEASE_NOTE_CONFIG,
fetcher: typeof fetch | undefined = globalThis.fetch?.bind(globalThis)
) {
this.config = config;
this.fetcher = fetcher;
}
get releasesUrl(): string {
return `https://github.com/${this.config.owner}/${this.config.repo}/releases`;
}
async load(options: LoadOptions = {}): Promise<ReleaseNote[] | null> {
if (this.cached !== undefined) return this.cached;
if (this.pending) return this.pending;
const online = options.online ?? browserIsOnline();
if (!online || !this.fetcher) {
this.cached = null;
return null;
}
this.pending = this.request();
try {
this.cached = await this.pending;
return this.cached;
} finally {
this.pending = null;
}
}
private async request(): Promise<ReleaseNote[] | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
const apiUrl = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/releases?per_page=${this.config.perPage}`;
try {
const response = await this.fetcher!(apiUrl, {
headers: {
accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
},
signal: controller.signal
});
if (!response.ok) return null;
const payload: unknown = await response.json();
if (!Array.isArray(payload)) return null;
return payload
.filter((release): release is GitHubRelease => Boolean(release) && typeof release === 'object')
.filter((release) => release.draft !== true)
.filter((release) => this.config.includePrereleases || release.prerelease !== true)
.map(toReleaseNote)
.filter((release): release is ReleaseNote => release !== null)
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt));
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
}
export const releaseNoteService = new ReleaseNoteService();
@@ -1,34 +0,0 @@
import { writable } from 'svelte/store';
import packageInfo from '../../../package.json';
import { releaseNoteService, type ReleaseNote } from './release-note-service';
import { versionTagMatches } from './version';
export type ReleaseNoteStatus = 'idle' | 'loading' | 'available' | 'unavailable';
export type ReleaseNoteState = {
status: ReleaseNoteStatus;
releases: ReleaseNote[];
};
const state = writable<ReleaseNoteState>({ status: 'idle', releases: [] });
let loadStarted = false;
export const currentAppVersion = packageInfo.version;
export const releaseNotes = {
subscribe: state.subscribe,
async load(): Promise<void> {
if (loadStarted) return;
loadStarted = true;
state.set({ status: 'loading', releases: [] });
const loaded = await releaseNoteService.load();
state.set(loaded && loaded.length > 0
? { status: 'available', releases: loaded }
: { status: 'unavailable', releases: [] });
}
};
export function isCurrentVersionTag(tagName: string, currentVersion = currentAppVersion): boolean {
return versionTagMatches(tagName, currentVersion);
}
-20
View File
@@ -1,20 +0,0 @@
export function normalizedVersion(version: string): string {
return version.trim().replace(/^v/i, '').split('+', 1)[0];
}
export function versionTagMatches(tagName: string, currentVersion: string): boolean {
return normalizedVersion(tagName) === normalizedVersion(currentVersion);
}
export function versionTagIsNewer(tagName: string, currentVersion: string): boolean {
const candidate = normalizedVersion(tagName).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
const current = normalizedVersion(currentVersion).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
const length = Math.max(candidate.length, current.length, 3);
for (let index = 0; index < length; index += 1) {
const difference = (candidate[index] ?? 0) - (current[index] ?? 0);
if (difference !== 0) return difference > 0;
}
return false;
}
+3 -2
View File
@@ -21,6 +21,7 @@
effectiveActiveProbability,
eventBonusBreakdown,
eventChallengeSongScoreBonusPercent,
eventOptimizationCardEligible,
eventPointIndex,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
@@ -102,7 +103,7 @@
const fiveStarCards = allCards.filter((card) => card.rarity === 5);
const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds]));
const eligibleCards = eventMode
? allCards.filter((card) => card.rarity === 5 || eventBonusCharacters.has(card.characterId))
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
: fiveStarCards;
const cardById = new Map(allCards.map((card) => [card.id, card]));
const allCharts = exactChartCatalog.charts;
@@ -428,7 +429,7 @@
}
function cardLabel(card: CardRow): string {
return `${collection[card.id]?.owned ? '● ' : ''}${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
return `${collection[card.id]?.owned ? '● ' : ''}${card.rarity}★ ${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
}
function updateFixedCard(slot: number, cardId: string): void {
+68 -6
View File
@@ -4,7 +4,7 @@
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import { calculateBoardEffects, calculateUnitPower, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
import { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
@@ -39,6 +39,7 @@
coreCubeFreeOnly: boolean;
};
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
type BoardMaterialTotal = { resourceId: string; label: string; quantity: number };
type GreenNodeEfficiency = {
memberId: string;
memberName: string;
@@ -68,6 +69,16 @@
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
const GREEN_CORE_CUBE_ID = 'item-skill_tree_allmember-2';
const boardMaterialLabels: Readonly<Record<string, string>> = {
'item-skill_tree_card-1': '블루 큐브',
'item-skill_tree_card-2': '블루 코어 큐브',
'item-skill_tree_allmember-1': '그린 큐브',
'item-skill_tree_allmember-2': '그린 코어 큐브',
'item-skill_tree_leader-1': '레드 큐브',
'item-skill_tree_leader-2': '레드 코어 큐브',
'item-skill_tree_content-1': '옐로 큐브',
'item-skill_tree_content-2': '옐로 코어 큐브'
};
const allCards = cardSummary.cards;
const boardMembers = boardSummary.members as BoardMember[];
const memberOrderByName = new Map(boardMembers.map((member) => [member.name, member.order]));
@@ -131,7 +142,7 @@
$: collectionCount = Object.values(userData.collection).filter((state) => state.owned).length;
$: boardCount = Object.values(userData.memberBoards).filter((nodes) => nodes.length > 0).length;
$: memoryPermil = memoryParameterPermil(userData.memoryCount, memorySummary.effects);
$: filteredCards = managedCards.filter((card) => `${card.name} ${card.versionName}`.toLowerCase().includes(search.trim().toLowerCase()));
$: filteredCards = managedCards.filter((card) => cardMatchesSearch(card, search));
$: filteredFiveStarCards = filteredCards.filter((card) => card.rarity === 5);
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
@@ -148,9 +159,10 @@
selectedBoardMember?.positionGroupId ?? ''
);
$: filteredConnectCards = connectCards.filter((card) => {
const query = boardCardSearch.trim().toLowerCase();
return !query || `${card.name} ${card.versionName}`.toLowerCase().includes(query);
return cardMatchesSearch(card, boardCardSearch);
});
$: selectedBoardMaterialTotals = boardMaterialTotals(selectedMemberId, selectedActiveBoardNodes);
$: selectedBoardPointTotal = selectedActiveBoardNodes.reduce((total, groupId) => total + (nodeForBoardMember(selectedMemberId, groupId)?.skillTreePointCost ?? 0), 0);
$: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId
? fiveStarCards.filter((card) => card.characterId === efficiencyFormation.leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
@@ -574,6 +586,20 @@
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
}
function boardMaterialTotals(memberId: string, groupIds: readonly string[]): BoardMaterialTotal[] {
const totals = new Map<string, number>();
for (const groupId of groupIds) {
const node = nodeForBoardMember(memberId, groupId);
for (const consumption of node?.consumptions ?? []) {
const quantity = Number(consumption.quantity) || 0;
totals.set(consumption.resourceId, (totals.get(consumption.resourceId) ?? 0) + quantity);
}
}
return [...totals.entries()]
.filter(([, quantity]) => quantity > 0)
.map(([resourceId, quantity]) => ({ resourceId, quantity, label: boardMaterialLabels[resourceId] ?? resourceId }));
}
function currentFormationPower(memberBoards: Record<string, string[]>, boardCards: BoardCardPlacements = userData.boardCards): number {
if (!efficiencyFormationReady) return 0;
const members = efficiencyFormation.cardIds.map((id, index) => ({
@@ -901,6 +927,21 @@
function assignBoardCard(cardId: string) {
if (!selectedSocketGroupId) return;
const destinationCardId = selectedBoardCardPlacements[selectedSocketGroupId];
const sourcePlacement = Object.entries(userData.boardCards).flatMap(([memberId, placements]) =>
Object.entries(placements).map(([socketId, placedCardId]) => ({ memberId, socketId, placedCardId }))
).find((placement) => placement.placedCardId === cardId
&& !(placement.memberId === selectedMemberId && placement.socketId === selectedSocketGroupId));
const warnings = [];
if (destinationCardId && destinationCardId !== cardId) {
const destinationCard = connectCardById.get(destinationCardId);
warnings.push(`현재 ${destinationCard ? `${destinationCard.rarity}★ ${cardLabel(destinationCard)}` : destinationCardId} 카드를 덮어씁니다.`);
}
if (sourcePlacement) {
const sourceMember = boardMembers.find((member) => member.id === sourcePlacement.memberId)?.name ?? sourcePlacement.memberId;
warnings.push(`${sourceMember} 보드에 배치된 같은 카드를 이곳으로 이동합니다.`);
}
if (warnings.length > 0 && !window.confirm(`${warnings.join('\n')}\n계속할까요?`)) return;
automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
@@ -1278,7 +1319,7 @@
</section>
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
<div class="efficiency-column-heading">
<div><h3 id="removal-efficiency-title">초기화 검토</h3><p>환급 큐브 대비 종합력 손실이 낮은 순서</p></div>
<div><h3 id="removal-efficiency-title">초기화 검토</h3><p>환급 큐브 대비 종합력 손실이 낮은 순서 · 기수 보너스 상한 초과분이나 현재 편성에 적용되지 않는 노드는 손실이 0일 수 있습니다.</p></div>
</div>
{#if greenEfficiencyResult.removals.length > 0}
<div class="efficiency-list">
@@ -1287,7 +1328,7 @@
<button type="button" on:click={() => focusEfficiencyNode(entry)}>
<span class="efficiency-rank">{index + 1}</span>
<span class="efficiency-node"><small>{entry.memberName}</small><strong>{entry.description}</strong></span>
<span class="efficiency-power"><strong>-{entry.powerDelta.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-power"><strong>{entry.powerDelta === 0 ? '0 · 영향 없음' : `-${entry.powerDelta.toLocaleString('ko-KR')}`}</strong></span>
<span class="efficiency-node-count"><strong>{entry.changedNodeCount}</strong></span>
<span class="efficiency-cost"><strong>{entry.cost.greenCubes.toLocaleString('ko-KR')} / {entry.cost.greenCoreCubes.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-rate"><strong>{formatEfficiency(entry.greenCubeEfficiency)}</strong></span>
@@ -1308,6 +1349,10 @@
</select>
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
</label>
<div class="member-navigation" aria-label="멤버 보드 이동">
<button type="button" on:click={() => selectAdjacentBoardMember(-1)} aria-label="이전 멤버 보드">‹ 이전</button>
<button type="button" on:click={() => selectAdjacentBoardMember(1)} aria-label="다음 멤버 보드">다음 ›</button>
</div>
</div>
{#if selectedBoardMember}
@@ -1315,6 +1360,13 @@
<p><strong>{selectedActiveBoardNodeCount}</strong> / {selectedBoardNodeCount}개 활성화</p>
<div><button type="button" on:click={activateAllBlueNodes}>모든 파란 노드 활성화</button><button type="button" on:click={() => setAllBoardNodes(true)}>모두 활성화</button><button type="button" on:click={() => setAllBoardNodes(false)}>모두 해제</button></div>
</div>
<div class="board-material-summary" aria-label="활성 보드 재료 총합">
<span><small>노드 포인트</small><strong>{selectedBoardPointTotal.toLocaleString('ko-KR')}</strong></span>
{#each selectedBoardMaterialTotals as material (material.resourceId)}
<span><small>{material.label}</small><strong>{material.quantity.toLocaleString('ko-KR')}</strong></span>
{/each}
{#if selectedBoardMaterialTotals.length === 0}<p>활성 노드에 사용된 재료가 없습니다.</p>{/if}
</div>
<div
class="board-scroll"
class:dragging={boardDragging}
@@ -1533,11 +1585,19 @@
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
.member-select small { color: #6f7888; font-size: 11px; }
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
.member-navigation { display: flex; gap: 7px; }
.member-navigation button { min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #465064; border-radius: 8px; color: #d5dbe4; background: #202733; font-size: 13px; font-weight: 850; cursor: pointer; }
.member-navigation button:hover { border-color: #d96b9a; color: #f4b0cc; }
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
.board-toolbar p { margin: 0; color: #929cad; font-size: 14px; }
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
.board-toolbar div { display: flex; gap: 6px; }
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
.board-material-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 12px; padding: 10px; border: 1px solid #303a47; border-radius: 10px; background: #131922; }
.board-material-summary span { display: grid; gap: 2px; min-width: 108px; padding: 7px 9px; border-radius: 7px; background: #1c2430; }
.board-material-summary small { color: #8490a0; font-size: 11px; }
.board-material-summary strong { color: #dce3eb; font-size: 14px; }
.board-material-summary p { margin: 0; color: #8792a2; font-size: 13px; }
.automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
.automatic-placement strong { color: #b7dec7; font-size: 14px; }
@@ -1717,6 +1777,8 @@
.board-editor { padding: 21px 18px; }
.board-heading { align-items: stretch; flex-direction: column; }
.member-select { width: 100%; }
.member-navigation { display: grid; grid-template-columns: 1fr 1fr; }
.member-navigation button { width: 100%; min-height: 44px; }
.board-toolbar { align-items: flex-start; gap: 10px; }
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }
.automatic-placement-actions { justify-content: flex-end; }
+1 -1
View File
@@ -21,7 +21,7 @@
<aside class="delivery-note">
<span aria-hidden="true">●</span>
<p><strong>온라인판</strong>은 변경 사항이 수시로 반영됩니다. <strong>오프라인판</strong>은 다시 내려받은 파일에 포함된 시점까지의 내역을 보여 줍니다.</p>
<p>변경 사항은 검증과 배포가 끝난 뒤 <strong>온라인 서비스</strong>에 반영됩니다.</p>
</aside>
<article class="markdown-body">{@html renderedUpdateHistory}</article>
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
-36
View File
@@ -1,36 +0,0 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
const root = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: path.join(root, 'offline'),
base: './',
publicDir: path.join(root, 'static'),
plugins: [svelte()],
resolve: {
alias: [
{ find: '$lib/analytics.ts', replacement: path.join(root, 'offline/analytics.ts') },
{ find: '$app/stores', replacement: path.join(root, 'offline/page-store.ts') },
{ find: '$app/environment', replacement: path.join(root, 'offline/app-environment.ts') },
{ find: '$lib/data/chart-loader.ts', replacement: path.join(root, 'offline/chart-loader.ts') },
{ find: '$lib', replacement: path.join(root, 'src/lib') }
]
},
worker: { format: 'iife' },
build: {
outDir: path.join(root, 'offline-dist'),
emptyOutDir: true,
cssCodeSplit: false,
rollupOptions: {
output: {
format: 'iife',
entryFileNames: 'assets/app.js',
assetFileNames: (assetInfo) => assetInfo.name?.endsWith('.css') ? 'assets/app.css' : 'assets/[name][extname]'
}
},
chunkSizeWarningLimit: 20000
}
});