feat: add green board efficiency analysis

This commit is contained in:
2026-08-18 18:44:05 +09:00
parent ab23508645
commit 2aefc14547
3 changed files with 581 additions and 18 deletions
+31
View File
@@ -87,3 +87,34 @@ test('조합 탐색용 기수 보너스와 악곡 보너스는 실제 노드 합
assert.equal(zeroOverride.flatByCardId['card-a'].performance, 0);
assert.equal(customOverride.flatByCardId['card-a'].performance, 450);
});
test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적용한다', () => {
const cappedSummary: BoardSummarySource = {
nodeGroups: {
'G-10': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 600, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0' } }],
'G-11': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 500, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0' } }]
}
};
const result = calculateBoardEffects(cappedSummary, { account: ['G-10', 'G-11'] }, cards, 'a');
assert.equal(result.flatByCardId['card-a'].performance, 900);
assert.equal(result.flatByCardId['card-a'].technique, 900);
assert.equal(result.flatByCardId['card-a'].sense, 900);
assert.equal(result.flatByCardId['card-b'].performance, 0);
});
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = {
nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }],
'G-21': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gamers' } }],
'G-22': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 100, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }]
}
};
const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }];
const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800);
});
+17 -4
View File
@@ -40,6 +40,7 @@ export type BoardEffectsResult = {
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
const generationBonusCap = 900;
const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 });
const groupingLabels: Record<string, string> = {
'grp-gen_0': '0기생', 'grp-gen_1': '1기생', 'grp-gen_2': '2기생', 'grp-gen_3': '3기생',
@@ -140,6 +141,7 @@ export function calculateBoardEffects(
let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) {
@@ -172,9 +174,19 @@ export function calculateBoardEffects(
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards).filter((card) =>
effect.type !== 'all_parameter_up_for_character_grouping' || overrides?.generationBonusByCardId?.[card.id] === undefined
);
const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {};
const currentBonus = cardBonuses[groupingId] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
cardBonuses[groupingId] = currentBonus + appliedValue;
for (const stat of stats) destination[card.id][stat] += appliedValue;
}
continue;
}
for (const card of effectTargets) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
}
@@ -183,7 +195,8 @@ export function calculateBoardEffects(
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
if (!flatByCardId[cardId]) continue;
for (const stat of stats) flatByCardId[cardId][stat] += value;
const cappedValue = Math.max(0, Math.min(generationBonusCap, value));
for (const stat of stats) flatByCardId[cardId][stat] += cappedValue;
}
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
+533 -14
View File
@@ -4,21 +4,14 @@
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 { maximumWeightMatching } from '$lib/engine/maximum-weight-matching.ts';
import { calculateBoardEffects, calculateUnitPower, maximumWeightMatching, plainSkillText } from '$lib/engine/index.ts';
import { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage';
type ConnectEffect = { level: number; description: string; effectPermilUp: number; extent: { x: number; y: number }[] };
type CardSummary = {
id: string;
rarity: number;
name: string;
versionName: string;
generation: string;
details: { connect: ConnectEffect[] };
};
type CardSummary = (typeof cardSummary.cards)[number];
type BoardMember = { id: string; name: string; order: number; positionGroupId: string };
type BoardPosition = { groupId: string; x: number; y: number };
type BoardNode = {
@@ -27,12 +20,49 @@
grade: number;
type: string;
effect: { id: string; description: string; type: string; value: number | null; trigger?: string | null } | null;
skillTreePointCost: number;
consumptions: { resourceType: string; resourceId: string; quantity: string }[];
};
type BoardEdge = { from: BoardPosition; to: BoardPosition };
const allCards = cardSummary.cards as CardSummary[];
type SavedAnalysisState = {
schemaVersion: 2;
cardIds: string[];
awakenings: number[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
type EfficiencyFormationState = {
cardIds: string[];
awakenings: number[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
type GreenNodeEfficiency = {
memberId: string;
memberName: string;
groupId: string;
description: string;
changedNodeCount: number;
powerDelta: number;
cost: GreenNodeCost;
greenCubeEfficiency: number;
greenCoreCubeEfficiency: number;
};
type GreenEfficiencyResult = {
basePower: number;
upgrades: GreenNodeEfficiency[];
removals: GreenNodeEfficiency[];
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const EFFICIENCY_FORMATION_STORAGE_KEY = 'holodori.green-efficiency-formation.v1';
const GREEN_CUBE_ID = 'item-skill_tree_allmember-1';
const GREEN_CORE_CUBE_ID = 'item-skill_tree_allmember-2';
const allCards = cardSummary.cards;
const boardMembers = boardSummary.members as BoardMember[];
const memberOrderByName = new Map(boardMembers.map((member) => [member.name, member.order]));
const fiveStarCards = allCards.filter((card) => card.rarity === 5);
const leaderMembers = fiveStarCards.filter((card, index, cards) => cards.findIndex((candidate) => candidate.characterId === card.characterId) === index);
const managedCards = allCards
.filter((card) => card.rarity === 5 || card.rarity === 4)
.sort((left, right) =>
@@ -40,6 +70,7 @@
|| right.rarity - left.rarity
);
const connectCards = allCards.filter((card) => card.details.connect.length > 0);
const cardById = new Map(allCards.map((card) => [card.id, card]));
const connectCardById = new Map(connectCards.map((card) => [card.id, card]));
const boardModels = boardSummary.models as Record<string, BoardPosition[]>;
const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>;
@@ -69,6 +100,18 @@
let selectedSocketGroupId = '';
let boardCardSearch = '';
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let savedAnalysis: SavedAnalysisState | null = null;
let efficiencyFormation: EfficiencyFormationState = {
cardIds: ['', '', '', '', ''],
awakenings: [0, 0, 0, 0, 0],
leaderCharacterId: '',
leaderOutfitCardId: ''
};
let greenEfficiencyResult: GreenEfficiencyResult | null = null;
let greenEfficiencyRunning = false;
let greenEfficiencyProgress = { completed: 0, total: 0 };
let greenEfficiencyError = '';
let efficiencyFocusGroupId = '';
$: collectionCount = Object.values(userData.collection).filter((state) => state.owned).length;
$: boardCount = Object.values(userData.memberBoards).filter((nodes) => nodes.length > 0).length;
@@ -90,6 +133,13 @@
const query = boardCardSearch.trim().toLowerCase();
return !query || `${card.name} ${card.versionName}`.toLowerCase().includes(query);
});
$: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId
? fiveStarCards.filter((card) => card.characterId === efficiencyFormation.leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
: [];
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
&& efficiencyFormation.cardIds.every((id) => cardById.has(id))
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5;
$: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
onMount(() => {
try {
@@ -97,6 +147,28 @@
} catch {
showMessage('저장된 데이터를 읽을 수 없습니다. 백업 파일이 있다면 가져와 주세요.', true);
}
try {
const parsed = JSON.parse(localStorage.getItem(ANALYSIS_STORAGE_KEY) ?? 'null') as SavedAnalysisState | null;
if (parsed?.schemaVersion === 2 && parsed.cardIds.length === 5 && parsed.awakenings.length === 5
&& parsed.cardIds.every((id) => allCards.some((card) => card.id === id))) {
savedAnalysis = parsed;
}
} catch {
savedAnalysis = null;
}
try {
const stored = JSON.parse(localStorage.getItem(EFFICIENCY_FORMATION_STORAGE_KEY) ?? 'null') as EfficiencyFormationState | null;
if (stored && stored.cardIds?.length === 5 && stored.awakenings?.length === 5) {
efficiencyFormation = {
cardIds: stored.cardIds.map((id) => cardById.has(id) ? id : ''),
awakenings: stored.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: leaderMembers.some((member) => member.characterId === stored.leaderCharacterId) ? stored.leaderCharacterId : '',
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : ''
};
}
} catch {
// 잘못된 보조 분석 입력은 빈 상태로 다시 시작한다.
}
});
function showMessage(text: string, error = false) {
@@ -104,6 +176,62 @@
isError = error;
}
function formatEfficiency(value: number): string {
if (!Number.isFinite(value)) return '소모 없음';
return value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
}
function normalizeEfficiencyAwakening(value: number): number {
return Math.max(0, Math.min(5, Math.round(Number.isFinite(value) ? value : 0)));
}
function saveEfficiencyFormation(next: EfficiencyFormationState) {
efficiencyFormation = next;
localStorage.setItem(EFFICIENCY_FORMATION_STORAGE_KEY, JSON.stringify(next));
greenEfficiencyResult = null;
greenEfficiencyError = '';
}
function efficiencyMemberSelectedInAnotherSlot(card: CardSummary, slot: number): boolean {
return efficiencyFormation.cardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateEfficiencyCard(slot: number, cardId: string) {
const cardIds = efficiencyFormation.cardIds.map((id, index) => index === slot ? cardId : id);
const awakenings = efficiencyFormation.awakenings.map((value, index) => index === slot
? normalizeEfficiencyAwakening(userData.collection[cardId]?.awakening ?? 0)
: value);
saveEfficiencyFormation({ ...efficiencyFormation, cardIds, awakenings });
}
function updateEfficiencyAwakening(slot: number, value: number) {
const awakenings = efficiencyFormation.awakenings.map((awakening, index) => index === slot ? normalizeEfficiencyAwakening(value) : awakening);
saveEfficiencyFormation({ ...efficiencyFormation, awakenings });
}
function updateEfficiencyLeader(characterId: string) {
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
saveEfficiencyFormation({
...efficiencyFormation,
leaderCharacterId: characterId,
leaderOutfitCardId: outfit?.id ?? ''
});
}
function updateEfficiencyOutfit(cardId: string) {
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
}
function importAnalysisFormation() {
if (!savedAnalysis) return;
saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId
});
}
function initializeBoardViewport(node: HTMLDivElement) {
boardViewport = node;
requestAnimationFrame(() => {
@@ -176,6 +304,8 @@
function persist(next = userData) {
userData = saveBrowserData(next);
greenEfficiencyResult = null;
greenEfficiencyError = '';
showMessage('변경 내용을 이 브라우저에 저장했습니다.');
}
@@ -269,6 +399,23 @@
return reached;
}
function reachableNodesForPositions(active: Set<string>, positions: BoardPosition[], center: string): Set<string> {
const byId = new Map(positions.map((position) => [position.groupId, position]));
const reached = new Set(center ? [center] : []);
const queue = center ? [center] : [];
while (queue.length > 0) {
const current = byId.get(queue.shift()!);
if (!current) continue;
for (const candidate of positions) {
if (!reached.has(candidate.groupId) && active.has(candidate.groupId) && areAdjacent(current, candidate)) {
reached.add(candidate.groupId);
queue.push(candidate.groupId);
}
}
}
return reached;
}
function shortestPathToActive(groupId: string): string[] {
const positions = currentBoardPositions();
return shortestPathToSet(groupId, new Set([centerNodeId(), ...activeBoardNodes()]), positions);
@@ -362,6 +509,173 @@
persist({ ...userData, memberBoards });
}
function greenNodeCost(memberId: string, groupIds: readonly string[]): GreenNodeCost {
return groupIds.reduce<GreenNodeCost>((total, groupId) => {
const node = nodeForBoardMember(memberId, groupId);
if (!node) return total;
total.points += node.skillTreePointCost ?? 0;
for (const consumption of node.consumptions ?? []) {
const quantity = Number(consumption.quantity) || 0;
if (consumption.resourceId === GREEN_CUBE_ID) total.greenCubes += quantity;
if (consumption.resourceId === GREEN_CORE_CUBE_ID) total.greenCoreCubes += quantity;
}
return total;
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
}
function currentFormationPower(memberBoards: Record<string, string[]>): number {
if (!efficiencyFormationReady) return 0;
const members = efficiencyFormation.cardIds.map((id, index) => ({
card: cardById.get(id)!,
awakening: efficiencyFormation.awakenings[index]
}));
if (members.some(({ card }) => !card)) return 0;
const sourceCards = members.map(({ card }) => card);
const connections = connectCards.map((card) => ({
id: card.id,
awakening: userData.collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, {
placements: userData.boardCards,
cards: connections
});
const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null;
return calculateUnitPower(members, leaderOutfit, boardEffects, memoryPermil, userData.memberPowerUpPercent).overallPower;
}
function efficiencyEntry(
member: BoardMember,
groupId: string,
changedGroupIds: string[],
powerDelta: number
): GreenNodeEfficiency {
const cost = greenNodeCost(member.id, changedGroupIds);
const node = nodeForBoardMember(member.id, groupId);
return {
memberId: member.id,
memberName: member.name,
groupId,
description: boardEffectDescription(node?.effect ?? null, member.name) || boardNodeLabel(node?.type ?? '노드'),
changedNodeCount: changedGroupIds.length,
powerDelta,
cost,
greenCubeEfficiency: cost.greenCubes > 0 ? powerDelta / cost.greenCubes : Number.POSITIVE_INFINITY,
greenCoreCubeEfficiency: cost.greenCoreCubes > 0 ? powerDelta / cost.greenCoreCubes : Number.POSITIVE_INFINITY
};
}
function compareUpgradeEfficiency(left: GreenNodeEfficiency, right: GreenNodeEfficiency): number {
return right.greenCubeEfficiency - left.greenCubeEfficiency
|| right.greenCoreCubeEfficiency - left.greenCoreCubeEfficiency
|| right.powerDelta - left.powerDelta
|| left.cost.greenCubes - right.cost.greenCubes;
}
function compareRemovalEfficiency(left: GreenNodeEfficiency, right: GreenNodeEfficiency): number {
return left.greenCubeEfficiency - right.greenCubeEfficiency
|| left.greenCoreCubeEfficiency - right.greenCoreCubeEfficiency
|| left.powerDelta - right.powerDelta
|| right.cost.greenCubes - left.cost.greenCubes;
}
function upgradeBeatsRemoval(upgrade: GreenNodeEfficiency, removal: GreenNodeEfficiency): boolean {
const cubeDifference = upgrade.greenCubeEfficiency - removal.greenCubeEfficiency;
if (Math.abs(cubeDifference) > 1e-9) return cubeDifference > 0;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes > 0) return true;
if (upgrade.cost.greenCoreCubes > 0 && removal.cost.greenCoreCubes === 0) return false;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes === 0) return false;
return upgrade.greenCoreCubeEfficiency > removal.greenCoreCubeEfficiency;
}
async function analyzeGreenNodeEfficiency() {
if (!efficiencyFormationReady) {
greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.';
return;
}
greenEfficiencyRunning = true;
greenEfficiencyProgress = {
completed: 0,
total: boardMembers.reduce((total, member) => total + (boardModels[member.positionGroupId] ?? [])
.filter((position) => boardColor(position.groupId) === 'green').length, 0)
};
greenEfficiencyError = '';
greenEfficiencyResult = null;
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
try {
const basePower = currentFormationPower(userData.memberBoards);
const upgrades: GreenNodeEfficiency[] = [];
const removals: GreenNodeEfficiency[] = [];
for (const member of boardMembers) {
const positions = boardModels[member.positionGroupId] ?? [];
const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId ?? '';
const currentNodes = userData.memberBoards[member.id] ?? [];
const active = new Set([center, ...currentNodes].filter(Boolean));
for (const position of positions.filter((item) => boardColor(item.groupId) === 'green')) {
greenEfficiencyProgress = { ...greenEfficiencyProgress, completed: greenEfficiencyProgress.completed + 1 };
if (greenEfficiencyProgress.completed % 24 === 0) {
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
if (!active.has(position.groupId)) {
const path = shortestPathToSet(position.groupId, active, positions);
const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
if (added.length === 0) continue;
const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] };
const nextPower = currentFormationPower(nextBoards);
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
const intermediateBoards = intermediateNodes.length > 0
? { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...intermediateNodes])] }
: userData.memberBoards;
const targetPowerDelta = nextPower - currentFormationPower(intermediateBoards);
if (targetPowerDelta <= 0) continue;
const powerDelta = nextPower - basePower;
if (powerDelta > 0) upgrades.push(efficiencyEntry(member, position.groupId, added, powerDelta));
continue;
}
const remaining = new Set(active);
remaining.delete(position.groupId);
const reachable = reachableNodesForPositions(remaining, positions, center);
const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
if (removed.length === 0) continue;
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
const nextBoards = { ...userData.memberBoards };
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
else delete nextBoards[member.id];
const powerDelta = basePower - currentFormationPower(nextBoards);
removals.push(efficiencyEntry(member, position.groupId, removed, Math.max(0, powerDelta)));
}
}
const sortedRemovals = removals.sort(compareRemovalEfficiency);
const removalThreshold = sortedRemovals[0];
greenEfficiencyResult = {
basePower,
upgrades: upgrades.sort(compareUpgradeEfficiency)
.filter((upgrade) => !removalThreshold || upgradeBeatsRemoval(upgrade, removalThreshold)),
removals: sortedRemovals
};
} catch (error) {
greenEfficiencyError = error instanceof Error ? error.message : '초록 노드 효율을 계산하지 못했습니다.';
} finally {
greenEfficiencyProgress = { ...greenEfficiencyProgress, completed: greenEfficiencyProgress.total };
greenEfficiencyRunning = false;
}
}
async function focusEfficiencyNode(entry: GreenNodeEfficiency) {
selectedMemberId = entry.memberId;
selectedSocketGroupId = '';
efficiencyFocusGroupId = entry.groupId;
await fitBoardViewport();
boardViewport?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function setAllBoardNodes(active: boolean) {
const member = boardMembers.find((item) => item.id === selectedMemberId);
if (!member) return;
@@ -375,6 +689,16 @@
return ({ card: '카드', all_member: '공통', leader: '리더', connection: '연결', content: '콘텐츠' } as Record<string, string>)[type] ?? type;
}
function boardEffectDescription(effect: BoardNode['effect'], memberName = '해당 멤버'): string {
if (!effect) return '';
const formatValue = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
return effect.description
.replaceAll('[value/10]', effect.value === null ? '-' : formatValue(effect.value / 10))
.replaceAll('[value]', effect.value === null ? '-' : formatValue(effect.value))
.replaceAll('[character]', memberName)
.trim();
}
function boardColor(groupId: string): string {
return ({ R: 'red', G: 'green', B: 'blue', Y: 'yellow', S: 'center' } as Record<string, string>)[groupId[0]] ?? 'center';
}
@@ -773,7 +1097,8 @@
{/if}
{#if dataView === 'boards'}
<section class="board-editor" aria-label="멤버 보드 설정">
<div class="boards-layout">
<section class="board-editor board-tools" aria-label="보드 공통 설정">
<aside class="board-guidance">
정확한 분석을 위해 리더, 편성 멤버, 악곡의 가창자 보너스와 관련된 멤버 보드는 직접 설정하는 것을 권장합니다.
</aside>
@@ -788,10 +1113,125 @@
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div>
</div>
</section>
<details class="green-efficiency">
<summary>
<span><strong>초록 노드 효율 분석</strong><small>현재 편성에서 활성화하거나 초기화할 초록 노드의 재료 효율을 비교합니다.</small></span>
</summary>
<div class="green-efficiency-heading">
<div>
<strong>현재 편성 초록 노드 효율</strong>
<small>아래에서 직접 설정한 편성의 실제 종합력 변화로 비교합니다. 그린 큐브 효율을 우선하고, 그린 코어 큐브 효율을 후순위로 적용합니다.</small>
</div>
<div class="green-efficiency-actions">
<button type="button" disabled={!savedAnalysis || greenEfficiencyRunning} on:click={importAnalysisFormation}>배치 분석 편성 불러오기</button>
<button type="button" class="primary" disabled={!efficiencyFormationReady || greenEfficiencyRunning} on:click={analyzeGreenNodeEfficiency}>
{greenEfficiencyRunning ? '계산 중…' : greenEfficiencyResult ? '다시 계산' : '효율 계산'}
</button>
</div>
</div>
{#if greenEfficiencyRunning}
<div class="green-efficiency-progress" role="status" aria-live="polite">
<progress value={greenEfficiencyProgress.completed} max={Math.max(1, greenEfficiencyProgress.total)}></progress>
<strong>{Math.round(greenEfficiencyProgress.completed / Math.max(1, greenEfficiencyProgress.total) * 100)}%</strong>
</div>
{/if}
<div class="efficiency-conditions" aria-label="효율 계산 편성 설정">
<div class="efficiency-card-grid">
{#each efficiencyFormation.cardIds as selectedId, slot}
<div class="efficiency-card-field">
<label><span>{slot + 1}번 카드</span>
<select value={selectedId} on:change={(event) => updateEfficiencyCard(slot, event.currentTarget.value)}>
<option value="">선택 안 함</option>
{#each fiveStarCards as card}
<option value={card.id} disabled={efficiencyMemberSelectedInAnotherSlot(card, slot)}>{userData.collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each}
</select>
</label>
<div class="efficiency-awakening">
<span>개화</span>
<div class="awakening-stepper">
<button type="button" aria-label={`${slot + 1}번 카드 개화 단계 낮추기`} disabled={!selectedId || efficiencyFormation.awakenings[slot] <= 0} on:click={() => updateEfficiencyAwakening(slot, efficiencyFormation.awakenings[slot] - 1)}>‹</button>
<strong>{efficiencyFormation.awakenings[slot]}</strong>
<button type="button" aria-label={`${slot + 1}번 카드 개화 단계 높이기`} disabled={!selectedId || efficiencyFormation.awakenings[slot] >= 5} on:click={() => updateEfficiencyAwakening(slot, efficiencyFormation.awakenings[slot] + 1)}>›</button>
</div>
</div>
</div>
{/each}
</div>
<div class="efficiency-leader-fields">
<label><span>리더</span>
<select value={efficiencyFormation.leaderCharacterId} on:change={(event) => updateEfficiencyLeader(event.currentTarget.value)}>
<option value="">선택 안 함</option>
{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}
</select>
</label>
<label><span>리더 의상</span>
<select value={efficiencyFormation.leaderOutfitCardId} disabled={!efficiencyFormation.leaderCharacterId} on:change={(event) => updateEfficiencyOutfit(event.currentTarget.value)}>
<option value="">선택 안 함</option>
{#each efficiencyLeaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}
</select>
</label>
</div>
<div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div>
</div>
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
{#if greenEfficiencyResult}
<div class="efficiency-summary">
<span>현재 종합력</span><strong>{greenEfficiencyResult.basePower.toLocaleString('ko-KR')}</strong>
<span>활성화 후보</span><strong>{greenEfficiencyResult.upgrades.length}개</strong>
<span>초기화 후보</span><strong>{greenEfficiencyResult.removals.length}개</strong>
</div>
<p class="efficiency-focus-note">두 목록의 노드 행을 선택하면 해당 멤버의 보드로 이동해 노드 위치를 바로 보여줍니다.</p>
<div class="efficiency-columns">
<section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title">
<div class="efficiency-column-heading">
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 검토 1순위보다 효율이 높은 노드만 표시</p></div>
</div>
{#if greenEfficiencyResult.upgrades.length > 0}
<div class="efficiency-list">
<div class="efficiency-table-head" aria-hidden="true"><span></span><span>노드</span><span>종합력 변화</span><span>필요 노드 수</span><span>자원 소모량<br />(큐브 / 코어)</span><span>큐브 효율</span></div>
{#each greenEfficiencyResult.upgrades.slice(0, 20) as entry, index}
<button type="button" on:click={() => focusEfficiencyNode(entry)}>
<span class="efficiency-rank">{index + 1}</span>
<span class="efficiency-node"><small>{entry.memberName}</small><strong>{entry.description}</strong></span>
<span class="efficiency-power"><strong>+{entry.powerDelta.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-node-count"><strong>{entry.changedNodeCount}</strong></span>
<span class="efficiency-cost"><strong>{entry.cost.greenCubes.toLocaleString('ko-KR')} / {entry.cost.greenCoreCubes.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-rate"><strong>{formatEfficiency(entry.greenCubeEfficiency)}</strong></span>
</button>
{/each}
</div>
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if}
</section>
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
<div class="efficiency-column-heading">
<div><h3 id="removal-efficiency-title">초기화 검토</h3><p>환급 큐브 대비 종합력 손실이 낮은 순서</p></div>
</div>
{#if greenEfficiencyResult.removals.length > 0}
<div class="efficiency-list">
<div class="efficiency-table-head" aria-hidden="true"><span></span><span>노드</span><span>종합력 변화</span><span>필요 노드 수</span><span>자원 소모량<br />(큐브 / 코어)</span><span>큐브 효율</span></div>
{#each greenEfficiencyResult.removals.slice(0, 20) as entry, index}
<button type="button" on:click={() => focusEfficiencyNode(entry)}>
<span class="efficiency-rank">{index + 1}</span>
<span class="efficiency-node"><small>{entry.memberName}</small><strong>{entry.description}</strong></span>
<span class="efficiency-power"><strong>-{entry.powerDelta.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-node-count"><strong>{entry.changedNodeCount}</strong></span>
<span class="efficiency-cost"><strong>{entry.cost.greenCubes.toLocaleString('ko-KR')} / {entry.cost.greenCoreCubes.toLocaleString('ko-KR')}</strong></span>
<span class="efficiency-rate"><strong>{formatEfficiency(entry.greenCubeEfficiency)}</strong></span>
</button>
{/each}
</div>
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if}
</section>
</div>
{/if}
</details>
<section class="board-editor board-member-settings" aria-label="멤버별 보드 설정">
<div class="board-heading">
<label class="member-select">
<span>멤버 선택</span>
<select bind:value={selectedMemberId} on:change={() => { selectedSocketGroupId = ''; fitBoardViewport(); }}>
<select bind:value={selectedMemberId} on:change={() => { selectedSocketGroupId = ''; efficiencyFocusGroupId = ''; fitBoardViewport(); }}>
{#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
</select>
</label>
@@ -838,8 +1278,9 @@
class:inactive={!active}
class:connect-affected={Boolean(selectedConnectionBoosts[position.groupId])}
class:connect-applied={active && Boolean(selectedConnectionBoosts[position.groupId])}
class:efficiency-focus={position.groupId === efficiencyFocusGroupId}
style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`}
title={`${boardNodeLabel(node.type)} · ${node.effect?.description || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`}
title={`${boardNodeLabel(node.type)} · ${boardEffectDescription(node.effect, selectedBoardMember.name) || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`}
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
aria-pressed={active}
on:click={() => handleBoardNodeClick(position.groupId, node)}
@@ -904,6 +1345,7 @@
{/if}
{/if}
</section>
</div>
{/if}
{#if dataView === 'home'}
@@ -1002,7 +1444,10 @@
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #515a68; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e4e8ee; font-size: 13px; text-align: center; }
.board-editor { margin-bottom: 18px; padding: 26px; border: 1px solid #303746; border-radius: 18px; background: #1a202a; }
.boards-layout { display: flex; flex-direction: column; }
.board-editor { display: flex; flex-direction: column; margin-bottom: 14px; padding: 26px; border: 1px solid #303746; border-radius: 18px; background: #1a202a; }
.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; }
.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%); }
@@ -1023,6 +1468,71 @@
.automatic-placement button.undo { color: #c4ccd5; background: transparent; }
.automatic-placement button:hover { filter: brightness(1.1); }
.automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; }
.green-efficiency { order: 2; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
.green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
.green-efficiency > summary::-webkit-details-marker { display: none; }
.green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); }
.green-efficiency > summary::after { content: '⌄'; flex: 0 0 auto; margin-left: auto; color: #9db0a4; font-size: 24px; font-weight: 850; line-height: 1; transition: transform .18s ease; }
.green-efficiency[open] > summary { border-bottom: 1px solid #2d3843; }
.green-efficiency[open] > summary::after { transform: rotate(180deg); }
.green-efficiency > summary > span { display: grid; gap: 4px; min-width: 0; }
.green-efficiency > summary strong { font-size: 16px; }
.green-efficiency > summary small { color: #8b96a5; font-size: 14px; line-height: 1.45; }
.green-efficiency-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 16px; }
.green-efficiency-heading > div { display: grid; gap: 5px; min-width: 0; }
.green-efficiency-heading strong { color: #dce6e0; font-size: 17px; }
.green-efficiency-heading small { color: #949ead; font-size: 14px; line-height: 1.55; }
.green-efficiency-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.green-efficiency-heading button { min-height: 40px; padding: 8px 13px; border: 1px solid #43505e; border-radius: 8px; color: #b7c1cd; background: #222a34; font-size: 14px; font-weight: 850; cursor: pointer; }
.green-efficiency-heading button.primary { border-color: #62aa7e; color: #101913; background: #78c493; }
.green-efficiency-heading button:disabled { border-color: #3a4540; color: #718078; background: #232b27; cursor: default; }
.green-efficiency-progress { display: grid; grid-template-columns: minmax(0, 1fr) 48px; align-items: center; gap: 12px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.green-efficiency-progress progress { width: 100%; height: 9px; overflow: hidden; border: 0; border-radius: 999px; color: #78c493; background: #27322c; appearance: none; }
.green-efficiency-progress progress::-webkit-progress-bar { border-radius: 999px; background: #27322c; }
.green-efficiency-progress progress::-webkit-progress-value { border-radius: 999px; background: #78c493; }
.green-efficiency-progress progress::-moz-progress-bar { border-radius: 999px; background: #78c493; }
.green-efficiency-progress strong { color: #9dd8b2; font-size: 14px; text-align: right; }
.efficiency-conditions { margin-top: 14px; padding: 13px; border: 1px solid #2d3743; border-radius: 10px; background: #10161d; }
.efficiency-card-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
.efficiency-card-field { display: grid; gap: 8px; min-width: 0; padding: 9px; border: 1px solid #2c3541; border-radius: 9px; background: #171e26; }
.efficiency-card-field > label, .efficiency-leader-fields label { display: grid; gap: 5px; min-width: 0; }
.efficiency-card-field label > span, .efficiency-awakening > span, .efficiency-leader-fields label > span { color: #929cab; font-size: 13px; font-weight: 800; }
.efficiency-card-field select, .efficiency-leader-fields select { width: 100%; height: 40px; min-width: 0; padding: 0 9px; border: 1px solid #384352; border-radius: 7px; color: #dce2ea; background: #11171e; font-size: 14px; }
.efficiency-awakening { display: grid; grid-template-columns: 34px minmax(0, 1fr); align-items: center; gap: 7px; }
.efficiency-awakening .awakening-stepper { height: 40px; }
.efficiency-leader-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
.efficiency-outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 10px; margin-top: 9px; padding: 9px 10px; border-radius: 8px; background: #171e26; }
.efficiency-outfit-effect span { color: #d990af; font-size: 13px; font-weight: 850; }
.efficiency-outfit-effect p { margin: 0; color: #a7b0bd; font-size: 13px; line-height: 1.5; }
.efficiency-summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; margin-top: 14px; padding: 10px 12px; border-radius: 8px; background: #10151c; }
.efficiency-summary span { color: #8b96a5; font-size: 13px; }
.efficiency-summary strong { margin-right: 10px; color: #d6dde7; font-size: 16px; }
.efficiency-focus-note { margin: 10px 2px 0; color: #9aa6b6; font-size: 14px; line-height: 1.55; }
.efficiency-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; margin-top: 10px; }
.efficiency-column { min-width: 0; overflow: hidden; border: 1px solid #303946; border-radius: 10px; background: #11171e; }
.efficiency-column-heading { padding: 12px 13px 10px; }
.efficiency-column-heading h3 { margin: 0; color: #dfe5ec; font-size: 17px; }
.efficiency-column.upgrade .efficiency-column-heading h3 { color: #8ed6aa; }
.efficiency-column.removal .efficiency-column-heading h3 { color: #e9a3a8; }
.efficiency-column-heading p { margin: 5px 0 0; color: #8490a0; font-size: 13px; }
.efficiency-list { display: grid; }
.efficiency-table-head, .efficiency-list > button { display: grid; grid-template-columns: 28px minmax(280px, 1fr) 120px 100px 130px 135px; align-items: center; gap: 12px; min-width: 0; }
.efficiency-table-head { min-height: 38px; padding: 6px 10px; border-top: 1px solid #27303b; color: #8994a3; background: #171e26; font-size: 12px; font-weight: 850; }
.efficiency-table-head span:not(:nth-child(2)) { text-align: center; }
.efficiency-list > button { min-height: 60px; padding: 8px 10px; border: 0; border-top: 1px solid #27303b; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.efficiency-list > button:hover { background: rgb(255 255 255 / 4%); }
.efficiency-list > button:focus-visible { position: relative; z-index: 1; outline: 2px solid #75c693; outline-offset: -2px; }
.efficiency-rank { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 50%; color: #8c97a6; background: #202731; font-size: 13px; font-weight: 900; }
.efficiency-node, .efficiency-power, .efficiency-node-count, .efficiency-cost, .efficiency-rate { min-width: 0; }
.efficiency-node { display: grid; gap: 3px; }
.efficiency-node small { overflow: hidden; color: #858f9e; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.efficiency-node strong, .efficiency-power strong, .efficiency-node-count strong, .efficiency-cost strong, .efficiency-rate strong { display: block; overflow: hidden; color: #d9dfe7; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.efficiency-power, .efficiency-node-count, .efficiency-cost, .efficiency-rate { text-align: center; }
.upgrade .efficiency-power strong, .upgrade .efficiency-rate strong { color: #82d4a1; }
.removal .efficiency-power strong, .removal .efficiency-rate strong { color: #e6a0a5; }
.efficiency-empty, .efficiency-error { margin: 12px 0 0; color: #8e99a8; font-size: 14px; line-height: 1.55; }
.efficiency-error { color: #ef9da3; }
.efficiency-column > .efficiency-empty { margin: 0; padding: 14px; border-top: 1px solid #27303b; }
.board-scroll { height: min(62vh, 720px); overflow: auto; border: 1px solid #2b3340; border-radius: 12px; background: radial-gradient(circle, rgb(93 104 121 / 24%) 1px, transparent 1px) 0 0 / 26px 26px, linear-gradient(180deg, #17151b, #10151c 45%, #131922); cursor: grab; scrollbar-width: thin; touch-action: none; }
.board-scroll.dragging { cursor: grabbing; user-select: none; }
.board-surface { position: relative; margin-inline: auto; }
@@ -1044,6 +1554,7 @@
.board-node.connect-affected::after { content: ''; position: absolute; z-index: 3; inset: -7px; border: 3px dashed #6ee7ff; border-radius: 50%; pointer-events: none; }
.board-node.connect-applied::after { inset: -8px; border: 4px solid #fff; }
.board-node.connect-applied::before { content: '+'; position: absolute; z-index: 4; top: -13px; right: -13px; display: grid; place-items: center; width: 19px; height: 19px; border: 2px solid #151a22; border-radius: 50%; color: #111820; background: #fff; font-size: 13px; font-weight: 950; line-height: 1; pointer-events: none; }
.board-node.efficiency-focus { z-index: 6; outline: 4px solid #fff; outline-offset: 6px; border-radius: 50%; }
.socket-card { position: absolute; inset: 19%; z-index: 2; display: grid; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; pointer-events: none; }
.board-node:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
.board-node.center { cursor: default; }
@@ -1112,6 +1623,14 @@
.board-toolbar { align-items: flex-start; gap: 10px; }
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }
.automatic-placement-actions { justify-content: flex-end; }
.green-efficiency-heading { align-items: stretch; flex-direction: column; gap: 12px; }
.green-efficiency-actions { display: grid; }
.green-efficiency-heading button { width: 100%; }
.efficiency-card-grid { grid-template-columns: 1fr; }
.efficiency-leader-fields { grid-template-columns: 1fr; }
.efficiency-columns { grid-template-columns: 1fr; }
.efficiency-column { overflow-x: auto; }
.efficiency-table-head, .efficiency-list > button { grid-template-columns: 28px 210px 100px 90px 110px 115px; min-width: 710px; }
.board-scroll { height: min(58vh, 560px); }
.card-picker-backdrop { align-items: end; padding: 0; }
.card-picker { max-height: 88vh; border-radius: 16px 16px 0 0; }