feat: improve analysis and release v1.0.2

This commit is contained in:
2026-08-19 13:47:49 +09:00
parent 488d652e61
commit e259f82b69
30 changed files with 1222 additions and 205 deletions
+73 -6
View File
@@ -36,6 +36,7 @@
awakenings: number[];
leaderCharacterId: string;
leaderOutfitCardId: string;
coreCubeFreeOnly: boolean;
};
type GreenNodeCost = { points: number; greenCubes: number; greenCoreCubes: number };
type GreenNodeEfficiency = {
@@ -54,6 +55,13 @@
upgrades: GreenNodeEfficiency[];
removals: GreenNodeEfficiency[];
};
type AutomaticPlacementResult = {
memberId: string;
memberName: string;
cardId: string;
cardName: string;
rarity: number;
};
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';
@@ -100,12 +108,14 @@
let selectedSocketGroupId = '';
let boardCardSearch = '';
let automaticPlacementSnapshot: BoardCardPlacements | null = null;
let automaticPlacementResult: AutomaticPlacementResult[] = [];
let savedAnalysis: SavedAnalysisState | null = null;
let efficiencyFormation: EfficiencyFormationState = {
cardIds: ['', '', '', '', ''],
awakenings: [0, 0, 0, 0, 0],
leaderCharacterId: '',
leaderOutfitCardId: ''
leaderOutfitCardId: '',
coreCubeFreeOnly: false
};
let greenEfficiencyResult: GreenEfficiencyResult | null = null;
let greenEfficiencyRunning = false;
@@ -163,7 +173,8 @@
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 : ''
leaderOutfitCardId: cardById.get(stored.leaderOutfitCardId)?.details.costume ? stored.leaderOutfitCardId : '',
coreCubeFreeOnly: stored.coreCubeFreeOnly === true
};
}
} catch {
@@ -222,13 +233,18 @@
saveEfficiencyFormation({ ...efficiencyFormation, leaderOutfitCardId: cardId });
}
function updateCoreCubeFreeOnly(coreCubeFreeOnly: boolean) {
saveEfficiencyFormation({ ...efficiencyFormation, coreCubeFreeOnly });
}
function importAnalysisFormation() {
if (!savedAnalysis) return;
saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
});
}
@@ -625,6 +641,7 @@
const path = shortestPathToSet(position.groupId, active, positions);
const added = path.filter((groupId) => groupId !== center && !active.has(groupId));
if (added.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, added).greenCoreCubes > 0) continue;
const nextBoards = { ...userData.memberBoards, [member.id]: [...new Set([...currentNodes, ...added])] };
const nextPower = currentFormationPower(nextBoards);
const intermediateNodes = added.filter((groupId) => groupId !== position.groupId);
@@ -643,6 +660,7 @@
const reachable = reachableNodesForPositions(remaining, positions, center);
const removed = currentNodes.filter((groupId) => !reachable.has(groupId));
if (removed.length === 0) continue;
if (efficiencyFormation.coreCubeFreeOnly && greenNodeCost(member.id, removed).greenCoreCubes > 0) continue;
const nextMemberNodes = currentNodes.filter((groupId) => reachable.has(groupId));
const nextBoards = { ...userData.memberBoards };
if (nextMemberNodes.length > 0) nextBoards[member.id] = nextMemberNodes;
@@ -788,12 +806,24 @@
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
const boardCards = cloneBoardCards(userData.boardCards);
const placementResult: AutomaticPlacementResult[] = [];
for (const match of matches) {
const card = candidateCards[match.rowIndex];
const slot = emptyGreenSlots[match.columnIndex];
if (!card || !slot) continue;
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id };
placementResult.push({
memberId: slot.member.id,
memberName: slot.member.name,
cardId: card.id,
cardName: cardLabel(card),
rarity: card.rarity
});
}
automaticPlacementResult = placementResult.sort((left, right) =>
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
);
persist({ ...userData, boardCards });
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
}
@@ -802,6 +832,7 @@
if (!automaticPlacementSnapshot) return;
const boardCards = cloneBoardCards(automaticPlacementSnapshot);
automaticPlacementSnapshot = null;
automaticPlacementResult = [];
persist({ ...userData, boardCards });
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
}
@@ -1111,6 +1142,19 @@
{#if automaticPlacementSnapshot}<button type="button" class="undo" on:click={undoAutomaticPlacement}>방금 배치 취소</button>{/if}
<button type="button" class="primary" on:click={optimizeEmptyGreenSlots}>중앙 연결 노드 자동 배치</button>
</div>
{#if automaticPlacementResult.length > 0}
<div class="automatic-placement-result" aria-live="polite">
<strong>자동 배치 결과 <span>{automaticPlacementResult.length}건</span></strong>
<div class="automatic-placement-result-list">
{#each automaticPlacementResult as placement (placement.memberId)}
<div class="automatic-placement-result-item">
<span>{placement.memberName} 보드</span>
<b><i>{placement.rarity}★</i>{placement.cardName}</b>
</div>
{/each}
</div>
</div>
{/if}
</div>
</section>
<details class="green-efficiency">
@@ -1173,6 +1217,15 @@
</label>
</div>
<div class="efficiency-outfit-effect"><span>리더 의상 효과</span><p>{plainSkillText(cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume?.description ?? '') || '적용하지 않음'}</p></div>
<label class="efficiency-core-filter">
<input
type="checkbox"
checked={efficiencyFormation.coreCubeFreeOnly}
disabled={greenEfficiencyRunning}
on:change={(event) => updateCoreCubeFreeOnly(event.currentTarget.checked)}
/>
<span><strong>코어 큐브 미소모만</strong><small>활성화 또는 초기화 경로 전체에서 그린 코어 큐브를 소모하지 않는 결과만 찾습니다.</small></span>
</label>
</div>
{#if greenEfficiencyError}<p class="efficiency-error" role="alert">{greenEfficiencyError}</p>{/if}
{#if greenEfficiencyResult}
@@ -1201,7 +1254,7 @@
</button>
{/each}
</div>
{:else}<p class="efficiency-empty">종합력을 올리는 비활성 초록 노드가 없습니다.</p>{/if}
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 소모하지 않고 종합력을 올리는 비활성 초록 노드가 없습니다.' : '종합력을 올리는 비활성 초록 노드가 없습니다.'}</p>{/if}
</section>
<section class="efficiency-column removal" aria-labelledby="removal-efficiency-title">
<div class="efficiency-column-heading">
@@ -1221,7 +1274,7 @@
</button>
{/each}
</div>
{:else}<p class="efficiency-empty">초기화를 검토할 활성 초록 노드가 없습니다.</p>{/if}
{:else}<p class="efficiency-empty">{efficiencyFormation.coreCubeFreeOnly ? '코어 큐브를 환급하지 않는 활성 초록 노드가 없습니다.' : '초기화를 검토할 활성 초록 노드가 없습니다.'}</p>{/if}
</section>
</div>
{/if}
@@ -1457,7 +1510,7 @@
.board-toolbar p strong { color: #f0a1c2; font-size: 16px; }
.board-toolbar div { display: flex; gap: 6px; }
.board-toolbar button { padding: 6px 9px; border: 1px solid #394252; border-radius: 7px; color: #aeb7c5; background: #202630; font-size: 12px; font-weight: 750; cursor: pointer; }
.automatic-placement { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
.automatic-placement { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 18px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid #355044; border-radius: 10px; background: rgb(58 113 83 / 10%); }
.automatic-placement > div:first-child { display: grid; gap: 4px; min-width: 0; }
.automatic-placement strong { color: #b7dec7; font-size: 14px; }
.automatic-placement small { color: #82988c; font-size: 12px; line-height: 1.45; }
@@ -1467,6 +1520,14 @@
.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; }
.automatic-placement-result { display: grid; flex: 1 0 100%; gap: 9px; padding-top: 12px; border-top: 1px solid rgb(120 196 147 / 18%); }
.automatic-placement-result > strong { display: flex; align-items: center; gap: 7px; color: #d8e9df; font-size: 14px; }
.automatic-placement-result > strong span { color: #78c493; font-size: 12px; }
.automatic-placement-result-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 6px 10px; max-height: 280px; overflow-y: auto; padding-right: 4px; }
.automatic-placement-result-item { display: grid; grid-template-columns: minmax(105px, 0.8fr) minmax(150px, 1.2fr); align-items: center; gap: 10px; min-height: 38px; padding: 7px 10px; border: 1px solid rgb(120 196 147 / 13%); border-radius: 7px; background: rgb(9 18 13 / 34%); }
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; }
.automatic-placement-result-item b { display: flex; align-items: center; gap: 7px; min-width: 0; color: #dbe8df; font-size: 13px; }
.automatic-placement-result-item i { flex: 0 0 auto; color: #e7bd70; font-size: 11px; font-style: normal; }
.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; }
@@ -1503,6 +1564,12 @@
.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-core-filter { display: flex; align-items: center; gap: 10px; margin-top: 9px; padding: 10px 11px; border: 1px solid #314238; border-radius: 8px; background: #111a15; cursor: pointer; }
.efficiency-core-filter input { width: 18px; height: 18px; margin: 0; accent-color: #78c493; cursor: pointer; }
.efficiency-core-filter input:disabled { cursor: default; }
.efficiency-core-filter > span { display: grid; gap: 2px; }
.efficiency-core-filter strong { color: #dbe5df; font-size: 14px; }
.efficiency-core-filter small { color: #8f9e96; font-size: 12px; line-height: 1.45; }
.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; }