From 2aefc14547bb9f581a22d0e1f441667f7006ab76 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Tue, 18 Aug 2026 18:44:05 +0900 Subject: [PATCH] feat: add green board efficiency analysis --- src/lib/engine/board-effects.test.ts | 31 ++ src/lib/engine/board-effects.ts | 21 +- src/routes/data/+page.svelte | 547 ++++++++++++++++++++++++++- 3 files changed, 581 insertions(+), 18 deletions(-) diff --git a/src/lib/engine/board-effects.test.ts b/src/lib/engine/board-effects.test.ts index f518e96..811f5fe 100644 --- a/src/lib/engine/board-effects.test.ts +++ b/src/lib/engine/board-effects.test.ts @@ -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); +}); diff --git a/src/lib/engine/board-effects.ts b/src/lib/engine/board-effects.ts index 04e0cd7..13c5715 100644 --- a/src/lib/engine/board-effects.ts +++ b/src/lib/engine/board-effects.ts @@ -40,6 +40,7 @@ export type BoardEffectsResult = { }; const stats: readonly UnitStat[] = ['performance', 'technique', 'sense']; +const generationBonusCap = 900; const emptyStats = (): Record => ({ performance: 0, technique: 0, sense: 0 }); const groupingLabels: Record = { '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> = {}; 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; } // 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다. diff --git a/src/routes/data/+page.svelte b/src/routes/data/+page.svelte index 173a6b8..78b8b19 100644 --- a/src/routes/data/+page.svelte +++ b/src/routes/data/+page.svelte @@ -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; const boardNodeGroups = boardSummary.nodeGroups as Record; @@ -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, positions: BoardPosition[], center: string): Set { + 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((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): 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((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((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)[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)[groupId[0]] ?? 'center'; } @@ -773,7 +1097,8 @@ {/if} {#if dataView === 'boards'} -
+
+
@@ -788,10 +1113,125 @@
+
+
+ + 초록 노드 효율 분석현재 편성에서 활성화하거나 초기화할 초록 노드의 재료 효율을 비교합니다. + +
+
+ 현재 편성 초록 노드 효율 + 아래에서 직접 설정한 편성의 실제 종합력 변화로 비교합니다. 그린 큐브 효율을 우선하고, 그린 코어 큐브 효율을 후순위로 적용합니다. +
+
+ + +
+
+ {#if greenEfficiencyRunning} +
+ + {Math.round(greenEfficiencyProgress.completed / Math.max(1, greenEfficiencyProgress.total) * 100)}% +
+ {/if} +
+
+ {#each efficiencyFormation.cardIds as selectedId, slot} +
+ +
+ 개화 +
+ + {efficiencyFormation.awakenings[slot]} + +
+
+
+ {/each} +
+
+ + +
+
리더 의상 효과

{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}

+
+ {#if greenEfficiencyError}{/if} + {#if greenEfficiencyResult} +
+ 현재 종합력{greenEfficiencyResult.basePower.toLocaleString('ko-KR')} + 활성화 후보{greenEfficiencyResult.upgrades.length}개 + 초기화 후보{greenEfficiencyResult.removals.length}개 +
+

두 목록의 노드 행을 선택하면 해당 멤버의 보드로 이동해 노드 위치를 바로 보여줍니다.

+
+
+
+

우선 활성화

초기화 검토 1순위보다 효율이 높은 노드만 표시

+
+ {#if greenEfficiencyResult.upgrades.length > 0} +
+ + {#each greenEfficiencyResult.upgrades.slice(0, 20) as entry, index} + + {/each} +
+ {:else}

종합력을 올리는 비활성 초록 노드가 없습니다.

{/if} +
+
+
+

초기화 검토

환급 큐브 대비 종합력 손실이 낮은 순서

+
+ {#if greenEfficiencyResult.removals.length > 0} +
+ + {#each greenEfficiencyResult.removals.slice(0, 20) as entry, index} + + {/each} +
+ {:else}

초기화를 검토할 활성 초록 노드가 없습니다.

{/if} +
+
+ {/if} +
+
@@ -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}
+ {/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; }