고정 멤버와 탐색 목적을 유지하며 제한적인 두 카드 교체 후보를 정밀 평가합니다. 기존 평가 예산 안에서 이미 평가한 편성을 제외하고 후보를 재선택합니다.
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일
|
## 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(); }
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 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 },
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in new issue
Block a user