feat: add combination search workflow
This commit is contained in:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user