feat: improve board review and connection optimization assumptions
Publish GitHub Pages output / publish (push) Successful in 45s

This commit is contained in:
2026-10-06 12:12:09 +09:00
parent 0b794edc80
commit d18972382a
8 changed files with 277 additions and 29 deletions
+11 -1
View File
@@ -1,9 +1,13 @@
---
updatedAt: 2026-10-06T02:36:54.977Z
updatedAt: 2026-10-06T03:11:36.779Z
---
## 2026년 10월 6일
### 파란 보드 개방 가정으로 연결 카드 추천
`NEW` 배치 분석의 연결 카드 최적화에 ‘파란 보드 개방 가정’을 추가했습니다. 편성 5명의 전체 파란 노드와 파란 영역 연결 소켓을 임시 개방한 조건으로 추천하며, 전후 점수는 동일한 개방 가정으로 비교합니다. 필요한 미개방 노드·소켓을 표시하고 실제 보드를 갱신하기 전에는 배치 적용을 차단합니다. 저장된 보드를 자동 변경하지 않으며 기본값은 기존 성장 기준입니다.
### 서비스 이름 변경
`IMPROVED` 서비스 이름을 ‘홀로도리 계산기’에서 ‘갤산기’로 변경했습니다. 상단 서비스 이름과 각 페이지의 브라우저 제목, 서비스 안내에 새 이름을 반영하고, 상단 이름의 글자 크기를 키웠습니다.
@@ -14,6 +18,12 @@ updatedAt: 2026-10-06T02:36:54.977Z
## 2026년 10월 5일
### 보드 데이터 확인 및 분석 진행 안내 개선
`IMPROVED` 사용자 데이터의 보드 멤버 목록에 개방 수를 표시하고, 개방 수가 많거나 적은 순으로 정렬할 수 있습니다. 이전·다음 멤버 이동도 선택한 정렬을 따릅니다. 연결 카드 선택에 미배치 보유 카드만 보는 필터를 추가했습니다.
`IMPROVED` 조합 탐색의 진행 상태를 후보 선별·선별 후보 평가·확장 후보 검증·달성 확률 계산으로 구분하고, 단계별 퍼센트와 별도로 현재 단계를 n/4로 표시합니다. 확장 후보가 없으면 3단계를 건너뜁니다.
### KONKON Beats 악곡 추가
`NEW` KONKON Beats의 Easy·Normal·Hard·Expert 채보를 추가했습니다. 각 난이도의 점수 계산과 채보 표시를 지원합니다.
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { assumeTeamBlueBoards, missingBoardOpenings } from './board-blue-assumption.ts';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
function fixture(side: number): BoardConnectionOptimizerSource {
return {
members: [{ id: 'team', positionGroupId: 'model' }, { id: 'other', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'B-one', x: side, y: 0 },
{ groupId: 'S-blue', x: 2 * side, y: 0 }, { groupId: 'B-two', x: 3 * side, y: 0 },
{ groupId: 'S-yellow', x: -2 * side, y: 0 }, { groupId: 'Y-one', x: -side, y: 0 }
] },
nodeGroups: Object.fromEntries(['S-center', 'S-blue', 'S-yellow', 'B-one', 'B-two', 'Y-one'].map((id) => [id, [{ characterIds: [], type: id.startsWith('S') ? 'connection' : 'card', effect: id.startsWith('S') ? null : { type: 'performance_up', value: 100 } }]]))
};
}
for (const side of [-1, 1]) test(`파란 방향 ${side}: 편성 멤버의 파란 노드·소켓만 임시 개방`, () => {
const source = fixture(side);
const saved = { team: ['Y-one'], other: ['B-one'] };
const before = JSON.stringify(saved);
const assumed = assumeTeamBlueBoards(source, saved, ['team'], true);
assert.deepEqual(assumed.team, ['Y-one', 'B-one', 'B-two', 'S-blue']);
assert.deepEqual(assumed.other, ['B-one']);
assert.equal(JSON.stringify(saved), before);
assert.deepEqual(assumeTeamBlueBoards(source, saved, ['team'], false), saved);
assert.deepEqual(missingBoardOpenings(source, saved, assumed), [{ memberId: 'team', nodeIds: ['B-one', 'B-two'], socketIds: ['S-blue'] }]);
assert.deepEqual(missingBoardOpenings(source, assumed, assumed), []);
const cards = [{ id: 'card', awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: side, y: 0 }] }] }];
const options = { placementWeight: (_card: unknown, member: string, socket: string) => member === 'team' && socket === 'S-blue' ? 100 : 0 };
assert.equal(optimizeBoardConnectionCards(source, saved, cards, options).placements.team?.['S-blue'], undefined);
assert.equal(optimizeBoardConnectionCards(source, assumed, cards, options).placements.team?.['S-blue'], 'card');
});
test('실제 모든 보드 모델에서 파란 소켓을 찾고 다른 색상은 개방하지 않는다', () => {
const source = JSON.parse(readFileSync(new URL('../data/boards-summary.json', import.meta.url), 'utf8'));
for (const member of source.members) {
const assumed = assumeTeamBlueBoards(source, {}, [member.id], true);
assert.equal(Object.keys(assumed).length, 1);
assert.equal(assumed[member.id].filter((id) => id.startsWith('S-')).length, 1);
assert.ok(assumed[member.id].every((id) => id.startsWith('B-') || id.startsWith('S-')));
}
});
test('가정 결과는 같은 보드로 전후 비교하고 미개방 상태의 적용을 차단한다', () => {
const source = readFileSync(new URL('../../routes/analysis/+page.svelte', import.meta.url), 'utf8');
assert.match(source, /let assumeBlueForConnections = false/);
assert.match(source, /const beforeContext = scoreContext\(boardCards\)/);
assert.match(source, /const afterContext = scoreContext\(optimized.placements\)/);
assert.match(source, /beforeExpected: baseline.expectedScore/);
const apply = source.slice(source.indexOf('async function applyConnectionOptimization'), source.indexOf('async function undoConnectionOptimization'));
assert.match(apply, /openingRequirements.length > 0\) return/);
assert.doesNotMatch(apply, /memberBoards\s*=/);
});
+33
View File
@@ -0,0 +1,33 @@
import type { BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
export type BoardOpeningRequirement = { memberId: string; nodeIds: string[]; socketIds: string[] };
/** Temporary optimization input only; never mutates the saved board. */
export function assumeTeamBlueBoards(
source: BoardConnectionOptimizerSource,
boards: Record<string, string[]>, memberIds: readonly string[], enabled: boolean
): Record<string, string[]> {
const result = Object.fromEntries(Object.entries(boards).map(([id, nodes]) => [id, [...nodes]]));
if (!enabled) return result;
for (const member of source.members.filter((member) => memberIds.includes(member.id))) {
const positions = source.models[member.positionGroupId] ?? [];
const blue = positions.filter((position) => position.groupId.startsWith('B-'));
// Board layouts may mirror the blue branch; identify its side from coordinates.
const sockets = positions.filter((position) => position.groupId.startsWith('S-')
&& position.x !== 0 && blue.some((node) => node.x * position.x > 0));
result[member.id] = [...new Set([...(result[member.id] ?? []), ...blue.map((node) => node.groupId), ...sockets.map((node) => node.groupId)])];
}
return result;
}
export function missingBoardOpenings(
source: BoardConnectionOptimizerSource, saved: Record<string, string[]>, assumed: Record<string, string[]>
): BoardOpeningRequirement[] {
return source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const center = positions.find((node) => node.x === 0 && node.y === 0)?.groupId;
const active = new Set(saved[member.id] ?? []);
const missing = [...new Set(assumed[member.id] ?? [])].filter((id) => id !== center && !active.has(id));
return missing.length ? [{ memberId: member.id, nodeIds: missing.filter((id) => !id.startsWith('S-')), socketIds: missing.filter((id) => id.startsWith('S-')) }] : [];
});
}
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { compile } from 'svelte/compiler';
import { boardMemberOptions, unplacedOwnedCards } from './board-editor-options.ts';
test('보드 개방 수는 중앙·중복·없는 노드를 제외하고 정렬하며 입력을 보존한다', () => {
const members = ['a', 'b', 'c'].map((id) => ({ id, positionGroupId: 'model' }));
const models = { model: [{ groupId: 'center', x: 0, y: 0 }, { groupId: 'one', x: 1, y: 0 }, { groupId: 'two', x: 2, y: 0 }] };
const boards = { a: ['one'], b: ['center', 'one', 'one', 'two', 'missing'], c: ['two'] };
const before = JSON.stringify({ members, models, boards });
const options = (sort: 'default' | 'most' | 'least') => boardMemberOptions(members, models, boards, sort);
assert.deepEqual(options('default').map((m) => m.openedCount), [1, 2, 1]);
assert.deepEqual(options('most').map((m) => m.id), ['b', 'a', 'c']);
assert.deepEqual(options('least').map((m) => m.id), ['a', 'c', 'b']);
assert.equal(JSON.stringify({ members, models, boards }), before);
assert.equal(boardMemberOptions(members, {}, {}, 'most')[0].openedCount, 0);
});
test('미배치 보유 필터는 모든 멤버의 배치를 제외하고 배치 해제·보유 변경을 반영한다', () => {
const cards = ['a', 'b', 'c', 'd'].map((id) => ({ id }));
const collection = { a: { owned: true }, b: { owned: true }, c: { owned: false } };
const placements = { member: { socket: 'a' } };
assert.deepEqual(unplacedOwnedCards(cards, collection, placements, true), [{ id: 'b' }]);
assert.deepEqual(unplacedOwnedCards(cards, collection, {}, true), [{ id: 'a' }, { id: 'b' }]);
assert.deepEqual(unplacedOwnedCards(cards, { b: { owned: false } }, {}, true), []);
assert.equal(unplacedOwnedCards(cards, collection, placements, false), cards);
});
test('보드 정렬·카드 필터는 사용자 데이터와 선택값을 반응형 의존성으로 추적한다', () => {
const filename = new URL('../../routes/data/+page.svelte', import.meta.url);
const source = readFileSync(filename, 'utf8');
const code = compile(source, { filename: filename.pathname, generate: 'client' }).js.code;
for (const [target, dependencies] of [
['sortedBoardMembers', ['userData', 'boardMemberSort']],
['filteredConnectCards', ['userData', 'unplacedOwnedOnly', 'boardCardSearch']]
] as const) {
const effect = code.split('$.legacy_pre_effect(').find((part) => part.includes(`$.set(${target},`));
assert.ok(effect);
for (const dependency of dependencies) assert.ok(effect.split('() => {')[0].includes(`$.get(${dependency})`), dependency);
}
assert.match(source, /selectedMemberId = sortedBoardMembers\[nextIndex\]\.id/);
});
test('조합 탐색은 정밀 분석 문구 없이 네 단계와 단계별 진행률을 구분한다', () => {
const source = readFileSync(new URL('../../routes/combination/+page.svelte', import.meta.url), 'utf8');
assert.match(source, /exactPass === 'refinement' \? '확장 후보 검증' : '선별 후보 평가'/);
assert.match(source, /searchPhase === 'screening' \? 1 : searchPhase === 'probability' \? 4 : exactPass === 'refinement' \? 3 : 2/);
assert.match(source, /현재 단계 \$\{searchStep\}\/4/);
assert.match(source, /class="search-step-badge"/);
assert.match(source, /현재 진행 단계/);
assert.doesNotMatch(source, /정밀 분석/);
});
+25
View File
@@ -0,0 +1,25 @@
type Member = { id: string; positionGroupId: string };
type Position = { groupId: string; x: number; y: number };
export function boardMemberOptions<T extends Member>(
members: readonly T[], models: Record<string, Position[]>,
activeBoards: Record<string, string[]>, sort: 'default' | 'most' | 'least'
): (T & { openedCount: number })[] {
const options = members.map((member) => {
const active = new Set(activeBoards[member.id] ?? []);
const valid = new Set((models[member.positionGroupId] ?? [])
.filter((node) => node.x !== 0 || node.y !== 0).map((node) => node.groupId));
return { ...member, openedCount: [...active].filter((id) => valid.has(id)).length };
});
return sort === 'default' ? options : options.sort((a, b) =>
sort === 'most' ? b.openedCount - a.openedCount : a.openedCount - b.openedCount);
}
export function unplacedOwnedCards<T extends { id: string }>(
cards: readonly T[], collection: Record<string, { owned: boolean }>,
placements: Record<string, Record<string, string>>, enabled: boolean
): readonly T[] {
if (!enabled) return cards;
const placed = new Set(Object.values(placements).flatMap((sockets) => Object.values(sockets)));
return cards.filter((card) => collection[card.id]?.owned && !placed.has(card.id));
}
+61 -19
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
import { assumeTeamBlueBoards, missingBoardOpenings, type BoardOpeningRequirement } from '$lib/engine/board-blue-assumption.ts';
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
@@ -35,6 +36,8 @@
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type ConnectionOptimizationProposal = {
assumedBlue: boolean;
openingRequirements: BoardOpeningRequirement[];
placements: BoardCards;
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
beforeExpected: number;
@@ -215,6 +218,7 @@
let errorMessage = '';
let analysisReady = false;
let connectionOptimizationRunning = false;
let assumeBlueForConnections = false;
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
@@ -605,8 +609,8 @@
};
}
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>, songBonusOverride = songScoreBonusOverride, boards = effectiveMemberBoards) {
const localBoardEffects = calculateBoardEffects(boardSummary, boards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, {
@@ -684,19 +688,28 @@
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
const assumedBlue = assumeBlueForConnections;
const optimizationInputKey = currentAnalysisKey;
const optimizationBoards = assumeTeamBlueBoards(boardSummary, effectiveMemberBoards, selectedSourceCards.map((card) => card.characterId), assumedBlue);
const openingRequirements = assumedBlue ? missingBoardOpenings(boardSummary, memberBoards, optimizationBoards) : [];
const scoreContext = (placements: BoardCards) => connectionScoreContext(placements, chartSummary, songScoreBonusOverride, optimizationBoards);
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
try {
const chart = await loadExactSongChart(chartSummary.path);
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
const beforeContext = scoreContext(boardCards);
const emptyContext = scoreContext({});
const baseline = assumedBlue ? optimizeExactOrderAndCooldowns(beforeContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(beforeContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: beforeContext.boardEffects.directSongScoreBonusPercent
}) : analysisResult;
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => baseline.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
orderedCards(context), baseline.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
@@ -715,7 +728,7 @@
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const context = scoreContext({ [memberId]: { [socketId]: card.id } });
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
@@ -725,7 +738,7 @@
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const active = new Set(optimizationBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
@@ -746,7 +759,7 @@
}
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
const optimized = optimizeBoardConnectionCards(boardSummary, optimizationBoards, candidateCards, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
preservePlacementOnTie: () => true,
@@ -754,13 +767,13 @@
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, optimizationBoards, candidateCards, optimized.placements, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
song: chartSummary,
songBonusEnabled: songScoreBonusOverride === null,
evaluate: (placements) => {
const context = connectionScoreContext(placements, chartSummary);
const context = scoreContext(placements);
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
},
onProgress: async (checks) => {
@@ -770,16 +783,16 @@
});
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const afterContext = scoreContext(optimized.placements);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
if (after.theoreticalMaxScore < baseline.theoreticalMaxScore) {
throw new Error(`동일 보드 조건의 기존 배치 최고점(${baseline.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
if (after.theoreticalMaxScore === baseline.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
const changedAssignments = boardMembers.flatMap((member) => {
@@ -794,12 +807,15 @@
cardId: afterPlacements[socketId] ?? null
}));
});
if (optimizationInputKey !== currentAnalysisKey) throw new Error('탐색 중 입력 조건이 변경되었습니다. 다시 분석한 뒤 연결 카드 최적화를 실행해 주세요.');
connectionOptimizationProposal = {
assumedBlue,
openingRequirements,
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
beforeExpected: baseline.expectedScore,
afterExpected: after.expectedScore,
beforeMaximum: analysisResult.theoreticalMaxScore,
beforeMaximum: baseline.theoreticalMaxScore,
afterMaximum: after.theoreticalMaxScore,
beforeActivationRateUp,
afterActivationRateUp
@@ -813,7 +829,7 @@
}
async function applyConnectionOptimization(): Promise<void> {
if (resultStale || !connectionOptimizationProposal) return;
if (resultStale || !connectionOptimizationProposal || connectionOptimizationProposal.openingRequirements.length > 0) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
@@ -1226,6 +1242,10 @@
</div>
</div>
<p>단, 활성 노란 홀로워크 노드를 증폭 중인 카드는 이론상 고점을 높이는 데 필요한 경우에만 이동합니다. 점수가 같으면 발동률 증가보다 해당 카드의 기존 배치를 우선 유지합니다.</p>
<label class="connection-assumption-option">
<input type="checkbox" bind:checked={assumeBlueForConnections} disabled={running || connectionOptimizationRunning} on:change={clearConnectionOptimizationProposal}>
<span><strong>파란 보드 개방 가정</strong><small>편성 5명의 전체 파란 노드와 파란 영역 연결 소켓을 임시 개방해 추천합니다. 다른 멤버·다른 색상 보드는 유지하며 저장된 보드는 변경하지 않습니다.</small></span>
</label>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
@@ -1235,6 +1255,21 @@
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
{#if connectionOptimizationProposal.assumedBlue}
<p class="connection-assumption-notice"><strong>개방 가정 결과</strong> · 아래 전후 점수는 모두 동일한 개방 가정 기준입니다. 현재 성장 대비 상승량이 아니라 카드 재배치 효과를 비교합니다.</p>
{#if connectionOptimizationProposal.openingRequirements.length > 0}
<div class="connection-opening-requirements">
<strong>적용 전 필요한 보드 개방</strong>
<p>아래 노드는 실제 저장 보드에서 미개방 상태입니다. 사용자 데이터에서 실제 개방 상태를 갱신한 뒤 다시 분석·최적화해 주세요. 지금은 추천만 확인할 수 있습니다.</p>
<ul>{#each connectionOptimizationProposal.openingRequirements as requirement}
<li><strong>{boardMembers.find((member) => member.id === requirement.memberId)?.name ?? requirement.memberId}</strong> · 능력치 노드 {requirement.nodeIds.length}개 · 연결 소켓 {requirement.socketIds.length}개
<details><summary>필요 노드 확인</summary><p>{requirement.nodeIds.join(', ')}{requirement.socketIds.length ? ` · 연결: ${requirement.socketIds.map((id) => `${connectionSocketName(requirement.memberId, id)} (${id})`).join(', ')}` : ''}</p></details>
</li>
{/each}</ul>
<a href="/data/boards" target="_blank" rel="noreferrer">사용자 보드 열기</a>
</div>
{/if}
{/if}
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
@@ -1249,7 +1284,7 @@
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
<button type="button" class="apply-connection" disabled={resultStale || running || connectionOptimizationProposal.openingRequirements.length > 0} on:click={applyConnectionOptimization}>{connectionOptimizationProposal.openingRequirements.length > 0 ? '필요 보드 개방 후 적용 가능' : '변경 배치를 적용하고 다시 분석'}</button>
{:else}
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
{/if}
@@ -1506,6 +1541,13 @@
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
.connection-assumption-option { display: flex; align-items: flex-start; gap: 10px; margin-top: 14px; padding: 12px; border: 1px solid #46574c; border-radius: 9px; }
.connection-assumption-option span { display: grid; gap: 5px; }
.connection-assumption-option small { color: #a9b8ad; line-height: 1.5; }
.connection-assumption-notice, .connection-opening-requirements { padding: 12px; border: 1px solid #967445; border-radius: 8px; background: #29251c; color: #eed6ac; }
.connection-opening-requirements li { margin: 8px 0; }
.connection-opening-requirements details p { overflow-wrap: anywhere; }
.connection-opening-requirements a { color: #a6ddba; }
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
+16 -2
View File
@@ -172,6 +172,7 @@
let activeSearchChart: ExactSongChart | null = null;
let primaryRankings: RankedResult[] = [];
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
$: searchStep = searchPhase === 'screening' ? 1 : searchPhase === 'probability' ? 4 : exactPass === 'refinement' ? 3 : 2;
let screeningProgress = 0;
let errorMessage = '';
let ready = false;
@@ -1379,7 +1380,14 @@
</label>
<p id="result-limit-guide" class="candidate-guide">다음 탐색부터 적용합니다. 결과 수를 늘리면 달성 확률 계산 시간이 늘어납니다. 분석한 조합이 적으면 선택한 수보다 적게 표시됩니다.</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)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
{#if running}
<div class="search-step-status" role="status" aria-live="polite">
<strong class="search-step-badge" aria-label={`현재 단계 ${searchStep}/4`}>{searchStep}<span>/4</span></strong>
<div><small>현재 진행 단계</small><strong>{['후보 선별', '선별 후보 평가', '확장 후보 검증', '달성 확률 계산'][searchStep - 1]}</strong></div>
</div>
<p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p>
{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${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>
@@ -1391,7 +1399,7 @@
</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}개 정밀 분석 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 후보 평가 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
{#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
@@ -1545,6 +1553,12 @@
.hint { margin: 13px 0 0; color: #858f9f; font-size: 13px; line-height: 1.6; }
.error { margin: 14px 0 0; color: #ff9f9f; font-size: 13px; }
.search { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.search-step-status { display: flex; align-items: center; gap: 14px; margin-top: 20px; padding: 14px 16px; border: 1px solid #b65c84; border-radius: 10px; background: #30212c; }
.search-step-badge { display: flex; align-items: baseline; gap: 3px; flex-shrink: 0; padding: 8px 12px; border-radius: 8px; background: #ed8bb5; color: #24131e; font-size: 26px; font-weight: 900; line-height: 1.2; font-variant-numeric: tabular-nums; }
.search-step-badge span { font-size: 16px; }
.search-step-status > div { display: grid; gap: 3px; }
.search-step-status small { color: #d6a1ba; font-size: 11px; font-weight: 700; }
.search-step-status > div > strong { color: #ffe4f0; font-size: 16px; }
.search:hover:not(:disabled) { background: #f2a0c2; }
.search:disabled { opacity: .55; cursor: default; }
.owned-cards-option, .compute-option { display: flex; align-items: center; gap: 9px; width: fit-content; margin-top: 12px; cursor: pointer; }
+23 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { boardMemberOptions, unplacedOwnedCards } from '$lib/engine/board-editor-options.ts';
import { onMount, tick } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
@@ -113,6 +114,8 @@
let boardDragStart = { x: 0, y: 0, left: 0, top: 0 };
let selectedSocketGroupId = '';
let boardCardSearch = '';
let boardMemberSort: 'default' | 'most' | 'least' = 'default';
let unplacedOwnedOnly = false;
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let automaticPlacementResult: AutomaticPlacementResult[] = [];
let savedAnalysis: SavedAnalysisState | null = null;
@@ -136,6 +139,7 @@
$: filteredFiveStarCards = filteredCards.filter((card) => card.rarity === 5);
$: filteredFourStarCards = filteredCards.filter((card) => card.rarity === 4);
$: selectedBoardMember = boardMembers.find((member) => member.id === selectedMemberId) ?? null;
$: sortedBoardMembers = boardMemberOptions(boardMembers, boardModels, userData.memberBoards, boardMemberSort);
$: selectedActiveBoardNodes = userData.memberBoards[selectedMemberId] ?? [];
$: selectedActiveBoardNodeCount = new Set(selectedActiveBoardNodes.filter((nodeId) => nodeId !== centerNodeId())).size;
$: selectedBoardNodeCount = selectedBoardMember
@@ -148,7 +152,7 @@
selectedActiveBoardNodes,
selectedBoardMember?.positionGroupId ?? ''
);
$: filteredConnectCards = connectCards.filter((card) => {
$: filteredConnectCards = unplacedOwnedCards(connectCards, userData.collection, userData.boardCards, unplacedOwnedOnly).filter((card) => {
return cardMatchesSearch(card, boardCardSearch);
});
$: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards);
@@ -381,10 +385,10 @@
}
function selectAdjacentBoardMember(offset: -1 | 1) {
if (boardMembers.length < 2) return;
const currentIndex = boardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + boardMembers.length) % boardMembers.length;
selectedMemberId = boardMembers[nextIndex].id;
if (sortedBoardMembers.length < 2) return;
const currentIndex = sortedBoardMembers.findIndex((member) => member.id === selectedMemberId);
const nextIndex = (Math.max(0, currentIndex) + offset + sortedBoardMembers.length) % sortedBoardMembers.length;
selectedMemberId = sortedBoardMembers[nextIndex].id;
finishBoardMemberChange();
}
@@ -1400,11 +1404,19 @@
<label class="member-select">
<span>멤버 선택</span>
<select bind:value={selectedMemberId} on:change={finishBoardMemberChange}>
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
{#each sortedBoardMembers as member}<option value={member.id}>{member.name} · {member.openedCount}개 개방</option>{/each}
</select>
<small><kbd>A</kbd> 이전 멤버 · <kbd>D</kbd> 다음 멤버</small>
</label>
<div class="member-navigation" aria-label="멤버 보드 이동">
<label class="member-select">
<span>멤버 정렬</span>
<select bind:value={boardMemberSort}>
<option value="default">기본순</option>
<option value="most">보드 개방 많은 순</option>
<option value="least">보드 개방 적은 순</option>
</select>
</label>
<button type="button" on:click={() => selectAdjacentBoardMember(-1)} aria-label="이전 멤버 보드">‹ 이전</button>
<button type="button" on:click={() => selectAdjacentBoardMember(1)} aria-label="다음 멤버 보드">다음 ›</button>
</div>
@@ -1502,11 +1514,13 @@
</div>
<p class="card-picker-note">배치한 카드의 범위에 포함된 활성 노드만 강화됩니다. 같은 카드를 다시 선택하면 기존 위치에서 이곳으로 이동합니다.</p>
<input class="card-picker-search" type="search" bind:value={boardCardSearch} placeholder="멤버 또는 버전 검색" aria-label="연결 카드 검색" />
<label class="card-picker-filter"><input type="checkbox" bind:checked={unplacedOwnedOnly} /> 미배치 보유 카드만</label>
{#if selectedBoardCardPlacements[selectedSocketGroupId]}
{@const currentCard = connectCardById.get(selectedBoardCardPlacements[selectedSocketGroupId])}
<div class="current-placement"><span>현재 배치</span><strong>{currentCard ? cardLabel(currentCard) : selectedBoardCardPlacements[selectedSocketGroupId]}</strong><button type="button" on:click={removeBoardCard}>배치 해제</button></div>
{/if}
<div class="connect-card-list">
{#if filteredConnectCards.length === 0}<p class="card-picker-note">조건에 맞는 연결 카드가 없습니다.</p>{/if}
{#each filteredConnectCards as card}
{@const effect = selectedConnectEffect(card)}
{@const placement = cardPlacementLabel(card.id)}
@@ -1645,13 +1659,14 @@
.board-member-settings { order: 1; margin-bottom: 0; }
.board-tools { order: 0; }
.board-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
.card-picker-filter { display: flex; align-items: center; gap: 8px; margin: 12px 0; color: #c9d0dc; font-size: 13px; }
.board-guidance { margin: 0 0 12px; padding: 12px 14px; border: 1px solid #4a3b46; border-radius: 10px; color: #c8b7c1; background: rgb(239 139 182 / 7%); font-size: 13px; line-height: 1.6; }
.member-select { display: grid; gap: 5px; width: min(360px, 100%); }
.member-select span { color: #818b9c; font-size: 12px; font-weight: 750; }
.member-select select { height: 38px; padding: 0 10px; border: 1px solid #394252; border-radius: 8px; color: #dfe3ea; background: #12171f; }
.member-select small { color: #6f7888; font-size: 11px; }
.member-select kbd { display: inline-grid; min-width: 18px; height: 18px; padding: 0 4px; place-items: center; border: 1px solid #465064; border-bottom-width: 2px; border-radius: 4px; color: #c9d0dc; background: #171d27; font: 700 10px/1 Pretendard, "Noto Sans KR", sans-serif; }
.member-navigation { display: flex; gap: 7px; }
.member-navigation { display: flex; align-items: flex-end; gap: 7px; }
.member-navigation button { min-width: 82px; height: 38px; padding: 0 12px; border: 1px solid #465064; border-radius: 8px; color: #d5dbe4; background: #202733; font-size: 13px; font-weight: 850; cursor: pointer; }
.member-navigation button:hover { border-color: #d96b9a; color: #f4b0cc; }
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 20px 0 10px; }
@@ -1852,6 +1867,7 @@
.board-heading { align-items: stretch; flex-direction: column; }
.member-select { width: 100%; }
.member-navigation { display: grid; grid-template-columns: 1fr 1fr; }
.member-navigation .member-select { grid-column: 1 / -1; }
.member-navigation button { width: 100%; min-height: 44px; }
.board-toolbar { align-items: flex-start; gap: 10px; }
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }