고정 멤버와 탐색 목적을 유지하며 제한적인 두 카드 교체 후보를 정밀 평가합니다. 기존 평가 예산 안에서 이미 평가한 편성을 제외하고 후보를 재선택합니다.
This commit is contained in:
1 parent
da40ec4238
commit
1e893e62c9
7 files changed
+330
-8
No files matched your search
+5
-1
@@ -1,9 +1,13 @@
|
||||
---
|
||||
updatedAt: 2026-10-13T02:39:27.450Z
|
||||
updatedAt: 2026-10-13T05:22:29.404Z
|
||||
---
|
||||
|
||||
## 2026년 10월 13일
|
||||
|
||||
### 조합 탐색 정확도 향상
|
||||
|
||||
`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(); }
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
additionalAwakeningsRequired,
|
||||
calculateBoardEffects,
|
||||
calculateUnitPower,
|
||||
calculatePartialUnitPower,
|
||||
createBoardEffectContext,
|
||||
combinationSearchLimits,
|
||||
compareEventRankings,
|
||||
@@ -27,6 +28,7 @@ import type {BoardSummarySource} from './board-effects.ts';
|
||||
import type {EventDefinition} from './event-optimization.ts';
|
||||
import type {MemoryCollectionEffect} from './memory.ts';
|
||||
import type {CooldownReduction, ExactOptimizationResult, OptimizerCard} from './types.ts';
|
||||
import { generateTwoCardReplacementCandidates, refinementCandidatePriority, selectRefinementCandidates } from './combination-refinement.ts';
|
||||
type CardRow=(typeof cardSummary.cards)[number];
|
||||
type ChartMetadata=(typeof exactChartCatalog.charts)[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 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 primaryCandidateKeys = new Set<string>();
|
||||
let roughStatCache=new Map<string,number>();let roughCardCache=new Map<string,number>();let roughSkillCache=new Map<string,ReturnType<typeof computeRoughSkillValues>>();
|
||||
function resetSearchCaches(): void {
|
||||
roughStatCache = new Map();
|
||||
@@ -226,7 +229,10 @@ function screeningCombinations(): CardRow[][] {
|
||||
return Array.from(combined.values()).slice(0, screeningLimit);
|
||||
}
|
||||
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 finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit;
|
||||
const selected = new Map<string, PreparedCandidate>();
|
||||
@@ -254,7 +260,9 @@ function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[
|
||||
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[] {
|
||||
if (fixedCards.length === 4 || rankings.length === 0) return [];
|
||||
@@ -309,9 +317,26 @@ function buildRefinementFinalists(rankings: RefinementRanking[]): PreparedCandid
|
||||
chosenAlternatives.set(combinationKey(candidate.cards), candidate);
|
||||
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
|
||||
.filter((card) => {
|
||||
const slot = fixedSlot(card.id);
|
||||
@@ -328,7 +353,7 @@ function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||
generationBonusByCardId,
|
||||
...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride })
|
||||
}, boardEffectContext);
|
||||
const power = calculateUnitPower(
|
||||
const power = (partial ? calculatePartialUnitPower : calculateUnitPower)(
|
||||
cards.map((card) => ({ card, awakening: awakeningForCard(card) })),
|
||||
leaderOutfit,
|
||||
boardEffects,
|
||||
@@ -380,5 +405,5 @@ function prepareCandidate(cards: CardRow[]): PreparedCandidate {
|
||||
};
|
||||
}
|
||||
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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 인게임 종합력의 모든 항목을 재현한다', () => {
|
||||
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('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => {
|
||||
const result = calculateUnitPower([
|
||||
{ card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 },
|
||||
|
||||
@@ -111,6 +111,28 @@ export function calculateUnitPower(
|
||||
memberPowerUpPercent = 0
|
||||
): UnitPowerResult {
|
||||
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 parameters = baseParameters.map((base, index) => {
|
||||
|
||||
Reference in new issue
Block a user