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
+15 -6
View File
@@ -97,11 +97,16 @@
}
</script>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
<div class="order-sections">
<section class="order-section">
<h3>추천 스페셜 발동 순서</h3>
<ol class="order-list compact">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
</section>
</div>
{#if showScoreSummary}
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
@@ -164,7 +169,10 @@
</section>
<style>
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -206,6 +214,7 @@
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
@media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
+6
View File
@@ -5,6 +5,7 @@ import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = {
id: 'card',
name: '카드',
power: { statsAtMaxLevel: { performance: 101, technique: 202, sense: 303 } },
details: {
active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
@@ -29,6 +30,11 @@ test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다',
assert.equal(specialAwakened.special.scoreUpPercent, 40);
});
test('2개화부터 보드 제외 기본 파라미터 합계에 10%를 적용한다', () => {
assert.equal(toOptimizerCard(source, 0).naturalParameterTotal, 606);
assert.equal(toOptimizerCard(source, 2).naturalParameterTotal, 669);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = {
...source,
+13
View File
@@ -16,6 +16,13 @@ export type OptimizerSourceCard = {
type?: string;
generation?: string;
versionName?: string;
power?: {
statsAtMaxLevel?: {
performance: number;
technique: number;
sense: number;
};
};
details: {
passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[];
@@ -87,6 +94,11 @@ export function toOptimizerCard(
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
const baseStats = source.power?.statsAtMaxLevel;
const potentialRate = awakening >= 2 ? 10 : 0;
const naturalParameterTotal = baseStats
? Object.values(baseStats).reduce((sum, value) => sum + Math.ceil(value * (100 + potentialRate) / 100), 0)
: 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
@@ -98,6 +110,7 @@ export function toOptimizerCard(
return {
id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
naturalParameterTotal,
type: source.type,
generation: source.generation,
active: {
+56 -1
View File
@@ -1,6 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts';
import {
combinationSearchLimits,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
resolveCandidateAwakening,
selectMemberDiverseCandidates
} from './index.ts';
const cards = [
{ id: 'a-1', characterId: 'a', score: 10 },
@@ -44,3 +50,52 @@ test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실
assert.equal(resolveCandidateAwakening(4, true, 2), 4);
assert.equal(resolveCandidateAwakening(2, false, 5), 2);
});
test('빈 멤버 수에 비례해 선별 및 정밀 분석 후보 수를 늘린다', () => {
assert.deepEqual(combinationSearchLimits(4), {
openSlots: 1,
screeningLimit: 5_000,
baseExactFinalistLimit: 56,
alternativeExactFinalistLimit: 56
});
assert.deepEqual(combinationSearchLimits(3), {
openSlots: 2,
screeningLimit: 10_000,
baseExactFinalistLimit: 112,
alternativeExactFinalistLimit: 112
});
assert.deepEqual(combinationSearchLimits(0), {
openSlots: 5,
screeningLimit: 25_000,
baseExactFinalistLimit: 280,
alternativeExactFinalistLimit: 280
});
});
test('각 자동 후보를 임시 축으로 삼은 조합을 선별한다', () => {
const anchoredCards = [
{ id: 'a', characterId: 'a', score: 100 },
{ id: 'b', characterId: 'b', score: 90 },
{ id: 'c', characterId: 'c', score: 80 },
{ id: 'd', characterId: 'd', score: 1 }
];
const result = generateAnchoredCombinationCandidates(
[],
anchoredCards,
2,
[{ scoreCard: (card) => card.score }],
1
);
assert.ok(result.some((combination) => combination.some((card) => card.id === 'd')));
});
test('정밀 분석 후보에 각 카드의 최고 대표 조합을 남긴다', () => {
const candidates = [
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'b', characterId: 'b' }], score: 100 },
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'c', characterId: 'c' }], score: 90 },
{ cards: [{ id: 'd', characterId: 'd' }, { id: 'e', characterId: 'e' }], score: 20 }
];
const result = selectMemberDiverseCandidates(candidates, (candidate) => candidate.cards, (candidate) => candidate.score, [], 10);
assert.equal(result.length, 3);
assert.ok(result.some((candidate) => candidate.cards.some((card) => card.id === 'e')));
});
+88
View File
@@ -8,6 +8,33 @@ type BeamState<T extends CombinationSearchCard> = {
score: number;
};
export type CombinationSearchLimits = {
openSlots: number;
screeningLimit: number;
baseExactFinalistLimit: number;
alternativeExactFinalistLimit: number;
};
export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
scoreCard: (card: T) => number;
scoreCombination?: (cards: readonly T[]) => number;
};
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56;
export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits {
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
const openSlots = Math.max(1, normalizedTargetSize - normalizedFixedCardCount);
return {
openSlots,
screeningLimit: SCREENING_LIMIT_PER_OPEN_SLOT * openSlots,
baseExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots,
alternativeExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots
};
}
export function resolveCandidateAwakening(
configuredAwakening: number,
owned: boolean,
@@ -63,3 +90,64 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
.sort((left, right) => right.score - left.score)
.map((state) => state.cards);
}
export function generateAnchoredCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
strategies: readonly CombinationScoringStrategy<T>[],
perAnchorLimit: number
): T[][] {
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchors = candidateCards.filter((card) => !fixedCharacters.has(card.characterId));
const perStrategyLimit = Math.max(1, Math.ceil(perAnchorLimit / strategies.length));
const combined = new Map<string, T[]>();
for (const anchor of anchors) {
const anchored = new Map<string, T[]>();
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
[...fixedCards, anchor],
candidateCards,
targetSize,
strategy.scoreCard,
perStrategyLimit,
strategy.scoreCombination
)) {
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
}
}
for (const [key, cards] of Array.from(anchored.entries()).slice(0, perAnchorLimit)) {
combined.set(key, cards);
}
}
return Array.from(combined.values());
}
export function selectMemberDiverseCandidates<T>(
candidates: readonly T[],
cardsOf: (candidate: T) => readonly { id: string }[],
scoreOf: (candidate: T) => number,
fixedCardIds: readonly string[],
limit: number
): T[] {
if (limit <= 0) return [];
const fixedIds = new Set(fixedCardIds);
const ranked = [...candidates].sort((left, right) => scoreOf(right) - scoreOf(left));
const bestByCardId = new Map<string, T>();
for (const candidate of ranked) {
for (const card of cardsOf(candidate)) {
if (!fixedIds.has(card.id) && !bestByCardId.has(card.id)) bestByCardId.set(card.id, candidate);
}
}
const selected = new Map<string, T>();
for (const candidate of bestByCardId.values()) {
const key = cardsOf(candidate).map((card) => card.id).sort().join('|');
selected.set(key, candidate);
if (selected.size >= limit) break;
}
return Array.from(selected.values());
}
+40 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
@@ -31,10 +32,11 @@ test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
});
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
test('패시브 스코어 서포트는 기본 파라미터 합계가 높은 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
naturalParameterTotal: [100, 300, 200, 50, 50][index],
passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
@@ -42,5 +44,42 @@ test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.deepEqual(passiveScoreSupportByCard(supported), [0, 20, 20, 0, 0]);
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
});
test('스페셜 발동 순서를 탐색해 가장 높은 액티브를 강한 구간에 배치한다', () => {
const formation: OptimizerCard[] = [
{ id: 'a', name: 'a', type: '퓨어', naturalParameterTotal: 200, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 105 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'b', name: 'b', type: '퓨어', naturalParameterTotal: 100, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 10 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{ id: 'c', name: 'c', type: '퓨어', naturalParameterTotal: 300, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 110 }, special: { durationSeconds: 2, scoreUpPercent: 100 }, allowedCooldownReductions: [0] },
{ id: 'd', name: 'd', type: '해피', active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
{
id: 'support',
name: 'support',
type: '해피',
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
special: { durationSeconds: 0, scoreUpPercent: 0 },
passiveScoreSupportEffects: [{ percent: 20, target: 'group', targetGroup: '퓨어 타입', targetCount: 2 }],
allowedCooldownReductions: [0]
}
];
const targetChart: ExactSongChart = {
songId: 'formation-order-test',
difficulty: 'expert',
durationSeconds: 3,
scoreRatio: 1,
notes: [
{ timeSeconds: 0.5, type: 'regular', isFlick: false },
{ timeSeconds: 1.1, type: 'regular', isFlick: false }
],
specialStartSeconds: [0, null, null, null, null]
};
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
const firstActiveWinner = result.effectiveActiveWindows.find((window) => window.startSeconds <= 1.1 && 1.1 < window.endSeconds);
assert.equal(result.order[0], 'c');
assert.equal(firstActiveWinner?.cardId, 'c');
assert.ok(Math.abs((firstActiveWinner?.scoreUpPercent ?? 0) - 242) < 1e-9);
});
+5 -22
View File
@@ -1,5 +1,5 @@
import { calculateExactScore } from './score.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
@@ -17,24 +17,6 @@ type CandidateProfile = {
probability: Float64Array;
};
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
const totals = order.map(() => 0);
order.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = order.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = order
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? order.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
@@ -242,17 +224,17 @@ export function optimizeExactOrderAndCooldowns(
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
const orders = permutations(cards);
const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length;
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) {
for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
@@ -273,6 +255,7 @@ export function optimizeExactOrderAndCooldowns(
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
formationOrder: cards.map((card) => card.id),
cooldownReductions: { ...bestReductions },
...windows,
evaluatedCandidates
+1
View File
@@ -6,6 +6,7 @@ export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
export * from './score.ts';
export * from './score-probability.ts';
export * from './skill-condition.ts';
export * from './timeline.ts';
export * from './types.ts';
+12
View File
@@ -29,6 +29,18 @@ test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880);
});
test('제한 인원 패시브가 있어도 편성 순서 후보를 추가하지 않는다', () => {
const targeted = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 11, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
assert.equal(countOptimizationCandidates(targeted), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
+27
View File
@@ -0,0 +1,27 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { OptimizerCard } from './types.ts';
export function passiveScoreSupportByCard(cards: readonly OptimizerCard[]): number[] {
const totals = cards.map(() => 0);
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.sort((left, right) =>
(right.card.naturalParameterTotal ?? 0) - (left.card.naturalParameterTotal ?? 0) ||
left.card.id.localeCompare(right.card.id))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
export function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
return passiveScoreSupportByCard(cards)[targetIndex] ?? 0;
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateScoreAchievementProbabilities } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
function card(
id: string,
scoreUpPercent: number,
probabilityPercent: number,
durationSeconds = 1
): OptimizerCard {
return {
id,
name: id,
active: { cooldownSeconds: 1, durationSeconds, scoreUpPercent, probabilityPercent },
special: { durationSeconds: 0, scoreUpPercent: 0 },
allowedCooldownReductions: [0]
};
}
function chart(times: number[], specialStartSeconds: (number | null)[] = []): ExactSongChart {
return {
songId: 'test',
difficulty: 'expert',
durationSeconds: 5,
scoreRatio: 1,
notes: times.map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds
};
}
test('한 번의 발동이 여러 노트에 영향을 주어도 최고점 확률은 한 번만 곱한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 50, 0.9)],
{ main: 0 },
chart([1.1, 1.5]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 1 }
);
assert.equal(result.maximum, 0.5);
});
test('최고점에 영향을 주지 않는 약한 액티브는 최고점 확률에서 제외한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('strong', 100, 50), card('weak', 20, 10)],
{ strong: 0, weak: 0 },
chart([1.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 2 }
);
assert.equal(result.maximum, 0.5);
});
test('같은 최고 효과의 액티브가 겹치면 둘 중 하나가 발동할 확률을 사용한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('left', 100, 50), card('right', 100, 50)],
{ left: 0, right: 0 },
chart([1.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 3 }
);
assert.equal(result.maximum, 0.75);
});
test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다', () => {
const main = card('main', 100, 50);
const support: OptimizerCard = {
...card('support', 0, 0),
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 50 }
};
const result = estimateScoreAchievementProbabilities(
[main, support],
{ main: 0, support: 0 },
chart([1.1], [null, 0]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 4 }
);
assert.equal(result.maximum, 0.75);
});
test('확정 발동이면 모든 달성 확률이 100%다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 100)],
{ main: 0 },
chart([1.1, 2.1, 3.1]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 5 }
);
assert.deepEqual(result, {
maximum: 1,
atLeast99Percent: 1,
atLeast95Percent: 1,
samples: 1_000
});
});
+242
View File
@@ -0,0 +1,242 @@
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import type {
CooldownReduction,
ExactScoreOptions,
ExactSongChart,
OptimizerCard,
ScoreAchievementProbabilities
} from './types.ts';
type Attempt = {
key: string;
probability: number;
};
type NoteCandidate = {
attemptIndex: number;
boost: number;
};
type ScoreGroup = {
candidates: readonly NoteCandidate[];
scores: readonly number[];
};
type PreparedProbabilityModel = {
attempts: readonly Attempt[];
groups: readonly ScoreGroup[];
maximumScore: number;
maximumClauses: readonly (readonly number[])[];
};
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
}
function specialRate(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
time: number,
field: 'scoreUpPercent' | 'activationRateUpPercent'
): number {
return cards.reduce((sum, card, slot) => sum +
(specialActive(card, chart.specialStartSeconds[slot], time) ? card.special[field] ?? 0 : 0), 0);
}
function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}
function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] {
const countedMidTimes = new Set<number>();
return chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
}
function prepareModel(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): PreparedProbabilityModel {
const attempts: Attempt[] = [];
const attemptIndexes = new Map<string, number>();
const passiveSupport = passiveScoreSupportByCard(cards);
const grouped = new Map<string, { candidates: NoteCandidate[]; scores: number[] }>();
const clauses: number[][] = [];
let maximumScore = 0;
scoreNotes(chart).forEach((note, noteIndex) => {
const candidates: NoteCandidate[] = [];
const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent');
cards.forEach((card, cardIndex) => {
const reduction = reductions[card.id] ?? 0;
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attemptNumber <= 0) return;
const activationTime = attemptNumber * interval;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
const key = `${card.id}:${attemptNumber}`;
let attemptIndex = attemptIndexes.get(key);
if (attemptIndex === undefined) {
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent');
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
attemptIndex = attempts.length;
attemptIndexes.set(key, attemptIndex);
attempts.push({ key, probability });
}
candidates.push({
attemptIndex,
boost: card.active.scoreUpPercent / 100 *
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
});
});
candidates.sort((left, right) => right.boost - left.boost || left.attemptIndex - right.attemptIndex);
const combo = noteIndex + 1;
const baseNoteScore = note.type === 'mid'
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaseline = baseNoteScore * (1 + comboBonus(combo));
const directBonus = options.directSongScoreBonusPercent ?? 0;
const scores = [
...candidates.map((candidate) => finalNoteScore(rawBaseline * (1 + candidate.boost), directBonus)),
finalNoteScore(rawBaseline, directBonus)
];
maximumScore += scores[0];
if (candidates.length > 0) {
clauses.push(candidates
.filter((_, index) => scores[index] === scores[0])
.map((candidate) => candidate.attemptIndex));
}
const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|');
const existing = grouped.get(signature);
if (existing) scores.forEach((score, index) => { existing.scores[index] += score; });
else grouped.set(signature, { candidates, scores });
});
return {
attempts,
groups: Array.from(grouped.values()),
maximumScore,
maximumClauses: simplifyClauses(clauses)
};
}
function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] {
const unique = new Map<string, number[]>();
for (const clause of clauses) {
const normalized = Array.from(new Set(clause)).sort((left, right) => left - right);
unique.set(normalized.join(','), normalized);
}
const sorted = Array.from(unique.values()).sort((left, right) => left.length - right.length);
return sorted.filter((clause, index) => !sorted.slice(0, index).some((candidate) =>
candidate.length <= clause.length && candidate.every((variable) => clause.includes(variable))));
}
function exactClauseProbability(clauses: readonly (readonly number[])[], attempts: readonly Attempt[]): number {
const memo = new Map<string, number>();
const solve = (current: readonly (readonly number[])[]): number => {
if (current.length === 0) return 1;
if (current.some((clause) => clause.length === 0)) return 0;
const simplified = simplifyClauses(current);
const key = simplified.map((clause) => clause.join(',')).join(';');
const cached = memo.get(key);
if (cached !== undefined) return cached;
const required = new Set(simplified.filter((clause) => clause.length === 1).map((clause) => clause[0]));
if (required.size > 0) {
let requiredProbability = 1;
for (const variable of required) requiredProbability *= attempts[variable]?.probability ?? 0;
const remaining = simplified.filter((clause) => !clause.some((variable) => required.has(variable)));
const result = requiredProbability * solve(remaining);
memo.set(key, result);
return result;
}
const occurrences = new Map<number, number>();
simplified.forEach((clause) => clause.forEach((variable) =>
occurrences.set(variable, (occurrences.get(variable) ?? 0) + 1)));
const variable = Array.from(occurrences).sort((left, right) => right[1] - left[1])[0][0];
const probability = attempts[variable]?.probability ?? 0;
const whenTrue = simplified.filter((clause) => !clause.includes(variable));
const whenFalse = simplified.map((clause) => clause.filter((candidate) => candidate !== variable));
const result = probability * solve(whenTrue) + (1 - probability) * solve(whenFalse);
memo.set(key, result);
return result;
};
return solve(clauses);
}
function hashSeed(cards: readonly OptimizerCard[], reductions: Readonly<Record<string, CooldownReduction>>): number {
const value = cards.map((card) => `${card.id}:${reductions[card.id] ?? 0}`).join('|');
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function randomGenerator(seed: number): () => number {
let state = seed || 0x9e3779b9;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ value >>> 15, value | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
return ((value ^ value >>> 14) >>> 0) / 4294967296;
};
}
export function estimateScoreAchievementProbabilities(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions & { sampleCount?: number; seed?: number }
): ScoreAchievementProbabilities {
const model = prepareModel(cards, reductions, chart, options);
const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000));
const random = randomGenerator(options.seed ?? hashSeed(cards, reductions));
const active = new Uint8Array(model.attempts.length);
let atLeast99 = 0;
let atLeast95 = 0;
const threshold99 = model.maximumScore * 0.99;
const threshold95 = model.maximumScore * 0.95;
for (let sample = 0; sample < samples; sample += 1) {
model.attempts.forEach((attempt, index) => { active[index] = random() < attempt.probability ? 1 : 0; });
let score = 0;
for (const group of model.groups) {
let scoreIndex = group.candidates.length;
for (let index = 0; index < group.candidates.length; index += 1) {
if (active[group.candidates[index].attemptIndex]) {
scoreIndex = index;
break;
}
}
score += group.scores[scoreIndex];
}
if (score >= threshold99) atLeast99 += 1;
if (score >= threshold95) atLeast95 += 1;
}
return {
maximum: exactClauseProbability(model.maximumClauses, model.attempts),
atLeast99Percent: atLeast99 / samples,
atLeast95Percent: atLeast95 / samples,
samples
};
}
+1 -19
View File
@@ -1,5 +1,5 @@
import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import { passiveScoreSupportAt } from './passive-support.ts';
import type {
CooldownReduction,
ExactScoreOptions,
@@ -34,24 +34,6 @@ export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
}
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
+11
View File
@@ -5,6 +5,8 @@ export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = {
id: string;
name: string;
/** 보드·패시브·의상·메모리를 제외한 현재 레벨/개화의 파라미터 합계. */
naturalParameterTotal?: number;
type?: string;
generation?: string;
active: {
@@ -116,7 +118,16 @@ export type ExactScoreOptions = {
directSongScoreBonusPercent?: number;
};
export type ScoreAchievementProbabilities = {
maximum: number;
atLeast99Percent: number;
atLeast95Percent: number;
samples: number;
};
export type ExactOptimizationResult = ExactScoreResult & {
/** 분석한 5인 편성. 기존 저장 데이터 호환을 위해 필드명을 유지한다. */
formationOrder: readonly string[];
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
+27
View File
@@ -91,3 +91,30 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는
assert.equal(result.passiveTotal, 20);
});
test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파라미터 합계 상위 멤버에게 적용한다', () => {
const source = {
...card('source', '퓨어 타입 2명의 퍼포먼스가 10% UP'),
type: '해피'
};
const targets = [
{ id: 'performance-high', performance: 200, technique: 0, sense: 0 },
{ id: 'total-high', performance: 100, technique: 200, sense: 0 },
{ id: 'total-second', performance: 150, technique: 100, sense: 0 }
].map(({ id, performance, technique, sense }) => ({
card: {
...card(id, ''),
type: '퓨어',
power: { maxLevel: 80, statsAtMaxLevel: { performance, technique, sense } }
},
awakening: 0
}));
const result = calculateUnitPower([
{ card: source, awakening: 0 },
...targets,
{ card: card('other', ''), awakening: 0 }
]);
assert.equal(result.passiveTotal, 25);
});
+5 -1
View File
@@ -89,7 +89,11 @@ function targetIndexes(
return members
.map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index)
.sort((left, right) => {
const rightTotal = stats.reduce((sum, key) => sum + parameters[right.index][key], 0);
const leftTotal = stats.reduce((sum, key) => sum + parameters[left.index][key], 0);
return rightTotal - leftTotal || left.member.card.id.localeCompare(right.member.card.id);
})
.slice(0, count)
.map(({ index }) => index);
}
+78 -8
View File
@@ -1,7 +1,7 @@
/// <reference lib="webworker" />
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
type OptimizeRequest = {
type: 'optimize-exact';
@@ -9,6 +9,7 @@ type OptimizeRequest = {
chart: ExactSongChart;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
includeAchievementProbabilities?: boolean;
};
type CombinationCandidate = {
@@ -24,7 +25,31 @@ type CombinationRequest = {
chart: ExactSongChart;
};
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
type RankedCombination = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
type AchievementRequest = {
type: 'estimate-achievement';
rankings: RankedCombination[];
chart: ExactSongChart;
sampleCount?: number;
};
function cardsForResult(cards: readonly OptimizerCard[], result: ExactOptimizationResult): OptimizerCard[] {
const cardById = new Map(cards.map((card) => [card.id, card]));
return result.order.map((cardId) => {
const card = cardById.get(cardId);
if (!card) throw new Error('확률 계산 대상의 스페셜 순서를 복원하지 못했습니다.');
return card;
});
}
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => {
try {
if (event.data.type === 'optimize-exact') {
@@ -34,14 +59,49 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
if (event.data.includeAchievementProbabilities) {
const orderedCards = cardsForResult(event.data.cards, result);
achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
result.cooldownReductions,
event.data.chart,
{ ...options, sampleCount: 25_000 }
);
}
self.postMessage({ type: 'result', result, achievementProbabilities });
return;
}
if (event.data.type === 'estimate-achievement') {
const request = event.data;
const rankings = request.rankings.map((ranking, rankingIndex) => {
if (!ranking.cards) throw new Error('확률 계산에 필요한 카드 정보가 없습니다.');
const orderedCards = cardsForResult(ranking.cards, ranking.result);
const achievementProbabilities = estimateScoreAchievementProbabilities(
orderedCards,
ranking.result.cooldownReductions,
event.data.chart,
{
perfectNoteScore: ranking.result.perfectNoteScore,
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent,
sampleCount: request.sampleCount
}
);
self.postMessage({
type: 'achievement-progress',
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
});
return { ...ranking, achievementProbabilities };
});
self.postMessage({ type: 'achievement-result', rankings });
return;
}
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
let completedCandidates = 0;
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
let best: RankedCombination | null = null;
const rankings: RankedCombination[] = [];
for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore,
@@ -54,9 +114,19 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
completedCandidates += countOptimizationCandidates(candidate.cards);
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
best = { result, overallPower: candidate.overallPower };
best = {
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
};
}
rankings.push({ result, overallPower: candidate.overallPower });
rankings.push({
result,
overallPower: candidate.overallPower,
cards: candidate.cards,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
});
}
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
+47 -9
View File
@@ -6,7 +6,7 @@
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -69,6 +69,7 @@
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = '';
let analysisReady = false;
@@ -182,6 +183,7 @@
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
achievementProbabilities = event.data.achievementProbabilities;
running = false;
}
if (event.data.type === 'error') {
@@ -315,6 +317,7 @@
async function analyze(): Promise<void> {
errorMessage = '';
result = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
@@ -342,7 +345,8 @@
cards,
chart,
perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
});
} catch (error) {
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
@@ -354,6 +358,19 @@
return card ? cardLabel(card) : cardId;
}
function probabilityLabel(value: number | undefined): string {
if (value === undefined) return '—';
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
return `${(value * 100).toFixed(1)}%`;
}
function scoreThreshold(maximumScore: number, ratio: number): string {
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
}
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
for (const note of scoreResult.notes) {
@@ -430,7 +447,7 @@
<header class="page-header">
<div>
<h1>배치 분석</h1>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 멤버 배치 순서와 빈도 감소 조합을 찾습니다.</p>
<p class="page-description">특정 악곡과 카드 조합에서 최고 점수를 낼 수 있는 스페셜 발동 순서와 빈도 감소 조합을 찾습니다.</p>
</div>
</header>
@@ -528,16 +545,27 @@
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
{#if result}
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
<div class="order-sections">
<section class="order-section">
<h3>추천 스페셜 발동 순서</h3>
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
</section>
</div>
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div>
<div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div>
<div><span>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
<div><span>최고점의 95% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast95Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.95)}점 이상</small></div>
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
<section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading">
@@ -662,7 +690,10 @@
.analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; }
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 22px; }
.order-section { min-width: 0; }
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -671,6 +702,12 @@
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 14px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.probability-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 8px; }
.probability-grid > div { display: grid; gap: 7px; padding: 16px; border: 1px solid #303744; border-radius: 10px; background: #171d25; }
.probability-grid span { color: #9ca5b3; font-size: 14px; }
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
@@ -710,6 +747,7 @@
}
@media (max-width: 700px) {
.order-sections { grid-template-columns: 1fr; }
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; }
.panel { padding: 17px; }
+194 -56
View File
@@ -9,14 +9,17 @@
import {
calculateBoardEffects,
calculateUnitPower,
combinationSearchLimits,
countOptimizationCandidates,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
perfectNoteScoreFromPower,
resolveCandidateAwakening,
selectMemberDiverseCandidates,
toOptimizerCard
} from '$lib/engine/index.ts';
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -33,7 +36,13 @@
heuristic: number;
skillPotential: number;
};
type RankedResult = { result: ExactOptimizationResult; overallPower: number };
type RankedResult = {
result: ExactOptimizationResult;
overallPower: number;
cards?: OptimizerCard[];
directSongScoreBonusPercent?: number;
achievementProbabilities?: ScoreAchievementProbabilities;
};
type DisplayRankedResult = RankedResult & { isBaseline: boolean };
type SavedState = {
schemaVersion: 1;
@@ -64,9 +73,6 @@
const STORAGE_KEY = 'holodori.combination.v1';
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const SCREENING_LIMIT = 4_000;
const BASE_EXACT_FINALIST_LIMIT = 48;
const ALTERNATIVE_EXACT_FINALIST_LIMIT = 48;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
@@ -107,13 +113,17 @@
let displayedRankings: DisplayRankedResult[] = [];
let baselineState: SavedAnalysisState | null = null;
let baselineResult: ExactOptimizationResult | null = null;
let baselineAchievementProbabilities: ScoreAchievementProbabilities | undefined;
let baselineOverallPower = 0;
let baselineRunning = false;
let baselineProgress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let baselineError = '';
let screenedCombinationCount = 0;
let exactCombinationCount = 0;
let searchPhase: 'idle' | 'screening' | 'exact' = 'idle';
let exactPass: 'primary' | 'refinement' = 'primary';
let activeSearchChart: ExactSongChart | null = null;
let primaryRankings: RankedResult[] = [];
let searchPhase: 'idle' | 'screening' | 'exact' | 'probability' = 'idle';
let screeningProgress = 0;
let errorMessage = '';
let ready = false;
@@ -126,7 +136,12 @@
$: comparableToBaseline = Boolean(baselineResult && baselineMatchesConditions);
$: displayedRankings = buildDisplayedRankings(
rankedResults,
comparableToBaseline ? { result: baselineResult!, overallPower: baselineOverallPower, isBaseline: true } : null
comparableToBaseline ? {
result: baselineResult!,
overallPower: baselineOverallPower,
achievementProbabilities: baselineAchievementProbabilities,
isBaseline: true
} : null
);
$: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline();
$: if (!result && rankedResults.length > 0) rankedResults = [];
@@ -205,8 +220,15 @@
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'combination-result') {
result = event.data.result;
rankedResults = Array.isArray(event.data.rankings) ? event.data.rankings : [{ result: event.data.result, overallPower: event.data.overallPower }];
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completeExactPass(rankings);
}
if (event.data.type === 'achievement-progress') progress = event.data.progress;
if (event.data.type === 'achievement-result') {
rankedResults = event.data.rankings as RankedResult[];
result = rankedResults[0]?.result ?? null;
running = false;
searchPhase = 'idle';
}
@@ -221,6 +243,7 @@
if (event.data.type === 'progress') baselineProgress = event.data.progress;
if (event.data.type === 'result') {
baselineResult = event.data.result;
baselineAchievementProbabilities = event.data.achievementProbabilities;
baselineRunning = false;
}
if (event.data.type === 'error') {
@@ -405,85 +428,127 @@
}
function screeningCombinations(): CardRow[][] {
const { screeningLimit } = combinationSearchLimits(fixedCards.length);
if (fixedCards.length === 4) {
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, SCREENING_LIMIT);
return generateCombinationCandidates(fixedCards, fiveStarCards, 5, roughCardScore, screeningLimit);
}
const perStrategy = Math.ceil(SCREENING_LIMIT / 4);
const globalBudget = Math.ceil(screeningLimit * 0.8);
const anchoredBudget = screeningLimit - globalBudget;
const perStrategy = Math.ceil(globalBudget / 4);
const strategies = [
{ scoreCard: roughCardScore },
{ scoreCard: roughSkillScore },
{ scoreCard: roughStatScore },
{ scoreCard: roughStatScore, scoreCombination: roughCombinationScore }
];
const combined = new Map<string, CardRow[]>();
for (const score of [roughCardScore, roughSkillScore, roughStatScore]) {
for (const cards of generateCombinationCandidates(fixedCards, fiveStarCards, 5, score, perStrategy)) {
for (const strategy of strategies) {
for (const cards of generateCombinationCandidates(
fixedCards,
fiveStarCards,
5,
strategy.scoreCard,
perStrategy,
strategy.scoreCombination
)) {
combined.set(combinationKey(cards), cards);
}
}
for (const cards of generateCombinationCandidates(
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const anchorCount = fiveStarCards.filter((card) => !fixedCharacters.has(card.characterId)).length;
const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount)));
for (const cards of generateAnchoredCombinationCandidates(
fixedCards,
fiveStarCards,
5,
roughStatScore,
perStrategy,
roughCombinationScore
strategies,
perAnchorLimit
)) {
combined.set(combinationKey(cards), cards);
}
return Array.from(combined.values()).slice(0, SCREENING_LIMIT);
return Array.from(combined.values()).slice(0, screeningLimit);
}
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared;
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const selected = new Map<string, PreparedCandidate>();
const add = (candidates: PreparedCandidate[], count: number) => {
for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate);
};
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), 24);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), 12);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), 12);
if (selected.size < BASE_EXACT_FINALIST_LIMIT) {
const diverse = selectMemberDiverseCandidates(
prepared,
(candidate) => candidate.cards,
(candidate) => candidate.heuristic,
fixedCardIds,
Math.ceil(baseExactFinalistLimit / 2)
);
add(diverse, diverse.length);
const remainingLimit = Math.max(0, baseExactFinalistLimit - selected.size);
const heuristicLimit = Math.ceil(remainingLimit / 2);
const skillLimit = Math.ceil(remainingLimit / 4);
const powerLimit = remainingLimit - heuristicLimit - skillLimit;
add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit);
add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit);
add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit);
if (selected.size < baseExactFinalistLimit) {
for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) {
selected.set(combinationKey(candidate.cards), candidate);
if (selected.size >= BASE_EXACT_FINALIST_LIMIT) break;
if (selected.size >= baseExactFinalistLimit) break;
}
}
return Array.from(selected.values()).slice(0, BASE_EXACT_FINALIST_LIMIT);
return Array.from(selected.values()).slice(0, baseExactFinalistLimit);
}
function addAlternativeVariantFinalists(baseFinalists: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return baseFinalists;
const selected = new Map(baseFinalists.map((candidate) => [combinationKey(candidate.cards), candidate]));
const alternatives = new Map<string, PreparedCandidate>();
const bestByReplacementId = new Map<string, PreparedCandidate>();
function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] {
if (fixedCards.length === 4 || rankings.length === 0) return [];
const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const alternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
const fixedIds = new Set(fixedCards.map((card) => card.id));
for (const finalist of baseFinalists) {
const sourceCards = finalist.cards.map((card) => cardById.get(card.id)).filter((card): card is CardRow => Boolean(card));
for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) {
const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card));
if (sourceCards.length !== 5) continue;
const sourceKey = combinationKey(sourceCards);
const bestByReplacementId = new Map<string, { cards: CardRow[]; roughScore: number }>();
sourceCards.forEach((source, index) => {
if (fixedIds.has(source.id)) return;
const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId));
for (const replacement of fiveStarCards) {
if (replacement.characterId !== source.characterId || replacement.id === source.id) continue;
if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue;
const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card);
const key = combinationKey(cards);
if (selected.has(key) || alternatives.has(key)) continue;
const prepared = prepareCandidate(cards);
alternatives.set(key, prepared);
if (key === sourceKey) continue;
const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) };
alternatives.set(key, candidate);
if (rankingIndex === 0) topResultNeighbors.set(key, candidate);
const previous = bestByReplacementId.get(replacement.id);
if (!previous || prepared.heuristic > previous.heuristic) bestByReplacementId.set(replacement.id, prepared);
if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate);
}
});
if (rankingIndex > 0 && rankingIndex < 3) {
for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate);
}
}
const refinementLimit = Math.max(alternativeExactFinalistLimit, topResultNeighbors.size);
const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) =>
right.heuristic - left.heuristic || right.skillPotential - left.skillPotential || right.overallPower - left.overallPower);
const chosenAlternatives = new Map<string, PreparedCandidate>();
for (const candidate of bestByReplacementId.values()) {
right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards)));
const chosenAlternatives = new Map<string, { cards: CardRow[]; roughScore: number }>();
for (const candidate of topResultNeighbors.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
}
for (const candidate of secondaryPriority.values()) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of rankedAlternatives) {
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= ALTERNATIVE_EXACT_FINALIST_LIMIT) break;
if (chosenAlternatives.size >= refinementLimit) break;
}
for (const candidate of chosenAlternatives.values()) {
selected.set(combinationKey(candidate.cards), candidate);
}
return Array.from(selected.values());
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
}
function prepareCandidate(cards: CardRow[]): PreparedCandidate {
@@ -586,7 +651,8 @@
cards: optimizerCards,
chart,
perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, chartMetadata.scoreRatio ?? null),
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent,
includeAchievementProbabilities: true
});
} catch (error) {
baselineError = error instanceof Error ? error.message : '기준 조합을 분석하지 못했습니다.';
@@ -599,11 +665,67 @@
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
}
function probabilityLabel(value: number | undefined): string {
if (value === undefined) return '—';
if (value <= 0) return '0%';
if (value < 0.000001) return '<0.0001%';
if (value < 0.0001) return `${(value * 100).toFixed(4)}%`;
if (value < 0.01) return `${(value * 100).toFixed(2)}%`;
return `${(value * 100).toFixed(1)}%`;
}
function scoreThreshold(maximumScore: number, ratio: number): string {
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
}
function terminateParallelWorkers(): void {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = [];
}
function finalizeRankings(rankings: RankedResult[]): void {
const bestByCombination = new Map<string, RankedResult>();
for (const ranking of rankings) {
const key = [...ranking.result.order].sort().join('|');
const previous = bestByCombination.get(key);
if (!previous || ranking.result.theoreticalMaxScore > previous.result.theoreticalMaxScore ||
(ranking.result.theoreticalMaxScore === previous.result.theoreticalMaxScore && ranking.result.expectedScore > previous.result.expectedScore)) {
bestByCombination.set(key, ranking);
}
}
rankedResults = Array.from(bestByCombination.values()).sort((left, right) =>
right.result.theoreticalMaxScore - left.result.theoreticalMaxScore || right.result.expectedScore - left.result.expectedScore).slice(0, 10);
result = rankedResults[0]?.result ?? null;
if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) {
searchPhase = 'probability';
progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length };
worker.postMessage({
type: 'estimate-achievement',
rankings: rankedResults,
chart: activeSearchChart,
sampleCount: 25_000
});
return;
}
running = false;
searchPhase = 'idle';
}
function completeExactPass(rankings: RankedResult[]): void {
if (exactPass === 'primary' && activeSearchChart) {
primaryRankings = rankings;
const refinements = buildRefinementFinalists(rankings);
if (refinements.length > 0) {
exactPass = 'refinement';
exactCombinationCount += refinements.length;
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart });
return;
}
}
finalizeRankings([...primaryRankings, ...rankings]);
}
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void {
terminateParallelWorkers();
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
@@ -649,11 +771,8 @@
if (completedWorkers !== workerCount) return;
completedRankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
right.result.expectedScore - left.result.expectedScore);
rankedResults = completedRankings.slice(0, 10);
result = rankedResults[0]?.result ?? null;
parallelWorkers = [];
running = false;
searchPhase = 'idle';
completeExactPass(completedRankings.slice(0, 10));
}
if (event.data.type === 'error') fail(event.data.message);
};
@@ -672,6 +791,9 @@
}
running = true;
searchPhase = 'screening';
exactPass = 'primary';
activeSearchChart = null;
primaryRankings = [];
screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -686,10 +808,11 @@
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
const finalists = addAlternativeVariantFinalists(selectExactFinalists(prepared));
const finalists = selectExactFinalists(prepared);
exactCombinationCount = finalists.length;
if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.');
const chart = await loadExactSongChart(selectedChart.path);
activeSearchChart = chart;
searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart });
@@ -756,7 +879,7 @@
<main>
<header>
<h1>조합 탐색</h1>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 빈자리에 들어갈 카드를 탐색해 최고 점수 조합을 찾습니다.</p>
<p class="page-description">악곡과 고정 멤버 조건을 바탕으로 최고 점수 조합과 스페셜 발동 순서를 함께 찾습니다.</p>
<p class="baseline-guide"><strong>기준 조합</strong>은 배치 분석 결과가 저장되어 있고, 저장된 악곡과 난이도가 현재 탐색 조건과 같을 때 표시됩니다.</p>
</header>
{#if baselineState && baselineMatchesConditions}
@@ -767,7 +890,7 @@
</div>
<p class="baseline-condition">{baselineChartMetadata?.title} · {baselineState.difficulty === 'expert' ? 'Expert' : 'Hard'}</p>
<div class="baseline-members">
{#each baselineResult?.order ?? baselineState.cardIds as cardId, slot}
{#each baselineResult?.formationOrder ?? baselineState.cardIds as cardId, slot}
<div><span>{slot + 1}</span><strong>{baselineCardLabel(cardId)}</strong></div>
{/each}
</div>
@@ -854,7 +977,7 @@
</div>
<p class="hint">기수 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·악곡 보너스 10% 조건으로 비교합니다.</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `정밀 분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span>
@@ -874,15 +997,21 @@
<span class="rank-number">{rank + 1}위</span>
<span class="rank-members">
{#if ranking.isBaseline}<b class="baseline-result-label">기준 조합</b>{/if}
{#each ranking.result.order as cardId, memberIndex}
{#each ranking.result.formationOrder as cardId, memberIndex}
<span class:added-member={!ranking.isBaseline && !fixedCardIds.includes(cardId)}>
<small class="member-awakening">{displayedCardAwakening(cardId, ranking.isBaseline)}개화</small>
<span class="member-name">{resultCardName(cardId)}</span>
</span>
{#if memberIndex < ranking.result.order.length - 1}<i aria-hidden="true">·</i>{/if}
{#if memberIndex < ranking.result.formationOrder.length - 1}<i aria-hidden="true">·</i>{/if}
{/each}
</span>
<span class="rank-metric"><small>최고 점수</small><strong>{ranking.result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if comparableToBaseline && !ranking.isBaseline}<i>{scoreDelta(ranking.result.theoreticalMaxScore, baselineResult!.theoreticalMaxScore)}</i>{/if}</span>
<span class="rank-probabilities">
<small>최고점 대비 달성 확률</small>
<span><b>100%</b><strong>{probabilityLabel(ranking.achievementProbabilities?.maximum)}</strong></span>
<span><b>99% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast99Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.99)}점</i></span>
<span><b>95% 이상</b><strong>{probabilityLabel(ranking.achievementProbabilities?.atLeast95Percent)}</strong><i>{scoreThreshold(ranking.result.theoreticalMaxScore, 0.95)}점</i></span>
</span>
</summary>
<div class="ranking-detail">
<AnalysisResultView
@@ -897,6 +1026,7 @@
</details>
{/each}
</div>
<p class="probability-guide">달성 확률은 모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
{:else}
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if}
@@ -980,7 +1110,7 @@
.ranking-card { overflow: hidden; border: 1px solid #303744; border-radius: 12px; background: #12161c; }
.ranking-card[open] { border-color: #55404f; background: #15151b; }
.ranking-card.baseline-ranking { border-color: #665061; background: #1b171d; }
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(170px, 15vw, 230px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
.ranking-card summary { position: relative; display: grid; grid-template-columns: 60px minmax(0, 1fr) clamp(150px, 12vw, 200px) clamp(280px, 23vw, 360px); align-items: center; gap: 18px; min-height: 80px; padding: 12px 46px 12px 18px; cursor: pointer; list-style: none; }
.ranking-card summary::-webkit-details-marker { display: none; }
.ranking-card summary::after { content: '⌄'; position: absolute; top: 50%; right: 15px; color: #858f9f; font-size: 16px; transform: translateY(-50%); transition: transform .15s; }
.ranking-card[open] summary::after { transform: translateY(-50%) rotate(180deg); }
@@ -997,6 +1127,13 @@
.rank-metric small { color: #9ca5b3; font-size: 13px; font-style: normal; font-weight: 750; }
.rank-metric strong { color: #f0a0c2; font-size: 18px; line-height: 1.2; }
.rank-metric i { color: #90d8ae; font-size: 12px; font-style: normal; font-weight: 800; }
.rank-probabilities { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px 12px; padding-left: 18px; border-left: 1px solid #303744; }
.rank-probabilities > small { grid-column: 1 / -1; color: #9ca5b3; font-size: 12px; font-weight: 750; }
.rank-probabilities > span { display: grid; gap: 2px; }
.rank-probabilities b { color: #7f8998; font-size: 10px; font-weight: 750; }
.rank-probabilities strong { color: #dce1e8; font-size: 14px; line-height: 1.2; }
.rank-probabilities i { color: #7f8998; font-size: 10px; font-style: normal; font-weight: 700; }
.probability-guide { margin: 11px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.ranking-detail { padding: 0 16px 18px; border-top: 1px solid #2b313c; }
.scores { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.scores div { display: grid; gap: 7px; padding: 15px; border-radius: 10px; background: #22202a; }
@@ -1024,6 +1161,7 @@
.rank-number { grid-column: 1; grid-row: 1; }
.rank-members { grid-column: 2; grid-row: 1; }
.rank-metric:nth-of-type(3) { grid-column: 2; grid-row: 2; }
.rank-probabilities { grid-column: 1 / -1; grid-row: 3; padding: 10px 0 0; border-top: 1px solid #303744; border-left: 0; }
.ranking-detail { padding: 0 10px 14px; }
}
</style>
+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; }