feat: release version 1.0.5
This commit is contained in:
@@ -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>
|
||||
@@ -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">
|
||||
<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>
|
||||
{:else}
|
||||
<span class="status">업데이트를 확인할 수 없습니다.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#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{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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if status === 'available' && latestRelease}
|
||||
<a class="action primary" href={downloadUrl(latestRelease)} target="_blank" rel="noreferrer">최신 버전 다운로드 <span aria-hidden="true">→</span></a>
|
||||
{:else}
|
||||
<a class="action" href={releasesUrl} target="_blank" rel="noreferrer">릴리즈 보기 <span aria-hidden="true">→</span></a>
|
||||
{/if}
|
||||
</aside>
|
||||
<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}
|
||||
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -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 & 合図",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { marked, Renderer } from 'marked';
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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, /<script>/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = () => ({
|
||||
serverNow: Date.now()
|
||||
});
|
||||
@@ -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)}%` : '최적 배치 분석'}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user