feat: recommend card investments for board connections
Publish GitHub Pages output / publish (push) Successful in 35s

This commit is contained in:
2026-09-30 19:02:43 +09:00
parent 115dc33937
commit de165bb53d
8 changed files with 428 additions and 1 deletions
+4
View File
@@ -12,6 +12,10 @@ updatedAt: 2026-09-30T00:00:00Z
`NEW` 카드 목록과 배치 분석 사이에 편성 비교 메뉴를 추가했습니다. 같은 악곡·난이도에서 두 리더·의상과 5인 편성을 구성하고, 카드 입력 순서와 관계없이 최고점 기준 최적 배치의 평균 점수·최고 점수·종합력 차이를 확인할 수 있습니다. 리더의 빨간 노드와 편성 멤버의 파란 노드를 모두 개방하고 연결 노드 증폭은 제외하며, 기수 보너스 900·가창 보너스 10%로 통일합니다. 메모리·멤버 종합력 UP은 사용자 데이터를 공통 적용합니다. A→B 복사와 편성 교환을 지원하고, 화면 최하단에 비교 기준을 안내합니다. `NEW` 카드 목록과 배치 분석 사이에 편성 비교 메뉴를 추가했습니다. 같은 악곡·난이도에서 두 리더·의상과 5인 편성을 구성하고, 카드 입력 순서와 관계없이 최고점 기준 최적 배치의 평균 점수·최고 점수·종합력 차이를 확인할 수 있습니다. 리더의 빨간 노드와 편성 멤버의 파란 노드를 모두 개방하고 연결 노드 증폭은 제외하며, 기수 보너스 900·가창 보너스 10%로 통일합니다. 메모리·멤버 종합력 UP은 사용자 데이터를 공통 적용합니다. A→B 복사와 편성 교환을 지원하고, 화면 최하단에 비교 기준을 안내합니다.
### 보드 연결노드용 개화 추천
`NEW` 도구에 연결노드 개화 추천 탭을 추가했습니다. 저장한 분석의 편성을 고정하고, 5성 카드의 첫 획득·5개화 연결 효과를 비교합니다. 현재 보유 카드와 보드를 기준으로 점수 증가량·필요 획득 수·1장당 이득과 추천 연결 위치를 확인할 수 있습니다. 기존 카드의 무료 재배치 이득은 투자 효과에서 제외하며, 빠른 연결 배정 방식으로 계산합니다.
### 퍼포먼스 패시브 종합력 계산 수정 ### 퍼포먼스 패시브 종합력 계산 수정
`FIX` 지정 인원의 퍼포먼스를 올리는 패시브가 전체 능력치 합계 대신 개화를 반영한 기본 퍼포먼스 순으로 대상을 선택하도록 수정했습니다. 일부 편성에서 게임과 종합력이 다르게 표시되던 문제를 해결했습니다. `FIX` 지정 인원의 퍼포먼스를 올리는 패시브가 전체 능력치 합계 대신 개화를 반영한 기본 퍼포먼스 순으로 대상을 선택하도록 수정했습니다. 일부 편성에서 게임과 종합력이 다르게 표시되던 문제를 해결했습니다.
+2
View File
@@ -7,6 +7,7 @@
import RatingPage from '../src/routes/rating/+page.svelte'; import RatingPage from '../src/routes/rating/+page.svelte';
import CombinationPage from '../src/routes/combination/+page.svelte'; import CombinationPage from '../src/routes/combination/+page.svelte';
import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte'; import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte';
import BoardAwakeningPage from '../src/routes/tools/board-awakening/+page.svelte';
import ToolsPage from '../src/routes/tools/+page.svelte'; import ToolsPage from '../src/routes/tools/+page.svelte';
import ComparePage from '../src/routes/compare/+page.svelte'; import ComparePage from '../src/routes/compare/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte'; import EventPage from '../src/routes/event/+page.svelte';
@@ -28,6 +29,7 @@
['/compare', ComparePage], ['/compare', ComparePage],
['/tools/random', ToolsPage], ['/tools/random', ToolsPage],
['/tools/holo-work', HoloWorkPage], ['/tools/holo-work', HoloWorkPage],
['/tools/board-awakening', BoardAwakeningPage],
['/random', ToolsPage], ['/random', ToolsPage],
['/event', EventPage], ['/event', EventPage],
['/data', DataPage], ['/data', DataPage],
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import BoardAwakeningWorker from '$lib/workers/board-awakening.worker.ts?worker&inline';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import type { AnalysisSettings, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import type { BoardAwakeningResult, BoardAwakeningProgress, BoardGrowthBest, BoardGrowthRow } from '$lib/engine/board-awakening.ts';
import type { BoardEffectSong } from '$lib/engine/board-effects.ts';
import type { CooldownReduction, SkillWindow } from '$lib/engine/types.ts';
export let settings: AnalysisSettings;
export let growth: GrowthSnapshot;
export let order: readonly string[];
export let cooldownReductions: Readonly<Record<string, CooldownReduction>>;
export let excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = [];
export let chartMetadata: (BoardEffectSong & { path: string; scoreRatio: number | null }) | undefined;
export let cardName: (id: string) => string;
export let socketName: (member: string, socket: string) => string;
export let disabled = false;
let sortBy: 'maximumGain' | 'gainPerCopy' = 'maximumGain';
let worker: Worker | null = null;
let generation = 0;
let running = false;
let error = '';
let snapshotKey = '';
let result: BoardAwakeningResult | null = null;
let progress: BoardAwakeningProgress = { stage: '', completed: 0, total: 1 };
$: inputKey = JSON.stringify({ settings, growth, order, cooldownReductions, excludedActiveWindows });
$: stale = Boolean(snapshotKey) && snapshotKey !== inputKey;
$: rows = result ? [...result.rows].sort((a, b) => b[sortBy] - a[sortBy] || b.maximumGain - a.maximumGain || a.copies - b.copies || a.cardId.localeCompare(b.cardId)) : [];
const number = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 1 });
const delta = (value: number) => `${value > 0 ? '+' : ''}${number(value)}`;
const action = (row: BoardGrowthRow) => row.to === 0 ? '첫 획득 · 0개화' : `${row.from === null ? '미보유' : `${row.from}개화`} → 5개화`;
function stop() { generation += 1; worker?.terminate(); worker = null; running = false; }
onDestroy(stop);
function changes(best: BoardGrowthBest, before: GrowthSnapshot['boardCards']) {
return [...new Set([...Object.keys(before), ...Object.keys(best.placements)])].flatMap((member) =>
[...new Set([...Object.keys(before[member] ?? {}), ...Object.keys(best.placements[member] ?? {})])].flatMap((socket) => {
const previous = before[member]?.[socket]; const next = best.placements[member]?.[socket];
return previous === next ? [] : [`${socketName(member, socket)}: ${previous ? cardName(previous) : '비어 있음'} → ${next ? cardName(next) : '비어 있음'}`];
}));
}
async function recommend() {
if (!chartMetadata || disabled || running) return;
stop(); const id = generation;
running = true; error = ''; result = null; snapshotKey = inputKey;
progress = { stage: '채보 준비', completed: 0, total: 1 };
const snapshot = structuredClone({ settings, growth, order, cooldownReductions, excludedActiveWindows, song: chartMetadata });
try {
const chart = await loadExactSongChart(snapshot.song.path);
if (id !== generation) return;
const target = new BoardAwakeningWorker(); worker = target;
target.onmessage = (event) => {
if (worker !== target) return;
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') { result = event.data.result; stop(); }
if (event.data.type === 'error') { error = event.data.message; stop(); }
};
target.onerror = () => { if (worker === target) { error = '보드 연결 추천 계산에 실패했습니다.'; stop(); } };
target.postMessage({ ...snapshot, chart });
} catch (e) { if (id === generation) { error = e instanceof Error ? e.message : '채보를 준비하지 못했습니다.'; stop(); } }
}
</script>
<section aria-labelledby="board-awakening-title">
<h3 id="board-awakening-title">보드 연결노드용 개화 추천</h3>
<p><strong>편성 5장·편성 개화·스페셜 순서·쿨타임을 고정</strong>하고, 5성 카드의 연결 효과만 비교합니다.</p>
<p>보유 카드는 5개화까지, 미보유 카드는 첫 획득과 5개화를 각각 비교합니다. 추가 획득 수는 첫 획득 1장, 미보유 → 5개화 6장입니다. 0~4개화의 연결 스킬은 같으며, 이미 5개화인 카드는 후보에서 제외합니다.</p>
<p>활성 노드를 유지한 채 연결 배치를 빠르게 배정하고 전체 보드 점수로 비교합니다. 비교 기준도 현재 보유 연결 카드로 배치를 개선한 점수이므로, 단순 재배치 이득을 개화 효과에 섞지 않습니다. 연결 배치를 전수 탐색하는 기능은 아닙니다. 사용자 데이터는 변경하지 않습니다.</p>
<div class="controls">
<button class="primary" disabled={disabled || running || !chartMetadata} on:click={recommend}>연결노드용 획득·개화 비교</button>
{#if running}<button on:click={() => { stop(); error = '계산을 취소했습니다.'; }}>취소</button>{/if}
<label>정렬 <select bind:value={sortBy}><option value="maximumGain">최고 점수 증가량</option><option value="gainPerCopy">추가 획득 1장당 증가량</option></select></label>
</div>
{#if running}<p role="status">{progress.stage} · {progress.completed}/{progress.total}</p><progress value={progress.completed} max={Math.max(1, progress.total)}></progress>{/if}
{#if stale}<p role="status">조건이 변경되었습니다. 다시 비교해 주세요.</p>{/if}
{#if error}<p role="alert">{error}</p>{/if}
{#if result && !stale}
<p>현재 최고 {number(result.current.maximum)}점 → 투자 전 기준 최고 {number(result.baseline.maximum)}점 · 평균 {number(result.baseline.expected)}점</p>
<details><summary>투자 전 기준 배치의 변경 내역</summary>
{#each changes(result.baseline, result.current.placements) as change}<p>{change}</p>{:else}<p>현재 배치와 동일합니다.</p>{/each}
</details>
{#if rows.length}
<p><strong>{rows[0].maximumGain > 0 ? `추천: ${cardName(rows[0].cardId)} · ${action(rows[0])}` : '비교한 배치에서 점수가 증가하는 후보가 없습니다.'}</strong></p>
<div class="table-scroll"><table><thead><tr><th>순위</th><th>카드</th><th>목표</th><th>추가 획득</th><th>최고점 증가</th><th>1장당 증가</th><th>평균점 증가</th><th>연결 위치</th></tr></thead><tbody>
{#each rows as row}<tr><td>{rows.findIndex((other) => other[sortBy] === row[sortBy]) + 1}</td><th>{cardName(row.cardId)}</th><td>{action(row)}</td><td>{row.copies}장</td><td>{delta(row.maximumGain)}</td><td>{delta(row.gainPerCopy)}</td><td>{delta(row.expectedGain)}</td><td><details><summary>배치 보기</summary>
{#each Object.entries(row.best.placements) as [member, slots]}{#each Object.entries(slots) as [socket, cardId]}{#if cardId === row.cardId}<p><strong>대상 카드: {socketName(member, socket)}</strong></p>{/if}{/each}{/each}
{#each changes(row.best, result.baseline.placements) as change}<p>{change}</p>{:else}<p>기준 배치와 동일한 위치에서 연결 효과만 비교합니다.</p>{/each}
<p>최고 {number(row.best.maximum)}점 · 평균 {number(row.best.expected)}점 · 종합력 {number(row.best.power)}</p>
</details></td></tr>{/each}
</tbody></table></div>
{:else}<p>비교할 5성 연결 카드가 없습니다.</p>{/if}
{/if}
</section>
<style>
section { margin-top: 22px; padding: 18px; border: 1px solid #384152; border-radius: 12px; background: #171d28; }
h3 { margin: 0 0 10px; font-size: 17px; } p { font-size: 12px; color: #a8b1c1; line-height: 1.65; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } label { font-size: 12px; }
button, select { border: 1px solid #526077; border-radius: 8px; padding: 9px 12px; background: #272f40; color: #edf0f5; }
.primary { background: #ed8bb5; color: #171019; font-weight: 800; } button, summary { cursor: pointer; } button:disabled { opacity: .5; cursor: default; }
progress { width: 100%; } .table-scroll { overflow-x: auto; } table { border-collapse: collapse; font-size: 12px; width: 100%; }
th, td { padding: 9px; text-align: left; border-bottom: 1px solid #384152; white-space: nowrap; vertical-align: top; }
td details { min-width: 120px; max-width: 360px; } details p { white-space: normal; overflow-wrap: anywhere; }
</style>
+2 -1
View File
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
export let active: 'random' | 'holo-work'; export let active: 'random' | 'holo-work' | 'board-awakening';
</script> </script>
<nav aria-label="도구 메뉴"> <nav aria-label="도구 메뉴">
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a> <a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a> <a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
<a href="/tools/board-awakening" aria-current={active === 'board-awakening' ? 'page' : undefined}>연결노드 개화 추천</a>
</nav> </nav>
<style> <style>
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { boardInvestments, recommendBoardAwakening, type BoardAwakeningData, type BoardAwakeningRequest } from './board-awakening.ts';
const catalog = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')) as { cards: BoardAwakeningData['cards'] };
const memory = JSON.parse(readFileSync(new URL('../data/memory-summary.json', import.meta.url), 'utf8'));
function fixture() {
const cards = ['a', 'b', 'c', 'd', 'e', 'new'].map((id) => {
const card = structuredClone(catalog.cards.find((card) => card.rarity === 5)!);
card.id = id; card.characterId = id; card.name = id;
card.details.connect = card.details.connect.map((effect, i) => ({ ...effect, level: i + 1, effectPermilUp: i ? 2000 : 1000, extent: [{ x: 1, y: 0 }] }));
return card;
});
const boards = {
members: [{ id: 'a', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'G', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
G: [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 10000, targetId: 'skill_tree_effect_target-skill_tree_character' } }]
}
};
const data: BoardAwakeningData = { cards, boards, memoryEffects: memory.effects };
const ids = ['a', 'b', 'c', 'd', 'e'];
const request: BoardAwakeningRequest = {
settings: { songId: 'test', difficulty: 'expert', cardIds: ids, awakenings: [0, 0, 0, 0, 0], leaderCharacterId: 'a', leaderOutfitCardId: 'a' },
growth: { collection: {}, memberBoards: { a: ['G'] }, boardCards: {}, memoryCount: 0, memberPowerUpPercent: 0 },
chart: { songId: 'test', difficulty: 'expert', durationSeconds: 8, scoreRatio: 30, notes: Array.from({ length: 8 }, (_, i) => ({ timeSeconds: i + 1, type: 'regular' as const, isFlick: false })), specialStartSeconds: [1, 2, 3, 4, 5] },
song: { scoreRatio: 30 }, order: ids, cooldownReductions: Object.fromEntries(ids.map((id) => [id, 0]))
};
return { data, request };
}
test('5성만 대상으로 보유 개화 잔여 수와 미보유 첫 획득·5개화 비용을 비교한다', () => {
const rows = boardInvestments([{ id: 'a', rarity: 5 }, { id: 'b', rarity: 5 }, { id: 'c', rarity: 5 }, { id: 'd', rarity: 4 }], { a: { owned: true, awakening: 3 }, b: { owned: true, awakening: 5 } });
assert.deepEqual(rows, [{ cardId: 'a', from: 3, to: 5, copies: 2 }, { cardId: 'c', from: null, to: 0, copies: 1 }, { cardId: 'c', from: null, to: 5, copies: 6 }]);
});
test('미보유 카드가 연결에 들어가 이득을 내지만 편성·입력은 바뀌지 않는다', () => {
const { data, request } = fixture(); const before = structuredClone(request);
const result = recommendBoardAwakening(data, request);
const acquire = result.rows.find((row) => row.cardId === 'new' && row.to === 0)!;
const max = result.rows.find((row) => row.cardId === 'new' && row.to === 5)!;
assert.ok(acquire.maximumGain > 0); assert.ok(max.maximumGain > acquire.maximumGain);
assert.equal(acquire.best.placements.a.S, 'new');
assert.deepEqual(request, before);
});
test('활성 효과가 없으면 편성 카드의 가상 5개화가 자체 능력·스킬을 올리지 않는다', () => {
const { data, request } = fixture(); request.growth.memberBoards = {};
request.growth.collection.a = { owned: true, awakening: 0 };
const result = recommendBoardAwakening(data, request);
assert.ok(result.rows.every((row) => row.maximumGain === 0 && row.expectedGain === 0 && row.best.power === result.current.power));
});
test('무료 재배치는 투자 전 기준에 포함하며 이미 같은 연결 효과가 있으면 획득 이득은 0이다', () => {
const { data, request } = fixture(); request.growth.collection.a = { owned: true, awakening: 0 };
const result = recommendBoardAwakening(data, request);
assert.ok(result.baseline.maximum > result.current.maximum);
assert.equal(result.rows.find((row) => row.cardId === 'new' && row.to === 0)!.maximumGain, 0);
const row = result.rows.find((row) => row.cardId === 'a')!;
assert.ok(row.maximumGain > 0); assert.equal(row.copies, 5);
});
test('가창 보너스 수동 보정과 비활성 보드 효과를 유지한다', () => {
const { data, request } = fixture(); request.settings.songScoreBonusOverride = 10;
const result = recommendBoardAwakening(data, request);
const plain = fixture(); const previous = recommendBoardAwakening(plain.data, plain.request);
assert.ok(result.baseline.maximum > previous.baseline.maximum);
assert.ok(result.rows.every((row) => Number.isFinite(row.gainPerCopy)));
});
+157
View File
@@ -0,0 +1,157 @@
import type cardSummary from '../data/cards-summary.json';
import type memorySummary from '../data/memory-summary.json';
import type { AnalysisSettings, GrowthSnapshot } from '../models/analysis-history.ts';
import { calculateBoardEffects, type BoardEffectSong, type BoardSummarySource } from './board-effects.ts';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource, type OptimizableConnectionCard } from './board-connection-optimizer.ts';
import { calculateUnitPower } from './unit-power.ts';
import { toOptimizerCard } from './card-adapter.ts';
import { memoryParameterPermil } from './memory.ts';
import { calculateExactScore, perfectNoteScoreFromPower } from './score.ts';
import type { CooldownReduction, ExactSongChart, SkillWindow } from './types.ts';
type Card = (typeof cardSummary.cards)[number];
type Placements = GrowthSnapshot['boardCards'];
export type BoardAwakeningData = {
cards: readonly Card[]; boards: BoardSummarySource & BoardConnectionOptimizerSource;
memoryEffects: typeof memorySummary.effects;
};
export type BoardAwakeningRequest = {
settings: AnalysisSettings; growth: GrowthSnapshot; chart: ExactSongChart;
song: BoardEffectSong & { scoreRatio: number | null };
order: readonly string[]; cooldownReductions: Readonly<Record<string, CooldownReduction>>;
excludedActiveWindows?: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[];
};
export type BoardInvestment = { cardId: string; from: number | null; to: number; copies: number };
export type BoardGrowthScore = { maximum: number; expected: number; power: number; activationRateUp: number };
export type BoardGrowthBest = BoardGrowthScore & { placements: Placements };
export type BoardGrowthRow = BoardInvestment & { best: BoardGrowthBest; maximumGain: number; expectedGain: number; gainPerCopy: number };
export type BoardAwakeningResult = { current: BoardGrowthBest; baseline: BoardGrowthBest; rows: BoardGrowthRow[]; scoreChecks: number };
export type BoardAwakeningProgress = { stage: string; completed: number; total: number };
export function boardInvestments(cards: readonly { id: string; rarity: number }[], collection: GrowthSnapshot['collection']): BoardInvestment[] {
return cards.filter((card) => card.rarity === 5).flatMap((card): BoardInvestment[] => {
const owned = collection[card.id]?.owned;
const from = owned ? collection[card.id]?.awakening ?? 0 : null;
if (from !== null && (!Number.isInteger(from) || from < 0 || from > 5)) throw new Error('보유 카드의 개화는 0~5여야 합니다.');
if (from === 5) return [];
return from === null
? [{ cardId: card.id, from, to: 0, copies: 1 }, { cardId: card.id, from, to: 5, copies: 6 }]
: [{ cardId: card.id, from, to: 5, copies: 5 - from }];
});
}
const selectedEffect = (card: OptimizableConnectionCard) => card.connect.find((effect) => effect.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const effectKey = (card: OptimizableConnectionCard) => {
const effect = selectedEffect(card);
return effect ? JSON.stringify([effect.effectPermilUp, effect.extent.map(({ x, y }) => `${x},${y}`).sort()]) : '';
};
/** Only connection effects vary; no formation, awakening, special-order or cooldown search. */
export function recommendBoardAwakening(data: BoardAwakeningData, request: BoardAwakeningRequest,
onProgress: (value: BoardAwakeningProgress) => void = () => {}): BoardAwakeningResult {
const { settings, growth, song, chart } = request;
const byId = new Map(data.cards.map((card) => [card.id, card]));
const sources = settings.cardIds.map((id) => byId.get(id));
if (sources.length !== 5 || sources.some((card) => !card) || new Set(sources.map((card) => card!.characterId)).size !== 5) throw new Error('서로 다른 멤버 5장의 편성을 먼저 분석해 주세요.');
const formation = sources as Card[];
if (request.order.length !== 5 || new Set(request.order).size !== 5 || request.order.some((id) => !settings.cardIds.includes(id))) throw new Error('분석된 스페셜 순서를 확인해 주세요.');
if (settings.awakenings.length !== 5 || settings.awakenings.some((value) => !Number.isInteger(value) || value < 0 || value > 5)) throw new Error('편성 카드 개화를 확인해 주세요.');
if (request.order.some((id) => ![0, 4, 8, 12].includes(request.cooldownReductions[id]))) throw new Error('분석된 쿨타임을 확인해 주세요.');
const outfit = byId.get(settings.leaderOutfitCardId)?.details.costume;
const connections = data.cards.filter((card) => card.details.connect.length && growth.collection[card.id]?.owned)
.map((card) => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
const candidates = boardInvestments(data.cards.filter((card) => card.details.connect.length), growth.collection);
const boards = data.boards;
const clean = (placements: Placements, cards: readonly OptimizableConnectionCard[]): Placements => {
const ids = new Set(cards.map((card) => card.id)); const used = new Set<string>(); const result: Placements = {};
for (const member of boards.members) {
const positions = boards.models[member.positionGroupId] ?? [];
const active = new Set(growth.memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
for (const [socket, id] of Object.entries(placements[member.id] ?? {})) {
if (!ids.has(id) || used.has(id) || !active.has(socket) || !positions.some((position) => position.groupId === socket)) continue;
const nodes = (boards as BoardConnectionOptimizerSource).nodeGroups[socket] ?? [];
const node = nodes.find((node) => node.characterIds.includes(member.id)) ?? nodes.find((node) => node.characterIds.length === 0);
if (node?.type !== 'connection') continue;
used.add(id); (result[member.id] ??= {})[socket] = id;
}
}
return result;
};
const cache = new Map<string, BoardGrowthScore>();
const effectScores = new Map<string, BoardGrowthScore>();
let scoreChecks = 0;
const evaluate = (placements: Placements, cards: readonly OptimizableConnectionCard[]): BoardGrowthBest => {
const effectsByCard = new Map(cards.map((card) => [card.id, effectKey(card)]));
const signature = JSON.stringify(Object.entries(placements).sort(([a], [b]) => a.localeCompare(b)).map(([member, slots]) =>
[member, Object.entries(slots).map(([slot, id]) => [slot, effectsByCard.get(id)])]));
const cached = cache.get(signature);
if (cached) return { ...cached, placements };
const effects = calculateBoardEffects(boards, growth.memberBoards, formation, settings.leaderCharacterId, song, { placements, cards }, {
generationBonusByCardId: Object.fromEntries(settings.cardIds.flatMap((id, i) => settings.generationBonusOverrides?.[i] == null ? [] : [[id, settings.generationBonusOverrides[i]!]])),
...(settings.songScoreBonusOverride == null ? {} : { directSongScoreBonusPercent: settings.songScoreBonusOverride })
});
const effectsKey = JSON.stringify(effects);
const sameEffects = effectScores.get(effectsKey);
if (sameEffects) { cache.set(signature, sameEffects); return { ...sameEffects, placements }; }
// Intentionally keep formation awakenings separate from hypothetical connection awakenings.
const power = calculateUnitPower(formation.map((card, i) => ({ card, awakening: settings.awakenings[i] })), outfit,
effects, memoryParameterPermil(growth.memoryCount, data.memoryEffects), growth.memberPowerUpPercent);
const adapted = formation.map((card, i) => {
const value = toOptimizerCard(card, settings.awakenings[i], [request.cooldownReductions[card.id]], formation);
return { ...value, active: { ...value.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 }, scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
const result = calculateExactScore(request.order.map((id) => adapted.find((card) => card.id === id)!), request.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, song.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent,
excludedActiveWindows: request.excludedActiveWindows
});
const score = { maximum: result.theoreticalMaxScore, expected: result.expectedScore, power: power.overallPower,
activationRateUp: Object.values(effects.activationRateUpByCardId).reduce((sum, value) => sum + value, 0) };
scoreChecks += 1; cache.set(signature, score); effectScores.set(effectsKey, score);
return { ...score, placements };
};
const better = (a: BoardGrowthBest, b: BoardGrowthBest) => a.maximum > b.maximum || (a.maximum === b.maximum && a.expected > b.expected);
const current = evaluate(clean(growth.boardCards, connections), connections);
const empty = evaluate({}, []);
const metricCache = new Map<string, BoardGrowthScore>();
const optimize = (cards: OptimizableConnectionCard[], seed: Placements): BoardGrowthBest => {
const metrics = (card: OptimizableConnectionCard, member: string, socket: string) => {
const key = `${member}:${socket}:${effectKey(card)}`;
if (!metricCache.has(key)) metricCache.set(key, evaluate({ [member]: { [socket]: card.id } }, [card]));
return metricCache.get(key)!;
};
const output = optimizeBoardConnectionCards(boards, growth.memberBoards, cards, {
currentPlacements: seed, requireBlueActivationCoverage: formation.map((card) => card.characterId), preservePlacementOnTie: () => true,
placementWeight: (card, member, socket) => metrics(card, member, socket).maximum - empty.maximum,
placementTieWeight: (card, member, socket) => metrics(card, member, socket).activationRateUp - empty.activationRateUp,
targetMemberId: settings.leaderCharacterId, groupSingerMemberIds: song.singerCharacterIds,
requireGroupSongBonus: settings.songScoreBonusOverride == null && song.singerType === 'group',
requireSoloSongBonus: settings.songScoreBonusOverride == null && song.singerType === 'solo' && Boolean(song.singerCharacterIds?.includes(settings.leaderCharacterId))
});
const existing = evaluate(clean(seed, cards), cards);
const proposed = evaluate(output.placements, cards);
return better(proposed, existing) ? proposed : existing;
};
onProgress({ stage: '보유 연결 카드 기준 배치 계산', completed: 0, total: candidates.length });
let baseline = optimize(connections, current.placements);
const seed = baseline.placements;
const prepared: { candidate: BoardInvestment; cards: OptimizableConnectionCard[]; best: BoardGrowthBest }[] = [];
for (const [i, candidate] of candidates.entries()) {
onProgress({ stage: '획득·5개화 연결 효과 비교', completed: i, total: candidates.length });
const card = byId.get(candidate.cardId)!;
const cards = connections.filter((entry) => entry.id !== card.id).concat({ id: card.id, awakening: candidate.to, connect: card.details.connect });
const best = optimize(cards, seed);
prepared.push({ candidate, cards, best });
// Do not credit free rearrangements of existing cards to acquiring/awakening a card.
const before = evaluate(clean(best.placements, connections), connections);
if (better(before, baseline)) baseline = before;
}
const rows = prepared.map(({ candidate, cards, best }) => {
const withBaseline = evaluate(baseline.placements, cards);
if (better(withBaseline, best)) best = withBaseline;
const maximumGain = best.maximum - baseline.maximum;
return { ...candidate, best, maximumGain, expectedGain: best.expected - baseline.expected, gainPerCopy: maximumGain / candidate.copies };
}).sort((a, b) => b.maximumGain - a.maximumGain || b.gainPerCopy - a.gainPerCopy || a.cardId.localeCompare(b.cardId));
onProgress({ stage: '비교 완료', completed: candidates.length, total: candidates.length });
return { current, baseline, rows, scoreChecks };
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference lib="webworker" />
import cards from '../data/cards-summary.json';
import boards from '../data/boards-summary.json';
import memory from '../data/memory-summary.json';
import { recommendBoardAwakening, type BoardAwakeningRequest } from '../engine/board-awakening.ts';
self.onmessage = (event: MessageEvent<BoardAwakeningRequest>) => {
try {
const result = recommendBoardAwakening({ cards: cards.cards, boards, memoryEffects: memory.effects }, event.data,
(progress) => self.postMessage({ type: 'progress', progress }));
self.postMessage({ type: 'result', result });
} catch (error) { self.postMessage({ type: 'error', message: error instanceof Error ? error.message : '연결노드용 개화 추천에 실패했습니다.' }); }
};
export {};
@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount } from 'svelte';
import ToolsNav from '$lib/components/ToolsNav.svelte';
import BoardAwakening from '$lib/components/BoardAwakening.svelte';
import { listAnalyses, readGrowth } from '$lib/storage/analysis-history.ts';
import { loadBrowserData } from '$lib/storage/browser-storage.ts';
import type { AnalysisRecord, GrowthSnapshot } from '$lib/models/analysis-history.ts';
import cards from '$lib/data/cards-summary.json';
import boards from '$lib/data/boards-summary.json';
import catalog from '$lib/data/exact-chart-catalog.json';
let records: AnalysisRecord[] = [];
let selectedId = '';
let selected: AnalysisRecord | null = null;
let growth: GrowthSnapshot | null = null;
let source: 'current' | 'saved' = 'current';
let error = '';
let loading = true;
let revision = 0;
const byId = new Map(cards.cards.map((card) => [card.id, card]));
const cardName = (id: string) => { const card = byId.get(id); return card ? `★${card.rarity} ${card.name}${card.versionName ? ` · ${card.versionName}` : ''}` : id; };
$: chart = selected ? catalog.charts.find((item) => item.songId === selected?.settings.songId && item.difficulty === selected?.settings.difficulty) : undefined;
onMount(() => { void refresh(); });
async function refresh() {
try { records = await listAnalyses(); selectedId = records.some((item) => item.id === selectedId) ? selectedId : records[0]?.id ?? ''; await select(); }
catch { error = '저장한 분석 기록을 불러오지 못했습니다.'; }
finally { loading = false; }
}
async function select() {
const version = ++revision;
selected = null; growth = null; error = ''; loading = true;
try {
const record = records.find((item) => item.id === selectedId);
if (!record) return;
const value = source === 'saved' ? await readGrowth(record.snapshotId) : loadBrowserData({ types: [], stats: [], activeConditions: [], specialConditions: [], activeProbabilities: [], cardVariants: [], generations: [], sortKey: null, sortDirection: 'desc', skillLevel: 1 });
if (version !== revision) return;
if (!value) throw new Error('성장 기록을 찾을 수 없습니다.');
const snapshot: GrowthSnapshot = structuredClone({ collection: value.collection, memberBoards: value.memberBoards, boardCards: value.boardCards, memoryCount: value.memoryCount, memberPowerUpPercent: value.memberPowerUpPercent });
record.settings.cardIds.forEach((id, index) => {
if (!record.settings.allBlueNodes?.[index]) return;
const member = boards.members.find((item) => item.id === byId.get(id)?.characterId);
if (!member) return;
const positions = (boards.models as Record<string, { groupId: string }[]>)[member.positionGroupId] ?? [];
snapshot.memberBoards[member.id] = [...(snapshot.memberBoards[member.id] ?? []).filter((id) => !id.startsWith('B')), ...positions.filter((item) => item.groupId.startsWith('B')).map((item) => item.groupId)];
});
selected = record; growth = snapshot;
} catch (e) { if (version === revision) error = e instanceof Error ? e.message : '조건을 불러오지 못했습니다.'; }
finally { if (version === revision) loading = false; }
}
</script>
<svelte:head><title>연결노드 개화 추천 · 도구 · 홀로도리 계산기</title></svelte:head>
<main>
<h1>도구</h1>
<ToolsNav active="board-awakening" />
<h2>보드 연결노드용 개화 추천</h2>
<p>저장한 분석의 편성·개화·리더 의상·곡·스페셜 순서·쿨타임을 고정하고 연결 카드 투자 효과를 비교합니다.</p>
<p>보유 카드와 활성 보드는 기본적으로 현재 데이터를 사용합니다. 분석의 보드 보정 설정도 반영하며, 액티브는 모두 발동 가능한 상태로 계산합니다.</p>
<div class="controls">
<label>기준 분석 <select bind:value={selectedId} on:change={select} disabled={loading}>{#each records as record}<option value={record.id}>{record.name} · {record.title} · {record.settings.difficulty}</option>{/each}</select></label>
<label>성장 데이터 <select bind:value={source} on:change={select} disabled={loading}><option value="current">현재 보유 카드·보드</option><option value="saved">분석 저장 당시</option></select></label>
<button on:click={refresh} disabled={loading}>다시 불러오기</button>
</div>
{#if error}<p role="alert">{error}</p>{/if}
{#if loading}<p role="status">조건을 불러오는 중…</p>
{:else if !records.length}<p><a href="/analysis">배치 분석</a>에서 편성을 분석하고 결과를 저장하면 여기서 선택할 수 있습니다.</p>{/if}
{#if selected && growth}
<p>고정 편성: {selected.settings.cardIds.map((id, index) => `${cardName(id)} (${selected!.settings.awakenings[index]}개화)`).join(' / ')}</p>
{#if !chart}<p role="alert">저장된 곡의 채보를 찾을 수 없습니다. 다른 분석을 선택해 주세요.</p>{/if}
{#key selected.id + source + revision}
<BoardAwakening settings={selected.settings} {growth} order={selected.result.order} cooldownReductions={selected.result.cooldownReductions} chartMetadata={chart} {cardName}
socketName={(member, socket) => `${boards.members.find((item) => item.id === member)?.name ?? member} · ${socket}`} />
{/key}
{/if}
</main>
<style>
main { width: min(1200px, calc(100% - var(--page-gutter))); margin: 0 auto; padding: 42px 0 80px; }
p { color: #a4adba; line-height: 1.7; } a { color: #f3a5c5; }
.controls { display: flex; gap: 12px; flex-wrap: wrap; align-items: end; }
label { display: grid; gap: 8px; max-width: 100%; }
select, button { max-width: 100%; padding: 10px; border: 1px solid #414b5b; border-radius: 8px; background: #171c24; color: #e8ebf1; }
button { cursor: pointer; } button:disabled { opacity: .5; }
</style>