feat: add standardized team comparison
Publish GitHub Pages output / publish (push) Successful in 42s

This commit is contained in:
2026-09-30 18:56:49 +09:00
parent d35ee95843
commit 115dc33937
13 changed files with 605 additions and 5 deletions
+8
View File
@@ -4,6 +4,14 @@ updatedAt: 2026-09-30T00:00:00Z
## 2026년 9월 30일
### 두 편성 점수 비교
`IMPROVED` 각 편성의 최적 배치에서 최고점에 도달할 확률과 두 편성의 확률 차이(%p)를 표시합니다.
`IMPROVED` 마지막으로 비교를 완료한 두 편성의 카드·개화·리더 의상과 악곡·난이도를 브라우저에 저장하고, 다음 방문에 복원합니다.
`NEW` 카드 목록과 배치 분석 사이에 편성 비교 메뉴를 추가했습니다. 같은 악곡·난이도에서 두 리더·의상과 5인 편성을 구성하고, 카드 입력 순서와 관계없이 최고점 기준 최적 배치의 평균 점수·최고 점수·종합력 차이를 확인할 수 있습니다. 리더의 빨간 노드와 편성 멤버의 파란 노드를 모두 개방하고 연결 노드 증폭은 제외하며, 기수 보너스 900·가창 보너스 10%로 통일합니다. 메모리·멤버 종합력 UP은 사용자 데이터를 공통 적용합니다. A→B 복사와 편성 교환을 지원하고, 화면 최하단에 비교 기준을 안내합니다.
### 퍼포먼스 패시브 종합력 계산 수정
`FIX` 지정 인원의 퍼포먼스를 올리는 패시브가 전체 능력치 합계 대신 개화를 반영한 기본 퍼포먼스 순으로 대상을 선택하도록 수정했습니다. 일부 편성에서 게임과 종합력이 다르게 표시되던 문제를 해결했습니다.
+4
View File
@@ -8,6 +8,7 @@
import CombinationPage from '../src/routes/combination/+page.svelte';
import HoloWorkPage from '../src/routes/tools/holo-work/+page.svelte';
import ToolsPage from '../src/routes/tools/+page.svelte';
import ComparePage from '../src/routes/compare/+page.svelte';
import EventPage from '../src/routes/event/+page.svelte';
import DataPage from '../src/routes/data/+page.svelte';
import DataCardsPage from '../src/routes/data/cards/+page.svelte';
@@ -24,6 +25,7 @@
['/rating', RatingPage],
['/combination', CombinationPage],
['/tools', ToolsPage],
['/compare', ComparePage],
['/tools/random', ToolsPage],
['/tools/holo-work', HoloWorkPage],
['/random', ToolsPage],
@@ -36,6 +38,7 @@
function pathFromHash() {
const path = window.location.hash.replace(/^#/, '') || '/';
if (path === '/tools/compare') return '/compare';
return routes.has(path) ? path : '/';
}
@@ -88,6 +91,7 @@
<button class="menu-toggle" type="button" aria-controls="primary-navigation" aria-expanded={menuOpen} on:click={() => menuOpen = !menuOpen}>{menuOpen ? '메뉴 닫기' : '전체 메뉴'}</button>
<nav id="primary-navigation" class:open={menuOpen} aria-label="주요 메뉴">
<a href="#/cards" aria-current={currentPath.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="#/compare" aria-current={currentPath.startsWith('/compare') ? 'page' : undefined}>편성 비교</a>
<a href="#/analysis" aria-current={currentPath.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="#/rating" aria-current={currentPath.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a>
<a href="#/combination" aria-current={currentPath.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
@@ -0,0 +1,77 @@
<script lang="ts">
import cardSummary from '$lib/data/cards-summary.json';
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
import type { ComparisonTeam } from '$lib/engine/team-comparison.ts';
export let team: ComparisonTeam;
export let label: string;
export let collection: Record<string, { owned?: boolean; awakening?: number }> = {};
const cards = cardSummary.cards.filter((card) => card.rarity === 5);
const leaders = [...new Map(cards.map((card) => [card.characterId, card])).values()];
$: outfits = leaderOutfitCards(cardSummary.cards, team.leaderCharacterId);
function setLeader(id: string) {
team = { ...team, leaderCharacterId: id, leaderOutfitCardId: leaderOutfitCards(cardSummary.cards, id)[0]?.id ?? '' };
}
function setCard(slot: number, id: string) {
team = { ...team, cardIds: team.cardIds.map((old, i) => i === slot ? id : old), awakenings: team.awakenings.map((old, i) => i === slot ? collection[id]?.awakening ?? 0 : old) };
}
function setAwakening(slot: number, value: number) {
team = { ...team, awakenings: team.awakenings.map((old, i) => i === slot ? Math.max(0, Math.min(5, value)) : old) };
}
function duplicate(id: string, slot: number) {
const member = cards.find((card) => card.id === id)?.characterId;
return team.cardIds.some((other, i) => i !== slot && cards.find((card) => card.id === other)?.characterId === member);
}
</script>
<section class:team-a={label === 'A'} aria-label={`편성 ${label}`}>
<h2>편성 {label}</h2>
<div class="leader">
<label>리더<select value={team.leaderCharacterId} on:change={(event) => setLeader(event.currentTarget.value)}>{#each leaders as card}<option value={card.characterId}>{card.name}</option>{/each}</select></label>
<label>리더 의상<select bind:value={team.leaderOutfitCardId}>{#each outfits as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}</select></label>
</div>
<p class="outfit">{cardSummary.cards.find((card) => card.id === team.leaderOutfitCardId)?.details.costume?.description.replace(/\[[^\]]+\]/g, '')}</p>
<div class="slot-heading"><span>개화</span></div>
{#each team.cardIds as id, slot}
<div class="slot">
<select aria-label={`편성 ${label} 카드 ${slot + 1}`} value={id} on:change={(event) => setCard(slot, event.currentTarget.value)}>
{#each cards as card}
<option value={card.id} disabled={duplicate(card.id, slot)}>{collection[card.id]?.owned ? '● ' : ''}{card.name}{card.versionName ? ` · ${card.versionName}` : ''}</option>
{/each}
</select>
<div class="awakening-field">
<div class="awakening-stepper" role="group" aria-label={`편성 ${label} 카드 ${slot + 1} 개화`}>
<button type="button" disabled={team.awakenings[slot] <= 0} aria-label={`편성 ${label} 카드 ${slot + 1} 개화 감소`} on:click={() => setAwakening(slot, team.awakenings[slot] - 1)}>‹</button>
<strong aria-live="polite">{team.awakenings[slot]}</strong>
<button type="button" disabled={team.awakenings[slot] >= 5} aria-label={`편성 ${label} 카드 ${slot + 1} 개화 증가`} on:click={() => setAwakening(slot, team.awakenings[slot] + 1)}>›</button>
</div>
</div>
</div>
{/each}
</section>
<style>
section { --accent: var(--team-b-color, #f3a5c5); --accent-rgb: 243 165 197; height: 100%; padding: 24px; border: 1px solid rgb(var(--accent-rgb) / 30%); border-top: 3px solid var(--accent); border-radius: 16px; background: #151a22; }
section.team-a { --accent: var(--team-a-color, #8bc9f5); --accent-rgb: 139 201 245; }
h2 { margin: 0 0 20px; color: var(--accent); }
label, .awakening-field { display: grid; gap: 7px; min-width: 0; color: #a4adba; font-size: 13px; }
.leader { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.slot, .slot-heading { display: grid; grid-template-columns: minmax(0, 1fr) 112px; gap: 10px; margin-top: 16px; }
.slot-heading { color: #a4adba; font-size: 13px; }
.slot-heading span { grid-column: 2; }
.slot-heading + .slot { margin-top: 7px; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 44px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: var(--accent); background: rgb(var(--accent-rgb) / 12%); }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid var(--accent); outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 14px; text-align: center; white-space: nowrap; }
select { width: 100%; min-width: 0; min-height: 44px; padding: 8px; background: #101319; border: 1px solid #414b5b; border-radius: 8px; color: #e8ebf1; font: inherit; }
.outfit { min-height: 3em; margin: 14px 0 18px; padding: 12px 14px; border-left: 3px solid var(--accent); border-radius: 6px; background: rgb(var(--accent-rgb) / 8%); color: var(--accent); font-size: 14px; font-weight: 650; line-height: 1.7; }
select:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (max-width: 520px) {
section { padding: 18px; }
.leader { grid-template-columns: 1fr; }
.slot, .slot-heading { grid-template-columns: minmax(0, 1fr) 120px; }
.awakening-stepper { height: 46px; grid-template-columns: 44px minmax(0, 1fr) 44px; }
}
</style>
+2 -1
View File
@@ -5,6 +5,7 @@
export let charts: readonly SongChartSummary[];
export let difficulty: string;
export let value: string;
export let showSelectedTitle = true;
const dispatch = createEventDispatcher<{ change: void }>();
let query = '';
@@ -49,7 +50,7 @@
{/if}
</select>
</label>
{#if selectedTitle}<p class="selected-title">선택한 악곡: {selectedTitle}</p>{/if}
{#if showSelectedTitle && selectedTitle}<p class="selected-title">선택한 악곡: {selectedTitle}</p>{/if}
{#if query.trim()}
<p class:no-results={options.length === 0} class="search-status" aria-live="polite">
{options.length > 0
+9 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateScoreAchievementProbabilities } from './index.ts';
import { calculateMaximumScoreProbabilityTimeline } from './score-probability.ts';
import { calculateMaximumScoreProbability, calculateMaximumScoreProbabilityTimeline } from './score-probability.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
function card(
@@ -30,6 +30,14 @@ function chart(times: number[], specialStartSeconds: (number | null)[] = []): Ex
};
}
test('최고점 전용 계산은 중복 발동과 독립 발동 조건을 정확히 계산한다', () => {
const options = { perfectNoteScore: 100 };
assert.equal(calculateMaximumScoreProbability([card('a', 100, 50, 0.9)], { a: 0 }, chart([1.1, 1.5]), options), 0.5);
assert.equal(calculateMaximumScoreProbability([card('a', 100, 50)], { a: 0 }, chart([1.1, 2.1]), options), 0.25);
assert.equal(calculateMaximumScoreProbability([card('a', 100, 50), card('b', 100, 50)], { a: 0, b: 0 }, chart([1.1]), options), 0.75);
assert.equal(calculateMaximumScoreProbability([card('a', 100, 100)], { a: 0 }, chart([1.1, 2.1]), options), 1);
});
test('한 번의 발동이 여러 노트에 영향을 주어도 최고점 확률은 한 번만 곱한다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 50, 0.9)],
+10
View File
@@ -221,6 +221,16 @@ function randomGenerator(seed: number): () => number {
};
}
export function calculateMaximumScoreProbability(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): number {
const model = prepareModel(cards, reductions, chart, options);
return exactClauseProbability(model.maximumClauses, model.attempts);
}
export function estimateScoreAchievementProbabilities(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { comparisonDelta, prepareComparisonTeam, type ComparisonCard, type ComparisonTeam } from './team-comparison.ts';
import { optimizeExactOrderAndCooldowns, rankExactCooldownCombinations } from './exact-optimizer.ts';
import type { GrowthSnapshot } from '../models/analysis-history.ts';
import type { ExactSongChart } from './types.ts';
import { calculateUnitPower } from './unit-power.ts';
import type { BoardSummarySource } from './board-effects.ts';
import { parseSavedComparison, type SavedComparison } from '../storage/team-comparison.ts';
const catalog: ComparisonCard[] = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')).cards;
const members = [...new Map(catalog.filter((card) => card.rarity === 5).map((card) => [card.characterId, card])).values()].slice(0, 5);
const team: ComparisonTeam = { cardIds: members.map((card) => card.id), awakenings: [0, 0, 0, 0, 0], leaderCharacterId: members[0].characterId, leaderOutfitCardId: members[0].id };
const growth: GrowthSnapshot = { collection: {}, memberBoards: {}, boardCards: {}, memoryCount: 0, memberPowerUpPercent: 0 };
const song = { scoreRatio: 30 };
const chart: ExactSongChart = { songId: 'test', difficulty: 'expert', durationSeconds: 60, scoreRatio: 30,
notes: Array.from({ length: 60 }, (_, i) => ({ timeSeconds: i + 1, type: 'regular', isFlick: false })), specialStartSeconds: [1, 12, 23, 34, 45] };
const prepare = (value: ComparisonTeam, memory = 0) => prepareComparisonTeam(value, catalog, { nodeGroups: {} }, growth, song, memory);
test('saved comparison restores independent teams, awakenings, leaders and song settings', () => {
const saved: SavedComparison = { schemaVersion: 1, songId: 'test-song', difficulty: 'hard', teamA: team,
teamB: { ...team, cardIds: [...team.cardIds].reverse(), awakenings: [5, 4, 3, 2, 1], leaderCharacterId: members[1].characterId, leaderOutfitCardId: members[1].id } };
const restored = parseSavedComparison(JSON.stringify(saved), catalog);
assert.deepEqual(restored, saved);
restored.teamB.awakenings[0] = 0;
assert.equal(saved.teamB.awakenings[0], 5);
assert.deepEqual(restored.teamA, team);
});
test('invalid or obsolete saved comparison cannot restore invalid UI selections', () => {
const saved: SavedComparison = { schemaVersion: 1, songId: 'song', difficulty: 'expert', teamA: team, teamB: team };
for (const invalid of [null, { ...saved, schemaVersion: 2 }, { ...saved, difficulty: 'invalid' },
{ ...saved, teamB: { ...team, awakenings: [0, 1, 2, 3, 6] } },
{ ...saved, teamA: { ...team, cardIds: team.cardIds.map(() => team.cardIds[0]) } },
{ ...saved, teamA: { ...team, leaderOutfitCardId: members[1].id } }]) {
assert.throws(() => parseSavedComparison(JSON.stringify(invalid), catalog));
}
assert.throws(() => parseSavedComparison('{broken', catalog));
assert.throws(() => parseSavedComparison(JSON.stringify(saved), catalog.filter((card) => card.id !== team.cardIds[0])));
});
test('identical teams tie; memory increases score and inputs remain unchanged', () => {
const before = JSON.stringify({ team, growth });
const a = prepare(team);
const b = prepare(structuredClone(team));
const score = (context: ReturnType<typeof prepare>) => optimizeExactOrderAndCooldowns(context.cards, chart, context);
const scoreA = score(a);
const scoreB = score(b);
assert.equal(scoreA.expectedScore, scoreB.expectedScore);
assert.equal(scoreA.theoreticalMaxScore, scoreB.theoreticalMaxScore);
assert.equal(comparisonDelta(scoreA.expectedScore, scoreB.expectedScore).difference, 0);
const stronger = prepare(team, 100);
assert.ok(stronger.overallPower > a.overallPower);
assert.ok(score(stronger).expectedScore > scoreA.expectedScore);
assert.equal(JSON.stringify({ team, growth }), before);
});
test('reject duplicate members, missing cards, invalid awakenings, mismatched leader outfits and missing score data', () => {
assert.throws(() => prepare({ ...team, cardIds: team.cardIds.map(() => team.cardIds[0]) }), /중복/);
assert.throws(() => prepare({ ...team, cardIds: ['missing', ...team.cardIds.slice(1)] }), /5성/);
assert.throws(() => prepare({ ...team, awakenings: [6, 0, 0, 0, 0] }), /개화/);
assert.throws(() => prepare({ ...team, leaderCharacterId: members[1].characterId }), /리더/);
assert.throws(() => prepareComparisonTeam(team, catalog, { nodeGroups: {} }, growth, { scoreRatio: null }, 0), /환산/);
});
test('changing B awakening does not change A and affects power and skills', () => {
const a = prepare(team);
const b = prepare({ ...team, awakenings: [5, 5, 5, 5, 5] });
assert.ok(b.overallPower > a.overallPower);
assert.notDeepEqual(b.cards, a.cards);
assert.deepEqual(team.awakenings, [0, 0, 0, 0, 0]);
});
test('difference consistently uses A as baseline, including ties and zero', () => {
assert.deepEqual(comparisonDelta(100, 120), { difference: 20, percent: 20 });
assert.deepEqual(comparisonDelta(120, 100), { difference: -20, percent: -20 / 120 * 100 });
assert.deepEqual(comparisonDelta(0, 10), { difference: 10, percent: null });
assert.deepEqual(comparisonDelta(0, 0), { difference: 0, percent: 0 });
});
test('comparison fixes the song bonus at 10% even without singer board bonuses', () => {
const context = prepare(team);
assert.equal(context.directSongScoreBonusPercent, 10);
const [boosted] = rankExactCooldownCombinations(context.cards, chart, context, 1);
const [withoutBonus] = rankExactCooldownCombinations(context.cards, chart, { ...context, directSongScoreBonusPercent: 0 }, 1);
assert.ok(boosted.expectedScore > withoutBonus.expectedScore);
assert.ok(boosted.theoreticalMaxScore > withoutBonus.theoreticalMaxScore);
});
test('reversing input slots preserves card awakenings, optimized layout, cooldowns and scores', () => {
const input = { ...team, awakenings: [0, 1, 2, 3, 5] };
const before = structuredClone(input);
const a = prepare(input);
const b = prepare({ ...input, cardIds: [...input.cardIds].reverse(), awakenings: [...input.awakenings].reverse() });
assert.deepEqual(a, b);
const [resultA] = rankExactCooldownCombinations(a.cards, chart, a, 1);
const [resultB] = rankExactCooldownCombinations(b.cards, chart, b, 1);
assert.deepEqual(resultA.order, resultB.order);
assert.deepEqual(resultA.cooldownReductions, resultB.cooldownReductions);
assert.equal(resultA.expectedScore, resultB.expectedScore);
assert.equal(resultA.theoreticalMaxScore, resultB.theoreticalMaxScore);
assert.deepEqual(input, before);
});
test('all five cards receive 900 in each stat regardless of stored generation nodes', () => {
const base = calculateUnitPower(members.map((card) => ({ card, awakening: 0 })), members[0].details.costume);
assert.equal(prepare(team).overallPower, base.overallPower + 900 * 3 * 5);
const boards: BoardSummarySource = { nodeGroups: { 'G-test': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 300, targetId: 'skill_tree_effect_target-all' } }] } };
const stored = { ...growth, memberBoards: { [members[0].characterId]: ['G-test'] } };
assert.deepEqual(prepareComparisonTeam(team, catalog, boards, stored, song, 0), prepare(team));
});
test('standardized boards ignore saved unlocks and preserve source data', () => {
const boards: BoardSummarySource = JSON.parse(readFileSync(new URL('../data/boards-summary.json', import.meta.url), 'utf8'));
const fullBoards = Object.fromEntries(members.map((card) => {
const member = boards.members!.find((member) => member.id === card.characterId)!;
return [card.characterId, boards.models![member.positionGroupId].filter((position) => position.groupId.startsWith('B')).map((position) => position.groupId)];
}));
const before = structuredClone(growth);
const automatic = prepareComparisonTeam(team, catalog, boards, growth, song, 0);
const saved = prepareComparisonTeam(team, catalog, boards, { ...growth, memberBoards: fullBoards }, song, 0);
assert.deepEqual(automatic, saved);
assert.ok(automatic.overallPower > prepare(team).overallPower);
assert.ok(automatic.cards.some((card) => (card.active.activationRateUpPercent ?? 0) > 0));
const withRed: BoardSummarySource = { ...boards, nodeGroups: { ...boards.nodeGroups, 'R-test': [{ characterIds: [], effect: { type: 'performance_up', value: 100, targetId: 'skill_tree_effect_target-all' } }] } };
const stored = { ...growth, memberBoards: { [team.leaderCharacterId]: ['R-test'] } };
assert.deepEqual(prepareComparisonTeam(team, catalog, withRed, stored, song, 0), automatic);
assert.deepEqual(growth, before);
assert.deepEqual(stored.memberBoards[team.leaderCharacterId], ['R-test']);
});
test('leader outside the team receives full red nodes; members receive only blue nodes and connections are ignored', () => {
const leader = catalog.find((card) => card.rarity === 5 && !members.some((member) => member.characterId === card.characterId))!;
const input = { ...team, leaderCharacterId: leader.characterId, leaderOutfitCardId: leader.id };
const boards: BoardSummarySource = {
members: [...members, leader].map((card) => ({ id: card.characterId, positionGroupId: 'model' })),
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'B-test', x: 1, y: 0 }, { groupId: 'R-test', x: 0, y: 1 }] },
nodeGroups: {
S: [{ characterIds: [], effect: null }],
'B-test': [{ characterIds: [], effect: { type: 'performance_up', value: 100, targetId: 'skill_tree_effect_target-skill_tree_character' } }],
'R-test': [{ characterIds: [], effect: { type: 'performance_up', value: 200, targetId: 'skill_tree_effect_target-all' } }]
}
};
const base = prepare(input);
const standard = prepareComparisonTeam(input, catalog, boards, growth, song, 0);
assert.equal(standard.overallPower, base.overallPower + 100 * 5 + 200 * 5);
const connection = structuredClone(catalog[0]);
connection.id = 'comparison-connection';
connection.details.connect = [{ level: 1, effectPermilUp: 10000, extent: [{ x: 1, y: 0 }, { x: 0, y: 1 }] }];
const connected = { ...growth,
memberBoards: Object.fromEntries([...members, leader].map((card) => [card.characterId, ['S', 'B-test', 'R-test']])),
boardCards: Object.fromEntries([...members, leader].map((card) => [card.characterId, { S: connection.id }])),
collection: { [connection.id]: { owned: true, awakening: 5 } }
};
const before = structuredClone(connected);
assert.deepEqual(prepareComparisonTeam(input, [...catalog, connection], boards, connected, song, 0), standard);
assert.deepEqual(connected, before);
});
+53
View File
@@ -0,0 +1,53 @@
import { calculateBoardEffects, type BoardSummarySource, type BoardEffectSong } from './board-effects.ts';
import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts';
import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
import { perfectNoteScoreFromPower } from './score.ts';
import type { GrowthSnapshot } from '../models/analysis-history.ts';
export type ComparisonTeam = { cardIds: string[]; awakenings: number[]; leaderCharacterId: string; leaderOutfitCardId: string };
export type ComparisonCard = UnitPowerCard & OptimizerSourceCard & {
rarity: number;
details: UnitPowerCard['details'] & OptimizerSourceCard['details'] & {
costume: { name: string; description: string } | null;
connect: { level: number; effectPermilUp: number; extent: { x: number; y: number }[] }[];
};
};
export function prepareComparisonTeam(team: ComparisonTeam, catalog: readonly ComparisonCard[], boards: BoardSummarySource,
growth: GrowthSnapshot, song: BoardEffectSong & { scoreRatio: number | null }, memoryPermil: number) {
const sources = team.cardIds.map((id) => catalog.find((card) => card.id === id));
if (sources.length !== 5 || sources.some((card) => !card || card.rarity !== 5)) throw new Error('각 편성에 5성 카드 5장을 선택해 주세요.');
const members = sources as ComparisonCard[];
if (new Set(members.map((card) => card.characterId)).size !== 5) throw new Error('한 편성에는 같은 멤버를 중복해서 넣을 수 없습니다.');
if (team.awakenings.length !== 5 || team.awakenings.some((value) => !Number.isInteger(value) || value < 0 || value > 5)) throw new Error('개화는 0~5 사이로 선택해 주세요.');
const awakeningByCardId = new Map(team.cardIds.map((id, index) => [id, team.awakenings[index]]));
// Use a stable search order so tied optimal layouts also ignore input slot order.
members.sort((left, right) => left.id.localeCompare(right.id));
const outfit = catalog.find((card) => card.id === team.leaderOutfitCardId);
if (!outfit?.details.costume || outfit.characterId !== team.leaderCharacterId) throw new Error('리더와 일치하는 의상을 선택해 주세요.');
if (song.scoreRatio === null || song.scoreRatio <= 0) throw new Error('점수 환산 데이터가 없는 채보입니다.');
const effectiveBoards: Record<string, string[]> = {};
const memberIds = new Set(members.map((card) => card.characterId));
for (const characterId of new Set([...memberIds, team.leaderCharacterId])) {
const modelId = boards.members?.find((member) => member.id === characterId)?.positionGroupId;
const positions = modelId ? boards.models?.[modelId] ?? [] : [];
effectiveBoards[characterId] = [...new Set(positions.filter((position) =>
(memberIds.has(characterId) && position.groupId.startsWith('B')) ||
(characterId === team.leaderCharacterId && position.groupId.startsWith('R'))
).map((position) => position.groupId))];
}
// Standardized comparison never reads saved board unlocks or connection multipliers.
const effects = calculateBoardEffects(boards, effectiveBoards, members, team.leaderCharacterId, song, undefined,
{ directSongScoreBonusPercent: 10, generationBonusByCardId: Object.fromEntries(members.map((card) => [card.id, 900])) });
const power = calculateUnitPower(members.map((card) => ({ card, awakening: awakeningByCardId.get(card.id)! })), outfit.details.costume, effects, memoryPermil, growth.memberPowerUpPercent);
const cards = members.map((card) => {
const adapted = toOptimizerCard(card, awakeningByCardId.get(card.id)!, effects.allowedCooldownReductionsByCardId[card.id], members);
return { ...adapted, active: { ...adapted.active, activationRateUpPercent: effects.activationRateUpByCardId[card.id] ?? 0 }, scoreSupportPercent: power.outfitScoreSupportPercent + effects.scoreSupportPercent };
});
return { cards, overallPower: power.overallPower, perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, song.scoreRatio), directSongScoreBonusPercent: effects.directSongScoreBonusPercent };
}
/** B minus A; A is always the percentage denominator. */
export function comparisonDelta(a: number, b: number) {
return { difference: b - a, percent: a === 0 ? (b === 0 ? 0 : null) : (b - a) / a * 100 };
}
+24
View File
@@ -0,0 +1,24 @@
import { isDifficulty, type DifficultyKey } from '../data/difficulty.ts';
import type { ComparisonTeam } from '../engine/team-comparison.ts';
export const COMPARISON_STORAGE_KEY = 'holodori.comparison.v1';
export type SavedComparison = { schemaVersion: 1; songId: string; difficulty: DifficultyKey; teamA: ComparisonTeam; teamB: ComparisonTeam };
type Card = { id: string; characterId: string; rarity: number; details: { costume: unknown } };
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
export function parseSavedComparison(text: string, catalog: readonly Card[]): SavedComparison {
const value: unknown = JSON.parse(text);
const validTeam = (team: unknown): team is ComparisonTeam => {
if (!object(team) || !Array.isArray(team.cardIds) || team.cardIds.length !== 5 ||
!Array.isArray(team.awakenings) || team.awakenings.length !== 5 ||
!team.awakenings.every((n) => Number.isInteger(n) && n >= 0 && n <= 5)) return false;
const cards = team.cardIds.map((id) => catalog.find((card) => card.id === id && card.rarity === 5));
if (cards.some((card) => !card) || new Set(cards.map((card) => card!.characterId)).size !== 5) return false;
return catalog.some((card) => card.id === team.leaderOutfitCardId && card.characterId === team.leaderCharacterId && Boolean(card.details.costume));
};
if (!object(value) || value.schemaVersion !== 1 || typeof value.songId !== 'string' || !value.songId ||
!isDifficulty(value.difficulty) || !validTeam(value.teamA) || !validTeam(value.teamB)) {
throw new Error('저장된 비교 조건을 읽지 못했습니다. 편성을 다시 선택해 주세요.');
}
return { schemaVersion: 1, songId: value.songId, difficulty: value.difficulty, teamA: value.teamA, teamB: value.teamB };
}
+7 -3
View File
@@ -1,6 +1,6 @@
/// <reference lib="webworker" />
import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import { calculateMaximumScoreProbability, countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import { rankExactCooldownCombinations } from '../engine/exact-optimizer.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
@@ -11,6 +11,7 @@ type OptimizeRequest = {
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
includeAchievementProbabilities?: boolean;
includeMaximumScoreProbability?: boolean;
cooldownResultLimit?: number;
};
@@ -81,7 +82,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
};
const request = event.data;
const results = rankExactCooldownCombinations(request.cards, request.chart, options, request.cooldownResultLimit === 5 ? 5 : 1);
if (request.includeAchievementProbabilities) self.postMessage({ type: 'probability-start' });
if (request.includeAchievementProbabilities || request.includeMaximumScoreProbability) self.postMessage({ type: 'probability-start' });
const cooldownRankings = results.map((result) => {
const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities(
cardsForResult(request.cards, result),
@@ -89,7 +90,10 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
request.chart,
{ ...options, sampleCount: 25_000 }
) : undefined;
return { result, achievementProbabilities };
const maximumScoreProbability = request.includeMaximumScoreProbability
? achievementProbabilities?.maximum ?? calculateMaximumScoreProbability(cardsForResult(request.cards, result), result.cooldownReductions, request.chart, options)
: undefined;
return { result, achievementProbabilities, maximumScoreProbability };
});
self.postMessage({ type: 'result', ...cooldownRankings[0], cooldownRankings });
return;
+1
View File
@@ -34,6 +34,7 @@
<button class="menu-toggle" type="button" aria-controls="primary-navigation" aria-expanded={menuOpen} on:click={() => menuOpen = !menuOpen}>{menuOpen ? '메뉴 닫기' : '전체 메뉴'}</button>
<nav id="primary-navigation" class:open={menuOpen} aria-label="주요 메뉴">
<a href="/cards" aria-current={$page.url.pathname.startsWith('/cards') ? 'page' : undefined}>카드 목록</a>
<a href="/compare" aria-current={$page.url.pathname.startsWith('/compare') ? 'page' : undefined}>편성 비교</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/rating" aria-current={$page.url.pathname.startsWith('/rating') ? 'page' : undefined}>리더 레이팅</a>
<a href="/combination" aria-current={$page.url.pathname.startsWith('/combination') ? 'page' : undefined}>조합 탐색</a>
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import SongPicker from '$lib/components/SongPicker.svelte';
import ComparisonTeamEditor from '$lib/components/ComparisonTeamEditor.svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import catalog from '$lib/data/exact-chart-catalog.json';
import { loadExactSongChart } from '$lib/data/chart-loader.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { difficulties, difficultyLabel, type DifficultyKey } from '$lib/data/difficulty.ts';
import { comparisonDelta, prepareComparisonTeam, type ComparisonTeam } from '$lib/engine/team-comparison.ts';
import { memoryParameterPermil } from '$lib/engine/memory.ts';
import type { ExactOptimizationResult, ExactSongChart } from '$lib/engine/types.ts';
import type { GrowthSnapshot } from '$lib/models/analysis-history.ts';
import { createUserData } from '$lib/models/user-data.ts';
import { defaultPreferences } from '$lib/models/default-preferences.ts';
import { parseUserData, USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage.ts';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
import { COMPARISON_STORAGE_KEY, parseSavedComparison, type SavedComparison } from '$lib/storage/team-comparison.ts';
const cards = cardSummary.cards.filter((card) => card.rarity === 5);
const unique = [...new Map(cards.map((card) => [card.characterId, card])).values()].slice(0, 5);
const initialTeam = (): ComparisonTeam => ({ cardIds: unique.map((card) => card.id), awakenings: unique.map(() => 0), leaderCharacterId: unique[0].characterId, leaderOutfitCardId: unique[0].id });
let teamA = initialTeam();
let teamB = initialTeam();
let growth: GrowthSnapshot = createUserData(defaultPreferences());
let clock = createInitialReleaseClock($page.data?.serverNow);
let releaseNow = clock.now();
$: charts = releasedSongCharts(catalog.charts, releaseNow);
let songId = releasedSongCharts(catalog.charts, releaseNow)[0]?.songId ?? '';
let difficulty: DifficultyKey = 'expert';
$: selectedChart = charts.find((chart) => chart.songId === songId && chart.difficulty === difficulty);
let ready = false;
let running = false;
let error = '';
let storageNotice = '';
let comparisonNotice = '';
let runId = 0;
let progress = ['대기', '대기'];
type Result = { score: ExactOptimizationResult; overallPower: number; maximumScoreProbability: number };
let results: Result[] | null = null;
const jobs = new Set<() => void>();
$: inputKey = JSON.stringify({ songId, difficulty, teamA, teamB, growth });
let lastInputKey = '';
$: if (inputKey !== lastInputKey) { lastInputKey = inputKey; invalidate(); }
$: metrics = results ? [
{ label: '평균 점수', a: results[0].score.expectedScore, b: results[1].score.expectedScore, unit: '점' },
{ label: '최고 점수', a: results[0].score.theoreticalMaxScore, b: results[1].score.theoreticalMaxScore, unit: '점' },
{ label: '종합력', a: results[0].overallPower, b: results[1].overallPower, unit: '' }
] : [];
function invalidate() {
runId++;
for (const cancel of jobs) cancel();
running = false; results = null; error = '';
}
function loadGrowth() {
try {
const text = localStorage.getItem(USER_DATA_STORAGE_KEY);
const data = text ? parseUserData(text, defaultPreferences()) : createUserData(defaultPreferences());
growth = { collection: data.collection, memberBoards: data.memberBoards, boardCards: data.boardCards, memoryCount: Math.min(data.memoryCount, memorySummary.maxCount), memberPowerUpPercent: data.memberPowerUpPercent };
storageNotice = text ? '' : '저장된 사용자 데이터가 없어 메모리와 멤버 종합력 UP은 0으로 적용합니다.';
} catch { storageNotice = '사용자 데이터를 읽지 못했습니다. 사용자 데이터 페이지에서 확인해 주세요.'; ready = false; return; }
ready = true;
}
onMount(() => {
let mounted = true;
loadGrowth();
const owned = [...new Map(cards.filter((card) => growth.collection[card.id]?.owned).map((card) => [card.characterId, card])).values()].slice(0, 5);
if (owned.length === 5) {
teamA = { cardIds: owned.map((card) => card.id), awakenings: owned.map((card) => growth.collection[card.id]?.awakening ?? 0), leaderCharacterId: owned[0].characterId, leaderOutfitCardId: owned[0].id };
teamB = structuredClone(teamA);
}
try {
const savedText = localStorage.getItem(COMPARISON_STORAGE_KEY);
if (savedText) {
const saved = parseSavedComparison(savedText, cardSummary.cards);
teamA = saved.teamA;
teamB = saved.teamB;
if (releasedSongCharts(catalog.charts, clock.now()).some((chart) => chart.songId === saved.songId && chart.difficulty === saved.difficulty)) {
songId = saved.songId;
difficulty = saved.difficulty;
} else comparisonNotice = '저장된 악곡·난이도를 사용할 수 없어 편성만 복원했습니다.';
}
} catch { comparisonNotice = '저장된 비교 조건을 불러오지 못했습니다. 편성을 다시 선택해 주세요.'; }
void synchronizeReleaseClock().then((value) => { if (mounted) { clock = value; releaseNow = clock.now(); } });
const timer = window.setInterval(() => releaseNow = clock.now(), 1000);
const onStorage = (event: StorageEvent) => { if (event.key === USER_DATA_STORAGE_KEY || event.key === null) loadGrowth(); };
window.addEventListener('storage', onStorage);
return () => { mounted = false; invalidate(); window.clearInterval(timer); window.removeEventListener('storage', onStorage); };
});
function optimize(context: ReturnType<typeof prepareComparisonTeam>, chart: ExactSongChart, index: number): Promise<Result> {
return new Promise((resolve, reject) => {
const worker = createScoreOptimizerWorker();
const finish = () => { worker.terminate(); jobs.delete(cancel); };
const cancel = () => { finish(); reject(new Error('비교를 취소했습니다.')); };
jobs.add(cancel);
worker.onerror = () => { finish(); reject(new Error('비교 계산을 실행하지 못했습니다. 다시 시도해 주세요.')); };
worker.onmessage = (event) => {
if (event.data.type === 'progress') {
const { evaluatedCandidates, totalCandidates } = event.data.progress;
progress[index] = `${Math.round(evaluatedCandidates / Math.max(1, totalCandidates) * 100)}%`;
} else if (event.data.type === 'probability-start') {
progress[index] = '최고점 확률 계산 중';
} else if (event.data.type === 'result') {
finish(); progress[index] = '완료';
resolve({ score: event.data.result, overallPower: context.overallPower, maximumScoreProbability: event.data.maximumScoreProbability });
} else if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
};
worker.postMessage({ type: 'optimize-exact', ...context, chart, includeMaximumScoreProbability: true });
});
}
async function compare() {
invalidate();
const token = runId;
if (!selectedChart) { error = '선택한 악곡·난이도의 채보가 없습니다.'; return; }
running = true; progress = ['채보 로딩', '채보 로딩'];
try {
const memory = memoryParameterPermil(growth.memoryCount, memorySummary.effects);
const contexts = [teamA, teamB].map((team) => prepareComparisonTeam(team, cardSummary.cards, boardSummary, growth, selectedChart!, memory));
const snapshot: SavedComparison = structuredClone({ schemaVersion: 1, songId, difficulty, teamA, teamB });
const chart = await loadExactSongChart(selectedChart.path);
if (token !== runId) return;
progress = ['계산 중', '계산 중'];
const computed = await Promise.all(contexts.map((context, index) => optimize(context, chart, index)));
if (token === runId) {
results = computed;
try {
localStorage.setItem(COMPARISON_STORAGE_KEY, JSON.stringify(snapshot));
comparisonNotice = '';
} catch { comparisonNotice = '비교는 완료했지만 조건을 저장하지 못했습니다. 브라우저 저장 공간 설정을 확인해 주세요.'; }
}
} catch (cause) {
if (token === runId) { for (const cancel of jobs) cancel(); error = cause instanceof Error ? cause.message : '비교하지 못했습니다.'; }
} finally { if (token === runId) running = false; }
}
const number = (value: number) => value.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
function probability(value: number) {
return value > 0 && value < 0.0001 ? '0.01% 미만' : `${(value * 100).toFixed(2)}%`;
}
</script>
<svelte:head><title>편성 비교 | 홀로도리 계산기</title><meta name="description" content="같은 악곡과 난이도에서 두 리더·편성의 평균 점수와 최고 점수를 비교합니다."></svelte:head>
<main>
<header class="page-header">
<h1>편성 비교</h1>
<p class="page-description">같은 악곡·난이도에서 두 리더와 편성이 얼마나 차이 나는지 확인하세요.</p>
</header>
<section class="panel" aria-label="공통 비교 조건">
<div class="song-fields">
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={songId} showSelectedTitle={false} /></div>
<label class="difficulty">난이도<select bind:value={difficulty}>{#each difficulties as value}<option value={value}>{difficultyLabel(value)}</option>{/each}</select></label>
</div>
{#if !selectedChart}<p role="alert">이 악곡·난이도는 공개된 채보가 없습니다. 다른 조건을 선택해 주세요.</p>{/if}
</section>
<div class="actions"><button on:click={() => teamB = structuredClone(teamA)}>A → B 복사</button><button on:click={() => { const old = teamA; teamA = teamB; teamB = old; }}>A ↔ B 교환</button></div>
<div class="teams"><ComparisonTeamEditor label="A" bind:team={teamA} collection={growth.collection} /><ComparisonTeamEditor label="B" bind:team={teamB} collection={growth.collection} /></div>
<div class="actions"><button class="primary" on:click={compare} disabled={!ready || !selectedChart || running}>{running ? '비교 계산 중…' : '두 편성 비교'}</button>{#if running}<button on:click={invalidate}>취소</button>{/if}</div>
{#if running}<p role="status">편성 A: {progress[0]} · 편성 B: {progress[1]}</p>{/if}
{#if error}<p class="error" role="alert">{error}</p>{/if}
{#if comparisonNotice}<p class="hint" role="status">{comparisonNotice}</p>{/if}
{#if results}
{@const probabilityDelta = (results[1].maximumScoreProbability - results[0].maximumScoreProbability) * 100}
<section class="panel result" aria-label="비교 결과" aria-live="polite">
<h2>비교 결과</h2><p>{selectedChart?.title} · {difficultyLabel(difficulty)}</p>
<div class="table-wrap"><table><thead><tr><th>비교 항목</th><th class="team-a">편성 A</th><th class="team-b">편성 B</th><th>차이</th></tr></thead><tbody>
{#each metrics as metric}
{@const delta = comparisonDelta(Math.min(metric.a, metric.b), Math.max(metric.a, metric.b))}
<tr><th>{metric.label}</th><td>{number(metric.a)}{metric.unit}</td><td>{number(metric.b)}{metric.unit}</td>
<td class="difference">
{#if delta.difference === 0}동일
{:else}
<strong class:team-a={metric.a > metric.b} class:team-b={metric.b > metric.a}>{metric.a > metric.b ? 'A' : 'B'}</strong>가
{#if delta.percent === null}<strong>{number(delta.difference)}{metric.unit}</strong> 우위 (비율 계산 불가)
{:else}<strong>{delta.percent < 0.01 ? '0.01% 미만' : `${delta.percent.toFixed(2)}%`}</strong> 우위{/if}
{/if}
</td>
</tr>
{/each}
<tr><th>최고점 달성 확률</th><td>{probability(results[0].maximumScoreProbability)}</td><td>{probability(results[1].maximumScoreProbability)}</td>
<td class="difference">
{#if probabilityDelta === 0}동일
{:else}<strong class:team-a={probabilityDelta < 0} class:team-b={probabilityDelta > 0}>{probabilityDelta > 0 ? 'B' : 'A'}</strong>가 <strong>{Math.abs(probabilityDelta) < 0.01 ? '0.01%p' : `${Math.abs(probabilityDelta).toFixed(2)}%p`}</strong>{Math.abs(probabilityDelta) < 0.01 ? ' 미만' : ''} 높음{/if}
</td>
</tr>
</tbody></table></div>
<p class="hint">우위 비율은 값이 낮은 편성을 기준으로 계산합니다. 최고점 달성 확률의 차이는 %p로 표시합니다.</p>
<p class="hint">해당 결과는 추정치며, 실제 배치 분석 결과와 다를 수 있습니다.</p>
</section>
{/if}
<section class="panel comparison-rules" aria-labelledby="comparison-rules-title">
<h2 id="comparison-rules-title">비교 기준</h2>
<ul>
<li>리더의 빨간 노드 전체, 편성 멤버의 파란 노드 전체를 개방한 상태로 계산합니다.</li>
<li>저장된 보드 개방 상태와 리더·멤버의 연결 노드 증폭 효과는 적용하지 않습니다.</li>
<li>기수 보너스는 카드마다 900, 가창 보너스는 10%로 고정합니다.</li>
<li>메모리 {growth.memoryCount}개 · 멤버 종합력 UP {growth.memberPowerUpPercent}%는 저장된 값을 두 편성에 동일하게 적용합니다.</li>
<li>5성 카드 · 최대 레벨 · ALL PERFECT 기준이며, 개화는 편성별 입력값을 사용합니다.</li>
<li>마지막으로 비교를 완료한 두 편성·리더 의상·개화·악곡·난이도는 이 브라우저에 저장되어 다음 방문에 복원됩니다.</li>
<li>입력 순서와 관계없이 스페셜 순서·쿨타임을 최고 점수 기준으로 최적화합니다. 평균 점수는 이 최적 배치에서 액티브 발동 확률을 반영한 기대값입니다.</li>
<li>최고점 달성 확률은 각 편성의 최적 배치에서 해당 편성의 최고 점수에 도달할 확률입니다. 액티브 발동이 서로 독립이라는 가정으로 계산하며, 확률 차이는 %p로 표시합니다.</li>
</ul>
<p>실제 점수는 판정과 액티브 발동 결과에 따라 달라집니다.</p>
{#if storageNotice}<p role="status">{storageNotice}</p>{/if}
</section>
</main>
<style>
main { --team-a-color: #8bc9f5; --team-b-color: #f3a5c5; width: min(1160px, calc(100% - var(--page-gutter))); margin: auto; padding: var(--page-content-top) 0 80px; }
.page-header { margin-bottom: var(--page-header-bottom); }
h1 { margin: 0; color: #f3f5f8; font-size: var(--page-title-size); line-height: 1.15; letter-spacing: -.045em; }
.page-description { margin: 10px 0 0; color: #929cab; font-size: var(--page-description-size); line-height: 1.6; }
p { color: #a4adba; line-height: 1.7; }
.panel { padding: 24px; background: #151a22; border: 1px solid #343c49; border-radius: 16px; }
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) minmax(140px, .85fr); align-items: start; gap: 12px; }
.song-picker-field { min-width: 0; }
.difficulty { display: grid; gap: 7px; min-width: 0; color: #8f98a8; font-size: 14px; font-weight: 750; }
.difficulty select { height: 42px; min-height: 42px; padding: 0 12px; border-color: #343c49; background: #11151b; }
select, button { min-height: 44px; padding: 10px 16px; border: 1px solid #414b5b; border-radius: 9px; background: #202632; color: #e8ebf1; font: inherit; }
button { cursor: pointer; font-weight: 750; } button:disabled { opacity: .5; cursor: not-allowed; }
:is(button, select):focus-visible { outline: 3px solid #ed8bb5; outline-offset: 3px; }
.actions { display: flex; gap: 10px; margin: 20px 0; flex-wrap: wrap; }
.primary { background: #ed8bb5; color: #21121a; min-width: 220px; }
.teams { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.hint { font-size: 13px; color: #a4adba; } .error { color: #ffa4a4; }
.comparison-rules { margin-top: 28px; color: #8f98a8; font-size: 12px; line-height: 1.6; }
.comparison-rules h2 { margin: 0 0 12px; color: #b9c1cd; font-size: 15px; }
.comparison-rules ul { margin: 0; padding-left: 18px; }
.comparison-rules li { padding: 3px 0; line-height: 1.6; }
.comparison-rules p { margin: 10px 0 0; color: inherit; }
.result { margin-top: 24px; }
.table-wrap { overflow-x: auto; } table { border-collapse: collapse; width: 100%; text-align: left; font-variant-numeric: tabular-nums; }
td, th { padding: 16px 12px; border-bottom: 1px solid #343c49; white-space: nowrap; }
.difference { color: #a4adba; }
.difference strong { color: #f3f5f8; font-weight: 750; }
.team-a, .difference strong.team-a { color: var(--team-a-color); }
.team-b, .difference strong.team-b { color: var(--team-b-color); }
th.team-a { background: rgb(139 201 245 / 7%); }
th.team-b { background: rgb(243 165 197 / 7%); }
@media (max-width: 760px) { .teams { grid-template-columns: 1fr; } .panel { padding: 18px; } }
@media (max-width: 520px) { .song-fields { grid-template-columns: minmax(0, 1fr); } }
</style>
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(307, '/compare');
}