Publish GitHub Pages output / publish (push) Successful in 34s
카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다. 준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다. 업데이트 문구를 한 항목으로 통합합니다.
191 lines
7.8 KiB
TypeScript
191 lines
7.8 KiB
TypeScript
export type CombinationSearchCard = {
|
|
id: string;
|
|
characterId: string;
|
|
};
|
|
|
|
type BeamState<T extends CombinationSearchCard> = {
|
|
cards: T[];
|
|
score: number;
|
|
mask0: number;
|
|
mask1: number;
|
|
mask2: number;
|
|
tieKey?: string;
|
|
};
|
|
|
|
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));
|
|
const identities = [...new Set([...fixedCards, ...pool].map((card) => card.id))];
|
|
const compactKeys = identities.length <= 96 && identities.every((id) => typeof id === 'string' && !id.includes('|'));
|
|
const flags = new Map(identities.map((id, index) => [id, [
|
|
compactKeys && index < 32 ? 1 << index : 0,
|
|
compactKeys && index >= 32 && index < 64 ? 1 << (index - 32) : 0,
|
|
compactKeys && index >= 64 ? 1 << (index - 64) : 0
|
|
]]));
|
|
const entries = pool.map((card) => ({ card, flags: flags.get(card.id)! }));
|
|
const fixedMasks = fixedCards.reduce((mask, card) => mask.map((value, index) => value | flags.get(card.id)![index]), [0, 0, 0]);
|
|
const tieKey = (state: BeamState<T>): string => state.tieKey ??= state.cards.map((card) => card.id).join();
|
|
let beam: BeamState<T>[] = [{
|
|
mask0: fixedMasks[0], mask1: fixedMasks[1], mask2: fixedMasks[2], tieKey: undefined,
|
|
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, flags: bits } of entries) {
|
|
if (usedIds.has(card.id)) continue;
|
|
if (usedCharacters.has(card.characterId)) continue;
|
|
const mask0 = state.mask0 | bits[0], mask1 = state.mask1 | bits[1], mask2 = state.mask2 | bits[2];
|
|
const compactKey = compactKeys ? mask0 + ':' + mask1 + ':' + mask2 : '';
|
|
if (compactKeys && !acceptsCombination && expanded.has(compactKey)) continue;
|
|
const cards = [...state.cards, card];
|
|
if (acceptsCombination && !acceptsCombination(cards)) continue;
|
|
// Preserve the first traversal's card order and Map insertion order.
|
|
// Scoring callbacks are pure; duplicate permutations need no evaluation.
|
|
const key = compactKeys ? compactKey : cards.map((entry) => entry.id).sort().join('|');
|
|
if (expanded.has(key)) continue;
|
|
const nextState = {
|
|
mask0, mask1, mask2, tieKey: undefined,
|
|
cards,
|
|
score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
|
|
};
|
|
expanded.set(key, nextState);
|
|
}
|
|
}
|
|
beam = Array.from(expanded.values())
|
|
.sort((left, right) => right.score - left.score || tieKey(left).localeCompare(tieKey(right)))
|
|
.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());
|
|
}
|