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(zeroOverride.flatByCardId['card-a'].performance, 0);
assert.equal(customOverride.flatByCardId['card-a'].performance, 450); 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 stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
const generationBonusCap = 900;
const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 }); const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 });
const groupingLabels: Record<string, string> = { const groupingLabels: Record<string, string> = {
'grp-gen_0': '0기생', 'grp-gen_1': '1기생', 'grp-gen_2': '2기생', 'grp-gen_3': '3기생', '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 scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0; let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections); const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) { for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) { for (const groupId of activeGroupIds) {
@@ -172,9 +174,19 @@ export function calculateBoardEffects(
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId; const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value; const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards).filter((card) => const effectTargets = targets(effect, ownerId, cards);
effect.type !== 'all_parameter_up_for_character_grouping' || overrides?.generationBonusByCardId?.[card.id] === undefined 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 card of effectTargets) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue; for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
} }
@@ -183,7 +195,8 @@ export function calculateBoardEffects(
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) { for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
if (!flatByCardId[cardId]) continue; 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 cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json'; import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-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 { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data'; import type { BoardCardPlacements, CardListPreferences, CollectionCardState } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data'; import { createUserData } from '$lib/models/user-data';
import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage'; import { clearBrowserData, loadBrowserData, parseUserData, saveBrowserData } from '$lib/storage/browser-storage';
type ConnectEffect = { level: number; description: string; effectPermilUp: number; extent: { x: number; y: number }[] }; type ConnectEffect = { level: number; description: string; effectPermilUp: number; extent: { x: number; y: number }[] };
type CardSummary = { type CardSummary = (typeof cardSummary.cards)[number];
id: string;
rarity: number;
name: string;
versionName: string;
generation: string;
details: { connect: ConnectEffect[] };
};
type BoardMember = { id: string; name: string; order: number; positionGroupId: string }; type BoardMember = { id: string; name: string; order: number; positionGroupId: string };
type BoardPosition = { groupId: string; x: number; y: number }; type BoardPosition = { groupId: string; x: number; y: number };
type BoardNode = { type BoardNode = {
@@ -27,12 +20,49 @@
grade: number; grade: number;
type: string; type: string;
effect: { id: string; description: string; type: string; value: number | null; trigger?: string | null } | null; 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 }; 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 boardMembers = boardSummary.members as BoardMember[];
const memberOrderByName = new Map(boardMembers.map((member) => [member.name, member.order])); const memberOrderByName = new Map(boardMembers.map((member) => [member.name, member.order]));
const fiveStarCards = allCards.filter((card) => card.rarity === 5); 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 const managedCards = allCards
.filter((card) => card.rarity === 5 || card.rarity === 4) .filter((card) => card.rarity === 5 || card.rarity === 4)
.sort((left, right) => .sort((left, right) =>
@@ -40,6 +70,7 @@
|| right.rarity - left.rarity || right.rarity - left.rarity
); );
const connectCards = allCards.filter((card) => card.details.connect.length > 0); 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 connectCardById = new Map(connectCards.map((card) => [card.id, card]));
const boardModels = boardSummary.models as Record<string, BoardPosition[]>; const boardModels = boardSummary.models as Record<string, BoardPosition[]>;
const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>; const boardNodeGroups = boardSummary.nodeGroups as Record<string, BoardNode[]>;
@@ -69,6 +100,18 @@
let selectedSocketGroupId = ''; let selectedSocketGroupId = '';
let boardCardSearch = ''; let boardCardSearch = '';
let automaticPlacementSnapshot: BoardCardPlacements | null = null; 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; $: collectionCount = Object.values(userData.collection).filter((state) => state.owned).length;
$: boardCount = Object.values(userData.memberBoards).filter((nodes) => nodes.length > 0).length; $: boardCount = Object.values(userData.memberBoards).filter((nodes) => nodes.length > 0).length;
@@ -90,6 +133,13 @@
const query = boardCardSearch.trim().toLowerCase(); const query = boardCardSearch.trim().toLowerCase();
return !query || `${card.name} ${card.versionName}`.toLowerCase().includes(query); 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'; $: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
onMount(() => { onMount(() => {
try { try {
@@ -97,6 +147,28 @@
} catch { } catch {
showMessage('저장된 데이터를 읽을 수 없습니다. 백업 파일이 있다면 가져와 주세요.', true); 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) { function showMessage(text: string, error = false) {
@@ -104,6 +176,62 @@
isError = error; 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) { function initializeBoardViewport(node: HTMLDivElement) {
boardViewport = node; boardViewport = node;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -176,6 +304,8 @@
function persist(next = userData) { function persist(next = userData) {
userData = saveBrowserData(next); userData = saveBrowserData(next);
greenEfficiencyResult = null;
greenEfficiencyError = '';
showMessage('변경 내용을 이 브라우저에 저장했습니다.'); showMessage('변경 내용을 이 브라우저에 저장했습니다.');
} }
@@ -269,6 +399,23 @@
return reached; 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[] { function shortestPathToActive(groupId: string): string[] {
const positions = currentBoardPositions(); const positions = currentBoardPositions();
return shortestPathToSet(groupId, new Set([centerNodeId(), ...activeBoardNodes()]), positions); return shortestPathToSet(groupId, new Set([centerNodeId(), ...activeBoardNodes()]), positions);
@@ -362,6 +509,173 @@
persist({ ...userData, memberBoards }); 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) { function setAllBoardNodes(active: boolean) {
const member = boardMembers.find((item) => item.id === selectedMemberId); const member = boardMembers.find((item) => item.id === selectedMemberId);
if (!member) return; if (!member) return;
@@ -375,6 +689,16 @@
return ({ card: '카드', all_member: '공통', leader: '리더', connection: '연결', content: '콘텐츠' } as Record<string, string>)[type] ?? type; 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 { function boardColor(groupId: string): string {
return ({ R: 'red', G: 'green', B: 'blue', Y: 'yellow', S: 'center' } as Record<string, string>)[groupId[0]] ?? 'center'; return ({ R: 'red', G: 'green', B: 'blue', Y: 'yellow', S: 'center' } as Record<string, string>)[groupId[0]] ?? 'center';
} }
@@ -773,7 +1097,8 @@
{/if} {/if}
{#if dataView === 'boards'} {#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 class="board-guidance">
정확한 분석을 위해 리더, 편성 멤버, 악곡의 가창자 보너스와 관련된 멤버 보드는 직접 설정하는 것을 권장합니다. 정확한 분석을 위해 리더, 편성 멤버, 악곡의 가창자 보너스와 관련된 멤버 보드는 직접 설정하는 것을 권장합니다.
</aside> </aside>
@@ -788,10 +1113,125 @@
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button> <button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div> </div>
</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"> <div class="board-heading">
<label class="member-select"> <label class="member-select">
<span>멤버 선택</span> <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} {#each boardMembers as member}<option value={member.id}>{member.name}</option>{/each}
</select> </select>
</label> </label>
@@ -838,8 +1278,9 @@
class:inactive={!active} class:inactive={!active}
class:connect-affected={Boolean(selectedConnectionBoosts[position.groupId])} class:connect-affected={Boolean(selectedConnectionBoosts[position.groupId])}
class:connect-applied={active && 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;`} 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-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
aria-pressed={active} aria-pressed={active}
on:click={() => handleBoardNodeClick(position.groupId, node)} on:click={() => handleBoardNodeClick(position.groupId, node)}
@@ -904,6 +1345,7 @@
{/if} {/if}
{/if} {/if}
</section> </section>
</div>
{/if} {/if}
{#if dataView === 'home'} {#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: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 button:disabled { color: #515a68; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e4e8ee; font-size: 13px; text-align: center; } .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-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; } .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 { display: grid; gap: 5px; width: min(360px, 100%); }
@@ -1023,6 +1468,71 @@
.automatic-placement button.undo { color: #c4ccd5; background: transparent; } .automatic-placement button.undo { color: #c4ccd5; background: transparent; }
.automatic-placement button:hover { filter: brightness(1.1); } .automatic-placement button:hover { filter: brightness(1.1); }
.automatic-placement button:focus-visible { outline: 3px solid rgb(120 196 147 / 25%); outline-offset: 2px; } .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 { 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-scroll.dragging { cursor: grabbing; user-select: none; }
.board-surface { position: relative; margin-inline: auto; } .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-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::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.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; } .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:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
.board-node.center { cursor: default; } .board-node.center { cursor: default; }
@@ -1112,6 +1623,14 @@
.board-toolbar { align-items: flex-start; gap: 10px; } .board-toolbar { align-items: flex-start; gap: 10px; }
.automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; } .automatic-placement { align-items: stretch; flex-direction: column; gap: 10px; }
.automatic-placement-actions { justify-content: flex-end; } .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); } .board-scroll { height: min(58vh, 560px); }
.card-picker-backdrop { align-items: end; padding: 0; } .card-picker-backdrop { align-items: end; padding: 0; }
.card-picker { max-height: 88vh; border-radius: 16px 16px 0 0; } .card-picker { max-height: 88vh; border-radius: 16px 16px 0 0; }