feat: apply feedback updates and streamline online delivery
Publish GitHub Pages output / publish (push) Successful in 34s
Publish GitHub Pages output / publish (push) Successful in 34s
This commit is contained in:
@@ -1,23 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dev } from '$app/environment';
|
||||
import updateHistoryMarkdown from '../../../UPDATES.md?raw';
|
||||
import { renderReleaseMarkdown } from '$lib/release-notes/markdown';
|
||||
import { currentAppVersion, releaseNotes } from '$lib/release-notes/release-note-store';
|
||||
import { versionTagIsNewer } from '$lib/release-notes/version';
|
||||
import { parseUpdateHistoryDocument } from '$lib/update-history-document.ts';
|
||||
|
||||
let previewUpdate = false;
|
||||
const updateHistoryDocument = parseUpdateHistoryDocument(updateHistoryMarkdown);
|
||||
|
||||
onMount(() => {
|
||||
previewUpdate = dev && new URLSearchParams(window.location.search).get('previewUpdate') === '1';
|
||||
void releaseNotes.load();
|
||||
});
|
||||
|
||||
$: latestRelease = $releaseNotes.releases[0] ?? null;
|
||||
$: updateAvailable = latestRelease !== null && (previewUpdate || versionTagIsNewer(latestRelease.tagName, currentAppVersion));
|
||||
|
||||
function updatedDateTime(value: string | null): string {
|
||||
if (!value) return '시각 미등록';
|
||||
const date = new Date(value);
|
||||
@@ -32,91 +18,32 @@
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function publishedDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class:available={updateAvailable} class="update-notice" aria-live="polite">
|
||||
<aside class="update-notice" aria-live="polite">
|
||||
<div class="notice-header">
|
||||
<div class="version-summary">
|
||||
<span class="label">최근 업데이트</span>
|
||||
<strong><time datetime={updateHistoryDocument.updatedAt ?? undefined}>{updatedDateTime(updateHistoryDocument.updatedAt)}</time></strong>
|
||||
{#if previewUpdate}
|
||||
<span class="status"><b>업데이트 알림 미리보기</b></span>
|
||||
{:else if updateAvailable}
|
||||
<span class="status"><b>{latestRelease.tagName}</b> 업데이트를 사용할 수 있습니다.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<a class="action" href="/updates">업데이트 내역 확인 <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
|
||||
{#if updateAvailable && latestRelease}
|
||||
<section class="latest-note" aria-labelledby="latest-release-title">
|
||||
<header>
|
||||
<div>
|
||||
<span class="release-tag">{latestRelease.tagName}</span>
|
||||
<h2 id="latest-release-title">{latestRelease.name}</h2>
|
||||
</div>
|
||||
<time datetime={latestRelease.publishedAt}>{publishedDate(latestRelease.publishedAt)}</time>
|
||||
</header>
|
||||
|
||||
{#if latestRelease.body.trim()}
|
||||
<div class="markdown-body">{@html renderReleaseMarkdown(latestRelease.body)}</div>
|
||||
{:else}
|
||||
<p class="no-notes">작성된 릴리즈 노트가 없습니다.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.update-notice { margin-bottom: 12px; border: 1px solid #2c3440; border-radius: 12px; background: #151a22; overflow: hidden; }
|
||||
.update-notice.available { border-color: rgb(237 139 181 / 38%); background: rgb(237 139 181 / 6%); }
|
||||
.notice-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px; }
|
||||
.version-summary { display: flex; align-items: baseline; min-width: 0; gap: 10px; }
|
||||
.label { color: #7f8998; font-size: 13px; font-weight: 800; }
|
||||
strong { color: #eef1f5; font-size: 15px; }
|
||||
.status { color: #a4adba; font-size: 14px; }
|
||||
.status b { color: #f0a1c3; }
|
||||
.action { flex: 0 0 auto; color: #aab3c0; font-size: 14px; font-weight: 800; text-decoration: none; }
|
||||
.available .action { color: #f2a6c6; }
|
||||
.action span { margin-left: 4px; }
|
||||
.action:hover { color: #ffd0e2; }
|
||||
.action:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 3px; }
|
||||
.latest-note { padding: 24px 26px 28px; border-top: 1px solid rgb(237 139 181 / 20%); background: rgb(13 16 22 / 38%); }
|
||||
.latest-note > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid rgb(255 255 255 / 8%); }
|
||||
.release-tag { color: #f0a1c3; font-size: 13px; font-weight: 900; }
|
||||
.latest-note h2 { margin: 6px 0 0; color: #edf0f5; font-size: 22px; letter-spacing: -.025em; }
|
||||
.latest-note time { flex: 0 0 auto; color: #858f9e; font-size: 13px; }
|
||||
.markdown-body { padding-top: 18px; color: #b7bfca; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.markdown-body :global(:first-child) { margin-top: 0; }
|
||||
.markdown-body :global(:last-child) { margin-bottom: 0; }
|
||||
.markdown-body :global(h1), .markdown-body :global(h2), .markdown-body :global(h3), .markdown-body :global(h4) { margin: 1.35em 0 .5em; color: #eef1f5; line-height: 1.35; }
|
||||
.markdown-body :global(h1) { font-size: 22px; }
|
||||
.markdown-body :global(h2) { font-size: 19px; }
|
||||
.markdown-body :global(h3) { font-size: 17px; }
|
||||
.markdown-body :global(p), .markdown-body :global(ul), .markdown-body :global(ol), .markdown-body :global(blockquote), .markdown-body :global(pre) { margin: .75em 0; }
|
||||
.markdown-body :global(ul), .markdown-body :global(ol) { padding-left: 1.5em; }
|
||||
.markdown-body :global(a) { color: #f0a1c3; text-underline-offset: 3px; }
|
||||
.markdown-body :global(code) { padding: 2px 5px; border-radius: 5px; color: #e8b3ca; background: #202631; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9em; }
|
||||
.markdown-body :global(pre) { padding: 14px; border: 1px solid #303846; border-radius: 9px; background: #10141a; overflow-x: auto; }
|
||||
.markdown-body :global(pre code) { padding: 0; color: #c9d0da; background: transparent; }
|
||||
.markdown-body :global(blockquote) { padding-left: 14px; border-left: 3px solid #596475; color: #929cab; }
|
||||
.markdown-body :global(img) { max-width: 100%; border-radius: 9px; }
|
||||
.no-notes { margin: 18px 0 0; color: #818b99; font-size: 14px; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.notice-header { align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
.version-summary { align-items: flex-start; flex-wrap: wrap; }
|
||||
.latest-note { padding: 22px 18px 24px; }
|
||||
.latest-note > header { flex-direction: column; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -264,8 +264,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 25,
|
||||
"durationSeconds": 108,
|
||||
"scoreRatio": 265.0822413749041,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 252.13563829787233,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 877,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
@@ -744,8 +744,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 24,
|
||||
"durationSeconds": 97,
|
||||
"scoreRatio": 192.63348422390104,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 195.1219628964692,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 596,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
@@ -840,8 +840,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 25,
|
||||
"durationSeconds": 133,
|
||||
"scoreRatio": 280.8271276035182,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 280.4822485207101,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 1022,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
@@ -1000,8 +1000,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 24,
|
||||
"durationSeconds": 130,
|
||||
"scoreRatio": 279.0075534874758,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 290.2132653061224,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 947,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
@@ -2538,8 +2538,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 26,
|
||||
"durationSeconds": 139,
|
||||
"scoreRatio": 379.73397776840375,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 373.73061760841,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 1159,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
@@ -5252,8 +5252,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 27,
|
||||
"durationSeconds": 136,
|
||||
"scoreRatio": 313.7465654376337,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 316.33724653148346,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 1088,
|
||||
"singerType": "group",
|
||||
"singerCharacterIds": [
|
||||
@@ -5356,8 +5356,8 @@
|
||||
"difficulty": "expert",
|
||||
"difficultyLevel": 26,
|
||||
"durationSeconds": 104,
|
||||
"scoreRatio": 305.22427442474174,
|
||||
"scoreCalibrationSource": "local-estimated-v1",
|
||||
"scoreRatio": 292.9459119496856,
|
||||
"scoreCalibrationSource": "measured",
|
||||
"totalNotes": 921,
|
||||
"singerType": "solo",
|
||||
"singerCharacterIds": [
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { cardMatchesSearch } from './card-search.ts';
|
||||
|
||||
test('IRyS와 AZKi를 한글 별칭으로 검색한다', () => {
|
||||
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '아이리스'), true);
|
||||
assert.equal(cardMatchesSearch({ name: 'AZKi' }, '아즈키'), true);
|
||||
});
|
||||
|
||||
test('기존 영문 이름과 버전 검색을 유지한다', () => {
|
||||
assert.equal(cardMatchesSearch({ name: 'IRyS', versionName: '수영복' }, 'irys'), true);
|
||||
assert.equal(cardMatchesSearch({ name: 'IRyS', versionName: '수영복' }, '수영복'), true);
|
||||
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '이리스'), false);
|
||||
assert.equal(cardMatchesSearch({ name: 'IRyS' }, '아즈키'), false);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export type SearchableCard = {
|
||||
name: string;
|
||||
versionName?: string | null;
|
||||
};
|
||||
|
||||
const memberAliases: Readonly<Record<string, readonly string[]>> = {
|
||||
irys: ['아이리스'],
|
||||
azki: ['아즈키']
|
||||
};
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value.trim().toLocaleLowerCase('ko-KR').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function cardMatchesSearch(card: SearchableCard, query: string): boolean {
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) return true;
|
||||
const aliases = memberAliases[normalizeSearchText(card.name)] ?? [];
|
||||
const nativeText = normalizeSearchText([card.name, card.versionName ?? ''].join(' '));
|
||||
return (
|
||||
nativeText.includes(normalizedQuery) ||
|
||||
aliases.some((alias) => normalizeSearchText(alias).startsWith(normalizedQuery))
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { events } from '../data/events.ts';
|
||||
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventPointIndex } from './event-optimization.ts';
|
||||
import { activeEventAt, eventBonusBreakdown, eventChallengeSongScoreBonusPercent, eventOptimizationCardEligible, eventPointIndex } from './event-optimization.ts';
|
||||
|
||||
const currentEvent = events[0];
|
||||
|
||||
@@ -11,6 +11,15 @@ test('이벤트 기간 안에서만 진행 중 이벤트를 반환한다', () =>
|
||||
assert.equal(activeEventAt(events, Date.parse('2026-09-06T11:00:00.000Z')), null);
|
||||
});
|
||||
|
||||
test('이벤트 최적화는 모든 4·5성과 보너스 멤버의 저등급 카드를 후보로 사용한다', () => {
|
||||
const bonusCharacters = new Set(['bonus']);
|
||||
|
||||
assert.equal(eventOptimizationCardEligible({ id: 'five', characterId: 'other', rarity: 5 }, bonusCharacters), true);
|
||||
assert.equal(eventOptimizationCardEligible({ id: 'four', characterId: 'other', rarity: 4 }, bonusCharacters), true);
|
||||
assert.equal(eventOptimizationCardEligible({ id: 'bonus-three', characterId: 'bonus', rarity: 3 }, bonusCharacters), true);
|
||||
assert.equal(eventOptimizationCardEligible({ id: 'regular-three', characterId: 'other', rarity: 3 }, bonusCharacters), false);
|
||||
});
|
||||
|
||||
test('편성·신규 카드·개화 보너스를 카드별로 합산한다', () => {
|
||||
const cards = [
|
||||
{ id: currentEvent.featuredCardIds[0], characterId: currentEvent.bonusCharacterIds[0], rarity: 5 },
|
||||
|
||||
@@ -25,6 +25,10 @@ export type EventBonusBreakdown = {
|
||||
totalPercent: number;
|
||||
};
|
||||
|
||||
export function eventOptimizationCardEligible(card: EventCard, bonusCharacterIds: ReadonlySet<string>): boolean {
|
||||
return card.rarity === 5 || card.rarity === 4 || bonusCharacterIds.has(card.characterId);
|
||||
}
|
||||
|
||||
export function activeEventAt(events: readonly EventDefinition[], now: number): EventDefinition | null {
|
||||
return events.find((event) => Date.parse(event.startsAt) <= now && now <= Date.parse(event.endsAt)) ?? null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './active-probability.ts';
|
||||
export * from './card-adapter.ts';
|
||||
export * from './card-search.ts';
|
||||
export * from './combination-search.ts';
|
||||
export * from './awakening-requirements.ts';
|
||||
export * from './board-effects.ts';
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderReleaseMarkdown } from './markdown.ts';
|
||||
import { ReleaseNoteService, type ReleaseNoteConfig } from './release-note-service.ts';
|
||||
import { versionTagIsNewer, versionTagMatches } from './version.ts';
|
||||
|
||||
const config: ReleaseNoteConfig = {
|
||||
owner: 'owner',
|
||||
repo: 'repo',
|
||||
includePrereleases: false,
|
||||
perPage: 30,
|
||||
timeoutMs: 100
|
||||
};
|
||||
|
||||
function response(payload: unknown, ok = true): Response {
|
||||
return { ok, json: async () => payload } as Response;
|
||||
}
|
||||
|
||||
test('정식 릴리즈만 최신순으로 변환하고 앱 실행 중 결과를 재사용한다', async () => {
|
||||
let requests = 0;
|
||||
const service = new ReleaseNoteService(config, async () => {
|
||||
requests += 1;
|
||||
return response([
|
||||
{ id: 1, tag_name: 'v1.0.0', name: null, published_at: '2026-01-01T00:00:00Z', body: null, html_url: 'https://example.com/1', draft: false, prerelease: false },
|
||||
{ id: 3, tag_name: 'v1.2.0-beta', name: 'beta', published_at: '2026-03-01T00:00:00Z', body: '', html_url: 'https://example.com/3', draft: false, prerelease: true },
|
||||
{ id: 2, tag_name: 'v1.1.0', name: 'one one', published_at: '2026-02-01T00:00:00Z', body: '# 변경', html_url: 'https://example.com/2', draft: false, prerelease: false },
|
||||
{ id: 4, tag_name: 'v9.0.0', name: 'draft', published_at: '2026-04-01T00:00:00Z', body: '', html_url: 'https://example.com/4', draft: true, prerelease: false }
|
||||
]);
|
||||
});
|
||||
|
||||
const first = await service.load({ online: true });
|
||||
const second = await service.load({ online: true });
|
||||
|
||||
assert.equal(requests, 1);
|
||||
assert.deepEqual(first, second);
|
||||
assert.deepEqual(first?.map((release) => release.tagName), ['v1.1.0', 'v1.0.0']);
|
||||
assert.equal(first?.[1].name, 'v1.0.0');
|
||||
assert.equal(first?.[1].body, '');
|
||||
});
|
||||
|
||||
test('오프라인 및 API 실패는 예외 없이 unavailable 값으로 캐시한다', async () => {
|
||||
let requests = 0;
|
||||
const service = new ReleaseNoteService(config, async () => {
|
||||
requests += 1;
|
||||
return response({}, false);
|
||||
});
|
||||
|
||||
assert.equal(await service.load({ online: false }), null);
|
||||
assert.equal(await service.load({ online: true }), null);
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('현재 버전 표시는 v 접두사와 빌드 메타데이터를 무시한다', () => {
|
||||
assert.equal(versionTagMatches('v1.0.4', '1.0.4'), true);
|
||||
assert.equal(versionTagMatches('1.0.4+offline', 'v1.0.4'), true);
|
||||
assert.equal(versionTagMatches('v1.0.3', '1.0.4'), false);
|
||||
assert.equal(versionTagIsNewer('v1.1.0', '1.0.4'), true);
|
||||
assert.equal(versionTagIsNewer('v1.0.4', '1.0.4'), false);
|
||||
assert.equal(versionTagIsNewer('v1.0.3', '1.0.4'), false);
|
||||
});
|
||||
|
||||
test('릴리즈 Markdown의 원문 HTML과 위험한 링크를 실행 가능한 태그로 만들지 않는다', () => {
|
||||
const rendered = renderReleaseMarkdown('<script>alert(1)</script> [위험](javascript:alert(1))');
|
||||
assert.equal(rendered.includes('<script>'), false);
|
||||
assert.equal(rendered.includes('href="javascript:'), false);
|
||||
assert.match(rendered, /<script>/);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
export type ReleaseNote = {
|
||||
id: number;
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
body: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type ReleaseNoteConfig = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
includePrereleases: boolean;
|
||||
perPage: number;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type GitHubRelease = {
|
||||
id?: unknown;
|
||||
tag_name?: unknown;
|
||||
name?: unknown;
|
||||
published_at?: unknown;
|
||||
body?: unknown;
|
||||
html_url?: unknown;
|
||||
draft?: unknown;
|
||||
prerelease?: unknown;
|
||||
};
|
||||
|
||||
type LoadOptions = {
|
||||
online?: boolean;
|
||||
};
|
||||
|
||||
export const RELEASE_NOTE_CONFIG: ReleaseNoteConfig = {
|
||||
owner: 'DD4SN',
|
||||
repo: 'HolodoriCalc',
|
||||
// 정식 릴리즈만 노출한다. 베타 릴리즈도 보여주려면 true로 변경한다.
|
||||
includePrereleases: false,
|
||||
perPage: 30,
|
||||
timeoutMs: 6_000
|
||||
};
|
||||
|
||||
export const RELEASES_PAGE_URL = `https://github.com/${RELEASE_NOTE_CONFIG.owner}/${RELEASE_NOTE_CONFIG.repo}/releases`;
|
||||
|
||||
function browserIsOnline(): boolean {
|
||||
return typeof navigator !== 'undefined'
|
||||
&& navigator.onLine
|
||||
&& typeof location !== 'undefined'
|
||||
&& location.protocol !== 'file:';
|
||||
}
|
||||
|
||||
function toReleaseNote(value: GitHubRelease): ReleaseNote | null {
|
||||
if (typeof value.id !== 'number'
|
||||
|| typeof value.tag_name !== 'string'
|
||||
|| typeof value.published_at !== 'string'
|
||||
|| typeof value.html_url !== 'string') return null;
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
tagName: value.tag_name,
|
||||
name: typeof value.name === 'string' && value.name.trim() ? value.name : value.tag_name,
|
||||
publishedAt: value.published_at,
|
||||
body: typeof value.body === 'string' ? value.body : '',
|
||||
url: value.html_url
|
||||
};
|
||||
}
|
||||
|
||||
export class ReleaseNoteService {
|
||||
private readonly config: ReleaseNoteConfig;
|
||||
private readonly fetcher: typeof fetch | undefined;
|
||||
private cached: ReleaseNote[] | null | undefined;
|
||||
private pending: Promise<ReleaseNote[] | null> | null = null;
|
||||
|
||||
constructor(
|
||||
config: ReleaseNoteConfig = RELEASE_NOTE_CONFIG,
|
||||
fetcher: typeof fetch | undefined = globalThis.fetch?.bind(globalThis)
|
||||
) {
|
||||
this.config = config;
|
||||
this.fetcher = fetcher;
|
||||
}
|
||||
|
||||
get releasesUrl(): string {
|
||||
return `https://github.com/${this.config.owner}/${this.config.repo}/releases`;
|
||||
}
|
||||
|
||||
async load(options: LoadOptions = {}): Promise<ReleaseNote[] | null> {
|
||||
if (this.cached !== undefined) return this.cached;
|
||||
if (this.pending) return this.pending;
|
||||
|
||||
const online = options.online ?? browserIsOnline();
|
||||
if (!online || !this.fetcher) {
|
||||
this.cached = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.pending = this.request();
|
||||
try {
|
||||
this.cached = await this.pending;
|
||||
return this.cached;
|
||||
} finally {
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async request(): Promise<ReleaseNote[] | null> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
||||
const apiUrl = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/releases?per_page=${this.config.perPage}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetcher!(apiUrl, {
|
||||
headers: {
|
||||
accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
|
||||
const payload: unknown = await response.json();
|
||||
if (!Array.isArray(payload)) return null;
|
||||
|
||||
return payload
|
||||
.filter((release): release is GitHubRelease => Boolean(release) && typeof release === 'object')
|
||||
.filter((release) => release.draft !== true)
|
||||
.filter((release) => this.config.includePrereleases || release.prerelease !== true)
|
||||
.map(toReleaseNote)
|
||||
.filter((release): release is ReleaseNote => release !== null)
|
||||
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt));
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const releaseNoteService = new ReleaseNoteService();
|
||||
@@ -1,34 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import packageInfo from '../../../package.json';
|
||||
import { releaseNoteService, type ReleaseNote } from './release-note-service';
|
||||
import { versionTagMatches } from './version';
|
||||
|
||||
export type ReleaseNoteStatus = 'idle' | 'loading' | 'available' | 'unavailable';
|
||||
|
||||
export type ReleaseNoteState = {
|
||||
status: ReleaseNoteStatus;
|
||||
releases: ReleaseNote[];
|
||||
};
|
||||
|
||||
const state = writable<ReleaseNoteState>({ status: 'idle', releases: [] });
|
||||
let loadStarted = false;
|
||||
|
||||
export const currentAppVersion = packageInfo.version;
|
||||
|
||||
export const releaseNotes = {
|
||||
subscribe: state.subscribe,
|
||||
async load(): Promise<void> {
|
||||
if (loadStarted) return;
|
||||
loadStarted = true;
|
||||
state.set({ status: 'loading', releases: [] });
|
||||
|
||||
const loaded = await releaseNoteService.load();
|
||||
state.set(loaded && loaded.length > 0
|
||||
? { status: 'available', releases: loaded }
|
||||
: { status: 'unavailable', releases: [] });
|
||||
}
|
||||
};
|
||||
|
||||
export function isCurrentVersionTag(tagName: string, currentVersion = currentAppVersion): boolean {
|
||||
return versionTagMatches(tagName, currentVersion);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export function normalizedVersion(version: string): string {
|
||||
return version.trim().replace(/^v/i, '').split('+', 1)[0];
|
||||
}
|
||||
|
||||
export function versionTagMatches(tagName: string, currentVersion: string): boolean {
|
||||
return normalizedVersion(tagName) === normalizedVersion(currentVersion);
|
||||
}
|
||||
|
||||
export function versionTagIsNewer(tagName: string, currentVersion: string): boolean {
|
||||
const candidate = normalizedVersion(tagName).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const current = normalizedVersion(currentVersion).split('-', 1)[0].split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const length = Math.max(candidate.length, current.length, 3);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (candidate[index] ?? 0) - (current[index] ?? 0);
|
||||
if (difference !== 0) return difference > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
effectiveActiveProbability,
|
||||
eventBonusBreakdown,
|
||||
eventChallengeSongScoreBonusPercent,
|
||||
eventOptimizationCardEligible,
|
||||
eventPointIndex,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
@@ -102,7 +103,7 @@
|
||||
const fiveStarCards = allCards.filter((card) => card.rarity === 5);
|
||||
const eventBonusCharacters = new Set<string>(events.flatMap((event) => [...event.bonusCharacterIds]));
|
||||
const eligibleCards = eventMode
|
||||
? allCards.filter((card) => card.rarity === 5 || eventBonusCharacters.has(card.characterId))
|
||||
? allCards.filter((card) => eventOptimizationCardEligible(card, eventBonusCharacters))
|
||||
: fiveStarCards;
|
||||
const cardById = new Map(allCards.map((card) => [card.id, card]));
|
||||
const allCharts = exactChartCatalog.charts;
|
||||
@@ -428,7 +429,7 @@
|
||||
}
|
||||
|
||||
function cardLabel(card: CardRow): string {
|
||||
return `${collection[card.id]?.owned ? '● ' : ''}${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
|
||||
return `${collection[card.id]?.owned ? '● ' : ''}${card.rarity}★ ${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
|
||||
}
|
||||
|
||||
function updateFixedCard(slot: number, cardId: string): void {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import cardSummary from '$lib/data/cards-summary.json';
|
||||
import boardSummary from '$lib/data/boards-summary.json';
|
||||
import memorySummary from '$lib/data/memory-summary.json';
|
||||
import { calculateBoardEffects, calculateUnitPower, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
|
||||
import { calculateBoardEffects, calculateUnitPower, cardMatchesSearch, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
|
||||
import { memoryParameterPermil } from '$lib/engine/memory.ts';
|
||||
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
|
||||
import { createUserData } from '$lib/models/user-data';
|
||||
@@ -39,6 +39,7 @@
|
||||
coreCubeFreeOnly: boolean;
|
||||
};
|
||||
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
|
||||
type BoardMaterialTotal = { resourceId: string; label: string; quantity: number };
|
||||
type GreenNodeEfficiency = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
@@ -68,6 +69,16 @@
|
||||
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
|
||||
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
|
||||
const GREEN_CORE_CUBE_ID = 'item-skill_tree_allmember-2';
|
||||
const boardMaterialLabels: Readonly<Record<string, string>> = {
|
||||
'item-skill_tree_card-1': '블루 큐브',
|
||||
'item-skill_tree_card-2': '블루 코어 큐브',
|
||||
'item-skill_tree_allmember-1': '그린 큐브',
|
||||
'item-skill_tree_allmember-2': '그린 코어 큐브',
|
||||
'item-skill_tree_leader-1': '레드 큐브',
|
||||
'item-skill_tree_leader-2': '레드 코어 큐브',
|
||||
'item-skill_tree_content-1': '옐로 큐브',
|
||||
'item-skill_tree_content-2': '옐로 코어 큐브'
|
||||
};
|
||||
const allCards = cardSummary.cards;
|
||||
const boardMembers = boardSummary.members as BoardMember[];
|
||||
const memberOrderByName = new Map(boardMembers.map((member) => [member.name, member.order]));
|
||||
@@ -131,7 +142,7 @@
|
||||
$: collectionCount = Object.values(userData.collection).filter((state) => state.owned).length;
|
||||
$: boardCount = Object.values(userData.memberBoards).filter((nodes) => nodes.length > 0).length;
|
||||
$: memoryPermil = memoryParameterPermil(userData.memoryCount, memorySummary.effects);
|
||||
$: filteredCards = managedCards.filter((card) => `${card.name} ${card.versionName}`.toLowerCase().includes(search.trim().toLowerCase()));
|
||||
$: filteredCards = managedCards.filter((card) => cardMatchesSearch(card, search));
|
||||
$: filteredFiveStarCards = filteredCards.filter((card) => card.rarity === 5);
|
||||
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
|
||||
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
|
||||
@@ -148,9 +159,10 @@
|
||||
selectedBoardMember?.positionGroupId ?? ''
|
||||
);
|
||||
$: filteredConnectCards = connectCards.filter((card) => {
|
||||
const query = boardCardSearch.trim().toLowerCase();
|
||||
return !query || `${card.name} ${card.versionName}`.toLowerCase().includes(query);
|
||||
return cardMatchesSearch(card, boardCardSearch);
|
||||
});
|
||||
$: selectedBoardMaterialTotals = boardMaterialTotals(selectedMemberId, selectedActiveBoardNodes);
|
||||
$: selectedBoardPointTotal = selectedActiveBoardNodes.reduce((total, groupId) => total + (nodeForBoardMember(selectedMemberId, groupId)?.skillTreePointCost ?? 0), 0);
|
||||
$: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId
|
||||
? fiveStarCards.filter((card) => card.characterId === efficiencyFormation.leaderCharacterId && card.details.costume)
|
||||
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
|
||||
@@ -574,6 +586,20 @@
|
||||
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
|
||||
}
|
||||
|
||||
function boardMaterialTotals(memberId: string, groupIds: readonly string[]): BoardMaterialTotal[] {
|
||||
const totals = new Map<string, number>();
|
||||
for (const groupId of groupIds) {
|
||||
const node = nodeForBoardMember(memberId, groupId);
|
||||
for (const consumption of node?.consumptions ?? []) {
|
||||
const quantity = Number(consumption.quantity) || 0;
|
||||
totals.set(consumption.resourceId, (totals.get(consumption.resourceId) ?? 0) + quantity);
|
||||
}
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.filter(([, quantity]) => quantity > 0)
|
||||
.map(([resourceId, quantity]) => ({ resourceId, quantity, label: boardMaterialLabels[resourceId] ?? resourceId }));
|
||||
}
|
||||
|
||||
function currentFormationPower(memberBoards: Record<string, string[]>, boardCards: BoardCardPlacements = userData.boardCards): number {
|
||||
if (!efficiencyFormationReady) return 0;
|
||||
const members = efficiencyFormation.cardIds.map((id, index) => ({
|
||||
@@ -901,6 +927,21 @@
|
||||
|
||||
function assignBoardCard(cardId: string) {
|
||||
if (!selectedSocketGroupId) return;
|
||||
const destinationCardId = selectedBoardCardPlacements[selectedSocketGroupId];
|
||||
const sourcePlacement = Object.entries(userData.boardCards).flatMap(([memberId, placements]) =>
|
||||
Object.entries(placements).map(([socketId, placedCardId]) => ({ memberId, socketId, placedCardId }))
|
||||
).find((placement) => placement.placedCardId === cardId
|
||||
&& !(placement.memberId === selectedMemberId && placement.socketId === selectedSocketGroupId));
|
||||
const warnings = [];
|
||||
if (destinationCardId && destinationCardId !== cardId) {
|
||||
const destinationCard = connectCardById.get(destinationCardId);
|
||||
warnings.push(`현재 ${destinationCard ? `${destinationCard.rarity}★ ${cardLabel(destinationCard)}` : destinationCardId} 카드를 덮어씁니다.`);
|
||||
}
|
||||
if (sourcePlacement) {
|
||||
const sourceMember = boardMembers.find((member) => member.id === sourcePlacement.memberId)?.name ?? sourcePlacement.memberId;
|
||||
warnings.push(`${sourceMember} 보드에 배치된 같은 카드를 이곳으로 이동합니다.`);
|
||||
}
|
||||
if (warnings.length > 0 && !window.confirm(`${warnings.join('\n')}\n계속할까요?`)) return;
|
||||
automaticPlacementSnapshot = null;
|
||||
automaticPlacementResult = [];
|
||||
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
|
||||
@@ -1278,7 +1319,7 @@
|
||||
</section>
|
||||
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
|
||||
<div class="efficiency-column-heading">
|
||||
<div><h3 id="removal-efficiency-title">초기화 검토</h3><p>환급 큐브 대비 종합력 손실이 낮은 순서</p></div>
|
||||
<div><h3 id="removal-efficiency-title">초기화 검토</h3><p>환급 큐브 대비 종합력 손실이 낮은 순서 · 기수 보너스 상한 초과분이나 현재 편성에 적용되지 않는 노드는 손실이 0일 수 있습니다.</p></div>
|
||||
</div>
|
||||
{#if greenEfficiencyResult.removals.length > 0}
|
||||
<div class="efficiency-list">
|
||||
@@ -1287,7 +1328,7 @@
|
||||
<button type="button" on:click={() => focusEfficiencyNode(entry)}>
|
||||
<span class="efficiency-rank">{index + 1}</span>
|
||||
<span class="efficiency-node"><small>{entry.memberName}</small><strong>{entry.description}</strong></span>
|
||||
<span class="efficiency-power"><strong>-{entry.powerDelta.toLocaleString('ko-KR')}</strong></span>
|
||||
<span class="efficiency-power"><strong>{entry.powerDelta === 0 ? '0 · 영향 없음' : `-${entry.powerDelta.toLocaleString('ko-KR')}`}</strong></span>
|
||||
<span class="efficiency-node-count"><strong>{entry.changedNodeCount}</strong></span>
|
||||
<span class="efficiency-cost"><strong>{entry.cost.greenCubes.toLocaleString('ko-KR')} / {entry.cost.greenCoreCubes.toLocaleString('ko-KR')}</strong></span>
|
||||
<span class="efficiency-rate"><strong>{formatEfficiency(entry.greenCubeEfficiency)}</strong></span>
|
||||
@@ -1308,6 +1349,10 @@
|
||||
</select>
|
||||
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
|
||||
</label>
|
||||
<div class="member-navigation" aria-label="멤버 보드 이동">
|
||||
<button type="button" on:click={() => selectAdjacentBoardMember(-1)} aria-label="이전 멤버 보드">‹ 이전</button>
|
||||
<button type="button" on:click={() => selectAdjacentBoardMember(1)} aria-label="다음 멤버 보드">다음 ›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedBoardMember}
|
||||
@@ -1315,6 +1360,13 @@
|
||||
<p><strong>{selectedActiveBoardNodeCount}</strong> / {selectedBoardNodeCount}개 활성화</p>
|
||||
<div><button type="button" on:click={activateAllBlueNodes}>모든 파란 노드 활성화</button><button type="button" on:click={() => setAllBoardNodes(true)}>모두 활성화</button><button type="button" on:click={() => setAllBoardNodes(false)}>모두 해제</button></div>
|
||||
</div>
|
||||
<div class="board-material-summary" aria-label="활성 보드 재료 총합">
|
||||
<span><small>노드 포인트</small><strong>{selectedBoardPointTotal.toLocaleString('ko-KR')}</strong></span>
|
||||
{#each selectedBoardMaterialTotals as material (material.resourceId)}
|
||||
<span><small>{material.label}</small><strong>{material.quantity.toLocaleString('ko-KR')}</strong></span>
|
||||
{/each}
|
||||
{#if selectedBoardMaterialTotals.length === 0}<p>활성 노드에 사용된 재료가 없습니다.</p>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="board-scroll"
|
||||
class:dragging={boardDragging}
|
||||
@@ -1533,11 +1585,19 @@
|
||||
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
|
||||
.member-select small { color: #6f7888; font-size: 11px; }
|
||||
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
|
||||
.member-navigation { display: flex; gap: 7px; }
|
||||
.member-navigation button { min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #465064; border-radius: 8px; color: #d5dbe4; background: #202733; font-size: 13px; font-weight: 850; cursor: pointer; }
|
||||
.member-navigation button:hover { border-color: #d96b9a; color: #f4b0cc; }
|
||||
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
|
||||
.board-toolbar p { margin: 0; color: #929cad; font-size: 14px; }
|
||||
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
|
||||
.board-toolbar div { display: flex; gap: 6px; }
|
||||
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
|
||||
.board-material-summary { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 12px; padding: 10px; border: 1px solid #303a47; border-radius: 10px; background: #131922; }
|
||||
.board-material-summary span { display: grid; gap: 2px; min-width: 108px; padding: 7px 9px; border-radius: 7px; background: #1c2430; }
|
||||
.board-material-summary small { color: #8490a0; font-size: 11px; }
|
||||
.board-material-summary strong { color: #dce3eb; font-size: 14px; }
|
||||
.board-material-summary p { margin: 0; color: #8792a2; font-size: 13px; }
|
||||
.automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
|
||||
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
|
||||
.automatic-placement strong { color: #b7dec7; font-size: 14px; }
|
||||
@@ -1717,6 +1777,8 @@
|
||||
.board-editor { padding: 21px 18px; }
|
||||
.board-heading { align-items: stretch; flex-direction: column; }
|
||||
.member-select { width: 100%; }
|
||||
.member-navigation { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.member-navigation button { width: 100%; min-height: 44px; }
|
||||
.board-toolbar { align-items: flex-start; gap: 10px; }
|
||||
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }
|
||||
.automatic-placement-actions { justify-content: flex-end; }
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<aside class="delivery-note">
|
||||
<span aria-hidden="true">●</span>
|
||||
<p><strong>온라인판</strong>은 변경 사항이 수시로 반영됩니다. <strong>오프라인판</strong>은 다시 내려받은 파일에 포함된 시점까지의 내역을 보여 줍니다.</p>
|
||||
<p>변경 사항은 검증과 배포가 끝난 뒤 <strong>온라인 서비스</strong>에 반영됩니다.</p>
|
||||
</aside>
|
||||
|
||||
<article class="markdown-body">{@html renderedUpdateHistory}</article>
|
||||
|
||||
Reference in New Issue
Block a user