diff --git a/UPDATES.md b/UPDATES.md index d3453b1..5782a18 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-10-13T02:39:27.450Z +updatedAt: 2026-10-13T05:22:29.404Z --- ## 2026년 10월 13일 +### 조합 탐색 정확도 향상 + +`IMPROVED` 조합 탐색 정확도를 향상했습니다. + ### 조합 탐색 속도 개선 `IMPROVED` 반복 계산을 줄여 조합 탐색 속도를 개선했습니다. diff --git a/src/lib/engine/combination-preparation.test.ts b/src/lib/engine/combination-preparation.test.ts new file mode 100644 index 0000000..ab37dc4 --- /dev/null +++ b/src/lib/engine/combination-preparation.test.ts @@ -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 { + 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(); } +}); diff --git a/src/lib/engine/combination-preparation.ts b/src/lib/engine/combination-preparation.ts index d9896bf..869a05c 100644 --- a/src/lib/engine/combination-preparation.ts +++ b/src/lib/engine/combination-preparation.ts @@ -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; @@ -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|undefined; + let primaryCandidateKeys = new Set(); let roughStatCache=new Map();let roughCardCache=new Map();let roughSkillCache=new Map>(); 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(); @@ -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}; } diff --git a/src/lib/engine/combination-refinement.test.ts b/src/lib/engine/combination-refinement.test.ts new file mode 100644 index 0000000..12acc66 --- /dev/null +++ b/src/lib/engine/combination-refinement.test.ts @@ -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(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(); + 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(), 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(), 0), []); + assert.equal(selectRefinementCandidates([a, duplicate, b], new Set(), 10).length, 2); +}); diff --git a/src/lib/engine/combination-refinement.ts b/src/lib/engine/combination-refinement.ts new file mode 100644 index 0000000..b88a3b8 --- /dev/null +++ b/src/lib/engine/combination-refinement.ts @@ -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( + sources: readonly (readonly T[])[], + candidates: readonly T[], + fixedIds: ReadonlySet, + prepare: (cards: T[]) => P, + priority: (candidate: P) => number, + accepts: (cards: readonly T[]) => boolean +): P[] { + const unique = new Map(); + 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( + candidates: readonly T[], + evaluatedKeys: ReadonlySet, + limit: number +): T[] { + const unique = new Map(); + 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)); +} diff --git a/src/lib/engine/unit-power.test.ts b/src/lib/engine/unit-power.test.ts index 25bcf26..890908a 100644 --- a/src/lib/engine/unit-power.test.ts +++ b/src/lib/engine/unit-power.test.ts @@ -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 }, diff --git a/src/lib/engine/unit-power.ts b/src/lib/engine/unit-power.ts index f30c040..48eef23 100644 --- a/src/lib/engine/unit-power.ts +++ b/src/lib/engine/unit-power.ts @@ -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) => {