Files
HolodoriCalc/src/lib/engine/combination-search.ts
T

169 lines
6.4 KiB
TypeScript

export type CombinationSearchCard = {
id: string;
characterId: string;
};
type BeamState<T extends CombinationSearchCard> = {
cards: T[];
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 withinMediumProbabilityCardLimit(
cards: readonly { details: { active: readonly { probabilityPercent?: number | null }[] } }[],
maximum: number | null
): boolean {
return maximum === null || cards.filter((card) =>
card.details.active.some((level) => level.probabilityPercent === 46)
).length <= maximum;
}
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,
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,
scoreCombination?: (cards: readonly T[]) => number,
// Must accept valid partial combinations, as it is checked at every expansion.
acceptsCombination?: (cards: readonly T[]) => boolean
): T[][] {
if (targetSize < fixedCards.length) throw new Error('목표 카드 수가 고정 카드 수보다 작습니다.');
if (new Set(fixedCards.map((card) => card.characterId)).size !== fixedCards.length) {
throw new Error('같은 멤버를 두 번 고정할 수 없습니다.');
}
if (acceptsCombination && !acceptsCombination(fixedCards)) return [];
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: scoreCombination?.(fixedCards) ?? 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 cards = [...state.cards, card];
if (acceptsCombination && !acceptsCombination(cards)) continue;
const nextState = {
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);
}
}
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);
}
export function generateAnchoredCombinationCandidates<T extends CombinationSearchCard>(
fixedCards: readonly T[],
candidateCards: readonly T[],
targetSize: number,
strategies: readonly CombinationScoringStrategy<T>[],
perAnchorLimit: number,
acceptsCombination?: (cards: readonly T[]) => boolean
): 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,
acceptsCombination
)) {
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());
}