feat: 조합 탐색 정확도 향상
Publish GitHub Pages output / publish (push) Successful in 30s

고정 멤버와 탐색 목적을 유지하며 제한적인 두 카드 교체 후보를 정밀 평가합니다.
기존 평가 예산 안에서 이미 평가한 편성을 제외하고 후보를 재선택합니다.
This commit is contained in:
dal4segno committed 2026-10-13 14:34:31 +09:00
1 parent da40ec4238
commit 1e893e62c9
7 files changed
+330 -8

No files matched your search

+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-10-13T02:39:27.450Z updatedAt: 2026-10-13T05:22:29.404Z
--- ---
## 2026년 10월 13일 ## 2026년 10월 13일
### 조합 탐색 정확도 향상
`IMPROVED` 조합 탐색 정확도를 향상했습니다.
### 조합 탐색 속도 개선 ### 조합 탐색 속도 개선
`IMPROVED` 반복 계산을 줄여 조합 탐색 속도를 개선했습니다. `IMPROVED` 반복 계산을 줄여 조합 탐색 속도를 개선했습니다.
@@ -0,0 +1,117 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { Worker } from 'node:worker_threads';
import { createCombinationPreparation, type CombinationPreparationContext } from './combination-preparation.ts';
import { refinementCombinationKey } from './combination-refinement.ts';
const sourceCards = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')).cards as CombinationPreparationContext['allCards'];
const charts = JSON.parse(readFileSync(new URL('../data/exact-chart-catalog.json', import.meta.url), 'utf8')).charts as CombinationPreparationContext['chartMetadata'][];
function context(fixedCount = 0, expected = false): CombinationPreparationContext {
const allCards = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((id, index) => ({
...structuredClone(sourceCards[0]), id, characterId: id, name: id, type: index % 2 ? '해피' : '퓨어',
generation: 'hololive 0기생', rarity: 5, versionName: id === 'h' ? '수영복' : '',
power: { ...sourceCards[0].power, statsAtMaxLevel: { performance: 100 + index * 10, technique: 100, sense: 100 } },
details: { ...sourceCards[0].details, passive: [], connect: [], costume: { name: '테스트', description: '' },
active: [1, 2].map((level) => ({ level, description: '10초마다 스코어 UP', coolTimeSeconds: 10, probabilityPercent: id === 'f' || id === 'g' ? 46 : 55, durationSeconds: 5, condition: '', effect: '스코어가 100% UP' })),
special: [1, 2].map((level) => ({ level, description: '스코어 서포트', coolTimeSeconds: null, probabilityPercent: null, durationSeconds: 10, condition: '', effect: '스코어 서포트 효과 100%' })) }
}));
return { allCards, boardSummary: { members: [], models: {}, nodeGroups: {} }, memoryEffects: [], chartMetadata: charts.find((chart) => chart.songId === 'm0357' && chart.difficulty === 'expert')!,
growth: { collection: Object.fromEntries(allCards.map((card) => [card.id, { owned: card.id !== 'g', awakening: 5 }])), memberBoards: {}, boardCards: {}, memoryCount: 0, memberPowerUpPercent: 0 },
settings: { fixedCardIds: ['a', 'b', 'c', 'd'].map((id, index) => index < fixedCount ? id : ''), fixedAwakenings: [1, 2, 4, 5], fixedCooldownReductions: [0, 4, 8, 12], fixedAllBlueNodes: [false, false, false, false], fixedGenerationBonusOverrides: [null, null, null, null], candidateAwakening: 5, candidateGenerationBonus: 900, songScoreBonusOverride: 10, ownedCardsOnly: true, regularCardsOnly: false, maxMediumProbabilityCards: 1, leaderCharacterId: 'a', leaderOutfitCardId: 'a' },
eventMode: false, eventPtMode: expected, currentEvent: null };
}
for (const fixedCount of [0, 1, 2, 3]) test(`refinement preserves ${fixedCount} fixed slots, candidate filters and the request snapshot`, () => {
const ctx = context(fixedCount), before = JSON.stringify(ctx.growth), preparation = createCombinationPreparation(ctx);
const source = preparation.prepare(ctx.allCards.slice(0, 5));
const another = preparation.prepare([...ctx.allCards.slice(0, 4), ctx.allCards[5]]);
preparation.select([source, another]);
const refined = preparation.refine([{ result: { order: source.cards.map((card) => card.id) } }]);
assert.ok(refined.length > 0);
assert.ok(refined.every((candidate) => candidate.cards.length === 5));
for (const candidate of refined) {
assert.equal(candidate.optimizationObjective, 'maximum');
assert.equal(candidate.cards.filter((card) => card.active.probabilityPercent === 46).length <= 1, true);
assert.ok(candidate.cards.every((card) => card.id !== 'g'));
for (const [slot, id] of ctx.settings.fixedCardIds.entries()) if (id) {
assert.ok(candidate.cards.some((card) => card.id === id));
assert.deepEqual(candidate.cards.find((card) => card.id === id)!.allowedCooldownReductions, [ctx.settings.fixedCooldownReductions[slot]]);
}
}
const keys = refined.map((candidate) => refinementCombinationKey(candidate.cards));
assert.equal(new Set(keys).size, keys.length);
assert.ok(!keys.includes(refinementCombinationKey(source.cards)));
assert.ok(!keys.includes(refinementCombinationKey(another.cards)));
assert.equal(JSON.stringify(ctx.growth), before);
assert.deepEqual(preparation.refine([{ result: { order: source.cards.map((card) => card.id) } }]), refined);
assert.throws(() => preparation.prepare(ctx.allCards.slice(0, 4)), /5장/);
});
test('four fixed slots retain their exhaustive one-slot path without refinement', () => {
const ctx = context(4), preparation = createCombinationPreparation(ctx), source = preparation.prepare(ctx.allCards.slice(0, 5));
assert.deepEqual(preparation.refine([{ result: { order: source.cards.map((card) => card.id) } }]), []);
});
test('regular-card filtering is shared by both one-card and two-card refinement', () => {
const ctx = context();
ctx.settings.ownedCardsOnly = false;
ctx.settings.regularCardsOnly = true;
const preparation = createCombinationPreparation(ctx), source = preparation.prepare(ctx.allCards.slice(0, 5));
preparation.select([source]);
const refined = preparation.refine([{ result: { order: source.cards.map((card) => card.id) } }]);
assert.ok(refined.length > 0);
assert.ok(refined.every((candidate) => candidate.cards.every((card) => card.id !== 'h')));
});
test('expected objective survives refinement preparation', () => {
const ctx = context(0, true), preparation = createCombinationPreparation(ctx), source = preparation.prepare(ctx.allCards.slice(0, 5));
preparation.select([source]);
const refined = preparation.refine([{ result: { order: source.cards.map((card) => card.id) } }]);
assert.ok(refined.length > 0);
assert.ok(refined.every((candidate) => candidate.optimizationObjective === 'expected'));
});
test('production preparation Worker retains evaluated keys only within the current request', async () => {
const worker = new Worker(`
const { parentPort, workerData } = require('node:worker_threads');
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
import(workerData.module).then(() => {
parentPort.on('message', (data) => self.onmessage({ data }));
parentPort.postMessage({ type: 'test-ready' });
}).catch((error) => { throw error; });
`, { eval: true, workerData: { module: new URL('../workers/combination-preparation.worker.ts', import.meta.url).href } });
function receive(predicate: (message: any) => boolean): Promise<any> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { cleanup(); reject(new Error('Preparation Worker timed out')); }, 5000);
const listener = (message: any) => {
if (message.type === 'error') { cleanup(); reject(new Error(message.message)); }
else if (predicate(message)) { cleanup(); resolve(message); }
};
const failed = (error: Error) => { cleanup(); reject(error); };
function cleanup() { clearTimeout(timer); worker.off('message', listener); worker.off('error', failed); }
worker.on('message', listener); worker.on('error', failed);
});
}
try {
await receive((message) => message.type === 'test-ready');
const first = context();
let response = receive((message) => message.type === 'preparation-result' && message.requestId === 1);
worker.postMessage({ type: 'prepare-search', requestId: 1, context: first });
const prepared = await response;
assert.ok(prepared.finalists.length > 0);
response = receive((message) => message.type === 'preparation-result' && message.pass === 'refinement');
worker.postMessage({ type: 'prepare-refinement', requestId: 1, rankings: prepared.finalists.slice(0, 3).map((candidate: any) => ({ result: { order: candidate.cards.map((card: any) => card.id) } })) });
assert.deepEqual((await response).finalists, []);
const second = context(3);
second.allCards = second.allCards.map((card) => ({ ...card, id: 'new-' + card.id, characterId: 'new-' + card.characterId }));
second.settings.fixedCardIds = ['new-a', 'new-b', 'new-c', ''];
second.settings.ownedCardsOnly = false;
response = receive((message) => message.type === 'preparation-result' && message.requestId === 2);
worker.postMessage({ type: 'prepare-search', requestId: 2, context: second });
const next = await response;
assert.ok(next.finalists.length > 0);
assert.ok(next.finalists.every((candidate: any) => candidate.cards.every((card: any) => card.id.startsWith('new-'))));
assert.ok(next.finalists.every((candidate: any) => ['new-a', 'new-b', 'new-c'].every((id) => candidate.cards.some((card: any) => card.id === id))));
} finally { await worker.terminate(); }
});
+31 -6
View File
@@ -2,6 +2,7 @@ import {
additionalAwakeningsRequired, additionalAwakeningsRequired,
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
calculatePartialUnitPower,
createBoardEffectContext, createBoardEffectContext,
combinationSearchLimits, combinationSearchLimits,
compareEventRankings, compareEventRankings,
@@ -27,6 +28,7 @@ import type {BoardSummarySource} from './board-effects.ts';
import type {EventDefinition} from './event-optimization.ts'; import type {EventDefinition} from './event-optimization.ts';
import type {MemoryCollectionEffect} from './memory.ts'; import type {MemoryCollectionEffect} from './memory.ts';
import type {CooldownReduction, ExactOptimizationResult, OptimizerCard} from './types.ts'; import type {CooldownReduction, ExactOptimizationResult, OptimizerCard} from './types.ts';
import { generateTwoCardReplacementCandidates, refinementCandidatePriority, selectRefinementCandidates } from './combination-refinement.ts';
type CardRow=(typeof cardSummary.cards)[number]; type CardRow=(typeof cardSummary.cards)[number];
type ChartMetadata=(typeof exactChartCatalog.charts)[number]; type ChartMetadata=(typeof exactChartCatalog.charts)[number];
export type CollectionState = Record<string, { owned?: boolean; awakening?: number }>; export type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
@@ -70,6 +72,7 @@ export function createCombinationPreparation(context:CombinationPreparationConte
const connectionCards=allCards.filter(card=>card.details.connect.length>0).map(card=>({id:card.id,awakening:collection[card.id]?.awakening??0,connect:card.details.connect})); const connectionCards=allCards.filter(card=>card.details.connect.length>0).map(card=>({id:card.id,awakening:collection[card.id]?.awakening??0,connect:card.details.connect}));
const blueGroupsByCharacter=Object.fromEntries((boardSummary.members??[]).map(member=>[member.id,(boardSummary.models?.[member.positionGroupId]??[]).filter(position=>position.groupId.startsWith('B')).map(position=>position.groupId)])); const blueGroupsByCharacter=Object.fromEntries((boardSummary.members??[]).map(member=>[member.id,(boardSummary.models?.[member.positionGroupId]??[]).filter(position=>position.groupId.startsWith('B')).map(position=>position.groupId)]));
let boardEffectContext:ReturnType<typeof createBoardEffectContext>|undefined; let boardEffectContext:ReturnType<typeof createBoardEffectContext>|undefined;
let primaryCandidateKeys = new Set<string>();
let roughStatCache=new Map<string,number>();let roughCardCache=new Map<string,number>();let roughSkillCache=new Map<string,ReturnType<typeof computeRoughSkillValues>>(); let roughStatCache=new Map<string,number>();let roughCardCache=new Map<string,number>();let roughSkillCache=new Map<string,ReturnType<typeof computeRoughSkillValues>>();
function resetSearchCaches(): void { function resetSearchCaches(): void {
roughStatCache = new Map(); roughStatCache = new Map();
@@ -226,7 +229,10 @@ function screeningCombinations(): CardRow[][] {
return Array.from(combined.values()).slice(0, screeningLimit); return Array.from(combined.values()).slice(0, screeningLimit);
} }
function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] { function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] {
if (fixedCards.length === 4) return prepared; if (fixedCards.length === 4) {
primaryCandidateKeys = new Set(prepared.map((candidate) => combinationKey(candidate.cards)));
return prepared;
}
const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length); const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length);
const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit; const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
const selected = new Map<string, PreparedCandidate>(); const selected = new Map<string, PreparedCandidate>();
@@ -254,7 +260,9 @@ function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[
if (selected.size >= finalistLimit) break; if (selected.size >= finalistLimit) break;
} }
} }
return Array.from(selected.values()).slice(0, finalistLimit); const finalists = Array.from(selected.values()).slice(0, finalistLimit);
primaryCandidateKeys = new Set(finalists.map((candidate) => combinationKey(candidate.cards)));
return finalists;
} }
function buildRefinementFinalists(rankings: RefinementRanking[]): PreparedCandidate[] { function buildRefinementFinalists(rankings: RefinementRanking[]): PreparedCandidate[] {
if (fixedCards.length === 4 || rankings.length === 0) return []; if (fixedCards.length === 4 || rankings.length === 0) return [];
@@ -309,9 +317,26 @@ function buildRefinementFinalists(rankings: RefinementRanking[]): PreparedCandid
chosenAlternatives.set(combinationKey(candidate.cards), candidate); chosenAlternatives.set(combinationKey(candidate.cards), candidate);
if (chosenAlternatives.size >= refinementLimit) break; if (chosenAlternatives.size >= refinementLimit) break;
} }
return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards)); const original = Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards));
const preparedByKey = new Map(original.map((candidate) => [combinationKey(candidate.cards), candidate]));
const prepareRefinement = (cards: CardRow[]) => {
const key = combinationKey(cards);
let candidate = preparedByKey.get(key);
if (!candidate) {
candidate = prepareCandidate(cards, cards.length < 5);
preparedByKey.set(key, candidate);
}
return candidate;
};
const sources = rankings.slice(0, 3).map((ranking) => ranking.result.order
.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card)));
const replacements = generateTwoCardReplacementCandidates(
sources, candidateCards, fixedIds, prepareRefinement, refinementCandidatePriority, acceptsCombination
);
const evaluatedKeys = new Set([...primaryCandidateKeys, ...rankings.map((ranking) => ranking.result.order.slice().sort().join('|'))]);
return selectRefinementCandidates([...original, ...replacements], evaluatedKeys, original.length);
} }
function prepareCandidate(cards: CardRow[]): PreparedCandidate { function prepareCandidate(cards: CardRow[], partial = false): PreparedCandidate {
const generationBonusByCardId = Object.fromEntries(cards const generationBonusByCardId = Object.fromEntries(cards
.filter((card) => { .filter((card) => {
const slot = fixedSlot(card.id); const slot = fixedSlot(card.id);
@@ -328,7 +353,7 @@ function prepareCandidate(cards: CardRow[]): PreparedCandidate {
generationBonusByCardId, generationBonusByCardId,
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride }) ...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
}, boardEffectContext); }, boardEffectContext);
const power = calculateUnitPower( const power = (partial ? calculatePartialUnitPower : calculateUnitPower)(
cards.map((card) => ({ card, awakening: awakeningForCard(card) })), cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
leaderOutfit, leaderOutfit,
boardEffects, boardEffects,
@@ -380,5 +405,5 @@ function prepareCandidate(cards: CardRow[]): PreparedCandidate {
}; };
} }
resetSearchCaches(); resetSearchCaches();
return {generate:screeningCombinations,prepare:prepareCandidate,select:selectExactFinalists,refine:buildRefinementFinalists}; return {generate:screeningCombinations,prepare:(cards:CardRow[])=>prepareCandidate(cards),select:selectExactFinalists,refine:buildRefinementFinalists};
} }
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { generateTwoCardReplacementCandidates, refinementCandidatePriority, refinementCombinationKey, selectRefinementCandidates } from './combination-refinement.ts';
import type { OptimizerCard } from './types.ts';
function optimizer(id: string, percent = 0, probability = 100): OptimizerCard {
return { id, name: id, active: { cooldownSeconds: 10, durationSeconds: 10, scoreUpPercent: percent, probabilityPercent: probability }, special: { durationSeconds: 0, scoreUpPercent: 0 } };
}
test('refinement priority distinguishes maximum from expected and retains event/song multipliers', () => {
const risky = { cards: [optimizer('risky', 150, 20)], overallPower: 1000 };
const steady = { cards: [optimizer('steady', 100)], overallPower: 1000 };
assert.ok(refinementCandidatePriority(risky) > refinementCandidatePriority(steady));
assert.ok(refinementCandidatePriority({ ...risky, optimizationObjective: 'expected' }) <
refinementCandidatePriority({ ...steady, optimizationObjective: 'expected' }));
const plain = { cards: [optimizer('plain')], overallPower: 1000 };
assert.ok(refinementCandidatePriority({ ...plain, overallPower: 900, rankingMultiplier: 2, optimizationObjective: 'expected' }) > refinementCandidatePriority(plain));
assert.ok(refinementCandidatePriority({ ...plain, overallPower: 900, directSongScoreBonusPercent: 10, finalScoreBonusPercent: 10 }) > refinementCandidatePriority(plain));
});
type Card = { id: string; characterId: string; medium: boolean };
const cards: Card[] = Array.from({ length: 7 }, (_, index) => ({ id: String(index), characterId: String(index), medium: index >= 5 }));
const accepts = (selected: readonly Card[]) => selected.filter((card) => card.medium).length <= 1;
const prepare = (selected: Card[]) => ({ cards: selected.map((card) => optimizer(card.id)), overallPower: 100 + selected.reduce((sum, card) => sum + Number(card.id), 0) });
function choose<T>(items: readonly T[], count: number): T[][] {
if (!count) return [[]];
return items.flatMap((item, index) => choose(items.slice(index + 1), count - 1).map((rest) => [item, ...rest]));
}
test('bounded two-card search matches the exhaustive tiny neighborhood and preserves fixed members', () => {
const source = cards.slice(0, 5), fixed = new Set(['0', '1']), expected = new Set<string>();
for (const removed of choose([2, 3, 4], 2)) {
const kept = source.filter((_, index) => !removed.includes(index));
for (const pair of choose(cards, 2)) {
const result = [...kept, ...pair];
if (new Set(result.map((card) => card.characterId)).size === 5 && accepts(result)) expected.add(refinementCombinationKey(result));
}
}
const actual = generateTwoCardReplacementCandidates([source], cards, fixed, prepare, refinementCandidatePriority, accepts);
assert.deepEqual(actual.map((candidate) => refinementCombinationKey(candidate.cards)).sort(), [...expected].sort());
assert.ok(actual.every((candidate) => ['0', '1'].every((id) => candidate.cards.some((card) => card.id === id))));
});
test('replacement search uses only the first three sources and returns no changes with four fixed members', () => {
const pool = Array.from({ length: 12 }, (_, index) => ({ id: String(index), characterId: String(index), medium: false }));
const sources = [pool.slice(0, 5), [pool[0], pool[1], pool[2], pool[3], pool[5]], [pool[0], pool[1], pool[2], pool[3], pool[6]], pool.slice(7, 12)];
const found = generateTwoCardReplacementCandidates(sources, pool, new Set<string>(), prepare, refinementCandidatePriority, () => true);
assert.ok(found.every((candidate) => sources.slice(0, 3).some((source) => source.filter((card) => candidate.cards.some((item) => item.id === card.id)).length >= 3)));
assert.deepEqual(generateTwoCardReplacementCandidates([sources[0]], pool, new Set(['0', '1', '2', '3']), prepare, refinementCandidatePriority, () => true), []);
});
test('mixed refinement removes already evaluated teams and permutations before applying the shared budget', () => {
const a = { cards: [optimizer('a'), optimizer('b')], overallPower: 100 };
const duplicate = { ...a, cards: [...a.cards].reverse() };
const b = { cards: [optimizer('c'), optimizer('d')], overallPower: 200 };
const c = { cards: [optimizer('e'), optimizer('f')], overallPower: 300 };
assert.deepEqual(selectRefinementCandidates([a, duplicate, b, c], new Set([refinementCombinationKey(c.cards)]), 1), [b]);
assert.deepEqual(selectRefinementCandidates([a], new Set<string>(), 0), []);
assert.equal(selectRefinementCandidates([a, duplicate, b], new Set<string>(), 10).length, 2);
});
+83
View File
@@ -0,0 +1,83 @@
import { effectiveActiveProbability } from './active-probability.ts';
import type { CombinationSearchCard } from './combination-search.ts';
import type { OptimizerCard } from './types.ts';
type RefinementCandidate = {
cards: readonly OptimizerCard[];
overallPower: number;
optimizationObjective?: 'maximum' | 'expected';
directSongScoreBonusPercent?: number;
finalScoreBonusPercent?: number;
rankingMultiplier?: number;
};
export function refinementCandidatePriority(candidate: RefinementCandidate): number {
const expected = candidate.optimizationObjective === 'expected';
const skillPotential = candidate.cards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) *
(expected ? effectiveActiveProbability(card) : 1) +
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((total, effect) => total +
effect.percent * (effect.target === 'all' ? candidate.cards.length : effect.targetCount ?? 1), 0), 0);
return candidate.overallPower * (1 + skillPotential / 500) *
(1 + (candidate.directSongScoreBonusPercent ?? 0) / 100) *
(1 + (candidate.finalScoreBonusPercent ?? 0) / 100) * (candidate.rankingMultiplier ?? 1);
}
export function refinementCombinationKey(cards: readonly { id: string }[]): string {
return cards.map((card) => card.id).sort().join('|');
}
/** Bounded two-card neighborhoods; all final scores still use the exact evaluator. */
export function generateTwoCardReplacementCandidates<T extends CombinationSearchCard, P>(
sources: readonly (readonly T[])[],
candidates: readonly T[],
fixedIds: ReadonlySet<string>,
prepare: (cards: T[]) => P,
priority: (candidate: P) => number,
accepts: (cards: readonly T[]) => boolean
): P[] {
const unique = new Map<string, P>();
const valid = (cards: readonly T[]) => new Set(cards.map((card) => card.characterId)).size === cards.length && accepts(cards);
for (const source of sources.slice(0, 3)) {
if (source.length !== 5 || !valid(source)) continue;
for (let first = 0; first < 5; first++) {
if (fixedIds.has(source[first].id)) continue;
for (let second = first + 1; second < 5; second++) {
if (fixedIds.has(source[second].id)) continue;
const kept = source.filter((_, index) => index !== first && index !== second);
const shortlist = candidates
.filter((card) => valid([...kept, card]))
.map((card) => ({ card, score: priority(prepare([...kept, card])) }))
.sort((left, right) => right.score - left.score || left.card.id.localeCompare(right.card.id))
.slice(0, 8);
for (let left = 0; left < shortlist.length; left++) {
for (let right = left + 1; right < shortlist.length; right++) {
const cards = [...kept, shortlist[left].card, shortlist[right].card];
if (!valid(cards)) continue;
const key = refinementCombinationKey(cards);
if (!unique.has(key)) unique.set(key, prepare(cards));
}
}
}
}
}
return Array.from(unique.values());
}
export function selectRefinementCandidates<T extends RefinementCandidate>(
candidates: readonly T[],
evaluatedKeys: ReadonlySet<string>,
limit: number
): T[] {
const unique = new Map<string, T>();
for (const candidate of candidates) {
const key = refinementCombinationKey(candidate.cards);
if (!evaluatedKeys.has(key) && !unique.has(key)) unique.set(key, candidate);
}
return Array.from(unique.values())
.sort((left, right) => refinementCandidatePriority(right) - refinementCandidatePriority(left) ||
refinementCombinationKey(left.cards).localeCompare(refinementCombinationKey(right.cards)))
.slice(0, Math.max(0, limit));
}
+12 -1
View File
@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts'; import { calculateUnitPower, calculatePartialUnitPower, type UnitPowerCard } from './unit-power.ts';
test('실제 카드 데이터로 2026-09-06 인게임 종합력의 모든 항목을 재현한다', () => { test('실제 카드 데이터로 2026-09-06 인게임 종합력의 모든 항목을 재현한다', () => {
const catalog = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')) as { const catalog = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')) as {
@@ -58,6 +58,17 @@ function card(id: string, passive: string, awakenedPassive = passive): UnitPower
}; };
} }
test('부분 편성은 공유 계산을 사용하고 정밀 종합력의 5장 제약은 유지한다', () => {
const members = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 2 }));
const outfit = { name: '테스트', description: '0기생(이)가 2명 이상이면 전원의 전체 파라미터가 50% UP' };
assert.deepEqual(calculatePartialUnitPower(members, outfit, null, 46, 10), calculateUnitPower(members, outfit, null, 46, 10));
assert.equal(calculatePartialUnitPower(members.slice(0, 1), outfit).outfitTotal, 0);
assert.equal(calculatePartialUnitPower(members.slice(0, 2), outfit).outfitTotal, 330);
assert.throws(() => calculateUnitPower(members.slice(0, 4)), /5장/);
assert.throws(() => calculatePartialUnitPower([]), /1~5장/);
assert.throws(() => calculatePartialUnitPower([...members, members[0]]), /1~5장/);
});
test('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => { test('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => {
const result = calculateUnitPower([ const result = calculateUnitPower([
{ card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 }, { card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 },
+22
View File
@@ -111,6 +111,28 @@ export function calculateUnitPower(
memberPowerUpPercent = 0 memberPowerUpPercent = 0
): UnitPowerResult { ): UnitPowerResult {
if (members.length !== 5) throw new Error('유닛 종합력 계산에는 카드 5장이 필요합니다.'); if (members.length !== 5) throw new Error('유닛 종합력 계산에는 카드 5장이 필요합니다.');
return calculateMemberPower(members, outfit, board, memoryPermil, memberPowerUpPercent);
}
/** Search heuristic only; a playable formation must still contain five members. */
export function calculatePartialUnitPower(
members: readonly UnitPowerMember[],
outfit?: UnitPowerOutfit | null,
board?: UnitPowerBoardEffects | null,
memoryPermil = 0,
memberPowerUpPercent = 0
): UnitPowerResult {
if (members.length < 1 || members.length > 5) throw new Error('부분 편성 계산에는 카드 1~5장이 필요합니다.');
return calculateMemberPower(members, outfit, board, memoryPermil, memberPowerUpPercent);
}
function calculateMemberPower(
members: readonly UnitPowerMember[],
outfit: UnitPowerOutfit | null | undefined,
board: UnitPowerBoardEffects | null | undefined,
memoryPermil: number,
memberPowerUpPercent: number
): UnitPowerResult {
const baseParameters = members.map(({ card }) => ({ ...card.power.statsAtMaxLevel })); const baseParameters = members.map(({ card }) => ({ ...card.power.statsAtMaxLevel }));
const parameters = baseParameters.map((base, index) => { const parameters = baseParameters.map((base, index) => {