feat: release version 1.0.5

This commit is contained in:
2026-08-20 14:30:22 +09:00
parent f910db8901
commit 6537ab1f20
35 changed files with 1151 additions and 181 deletions
+8
View File
@@ -0,0 +1,8 @@
# Repository instructions
## Online and offline parity
- 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.
+5
View File
@@ -0,0 +1,5 @@
// Minimal `$app/environment` adapter for the standalone offline bundle.
export const browser = true;
export const building = false;
export const dev = false;
export const version = '';
+1 -1
View File
@@ -1,7 +1,7 @@
import { writable } from 'svelte/store';
function pageValue(pathname: string) {
return { url: new URL(pathname, 'https://offline.holodori.local') };
return { url: new URL(pathname, 'https://offline.holodori.local'), data: {} };
}
export const page = writable(pageValue('/'));
+19 -6
View File
@@ -1,18 +1,16 @@
{
"name": "holodream-tools",
"version": "1.0.4",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "holodream-tools",
"version": "1.0.4",
"engines": {
"node": ">=22.12.0"
},
"version": "1.0.5",
"dependencies": {
"better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2"
"drizzle-orm": "^0.45.2",
"marked": "^16.4.2"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.7",
@@ -25,6 +23,9 @@
"svelte-check": "^4.7.4",
"typescript": "^5.9.3",
"vite": "^8.1.5"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@drizzle-team/brocli": {
@@ -3166,6 +3167,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "holodream-tools",
"version": "1.0.4",
"version": "1.0.5",
"private": true,
"type": "module",
"engines": {
@@ -36,6 +36,7 @@
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2"
"drizzle-orm": "^0.45.2",
"marked": "^16.4.2"
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
# 홀로도리 계산기 1.0.4
# 홀로도리 계산기 v1.0.4
카드 목록에서 액티브 정보를 더 쉽게 비교하고, 배치 분석과 조합 탐색의 입력 화면을 일관되게 개선한 업데이트입니다.
@@ -9,6 +9,8 @@
- 카드 목록 전체를 명함 또는 5개화 상태로 전환해 표시와 정렬 결과를 비교할 수 있습니다.
- 카드 목록 출력 조건에 `모두 취소` 기능을 추가했습니다.
- 액티브 또는 일부 값이 없는 카드는 `—`로 표시하고 정렬 시 항상 뒤에 배치합니다.
- 신규 이벤트 악곡 5곡의 정확 채보를 추가했습니다. 각 악곡은 게임 내 오픈 시간에 맞춰 계산기에서도 자동으로 사용할 수 있습니다.
- 악곡 오픈 판정은 온라인에서 서버 시각을 사용하며, 서버에 연결할 수 없는 오프라인 환경에서만 PC 시스템 시각을 사용합니다.
- 배치 분석의 카드 선택, 개화와 빈도 감소 입력을 열 헤더 중심의 표 형태로 정리했습니다.
- 조합 탐색의 탐색 조건 UI를 배치 분석과 같은 입력 구조와 스타일로 통일했습니다.
- 조합 탐색의 기수 보너스 입력을 숫자 `0~900` 범위로 제한했습니다.
+18
View File
@@ -0,0 +1,18 @@
# 홀로도리 계산기 v1.0.5
자주 사용하는 분석 조건을 빠르게 다시 불러오고, 보유 카드 중심의 조합 탐색과 업데이트 확인을 편리하게 만든 업데이트입니다.
## 주요 변경 사항
- 배치 분석과 조합 탐색에서 현재 조건을 이름별 프리셋으로 저장하고 불러오거나 삭제할 수 있습니다.
- 조합 탐색에 `내가 보유한 카드만 사용하기` 옵션을 추가해 고정 카드와 자동 후보를 보유 5성 카드로 제한할 수 있습니다.
- 멤버 보드 화면에서 `A`와 `D` 키로 이전·다음 멤버를 빠르게 이동할 수 있습니다.
- 업데이트 영역에서 현재 버전과 최신 여부를 확인하고, 새 버전이 있으면 GitHub 릴리즈 노트를 앱 안에서 바로 읽을 수 있습니다.
## 참고 사항
프리셋은 사용 중인 브라우저 또는 오프라인 앱의 로컬 저장 공간에 보관됩니다. 다른 브라우저나 PC로 자동 동기화되지는 않습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.5.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
type Preset = {
id: string;
name: string;
updatedAt: string;
state: unknown;
};
type PresetDocument = {
schemaVersion: 1;
presets: Preset[];
};
export let storageKey: string;
export let snapshot: unknown;
export let disabled = false;
const dispatch = createEventDispatcher<{ apply: unknown }>();
let presets: Preset[] = [];
let selectedId = '';
let name = '';
let ready = false;
let message = '';
let messageIsError = false;
$: selectedPreset = presets.find((preset) => preset.id === selectedId);
onMount(() => {
try {
const parsed = JSON.parse(localStorage.getItem(storageKey) ?? 'null') as Partial<PresetDocument> | null;
if (parsed?.schemaVersion === 1 && Array.isArray(parsed.presets)) {
presets = parsed.presets.filter(isPreset).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
} catch {
showMessage('프리셋 목록을 읽지 못했습니다.', true);
} finally {
ready = true;
}
});
function isPreset(value: unknown): value is Preset {
if (!value || typeof value !== 'object') return false;
const preset = value as Partial<Preset>;
return typeof preset.id === 'string' && typeof preset.name === 'string' &&
typeof preset.updatedAt === 'string' && 'state' in preset;
}
function persist(next: Preset[]): boolean {
try {
localStorage.setItem(storageKey, JSON.stringify({ schemaVersion: 1, presets: next } satisfies PresetDocument));
presets = next;
return true;
} catch {
showMessage('프리셋을 저장할 수 없습니다. 브라우저 저장 공간을 확인해 주세요.', true);
return false;
}
}
function showMessage(text: string, isError = false): void {
message = text;
messageIsError = isError;
}
function save(): void {
const normalizedName = name.trim();
if (!normalizedName) {
showMessage('프리셋 이름을 입력해 주세요.', true);
return;
}
const now = new Date().toISOString();
const existing = presets.find((preset) => preset.name.toLocaleLowerCase('ko-KR') === normalizedName.toLocaleLowerCase('ko-KR'));
const saved: Preset = {
id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: normalizedName,
updatedAt: now,
state: JSON.parse(JSON.stringify(snapshot))
};
const next = [saved, ...presets.filter((preset) => preset.id !== saved.id)];
if (!persist(next)) return;
selectedId = saved.id;
name = saved.name;
showMessage(existing ? '같은 이름의 프리셋을 갱신했습니다.' : '현재 조건을 프리셋으로 저장했습니다.');
}
function apply(): void {
if (!selectedPreset) return;
dispatch('apply', JSON.parse(JSON.stringify(selectedPreset.state)));
name = selectedPreset.name;
showMessage(`‘${selectedPreset.name}’ 프리셋을 불러왔습니다.`);
}
function remove(): void {
if (!selectedPreset) return;
const removedName = selectedPreset.name;
if (!persist(presets.filter((preset) => preset.id !== selectedPreset!.id))) return;
selectedId = '';
name = '';
showMessage(`‘${removedName}’ 프리셋을 삭제했습니다.`);
}
function selectPreset(): void {
if (selectedPreset) name = selectedPreset.name;
message = '';
}
</script>
<div class="preset-manager" aria-label="프리셋 관리">
<div class="preset-heading">
<strong>프리셋</strong>
<span>현재 조건을 이름별로 저장하고 다시 불러옵니다.</span>
</div>
<div class="preset-controls">
<input bind:value={name} maxlength="40" placeholder="프리셋 이름" aria-label="프리셋 이름" disabled={disabled || !ready} on:keydown={(event) => event.key === 'Enter' && save()}>
<button class="save" type="button" disabled={disabled || !ready} on:click={save}>저장</button>
<select bind:value={selectedId} aria-label="저장된 프리셋" disabled={disabled || presets.length === 0} on:change={selectPreset}>
<option value="">{presets.length === 0 ? '저장된 프리셋 없음' : '불러올 프리셋 선택'}</option>
{#each presets as preset}<option value={preset.id}>{preset.name}</option>{/each}
</select>
<button type="button" disabled={disabled || !selectedPreset} on:click={apply}>불러오기</button>
<button class="delete" type="button" disabled={disabled || !selectedPreset} on:click={remove}>삭제</button>
</div>
{#if message}<p class:error={messageIsError} aria-live="polite">{message}</p>{/if}
</div>
<style>
.preset-manager { margin-top: 18px; padding: 14px; border: 1px solid #343b47; border-radius: 11px; background: #12161c; }
.preset-heading { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.preset-heading strong { color: #e4e8ee; font-size: 14px; }
.preset-heading span { color: #7f8998; font-size: 12px; }
.preset-controls { display: grid; grid-template-columns: minmax(150px, .8fr) auto minmax(190px, 1fr) auto auto; gap: 7px; }
input, select, button { min-width: 0; height: 38px; border: 1px solid #3a4350; border-radius: 8px; font: inherit; }
input, select { padding: 0 10px; color: #e8ebf1; background: #0f1319; }
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
button { padding: 0 13px; color: #c7ced8; background: #252c36; font-size: 13px; font-weight: 800; cursor: pointer; }
button:hover:not(:disabled) { color: #fff; background: #303844; }
button.save { border-color: #bd6e91; color: #21151b; background: #df83aa; }
button.save:hover:not(:disabled) { background: #eda0c0; }
button.delete { color: #e5a4a4; }
button:disabled, input:disabled, select:disabled { opacity: .45; cursor: default; }
p { margin: 9px 2px 0; color: #8fc9a6; font-size: 12px; }
p.error { color: #ff9f9f; }
@media (max-width: 700px) {
.preset-heading { align-items: flex-start; flex-direction: column; gap: 4px; }
.preset-controls { grid-template-columns: minmax(0, 1fr) auto auto; }
.preset-controls select { grid-column: 1 / -1; grid-row: 2; }
}
</style>
+79 -121
View File
@@ -1,152 +1,110 @@
<script lang="ts">
import { onMount } from 'svelte';
import packageInfo from '../../../package.json';
import { dev } from '$app/environment';
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
import { RELEASES_PAGE_URL } from '$lib/release-notes/release-note-service';
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
import { versionTagIsNewer } from '$lib/release-notes/version';
type ReleaseAsset = {
name: string;
browser_download_url: string;
};
type GitHubRelease = {
tag_name: string;
html_url: string;
draft: boolean;
prerelease: boolean;
assets: ReleaseAsset[];
};
type CachedRelease = {
checkedAt: number;
release: GitHubRelease;
};
const currentVersion = packageInfo.version;
const releaseApiUrl = 'https://api.github.com/repos/DD4SN/HolodoriCalc/releases/latest';
const releasesUrl = 'https://github.com/DD4SN/HolodoriCalc/releases';
const cacheKey = 'holodori-calc:latest-release';
const cacheDuration = 6 * 60 * 60 * 1000;
let status: 'checking' | 'current' | 'available' | 'failed' = 'checking';
let latestRelease: GitHubRelease | null = null;
function versionParts(version: string): number[] {
return version.replace(/^v/i, '').split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
}
function isNewerVersion(candidate: string, current: string): boolean {
const candidateParts = versionParts(candidate);
const currentParts = versionParts(current);
const length = Math.max(candidateParts.length, currentParts.length, 3);
for (let index = 0; index < length; index += 1) {
const difference = (candidateParts[index] ?? 0) - (currentParts[index] ?? 0);
if (difference !== 0) return difference > 0;
}
return false;
}
function isRelease(value: unknown): value is GitHubRelease {
if (!value || typeof value !== 'object') return false;
const release = value as Partial<GitHubRelease>;
return typeof release.tag_name === 'string'
&& typeof release.html_url === 'string'
&& Array.isArray(release.assets);
}
function applyRelease(release: GitHubRelease): void {
latestRelease = release;
status = isNewerVersion(release.tag_name, currentVersion) ? 'available' : 'current';
}
function readCachedRelease(): CachedRelease | null {
try {
const parsed = JSON.parse(localStorage.getItem(cacheKey) ?? 'null') as Partial<CachedRelease> | null;
if (!parsed || typeof parsed.checkedAt !== 'number' || !isRelease(parsed.release)) return null;
return parsed as CachedRelease;
} catch {
return null;
}
}
async function checkLatestRelease(): Promise<void> {
const cached = readCachedRelease();
if (cached && Date.now() - cached.checkedAt < cacheDuration) {
applyRelease(cached.release);
return;
}
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000);
try {
const response = await fetch(releaseApiUrl, {
headers: { Accept: 'application/vnd.github+json' },
signal: controller.signal
});
if (!response.ok) throw new Error(`GitHub release lookup failed: ${response.status}`);
const release: unknown = await response.json();
if (!isRelease(release) || release.draft || release.prerelease) throw new Error('Invalid GitHub release response');
localStorage.setItem(cacheKey, JSON.stringify({ checkedAt: Date.now(), release } satisfies CachedRelease));
applyRelease(release);
} catch {
if (cached) applyRelease(cached.release);
else status = 'failed';
} finally {
window.clearTimeout(timeout);
}
}
function downloadUrl(release: GitHubRelease): string {
const zip = release.assets.find((asset) => /^HolodoriCalc-offline-v.+\.zip$/i.test(asset.name));
return zip?.browser_download_url ?? release.html_url;
}
let previewUpdate = false;
onMount(() => {
void checkLatestRelease();
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 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={status === 'available'} class="update-notice" aria-live="polite">
{#if $releaseNotes.status === 'available' && latestRelease}
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
<div class="notice-header">
<div class="version-summary">
<span class="label">현재 버전</span>
<strong>v{currentVersion}</strong>
{#if status === 'checking'}
<span class="status">최신 버전을 확인하고 있습니다.</span>
{:else if status === 'available' && latestRelease}
<span class="status"><b>v{latestRelease.tag_name.replace(/^v/i, '')}</b> 업데이트를 사용할 수 있습니다.</span>
{:else if status === 'current'}
<span class="status">최신 버전입니다.</span>
<strong>v{currentAppVersion.replace(/^v/i, '')}</strong>
{#if previewUpdate}
<span class="status"><b>업데이트 알림 미리보기</b></span>
{:else if updateAvailable}
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
{:else}
<span class="status">업데이트를 확인할 수 없습니다.</span>
<span class="status">최신 버전입니다.</span>
{/if}
</div>
{#if status === 'available' && latestRelease}
<a class="action primary" href={downloadUrl(latestRelease)} target="_blank" rel="noreferrer">최신 버전 다운로드 <span aria-hidden="true">→</span></a>
<a class="action" href={RELEASES_PAGE_URL} target="_blank" rel="noreferrer">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
</div>
{#if updateAvailable}
<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}
<a class="action" href={releasesUrl} target="_blank" rel="noreferrer">릴리즈 보기 <span aria-hidden="true">→</span></a>
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
{/if}
</aside>
</section>
{/if}
</aside>
{/if}
<style>
.update-notice { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 12px; padding: 14px 18px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; }
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 8%); }
.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; }
.action.primary { color: #f2a6c6; }
.available .action { color: #f2a6c6; }
.action span { margin-left: 4px; }
.action:hover { color: #f2a6c6; }
.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) {
.update-notice { align-items: flex-start; flex-direction: column; gap: 8px; }
.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>
+131 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"source": "https://dreams.wf-calc.net",
"source": "https://dreams.wf-calc.net; hololive Dreams CDN via https://github.com/HolodoriDB/holodori-asset-tools",
"charts": [
{
"songId": "m0001",
@@ -4712,6 +4712,58 @@
],
"path": "/data/charts/m0331.hard.json"
},
{
"songId": "m0332",
"title": "BANZAI☆MANKAI",
"difficulty": "expert",
"durationSeconds": 136,
"scoreRatio": 280.40008469873237,
"totalNotes": 938,
"singerType": "solo",
"singerCharacterIds": [
"chr-00010"
],
"path": "/data/charts/m0332.expert.json"
},
{
"songId": "m0332",
"title": "BANZAI☆MANKAI",
"difficulty": "hard",
"durationSeconds": 136,
"scoreRatio": 220.44697428340993,
"totalNotes": 760,
"singerType": "solo",
"singerCharacterIds": [
"chr-00010"
],
"path": "/data/charts/m0332.hard.json"
},
{
"songId": "m0333",
"title": "Takoyaki Gazing",
"difficulty": "expert",
"durationSeconds": 131,
"scoreRatio": 299.0414358467602,
"totalNotes": 888,
"singerType": "solo",
"singerCharacterIds": [
"chr-00028"
],
"path": "/data/charts/m0333.expert.json"
},
{
"songId": "m0333",
"title": "Takoyaki Gazing",
"difficulty": "hard",
"durationSeconds": 131,
"scoreRatio": 209.7523345809546,
"totalNotes": 624,
"singerType": "solo",
"singerCharacterIds": [
"chr-00028"
],
"path": "/data/charts/m0333.hard.json"
},
{
"songId": "m0334",
"title": "NIGHTBREAK",
@@ -4738,6 +4790,58 @@
],
"path": "/data/charts/m0334.hard.json"
},
{
"songId": "m0335",
"title": "Oshi Mode ON",
"difficulty": "expert",
"durationSeconds": 129,
"scoreRatio": 418.6877175179705,
"totalNotes": 1173,
"singerType": "solo",
"singerCharacterIds": [
"chr-03004"
],
"path": "/data/charts/m0335.expert.json"
},
{
"songId": "m0335",
"title": "Oshi Mode ON",
"difficulty": "hard",
"durationSeconds": 129,
"scoreRatio": 250.28427636994522,
"totalNotes": 753,
"singerType": "solo",
"singerCharacterIds": [
"chr-03004"
],
"path": "/data/charts/m0335.hard.json"
},
{
"songId": "m0336",
"title": "Stardust Capsule",
"difficulty": "expert",
"durationSeconds": 117,
"scoreRatio": 250.46994719811275,
"totalNotes": 879,
"singerType": "solo",
"singerCharacterIds": [
"chr-04003"
],
"path": "/data/charts/m0336.expert.json"
},
{
"songId": "m0336",
"title": "Stardust Capsule",
"difficulty": "hard",
"durationSeconds": 117,
"scoreRatio": 152.06440826927707,
"totalNotes": 611,
"singerType": "solo",
"singerCharacterIds": [
"chr-04003"
],
"path": "/data/charts/m0336.hard.json"
},
{
"songId": "m0337",
"title": "美少女戦士まつりみっとZero",
@@ -4816,6 +4920,32 @@
],
"path": "/data/charts/m0347.hard.json"
},
{
"songId": "m0353",
"title": "Night Loop",
"difficulty": "expert",
"durationSeconds": 134,
"scoreRatio": 327.3933713079548,
"totalNotes": 953,
"singerType": "solo",
"singerCharacterIds": [
"chr-00014"
],
"path": "/data/charts/m0353.expert.json"
},
{
"songId": "m0353",
"title": "Night Loop",
"difficulty": "hard",
"durationSeconds": 134,
"scoreRatio": 204.12650848747597,
"totalNotes": 641,
"singerType": "solo",
"singerCharacterIds": [
"chr-00014"
],
"path": "/data/charts/m0353.hard.json"
},
{
"songId": "m0524",
"title": "You & 合図",
+73
View File
@@ -0,0 +1,73 @@
export type ReleaseClockSource = 'server' | 'system';
export type ReleaseClock = {
source: ReleaseClockSource;
now: () => number;
};
type TimeResponse = {
now?: unknown;
};
type ReleaseClockOptions = {
fetcher?: typeof fetch;
wallNow?: () => number;
monotonicNow?: () => number;
online?: boolean;
timeoutMs?: number;
};
function defaultMonotonicNow(): number {
return typeof performance === 'undefined' ? Date.now() : performance.now();
}
function serverClock(serverNow: number, monotonicNow: () => number, baseMonotonic = monotonicNow()): ReleaseClock {
return {
source: 'server',
now: () => serverNow + Math.max(0, monotonicNow() - baseMonotonic)
};
}
function systemClock(wallNow: () => number): ReleaseClock {
return { source: 'system', now: wallNow };
}
export function createInitialReleaseClock(serverNow: unknown, options: Pick<ReleaseClockOptions, 'wallNow' | 'monotonicNow'> = {}): ReleaseClock {
const wallNow = options.wallNow ?? Date.now;
const monotonicNow = options.monotonicNow ?? defaultMonotonicNow;
return typeof serverNow === 'number' && Number.isFinite(serverNow)
? serverClock(serverNow, monotonicNow)
: systemClock(wallNow);
}
export async function synchronizeReleaseClock(options: ReleaseClockOptions = {}): Promise<ReleaseClock> {
const wallNow = options.wallNow ?? Date.now;
const monotonicNow = options.monotonicNow ?? defaultMonotonicNow;
const online = options.online ?? (typeof location === 'undefined' || location.protocol !== 'file:');
if (!online) return systemClock(wallNow);
const fetcher = options.fetcher ?? globalThis.fetch?.bind(globalThis);
if (!fetcher) return systemClock(wallNow);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3_000);
const requestStarted = monotonicNow();
try {
const response = await fetcher('/api/time', {
cache: 'no-store',
headers: { accept: 'application/json' },
signal: controller.signal
});
if (!response.ok) return systemClock(wallNow);
const payload = await response.json() as TimeResponse;
if (typeof payload.now !== 'number' || !Number.isFinite(payload.now)) return systemClock(wallNow);
const responseReceived = monotonicNow();
const estimatedNow = payload.now + Math.max(0, responseReceived - requestStarted) / 2;
return serverClock(estimatedNow, monotonicNow, responseReceived);
} catch {
return systemClock(wallNow);
} finally {
clearTimeout(timeout);
}
}
+16
View File
@@ -0,0 +1,16 @@
export const SONG_RELEASE_TIMES: Readonly<Record<string, number>> = {
m0332: 1_787_137_200_000,
m0353: 1_787_151_600_000,
m0335: 1_787_310_000_000,
m0333: 1_787_482_800_000,
m0336: 1_787_655_600_000
};
export function isSongReleased(songId: string, now = Date.now()): boolean {
const releaseTime = SONG_RELEASE_TIMES[songId];
return releaseTime === undefined || now >= releaseTime;
}
export function releasedSongCharts<T extends { songId: string }>(charts: readonly T[], now = Date.now()): T[] {
return charts.filter((chart) => isSongReleased(chart.songId, now));
}
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInitialReleaseClock, synchronizeReleaseClock } from '../data/release-clock.ts';
test('서버 시각은 시스템 시각 변경과 무관하게 단조 시계로 흐른다', async () => {
let monotonic = 100;
let wall = 9_999_999;
const clock = await synchronizeReleaseClock({
fetcher: async () => {
monotonic = 140;
return { ok: true, json: async () => ({ now: 1_000 }) } as Response;
},
wallNow: () => wall,
monotonicNow: () => monotonic,
online: true
});
assert.equal(clock.source, 'server');
assert.equal(clock.now(), 1_020);
wall = 1;
monotonic = 240;
assert.equal(clock.now(), 1_120);
});
test('오프라인에서는 요청하지 않고 현재 시스템 시각을 사용한다', async () => {
let wall = 2_000;
let requested = false;
const clock = await synchronizeReleaseClock({
fetcher: async () => {
requested = true;
throw new Error('호출되면 안 됨');
},
wallNow: () => wall,
online: false
});
assert.equal(requested, false);
assert.equal(clock.source, 'system');
assert.equal(clock.now(), 2_000);
wall = 3_000;
assert.equal(clock.now(), 3_000);
});
test('온라인 시각 요청 실패 시에만 시스템 시각으로 대체한다', async () => {
const clock = await synchronizeReleaseClock({
fetcher: async () => { throw new Error('offline'); },
wallNow: () => 4_000,
online: true
});
assert.equal(clock.source, 'system');
assert.equal(clock.now(), 4_000);
});
test('초기 서버 시각도 시스템 시각 대신 단조 시계로 진행한다', () => {
let monotonic = 10;
const clock = createInitialReleaseClock(5_000, {
wallNow: () => 99_999,
monotonicNow: () => monotonic
});
monotonic = 35;
assert.equal(clock.source, 'server');
assert.equal(clock.now(), 5_025);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSongReleased, releasedSongCharts, SONG_RELEASE_TIMES } from '../data/song-release.ts';
test('신규 악곡 공개 시각을 HolodoriDB의 한국 시각과 일치시킨다', () => {
assert.deepEqual(SONG_RELEASE_TIMES, {
m0332: Date.parse('2026-08-19T20:00:00+09:00'),
m0353: Date.parse('2026-08-20T00:00:00+09:00'),
m0335: Date.parse('2026-08-21T20:00:00+09:00'),
m0333: Date.parse('2026-08-23T20:00:00+09:00'),
m0336: Date.parse('2026-08-25T20:00:00+09:00')
});
});
test('공개 시각 전에는 신규 악곡을 숨기고 공개 시각부터 표시한다', () => {
const releaseTime = SONG_RELEASE_TIMES.m0332;
assert.equal(isSongReleased('m0332', releaseTime - 1), false);
assert.equal(isSongReleased('m0332', releaseTime), true);
assert.equal(isSongReleased('existing-song', releaseTime - 1), true);
});
test('채보 목록에서 공개되지 않은 악곡의 모든 난이도를 제외한다', () => {
const charts = [
{ songId: 'existing-song', difficulty: 'expert' },
{ songId: 'm0332', difficulty: 'expert' },
{ songId: 'm0332', difficulty: 'hard' }
];
assert.deepEqual(releasedSongCharts(charts, SONG_RELEASE_TIMES.m0332 - 1), [charts[0]]);
assert.deepEqual(releasedSongCharts(charts, SONG_RELEASE_TIMES.m0332), charts);
});
+48
View File
@@ -0,0 +1,48 @@
import { marked, Renderer } from 'marked';
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function safeUrl(value: string): string | null {
const trimmed = value.trim();
if (trimmed.startsWith('#') || trimmed.startsWith('/')) return trimmed;
try {
const url = new URL(trimmed);
return ['http:', 'https:'].includes(url.protocol) ? url.href : null;
} catch {
return null;
}
}
const renderer = new Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.link = function ({ href, title, tokens }) {
const label = this.parser.parseInline(tokens);
const url = safeUrl(href);
if (!url) return label;
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<a href="${escapeHtml(url)}"${titleAttribute} target="_blank" rel="noreferrer">${label}</a>`;
};
renderer.image = ({ href, title, text }) => {
const url = safeUrl(href);
if (!url) return escapeHtml(text);
const titleAttribute = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(url)}" alt="${escapeHtml(text)}"${titleAttribute} loading="lazy">`;
};
export function renderReleaseMarkdown(markdown: string): string {
return marked.parse(markdown, {
async: false,
breaks: true,
gfm: true,
renderer
}) as string;
}
@@ -0,0 +1,67 @@
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;/);
});
@@ -0,0 +1,137 @@
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();
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,20 @@
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;
}
+5
View File
@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = () => ({
serverNow: Date.now()
});
+48 -2
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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 exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
@@ -33,13 +37,18 @@
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const ANALYSIS_PRESET_STORAGE_KEY = 'holodori.analysis.presets.v1';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const charts = exactChartCatalog.charts;
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
let releaseNow = currentReleaseClock.now();
let charts = releasedSongCharts(allCharts, releaseNow);
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
@@ -73,6 +82,9 @@
let errorMessage = '';
let analysisReady = false;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
@@ -127,6 +139,16 @@
return 0;
}
})();
$: presetSnapshot = {
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedAnalysisState;
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
@@ -139,6 +161,15 @@
});
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
currentReleaseClock = clock;
releaseNow = currentReleaseClock.now();
});
const releaseClock = window.setInterval(() => {
releaseNow = currentReleaseClock.now();
}, 1_000);
const applyStoredUserData = (stored: string | null) => {
const parsed = stored ? JSON.parse(stored) : null;
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
@@ -202,6 +233,8 @@
};
window.addEventListener('storage', syncStorage);
return () => {
mounted = false;
window.clearInterval(releaseClock);
window.removeEventListener('storage', syncStorage);
worker?.terminate();
};
@@ -256,6 +289,17 @@
}
}
function applyPreset(event: CustomEvent<unknown>): void {
try {
restoreAnalysisState(JSON.stringify(event.detail));
result = null;
achievementProbabilities = undefined;
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 분석 조건을 불러오지 못했습니다.';
}
}
function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
@@ -538,6 +582,8 @@
</div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
<PresetManager storageKey={ANALYSIS_PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
+12
View File
@@ -0,0 +1,12 @@
import { json } from '@sveltejs/kit';
export function GET() {
const now = Date.now();
return json({ now, iso: new Date(now).toISOString() }, {
headers: {
'cache-control': 'no-store, no-cache, must-revalidate',
expires: '0',
pragma: 'no-cache'
}
});
}
+126 -39
View File
@@ -1,11 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
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 exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import PresetManager from '$lib/components/PresetManager.svelte';
import {
calculateBoardEffects,
calculateUnitPower,
@@ -56,6 +60,7 @@
fixedAllBlueNodes?: boolean[];
fixedMaxGenerationBonuses?: boolean[];
fixedGenerationBonusOverrides?: (number | null)[];
ownedCardsOnly?: boolean;
maximizeCompute?: boolean;
leaderCharacterId: string;
leaderOutfitCardId: string;
@@ -72,18 +77,23 @@
};
const STORAGE_KEY = 'holodori.combination.v1';
const PRESET_STORAGE_KEY = 'holodori.combination.presets.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const charts = exactChartCatalog.charts;
const allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock;
let releaseNow = currentReleaseClock.now();
let charts = releasedSongCharts(allCharts, releaseNow);
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
?.filter((position) => position.groupId.startsWith('B'))
.map((position) => position.groupId) ?? []
]));
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const leaderMembers = fiveStarCards.filter((card, index, cards) => cards.findIndex((entry) => entry.characterId === card.characterId) === index);
let collection: CollectionState = {};
@@ -100,6 +110,7 @@
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
let fixedAllBlueNodes = [false, false, false, false];
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
let ownedCardsOnly = false;
let maximizeCompute = false;
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
@@ -128,6 +139,9 @@
let errorMessage = '';
let ready = false;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
@@ -146,6 +160,9 @@
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = [];
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: candidateCards = ownedCardsOnly
? fiveStarCards.filter((card) => collection[card.id]?.owned === true)
: fiveStarCards;
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
@@ -161,6 +178,22 @@
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: presetSnapshot = {
schemaVersion: 1,
songId: selectedSongId,
difficulty,
fixedCardIds,
awakening: candidateAwakening,
candidateGenerationBonus,
fixedAwakenings,
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
ownedCardsOnly,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
} satisfies SavedState;
$: if (ready) saveState({
schemaVersion: 1,
songId: selectedSongId,
@@ -172,12 +205,22 @@
fixedCooldownReductions,
fixedAllBlueNodes,
fixedGenerationBonusOverrides,
ownedCardsOnly,
maximizeCompute,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
currentReleaseClock = clock;
releaseNow = currentReleaseClock.now();
});
const releaseClock = window.setInterval(() => {
releaseNow = currentReleaseClock.now();
}, 1_000);
try {
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? 'null');
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
@@ -190,29 +233,7 @@
}
try {
const state = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedState | null;
if (state?.schemaVersion === 1) {
if (songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index]) ? state.fixedCardIds[index] : '');
candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
? normalizeGenerationBonus(state.candidateGenerationBonus)
: null;
fixedAwakenings = Array.from({ length: 4 }, (_, index) => normalizeAwakening(state.fixedAwakenings?.[index] ?? state.awakening));
fixedCooldownReductions = Array.from({ length: 4 }, (_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
fixedAllBlueNodes = Array.from({ length: 4 }, (_, index) => state.fixedAllBlueNodes?.[index] === true);
fixedGenerationBonusOverrides = Array.from({ length: 4 }, (_, index) => {
const savedValue = state.fixedGenerationBonusOverrides?.[index];
if (typeof savedValue === 'number' && Number.isFinite(savedValue)) return normalizeGenerationBonus(savedValue);
return state.fixedMaxGenerationBonuses?.[index] === true ? 900 : null;
});
maximizeCompute = state.maximizeCompute === true;
if (leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) leaderCharacterId = state.leaderCharacterId;
const outfit = cardById.get(state.leaderOutfitCardId);
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
? outfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
if (state) applySavedState(state);
} catch {
localStorage.removeItem(STORAGE_KEY);
}
@@ -259,6 +280,8 @@
}
ready = true;
return () => {
mounted = false;
window.clearInterval(releaseClock);
worker?.terminate();
terminateParallelWorkers();
baselineWorker?.terminate();
@@ -302,6 +325,46 @@
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function applySavedState(state: Partial<SavedState>): void {
if (state.schemaVersion !== 1) throw new Error('Unsupported preset');
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index] ?? '') ? state.fixedCardIds![index] : '');
candidateAwakening = normalizeAwakening(state.awakening);
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
? normalizeGenerationBonus(state.candidateGenerationBonus)
: null;
fixedAwakenings = Array.from({ length: 4 }, (_, index) => normalizeAwakening(state.fixedAwakenings?.[index] ?? state.awakening));
fixedCooldownReductions = Array.from({ length: 4 }, (_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
fixedAllBlueNodes = Array.from({ length: 4 }, (_, index) => state.fixedAllBlueNodes?.[index] === true);
fixedGenerationBonusOverrides = Array.from({ length: 4 }, (_, index) => {
const savedValue = state.fixedGenerationBonusOverrides?.[index];
if (typeof savedValue === 'number' && Number.isFinite(savedValue)) return normalizeGenerationBonus(savedValue);
return state.fixedMaxGenerationBonuses?.[index] === true ? 900 : null;
});
ownedCardsOnly = state.ownedCardsOnly === true;
if (ownedCardsOnly) toggleOwnedCardsOnly(true);
maximizeCompute = state.maximizeCompute === true;
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId;
}
const outfit = typeof state.leaderOutfitCardId === 'string' ? cardById.get(state.leaderOutfitCardId) : undefined;
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
? outfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
function applyPreset(event: CustomEvent<unknown>): void {
try {
applySavedState(event.detail as Partial<SavedState>);
result = null;
rankedResults = [];
errorMessage = '';
} catch {
errorMessage = '이 프리셋의 탐색 조건을 불러오지 못했습니다.';
}
}
function readAnalysisState(stored: string | null): SavedAnalysisState | null {
if (!stored) return null;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
@@ -374,6 +437,18 @@
result = null;
}
function toggleOwnedCardsOnly(enabled: boolean): void {
ownedCardsOnly = enabled;
if (enabled) {
const removedSlots = fixedCardIds.map((cardId) => Boolean(cardId && !collection[cardId]?.owned));
fixedCardIds = fixedCardIds.map((cardId, slot) => removedSlots[slot] ? '' : cardId);
fixedCooldownReductions = fixedCooldownReductions.map((value, slot) => removedSlots[slot] ? null : value);
fixedAllBlueNodes = fixedAllBlueNodes.map((value, slot) => removedSlots[slot] ? false : value);
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((value, slot) => removedSlots[slot] ? null : value);
}
result = null;
}
function fixedSlot(cardId: string): number {
return fixedCardIds.indexOf(cardId);
}
@@ -451,7 +526,7 @@
function screeningCombinations(): CardRow[][] {
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit);
}
const globalBudget = Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
@@ -466,7 +541,7 @@
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
fixedCards,
fiveStarCards,
candidateCards,
5,
strategy.scoreCard,
perStrategy,
@@ -476,11 +551,11 @@
}
}
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = fiveStarCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
for (const cards of generateAnchoredCombinationCandidates(
fixedCards,
fiveStarCards,
candidateCards,
5,
strategies,
perAnchorLimit
@@ -537,7 +612,7 @@
sourceCards.forEach((source, index) => {
if (fixedIds.has(source.id)) return;
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
for (const replacement of fiveStarCards) {
for (const replacement of candidateCards) {
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
const key = combinationKey(cards);
@@ -806,6 +881,10 @@
errorMessage = '';
result = null;
if (!worker || !selectedChart) return;
if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) {
errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.';
return;
}
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return;
@@ -930,7 +1009,7 @@
</section>
{/if}
<section class="panel" aria-labelledby="conditions-title">
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>5성 전체 대상</strong></div>
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '5성 전체 대상'}</strong></div>
<div class="song-fields">
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
@@ -947,7 +1026,13 @@
</div>
<label>자동 후보 기수 보너스 덮어쓰기 (0~900)<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:input={(event) => updateCandidateGenerationBonus(sanitizeGenerationBonusInput(event.currentTarget, candidateGenerationBonus))}></label>
</div>
<label class="owned-cards-option">
<input type="checkbox" checked={ownedCardsOnly} disabled={running} on:change={(event) => toggleOwnedCardsOnly(event.currentTarget.checked)}>
<span aria-hidden="true"></span>
<strong>내가 보유한 카드만 사용하기</strong>
</label>
<p class="candidate-guide">
‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 보유 5성 카드로 제한됩니다.<br>
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.<br>
기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.
</p>
@@ -961,7 +1046,7 @@
<label><span>{slot + 1}번 고정</span>
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
<option value="">고정하지 않음</option>
{#each fiveStarCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
{#each candidateCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
</select>
</label>
<div class="fixed-awakening">
@@ -998,6 +1083,7 @@
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
</div>
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
@@ -1125,14 +1211,15 @@
.search { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search:hover:not(:disabled) { background: #f2a0c2; }
.search:disabled { opacity: .55; cursor: default; }
.compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
.compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.owned-cards-option, .compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
.owned-cards-option input, .compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.owned-cards-option span, .compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.owned-cards-option input:checked + span, .compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.owned-cards-option input:checked + span::after, .compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
.owned-cards-option input:focus-visible + span, .compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.owned-cards-option input:disabled + span, .owned-cards-option input:disabled ~ strong,
.compute-option input:disabled + span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
.compute-option strong { color: #c9d0da; font-size: 14px; }
.owned-cards-option strong, .compute-option strong { color: #c9d0da; font-size: 14px; }
.compute-warning { margin: 6px 0 0 29px; color: #a98686; font-size: 13px; line-height: 1.5; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
+35 -1
View File
@@ -324,6 +324,35 @@
if (boardViewport.hasPointerCapture(event.pointerId)) boardViewport.releasePointerCapture(event.pointerId);
}
function finishBoardMemberChange() {
selectedSocketGroupId = '';
efficiencyFocusGroupId = '';
boardDragging = false;
void fitBoardViewport();
}
function selectAdjacentBoardMember(offset: -1 | 1) {
if (boardMembers.length < 2) return;
const currentIndex = boardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + boardMembers.length) % boardMembers.length;
selectedMemberId = boardMembers[nextIndex].id;
finishBoardMemberChange();
}
function isKeyboardInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable="plaintext-only"]'));
}
function handleBoardKeyboardNavigation(event: KeyboardEvent) {
if (dataView !== 'boards' || event.defaultPrevented || event.repeat || event.isComposing
|| event.ctrlKey || event.metaKey || event.altKey || isKeyboardInputTarget(event.target)) return;
const key = event.key.toLowerCase();
if (key !== 'a' && key !== 'd') return;
event.preventDefault();
selectAdjacentBoardMember(key === 'a' ? -1 : 1);
}
function persist(next = userData) {
userData = saveBrowserData(next);
greenEfficiencyResult = null;
@@ -971,6 +1000,8 @@
}
</script>
<svelte:window on:keydown={handleBoardKeyboardNavigation} />
<svelte:head>
<title>사용자 데이터 | 홀로도리 계산기</title>
<meta name="description" content="홀로도리 사용자 데이터와 백업을 관리합니다." />
@@ -1289,9 +1320,10 @@
<div class="board-heading">
<label class="member-select">
<span>멤버 선택</span>
<select bind:value={selectedMemberId} on:change={() => { selectedSocketGroupId = ''; efficiencyFocusGroupId = ''; fitBoardViewport(); }}>
<select bind:value={selectedMemberId} on:change={finishBoardMemberChange}>
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
</select>
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
</label>
</div>
@@ -1511,6 +1543,8 @@
.member-select { display: grid; gap: 5px; width: min(360px, 100%); }
.member-select span { color: #818b9c; font-size: 12px; font-weight: 750; }
.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; }
.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; }
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
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
resolve: {
alias: [
{ 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') }
]