feat: add combination search workflow
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import type { EffectiveSkillWindow, ExactOptimizationResult, SkillWindow } from '$lib/engine/index.ts';
|
||||
|
||||
type DisplayEffectiveWindow = SkillWindow & {
|
||||
baseScoreUpPercent: number;
|
||||
minScoreUpPercent: number;
|
||||
maxScoreUpPercent: number;
|
||||
maxOverlapCount: number;
|
||||
};
|
||||
|
||||
export let result: ExactOptimizationResult;
|
||||
export let durationSeconds: number;
|
||||
export let cardName: (cardId: string) => string;
|
||||
export let instanceId = 'result';
|
||||
export let baseline: ExactOptimizationResult | null = null;
|
||||
export let showScoreSummary = true;
|
||||
|
||||
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
|
||||
|
||||
$: noteCountsBySecond = countNotesBySecond(result, durationSeconds);
|
||||
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
|
||||
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result.effectiveActiveWindows ?? []);
|
||||
$: timelineTicks = buildTimelineTicks(durationSeconds);
|
||||
$: timelineWidth = Math.max(720, durationSeconds * 6);
|
||||
|
||||
function countNotesBySecond(scoreResult: ExactOptimizationResult, duration: number): number[] {
|
||||
const counts = Array.from({ length: Math.ceil(duration) }, () => 0);
|
||||
for (const note of scoreResult.notes) {
|
||||
const second = Math.floor(note.timeSeconds);
|
||||
if (second >= 0 && second < counts.length) counts[second] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function buildTimelineTicks(duration: number): number[] {
|
||||
if (duration <= 0) return [];
|
||||
const ticks = Array.from({ length: Math.floor(duration / 10) + 1 }, (_, index) => index * 10);
|
||||
if (ticks.at(-1) !== duration) ticks.push(duration);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function timelinePosition(seconds: number): number {
|
||||
return durationSeconds > 0 ? Math.max(0, Math.min(100, seconds / durationSeconds * 100)) : 0;
|
||||
}
|
||||
|
||||
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
|
||||
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
|
||||
}
|
||||
|
||||
function timelineTrackStyle(): string {
|
||||
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
|
||||
}
|
||||
|
||||
function memberColor(cardId: string): string {
|
||||
const index = result.order.indexOf(cardId);
|
||||
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
|
||||
}
|
||||
|
||||
function coloredWindowStyle(window: SkillWindow): string {
|
||||
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
|
||||
}
|
||||
|
||||
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
|
||||
const merged: DisplayEffectiveWindow[] = [];
|
||||
for (const window of windows) {
|
||||
const previous = merged.at(-1);
|
||||
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
|
||||
previous.endSeconds = window.endSeconds;
|
||||
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
|
||||
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
|
||||
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
|
||||
continue;
|
||||
}
|
||||
merged.push({
|
||||
...window,
|
||||
minScoreUpPercent: window.scoreUpPercent,
|
||||
maxScoreUpPercent: window.scoreUpPercent,
|
||||
maxOverlapCount: window.overlapCount
|
||||
});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
|
||||
const minimum = Number(window.minScoreUpPercent.toFixed(1));
|
||||
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
|
||||
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
|
||||
}
|
||||
|
||||
function formatSeconds(seconds: number): string {
|
||||
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
|
||||
}
|
||||
|
||||
function delta(current: number, reference: number): string {
|
||||
const difference = current - reference;
|
||||
return `${difference >= 0 ? '+' : ''}${difference.toLocaleString('ko-KR')}`;
|
||||
}
|
||||
</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>
|
||||
{#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>
|
||||
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.theoreticalMaxScore, baseline.theoreticalMaxScore)}</small>{/if}</div>
|
||||
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong>{#if baseline}<small>기준 대비 {delta(result.baselineScore, baseline.baselineScore)}</small>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="skill-timeline" aria-labelledby={`timeline-title-${instanceId}`}>
|
||||
<div class="timeline-heading">
|
||||
<div><h3 id={`timeline-title-${instanceId}`}>스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
|
||||
<div class="timeline-legend" aria-label="멤버 색상 범례">
|
||||
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{cardName(cardId)}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
|
||||
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
|
||||
<div class="timeline-row note-row">
|
||||
<strong class="lane-label">초당 노트</strong>
|
||||
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
|
||||
{#each noteCountsBySecond as count, second}
|
||||
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#each result.order as cardId, slot}
|
||||
<div class="timeline-row member-row">
|
||||
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{cardName(cardId)}</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
|
||||
<span class="skill-window active-window" style={coloredWindowStyle(window)} title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="timeline-row summary-row">
|
||||
<strong class="lane-label summary-label">최종 액티브</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each displayedEffectiveActiveWindows as window}
|
||||
<span class="skill-window effective-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`} role="img" aria-label={`${cardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-row summary-row">
|
||||
<strong class="lane-label summary-label">스페셜</strong>
|
||||
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
|
||||
{#each result.specialWindows as window}
|
||||
<span class="skill-window special-window" style={coloredWindowStyle(window)} title={`${cardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`} role="img" aria-label={`${cardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-row axis-row" aria-hidden="true">
|
||||
<span class="lane-label axis-label"></span>
|
||||
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
|
||||
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.order-list { display: grid; gap: 8px; padding: 0; margin: 18px 0 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; }
|
||||
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
|
||||
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
|
||||
.score-summary span { color: #a9b1be; font-size: 12px; }
|
||||
.score-summary strong { color: #f2a3c4; font-size: 20px; }
|
||||
.score-summary small { color: #90d8ae; font-size: 10px; font-weight: 800; }
|
||||
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
|
||||
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
|
||||
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
|
||||
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
|
||||
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
|
||||
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
|
||||
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
|
||||
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
|
||||
.timeline-row { display: flex; min-height: 36px; }
|
||||
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
|
||||
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
|
||||
.timeline-track { position: relative; flex: none; }
|
||||
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
|
||||
.note-row { min-height: 68px; }
|
||||
.note-track { height: 52px; align-self: center; border-bottom: 1px solid #465062; }
|
||||
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
|
||||
.skill-track { height: 36px; }
|
||||
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
|
||||
.active-window { top: 12px; height: 12px; opacity: .72; }
|
||||
.summary-row { background: rgb(255 255 255 / 1.5%); }
|
||||
.summary-label { color: #e1e5ec; font-weight: 800; }
|
||||
.effective-window { top: 9px; height: 18px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
|
||||
.special-window { top: 11px; height: 14px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
|
||||
.axis-row { min-height: 24px; border-top: 1px solid #343c49 !important; }
|
||||
.time-axis { height: 24px; }
|
||||
.time-axis::before { display: none; }
|
||||
.time-axis span { position: absolute; top: 5px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
|
||||
.time-axis span:first-child { transform: none; }
|
||||
.time-axis span:last-child { transform: translateX(-100%); }
|
||||
@media (max-width: 700px) {
|
||||
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
|
||||
.reduction { grid-column: 2; justify-self: start; }
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
.timeline-heading { align-items: flex-start; flex-direction: column; }
|
||||
.timeline-legend { justify-content: flex-start; }
|
||||
.lane-label { flex-basis: 118px; min-width: 118px; }
|
||||
.timeline-canvas { padding-right: 8px; }
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const summary: BoardSummarySource = {
|
||||
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
|
||||
} }],
|
||||
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
|
||||
'G-2': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 50, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_0', trigger: 'always' } }],
|
||||
'Y-1': [{ characterIds: [], effect: {
|
||||
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
|
||||
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
|
||||
@@ -63,3 +64,26 @@ test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () =
|
||||
|
||||
assert.equal(result.flatByCardId['card-a'].performance, 300);
|
||||
});
|
||||
|
||||
test('조합 탐색용 기수 보너스와 악곡 보너스는 실제 노드 합계를 대체한다', () => {
|
||||
const result = calculateBoardEffects(summary, { account: ['G-2', 'Y-1'] }, cards, 'a', {
|
||||
singerType: 'solo', singerCharacterIds: ['a']
|
||||
}, null, {
|
||||
generationBonusByCardId: { 'card-a': 900 },
|
||||
directSongScoreBonusPercent: 10
|
||||
});
|
||||
|
||||
assert.equal(result.flatByCardId['card-a'].performance, 900);
|
||||
assert.equal(result.flatByCardId['card-a'].technique, 900);
|
||||
assert.equal(result.directSongScoreBonusPercent, 10);
|
||||
|
||||
const zeroOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
|
||||
generationBonusByCardId: { 'card-a': 0 }
|
||||
});
|
||||
const customOverride = calculateBoardEffects(summary, { account: ['G-2'] }, cards, 'a', null, null, {
|
||||
generationBonusByCardId: { 'card-a': 450 }
|
||||
});
|
||||
|
||||
assert.equal(zeroOverride.flatByCardId['card-a'].performance, 0);
|
||||
assert.equal(customOverride.flatByCardId['card-a'].performance, 450);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ export type BoardConnections = {
|
||||
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||
cards: readonly BoardConnectionCard[];
|
||||
};
|
||||
export type BoardEffectOverrides = {
|
||||
generationBonusByCardId?: Readonly<Record<string, number>>;
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
export type BoardEffectsResult = {
|
||||
flatByCardId: Record<string, Record<UnitStat, number>>;
|
||||
rateByCardId: Record<string, Record<UnitStat, number>>;
|
||||
@@ -126,7 +130,8 @@ export function calculateBoardEffects(
|
||||
cards: readonly BoardEffectCard[],
|
||||
leaderCharacterId: string,
|
||||
song?: BoardEffectSong | null,
|
||||
connections?: BoardConnections | null
|
||||
connections?: BoardConnections | null,
|
||||
overrides?: BoardEffectOverrides | null
|
||||
): BoardEffectsResult {
|
||||
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
||||
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
|
||||
@@ -167,12 +172,20 @@ export function calculateBoardEffects(
|
||||
|
||||
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
|
||||
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
|
||||
for (const card of targets(effect, ownerId, cards)) {
|
||||
const effectTargets = targets(effect, ownerId, cards).filter((card) =>
|
||||
effect.type !== 'all_parameter_up_for_character_grouping' || overrides?.generationBonusByCardId?.[card.id] === undefined
|
||||
);
|
||||
for (const card of effectTargets) {
|
||||
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cardId, value] of Object.entries(overrides?.generationBonusByCardId ?? {})) {
|
||||
if (!flatByCardId[cardId]) continue;
|
||||
for (const stat of stats) flatByCardId[cardId][stat] += value;
|
||||
}
|
||||
|
||||
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
|
||||
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
|
||||
card.id,
|
||||
@@ -184,6 +197,6 @@ export function calculateBoardEffects(
|
||||
activationRateUpByCardId,
|
||||
allowedCooldownReductionsByCardId,
|
||||
scoreSupportPercent,
|
||||
directSongScoreBonusPercent: Math.min(10, directSongScoreBonusPercent)
|
||||
directSongScoreBonusPercent: overrides?.directSongScoreBonusPercent ?? Math.min(10, directSongScoreBonusPercent)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { generateCombinationCandidates } from './index.ts';
|
||||
|
||||
const cards = [
|
||||
{ id: 'a-1', characterId: 'a', score: 10 },
|
||||
{ id: 'a-2', characterId: 'a', score: 20 },
|
||||
{ id: 'b-1', characterId: 'b', score: 30 },
|
||||
{ id: 'c-1', characterId: 'c', score: 40 }
|
||||
];
|
||||
|
||||
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
|
||||
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1', 'c-1']);
|
||||
});
|
||||
|
||||
test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
|
||||
const result = generateCombinationCandidates([], cards, 2, (card) => card.score, 2);
|
||||
assert.equal(result.length, 2);
|
||||
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export type CombinationSearchCard = {
|
||||
id: string;
|
||||
characterId: string;
|
||||
};
|
||||
|
||||
type BeamState<T extends CombinationSearchCard> = {
|
||||
cards: T[];
|
||||
score: number;
|
||||
};
|
||||
|
||||
export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
fixedCards: readonly T[],
|
||||
candidateCards: readonly T[],
|
||||
targetSize: number,
|
||||
scoreCard: (card: T) => number,
|
||||
beamWidth = 800
|
||||
): T[][] {
|
||||
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
|
||||
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
|
||||
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
|
||||
}
|
||||
const fixedIds = new Set(fixedCards.map((card) => card.id));
|
||||
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
|
||||
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId));
|
||||
let beam: BeamState<T>[] = [{
|
||||
cards: Array.from(fixedCards),
|
||||
score: fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
|
||||
}];
|
||||
|
||||
for (let size = fixedCards.length; size < targetSize; size += 1) {
|
||||
const expanded = new Map<string, BeamState<T>>();
|
||||
for (const state of beam) {
|
||||
const usedCharacters = new Set(state.cards.map((card) => card.characterId));
|
||||
const usedIds = new Set(state.cards.map((card) => card.id));
|
||||
for (const card of pool) {
|
||||
if (usedIds.has(card.id)) continue;
|
||||
if (usedCharacters.has(card.characterId)) continue;
|
||||
const nextState = {
|
||||
cards: [...state.cards, card],
|
||||
score: state.score + scoreCard(card)
|
||||
};
|
||||
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
|
||||
if (!expanded.has(key)) expanded.set(key, nextState);
|
||||
}
|
||||
}
|
||||
beam = Array.from(expanded.values())
|
||||
.sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join()))
|
||||
.slice(0, Math.max(1, beamWidth));
|
||||
if (beam.length === 0) break;
|
||||
}
|
||||
return beam
|
||||
.filter((state) => state.cards.length === targetSize)
|
||||
.sort((left, right) => right.score - left.score)
|
||||
.map((state) => state.cards);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './card-adapter.ts';
|
||||
export * from './combination-search.ts';
|
||||
export * from './board-effects.ts';
|
||||
export * from './exact-optimizer.ts';
|
||||
export * from './memory.ts';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
||||
import { countOptimizationCandidates, optimizeExactOrderAndCooldowns } from '../engine/index.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
|
||||
|
||||
type OptimizeRequest = {
|
||||
type: 'optimize-exact';
|
||||
@@ -11,17 +11,57 @@ type OptimizeRequest = {
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
|
||||
if (event.data.type !== 'optimize-exact') return;
|
||||
type CombinationCandidate = {
|
||||
cards: OptimizerCard[];
|
||||
overallPower: number;
|
||||
perfectNoteScore: number;
|
||||
directSongScoreBonusPercent?: number;
|
||||
};
|
||||
|
||||
type CombinationRequest = {
|
||||
type: 'optimize-combinations';
|
||||
candidates: CombinationCandidate[];
|
||||
chart: ExactSongChart;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest>) => {
|
||||
|
||||
try {
|
||||
const options: ExactOptimizeOptions = {
|
||||
perfectNoteScore: event.data.perfectNoteScore,
|
||||
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
|
||||
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
|
||||
};
|
||||
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
|
||||
self.postMessage({ type: 'result', result });
|
||||
if (event.data.type === 'optimize-exact') {
|
||||
const options: ExactOptimizeOptions = {
|
||||
perfectNoteScore: event.data.perfectNoteScore,
|
||||
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
|
||||
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
|
||||
};
|
||||
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
|
||||
self.postMessage({ type: 'result', result });
|
||||
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 }[] = [];
|
||||
for (const candidate of event.data.candidates) {
|
||||
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
|
||||
perfectNoteScore: candidate.perfectNoteScore,
|
||||
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
|
||||
onProgress: (progress) => self.postMessage({
|
||||
type: 'progress',
|
||||
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
|
||||
})
|
||||
});
|
||||
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 };
|
||||
}
|
||||
rankings.push({ result, overallPower: candidate.overallPower });
|
||||
}
|
||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||
rankings.sort((left, right) => right.result.theoreticalMaxScore - left.result.theoreticalMaxScore ||
|
||||
right.result.expectedScore - left.result.expectedScore);
|
||||
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, 10) });
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'error',
|
||||
|
||||
Reference in New Issue
Block a user