카드 목록
+ 편성 비교
배치 분석
리더 레이팅
조합 탐색
diff --git a/src/lib/components/ComparisonTeamEditor.svelte b/src/lib/components/ComparisonTeamEditor.svelte
new file mode 100644
index 0000000..78f9f2c
--- /dev/null
+++ b/src/lib/components/ComparisonTeamEditor.svelte
@@ -0,0 +1,77 @@
+
+
+
+ 편성 {label}
+
+ 리더 setLeader(event.currentTarget.value)}>{#each leaders as card}{card.name} {/each}
+ 리더 의상{#each outfits as card}{card.details.costume?.name} {/each}
+
+ {cardSummary.cards.find((card) => card.id === team.leaderOutfitCardId)?.details.costume?.description.replace(/\[[^\]]+\]/g, '')}
+ 개화
+ {#each team.cardIds as id, slot}
+
+
setCard(slot, event.currentTarget.value)}>
+ {#each cards as card}
+ {collection[card.id]?.owned ? '● ' : ''}{card.name}{card.versionName ? ` · ${card.versionName}` : ''}
+ {/each}
+
+
+
+ setAwakening(slot, team.awakenings[slot] - 1)}>‹
+ {team.awakenings[slot]}
+ = 5} aria-label={`편성 ${label} 카드 ${slot + 1} 개화 증가`} on:click={() => setAwakening(slot, team.awakenings[slot] + 1)}>›
+
+
+
+ {/each}
+
+
+
diff --git a/src/lib/components/SongPicker.svelte b/src/lib/components/SongPicker.svelte
index 22c103b..ac13304 100644
--- a/src/lib/components/SongPicker.svelte
+++ b/src/lib/components/SongPicker.svelte
@@ -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}
- {#if selectedTitle}선택한 악곡: {selectedTitle}
{/if}
+ {#if showSelectedTitle && selectedTitle}선택한 악곡: {selectedTitle}
{/if}
{#if query.trim()}
{options.length > 0
diff --git a/src/lib/engine/score-probability.test.ts b/src/lib/engine/score-probability.test.ts
index af8291e..cd91de8 100644
--- a/src/lib/engine/score-probability.test.ts
+++ b/src/lib/engine/score-probability.test.ts
@@ -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)],
diff --git a/src/lib/engine/score-probability.ts b/src/lib/engine/score-probability.ts
index 6a1f1b6..e305af3 100644
--- a/src/lib/engine/score-probability.ts
+++ b/src/lib/engine/score-probability.ts
@@ -221,6 +221,16 @@ function randomGenerator(seed: number): () => number {
};
}
+export function calculateMaximumScoreProbability(
+ cards: readonly OptimizerCard[],
+ reductions: Readonly>,
+ 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>,
diff --git a/src/lib/engine/team-comparison.test.ts b/src/lib/engine/team-comparison.test.ts
new file mode 100644
index 0000000..985ed20
--- /dev/null
+++ b/src/lib/engine/team-comparison.test.ts
@@ -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) => 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);
+});
diff --git a/src/lib/engine/team-comparison.ts b/src/lib/engine/team-comparison.ts
new file mode 100644
index 0000000..671dac0
--- /dev/null
+++ b/src/lib/engine/team-comparison.ts
@@ -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 = {};
+ 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 };
+}
diff --git a/src/lib/storage/team-comparison.ts b/src/lib/storage/team-comparison.ts
new file mode 100644
index 0000000..52a1e1b
--- /dev/null
+++ b/src/lib/storage/team-comparison.ts
@@ -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 => !!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 };
+}
diff --git a/src/lib/workers/score-optimizer.worker.ts b/src/lib/workers/score-optimizer.worker.ts
index ce8e185..fab7c11 100644
--- a/src/lib/workers/score-optimizer.worker.ts
+++ b/src/lib/workers/score-optimizer.worker.ts
@@ -1,6 +1,6 @@
///
-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 {
const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities(
cardsForResult(request.cards, result),
@@ -89,7 +90,10 @@ self.onmessage = (event: MessageEvent