diff --git a/package-lock.json b/package-lock.json index 97fe535..5ee659a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "holodream-tools", - "version": "1.0.5", + "version": "1.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "holodream-tools", - "version": "1.0.5", + "version": "1.0.6", "dependencies": { "better-sqlite3": "^13.0.3", "drizzle-orm": "^0.45.2", diff --git a/package.json b/package.json index 4130ecc..5fbe9f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "holodream-tools", - "version": "1.0.5", + "version": "1.0.6", "private": true, "type": "module", "engines": { diff --git a/release-notes/1.0.6.md b/release-notes/1.0.6.md new file mode 100644 index 0000000..bcecd1e --- /dev/null +++ b/release-notes/1.0.6.md @@ -0,0 +1,20 @@ +# 홀로도리 계산기 v1.0.6 + +배치 분석의 보드 조건을 세밀하게 조정하고, 조합을 완성하는 데 필요한 추가 개화 횟수를 바로 확인할 수 있도록 개선한 업데이트입니다. + +## 주요 변경 사항 + +- 배치 분석에서 카드별로 파란 노드를 모두 활성화한 상태를 적용할 수 있습니다. +- 카드별 기수 보너스를 `0~900` 범위의 값으로 덮어쓸 수 있습니다. 값을 비워 두면 사용자 데이터에 저장된 실제 보드 수치를 사용합니다. +- 파란 노드와 기수 보너스 조건을 현재 분석 상태와 프리셋에 저장하고 다시 불러올 수 있습니다. +- 배치 분석에서 가져온 조건으로 조합 탐색의 기준 조합을 계산할 때도 같은 보드 설정을 적용합니다. +- 조합 탐색 결과에 카드별 부족한 개화 횟수와 조합 전체의 추가 개화 합계를 표시합니다. +- 미보유 카드를 처음 획득하는 과정은 추가 개화 1회로 계산합니다. + +## 참고 사항 + +파란 노드 전체 활성화와 기수 보너스 덮어쓰기는 분석 조건에만 적용되며 사용자 데이터에 저장된 실제 보드 상태를 변경하지 않습니다. + +## 다운로드 + +Release의 Assets에서 `HolodoriCalc-offline-v1.0.6.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다. diff --git a/src/lib/engine/awakening-requirements.test.ts b/src/lib/engine/awakening-requirements.test.ts new file mode 100644 index 0000000..70cd9b6 --- /dev/null +++ b/src/lib/engine/awakening-requirements.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { additionalAwakeningsRequired } from './awakening-requirements.ts'; + +test('미보유 카드를 보유하는 과정은 1회의 개화로 계산한다', () => { + assert.equal(additionalAwakeningsRequired(undefined, 0), 1); + assert.equal(additionalAwakeningsRequired({ owned: false, awakening: 5 }, 0), 1); + assert.equal(additionalAwakeningsRequired({ owned: false }, 5), 6); +}); + +test('보유 카드는 현재 상태에서 부족한 개화만 계산한다', () => { + assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 2 }, 5), 3); + assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 5 }, 3), 0); + assert.equal(additionalAwakeningsRequired({ owned: true, awakening: 0 }, 0), 0); +}); diff --git a/src/lib/engine/awakening-requirements.ts b/src/lib/engine/awakening-requirements.ts new file mode 100644 index 0000000..d446ba9 --- /dev/null +++ b/src/lib/engine/awakening-requirements.ts @@ -0,0 +1,21 @@ +export type CardAwakeningState = { + owned?: boolean; + awakening?: number; +}; + +function normalizeAwakening(value: unknown): number { + return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 0)); +} + +/** + * 현재 보유 상태에서 목표 개화까지 필요한 횟수. + * 미보유 카드를 처음 획득하는 과정은 1회의 개화로 계산한다. + */ +export function additionalAwakeningsRequired( + state: CardAwakeningState | undefined, + targetAwakening: number +): number { + const target = normalizeAwakening(targetAwakening); + if (state?.owned !== true) return target + 1; + return Math.max(0, target - normalizeAwakening(state.awakening)); +} diff --git a/src/lib/engine/index.ts b/src/lib/engine/index.ts index 3c3ceb5..d5e82e7 100644 --- a/src/lib/engine/index.ts +++ b/src/lib/engine/index.ts @@ -1,5 +1,6 @@ export * from './card-adapter.ts'; export * from './combination-search.ts'; +export * from './awakening-requirements.ts'; export * from './board-effects.ts'; export * from './exact-optimizer.ts'; export * from './memory.ts'; diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index fa296f0..a28e901 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -32,6 +32,8 @@ cardIds: string[]; awakenings: number[]; fixedCooldownReductions?: (CooldownReduction | null)[]; + allBlueNodes?: boolean[]; + generationBonusOverrides?: (number | null)[]; leaderCharacterId: string; leaderOutfitCardId: string; }; @@ -50,6 +52,12 @@ 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])); + const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [ + member.id, + (boardSummary.models as Record)[member.positionGroupId] + ?.filter((position) => position.groupId.startsWith('B')) + .map((position) => position.groupId) ?? [] + ])); function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] { const memberIds = new Set(); @@ -72,6 +80,8 @@ let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id); let selectedAwakenings = selectedCardIds.map(() => 5); let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null); + let allBlueNodes = selectedCardIds.map(() => false); + let generationBonusOverrides: (number | null)[] = selectedCardIds.map(() => null); let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let worker: Worker | null = null; @@ -105,10 +115,14 @@ awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect })); - $: boardEffects = calculateBoardEffects(boardSummary, memberBoards, selectedSourceCards, leaderCharacterId, selectedChart, { + $: effectiveMemberBoards = boardsForSelectedCards(memberBoards, selectedCardIds, allBlueNodes); + $: generationBonusByCardId = Object.fromEntries(selectedCardIds + .map((cardId, index) => [cardId, generationBonusOverrides[index]] as const) + .filter((entry): entry is readonly [string, number] => entry[1] !== null)); + $: boardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, selectedChart, { placements: boardCards, cards: connectionCards - }); + }, { generationBonusByCardId }); $: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects); $: unitPower = (() => { try { @@ -146,6 +160,8 @@ cardIds: selectedCardIds, awakenings: selectedAwakenings, fixedCooldownReductions, + allBlueNodes, + generationBonusOverrides, leaderCharacterId, leaderOutfitCardId } satisfies SavedAnalysisState; @@ -156,6 +172,8 @@ cardIds: selectedCardIds, awakenings: selectedAwakenings, fixedCooldownReductions, + allBlueNodes, + generationBonusOverrides, leaderCharacterId, leaderOutfitCardId }); @@ -275,6 +293,8 @@ return normalizeAwakening(state.awakenings?.[index]); }); fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])); + allBlueNodes = restoredCards.map((_, index) => state.allBlueNodes?.[index] === true); + generationBonusOverrides = restoredCards.map((_, index) => normalizeGenerationBonusOverride(state.generationBonusOverrides?.[index])); } } @@ -315,8 +335,47 @@ result = null; } - function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean { - return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId); + function normalizeGenerationBonusOverride(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(900, Math.round(value))); + } + + function sanitizeGenerationBonusInput(input: HTMLInputElement, previousValue: number | null): string { + if (input.value === '') return ''; + if (!/^\d+$/.test(input.value) || Number(input.value) > 900) { + input.value = previousValue === null ? '' : String(previousValue); + return input.value; + } + input.value = String(Number(input.value)); + return input.value; + } + + function toggleAllBlueNodes(slot: number): void { + allBlueNodes = allBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled); + result = null; + } + + function updateGenerationBonus(slot: number, rawValue: string): void { + const parsed = Number(rawValue); + const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonusOverride(parsed); + generationBonusOverrides = generationBonusOverrides.map((bonus, index) => index === slot ? value : bonus); + result = null; + } + + function boardsForSelectedCards( + storedBoards: MemberBoards, + cardIds: readonly string[], + blueNodeOverrides: readonly boolean[] + ): MemberBoards { + const boards = Object.fromEntries(Object.entries(storedBoards).map(([characterId, groupIds]) => [characterId, [...groupIds]])); + cardIds.forEach((cardId, index) => { + if (!blueNodeOverrides[index]) return; + const characterId = cardById.get(cardId)?.characterId; + if (!characterId) return; + const nonBlueGroups = (boards[characterId] ?? []).filter((groupId) => !groupId.startsWith('B')); + boards[characterId] = [...nonBlueGroups, ...(blueGroupsByCharacter[characterId] ?? [])]; + }); + return boards; } function updateCard(slot: number, cardId: string): void { @@ -522,13 +581,14 @@ 카드 선택 개화 빈도 감소 고정 +
파란 노드 전체기수 보너스
덮어쓰기 (0~900)
{#each selectedCardIds as selectedId, slot}
@@ -561,9 +621,17 @@ {/each}
+
+ + updateGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, generationBonusOverrides[slot] ?? null))}> +
{/each} +

‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.

@@ -1107,13 +1131,23 @@ {#if ranking.isBaseline}기준 조합{/if} {#each ranking.result.formationOrder as cardId, memberIndex} - {displayedCardAwakening(cardId, ranking.isBaseline)}개화 + + {displayedCardAwakening(cardId, ranking.isBaseline)}개화 + {#if displayedCardAdditionalAwakenings(cardId, ranking.isBaseline) > 0} + · +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회 + {/if} + {resultCardName(cardId)} {#if memberIndex < ranking.result.formationOrder.length - 1}{/if} {/each} - 최고 점수{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}{#if comparableToBaseline && !ranking.isBaseline}{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}{/if} + + 최고 점수 + {ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')} + {#if comparableToBaseline && !ranking.isBaseline}{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}{/if} + 추가 개화 {displayedAdditionalAwakenings(ranking)}회 + 최고점 대비 달성 확률 100%{probabilityLabel(ranking.achievementProbabilities?.maximum)} @@ -1134,6 +1168,7 @@ {/each}
+

추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.

달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.

{:else}
고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.
@@ -1243,13 +1278,17 @@ .rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; } .rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; } .rank-metric i { color: #90d8ae; font-size: 12px; font-style: normal; font-weight: 800; } + .rank-metric b { width: fit-content; margin-top: 2px; padding: 3px 7px; border-radius: 999px; color: #f0b0c9; background: #30212a; font-size: 11px; font-weight: 850; } + .rank-metric b.complete { color: #9edbb7; background: #1e3028; } .rank-probabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px 12px; padding-left: 18px; border-left: 1px solid #303744; } .rank-probabilities > small { grid-column: 1 / -1; color: #9ca5b3; font-size: 12px; font-weight: 750; } .rank-probabilities > span { display: grid; gap: 2px; } .rank-probabilities b { color: #7f8998; font-size: 10px; font-weight: 750; } .rank-probabilities strong { color: #dce1e8; font-size: 14px; line-height: 1.2; } .rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; } - .probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; } + .awakening-guide, .probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; } + .awakening-guide { color: #aa8797; } + .awakening-guide + .probability-guide { margin-top: 5px; } .ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; } .scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; } .scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; } diff --git a/src/routes/data/+page.svelte b/src/routes/data/+page.svelte index a12e5e9..a1d4ec0 100644 --- a/src/routes/data/+page.svelte +++ b/src/routes/data/+page.svelte @@ -209,10 +209,6 @@ greenEfficiencyError = ''; } - function efficiencyMemberSelectedInAnotherSlot(card: CardSummary, slot: number): boolean { - return efficiencyFormation.cardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId); - } - function updateEfficiencyCard(slot: number, cardId: string) { const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id); const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot @@ -1224,7 +1220,7 @@