feat: add combination search workflow

This commit is contained in:
2026-08-18 16:30:51 +09:00
parent 7b713bffe3
commit f953fe9970
10 changed files with 1294 additions and 24 deletions
+24
View File
@@ -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);
});
+16 -3
View File
@@ -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)
};
}
+22
View File
@@ -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']);
});
+55
View File
@@ -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
View File
@@ -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';