feat: limit medium-probability cards and sync Docker dev dependencies
This commit is contained in:
@@ -5,7 +5,8 @@ import {
|
||||
generateAnchoredCombinationCandidates,
|
||||
generateCombinationCandidates,
|
||||
resolveCandidateAwakening,
|
||||
selectMemberDiverseCandidates
|
||||
selectMemberDiverseCandidates,
|
||||
withinMediumProbabilityCardLimit
|
||||
} from './index.ts';
|
||||
|
||||
const cards = [
|
||||
@@ -15,6 +16,57 @@ const cards = [
|
||||
{ id: 'c-1', characterId: 'c', score: 40 }
|
||||
];
|
||||
|
||||
const probabilityCards = [46, 46, 55, 55, 55, 55].map((probabilityPercent, index) => ({
|
||||
id: String(index),
|
||||
characterId: String(index),
|
||||
score: 100 - index,
|
||||
details: { active: [{ probabilityPercent }, { probabilityPercent }] }
|
||||
}));
|
||||
|
||||
test('중간 확률은 스킬 레벨 수와 무관하게 카드당 한 장으로 세고 낮음은 제외한다', () => {
|
||||
assert.equal(withinMediumProbabilityCardLimit([probabilityCards[0]], 1), true);
|
||||
assert.equal(withinMediumProbabilityCardLimit([probabilityCards[0]], 0), false);
|
||||
assert.equal(withinMediumProbabilityCardLimit([
|
||||
probabilityCards[2],
|
||||
{ details: { active: [{ probabilityPercent: 37 }] } },
|
||||
{ details: { active: [] } }
|
||||
], 0), true);
|
||||
assert.equal(withinMediumProbabilityCardLimit(probabilityCards, null), true);
|
||||
});
|
||||
|
||||
test('높은 점수의 중간 확률 카드가 빔을 독점하기 전에 제한을 적용한다', () => {
|
||||
const result = generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 1,
|
||||
undefined, (selected) => withinMediumProbabilityCardLimit(selected, 1));
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].map((card) => card.id).sort(), ['0', '2', '3', '4', '5']);
|
||||
});
|
||||
|
||||
test('고정 카드도 중간 확률 제한에 포함하고 초과하거나 완성할 수 없으면 후보를 반환하지 않는다', () => {
|
||||
const accepts = (selected: readonly (typeof probabilityCards)[number][]) => withinMediumProbabilityCardLimit(selected, 1);
|
||||
const result = generateCombinationCandidates([probabilityCards[1]], probabilityCards, 5, (card) => card.score, 10, undefined, accepts);
|
||||
assert.equal(result.length, 1);
|
||||
assert.ok(result[0].some((card) => card.id === '1'));
|
||||
assert.ok(result[0].every((card) => card.id !== '0'));
|
||||
assert.deepEqual(generateCombinationCandidates(probabilityCards.slice(0, 2), probabilityCards, 5,
|
||||
(card) => card.score, 10, undefined, accepts), []);
|
||||
assert.deepEqual(generateCombinationCandidates([], probabilityCards, 5,
|
||||
(card) => card.score, 10, undefined, (selected) => withinMediumProbabilityCardLimit(selected, 0)), []);
|
||||
});
|
||||
|
||||
test('임시 축 후보에도 제한을 적용하고 중간 확률 카드를 모두 제외할 수 있다', () => {
|
||||
const result = generateAnchoredCombinationCandidates([], probabilityCards, 3,
|
||||
[{ scoreCard: (card) => card.score }], 1,
|
||||
(selected) => withinMediumProbabilityCardLimit(selected, 0));
|
||||
assert.ok(result.length > 0);
|
||||
assert.ok(result.every((selected) => selected.every((card) => card.details.active[0].probabilityPercent === 55)));
|
||||
});
|
||||
|
||||
test('제한 없음은 기존 조합 후보를 그대로 유지한다', () => {
|
||||
const expected = generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 10);
|
||||
assert.deepEqual(generateCombinationCandidates([], probabilityCards, 5, (card) => card.score, 10,
|
||||
undefined, (selected) => withinMediumProbabilityCardLimit(selected, null)), expected);
|
||||
});
|
||||
|
||||
test('고정 카드를 유지하고 같은 멤버의 이격 카드를 제외한다', () => {
|
||||
const result = generateCombinationCandidates([cards[0]], cards, 3, (card) => card.score, 20);
|
||||
assert.equal(result.length, 1);
|
||||
|
||||
@@ -23,6 +23,15 @@ export type CombinationScoringStrategy<T extends CombinationSearchCard> = {
|
||||
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)));
|
||||
@@ -49,12 +58,15 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
targetSize: number,
|
||||
scoreCard: (card: T) => number,
|
||||
beamWidth = 800,
|
||||
scoreCombination?: (cards: readonly T[]) => number
|
||||
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));
|
||||
@@ -72,6 +84,7 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
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)
|
||||
@@ -96,7 +109,8 @@ export function generateAnchoredCombinationCandidates<T extends CombinationSearc
|
||||
candidateCards: readonly T[],
|
||||
targetSize: number,
|
||||
strategies: readonly CombinationScoringStrategy<T>[],
|
||||
perAnchorLimit: number
|
||||
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));
|
||||
@@ -113,7 +127,8 @@ export function generateAnchoredCombinationCandidates<T extends CombinationSearc
|
||||
targetSize,
|
||||
strategy.scoreCard,
|
||||
perStrategyLimit,
|
||||
strategy.scoreCombination
|
||||
strategy.scoreCombination,
|
||||
acceptsCombination
|
||||
)) {
|
||||
anchored.set(cards.map((card) => card.id).sort().join('|'), cards);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user