feat: improve combination search and offline build

This commit is contained in:
2026-08-18 20:59:20 +09:00
parent ee3498d146
commit d0f7654998
20 changed files with 552 additions and 99 deletions
+7
View File
@@ -0,0 +1,7 @@
import type { ExactSongChart } from '$lib/engine/index.ts';
export async function loadExactSongChart(path: string): Promise<ExactSongChart> {
const response = await fetch(path);
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
return response.json() as Promise<ExactSongChart>;
}
+25 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateCombinationCandidates } from './index.ts';
import { generateCombinationCandidates, resolveCandidateAwakening } from './index.ts';
const cards = [
{ id: 'a-1', characterId: 'a', score: 10 },
@@ -20,3 +20,27 @@ test('빔 너비만큼 점수가 높은 조합을 남긴다', () => {
assert.equal(result.length, 2);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['b-1', 'c-1']);
});
test('편성 점수로 카드 간 시너지를 선별할 수 있다', () => {
const synergyCards = [
{ id: 'a-1', characterId: 'a', score: 10, group: 'x' },
{ id: 'b-1', characterId: 'b', score: 9, group: 'x' },
{ id: 'c-1', characterId: 'c', score: 20, group: 'y' }
];
const result = generateCombinationCandidates(
[],
synergyCards,
2,
(card) => card.score,
2,
(selected) => selected.reduce((sum, card) => sum + card.score, 0) +
(selected.filter((card) => card.group === 'x').length >= 2 ? 100 : 0)
);
assert.deepEqual(result[0].map((card) => card.id).sort(), ['a-1', 'b-1']);
});
test('보유한 자동 후보는 설정값보다 실제 개화가 높으면 실제 개화를 사용한다', () => {
assert.equal(resolveCandidateAwakening(2, true, 4), 4);
assert.equal(resolveCandidateAwakening(4, true, 2), 4);
assert.equal(resolveCandidateAwakening(2, false, 5), 2);
});
+14 -4
View File
@@ -8,12 +8,21 @@ type BeamState<T extends CombinationSearchCard> = {
score: number;
};
export function resolveCandidateAwakening(
configuredAwakening: number,
owned: boolean,
ownedAwakening: number
): number {
return owned ? Math.max(configuredAwakening, ownedAwakening) : configuredAwakening;
}
export function generateCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
scoreCard: (card: T) => number,
beamWidth = 800
beamWidth = 800,
scoreCombination?: (cards: readonly T[]) => number
): T[][] {
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
@@ -24,7 +33,7 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
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)
score: scoreCombination?.(fixedCards) ?? fixedCards.reduce((sum, card) => sum + scoreCard(card), 0)
}];
for (let size = fixedCards.length; size < targetSize; size += 1) {
@@ -35,9 +44,10 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
for (const card of pool) {
if (usedIds.has(card.id)) continue;
if (usedCharacters.has(card.characterId)) continue;
const cards = [...state.cards, card];
const nextState = {
cards: [...state.cards, card],
score: state.score + scoreCard(card)
cards,
score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
};
const key = nextState.cards.map((entry) => entry.id).sort().join('|');
if (!expanded.has(key)) expanded.set(key, nextState);
+8
View File
@@ -0,0 +1,8 @@
:root {
--page-max-width: 1520px;
--page-gutter: clamp(40px, 5vw, 96px);
--page-title-size: 72px;
--page-content-top: 48px;
--page-header-bottom: 28px;
--page-description-size: 16px;
}
@@ -0,0 +1,5 @@
import ScoreOptimizerWorker from './score-optimizer.worker.ts?worker&inline';
export function createScoreOptimizerWorker(): Worker {
return new ScoreOptimizerWorker();
}