feat: release version 1.0.4

This commit is contained in:
2026-08-19 18:56:00 +09:00
parent bd9d5a6ba6
commit f910db8901
11 changed files with 506 additions and 105 deletions
+54
View File
@@ -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> = {}): 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);
});
+82
View File
@@ -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);
}
+7
View File
@@ -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<string, Record<string, string>>;
+19 -2
View File
@@ -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<string, unknown>): 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)
}
};
}