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
@@ -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;