feat: simulate individual active skill failures in analysis
Publish GitHub Pages output / publish (push) Successful in 45s

This commit is contained in:
2026-09-08 12:46:00 +09:00
parent dad8b2c53a
commit 795bf47e80
7 changed files with 126 additions and 24 deletions
+4
View File
@@ -8,6 +8,10 @@ updatedAt: 2026-09-08T12:00:00+09:00
`DATA` 타카네 루이, 후와와, 모코코의 신규 5성 수영복 카드와 스킬·의상·메모리 데이터를 반영했습니다. `DATA` 타카네 루이, 후와와, 모코코의 신규 5성 수영복 카드와 스킬·의상·메모리 데이터를 반영했습니다.
### 액티브 미발동 시뮬레이션
`NEW` 배치 분석의 카드별 액티브 구간을 클릭해 해당 발동을 제외한 예상 점수·이론상 고점과 그래프를 확인할 수 있습니다. 다시 클릭하거나 모두 복원하면 원래 결과로 돌아옵니다.
### 연결 카드 최적화의 최고점 우선 적용 ### 연결 카드 최적화의 최고점 우선 적용
`FIXED` 배치 분석의 연결 카드 최적화에서 파란 영역의 연결 카드가 인근의 큰 발동률 노드를 반드시 포함하도록 했습니다. 이 조건 안에서 최고점을 최우선으로 비교하고, 최고점이 같을 때 액티브 발동률 UP 합계를 비교합니다. 조건을 충족할 수 없거나 최종 재계산에서 기존 배치보다 최고점이 낮아지면 기존 배치를 유지합니다. `FIXED` 배치 분석의 연결 카드 최적화에서 파란 영역의 연결 카드가 인근의 큰 발동률 노드를 반드시 포함하도록 했습니다. 이 조건 안에서 최고점을 최우선으로 비교하고, 최고점이 같을 때 액티브 발동률 UP 합계를 비교합니다. 조건을 충족할 수 없거나 최종 재계산에서 기존 배치보다 최고점이 낮아지면 기존 배치를 유지합니다.
+6 -4
View File
@@ -99,10 +99,11 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
return profiles; return profiles;
} }
function resultWindows( export function buildExactSkillWindows(
order: readonly OptimizerCard[], order: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>, reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart chart: ExactSongChart,
excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = []
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } { ): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
const activeWindows = order.flatMap((card) => { const activeWindows = order.flatMap((card) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0); const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
@@ -143,7 +144,8 @@ function resultWindows(
const midpoint = (startSeconds + endSeconds) / 2; const midpoint = (startSeconds + endSeconds) / 2;
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent'); const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
const active = activeWindows const active = activeWindows
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds) .filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds &&
!excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9))
.map((window) => { .map((window) => {
const cardIndex = orderIndex.get(window.cardId) ?? 0; const cardIndex = orderIndex.get(window.cardId) ?? 0;
const card = order[cardIndex]; const card = order[cardIndex];
@@ -265,7 +267,7 @@ export function optimizeExactOrderAndCooldowns(
} }
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.'); if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = resultWindows(bestOrder, bestReductions, chart); const windows = buildExactSkillWindows(bestOrder, bestReductions, chart);
return { return {
...calculateExactScore(bestOrder, bestReductions, chart, options), ...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id), order: bestOrder.map((card) => card.id),
+33 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' }; import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts'; import { buildExactSkillWindows, calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = { const inactiveCard: OptimizerCard = {
@@ -121,3 +121,35 @@ test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () =>
} }
} }
}); });
test('지정한 액티브만 실패 처리하고 겹친 다른 카드와 다음 발동은 유지한다', () => {
const strong: OptimizerCard = {
...inactiveCard, id: 'strong',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 100, probabilityPercent: 50 }
};
const weak: OptimizerCard = {
...inactiveCard, id: 'weak',
active: { cooldownSeconds: 10, durationSeconds: 3, scoreUpPercent: 50, probabilityPercent: 100 }
};
const interval = 10 / 1.04;
const song = chart([interval - 0.01, interval, interval + 3, interval * 2].map((timeSeconds) => ({ timeSeconds, type: 'regular' as const, isFlick: false })));
const reductions = { strong: 4 as const, weak: 4 as const };
const cards = [strong, weak];
const original = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100 });
const excludedActiveWindows = [{ cardId: 'strong', startSeconds: interval }];
const modified = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows });
assert.deepEqual(modified.notes.map((note) => note.theoreticalMaxScore), [100, 150, 100, 200]);
assert.deepEqual(modified.notes.map((note) => note.expectedScore), [100, 150, 100, 175]);
assert.equal(modified.baselineScore, original.baselineScore);
assert.equal(modified.scoreBySecond[9], 250);
assert.deepEqual(calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: [] }), original);
const windows = buildExactSkillWindows(cards, reductions, song, excludedActiveWindows);
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval)?.cardId, 'weak');
assert.equal(windows.effectiveActiveWindows.find((window) => window.startSeconds === interval * 2)?.cardId, 'strong');
assert.ok(windows.activeWindows.some((window) => window.cardId === 'strong' && window.startSeconds === interval));
const bothExcluded = [...excludedActiveWindows, { cardId: 'weak', startSeconds: interval }];
const noActive = calculateExactScore(cards, reductions, song, { perfectNoteScore: 100, excludedActiveWindows: bothExcluded });
assert.equal(noActive.notes[1].theoreticalMaxScore, noActive.notes[1].baselineScore);
assert.ok(!buildExactSkillWindows(cards, reductions, song, bothExcluded).effectiveActiveWindows.some((window) => window.startSeconds === interval));
});
+4 -2
View File
@@ -95,7 +95,8 @@ function activeCandidates(
cardsInOrder: readonly OptimizerCard[], cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>, reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart, chart: ExactSongChart,
timeSeconds: number timeSeconds: number,
excludedActiveWindows: ExactScoreOptions['excludedActiveWindows']
): ActiveCandidate[] { ): ActiveCandidate[] {
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds); const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
const candidates = cardsInOrder.flatMap((card, memberIndex) => { const candidates = cardsInOrder.flatMap((card, memberIndex) => {
@@ -104,6 +105,7 @@ function activeCandidates(
if (attempt <= 0) return []; if (attempt <= 0) return [];
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const activationTime = attempt * interval; const activationTime = attempt * interval;
if (excludedActiveWindows?.some((window) => window.cardId === card.id && Math.abs(window.startSeconds - activationTime) < 1e-9)) return [];
if (timeSeconds >= activationTime + card.active.durationSeconds) return []; if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime); const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = effectiveActiveProbability(card, activationRateBonus); const probability = effectiveActiveProbability(card, activationRateBonus);
@@ -153,7 +155,7 @@ export function calculateExactScore(
? Math.ceil(options.perfectNoteScore * 0.1) ? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1); : options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo)); const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds); const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds, options.excludedActiveWindows);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus); const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus); const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus); const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
+6 -4
View File
@@ -41,13 +41,15 @@ test('공식 적용 카탈로그와 실제 채보가 같은 분모 및 환산값
continue; continue;
} }
assert.equal(row.scoreCalibrationSource, 'formula-v1'); assert.equal(row.scoreCalibrationSource, 'formula-v1');
assert.equal(row.scoreRatio, scoreRatioFromFormula(ref)); assert.ok(typeof ref.liveScoreCoefficientPermil === 'number' && typeof ref.difficultyLevel === 'number');
assert.deepEqual(chart.scoreFormula, row.scoreFormula); const formula = {
assert.deepEqual(chart.scoreFormula, {
perfectNoteWeight: ref.perfectNoteWeight, perfectNoteWeight: ref.perfectNoteWeight,
liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil, liveScoreCoefficientPermil: ref.liveScoreCoefficientPermil,
difficultyLevel: ref.difficultyLevel difficultyLevel: ref.difficultyLevel
}); };
assert.equal(row.scoreRatio, scoreRatioFromFormula(formula));
assert.deepEqual(chart.scoreFormula, row.scoreFormula);
assert.deepEqual(chart.scoreFormula, formula);
assert.equal(chart.notes.length, ref.fullComboNoteCount); assert.equal(chart.notes.length, ref.fullComboNoteCount);
formulaCount += 1; formulaCount += 1;
} }
+2
View File
@@ -118,6 +118,8 @@ export type ExactScoreResult = {
}; };
export type ExactScoreOptions = { export type ExactScoreOptions = {
/** 지정한 카드의 해당 발동만 실패한 것으로 계산한다. */
excludedActiveWindows?: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[];
perfectNoteScore: number; perfectNoteScore: number;
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
}; };
+71 -13
View File
@@ -17,8 +17,8 @@
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte'; import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import SongPicker from '$lib/components/SongPicker.svelte'; import SongPicker from '$lib/components/SongPicker.svelte';
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -114,6 +114,44 @@
let running = false; let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null; let result: ExactOptimizationResult | null = null;
let simulationContext: { cards: OptimizerCard[]; chart: ExactSongChart; options: ExactScoreOptions } | null = null;
let simulationBase: ExactOptimizationResult | null = null;
let simulatedResult: ExactOptimizationResult | null = null;
let excludedActiveWindows: SkillWindow[] = [];
$: if (result !== simulationBase) {
simulationBase = result;
simulatedResult = null;
excludedActiveWindows = [];
}
$: displayedResult = simulatedResult ?? result;
function isExcluded(window: SkillWindow): boolean {
return excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds);
}
function toggleActiveWindow(window: SkillWindow): void {
if (!result || !simulationContext) return;
excludedActiveWindows = isExcluded(window)
? excludedActiveWindows.filter((excluded) => excluded.cardId !== window.cardId || excluded.startSeconds !== window.startSeconds)
: [...excludedActiveWindows, window];
if (!excludedActiveWindows.length) {
simulatedResult = null;
return;
}
const { cards, chart, options } = simulationContext;
const order = result.order.map((id) => cards.find((card) => card.id === id)!);
simulatedResult = {
...result,
...calculateExactScore(order, result.cooldownReductions, chart, { ...options, excludedActiveWindows }),
...buildExactSkillWindows(order, result.cooldownReductions, chart, excludedActiveWindows)
};
}
function resetActiveWindows(): void {
excludedActiveWindows = [];
simulatedResult = null;
}
let ratingResultKey = ''; let ratingResultKey = '';
let resultCapture: AnalysisCapture | null = null; let resultCapture: AnalysisCapture | null = null;
let pendingCapture: AnalysisCapture | null = null; let pendingCapture: AnalysisCapture | null = null;
@@ -176,7 +214,7 @@
$: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent); $: inGamePerfectNoteScore = finalNoteScore(perfectNoteScore, boardEffects.directSongScoreBonusPercent);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값'; $: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'formula-v1' ? '곡별 공식' : selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0; $: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []); $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(displayedResult?.effectiveActiveWindows ?? []);
$: timelineTicks = buildTimelineTicks(timelineDuration); $: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6); $: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => { $: candidateCount = (() => {
@@ -749,6 +787,7 @@
})) }))
}); });
const chart = await loadExactSongChart(selectedChart.path); const chart = await loadExactSongChart(selectedChart.path);
simulationContext = { cards, chart, options: { perfectNoteScore: noteScore, directSongScoreBonusPercent: songBonus } };
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) }; progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({ worker.postMessage({
type: 'optimize-exact', type: 'optimize-exact',
@@ -1037,11 +1076,11 @@
</section> </section>
</div> </div>
<div class="score-grid"> <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>{displayedResult!.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>{displayedResult!.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div> <div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div> </div>
{#if mode === 'analysis' || achievementProbabilities} {#if !simulatedResult && (mode === 'analysis' || achievementProbabilities)}
<div class="probability-grid"> <div class="probability-grid">
<div><span>최고점 100%</span><strong>{probabilityLabel(achievementProbabilities?.maximum)}</strong></div> <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>최고점의 99% 이상</span><strong>{probabilityLabel(achievementProbabilities?.atLeast99Percent)}</strong><small>{scoreThreshold(result.theoreticalMaxScore, 0.99)}점 이상</small></div>
@@ -1096,21 +1135,34 @@
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each} {#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
</div> </div>
</div> </div>
{#if mode === 'analysis'}
<p class="simulation-guide" aria-live="polite">카드별 색칠된 구간을 클릭하면 해당 액티브만 미발동으로 계산합니다. 다시 클릭하면 복원됩니다. 배치와 쿨타임은 유지됩니다.
{#if excludedActiveWindows.length}
<strong>{excludedActiveWindows.length}회 미발동 적용 중</strong>
<button type="button" on:click={resetActiveWindows}>모두 복원</button>
<span>미발동 적용 중에는 달성 확률을 표시하지 않습니다. 분석 기록에는 원래 분석 결과가 저장됩니다.</span>
{/if}
</p>
{/if}
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프"> <div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}> <div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<ScoreTimelineChart notes={result.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} /> <ScoreTimelineChart notes={displayedResult!.notes} durationSeconds={timelineDuration} minimumWidth={timelineWidth} timeTicks={timelineTicks} />
{#each result.order as cardId, slot} {#each result.order as cardId, slot}
<div class="timeline-row member-row"> <div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong> <strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}> <div class="timeline-track skill-track" style={timelineTrackStyle(timelineWidth, timelineDuration)}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window} {#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span <button
type="button"
class="skill-window active-window" class="skill-window active-window"
class:excluded={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
disabled={mode !== 'analysis' || !simulationContext || running}
style={coloredWindowStyle(window, timelineDuration)} style={coloredWindowStyle(window, timelineDuration)}
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} title={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}% · 클릭하여 미발동/복원`}
role="img" aria-label={`${resultCardName(cardId)} 액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 미발동 처리`}
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`} aria-pressed={excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && excluded.startSeconds === window.startSeconds)}
></span> on:click={() => toggleActiveWindow(window)}
></button>
{/each} {/each}
</div> </div>
</div> </div>
@@ -1290,7 +1342,13 @@
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; } .timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.skill-track { height: 36px; } .skill-track { height: 36px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); } .skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 12px; height: 12px; opacity: .72; } .active-window { top: 8px; height: 20px; opacity: .72; padding: 0; border: 0; cursor: pointer; }
.active-window:hover:not(:disabled), .active-window:focus-visible { opacity: 1; outline: 2px solid #fff; outline-offset: 2px; }
.active-window.excluded { opacity: .28; border: 1px dashed var(--member-color); background: repeating-linear-gradient(135deg, transparent 0 3px, var(--member-color) 3px 4px); }
.active-window:disabled { cursor: default; }
.simulation-guide { color: #adb5c2; font-size: 13px; line-height: 1.7; }
.simulation-guide strong, .simulation-guide span { display: block; }
.simulation-guide button { margin: 4px 0; }
.summary-row { background: rgb(255 255 255 / 1.5%); } .summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; } .summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); } .effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }