feat: release version 1.0.6

This commit is contained in:
2026-08-20 18:07:26 +09:00
parent 6537ab1f20
commit 5d29b63f3a
9 changed files with 198 additions and 26 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.5", "version": "1.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.5", "version": "1.0.6",
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.5", "version": "1.0.6",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+20
View File
@@ -0,0 +1,20 @@
# 홀로도리 계산기 v1.0.6
배치 분석의 보드 조건을 세밀하게 조정하고, 조합을 완성하는 데 필요한 추가 개화 횟수를 바로 확인할 수 있도록 개선한 업데이트입니다.
## 주요 변경 사항
- 배치 분석에서 카드별로 파란 노드를 모두 활성화한 상태를 적용할 수 있습니다.
- 카드별 기수 보너스를 `0~900` 범위의 값으로 덮어쓸 수 있습니다. 값을 비워 두면 사용자 데이터에 저장된 실제 보드 수치를 사용합니다.
- 파란 노드와 기수 보너스 조건을 현재 분석 상태와 프리셋에 저장하고 다시 불러올 수 있습니다.
- 배치 분석에서 가져온 조건으로 조합 탐색의 기준 조합을 계산할 때도 같은 보드 설정을 적용합니다.
- 조합 탐색 결과에 카드별 부족한 개화 횟수와 조합 전체의 추가 개화 합계를 표시합니다.
- 미보유 카드를 처음 획득하는 과정은 추가 개화 1회로 계산합니다.
## 참고 사항
파란 노드 전체 활성화와 기수 보너스 덮어쓰기는 분석 조건에만 적용되며 사용자 데이터에 저장된 실제 보드 상태를 변경하지 않습니다.
## 다운로드
Release의 Assets에서 `HolodoriCalc-offline-v1.0.6.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
@@ -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);
});
+21
View File
@@ -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));
}
+1
View File
@@ -1,5 +1,6 @@
export * from './card-adapter.ts'; export * from './card-adapter.ts';
export * from './combination-search.ts'; export * from './combination-search.ts';
export * from './awakening-requirements.ts';
export * from './board-effects.ts'; export * from './board-effects.ts';
export * from './exact-optimizer.ts'; export * from './exact-optimizer.ts';
export * from './memory.ts'; export * from './memory.ts';
+88 -8
View File
@@ -32,6 +32,8 @@
cardIds: string[]; cardIds: string[];
awakenings: number[]; awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[]; fixedCooldownReductions?: (CooldownReduction | null)[];
allBlueNodes?: boolean[];
generationBonusOverrides?: (number | null)[];
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
@@ -50,6 +52,12 @@
let charts = releasedSongCharts(allCharts, releaseNow); let charts = releasedSongCharts(allCharts, releaseNow);
let 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 cardById = new Map(fiveStarCards.map((card) => [card.id, card])); const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
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) ?? []
]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] { function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
const memberIds = new Set<string>(); const memberIds = new Set<string>();
@@ -72,6 +80,8 @@
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id); let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
let selectedAwakenings = selectedCardIds.map(() => 5); let selectedAwakenings = selectedCardIds.map(() => 5);
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null); 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 leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null; let worker: Worker | null = null;
@@ -105,10 +115,14 @@
awakening: collection[card.id]?.awakening ?? 0, awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect 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, placements: boardCards,
cards: connectionCards cards: connectionCards
}); }, { generationBonusByCardId });
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects); $: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
$: unitPower = (() => { $: unitPower = (() => {
try { try {
@@ -146,6 +160,8 @@
cardIds: selectedCardIds, cardIds: selectedCardIds,
awakenings: selectedAwakenings, awakenings: selectedAwakenings,
fixedCooldownReductions, fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
} satisfies SavedAnalysisState; } satisfies SavedAnalysisState;
@@ -156,6 +172,8 @@
cardIds: selectedCardIds, cardIds: selectedCardIds,
awakenings: selectedAwakenings, awakenings: selectedAwakenings,
fixedCooldownReductions, fixedCooldownReductions,
allBlueNodes,
generationBonusOverrides,
leaderCharacterId, leaderCharacterId,
leaderOutfitCardId leaderOutfitCardId
}); });
@@ -275,6 +293,8 @@
return normalizeAwakening(state.awakenings?.[index]); return normalizeAwakening(state.awakenings?.[index]);
}); });
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[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; result = null;
} }
function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean { function normalizeGenerationBonusOverride(value: unknown): number | null {
return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId); 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 { function updateCard(slot: number, cardId: string): void {
@@ -522,13 +581,14 @@
<span>카드 선택</span> <span>카드 선택</span>
<span>개화</span> <span>개화</span>
<span>빈도 감소 고정</span> <span>빈도 감소 고정</span>
<div><span>파란 노드 전체</span><span>기수 보너스<br>덮어쓰기 (0~900)</span></div>
</div> </div>
{#each selectedCardIds as selectedId, slot} {#each selectedCardIds as selectedId, slot}
<div class="card-field"> <div class="card-field">
<label><span>{slot + 1}번 선택</span> <label><span>{slot + 1}번 선택</span>
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}> <select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
{#each fiveStarCards as card} {#each fiveStarCards as card}
<option value={card.id} disabled={memberSelectedInAnotherSlot(card, slot)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option> <option value={card.id} disabled={selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each} {/each}
</select> </select>
</label> </label>
@@ -561,9 +621,17 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="board-overrides">
<label class="override-checkbox">
<input type="checkbox" checked={allBlueNodes[slot]} aria-label={`${slot + 1}번 카드 파란 노드 전체 활성화`} on:change={() => toggleAllBlueNodes(slot)}>
<span aria-hidden="true"></span>
</label>
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="3" value={generationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 카드 기수 보너스 덮어쓰기 (0~900)`} on:input={(event) => updateGenerationBonus(slot, sanitizeGenerationBonusInput(event.currentTarget, generationBonusOverrides[slot] ?? null))}>
</div>
</div> </div>
{/each} {/each}
</div> </div>
<p class="board-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다. 기수 보너스는 빈칸이면 실제 보드 수치를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다.</p>
<div class="leader-fields"> <div class="leader-fields">
<label>리더 <label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}> <select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
@@ -705,7 +773,7 @@
.section-title > strong { color: #aeb6c4; font-size: 14px; } .section-title > strong { color: #aeb6c4; font-size: 14px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; } label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; } select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); } select:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; } .song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; } .power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; } .power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
@@ -713,9 +781,10 @@
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; } .chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; } .chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; } .card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field-header, .card-field { display: grid; grid-template-columns: minmax(320px, 1fr) clamp(130px, 12vw, 180px) clamp(304px, 27vw, 400px); gap: 9px; } .card-field-header, .card-field { display: grid; grid-template-columns: minmax(280px, 1fr) clamp(120px, 10vw, 160px) clamp(280px, 24vw, 360px) clamp(180px, 16vw, 240px); gap: 9px; }
.card-field-header { padding: 0 2px; color: #8f98a8; font-size: 13px; font-weight: 800; } .card-field-header { padding: 0 2px; color: #8f98a8; font-size: 13px; font-weight: 800; }
.card-field-header span { padding-left: 2px; } .card-field-header span { padding-left: 2px; }
.card-field-header > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 12px; text-align: center; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; } .card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { min-width: 0; } .card-field > .awakening { min-width: 0; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; } .awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
@@ -731,6 +800,16 @@
.reduction-options button:hover { color: #e9edf3; background: #202630; } .reduction-options button:hover { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; } .reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; } .reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.board-overrides { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; align-self: end; height: 42px; }
.override-checkbox { display: grid; place-items: center; cursor: pointer; }
.override-checkbox input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.override-checkbox span { display: grid; place-items: center; width: 22px; height: 22px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
.override-checkbox input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
.override-checkbox input:checked + span::after { content: '✓'; color: #171019; font-size: 14px; font-weight: 950; }
.override-checkbox input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
.generation-input { align-self: center; width: 100%; min-width: 0; height: 32px; padding: 0 7px; border: 1px solid #414957; border-radius: 7px; color: #e8ebf1; background: #11151b; font-size: 12px; font-weight: 800; text-align: center; }
.generation-input::placeholder { color: #68717f; opacity: 1; }
.board-guide { margin: 8px 0 0; color: #737d8d; font-size: 12px; line-height: 1.5; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; } .leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; } .outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; } .outfit-effect span { color: #d993b1; font-size: 13px; font-weight: 850; }
@@ -794,7 +873,7 @@
.time-axis span:last-child { transform: translateX(-100%); } .time-axis span:last-child { transform: translateX(-100%); }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; } .empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.card-field { grid-template-columns: minmax(0, 1fr) 130px 304px; } .card-field-header, .card-field { grid-template-columns: minmax(0, 1fr) 120px 280px 180px; }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
@@ -811,6 +890,7 @@
.card-field > .awakening { grid-template-columns: 1fr; } .card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; } .awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); } .cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
.board-overrides { grid-column: 1 / -1; }
.leader-fields { grid-template-columns: 1fr; } .leader-fields { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; } .outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); } .order-list li { grid-template-columns: 30px minmax(0, 1fr); }
+49 -10
View File
@@ -11,6 +11,7 @@
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte'; import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import { import {
additionalAwakeningsRequired,
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
combinationSearchLimits, combinationSearchLimits,
@@ -72,6 +73,8 @@
cardIds: string[]; cardIds: string[];
awakenings: number[]; awakenings: number[];
fixedCooldownReductions: (CooldownReduction | null)[]; fixedCooldownReductions: (CooldownReduction | null)[];
allBlueNodes: boolean[];
generationBonusOverrides: (number | null)[];
leaderCharacterId: string; leaderCharacterId: string;
leaderOutfitCardId: string; leaderOutfitCardId: string;
}; };
@@ -378,6 +381,11 @@
cardIds: cards.map((card) => card.id), cardIds: cards.map((card) => card.id),
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0), awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])), fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
allBlueNodes: cards.map((_, index) => state.allBlueNodes?.[index] === true),
generationBonusOverrides: cards.map((_, index) => {
const value = state.generationBonusOverrides?.[index];
return typeof value === 'number' && Number.isFinite(value) ? normalizeGenerationBonus(value) : null;
}),
leaderCharacterId: typeof state.leaderCharacterId === 'string' ? state.leaderCharacterId : '', leaderCharacterId: typeof state.leaderCharacterId === 'string' ? state.leaderCharacterId : '',
leaderOutfitCardId: typeof state.leaderOutfitCardId === 'string' ? state.leaderOutfitCardId : '' leaderOutfitCardId: typeof state.leaderOutfitCardId === 'string' ? state.leaderOutfitCardId : ''
}; };
@@ -387,10 +395,6 @@
return `${collection[card.id]?.owned ? '● ' : ''}${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`; return `${collection[card.id]?.owned ? '● ' : ''}${card.name}${card.versionName ? ` · ${card.versionName}` : ''}`;
} }
function selectedInAnotherSlot(card: CardRow, slot: number): boolean {
return fixedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateFixedCard(slot: number, cardId: string): void { function updateFixedCard(slot: number, cardId: string): void {
const cardChanged = fixedCardIds[slot] !== cardId; const cardChanged = fixedCardIds[slot] !== cardId;
fixedCardIds = fixedCardIds.map((id, index) => index === slot ? cardId : id); fixedCardIds = fixedCardIds.map((id, index) => index === slot ? cardId : id);
@@ -713,10 +717,19 @@
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card)); const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (cards.length !== 5) throw new Error('기준 조합의 카드 정보를 찾지 못했습니다.'); if (cards.length !== 5) throw new Error('기준 조합의 카드 정보를 찾지 못했습니다.');
const outfit = cardById.get(state.leaderOutfitCardId)?.details.costume ?? null; const outfit = cardById.get(state.leaderOutfitCardId)?.details.costume ?? null;
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, cards, state.leaderCharacterId, chartMetadata, { const baselineBoards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, [...groupIds]]));
cards.forEach((card, index) => {
if (!state.allBlueNodes[index]) return;
const nonBlueGroups = (baselineBoards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
baselineBoards[card.characterId] = [...nonBlueGroups, ...(blueGroupsByCharacter[card.characterId] ?? [])];
});
const generationBonusByCardId = Object.fromEntries(cards
.map((card, index) => [card.id, state.generationBonusOverrides[index]] as const)
.filter((entry): entry is readonly [string, number] => entry[1] !== null));
const boardEffects = calculateBoardEffects(boardSummary, baselineBoards, cards, state.leaderCharacterId, chartMetadata, {
placements: boardCards, placements: boardCards,
cards: connectionCards cards: connectionCards
}); }, { generationBonusByCardId });
const power = calculateUnitPower( const power = calculateUnitPower(
cards.map((card, index) => ({ card, awakening: state.awakenings[index] })), cards.map((card, index) => ({ card, awakening: state.awakenings[index] })),
outfit, outfit,
@@ -946,6 +959,17 @@
return index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0; return index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
} }
function displayedCardAdditionalAwakenings(cardId: string, isBaseline: boolean): number {
return additionalAwakeningsRequired(collection[cardId], displayedCardAwakening(cardId, isBaseline));
}
function displayedAdditionalAwakenings(ranking: DisplayRankedResult): number {
return ranking.result.formationOrder.reduce(
(total, cardId) => total + displayedCardAdditionalAwakenings(cardId, ranking.isBaseline),
0
);
}
function displayedCompositionKey(ranking: DisplayRankedResult): string { function displayedCompositionKey(ranking: DisplayRankedResult): string {
return ranking.result.order return ranking.result.order
.map((cardId) => `${cardId}:${displayedCardAwakening(cardId, ranking.isBaseline)}`) .map((cardId) => `${cardId}:${displayedCardAwakening(cardId, ranking.isBaseline)}`)
@@ -1046,7 +1070,7 @@
<label><span>{slot + 1}번 고정</span> <label><span>{slot + 1}번 고정</span>
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}> <select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
<option value="">고정하지 않음</option> <option value="">고정하지 않음</option>
{#each candidateCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each} {#each candidateCards as card}<option value={card.id} disabled={fixedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{cardLabel(card)}</option>{/each}
</select> </select>
</label> </label>
<div class="fixed-awakening"> <div class="fixed-awakening">
@@ -1107,13 +1131,23 @@
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if} {#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
{#each ranking.result.formationOrder as cardId, memberIndex} {#each ranking.result.formationOrder as cardId, memberIndex}
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}> <span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small> <small class="member-awakening">
{displayedCardAwakening(cardId, ranking.isBaseline)}개화
{#if displayedCardAdditionalAwakenings(cardId, ranking.isBaseline) > 0}
· +{displayedCardAdditionalAwakenings(cardId, ranking.isBaseline)}회
{/if}
</small>
<span class="member-name">{resultCardName(cardId)}</span> <span class="member-name">{resultCardName(cardId)}</span>
</span> </span>
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if} {#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
{/each} {/each}
</span> </span>
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span> <span class="rank-metric">
<small>최고 점수</small>
<strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>
{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}
<b class:complete={displayedAdditionalAwakenings(ranking) === 0}>추가 개화 {displayedAdditionalAwakenings(ranking)}회</b>
</span>
<span class="rank-probabilities"> <span class="rank-probabilities">
<small>최고점 대비 달성 확률</small> <small>최고점 대비 달성 확률</small>
<span><b>100%</b><strong>{probabilityLabel(ranking.achievementProbabilities?.maximum)}</strong></span> <span><b>100%</b><strong>{probabilityLabel(ranking.achievementProbabilities?.maximum)}</strong></span>
@@ -1134,6 +1168,7 @@
</details> </details>
{/each} {/each}
</div> </div>
<p class="awakening-guide">추가 개화는 현재 카드 데이터와 조합의 목표 개화를 비교한 값입니다. 미보유 카드를 처음 얻는 과정도 1회로 계산합니다.</p>
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p> <p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else} {:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div> <div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
@@ -1243,13 +1278,17 @@
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; } .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 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 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 { 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 > small { grid-column: 1 / -1; color: #9ca5b3; font-size: 12px; font-weight: 750; }
.rank-probabilities > span { display: grid; gap: 2px; } .rank-probabilities > span { display: grid; gap: 2px; }
.rank-probabilities b { color: #7f8998; font-size: 10px; font-weight: 750; } .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 strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; } .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; } .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 { 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; } .scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
+1 -5
View File
@@ -209,10 +209,6 @@
greenEfficiencyError = ''; 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) { function updateEfficiencyCard(slot: number, cardId: string) {
const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id); const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id);
const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot
@@ -1224,7 +1220,7 @@
<select value={selectedId} on:change={(event) => updateEfficiencyCard(slot, event.currentTarget.value)}> <select value={selectedId} on:change={(event) => updateEfficiencyCard(slot, event.currentTarget.value)}>
<option value="">선택 안 함</option> <option value="">선택 안 함</option>
{#each fiveStarCards as card} {#each fiveStarCards as card}
<option value={card.id} disabled={efficiencyMemberSelectedInAnotherSlot(card, slot)}>{userData.collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option> <option value={card.id} disabled={efficiencyFormation.cardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId)}>{userData.collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each} {/each}
</select> </select>
</label> </label>