feat: add combination search workflow
This commit is contained in:
@@ -0,0 +1,217 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
|
||||||
|
|
||||||
|
type DisplayEffectiveWindow = SkillWindow & {
|
||||||
|
baseScoreUpPercent: number;
|
||||||
|
minScoreUpPercent: number;
|
||||||
|
maxScoreUpPercent: number;
|
||||||
|
maxOverlapCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export let result: ExactOptimizationResult;
|
||||||
|
export let durationSeconds: number;
|
||||||
|
export let cardName: (cardId: string) => string;
|
||||||
|
export let instanceId = 'result';
|
||||||
|
export let baseline: ExactOptimizationResult | null = null;
|
||||||
|
export let showScoreSummary = true;
|
||||||
|
|
||||||
|
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
|
||||||
|
|
||||||
|
$: noteCountsBySecond = countNotesBySecond(result, durationSeconds);
|
||||||
|
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
|
||||||
|
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result.effectiveActiveWindows ?? []);
|
||||||
|
$: timelineTicks = buildTimelineTicks(durationSeconds);
|
||||||
|
$: timelineWidth = Math.max(720, durationSeconds * 6);
|
||||||
|
|
||||||
|
function countNotesBySecond(scoreResult: ExactOptimizationResult, duration: number): number[] {
|
||||||
|
const counts = Array.from({ length: Math.ceil(duration) }, () => 0);
|
||||||
|
for (const note of scoreResult.notes) {
|
||||||
|
const second = Math.floor(note.timeSeconds);
|
||||||
|
if (second >= 0 && second < counts.length) counts[second] += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineTicks(duration: number): number[] {
|
||||||
|
if (duration <= 0) return [];
|
||||||
|
const ticks = Array.from({ length: Math.floor(duration / 10) + 1 }, (_, index) => index * 10);
|
||||||
|
if (ticks.at(-1) !== duration) ticks.push(duration);
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelinePosition(seconds: number): number {
|
||||||
|
return durationSeconds > 0 ? Math.max(0, Math.min(100, seconds / durationSeconds * 100)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
|
||||||
|
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineTrackStyle(): string {
|
||||||
|
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberColor(cardId: string): string {
|
||||||
|
const index = result.order.indexOf(cardId);
|
||||||
|
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function coloredWindowStyle(window: SkillWindow): string {
|
||||||
|
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
|
||||||
|
const merged: DisplayEffectiveWindow[] = [];
|
||||||
|
for (const window of windows) {
|
||||||
|
const previous = merged.at(-1);
|
||||||
|
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
|
||||||
|
previous.endSeconds = window.endSeconds;
|
||||||
|
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
|
||||||
|
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
|
||||||
|
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
merged.push({
|
||||||
|
...window,
|
||||||
|
minScoreUpPercent: window.scoreUpPercent,
|
||||||
|
maxScoreUpPercent: window.scoreUpPercent,
|
||||||
|
maxOverlapCount: window.overlapCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
|
||||||
|
const minimum = Number(window.minScoreUpPercent.toFixed(1));
|
||||||
|
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
|
||||||
|
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(seconds: number): string {
|
||||||
|
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delta(current: number, reference: number): string {
|
||||||
|
const difference = current - reference;
|
||||||
|
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ol class="order-list">
|
||||||
|
{#each result.order as cardId, slot}
|
||||||
|
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{#if showScoreSummary}
|
||||||
|
<div class="score-grid">
|
||||||
|
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
|
||||||
|
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}</small>{/if}</div>
|
||||||
|
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.baselineScore, baseline.baselineScore)}</small>{/if}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
|
||||||
|
<div class="timeline-heading">
|
||||||
|
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
|
||||||
|
<div class="timeline-legend" aria-label="멤버 색상 범례">
|
||||||
|
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{cardName(cardId)}</span>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
|
||||||
|
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
|
||||||
|
<div class="timeline-row note-row">
|
||||||
|
<strong class="lane-label">초당 노트</strong>
|
||||||
|
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
|
||||||
|
{#each noteCountsBySecond as count, second}
|
||||||
|
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#each result.order as cardId, slot}
|
||||||
|
<div class="timeline-row member-row">
|
||||||
|
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{cardName(cardId)}</strong>
|
||||||
|
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||||
|
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||||
|
<span class="skill-window active-window" style={coloredWindowStyle(window)} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="timeline-row summary-row">
|
||||||
|
<strong class="lane-label summary-label">최종 액티브</strong>
|
||||||
|
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||||
|
{#each displayedEffectiveActiveWindows as window}
|
||||||
|
<span class="skill-window effective-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-row summary-row">
|
||||||
|
<strong class="lane-label summary-label">스페셜</strong>
|
||||||
|
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||||
|
{#each result.specialWindows as window}
|
||||||
|
<span class="skill-window special-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`${cardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-row axis-row" aria-hidden="true">
|
||||||
|
<span class="lane-label axis-label"></span>
|
||||||
|
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
|
||||||
|
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
|
||||||
|
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
|
||||||
|
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
||||||
|
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
|
||||||
|
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
|
||||||
|
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||||
|
.score-summary span { color: #a9b1be; font-size: 12px; }
|
||||||
|
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
||||||
|
.score-summary small { color: #90d8ae; font-size: 10px; font-weight: 800; }
|
||||||
|
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
|
||||||
|
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
||||||
|
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
|
||||||
|
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
|
||||||
|
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
|
||||||
|
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||||
|
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
||||||
|
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
|
||||||
|
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
|
||||||
|
.timeline-row { display: flex; min-height: 36px; }
|
||||||
|
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
|
||||||
|
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
|
||||||
|
.timeline-track { position: relative; flex: none; }
|
||||||
|
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||||
|
.note-row { min-height: 68px; }
|
||||||
|
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
|
||||||
|
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
|
||||||
|
.skill-track { height: 36px; }
|
||||||
|
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||||
|
.active-window { top: 12px; height: 12px; opacity: .72; }
|
||||||
|
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||||
|
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||||
|
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||||
|
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
|
||||||
|
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
|
||||||
|
.time-axis { height: 24px; }
|
||||||
|
.time-axis::before { display: none; }
|
||||||
|
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
|
||||||
|
.time-axis span:first-child { transform: none; }
|
||||||
|
.time-axis span:last-child { transform: translateX(-100%); }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||||
|
.reduction { grid-column: 2; justify-self: start; }
|
||||||
|
.score-grid { grid-template-columns: 1fr; }
|
||||||
|
.timeline-heading { align-items: flex-start; flex-direction: column; }
|
||||||
|
.timeline-legend { justify-content: flex-start; }
|
||||||
|
.lane-label { flex-basis: 118px; min-width: 118px; }
|
||||||
|
.timeline-canvas { padding-right: 8px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,7 @@ const summary: BoardSummarySource = {
|
|||||||
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
|
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
|
||||||
} }],
|
} }],
|
||||||
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
|
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
|
||||||
|
'G-2': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 50, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0', trigger: 'always' } }],
|
||||||
'Y-1': [{ characterIds: [], effect: {
|
'Y-1': [{ characterIds: [], effect: {
|
||||||
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
|
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
|
||||||
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
|
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
|
||||||
@@ -63,3 +64,26 @@ test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () =
|
|||||||
|
|
||||||
assert.equal(result.flatByCardId['card-a'].performance, 300);
|
assert.equal(result.flatByCardId['card-a'].performance, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('조합 탐색용 기수 보너스와 악곡 보너스는 실제 노드 합계를 대체한다', () => {
|
||||||
|
const result = calculateBoardEffects(summary, { account: ['G-2', 'Y-1'] }, cards, 'a', {
|
||||||
|
singerType: 'solo', singerCharacterIds: ['a']
|
||||||
|
}, null, {
|
||||||
|
generationBonusByCardId: { 'card-a': 900 },
|
||||||
|
directSongScoreBonusPercent: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.flatByCardId['card-a'].performance, 900);
|
||||||
|
assert.equal(result.flatByCardId['card-a'].technique, 900);
|
||||||
|
assert.equal(result.directSongScoreBonusPercent, 10);
|
||||||
|
|
||||||
|
const zeroOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
|
||||||
|
generationBonusByCardId: { 'card-a': 0 }
|
||||||
|
});
|
||||||
|
const customOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
|
||||||
|
generationBonusByCardId: { 'card-a': 450 }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(zeroOverride.flatByCardId['card-a'].performance, 0);
|
||||||
|
assert.equal(customOverride.flatByCardId['card-a'].performance, 450);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export type BoardConnections = {
|
|||||||
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||||
cards: readonly BoardConnectionCard[];
|
cards: readonly BoardConnectionCard[];
|
||||||
};
|
};
|
||||||
|
export type BoardEffectOverrides = {
|
||||||
|
generationBonusByCardId?: Readonly<Record<string, number>>;
|
||||||
|
directSongScoreBonusPercent?: number;
|
||||||
|
};
|
||||||
export type BoardEffectsResult = {
|
export type BoardEffectsResult = {
|
||||||
flatByCardId: Record<string, Record<UnitStat, number>>;
|
flatByCardId: Record<string, Record<UnitStat, number>>;
|
||||||
rateByCardId: Record<string, Record<UnitStat, number>>;
|
rateByCardId: Record<string, Record<UnitStat, number>>;
|
||||||
@@ -126,7 +130,8 @@ export function calculateBoardEffects(
|
|||||||
cards: readonly BoardEffectCard[],
|
cards: readonly BoardEffectCard[],
|
||||||
leaderCharacterId: string,
|
leaderCharacterId: string,
|
||||||
song?: BoardEffectSong | null,
|
song?: BoardEffectSong | null,
|
||||||
connections?: BoardConnections | null
|
connections?: BoardConnections | null,
|
||||||
|
overrides?: BoardEffectOverrides | null
|
||||||
): BoardEffectsResult {
|
): BoardEffectsResult {
|
||||||
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
||||||
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
||||||
@@ -167,12 +172,20 @@ export function calculateBoardEffects(
|
|||||||
|
|
||||||
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
|
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
|
||||||
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
|
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
|
||||||
for (const card of targets(effect, ownerId, cards)) {
|
const effectTargets = targets(effect, ownerId, cards).filter((card) =>
|
||||||
|
effect.type !== 'all_parameter_up_for_character_grouping' || overrides?.generationBonusByCardId?.[card.id] === undefined
|
||||||
|
);
|
||||||
|
for (const card of effectTargets) {
|
||||||
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
|
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
|
||||||
|
if (!flatByCardId[cardId]) continue;
|
||||||
|
for (const stat of stats) flatByCardId[cardId][stat] += value;
|
||||||
|
}
|
||||||
|
|
||||||
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
|
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
|
||||||
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
|
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
|
||||||
card.id,
|
card.id,
|
||||||
@@ -184,6 +197,6 @@ export function calculateBoardEffects(
|
|||||||
activationRateUpByCardId,
|
activationRateUpByCardId,
|
||||||
allowedCooldownReductionsByCardId,
|
allowedCooldownReductionsByCardId,
|
||||||
scoreSupportPercent,
|
scoreSupportPercent,
|
||||||
directSongScoreBonusPercent: Math.min(10, directSongScoreBonusPercent)
|
directSongScoreBonusPercent: overrides?.directSongScoreBonusPercent ?? Math.min(10, directSongScoreBonusPercent)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { generateCombinationCandidates } from './index.ts';
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ id: 'a-1', characterId: 'a', score: 10 },
|
||||||
|
{ id: 'a-2', characterId: 'a', score: 20 },
|
||||||
|
{ id: 'b-1', characterId: 'b', score: 30 },
|
||||||
|
{ id: 'c-1', characterId: 'c', score: 40 }
|
||||||
|
];
|
||||||
|
|
||||||
|
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
|
||||||
|
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
|
||||||
|
assert.equal(result.length, 1);
|
||||||
|
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1', 'c-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
|
||||||
|
const result = generateCombinationCandidates([], cards, 2, (card) => card.score, 2);
|
||||||
|
assert.equal(result.length, 2);
|
||||||
|
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type CombinationSearchCard = {
|
||||||
|
id: string;
|
||||||
|
characterId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BeamState<T extends CombinationSearchCard> = {
|
||||||
|
cards: T[];
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||||
|
fixedCards: readonly T[],
|
||||||
|
candidateCards: readonly T[],
|
||||||
|
targetSize: number,
|
||||||
|
scoreCard: (card: T) => number,
|
||||||
|
beamWidth = 800
|
||||||
|
): T[][] {
|
||||||
|
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
||||||
|
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
||||||
|
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
|
||||||
|
}
|
||||||
|
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||||
|
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||||
|
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
||||||
|
let beam: BeamState<T>[] = [{
|
||||||
|
cards: Array.from(fixedCards),
|
||||||
|
score: fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
|
||||||
|
}];
|
||||||
|
|
||||||
|
for (let size = fixedCards.length; size < targetSize; size += 1) {
|
||||||
|
const expanded = new Map<string, BeamState<T>>();
|
||||||
|
for (const state of beam) {
|
||||||
|
const usedCharacters = new Set(state.cards.map((card) => card.characterId));
|
||||||
|
const usedIds = new Set(state.cards.map((card) => card.id));
|
||||||
|
for (const card of pool) {
|
||||||
|
if (usedIds.has(card.id)) continue;
|
||||||
|
if (usedCharacters.has(card.characterId)) continue;
|
||||||
|
const nextState = {
|
||||||
|
cards: [...state.cards, card],
|
||||||
|
score: state.score + scoreCard(card)
|
||||||
|
};
|
||||||
|
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
|
||||||
|
if (!expanded.has(key)) expanded.set(key, nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beam = Array.from(expanded.values())
|
||||||
|
.sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join()))
|
||||||
|
.slice(0, Math.max(1, beamWidth));
|
||||||
|
if (beam.length === 0) break;
|
||||||
|
}
|
||||||
|
return beam
|
||||||
|
.filter((state) => state.cards.length === targetSize)
|
||||||
|
.sort((left, right) => right.score - left.score)
|
||||||
|
.map((state) => state.cards);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './card-adapter.ts';
|
export * from './card-adapter.ts';
|
||||||
|
export * from './combination-search.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';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import { optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||||
import type { ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
||||||
|
|
||||||
type OptimizeRequest = {
|
type OptimizeRequest = {
|
||||||
type: 'optimize-exact';
|
type: 'optimize-exact';
|
||||||
@@ -11,10 +11,23 @@ type OptimizeRequest = {
|
|||||||
directSongScoreBonusPercent?: number;
|
directSongScoreBonusPercent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
|
type CombinationCandidate = {
|
||||||
if (event.data.type !== 'optimize-exact') return;
|
cards: OptimizerCard[];
|
||||||
|
overallPower: number;
|
||||||
|
perfectNoteScore: number;
|
||||||
|
directSongScoreBonusPercent?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CombinationRequest = {
|
||||||
|
type: 'optimize-combinations';
|
||||||
|
candidates: CombinationCandidate[];
|
||||||
|
chart: ExactSongChart;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (event.data.type === 'optimize-exact') {
|
||||||
const options: ExactOptimizeOptions = {
|
const options: ExactOptimizeOptions = {
|
||||||
perfectNoteScore: event.data.perfectNoteScore,
|
perfectNoteScore: event.data.perfectNoteScore,
|
||||||
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
|
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
|
||||||
@@ -22,6 +35,33 @@ self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
|
|||||||
};
|
};
|
||||||
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
|
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
|
||||||
self.postMessage({ type: 'result', result });
|
self.postMessage({ type: 'result', result });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
|
||||||
|
let completedCandidates = 0;
|
||||||
|
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
|
||||||
|
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
|
||||||
|
for (const candidate of event.data.candidates) {
|
||||||
|
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
||||||
|
perfectNoteScore: candidate.perfectNoteScore,
|
||||||
|
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
|
||||||
|
onProgress: (progress) => self.postMessage({
|
||||||
|
type: 'progress',
|
||||||
|
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
completedCandidates += countOptimizationCandidates(candidate.cards);
|
||||||
|
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
|
||||||
|
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
|
||||||
|
best = { result, overallPower: candidate.overallPower };
|
||||||
|
}
|
||||||
|
rankings.push({ result, overallPower: candidate.overallPower });
|
||||||
|
}
|
||||||
|
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||||
|
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||||
|
right.result.expectedScore - left.result.expectedScore);
|
||||||
|
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({
|
self.postMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<nav aria-label="주요 메뉴">
|
<nav aria-label="주요 메뉴">
|
||||||
<a href="/" aria-current={$page.url.pathname === '/' ? 'page' : undefined}>카드 목록</a>
|
<a href="/" aria-current={$page.url.pathname === '/' ? 'page' : undefined}>카드 목록</a>
|
||||||
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
|
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
|
||||||
|
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
|
||||||
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -681,27 +681,27 @@
|
|||||||
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
||||||
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
|
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
|
||||||
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
|
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
|
||||||
.timeline-row { display: flex; min-height: 42px; }
|
.timeline-row { display: flex; min-height: 36px; }
|
||||||
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
|
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
|
||||||
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
|
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
|
||||||
.timeline-track { position: relative; flex: none; }
|
.timeline-track { position: relative; flex: none; }
|
||||||
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||||
.note-row { min-height: 78px; }
|
.note-row { min-height: 68px; }
|
||||||
.note-track { height: 62px; align-self: center; border-bottom: 1px solid #465062; }
|
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
|
||||||
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
|
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
|
||||||
.skill-track { height: 42px; }
|
.skill-track { height: 36px; }
|
||||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||||
.active-window { top: 14px; height: 13px; opacity: .72; }
|
.active-window { top: 12px; height: 12px; opacity: .72; }
|
||||||
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||||
.summary-label { color: #e1e5ec; font-weight: 800; }
|
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||||
.effective-window { top: 11px; height: 20px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||||
.special-window { top: 13px; height: 16px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
|
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
|
||||||
.axis-row { min-height: 28px; border-top: 1px solid #343c49 !important; }
|
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
|
||||||
.axis-label { color: #778293; font-size: 10px; font-weight: 750; }
|
.axis-label { color: #778293; font-size: 10px; font-weight: 750; }
|
||||||
.time-axis { height: 28px; }
|
.time-axis { height: 24px; }
|
||||||
.time-axis::before { display: none; }
|
.time-axis::before { display: none; }
|
||||||
.time-axis span { position: absolute; top: 7px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
|
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
|
||||||
.time-axis span:first-child { transform: none; }
|
.time-axis span:first-child { transform: none; }
|
||||||
.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; }
|
||||||
|
|||||||
@@ -0,0 +1,897 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
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 exactChartCatalog from '$lib/data/exact-chart-catalog.json';
|
||||||
|
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
|
||||||
|
import {
|
||||||
|
calculateBoardEffects,
|
||||||
|
calculateUnitPower,
|
||||||
|
countOptimizationCandidates,
|
||||||
|
generateCombinationCandidates,
|
||||||
|
memoryParameterPermil,
|
||||||
|
perfectNoteScoreFromPower,
|
||||||
|
toOptimizerCard
|
||||||
|
} from '$lib/engine/index.ts';
|
||||||
|
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
|
||||||
|
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||||
|
|
||||||
|
type CardRow = (typeof cardSummary.cards)[number];
|
||||||
|
type DifficultyKey = 'hard' | 'expert';
|
||||||
|
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
|
||||||
|
type MemberBoards = Record<string, string[]>;
|
||||||
|
type BoardCards = Record<string, Record<string, string>>;
|
||||||
|
type PreparedCandidate = {
|
||||||
|
cards: OptimizerCard[];
|
||||||
|
overallPower: number;
|
||||||
|
perfectNoteScore: number;
|
||||||
|
directSongScoreBonusPercent: number;
|
||||||
|
heuristic: number;
|
||||||
|
skillPotential: number;
|
||||||
|
};
|
||||||
|
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
|
||||||
|
type SavedState = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
songId: string;
|
||||||
|
difficulty: DifficultyKey;
|
||||||
|
fixedCardIds: string[];
|
||||||
|
awakening: number;
|
||||||
|
candidateGenerationBonus?: number | null;
|
||||||
|
fixedAwakenings?: number[];
|
||||||
|
fixedCooldownReductions?: (CooldownReduction | null)[];
|
||||||
|
fixedAllBlueNodes?: boolean[];
|
||||||
|
fixedMaxGenerationBonuses?: boolean[];
|
||||||
|
fixedGenerationBonusOverrides?: (number | null)[];
|
||||||
|
maximizeCompute?: boolean;
|
||||||
|
leaderCharacterId: string;
|
||||||
|
leaderOutfitCardId: string;
|
||||||
|
};
|
||||||
|
type SavedAnalysisState = {
|
||||||
|
schemaVersion: 2;
|
||||||
|
songId: string;
|
||||||
|
difficulty: DifficultyKey;
|
||||||
|
cardIds: string[];
|
||||||
|
awakenings: number[];
|
||||||
|
fixedCooldownReductions: (CooldownReduction | null)[];
|
||||||
|
leaderCharacterId: string;
|
||||||
|
leaderOutfitCardId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'holodori.combination.v1';
|
||||||
|
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
|
||||||
|
const SCREENING_LIMIT = 3_000;
|
||||||
|
const EXACT_FINALIST_LIMIT = 24;
|
||||||
|
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
|
||||||
|
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
|
||||||
|
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
|
||||||
|
const charts = exactChartCatalog.charts;
|
||||||
|
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) ?? []
|
||||||
|
]));
|
||||||
|
const songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
|
||||||
|
const leaderMembers = fiveStarCards.filter((card, index, cards) => cards.findIndex((entry) => entry.characterId === card.characterId) === index);
|
||||||
|
|
||||||
|
let collection: CollectionState = {};
|
||||||
|
let memberBoards: MemberBoards = {};
|
||||||
|
let boardCards: BoardCards = {};
|
||||||
|
let memoryCount = 0;
|
||||||
|
let memberPowerUpPercent = 0;
|
||||||
|
let selectedSongId = songs[0]?.id ?? '';
|
||||||
|
let difficulty: DifficultyKey = 'expert';
|
||||||
|
let fixedCardIds = ['', '', '', ''];
|
||||||
|
let candidateAwakening = 5;
|
||||||
|
let candidateGenerationBonus: number | null = null;
|
||||||
|
let fixedAwakenings = [5, 5, 5, 5];
|
||||||
|
let fixedCooldownReductions: (CooldownReduction | null)[] = [null, null, null, null];
|
||||||
|
let fixedAllBlueNodes = [false, false, false, false];
|
||||||
|
let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null];
|
||||||
|
let maximizeCompute = false;
|
||||||
|
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
|
||||||
|
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
let parallelWorkers: Worker[] = [];
|
||||||
|
let baselineWorker: Worker | null = null;
|
||||||
|
let running = false;
|
||||||
|
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||||
|
let result: ExactOptimizationResult | null = null;
|
||||||
|
let rankedResults: RankedResult[] = [];
|
||||||
|
let baselineState: SavedAnalysisState | null = null;
|
||||||
|
let baselineResult: ExactOptimizationResult | null = null;
|
||||||
|
let baselineOverallPower = 0;
|
||||||
|
let baselineRunning = false;
|
||||||
|
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
|
||||||
|
let baselineError = '';
|
||||||
|
let screenedCombinationCount = 0;
|
||||||
|
let exactCombinationCount = 0;
|
||||||
|
let searchPhase: 'idle' | 'screening' | 'exact' = 'idle';
|
||||||
|
let screeningProgress = 0;
|
||||||
|
let errorMessage = '';
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
|
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
|
||||||
|
$: baselineChartMetadata = baselineState
|
||||||
|
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
|
||||||
|
: undefined;
|
||||||
|
$: baselineMatchesConditions = Boolean(baselineState?.songId === selectedSongId && baselineState?.difficulty === difficulty);
|
||||||
|
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
|
||||||
|
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
|
||||||
|
$: if (!result && rankedResults.length > 0) rankedResults = [];
|
||||||
|
$: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||||
|
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
|
||||||
|
$: leaderOutfitOptions = leaderMember
|
||||||
|
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
|
||||||
|
.filter((card, index, cards) => cards.findIndex((entry) => entry.details.costume?.name === card.details.costume?.name) === index)
|
||||||
|
: [];
|
||||||
|
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
|
||||||
|
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
|
||||||
|
.replace(/\[[^\]]+\]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
$: connectionCards = cardSummary.cards.filter((card) => card.details.connect.length > 0).map((card) => ({
|
||||||
|
id: card.id,
|
||||||
|
awakening: collection[card.id]?.awakening ?? 0,
|
||||||
|
connect: card.details.connect
|
||||||
|
}));
|
||||||
|
$: if (ready) saveState({
|
||||||
|
schemaVersion: 1,
|
||||||
|
songId: selectedSongId,
|
||||||
|
difficulty,
|
||||||
|
fixedCardIds,
|
||||||
|
awakening: candidateAwakening,
|
||||||
|
candidateGenerationBonus,
|
||||||
|
fixedAwakenings,
|
||||||
|
fixedCooldownReductions,
|
||||||
|
fixedAllBlueNodes,
|
||||||
|
fixedGenerationBonusOverrides,
|
||||||
|
maximizeCompute,
|
||||||
|
leaderCharacterId,
|
||||||
|
leaderOutfitCardId
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
try {
|
||||||
|
const stored = JSON.parse(localStorage.getItem(USER_DATA_STORAGE_KEY) ?? 'null');
|
||||||
|
collection = stored?.collection && typeof stored.collection === 'object' ? stored.collection : {};
|
||||||
|
memberBoards = stored?.memberBoards && typeof stored.memberBoards === 'object' ? stored.memberBoards : {};
|
||||||
|
boardCards = stored?.boardCards && typeof stored.boardCards === 'object' ? stored.boardCards : {};
|
||||||
|
memoryCount = Number.isFinite(stored?.memoryCount) ? stored.memoryCount : 0;
|
||||||
|
memberPowerUpPercent = Number.isFinite(stored?.memberPowerUpPercent) ? stored.memberPowerUpPercent : 0;
|
||||||
|
} catch {
|
||||||
|
collection = {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const state = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? 'null') as SavedState | null;
|
||||||
|
if (state?.schemaVersion === 1) {
|
||||||
|
if (songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
|
||||||
|
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
|
||||||
|
fixedCardIds = Array.from({ length: 4 }, (_, index) => cardById.has(state.fixedCardIds?.[index]) ? state.fixedCardIds[index] : '');
|
||||||
|
candidateAwakening = normalizeAwakening(state.awakening);
|
||||||
|
candidateGenerationBonus = typeof state.candidateGenerationBonus === 'number' && Number.isFinite(state.candidateGenerationBonus)
|
||||||
|
? normalizeGenerationBonus(state.candidateGenerationBonus)
|
||||||
|
: null;
|
||||||
|
fixedAwakenings = Array.from({ length: 4 }, (_, index) => normalizeAwakening(state.fixedAwakenings?.[index] ?? state.awakening));
|
||||||
|
fixedCooldownReductions = Array.from({ length: 4 }, (_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
|
||||||
|
fixedAllBlueNodes = Array.from({ length: 4 }, (_, index) => state.fixedAllBlueNodes?.[index] === true);
|
||||||
|
fixedGenerationBonusOverrides = Array.from({ length: 4 }, (_, index) => {
|
||||||
|
const savedValue = state.fixedGenerationBonusOverrides?.[index];
|
||||||
|
if (typeof savedValue === 'number' && Number.isFinite(savedValue)) return normalizeGenerationBonus(savedValue);
|
||||||
|
return state.fixedMaxGenerationBonuses?.[index] === true ? 900 : null;
|
||||||
|
});
|
||||||
|
maximizeCompute = state.maximizeCompute === true;
|
||||||
|
if (leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) leaderCharacterId = state.leaderCharacterId;
|
||||||
|
const outfit = cardById.get(state.leaderOutfitCardId);
|
||||||
|
leaderOutfitCardId = outfit?.characterId === leaderCharacterId && outfit.details.costume
|
||||||
|
? outfit.id
|
||||||
|
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
worker.onmessage = (event) => {
|
||||||
|
if (event.data.type === 'progress') progress = event.data.progress;
|
||||||
|
if (event.data.type === 'combination-result') {
|
||||||
|
result = event.data.result;
|
||||||
|
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }];
|
||||||
|
running = false;
|
||||||
|
searchPhase = 'idle';
|
||||||
|
}
|
||||||
|
if (event.data.type === 'error') {
|
||||||
|
errorMessage = event.data.message;
|
||||||
|
running = false;
|
||||||
|
searchPhase = 'idle';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
baselineWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
baselineWorker.onmessage = (event) => {
|
||||||
|
if (event.data.type === 'progress') baselineProgress = event.data.progress;
|
||||||
|
if (event.data.type === 'result') {
|
||||||
|
baselineResult = event.data.result;
|
||||||
|
baselineRunning = false;
|
||||||
|
}
|
||||||
|
if (event.data.type === 'error') {
|
||||||
|
baselineError = event.data.message;
|
||||||
|
baselineRunning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
|
||||||
|
} catch {
|
||||||
|
baselineState = null;
|
||||||
|
baselineError = '배치 분석의 저장 데이터를 읽지 못했습니다.';
|
||||||
|
}
|
||||||
|
ready = true;
|
||||||
|
return () => {
|
||||||
|
worker?.terminate();
|
||||||
|
terminateParallelWorkers();
|
||||||
|
baselineWorker?.terminate();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeAwakening(value: unknown): number {
|
||||||
|
return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
|
||||||
|
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGenerationBonus(value: unknown): number {
|
||||||
|
return Math.max(0, Math.min(900, Number.isFinite(value) ? Math.round(Number(value)) : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveState(state: SavedState): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAnalysisState(stored: string | null): SavedAnalysisState | null {
|
||||||
|
if (!stored) return null;
|
||||||
|
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
|
||||||
|
if (state.schemaVersion !== 2 || !Array.isArray(state.cardIds) || state.cardIds.length !== 5) return null;
|
||||||
|
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||||
|
if (cards.length !== 5 || new Set(cards.map((card) => card.characterId)).size !== 5) return null;
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
songId: typeof state.songId === 'string' ? state.songId : '',
|
||||||
|
difficulty: state.difficulty === 'hard' ? 'hard' : 'expert',
|
||||||
|
cardIds: cards.map((card) => card.id),
|
||||||
|
awakenings: cards.map((_, index) => Number.isFinite(state.awakenings?.[index]) ? normalizeAwakening(state.awakenings?.[index]) : 0),
|
||||||
|
fixedCooldownReductions: cards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index])),
|
||||||
|
leaderCharacterId: typeof state.leaderCharacterId === 'string' ? state.leaderCharacterId : '',
|
||||||
|
leaderOutfitCardId: typeof state.leaderOutfitCardId === 'string' ? state.leaderOutfitCardId : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardLabel(card: CardRow): string {
|
||||||
|
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 {
|
||||||
|
const cardChanged = fixedCardIds[slot] !== cardId;
|
||||||
|
fixedCardIds = fixedCardIds.map((id, index) => index === slot ? cardId : id);
|
||||||
|
if (cardChanged) {
|
||||||
|
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? null : reduction);
|
||||||
|
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? null : bonus);
|
||||||
|
}
|
||||||
|
if (cardId && collection[cardId]?.owned) {
|
||||||
|
fixedAwakenings = fixedAwakenings.map((value, index) => index === slot ? normalizeAwakening(collection[cardId]?.awakening) : value);
|
||||||
|
}
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFixedAwakening(slot: number, value: number): void {
|
||||||
|
fixedAwakenings = fixedAwakenings.map((awakening, index) => index === slot ? normalizeAwakening(value) : awakening);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCandidateAwakening(value: number): void {
|
||||||
|
candidateAwakening = normalizeAwakening(value);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFixedReduction(slot: number, value: CooldownReduction | null): void {
|
||||||
|
fixedCooldownReductions = fixedCooldownReductions.map((reduction, index) => index === slot ? value : reduction);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFixedAllBlueNodes(slot: number): void {
|
||||||
|
fixedAllBlueNodes = fixedAllBlueNodes.map((enabled, index) => index === slot ? !enabled : enabled);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFixedGenerationBonus(slot: number, rawValue: string): void {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
const value = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
|
||||||
|
fixedGenerationBonusOverrides = fixedGenerationBonusOverrides.map((bonus, index) => index === slot ? value : bonus);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCandidateGenerationBonus(rawValue: string): void {
|
||||||
|
const parsed = Number(rawValue);
|
||||||
|
candidateGenerationBonus = rawValue.trim() === '' || !Number.isFinite(parsed) ? null : normalizeGenerationBonus(parsed);
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedSlot(cardId: string): number {
|
||||||
|
return fixedCardIds.indexOf(cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function awakeningForCard(card: CardRow): number {
|
||||||
|
const slot = fixedSlot(card.id);
|
||||||
|
return slot >= 0 ? fixedAwakenings[slot] : candidateAwakening;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLeader(characterId: string): void {
|
||||||
|
leaderCharacterId = characterId;
|
||||||
|
leaderOutfitCardId = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume)?.id ?? '';
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roughCardScore(card: CardRow): number {
|
||||||
|
const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
|
||||||
|
const { activeValue, specialValue } = roughSkillValues(card);
|
||||||
|
return statTotal * (1 + activeValue / 100 + specialValue / 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roughStatScore(card: CardRow): number {
|
||||||
|
return Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roughSkillScore(card: CardRow): number {
|
||||||
|
const { adapted, activeValue, specialValue } = roughSkillValues(card);
|
||||||
|
return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roughSkillValues(card: CardRow) {
|
||||||
|
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
|
||||||
|
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
|
||||||
|
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
|
||||||
|
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
|
||||||
|
return { adapted, activeValue, specialValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string {
|
||||||
|
return cards.map((card) => card.id).sort().join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardsForCombination(cards: readonly CardRow[]): MemberBoards {
|
||||||
|
const boards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, Array.from(groupIds)]));
|
||||||
|
for (const card of cards) {
|
||||||
|
const slot = fixedSlot(card.id);
|
||||||
|
if (slot >= 0 && !fixedAllBlueNodes[slot]) continue;
|
||||||
|
const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B'));
|
||||||
|
boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])];
|
||||||
|
}
|
||||||
|
return boards;
|
||||||
|
}
|
||||||
|
|
||||||
|
function screeningCombinations(): CardRow[][] {
|
||||||
|
if (fixedCards.length === 4) {
|
||||||
|
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
|
||||||
|
}
|
||||||
|
const perStrategy = Math.ceil(SCREENING_LIMIT / 3);
|
||||||
|
const combined = new Map<string, CardRow[]>();
|
||||||
|
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
|
||||||
|
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
|
||||||
|
combined.set(combinationKey(cards), cards);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
|
||||||
|
if (fixedCards.length === 4) return prepared;
|
||||||
|
const selected = new Map<string, PreparedCandidate>();
|
||||||
|
const add = (candidates: PreparedCandidate[], count: number) => {
|
||||||
|
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
|
||||||
|
};
|
||||||
|
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 12);
|
||||||
|
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 6);
|
||||||
|
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 6);
|
||||||
|
if (selected.size < EXACT_FINALIST_LIMIT) {
|
||||||
|
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
|
||||||
|
selected.set(combinationKey(candidate.cards), candidate);
|
||||||
|
if (selected.size >= EXACT_FINALIST_LIMIT) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(selected.values()).slice(0, EXACT_FINALIST_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||||
|
const generationBonusByCardId = Object.fromEntries(cards
|
||||||
|
.filter((card) => {
|
||||||
|
const slot = fixedSlot(card.id);
|
||||||
|
return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null;
|
||||||
|
})
|
||||||
|
.map((card) => {
|
||||||
|
const slot = fixedSlot(card.id);
|
||||||
|
return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0];
|
||||||
|
}));
|
||||||
|
const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, {
|
||||||
|
placements: boardCards,
|
||||||
|
cards: connectionCards
|
||||||
|
}, {
|
||||||
|
generationBonusByCardId,
|
||||||
|
directSongScoreBonusPercent: 10
|
||||||
|
});
|
||||||
|
const power = calculateUnitPower(
|
||||||
|
cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
|
||||||
|
leaderOutfit,
|
||||||
|
boardEffects,
|
||||||
|
memoryParameterPermil(memoryCount, memorySummary.effects),
|
||||||
|
memberPowerUpPercent
|
||||||
|
);
|
||||||
|
const optimizerCards = cards.map((card) => {
|
||||||
|
const slot = fixedSlot(card.id);
|
||||||
|
const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null;
|
||||||
|
const adapted = toOptimizerCard(
|
||||||
|
card,
|
||||||
|
awakeningForCard(card),
|
||||||
|
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
|
||||||
|
cards
|
||||||
|
);
|
||||||
|
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
|
||||||
|
return {
|
||||||
|
...adapted,
|
||||||
|
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
|
||||||
|
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const skillPotential = optimizerCards.reduce((sum, card) => sum +
|
||||||
|
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * ((card.active.probabilityPercent ?? 100) / 100) +
|
||||||
|
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
|
||||||
|
(card.scoreSupportPercent ?? 0) +
|
||||||
|
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
|
||||||
|
return {
|
||||||
|
cards: optimizerCards,
|
||||||
|
overallPower: power.overallPower,
|
||||||
|
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null),
|
||||||
|
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
|
||||||
|
heuristic: power.overallPower * (1 + skillPotential / 500),
|
||||||
|
skillPotential
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeBaseline(): Promise<void> {
|
||||||
|
const state = baselineState;
|
||||||
|
const chartMetadata = baselineChartMetadata;
|
||||||
|
if (!state || !chartMetadata || !baselineWorker || !baselineMatchesConditions) return;
|
||||||
|
baselineRunning = true;
|
||||||
|
baselineError = '';
|
||||||
|
baselineProgress = { evaluatedCandidates: 0, totalCandidates: 1 };
|
||||||
|
try {
|
||||||
|
const cards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
|
||||||
|
if (cards.length !== 5) throw new Error('기준 조합의 카드 정보를 찾지 못했습니다.');
|
||||||
|
const outfit = cardById.get(state.leaderOutfitCardId)?.details.costume ?? null;
|
||||||
|
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, cards, state.leaderCharacterId, chartMetadata, {
|
||||||
|
placements: boardCards,
|
||||||
|
cards: connectionCards
|
||||||
|
});
|
||||||
|
const power = calculateUnitPower(
|
||||||
|
cards.map((card, index) => ({ card, awakening: state.awakenings[index] })),
|
||||||
|
outfit,
|
||||||
|
boardEffects,
|
||||||
|
memoryParameterPermil(memoryCount, memorySummary.effects),
|
||||||
|
memberPowerUpPercent
|
||||||
|
);
|
||||||
|
const optimizerCards = cards.map((card, index) => {
|
||||||
|
const fixedReduction = state.fixedCooldownReductions[index];
|
||||||
|
const adapted = toOptimizerCard(
|
||||||
|
card,
|
||||||
|
state.awakenings[index],
|
||||||
|
fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction],
|
||||||
|
cards
|
||||||
|
);
|
||||||
|
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
|
||||||
|
return {
|
||||||
|
...adapted,
|
||||||
|
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
|
||||||
|
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const response = await fetch(chartMetadata.path);
|
||||||
|
if (!response.ok) throw new Error('기준 조합의 정확 채보 데이터를 불러오지 못했습니다.');
|
||||||
|
const chart = await response.json() as ExactSongChart;
|
||||||
|
baselineOverallPower = power.overallPower;
|
||||||
|
baselineProgress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(optimizerCards) };
|
||||||
|
baselineWorker.postMessage({
|
||||||
|
type: 'optimize-exact',
|
||||||
|
cards: optimizerCards,
|
||||||
|
chart,
|
||||||
|
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
|
||||||
|
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
|
||||||
|
baselineRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDelta(current: number, baseline: number): string {
|
||||||
|
const difference = current - baseline;
|
||||||
|
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateParallelWorkers(): void {
|
||||||
|
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
|
||||||
|
parallelWorkers = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
|
||||||
|
terminateParallelWorkers();
|
||||||
|
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
|
||||||
|
const workerCount = Math.min(finalists.length, Math.max(1, reportedCores));
|
||||||
|
const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]);
|
||||||
|
finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate));
|
||||||
|
|
||||||
|
const groupProgress = Array.from({ length: workerCount }, () => 0);
|
||||||
|
const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
|
||||||
|
const completedRankings: RankedResult[] = [];
|
||||||
|
let completedWorkers = 0;
|
||||||
|
let failed = false;
|
||||||
|
progress = { evaluatedCandidates: 0, totalCandidates };
|
||||||
|
|
||||||
|
const fail = (message: string) => {
|
||||||
|
if (failed) return;
|
||||||
|
failed = true;
|
||||||
|
terminateParallelWorkers();
|
||||||
|
errorMessage = message;
|
||||||
|
running = false;
|
||||||
|
searchPhase = 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
|
groups.forEach((candidates, workerIndex) => {
|
||||||
|
const parallelWorker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), { type: 'module' });
|
||||||
|
parallelWorkers.push(parallelWorker);
|
||||||
|
parallelWorker.onmessage = (event) => {
|
||||||
|
if (failed) return;
|
||||||
|
if (event.data.type === 'progress') {
|
||||||
|
groupProgress[workerIndex] = event.data.progress.evaluatedCandidates;
|
||||||
|
progress = {
|
||||||
|
evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0),
|
||||||
|
totalCandidates
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.data.type === 'combination-result') {
|
||||||
|
const rankings = Array.isArray(event.data.rankings)
|
||||||
|
? event.data.rankings as RankedResult[]
|
||||||
|
: [{ result: event.data.result, overallPower: event.data.overallPower }];
|
||||||
|
completedRankings.push(...rankings);
|
||||||
|
completedWorkers += 1;
|
||||||
|
parallelWorker.terminate();
|
||||||
|
if (completedWorkers !== workerCount) return;
|
||||||
|
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||||
|
right.result.expectedScore - left.result.expectedScore);
|
||||||
|
rankedResults = completedRankings.slice(0, 10);
|
||||||
|
result = rankedResults[0]?.result ?? null;
|
||||||
|
parallelWorkers = [];
|
||||||
|
running = false;
|
||||||
|
searchPhase = 'idle';
|
||||||
|
}
|
||||||
|
if (event.data.type === 'error') fail(event.data.message);
|
||||||
|
};
|
||||||
|
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
|
||||||
|
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(): Promise<void> {
|
||||||
|
errorMessage = '';
|
||||||
|
result = null;
|
||||||
|
if (!worker || !selectedChart) return;
|
||||||
|
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
||||||
|
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
searchPhase = 'screening';
|
||||||
|
screeningProgress = 0;
|
||||||
|
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
try {
|
||||||
|
const combinations = screeningCombinations();
|
||||||
|
screenedCombinationCount = combinations.length;
|
||||||
|
const prepared: PreparedCandidate[] = [];
|
||||||
|
for (let index = 0; index < combinations.length; index += 1) {
|
||||||
|
prepared.push(prepareCandidate(combinations[index]));
|
||||||
|
if (index % 50 === 49 || index === combinations.length - 1) {
|
||||||
|
screeningProgress = (index + 1) / combinations.length;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finalists = selectExactFinalists(prepared);
|
||||||
|
exactCombinationCount = finalists.length;
|
||||||
|
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
|
||||||
|
const response = await fetch(selectedChart.path);
|
||||||
|
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
|
||||||
|
const chart = await response.json() as ExactSongChart;
|
||||||
|
searchPhase = 'exact';
|
||||||
|
if (maximizeCompute) runParallelExact(finalists, chart);
|
||||||
|
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.';
|
||||||
|
running = false;
|
||||||
|
searchPhase = 'idle';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baselineCardLabel(cardId: string): string {
|
||||||
|
const card = cardById.get(cardId);
|
||||||
|
const index = baselineState?.cardIds.indexOf(cardId) ?? -1;
|
||||||
|
const awakening = index >= 0 ? baselineState?.awakenings[index] ?? 0 : 0;
|
||||||
|
return card ? `${card.name}${card.versionName ? ` · ${card.versionName}` : ''} · ${awakening}개화` : cardId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultCardName(cardId: string): string {
|
||||||
|
const card = cardById.get(cardId);
|
||||||
|
return card ? cardLabel(card) : cardId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>조합 탐색 · 홀로도리</title></svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header><h1>조합 탐색</h1></header>
|
||||||
|
{#if baselineState && baselineMatchesConditions}
|
||||||
|
<section class="panel baseline-panel" aria-labelledby="baseline-title">
|
||||||
|
<div class="section-title">
|
||||||
|
<div><span>00</span><h2 id="baseline-title">기준 조합</h2></div>
|
||||||
|
<strong>배치 분석 저장값</strong>
|
||||||
|
</div>
|
||||||
|
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
|
||||||
|
<div class="baseline-members">
|
||||||
|
{#each baselineResult?.order ?? baselineState.cardIds as cardId, slot}
|
||||||
|
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if baselineResult}
|
||||||
|
<div class="scores baseline-scores">
|
||||||
|
<div><span>종합력</span><strong>{baselineOverallPower.toLocaleString('ko-KR')}</strong></div>
|
||||||
|
<div><span>평균 점수</span><strong>{baselineResult.expectedScore.toLocaleString('ko-KR')}</strong></div>
|
||||||
|
<div><span>최고 점수</span><strong>{baselineResult.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
|
||||||
|
</div>
|
||||||
|
{:else if baselineRunning}
|
||||||
|
<p class="baseline-status">배치 분석의 저장 조건으로 기준 점수를 계산하고 있습니다.</p>
|
||||||
|
<progress value={baselineProgress.evaluatedCandidates} max={Math.max(1, baselineProgress.totalCandidates)}></progress>
|
||||||
|
{:else if baselineError}
|
||||||
|
<p class="error" role="alert">{baselineError}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<section class="panel" aria-labelledby="conditions-title">
|
||||||
|
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>5성 전체 대상</strong></div>
|
||||||
|
<div class="song-fields">
|
||||||
|
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
|
||||||
|
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="candidate-fields">
|
||||||
|
<div class="candidate-awakening">
|
||||||
|
<span>자동 후보 개화</span>
|
||||||
|
<div class="awakening-stepper">
|
||||||
|
<button type="button" disabled={candidateAwakening <= 0} aria-label="자동 후보 개화 낮추기" on:click={() => updateCandidateAwakening(candidateAwakening - 1)}>‹</button>
|
||||||
|
<strong>{candidateAwakening}</strong>
|
||||||
|
<button type="button" disabled={candidateAwakening >= 5} aria-label="자동 후보 개화 높이기" on:click={() => updateCandidateAwakening(candidateAwakening + 1)}>›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label>자동 후보 기수 보너스 덮어쓰기<input class="number-input" type="text" inputmode="numeric" pattern="[0-9]*" value={candidateGenerationBonus ?? ''} placeholder="미사용" on:change={(event) => updateCandidateGenerationBonus(event.currentTarget.value)}></label>
|
||||||
|
</div>
|
||||||
|
<p class="candidate-guide">기수 보너스는 녹색 노드를 활성화해 얻을 수 있는 보너스를 의미합니다.</p>
|
||||||
|
<div class="fixed-fields">
|
||||||
|
<div class="fixed-column-headings">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
<div><span>파란 노드 전체</span><span class="generation-heading">기수 보너스<br>덮어쓰기</span></div>
|
||||||
|
</div>
|
||||||
|
{#each fixedCardIds as selectedId, slot}
|
||||||
|
<div class="fixed-field">
|
||||||
|
<label>{slot + 1}번 고정
|
||||||
|
<select value={selectedId} on:change={(event) => updateFixedCard(slot, event.currentTarget.value)}>
|
||||||
|
<option value="">고정하지 않음</option>
|
||||||
|
{#each fiveStarCards as card}<option value={card.id} disabled={selectedInAnotherSlot(card, slot)}>{cardLabel(card)}</option>{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="fixed-awakening">
|
||||||
|
<span>개화</span>
|
||||||
|
<div class="awakening-stepper">
|
||||||
|
<button type="button" disabled={!selectedId || fixedAwakenings[slot] <= 0} aria-label={`${slot + 1}번 고정 카드 개화 낮추기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] - 1)}>‹</button>
|
||||||
|
<strong>{fixedAwakenings[slot]}</strong>
|
||||||
|
<button type="button" disabled={!selectedId || fixedAwakenings[slot] >= 5} aria-label={`${slot + 1}번 고정 카드 개화 높이기`} on:click={() => updateFixedAwakening(slot, fixedAwakenings[slot] + 1)}>›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fixed-reduction">
|
||||||
|
<span>빈도 감소 고정</span>
|
||||||
|
<div class="reduction-options">
|
||||||
|
{#each REDUCTION_LOCK_OPTIONS as reduction}
|
||||||
|
<button type="button" disabled={!selectedId} class:active={fixedCooldownReductions[slot] === reduction} aria-pressed={fixedCooldownReductions[slot] === reduction} on:click={() => updateFixedReduction(slot, reduction)}>{reduction === null ? '미사용' : `${reduction}%`}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fixed-overrides">
|
||||||
|
<label class="override-checkbox">
|
||||||
|
<input type="checkbox" disabled={!selectedId} checked={fixedAllBlueNodes[slot]} aria-label={`${slot + 1}번 고정 카드 파란 노드 전체 활성화`} on:change={() => toggleFixedAllBlueNodes(slot)}>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
</label>
|
||||||
|
<input class="generation-input" type="text" inputmode="numeric" pattern="[0-9]*" disabled={!selectedId} value={fixedGenerationBonusOverrides[slot] ?? ''} placeholder="미사용" aria-label={`${slot + 1}번 고정 카드 기수 보너스 덮어쓰기`} on:change={(event) => updateFixedGenerationBonus(slot, event.currentTarget.value)}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="fixed-guide">‘파란 노드 전체’를 사용하지 않으면 사용자 데이터에 저장된 해당 멤버의 보드를 사용합니다.</p>
|
||||||
|
<div class="leader-fields">
|
||||||
|
<label>리더<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}</select></label>
|
||||||
|
<label>리더 의상<select bind:value={leaderOutfitCardId} on:change={() => result = null}>{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}</select></label>
|
||||||
|
</div>
|
||||||
|
<div class="outfit-effect">
|
||||||
|
<span>리더 의상 효과</span>
|
||||||
|
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
|
||||||
|
</div>
|
||||||
|
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
|
||||||
|
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
|
||||||
|
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
|
||||||
|
<label class="compute-option">
|
||||||
|
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
|
<strong>최대 컴퓨팅 자원 활용</strong>
|
||||||
|
</label>
|
||||||
|
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
|
||||||
|
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" aria-labelledby="result-title">
|
||||||
|
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 분석 · 상위 {rankedResults.length}개</strong>{/if}</div>
|
||||||
|
{#if rankedResults.length > 0}
|
||||||
|
<div class="ranking-list">
|
||||||
|
{#each rankedResults as ranking, rank}
|
||||||
|
<details class="ranking-card" open={rank === 0}>
|
||||||
|
<summary>
|
||||||
|
<span class="rank-number">{rank + 1}위</span>
|
||||||
|
<span class="rank-members">
|
||||||
|
{#each ranking.result.order as cardId, memberIndex}
|
||||||
|
<span class:added-member={!fixedCardIds.includes(cardId)}>
|
||||||
|
{resultCardName(cardId)}
|
||||||
|
</span>
|
||||||
|
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
|
||||||
|
</summary>
|
||||||
|
<div class="ranking-detail">
|
||||||
|
<AnalysisResultView
|
||||||
|
result={ranking.result}
|
||||||
|
durationSeconds={selectedChart?.durationSeconds ?? 0}
|
||||||
|
cardName={resultCardName}
|
||||||
|
instanceId={`combination-${rank}`}
|
||||||
|
baseline={comparableToBaseline ? baselineResult : null}
|
||||||
|
showScoreSummary={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main { width: min(1080px, calc(100% - 40px)); margin: 0 auto; padding: 46px 0 80px; }
|
||||||
|
header { margin-bottom: 28px; }
|
||||||
|
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
|
||||||
|
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
|
||||||
|
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
|
.section-title > div { justify-content: flex-start; }
|
||||||
|
.section-title span { color: #ef8cb6; font-size: 11px; font-weight: 900; }
|
||||||
|
.section-title h2 { margin: 0; font-size: 18px; }
|
||||||
|
.section-title > strong { color: #aeb6c4; font-size: 12px; }
|
||||||
|
.baseline-panel { border-color: #4a3a4b; background: #1c1920; }
|
||||||
|
.baseline-condition { margin: 12px 0 0; color: #b8a8b4; font-size: 12px; }
|
||||||
|
.baseline-members { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 7px; margin-top: 14px; }
|
||||||
|
.baseline-members div { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; gap: 7px; min-height: 42px; padding: 7px 9px; border: 1px solid #37303a; border-radius: 9px; background: #141218; }
|
||||||
|
.baseline-members span { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; color: #181118; background: #d987ac; font-size: 10px; font-weight: 900; }
|
||||||
|
.baseline-members strong { overflow: hidden; color: #e8e1e7; font-size: 10px; line-height: 1.35; text-overflow: ellipsis; }
|
||||||
|
.baseline-status { margin: 15px 0 0; color: #9ba4b1; font-size: 12px; }
|
||||||
|
label { display: grid; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||||
|
select, .number-input { 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, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
|
||||||
|
.song-fields { display: grid; grid-template-columns: minmax(0, 1fr) 130px; gap: 10px; margin-top: 22px; }
|
||||||
|
.candidate-fields { display: grid; grid-template-columns: 130px minmax(220px, 320px); gap: 10px; margin-top: 14px; padding-top: 14px; border-top: 1px solid #292f39; }
|
||||||
|
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||||
|
.candidate-guide { margin: 7px 0 0; color: #737d8d; font-size: 10px; line-height: 1.5; }
|
||||||
|
.fixed-fields { display: grid; gap: 9px; margin-top: 16px; }
|
||||||
|
.fixed-guide { margin: 8px 0 0; color: #737d8d; font-size: 10px; line-height: 1.5; }
|
||||||
|
.fixed-column-headings { display: grid; grid-template-columns: minmax(0, 1fr) 120px 304px 180px; gap: 9px; margin-bottom: -3px; }
|
||||||
|
.fixed-column-headings > div { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; color: #8f98a8; font-size: 10px; font-weight: 800; text-align: center; }
|
||||||
|
.generation-heading { line-height: 1.25; white-space: nowrap; }
|
||||||
|
.fixed-field { display: grid; grid-template-columns: minmax(0, 1fr) 120px 304px 180px; gap: 9px; }
|
||||||
|
.fixed-awakening, .fixed-reduction { display: grid; align-self: end; align-items: center; height: 42px; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
|
||||||
|
.fixed-awakening { grid-template-columns: 32px minmax(0, 1fr); }
|
||||||
|
.fixed-reduction { grid-template-columns: 88px minmax(0, 1fr); }
|
||||||
|
.awakening-stepper { display: grid; grid-template-columns: 30px minmax(0, 1fr) 30px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
||||||
|
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 22px; cursor: pointer; }
|
||||||
|
.awakening-stepper strong { color: #e8ebf1; font-size: 12px; text-align: center; }
|
||||||
|
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
|
||||||
|
.reduction-options button { min-width: 0; padding: 0 5px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 10px; font-weight: 750; cursor: pointer; }
|
||||||
|
.reduction-options button:first-child { border-left: 0; }
|
||||||
|
.reduction-options button.active { color: #171019; background: #ed8bb5; }
|
||||||
|
.fixed-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; transition: border-color .12s, background .12s; }
|
||||||
|
.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%); }
|
||||||
|
.override-checkbox input:disabled + span { opacity: .35; cursor: default; }
|
||||||
|
.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: 10px; font-weight: 800; text-align: center; }
|
||||||
|
.generation-input::placeholder { color: #68717f; opacity: 1; }
|
||||||
|
.generation-input:disabled { opacity: .35; }
|
||||||
|
.awakening-stepper button:disabled, .reduction-options button:disabled { opacity: .35; cursor: default; }
|
||||||
|
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 26px; padding-top: 20px; border-top: 1px solid #303641; }
|
||||||
|
.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: 11px; font-weight: 850; }
|
||||||
|
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 12px; line-height: 1.55; }
|
||||||
|
.hint { margin: 13px 0 0; color: #858f9f; font-size: 11px; line-height: 1.6; }
|
||||||
|
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
|
||||||
|
.search { width: 100%; height: 46px; margin-top: 18px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
|
||||||
|
.search:disabled { opacity: .55; cursor: default; }
|
||||||
|
.compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
|
||||||
|
.compute-option input { position: absolute; width: 1px; height: 1px; opacity: 0; }
|
||||||
|
.compute-option span { display: grid; place-items: center; width: 20px; height: 20px; border: 1px solid #4a5361; border-radius: 6px; background: #11151b; }
|
||||||
|
.compute-option input:checked + span { border-color: #ed8bb5; background: #ed8bb5; }
|
||||||
|
.compute-option input:checked + span::after { content: '✓'; color: #171019; font-size: 13px; font-weight: 950; }
|
||||||
|
.compute-option input:focus-visible + span { outline: 3px solid rgb(216 111 156 / 20%); }
|
||||||
|
.compute-option input:disabled + span, .compute-option input:disabled ~ strong { opacity: .5; cursor: default; }
|
||||||
|
.compute-option strong { color: #c9d0da; font-size: 12px; }
|
||||||
|
.compute-warning { margin: 6px 0 0 29px; color: #a98686; font-size: 11px; line-height: 1.5; }
|
||||||
|
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
|
||||||
|
.ranking-list { display: grid; gap: 10px; margin-top: 18px; }
|
||||||
|
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
|
||||||
|
.ranking-card[open] { border-color: #55404f; background: #15151b; }
|
||||||
|
.ranking-card summary { position: relative; display: grid; grid-template-columns: 52px minmax(0, 1fr) 170px; align-items: center; gap: 14px; min-height: 76px; padding: 12px 42px 12px 16px; cursor: pointer; list-style: none; }
|
||||||
|
.ranking-card summary::-webkit-details-marker { display: none; }
|
||||||
|
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
|
||||||
|
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
|
||||||
|
.rank-number { display: grid; place-items: center; width: 48px; height: 34px; border-radius: 8px; color: #171019; background: #ed8bb5; font-size: 12px; font-weight: 900; }
|
||||||
|
.rank-members { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: #dce1e8; font-size: 13px; font-weight: 800; line-height: 1.55; }
|
||||||
|
.rank-members > span { white-space: nowrap; }
|
||||||
|
.rank-members > i { flex: 0 0 auto; color: #596270; font-style: normal; }
|
||||||
|
.rank-members .added-member { color: #f3a5c6; }
|
||||||
|
.rank-metric { display: grid; gap: 4px; }
|
||||||
|
.rank-metric small { color: #9ca5b3; font-size: 11px; 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: 10px; font-style: normal; font-weight: 800; }
|
||||||
|
.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; }
|
||||||
|
.scores span { color: #a9b1be; font-size: 12px; }
|
||||||
|
.scores strong { color: #f2a3c4; font-size: 19px; }
|
||||||
|
.baseline-scores { margin-top: 10px; }
|
||||||
|
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; padding: 28px; border: 1px dashed #303744; border-radius: 12px; color: #858f9f; font-size: 12px; text-align: center; }
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
main { width: calc(100% - 24px); padding-top: 28px; }
|
||||||
|
.panel { padding: 17px; }
|
||||||
|
.song-fields { grid-template-columns: 1fr 100px; }
|
||||||
|
.candidate-fields { grid-template-columns: 1fr; }
|
||||||
|
.fixed-column-headings { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||||
|
.fixed-column-headings > span { display: none; }
|
||||||
|
.fixed-column-headings > div { grid-column: 1 / -1; }
|
||||||
|
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
|
||||||
|
.fixed-reduction, .fixed-overrides { grid-column: 1 / -1; }
|
||||||
|
.fixed-awakening { grid-template-columns: 1fr; }
|
||||||
|
.leader-fields, .scores { grid-template-columns: 1fr; }
|
||||||
|
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
|
||||||
|
.baseline-members { grid-template-columns: 1fr; }
|
||||||
|
.ranking-card summary { grid-template-columns: 48px minmax(0, 1fr); gap: 12px 10px; padding: 13px 34px 13px 12px; }
|
||||||
|
.rank-number { grid-column: 1; grid-row: 1; }
|
||||||
|
.rank-members { grid-column: 2; grid-row: 1; }
|
||||||
|
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }
|
||||||
|
.ranking-detail { padding: 0 10px 14px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user