feat: improve analysis and release v1.0.2
This commit is contained in:
@@ -97,11 +97,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol class="order-list">
|
||||
{#each result.order as cardId, slot}
|
||||
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="order-sections">
|
||||
<section class="order-section">
|
||||
<h3>추천 스페셜 발동 순서</h3>
|
||||
<ol class="order-list compact">
|
||||
{#each result.order as cardId, slot}
|
||||
<li><span class="slot">{slot + 1}</span><strong>{cardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
{#if showScoreSummary}
|
||||
<div class="score-grid">
|
||||
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.expectedScore, baseline.expectedScore)}</small>{/if}</div>
|
||||
@@ -164,7 +169,10 @@
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 0; list-style: none; }
|
||||
.order-sections { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
|
||||
.order-section { min-width: 0; }
|
||||
.order-section h3 { margin: 0 0 9px; color: #aeb7c5; font-size: 14px; }
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 0; list-style: none; }
|
||||
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
|
||||
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
|
||||
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@@ -206,6 +214,7 @@
|
||||
.time-axis span:first-child { transform: none; }
|
||||
.time-axis span:last-child { transform: translateX(-100%); }
|
||||
@media (max-width: 700px) {
|
||||
.order-sections { grid-template-columns: 1fr; }
|
||||
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.reduction { grid-column: 2; justify-self: start; }
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
|
||||
const source: OptimizerSourceCard = {
|
||||
id: 'card',
|
||||
name: '카드',
|
||||
power: { statsAtMaxLevel: { performance: 101, technique: 202, sense: 303 } },
|
||||
details: {
|
||||
active: [
|
||||
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
|
||||
@@ -29,6 +30,11 @@ test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다',
|
||||
assert.equal(specialAwakened.special.scoreUpPercent, 40);
|
||||
});
|
||||
|
||||
test('2개화부터 보드 제외 기본 파라미터 합계에 10%를 적용한다', () => {
|
||||
assert.equal(toOptimizerCard(source, 0).naturalParameterTotal, 606);
|
||||
assert.equal(toOptimizerCard(source, 2).naturalParameterTotal, 669);
|
||||
});
|
||||
|
||||
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
|
||||
const passiveSource: OptimizerSourceCard = {
|
||||
...source,
|
||||
|
||||
@@ -16,6 +16,13 @@ export type OptimizerSourceCard = {
|
||||
type?: string;
|
||||
generation?: string;
|
||||
versionName?: string;
|
||||
power?: {
|
||||
statsAtMaxLevel?: {
|
||||
performance: number;
|
||||
technique: number;
|
||||
sense: number;
|
||||
};
|
||||
};
|
||||
details: {
|
||||
passive?: readonly { level: number; description: string }[];
|
||||
active: readonly SkillLevel[];
|
||||
@@ -87,6 +94,11 @@ export function toOptimizerCard(
|
||||
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
|
||||
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
|
||||
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
|
||||
const baseStats = source.power?.statsAtMaxLevel;
|
||||
const potentialRate = awakening >= 2 ? 10 : 0;
|
||||
const naturalParameterTotal = baseStats
|
||||
? Object.values(baseStats).reduce((sum, value) => sum + Math.ceil(value * (100 + potentialRate) / 100), 0)
|
||||
: 0;
|
||||
|
||||
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
|
||||
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
|
||||
@@ -98,6 +110,7 @@ export function toOptimizerCard(
|
||||
return {
|
||||
id: source.id,
|
||||
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
|
||||
naturalParameterTotal,
|
||||
type: source.type,
|
||||
generation: source.generation,
|
||||
active: {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts';
|
||||
import {
|
||||
combinationSearchLimits,
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
resolveCandidateAwakening,
|
||||
selectMemberDiverseCandidates
|
||||
} from './index.ts';
|
||||
|
||||
const cards = [
|
||||
{ id: 'a-1', characterId: 'a', score: 10 },
|
||||
@@ -44,3 +50,52 @@ test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실
|
||||
assert.equal(resolveCandidateAwakening(4, true, 2), 4);
|
||||
assert.equal(resolveCandidateAwakening(2, false, 5), 2);
|
||||
});
|
||||
|
||||
test('빈 멤버 수에 비례해 선별 및 정밀 분석 후보 수를 늘린다', () => {
|
||||
assert.deepEqual(combinationSearchLimits(4), {
|
||||
openSlots: 1,
|
||||
screeningLimit: 5_000,
|
||||
baseExactFinalistLimit: 56,
|
||||
alternativeExactFinalistLimit: 56
|
||||
});
|
||||
assert.deepEqual(combinationSearchLimits(3), {
|
||||
openSlots: 2,
|
||||
screeningLimit: 10_000,
|
||||
baseExactFinalistLimit: 112,
|
||||
alternativeExactFinalistLimit: 112
|
||||
});
|
||||
assert.deepEqual(combinationSearchLimits(0), {
|
||||
openSlots: 5,
|
||||
screeningLimit: 25_000,
|
||||
baseExactFinalistLimit: 280,
|
||||
alternativeExactFinalistLimit: 280
|
||||
});
|
||||
});
|
||||
|
||||
test('각 자동 후보를 임시 축으로 삼은 조합을 선별한다', () => {
|
||||
const anchoredCards = [
|
||||
{ id: 'a', characterId: 'a', score: 100 },
|
||||
{ id: 'b', characterId: 'b', score: 90 },
|
||||
{ id: 'c', characterId: 'c', score: 80 },
|
||||
{ id: 'd', characterId: 'd', score: 1 }
|
||||
];
|
||||
const result = generateAnchoredCombinationCandidates(
|
||||
[],
|
||||
anchoredCards,
|
||||
2,
|
||||
[{ scoreCard: (card) => card.score }],
|
||||
1
|
||||
);
|
||||
assert.ok(result.some((combination) => combination.some((card) => card.id === 'd')));
|
||||
});
|
||||
|
||||
test('정밀 분석 후보에 각 카드의 최고 대표 조합을 남긴다', () => {
|
||||
const candidates = [
|
||||
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'b', characterId: 'b' }], score: 100 },
|
||||
{ cards: [{ id: 'a', characterId: 'a' }, { id: 'c', characterId: 'c' }], score: 90 },
|
||||
{ cards: [{ id: 'd', characterId: 'd' }, { id: 'e', characterId: 'e' }], score: 20 }
|
||||
];
|
||||
const result = selectMemberDiverseCandidates(candidates, (candidate) => candidate.cards, (candidate) => candidate.score, [], 10);
|
||||
assert.equal(result.length, 3);
|
||||
assert.ok(result.some((candidate) => candidate.cards.some((card) => card.id === 'e')));
|
||||
});
|
||||
|
||||
@@ -8,6 +8,33 @@ type BeamState<T extends CombinationSearchCard> = {
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type CombinationSearchLimits = {
|
||||
openSlots: number;
|
||||
screeningLimit: number;
|
||||
baseExactFinalistLimit: number;
|
||||
alternativeExactFinalistLimit: number;
|
||||
};
|
||||
|
||||
export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
|
||||
scoreCard: (card: T) => number;
|
||||
scoreCombination?: (cards: readonly T[]) => number;
|
||||
};
|
||||
|
||||
const SCREENING_LIMIT_PER_OPEN_SLOT = 5_000;
|
||||
const EXACT_FINALIST_LIMIT_PER_OPEN_SLOT = 56;
|
||||
|
||||
export function combinationSearchLimits(fixedCardCount: number, targetSize = 5): CombinationSearchLimits {
|
||||
const normalizedTargetSize = Math.max(1, Math.floor(targetSize));
|
||||
const normalizedFixedCardCount = Math.min(normalizedTargetSize, Math.max(0, Math.floor(fixedCardCount)));
|
||||
const openSlots = Math.max(1, normalizedTargetSize - normalizedFixedCardCount);
|
||||
return {
|
||||
openSlots,
|
||||
screeningLimit: SCREENING_LIMIT_PER_OPEN_SLOT * openSlots,
|
||||
baseExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots,
|
||||
alternativeExactFinalistLimit: EXACT_FINALIST_LIMIT_PER_OPEN_SLOT * openSlots
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCandidateAwakening(
|
||||
configuredAwakening: number,
|
||||
owned: boolean,
|
||||
@@ -63,3 +90,64 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((state) => state.cards);
|
||||
}
|
||||
|
||||
export function generateAnchoredCombinationCandidates<T extends CombinationSearchCard>(
|
||||
fixedCards: readonly T[],
|
||||
candidateCards: readonly T[],
|
||||
targetSize: number,
|
||||
strategies: readonly CombinationScoringStrategy<T>[],
|
||||
perAnchorLimit: number
|
||||
): T[][] {
|
||||
if (strategies.length === 0 || perAnchorLimit <= 0 || fixedCards.length >= targetSize) return [];
|
||||
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||
const anchors = candidateCards.filter((card) => !fixedCharacters.has(card.characterId));
|
||||
const perStrategyLimit = Math.max(1, Math.ceil(perAnchorLimit / strategies.length));
|
||||
const combined = new Map<string, T[]>();
|
||||
|
||||
for (const anchor of anchors) {
|
||||
const anchored = new Map<string, T[]>();
|
||||
for (const strategy of strategies) {
|
||||
for (const cards of generateCombinationCandidates(
|
||||
[...fixedCards, anchor],
|
||||
candidateCards,
|
||||
targetSize,
|
||||
strategy.scoreCard,
|
||||
perStrategyLimit,
|
||||
strategy.scoreCombination
|
||||
)) {
|
||||
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
|
||||
}
|
||||
}
|
||||
for (const [key, cards] of Array.from(anchored.entries()).slice(0, perAnchorLimit)) {
|
||||
combined.set(key, cards);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(combined.values());
|
||||
}
|
||||
|
||||
export function selectMemberDiverseCandidates<T>(
|
||||
candidates: readonly T[],
|
||||
cardsOf: (candidate: T) => readonly { id: string }[],
|
||||
scoreOf: (candidate: T) => number,
|
||||
fixedCardIds: readonly string[],
|
||||
limit: number
|
||||
): T[] {
|
||||
if (limit <= 0) return [];
|
||||
const fixedIds = new Set(fixedCardIds);
|
||||
const ranked = [...candidates].sort((left, right) => scoreOf(right) - scoreOf(left));
|
||||
const bestByCardId = new Map<string, T>();
|
||||
for (const candidate of ranked) {
|
||||
for (const card of cardsOf(candidate)) {
|
||||
if (!fixedIds.has(card.id) && !bestByCardId.has(card.id)) bestByCardId.set(card.id, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const selected = new Map<string, T>();
|
||||
for (const candidate of bestByCardId.values()) {
|
||||
const key = cardsOf(candidate).map((card) => card.id).sort().join('|');
|
||||
selected.set(key, candidate);
|
||||
if (selected.size >= limit) break;
|
||||
}
|
||||
return Array.from(selected.values());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
|
||||
import { passiveScoreSupportByCard } from './passive-support.ts';
|
||||
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
||||
|
||||
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
|
||||
@@ -31,10 +32,11 @@ test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구
|
||||
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
|
||||
});
|
||||
|
||||
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
|
||||
test('패시브 스코어 서포트는 기본 파라미터 합계가 높은 지정 인원에게 적용한다', () => {
|
||||
const supported = cards.map((card, index) => ({
|
||||
...card,
|
||||
type: index < 3 ? '퓨어' : '해피',
|
||||
naturalParameterTotal: [100, 300, 200, 50, 50][index],
|
||||
passiveScoreSupportEffects: index === 4
|
||||
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
|
||||
: []
|
||||
@@ -42,5 +44,42 @@ test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정
|
||||
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
|
||||
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
|
||||
|
||||
assert.deepEqual(passiveScoreSupportByCard(supported), [0, 20, 20, 0, 0]);
|
||||
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
|
||||
});
|
||||
|
||||
test('스페셜 발동 순서를 탐색해 가장 높은 액티브를 강한 구간에 배치한다', () => {
|
||||
const formation: OptimizerCard[] = [
|
||||
{ id: 'a', name: 'a', type: '퓨어', naturalParameterTotal: 200, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 105 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
|
||||
{ id: 'b', name: 'b', type: '퓨어', naturalParameterTotal: 100, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 10 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
|
||||
{ id: 'c', name: 'c', type: '퓨어', naturalParameterTotal: 300, active: { cooldownSeconds: 1, durationSeconds: 0.9, scoreUpPercent: 110 }, special: { durationSeconds: 2, scoreUpPercent: 100 }, allowedCooldownReductions: [0] },
|
||||
{ id: 'd', name: 'd', type: '해피', active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 }, special: { durationSeconds: 0, scoreUpPercent: 0 }, allowedCooldownReductions: [0] },
|
||||
{
|
||||
id: 'support',
|
||||
name: 'support',
|
||||
type: '해피',
|
||||
active: { cooldownSeconds: 1, durationSeconds: 0, scoreUpPercent: 0 },
|
||||
special: { durationSeconds: 0, scoreUpPercent: 0 },
|
||||
passiveScoreSupportEffects: [{ percent: 20, target: 'group', targetGroup: '퓨어 타입', targetCount: 2 }],
|
||||
allowedCooldownReductions: [0]
|
||||
}
|
||||
];
|
||||
const targetChart: ExactSongChart = {
|
||||
songId: 'formation-order-test',
|
||||
difficulty: 'expert',
|
||||
durationSeconds: 3,
|
||||
scoreRatio: 1,
|
||||
notes: [
|
||||
{ timeSeconds: 0.5, type: 'regular', isFlick: false },
|
||||
{ timeSeconds: 1.1, type: 'regular', isFlick: false }
|
||||
],
|
||||
specialStartSeconds: [0, null, null, null, null]
|
||||
};
|
||||
|
||||
const result = optimizeExactOrderAndCooldowns(formation, targetChart, { perfectNoteScore: 100 });
|
||||
const firstActiveWinner = result.effectiveActiveWindows.find((window) => window.startSeconds <= 1.1 && 1.1 < window.endSeconds);
|
||||
|
||||
assert.equal(result.order[0], 'c');
|
||||
assert.equal(firstActiveWinner?.cardId, 'c');
|
||||
assert.ok(Math.abs((firstActiveWinner?.scoreUpPercent ?? 0) - 242) < 1e-9);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { calculateExactScore } from './score.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import { passiveScoreSupportByCard } from './passive-support.ts';
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import { COOLDOWN_REDUCTIONS } from './types.ts';
|
||||
import type {
|
||||
@@ -17,24 +17,6 @@ type CandidateProfile = {
|
||||
probability: Float64Array;
|
||||
};
|
||||
|
||||
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
|
||||
const totals = order.map(() => 0);
|
||||
order.forEach((source, sourceIndex) => {
|
||||
for (const effect of source.passiveScoreSupportEffects ?? []) {
|
||||
let indexes: number[];
|
||||
if (effect.target === 'all') indexes = order.map((_, index) => index);
|
||||
else if (effect.target === 'self') indexes = [sourceIndex];
|
||||
else indexes = order
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
|
||||
.slice(0, effect.targetCount ?? order.length)
|
||||
.map(({ index }) => index);
|
||||
for (const index of indexes) totals[index] += effect.percent;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
function permutations<T>(items: readonly T[]): T[][] {
|
||||
if (items.length <= 1) return [Array.from(items)];
|
||||
const result: T[][] = [];
|
||||
@@ -242,17 +224,17 @@ export function optimizeExactOrderAndCooldowns(
|
||||
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
|
||||
throw new Error('서로 다른 카드 5장이 필요합니다.');
|
||||
}
|
||||
const orders = permutations(cards);
|
||||
const reductionAssignments = assignments(cards);
|
||||
const base = baseScores(chart, options.perfectNoteScore);
|
||||
const totalCandidates = orders.length * reductionAssignments.length;
|
||||
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
|
||||
const totalCandidates = orderCount * reductionAssignments.length;
|
||||
let evaluatedCandidates = 0;
|
||||
let bestScore = Number.NEGATIVE_INFINITY;
|
||||
let bestOrder: OptimizerCard[] | null = null;
|
||||
let bestReductions: Record<string, CooldownReduction> | null = null;
|
||||
let bestReductionTotal = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const order of orders) {
|
||||
for (const order of permutations(cards)) {
|
||||
const profiles = makeProfiles(order, chart);
|
||||
for (const reductions of reductionAssignments) {
|
||||
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
|
||||
@@ -273,6 +255,7 @@ export function optimizeExactOrderAndCooldowns(
|
||||
return {
|
||||
...calculateExactScore(bestOrder, bestReductions, chart, options),
|
||||
order: bestOrder.map((card) => card.id),
|
||||
formationOrder: cards.map((card) => card.id),
|
||||
cooldownReductions: { ...bestReductions },
|
||||
...windows,
|
||||
evaluatedCandidates
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './memory.ts';
|
||||
export * from './maximum-weight-matching.ts';
|
||||
export * from './optimizer.ts';
|
||||
export * from './score.ts';
|
||||
export * from './score-probability.ts';
|
||||
export * from './skill-condition.ts';
|
||||
export * from './timeline.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
@@ -29,6 +29,18 @@ test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
|
||||
assert.equal(countOptimizationCandidates(cards), 122_880);
|
||||
});
|
||||
|
||||
test('제한 인원 패시브가 있어도 편성 순서 후보를 추가하지 않는다', () => {
|
||||
const targeted = cards.map((card, index) => ({
|
||||
...card,
|
||||
type: index < 3 ? '퓨어' : '해피',
|
||||
passiveScoreSupportEffects: index === 4
|
||||
? [{ percent: 11, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
|
||||
: []
|
||||
}));
|
||||
|
||||
assert.equal(countOptimizationCandidates(targeted), 122_880);
|
||||
});
|
||||
|
||||
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
|
||||
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
|
||||
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import type { OptimizerCard } from './types.ts';
|
||||
|
||||
export function passiveScoreSupportByCard(cards: readonly OptimizerCard[]): number[] {
|
||||
const totals = cards.map(() => 0);
|
||||
cards.forEach((source, sourceIndex) => {
|
||||
for (const effect of source.passiveScoreSupportEffects ?? []) {
|
||||
let indexes: number[];
|
||||
if (effect.target === 'all') indexes = cards.map((_, index) => index);
|
||||
else if (effect.target === 'self') indexes = [sourceIndex];
|
||||
else indexes = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
|
||||
.sort((left, right) =>
|
||||
(right.card.naturalParameterTotal ?? 0) - (left.card.naturalParameterTotal ?? 0) ||
|
||||
left.card.id.localeCompare(right.card.id))
|
||||
.slice(0, effect.targetCount ?? cards.length)
|
||||
.map(({ index }) => index);
|
||||
for (const index of indexes) totals[index] += effect.percent;
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}
|
||||
|
||||
export function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
|
||||
return passiveScoreSupportByCard(cards)[targetIndex] ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { estimateScoreAchievementProbabilities } from './index.ts';
|
||||
import type { ExactSongChart, OptimizerCard } from './types.ts';
|
||||
|
||||
function card(
|
||||
id: string,
|
||||
scoreUpPercent: number,
|
||||
probabilityPercent: number,
|
||||
durationSeconds = 1
|
||||
): OptimizerCard {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
active: { cooldownSeconds: 1, durationSeconds, scoreUpPercent, probabilityPercent },
|
||||
special: { durationSeconds: 0, scoreUpPercent: 0 },
|
||||
allowedCooldownReductions: [0]
|
||||
};
|
||||
}
|
||||
|
||||
function chart(times: number[], specialStartSeconds: (number | null)[] = []): ExactSongChart {
|
||||
return {
|
||||
songId: 'test',
|
||||
difficulty: 'expert',
|
||||
durationSeconds: 5,
|
||||
scoreRatio: 1,
|
||||
notes: times.map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
|
||||
specialStartSeconds
|
||||
};
|
||||
}
|
||||
|
||||
test('한 번의 발동이 여러 노트에 영향을 주어도 최고점 확률은 한 번만 곱한다', () => {
|
||||
const result = estimateScoreAchievementProbabilities(
|
||||
[card('main', 100, 50, 0.9)],
|
||||
{ main: 0 },
|
||||
chart([1.1, 1.5]),
|
||||
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 1 }
|
||||
);
|
||||
assert.equal(result.maximum, 0.5);
|
||||
});
|
||||
|
||||
test('최고점에 영향을 주지 않는 약한 액티브는 최고점 확률에서 제외한다', () => {
|
||||
const result = estimateScoreAchievementProbabilities(
|
||||
[card('strong', 100, 50), card('weak', 20, 10)],
|
||||
{ strong: 0, weak: 0 },
|
||||
chart([1.1]),
|
||||
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 2 }
|
||||
);
|
||||
assert.equal(result.maximum, 0.5);
|
||||
});
|
||||
|
||||
test('같은 최고 효과의 액티브가 겹치면 둘 중 하나가 발동할 확률을 사용한다', () => {
|
||||
const result = estimateScoreAchievementProbabilities(
|
||||
[card('left', 100, 50), card('right', 100, 50)],
|
||||
{ left: 0, right: 0 },
|
||||
chart([1.1]),
|
||||
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 3 }
|
||||
);
|
||||
assert.equal(result.maximum, 0.75);
|
||||
});
|
||||
|
||||
test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다', () => {
|
||||
const main = card('main', 100, 50);
|
||||
const support: OptimizerCard = {
|
||||
...card('support', 0, 0),
|
||||
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 50 }
|
||||
};
|
||||
const result = estimateScoreAchievementProbabilities(
|
||||
[main, support],
|
||||
{ main: 0, support: 0 },
|
||||
chart([1.1], [null, 0]),
|
||||
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 4 }
|
||||
);
|
||||
assert.equal(result.maximum, 0.75);
|
||||
});
|
||||
|
||||
test('확정 발동이면 모든 달성 확률이 100%다', () => {
|
||||
const result = estimateScoreAchievementProbabilities(
|
||||
[card('main', 100, 100)],
|
||||
{ main: 0 },
|
||||
chart([1.1, 2.1, 3.1]),
|
||||
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 5 }
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
maximum: 1,
|
||||
atLeast99Percent: 1,
|
||||
atLeast95Percent: 1,
|
||||
samples: 1_000
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { passiveScoreSupportByCard } from './passive-support.ts';
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import type {
|
||||
CooldownReduction,
|
||||
ExactScoreOptions,
|
||||
ExactSongChart,
|
||||
OptimizerCard,
|
||||
ScoreAchievementProbabilities
|
||||
} from './types.ts';
|
||||
|
||||
type Attempt = {
|
||||
key: string;
|
||||
probability: number;
|
||||
};
|
||||
|
||||
type NoteCandidate = {
|
||||
attemptIndex: number;
|
||||
boost: number;
|
||||
};
|
||||
|
||||
type ScoreGroup = {
|
||||
candidates: readonly NoteCandidate[];
|
||||
scores: readonly number[];
|
||||
};
|
||||
|
||||
type PreparedProbabilityModel = {
|
||||
attempts: readonly Attempt[];
|
||||
groups: readonly ScoreGroup[];
|
||||
maximumScore: number;
|
||||
maximumClauses: readonly (readonly number[])[];
|
||||
};
|
||||
|
||||
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
|
||||
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
|
||||
}
|
||||
|
||||
function specialRate(
|
||||
cards: readonly OptimizerCard[],
|
||||
chart: ExactSongChart,
|
||||
time: number,
|
||||
field: 'scoreUpPercent' | 'activationRateUpPercent'
|
||||
): number {
|
||||
return cards.reduce((sum, card, slot) => sum +
|
||||
(specialActive(card, chart.specialStartSeconds[slot], time) ? card.special[field] ?? 0 : 0), 0);
|
||||
}
|
||||
|
||||
function comboBonus(combo: number): number {
|
||||
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
|
||||
}
|
||||
|
||||
function finalNoteScore(score: number, percent: number): number {
|
||||
const scaled = score * (1 + percent / 100);
|
||||
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
|
||||
}
|
||||
|
||||
function scoreNotes(chart: ExactSongChart): ExactSongChart['notes'] {
|
||||
const countedMidTimes = new Set<number>();
|
||||
return chart.notes.filter((note) => {
|
||||
if (note.type !== 'mid') return true;
|
||||
if (countedMidTimes.has(note.timeSeconds)) return false;
|
||||
countedMidTimes.add(note.timeSeconds);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function prepareModel(
|
||||
cards: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart,
|
||||
options: ExactScoreOptions
|
||||
): PreparedProbabilityModel {
|
||||
const attempts: Attempt[] = [];
|
||||
const attemptIndexes = new Map<string, number>();
|
||||
const passiveSupport = passiveScoreSupportByCard(cards);
|
||||
const grouped = new Map<string, { candidates: NoteCandidate[]; scores: number[] }>();
|
||||
const clauses: number[][] = [];
|
||||
let maximumScore = 0;
|
||||
|
||||
scoreNotes(chart).forEach((note, noteIndex) => {
|
||||
const candidates: NoteCandidate[] = [];
|
||||
const specialSupport = specialRate(cards, chart, note.timeSeconds, 'scoreUpPercent');
|
||||
cards.forEach((card, cardIndex) => {
|
||||
const reduction = reductions[card.id] ?? 0;
|
||||
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
|
||||
const attemptNumber = Math.floor((note.timeSeconds + 1e-9) / interval);
|
||||
if (attemptNumber <= 0) return;
|
||||
const activationTime = attemptNumber * interval;
|
||||
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
|
||||
const key = `${card.id}:${attemptNumber}`;
|
||||
let attemptIndex = attemptIndexes.get(key);
|
||||
if (attemptIndex === undefined) {
|
||||
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent');
|
||||
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
|
||||
attemptIndex = attempts.length;
|
||||
attemptIndexes.set(key, attemptIndex);
|
||||
attempts.push({ key, probability });
|
||||
}
|
||||
candidates.push({
|
||||
attemptIndex,
|
||||
boost: card.active.scoreUpPercent / 100 *
|
||||
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
|
||||
});
|
||||
});
|
||||
candidates.sort((left, right) => right.boost - left.boost || left.attemptIndex - right.attemptIndex);
|
||||
|
||||
const combo = noteIndex + 1;
|
||||
const baseNoteScore = note.type === 'mid'
|
||||
? Math.ceil(options.perfectNoteScore * 0.1)
|
||||
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
|
||||
const rawBaseline = baseNoteScore * (1 + comboBonus(combo));
|
||||
const directBonus = options.directSongScoreBonusPercent ?? 0;
|
||||
const scores = [
|
||||
...candidates.map((candidate) => finalNoteScore(rawBaseline * (1 + candidate.boost), directBonus)),
|
||||
finalNoteScore(rawBaseline, directBonus)
|
||||
];
|
||||
maximumScore += scores[0];
|
||||
|
||||
if (candidates.length > 0) {
|
||||
clauses.push(candidates
|
||||
.filter((_, index) => scores[index] === scores[0])
|
||||
.map((candidate) => candidate.attemptIndex));
|
||||
}
|
||||
|
||||
const signature = candidates.map((candidate) => `${candidate.attemptIndex}:${candidate.boost.toPrecision(15)}`).join('|');
|
||||
const existing = grouped.get(signature);
|
||||
if (existing) scores.forEach((score, index) => { existing.scores[index] += score; });
|
||||
else grouped.set(signature, { candidates, scores });
|
||||
});
|
||||
|
||||
return {
|
||||
attempts,
|
||||
groups: Array.from(grouped.values()),
|
||||
maximumScore,
|
||||
maximumClauses: simplifyClauses(clauses)
|
||||
};
|
||||
}
|
||||
|
||||
function simplifyClauses(clauses: readonly (readonly number[])[]): number[][] {
|
||||
const unique = new Map<string, number[]>();
|
||||
for (const clause of clauses) {
|
||||
const normalized = Array.from(new Set(clause)).sort((left, right) => left - right);
|
||||
unique.set(normalized.join(','), normalized);
|
||||
}
|
||||
const sorted = Array.from(unique.values()).sort((left, right) => left.length - right.length);
|
||||
return sorted.filter((clause, index) => !sorted.slice(0, index).some((candidate) =>
|
||||
candidate.length <= clause.length && candidate.every((variable) => clause.includes(variable))));
|
||||
}
|
||||
|
||||
function exactClauseProbability(clauses: readonly (readonly number[])[], attempts: readonly Attempt[]): number {
|
||||
const memo = new Map<string, number>();
|
||||
const solve = (current: readonly (readonly number[])[]): number => {
|
||||
if (current.length === 0) return 1;
|
||||
if (current.some((clause) => clause.length === 0)) return 0;
|
||||
const simplified = simplifyClauses(current);
|
||||
const key = simplified.map((clause) => clause.join(',')).join(';');
|
||||
const cached = memo.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const required = new Set(simplified.filter((clause) => clause.length === 1).map((clause) => clause[0]));
|
||||
if (required.size > 0) {
|
||||
let requiredProbability = 1;
|
||||
for (const variable of required) requiredProbability *= attempts[variable]?.probability ?? 0;
|
||||
const remaining = simplified.filter((clause) => !clause.some((variable) => required.has(variable)));
|
||||
const result = requiredProbability * solve(remaining);
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const occurrences = new Map<number, number>();
|
||||
simplified.forEach((clause) => clause.forEach((variable) =>
|
||||
occurrences.set(variable, (occurrences.get(variable) ?? 0) + 1)));
|
||||
const variable = Array.from(occurrences).sort((left, right) => right[1] - left[1])[0][0];
|
||||
const probability = attempts[variable]?.probability ?? 0;
|
||||
const whenTrue = simplified.filter((clause) => !clause.includes(variable));
|
||||
const whenFalse = simplified.map((clause) => clause.filter((candidate) => candidate !== variable));
|
||||
const result = probability * solve(whenTrue) + (1 - probability) * solve(whenFalse);
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
};
|
||||
return solve(clauses);
|
||||
}
|
||||
|
||||
function hashSeed(cards: readonly OptimizerCard[], reductions: Readonly<Record<string, CooldownReduction>>): number {
|
||||
const value = cards.map((card) => `${card.id}:${reductions[card.id] ?? 0}`).join('|');
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function randomGenerator(seed: number): () => number {
|
||||
let state = seed || 0x9e3779b9;
|
||||
return () => {
|
||||
state += 0x6d2b79f5;
|
||||
let value = state;
|
||||
value = Math.imul(value ^ value >>> 15, value | 1);
|
||||
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
||||
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateScoreAchievementProbabilities(
|
||||
cards: readonly OptimizerCard[],
|
||||
reductions: Readonly<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart,
|
||||
options: ExactScoreOptions & { sampleCount?: number; seed?: number }
|
||||
): ScoreAchievementProbabilities {
|
||||
const model = prepareModel(cards, reductions, chart, options);
|
||||
const samples = Math.max(1, Math.round(options.sampleCount ?? 25_000));
|
||||
const random = randomGenerator(options.seed ?? hashSeed(cards, reductions));
|
||||
const active = new Uint8Array(model.attempts.length);
|
||||
let atLeast99 = 0;
|
||||
let atLeast95 = 0;
|
||||
const threshold99 = model.maximumScore * 0.99;
|
||||
const threshold95 = model.maximumScore * 0.95;
|
||||
|
||||
for (let sample = 0; sample < samples; sample += 1) {
|
||||
model.attempts.forEach((attempt, index) => { active[index] = random() < attempt.probability ? 1 : 0; });
|
||||
let score = 0;
|
||||
for (const group of model.groups) {
|
||||
let scoreIndex = group.candidates.length;
|
||||
for (let index = 0; index < group.candidates.length; index += 1) {
|
||||
if (active[group.candidates[index].attemptIndex]) {
|
||||
scoreIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
score += group.scores[scoreIndex];
|
||||
}
|
||||
if (score >= threshold99) atLeast99 += 1;
|
||||
if (score >= threshold95) atLeast95 += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
maximum: exactClauseProbability(model.maximumClauses, model.attempts),
|
||||
atLeast99Percent: atLeast99 / samples,
|
||||
atLeast95Percent: atLeast95 / samples,
|
||||
samples
|
||||
};
|
||||
}
|
||||
+1
-19
@@ -1,5 +1,5 @@
|
||||
import { effectiveCooldownSeconds } from './timeline.ts';
|
||||
import { skillTargetBelongsTo } from './skill-condition.ts';
|
||||
import { passiveScoreSupportAt } from './passive-support.ts';
|
||||
import type {
|
||||
CooldownReduction,
|
||||
ExactScoreOptions,
|
||||
@@ -34,24 +34,6 @@ export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
|
||||
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
|
||||
}
|
||||
|
||||
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
|
||||
let total = 0;
|
||||
cards.forEach((source, sourceIndex) => {
|
||||
for (const effect of source.passiveScoreSupportEffects ?? []) {
|
||||
let indexes: number[];
|
||||
if (effect.target === 'all') indexes = cards.map((_, index) => index);
|
||||
else if (effect.target === 'self') indexes = [sourceIndex];
|
||||
else indexes = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
|
||||
.slice(0, effect.targetCount ?? cards.length)
|
||||
.map(({ index }) => index);
|
||||
if (indexes.includes(targetIndex)) total += effect.percent;
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
function comboBonus(combo: number): number {
|
||||
if (combo >= 1_000) return 0.1;
|
||||
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
|
||||
|
||||
@@ -5,6 +5,8 @@ export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
|
||||
export type OptimizerCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 보드·패시브·의상·메모리를 제외한 현재 레벨/개화의 파라미터 합계. */
|
||||
naturalParameterTotal?: number;
|
||||
type?: string;
|
||||
generation?: string;
|
||||
active: {
|
||||
@@ -116,7 +118,16 @@ export type ExactScoreOptions = {
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
|
||||
export type ScoreAchievementProbabilities = {
|
||||
maximum: number;
|
||||
atLeast99Percent: number;
|
||||
atLeast95Percent: number;
|
||||
samples: number;
|
||||
};
|
||||
|
||||
export type ExactOptimizationResult = ExactScoreResult & {
|
||||
/** 분석한 5인 편성. 기존 저장 데이터 호환을 위해 필드명을 유지한다. */
|
||||
formationOrder: readonly string[];
|
||||
order: readonly string[];
|
||||
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
|
||||
activeWindows: readonly SkillWindow[];
|
||||
|
||||
@@ -91,3 +91,30 @@ test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는
|
||||
|
||||
assert.equal(result.passiveTotal, 20);
|
||||
});
|
||||
|
||||
test('지정 인원 능력치 패시브는 해당 능력치가 아닌 전체 파라미터 합계 상위 멤버에게 적용한다', () => {
|
||||
const source = {
|
||||
...card('source', '퓨어 타입 2명의 퍼포먼스가 10% UP'),
|
||||
type: '해피'
|
||||
};
|
||||
const targets = [
|
||||
{ id: 'performance-high', performance: 200, technique: 0, sense: 0 },
|
||||
{ id: 'total-high', performance: 100, technique: 200, sense: 0 },
|
||||
{ id: 'total-second', performance: 150, technique: 100, sense: 0 }
|
||||
].map(({ id, performance, technique, sense }) => ({
|
||||
card: {
|
||||
...card(id, ''),
|
||||
type: '퓨어',
|
||||
power: { maxLevel: 80, statsAtMaxLevel: { performance, technique, sense } }
|
||||
},
|
||||
awakening: 0
|
||||
}));
|
||||
|
||||
const result = calculateUnitPower([
|
||||
{ card: source, awakening: 0 },
|
||||
...targets,
|
||||
{ card: card('other', ''), awakening: 0 }
|
||||
]);
|
||||
|
||||
assert.equal(result.passiveTotal, 25);
|
||||
});
|
||||
|
||||
@@ -89,7 +89,11 @@ function targetIndexes(
|
||||
return members
|
||||
.map((member, index) => ({ member, index }))
|
||||
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
|
||||
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index)
|
||||
.sort((left, right) => {
|
||||
const rightTotal = stats.reduce((sum, key) => sum + parameters[right.index][key], 0);
|
||||
const leftTotal = stats.reduce((sum, key) => sum + parameters[left.index][key], 0);
|
||||
return rightTotal - leftTotal || left.member.card.id.localeCompare(right.member.card.id);
|
||||
})
|
||||
.slice(0, count)
|
||||
.map(({ index }) => index);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
||||
import { countOptimizationCandidates, estimateScoreAchievementProbabilities, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
|
||||
|
||||
type OptimizeRequest = {
|
||||
type: 'optimize-exact';
|
||||
@@ -9,6 +9,7 @@ type OptimizeRequest = {
|
||||
chart: ExactSongChart;
|
||||
perfectNoteScore: number;
|
||||
directSongScoreBonusPercent?: number;
|
||||
includeAchievementProbabilities?: boolean;
|
||||
};
|
||||
|
||||
type CombinationCandidate = {
|
||||
@@ -24,7 +25,31 @@ type CombinationRequest = {
|
||||
chart: ExactSongChart;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
|
||||
type RankedCombination = {
|
||||
result: ExactOptimizationResult;
|
||||
overallPower: number;
|
||||
cards?: OptimizerCard[];
|
||||
directSongScoreBonusPercent?: number;
|
||||
achievementProbabilities?: ScoreAchievementProbabilities;
|
||||
};
|
||||
|
||||
type AchievementRequest = {
|
||||
type: 'estimate-achievement';
|
||||
rankings: RankedCombination[];
|
||||
chart: ExactSongChart;
|
||||
sampleCount?: number;
|
||||
};
|
||||
|
||||
function cardsForResult(cards: readonly OptimizerCard[], result: ExactOptimizationResult): OptimizerCard[] {
|
||||
const cardById = new Map(cards.map((card) => [card.id, card]));
|
||||
return result.order.map((cardId) => {
|
||||
const card = cardById.get(cardId);
|
||||
if (!card) throw new Error('확률 계산 대상의 스페셜 순서를 복원하지 못했습니다.');
|
||||
return card;
|
||||
});
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => {
|
||||
|
||||
try {
|
||||
if (event.data.type === 'optimize-exact') {
|
||||
@@ -34,14 +59,49 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
|
||||
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
|
||||
};
|
||||
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
|
||||
self.postMessage({ type: 'result', result });
|
||||
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
|
||||
if (event.data.includeAchievementProbabilities) {
|
||||
const orderedCards = cardsForResult(event.data.cards, result);
|
||||
achievementProbabilities = estimateScoreAchievementProbabilities(
|
||||
orderedCards,
|
||||
result.cooldownReductions,
|
||||
event.data.chart,
|
||||
{ ...options, sampleCount: 25_000 }
|
||||
);
|
||||
}
|
||||
self.postMessage({ type: 'result', result, achievementProbabilities });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data.type === 'estimate-achievement') {
|
||||
const request = event.data;
|
||||
const rankings = request.rankings.map((ranking, rankingIndex) => {
|
||||
if (!ranking.cards) throw new Error('확률 계산에 필요한 카드 정보가 없습니다.');
|
||||
const orderedCards = cardsForResult(ranking.cards, ranking.result);
|
||||
const achievementProbabilities = estimateScoreAchievementProbabilities(
|
||||
orderedCards,
|
||||
ranking.result.cooldownReductions,
|
||||
event.data.chart,
|
||||
{
|
||||
perfectNoteScore: ranking.result.perfectNoteScore,
|
||||
directSongScoreBonusPercent: ranking.directSongScoreBonusPercent,
|
||||
sampleCount: request.sampleCount
|
||||
}
|
||||
);
|
||||
self.postMessage({
|
||||
type: 'achievement-progress',
|
||||
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
|
||||
});
|
||||
return { ...ranking, achievementProbabilities };
|
||||
});
|
||||
self.postMessage({ type: 'achievement-result', rankings });
|
||||
return;
|
||||
}
|
||||
|
||||
const totalCandidates = event.data.candidates.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0);
|
||||
let completedCandidates = 0;
|
||||
let best: { result: ExactOptimizationResult; overallPower: number } | null = null;
|
||||
const rankings: { result: ExactOptimizationResult; overallPower: number }[] = [];
|
||||
let best: RankedCombination | null = null;
|
||||
const rankings: RankedCombination[] = [];
|
||||
for (const candidate of event.data.candidates) {
|
||||
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
||||
perfectNoteScore: candidate.perfectNoteScore,
|
||||
@@ -54,9 +114,19 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) =>
|
||||
completedCandidates += countOptimizationCandidates(candidate.cards);
|
||||
if (!best || result.theoreticalMaxScore > best.result.theoreticalMaxScore ||
|
||||
(result.theoreticalMaxScore === best.result.theoreticalMaxScore && result.expectedScore > best.result.expectedScore)) {
|
||||
best = { result, overallPower: candidate.overallPower };
|
||||
best = {
|
||||
result,
|
||||
overallPower: candidate.overallPower,
|
||||
cards: candidate.cards,
|
||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
||||
};
|
||||
}
|
||||
rankings.push({ result, overallPower: candidate.overallPower });
|
||||
rankings.push({
|
||||
result,
|
||||
overallPower: candidate.overallPower,
|
||||
cards: candidate.cards,
|
||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent
|
||||
});
|
||||
}
|
||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
|
||||
Reference in New Issue
Block a user