diff --git a/package-lock.json b/package-lock.json index 86b65f3..ea9c988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "holodream-tools", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "holodream-tools", - "version": "1.0.3", + "version": "1.0.4", "engines": { "node": ">=22.12.0" }, diff --git a/package.json b/package.json index def9128..cd2054e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "holodream-tools", - "version": "1.0.3", + "version": "1.0.4", "private": true, "type": "module", "engines": { diff --git a/release-notes/1.0.4.md b/release-notes/1.0.4.md new file mode 100644 index 0000000..60b5c25 --- /dev/null +++ b/release-notes/1.0.4.md @@ -0,0 +1,23 @@ +# 홀로도리 계산기 1.0.4 + +카드 목록에서 액티브 정보를 더 쉽게 비교하고, 배치 분석과 조합 탐색의 입력 화면을 일관되게 개선한 업데이트입니다. + +## 주요 변경 사항 + +- 카드 목록의 각 행에 액티브 주기, 확률, 지속 시간과 효과를 표시합니다. +- 액티브 주기, 확률, 지속 시간과 효과를 기준으로 오름차순·내림차순 정렬할 수 있습니다. +- 카드 목록 전체를 명함 또는 5개화 상태로 전환해 표시와 정렬 결과를 비교할 수 있습니다. +- 카드 목록 출력 조건에 `모두 취소` 기능을 추가했습니다. +- 액티브 또는 일부 값이 없는 카드는 `—`로 표시하고 정렬 시 항상 뒤에 배치합니다. +- 배치 분석의 카드 선택, 개화와 빈도 감소 입력을 열 헤더 중심의 표 형태로 정리했습니다. +- 조합 탐색의 탐색 조건 UI를 배치 분석과 같은 입력 구조와 스타일로 통일했습니다. +- 조합 탐색의 기수 보너스 입력을 숫자 `0~900` 범위로 제한했습니다. +- 보드 데이터의 활성 노드 수에서 항상 활성화된 가운데 연결 노드를 제외했습니다. + +## 참고 사항 + +카드 목록의 정렬 기준, 정렬 방향과 명함·5개화 선택은 사용자 데이터에 저장됩니다. 이전 버전의 사용자 데이터와 백업 파일은 기본값을 자동으로 적용해 그대로 사용할 수 있습니다. + +## 다운로드 + +Release의 Assets에서 `HolodoriCalc-offline-v1.0.4.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다. diff --git a/src/lib/engine/card-list-sort.test.ts b/src/lib/engine/card-list-sort.test.ts new file mode 100644 index 0000000..fb471cd --- /dev/null +++ b/src/lib/engine/card-list-sort.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { activeEffectPercent, sortCardsByActive, type CardSummary, type SkillLevel } from '../models/card-list.ts'; + +function skill(level: number, values: Partial = {}): SkillLevel { + return { + level, + description: '', + coolTimeSeconds: null, + probabilityPercent: null, + durationSeconds: null, + ...values + }; +} + +function card(id: string, active?: SkillLevel[]): CardSummary { + return { + id, + rarity: 5, + type: '큐트', + name: id, + versionName: '', + generation: '', + highestStat: '퍼포먼스', + details: { title: '', passive: [], active, special: [], costume: null } + }; +} + +test('sorts numeric active values in both directions and keeps missing values last', () => { + const cards = [ + card('missing'), + card('slow', [skill(1, { coolTimeSeconds: 24 })]), + card('fast', [skill(1, { coolTimeSeconds: 18 })]) + ]; + + assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'asc', 1).map(({ id }) => id), ['fast', 'slow', 'missing']); + assert.deepEqual(sortCardsByActive(cards, 'coolTime', 'desc', 1).map(({ id }) => id), ['slow', 'fast', 'missing']); +}); + +test('uses the selected skill level and preserves source order for ties', () => { + const cards = [ + card('first', [skill(1, { effect: '스코어가 80% UP' }), skill(2, { effect: '스코어가 120% UP' })]), + card('second', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })]), + card('same', [skill(1, { effect: '스코어가 90% UP' }), skill(2, { effect: '스코어가 100% UP' })]) + ]; + + assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 1).map(({ id }) => id), ['second', 'same', 'first']); + assert.deepEqual(sortCardsByActive(cards, 'effect', 'desc', 2).map(({ id }) => id), ['first', 'second', 'same']); +}); + +test('falls back to the active description when the separated effect is unavailable', () => { + assert.equal(activeEffectPercent(skill(1, { description: '10초 동안 스코어가 85% UP' })), 85); + assert.equal(activeEffectPercent(skill(1, { effect: '라이프가 회복됩니다.' })), null); +}); diff --git a/src/lib/models/card-list.ts b/src/lib/models/card-list.ts new file mode 100644 index 0000000..6dec911 --- /dev/null +++ b/src/lib/models/card-list.ts @@ -0,0 +1,82 @@ +import type { CardListSortKey, SortDirection } from './user-data.ts'; + +export type CardType = '큐트' | '퓨어' | '해피'; +export type HighestStat = '퍼포먼스' | '테크닉' | '센스'; + +export type SkillLevel = { + level: number; + description: string; + coolTimeSeconds: number | null; + probabilityPercent: number | null; + durationSeconds: number | null; + effect?: string; + condition?: string; +}; + +export type CardDetails = { + title: string; + passive: SkillLevel[]; + active?: SkillLevel[]; + special: SkillLevel[]; + costume: { name: string; description: string } | null; +}; + +export type CardSummary = { + id: string; + rarity: number; + type: CardType; + name: string; + versionName: string; + generation: string; + highestStat: HighestStat; + details: CardDetails; +}; + +export function skillAtLevel( + levels: readonly SkillLevel[] | undefined, + selectedLevel: number +): SkillLevel | undefined { + return levels?.find((level) => level.level === selectedLevel) ?? levels?.at(-1); +} + +export function activeEffectPercent(skill: SkillLevel | undefined): number | null { + const match = (skill?.effect || skill?.description)?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/); + return match ? Number(match[1]) : null; +} + +export function activeSortValue( + card: CardSummary, + sortKey: CardListSortKey, + selectedLevel: number +): number | null { + const active = skillAtLevel(card.details.active, selectedLevel); + if (!active) return null; + + const value = { + coolTime: active.coolTimeSeconds, + probability: active.probabilityPercent, + duration: active.durationSeconds, + effect: activeEffectPercent(active) + }[sortKey]; + + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +export function sortCardsByActive( + cards: readonly CardSummary[], + sortKey: CardListSortKey | null, + sortDirection: SortDirection, + selectedLevel: number +): CardSummary[] { + if (!sortKey) return [...cards]; + + const direction = sortDirection === 'asc' ? 1 : -1; + return cards + .map((card, index) => ({ card, index, value: activeSortValue(card, sortKey, selectedLevel) })) + .sort((left, right) => { + if (left.value === null) return right.value === null ? left.index - right.index : 1; + if (right.value === null) return -1; + return (left.value - right.value) * direction || left.index - right.index; + }) + .map(({ card }) => card); +} diff --git a/src/lib/models/user-data.ts b/src/lib/models/user-data.ts index 3161e7d..565ad79 100644 --- a/src/lib/models/user-data.ts +++ b/src/lib/models/user-data.ts @@ -12,6 +12,10 @@ export type SavedDeck = { updatedAt: string; }; +export type CardListSortKey = 'coolTime' | 'probability' | 'duration' | 'effect'; +export type SortDirection = 'asc' | 'desc'; +export type CardSkillLevel = 1 | 2; + export type CardListPreferences = { types: string[]; stats: string[]; @@ -20,6 +24,9 @@ export type CardListPreferences = { activeProbabilities: string[]; cardVariants: string[]; generations: string[]; + sortKey: CardListSortKey | null; + sortDirection: SortDirection; + skillLevel: CardSkillLevel; }; export type BoardCardPlacements = Record>; diff --git a/src/lib/storage/browser-storage.ts b/src/lib/storage/browser-storage.ts index 94e05b1..b812e62 100644 --- a/src/lib/storage/browser-storage.ts +++ b/src/lib/storage/browser-storage.ts @@ -1,4 +1,4 @@ -import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data'; +import type { CardListPreferences, CardListSortKey, CardSkillLevel, CollectionCardState, SortDirection, UserData } from '$lib/models/user-data'; import { createUserData } from '$lib/models/user-data'; export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1'; @@ -17,6 +17,20 @@ function finiteNumber(value: unknown, fallback = 0): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } +function cardListSortKey(value: unknown, fallback: CardListSortKey | null): CardListSortKey | null { + return value === 'coolTime' || value === 'probability' || value === 'duration' || value === 'effect' + ? value + : fallback; +} + +function sortDirection(value: unknown, fallback: SortDirection): SortDirection { + return value === 'asc' || value === 'desc' ? value : fallback; +} + +function cardSkillLevel(value: unknown, fallback: CardSkillLevel): CardSkillLevel { + return value === 1 || value === 2 ? value : fallback; +} + function parseCollection(value: Record): UserData['collection'] { return Object.fromEntries( Object.entries(value).flatMap(([cardId, state]) => { @@ -79,7 +93,10 @@ export function parseUserData(text: string, fallbackPreferences: CardListPrefere specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions), activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities), cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants), - generations: stringArray(preferences.generations, fallbackPreferences.generations) + generations: stringArray(preferences.generations, fallbackPreferences.generations), + sortKey: cardListSortKey(preferences.sortKey, fallbackPreferences.sortKey), + sortDirection: sortDirection(preferences.sortDirection, fallbackPreferences.sortDirection), + skillLevel: cardSkillLevel(preferences.skillLevel, fallbackPreferences.skillLevel) } }; } diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index dacd787..fa9c98a 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -474,6 +474,11 @@
+ {#each selectedCardIds as selectedId, slot}
- 개화
- 빈도 감소 고정
{#each REDUCTION_LOCK_OPTIONS as reduction}