diff --git a/UPDATES.md b/UPDATES.md index 44472ec..d3453b1 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-10-12T15:37:11.134Z +updatedAt: 2026-10-13T02:39:27.450Z --- ## 2026년 10월 13일 +### 조합 탐색 속도 개선 + +`IMPROVED` 반복 계산을 줄여 조합 탐색 속도를 개선했습니다. + ### NEW[zom]BIE!! 추가 `NEW` 신곡 NEW[zom]BIE!!의 Easy·Normal·Hard·Expert 네 채보와 채보 표시 데이터, 재킷 이미지를 추가했습니다. 난이도는 6/13/19/26이며, 공식 게임 채보와 현재 Android 게임 DB를 대조해 반영했습니다. diff --git a/src/lib/engine/board-effects.ts b/src/lib/engine/board-effects.ts index 3260449..5a35367 100644 --- a/src/lib/engine/board-effects.ts +++ b/src/lib/engine/board-effects.ts @@ -26,6 +26,32 @@ export type BoardConnections = { placements: Readonly>>>; cards: readonly BoardConnectionCard[]; }; + +type BoardModelIndex = { + positionById: ReadonlyMap; + groupIdByCoordinate: ReadonlyMap; + center?: string; +}; + +/** Per-search indexes. Treat the source model geometry and card levels as immutable. */ +export type BoardEffectContext = { + readonly summary: BoardSummarySource; + readonly connectionCards?: readonly BoardConnectionCard[]; + readonly models: ReadonlyMap; + readonly cards: ReadonlyMap; +}; + +export function createBoardEffectContext(summary: BoardSummarySource, connections?: BoardConnections | null): BoardEffectContext { + const models = new Map(); + for (const [id, positions] of Object.entries(summary.models ?? {})) { + models.set(id, { + positionById: new Map(positions.map((position) => [position.groupId, position])), + groupIdByCoordinate: new Map(positions.map((position) => [`${position.x},${position.y}`, position.groupId])), + center: positions.find((position) => position.x === 0 && position.y === 0)?.groupId + }); + } + return { summary, connectionCards: connections?.cards, models, cards: new Map((connections?.cards ?? []).map((card) => [card.id, card])) }; +} export type BoardEffectOverrides = { generationBonusByCardId?: Readonly>; directSongScoreBonusPercent?: number; @@ -95,19 +121,21 @@ function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: Boa function connectionMultipliers( summary: BoardSummarySource, memberBoards: Readonly>, - connections?: BoardConnections | null + connections?: BoardConnections | null, + context?: BoardEffectContext ): Record> { if (!connections || !summary.models || !summary.members) return {}; - const cards = new Map(connections.cards.map((card) => [card.id, card])); + const indexes = context?.summary === summary && context.connectionCards === connections.cards + ? context : createBoardEffectContext(summary, connections); + const cards = indexes.cards; const usedCards = new Set(); const result: Record> = {}; for (const member of summary.members) { - const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? []; - const positionById = new Map(positions.map((position) => [position.groupId, position])); - const groupIdByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position.groupId])); + const model = indexes.models.get(member.positionGroupId); + if (!model) continue; + const { positionById, groupIdByCoordinate, center } = model; const active = new Set(memberBoards[member.id] ?? []); - const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId; if (center) active.add(center); for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) { @@ -247,7 +275,8 @@ export function calculateBoardEffects( leaderCharacterId: string, song?: BoardEffectSong | null, connections?: BoardConnections | null, - overrides?: BoardEffectOverrides | null + overrides?: BoardEffectOverrides | null, + context?: BoardEffectContext ): BoardEffectsResult { const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()])); const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()])); @@ -255,7 +284,7 @@ export function calculateBoardEffects( const unitCharacterIds = new Set(cards.map((card) => card.characterId)); let scoreSupportPercent = 0; let directSongScoreBonusPercent = 0; - const multipliers = connectionMultipliers(summary, memberBoards, connections); + const multipliers = connectionMultipliers(summary, memberBoards, connections, context); const generationBonusByCardId: Record = {}; for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) { diff --git a/src/lib/engine/combination-evaluation-pool.test.ts b/src/lib/engine/combination-evaluation-pool.test.ts new file mode 100644 index 0000000..e58fa5f --- /dev/null +++ b/src/lib/engine/combination-evaluation-pool.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CombinationEvaluationPool } from './combination-evaluation-pool.ts'; +import { CombinationWorkerSession } from './combination-worker-session.ts'; +import type { EvaluationCandidate } from './combination-evaluation-pool.ts'; +import type { ExactSongChart } from './types.ts'; +const chart: ExactSongChart = {songId:'pool',difficulty:'expert',durationSeconds:1,scoreRatio:1,specialStartSeconds:[0,0,0,0,0],notes:[]}; +const candidates = Array.from({length:16},(_,index)=>({overallPower:1000+index,perfectNoteScore:100, + cards:Array.from({length:5},(_,slot)=>({id:`${index}-${slot}`,name:'test',type:'pure',generation:'0',active:{scoreUpPercent:0,cooldownSeconds:1,durationSeconds:1,probabilityPercent:100},special:{scoreUpPercent:0,durationSeconds:1},allowedCooldownReductions:[0,4,8,12] as const})) +})) satisfies EvaluationCandidate[]; +class FakeWorker { + onmessage: ((event:{data:any})=>void)|null=null;onerror:(()=>void)|null=null;terminations=0; + requests:any[]=[];automatic=true; + terminate(){this.terminations++;} + respond(request:any,operationId=request.operationId){ + const rankings=request.candidates.map((c:EvaluationCandidate)=>({overallPower:c.overallPower,cards:c.cards,result:{order:c.cards.map(c=>c.id),theoreticalMaxScore:c.overallPower,expectedScore:c.overallPower-1}})); + this.onmessage?.({data:{type:'combination-result',operationId,rankings,indices:rankings.map((_:any,i:number)=>i)}}); + } + postMessage(request:any){this.requests.push(request);if(this.automatic)queueMicrotask(()=>this.respond(request));} +} +test('one pool reuses Workers across complete precision passes',async()=>{ + const session=new CombinationWorkerSession();const id=session.start();const workers:FakeWorker[]=[]; + const pool=new CombinationEvaluationPool(()=>{const w=new FakeWorker();workers.push(w);return w as unknown as Worker;},session,id,4); + const phases:string[]=[];const options={resultLimit:10,onPhase:(p:string)=>phases.push(p),onProgress:()=>{}}; + const first=await pool.run(candidates,chart,options);assert.equal(first.length,10);assert.equal(first[0].overallPower,1015); + const full=await pool.run(candidates,chart,{...options});assert.deepEqual(first,full);assert.equal(workers.length,4); + assert.ok(workers.every(w=>w.requests.length>=2));assert.deepEqual(phases,['exact','exact']); + pool.close();pool.close();assert.ok(workers.every(w=>w.terminations===1)); +}); +test('stale operation replies are ignored and cancellation rejects the pending pass',async()=>{ + const session=new CombinationWorkerSession();const id=session.start();const worker=new FakeWorker();worker.automatic=false; + const pool=new CombinationEvaluationPool(()=>worker as unknown as Worker,session,id,1); + let resolved=false;const pending=pool.run(candidates,chart,{resultLimit:10,onPhase:()=>{},onProgress:()=>{}}).then(()=>{resolved=true;}); + worker.respond(worker.requests[0],0);await Promise.resolve();assert.equal(resolved,false); + session.cancel();pool.close();await assert.rejects(pending,/취소/);assert.equal(worker.terminations,1); + worker.respond(worker.requests[0]);assert.equal(resolved,false); +}); +test('Worker failures reject the pass and closing terminates every owned Worker',async()=>{ + const session=new CombinationWorkerSession();const id=session.start();const worker=new FakeWorker();worker.automatic=false; + const pool=new CombinationEvaluationPool(()=>worker as unknown as Worker,session,id,1); + const pending=pool.run(candidates,chart,{resultLimit:10,onPhase:()=>{},onProgress:()=>{}}); + worker.onerror?.();await assert.rejects(pending,/Worker/);pool.close();assert.equal(worker.terminations,1); +}); +test('every prepared candidate keeps all allowed cooldowns and receives precision evaluation',async()=>{ + const session=new CombinationWorkerSession();const id=session.start();const workers:FakeWorker[]=[]; + const pool=new CombinationEvaluationPool(()=>{const worker=new FakeWorker();workers.push(worker);return worker as unknown as Worker;},session,id,4); + const result=await pool.run(candidates,chart,{resultLimit:10,onPhase:()=>{},onProgress:()=>{}}); + assert.equal(pool.lastExactCount,candidates.length);assert.equal(result.length,10); + assert.equal(workers.reduce((sum,worker)=>sum+worker.requests.reduce((n,request)=>n+request.candidates.length,0),0),candidates.length); + assert.ok(workers.every(worker=>worker.requests.every(request=>!request.summaryOnly&&request.candidates.every((candidate:EvaluationCandidate)=>candidate.cards.every(card=>card.allowedCooldownReductions?.length===4))))); + pool.close();assert.ok(workers.every(worker=>worker.terminations===1)); +}); diff --git a/src/lib/engine/combination-evaluation-pool.ts b/src/lib/engine/combination-evaluation-pool.ts new file mode 100644 index 0000000..c0797d0 --- /dev/null +++ b/src/lib/engine/combination-evaluation-pool.ts @@ -0,0 +1,101 @@ +import { CombinationWorkerSession } from './combination-worker-session.ts'; +import { countOptimizationCandidates } from './index.ts'; +import type { ExactOptimizationResult, ExactSongChart, OptimizerCard, OptimizationProgress } from './types.ts'; + +export type EvaluationCandidate = { + cards: OptimizerCard[]; overallPower: number; perfectNoteScore: number; + directSongScoreBonusPercent?: number; finalScoreBonusPercent?: number; + eventBonusPercent?: number; rankingMultiplier?: number; optimizationObjective?: 'maximum' | 'expected'; +}; +export type EvaluationRanking = { + result: ExactOptimizationResult; overallPower: number; cards?: OptimizerCard[]; + directSongScoreBonusPercent?: number; finalScoreBonusPercent?: number; + eventBonusPercent?: number; rankingMultiplier?: number; +}; + +type IndexedRanking = { index: number; ranking: EvaluationRanking }; +type Pending = { operationId: number; resolve: (data: any) => void; reject: (error: Error) => void; progress: (value: number) => void }; + +export function evaluationRankingValue(ranking: EvaluationRanking): number { + return (ranking.rankingMultiplier === undefined ? ranking.result.theoreticalMaxScore : ranking.result.expectedScore) * (ranking.rankingMultiplier ?? 1); +} +export function compareEvaluationRankings(left: EvaluationRanking, right: EvaluationRanking): number { + return evaluationRankingValue(right) - evaluationRankingValue(left) || right.result.expectedScore - left.result.expectedScore || right.result.theoreticalMaxScore - left.result.theoreticalMaxScore; +} +/** One reusable Worker pool per immutable search snapshot. */ +export class CombinationEvaluationPool { + private workers: Worker[] = []; + private pending = new Map(); + private sequence = 0; + private closed = false; + lastExactCount = 0; + private session: CombinationWorkerSession; + private requestId: number; + constructor(factory: () => Worker, session: CombinationWorkerSession, requestId: number, count: number) { + this.session = session; + this.requestId = requestId; + const limit = Math.max(1, Math.min(32, Number.isFinite(count) ? Math.floor(count) : 1)); + for (let index = 0; index < limit; index++) { + const worker = factory(); + this.workers.push(worker); session.track(worker, requestId); + worker.onmessage = (event) => { + const job = this.pending.get(worker); + if (!job || !session.owns(worker, requestId) || event.data.operationId !== job.operationId) return; + if (event.data.type === 'progress') job.progress(event.data.progress.evaluatedCandidates); + if (event.data.type === 'combination-result') { this.pending.delete(worker); job.resolve(event.data); } + if (event.data.type === 'error') { this.pending.delete(worker); job.reject(new Error(event.data.message)); } + }; + worker.onerror = () => { + const job = this.pending.get(worker); + if (!job || !session.owns(worker, requestId)) return; + this.pending.delete(worker); job.reject(new Error('후보 평가 Worker에서 오류가 발생했습니다.')); + }; + } + } + close(): void { + if (this.closed) return; + this.closed = true; + for (const [worker, job] of this.pending) job.reject(new Error('취소된 탐색입니다.')); + this.pending.clear(); + for (const worker of this.workers) this.session.release(worker); + this.workers = []; + } + private send(worker: Worker, payload: unknown, progress: (value: number) => void): Promise { + if (this.closed || !this.session.owns(worker, this.requestId)) return Promise.reject(new Error('취소된 탐색입니다.')); + if (this.pending.has(worker)) return Promise.reject(new Error('Worker 작업이 겹쳤습니다.')); + const operationId = ++this.sequence; + return new Promise((resolve, reject) => { + this.pending.set(worker, { operationId, resolve, reject, progress }); + try { worker.postMessage({ ...(payload as object), operationId }); } + catch (error) { this.pending.delete(worker); reject(error); } + }); + } + private async evaluate(items: Array<{ index: number; candidate: EvaluationCandidate }>, chart: ExactSongChart, + onProgress: (progress: OptimizationProgress) => void, resultLimit = 10): Promise { + const count = Math.min(this.workers.length, items.length); + if (count === 0) return []; + this.lastExactCount += items.length; + const groups = Array.from({ length: count }, () => [] as typeof items); + items.forEach((item, index) => groups[index % count].push(item)); + const values = Array(count).fill(0); + const totalCandidates = items.reduce((sum, item) => sum + countOptimizationCandidates(item.candidate.cards), 0); + const update = () => onProgress({ evaluatedCandidates: values.reduce((sum, value) => sum + value, 0), totalCandidates }); + const replies = await Promise.all(groups.map(async (group, index) => { + const reply = await this.send(this.workers[index], { type: 'optimize-combinations', candidates: group.map(item => item.candidate), chart, + returnAllRankings: false, resultLimit, withInputIndices: true }, value => { values[index] = value; update(); }); + return reply.rankings.map((ranking: EvaluationRanking, position: number) => ({ index: group[reply.indices[position]].index, ranking })); + })); + return replies.flat(); + } + async run(candidates: EvaluationCandidate[], chart: ExactSongChart, options: { + resultLimit: number; + onPhase: (phase: 'exact') => void; onProgress: (progress: OptimizationProgress) => void; + }): Promise { + const indexed = candidates.map((candidate, index) => ({ candidate, index })); + this.lastExactCount = 0; + options.onPhase('exact'); + const full = await this.evaluate(indexed, chart, options.onProgress, options.resultLimit); + return full.sort((left, right) => compareEvaluationRankings(left.ranking, right.ranking) || left.index - right.index) + .slice(0, options.resultLimit).map(row => row.ranking); + } +} diff --git a/src/lib/engine/combination-performance.test.ts b/src/lib/engine/combination-performance.test.ts new file mode 100644 index 0000000..b8e8394 --- /dev/null +++ b/src/lib/engine/combination-performance.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {generateCombinationCandidates} from './combination-search.ts'; +import {calculateBoardEffects, createBoardEffectContext, type BoardSummarySource} from './board-effects.ts'; + +// The old traversal evaluates duplicate permutations before retaining the first. +function reference(pool: {id:string;characterId:string;score:number}[], target:number, width:number, score:(cards:readonly typeof pool[number][])=>number) { + let beam=[{cards:[] as typeof pool,score:score([])}]; + for(let size=0;size(); + for(const state of beam)for(const card of pool) { + if(state.cards.some(c=>c.id===card.id||c.characterId===card.characterId))continue; + const cards=[...state.cards,card];const next={cards,score:score(cards)}; + const key=cards.map(c=>c.id).sort().join('|');if(!expanded.has(key))expanded.set(key,next); + } + beam=[...expanded.values()].sort((a,b)=>b.score-a.score||a.cards.map(c=>c.id).join().localeCompare(b.cards.map(c=>c.id).join())).slice(0,width); + } + return beam.sort((a,b)=>b.score-a.score).map(s=>s.cards); +} + +test('duplicate pruning preserves first representation, stable ties and beam truncation',()=>{ + const pool=Array.from({length:9},(_,i)=>({id:String.fromCharCode(97+i),characterId:String(i%7),score:i%3})); + for(const width of [1,2,7,100])for(const target of [2,3,5])for(const mode of [0,1,2]) { + const score=(cards:readonly typeof pool[number][])=>mode===0?0:mode===1?cards.reduce((n,c)=>n+c.score,0):cards.length===1&&cards[0].id==='b'?1:0; + assert.deepEqual(generateCombinationCandidates([],pool,target,c=>c.score,width,score),reference(pool,target,width,score)); + } + let calls=0; + generateCombinationCandidates([],pool,3,c=>c.score,100,cards=>{calls++;return cards.length;}); + let referenceCalls=0;reference(pool,3,100,cards=>{referenceCalls++;return cards.length;}); + assert.ok(calls{ + const summary:BoardSummarySource={members:[{id:'a',positionGroupId:'m'},{id:'b',positionGroupId:'m'}],models:{m:[{groupId:'S',x:0,y:0},{groupId:'B',x:1,y:0},{groupId:'G',x:2,y:0}]},nodeGroups:{ + B:[{characterIds:[],effect:{type:'performance_up',value:7,targetId:'skill_tree_effect_target-skill_tree_character'}}], + G:[{characterIds:[],effect:{type:'all_parameter_up',value:11}}] + }}; + const cards=[{id:'a1',characterId:'a',type:'pure',generation:'0'},{id:'b1',characterId:'b',type:'pure',generation:'1'}]; + for(const awakening of [0,5])for(const active of [[],['B'],['B','G','B']])for(const duplicate of [true,false]) { + const connections={placements:{a:{S:'c'},b:{S:duplicate?'c':'d'}},cards:['c','d'].map(id=>({id,awakening,connect:[{level:1,effectPermilUp:150,extent:[{x:1,y:0},{x:2,y:0}]},{level:2,effectPermilUp:500,extent:[{x:1,y:0}]}]}))}; + const boards={a:active,b:['B','G']};const context=createBoardEffectContext(summary,connections); + const expected=calculateBoardEffects(summary,boards,cards,'a',null,connections); + assert.deepEqual(calculateBoardEffects(summary,boards,cards,'a',null,connections,null,context),expected); + const changed={...connections,cards:connections.cards.map(c=>({...c,awakening:5}))}; + assert.deepEqual(calculateBoardEffects(summary,boards,cards,'a',null,changed,null,context),calculateBoardEffects(summary,boards,cards,'a',null,changed)); + } +}); + +test('compact identity words and generic fallbacks preserve traversal at boundary identities',()=>{ + for(const length of [33,65,96,97])for(const pipe of [false,true]) { + const pool=Array.from({length},(_,i)=>({id:(pipe&&i===0?'a|b':`card-${i}`),characterId:`member-${i}`,score:i%4})); + const score=(cards:readonly typeof pool[number][])=>cards.reduce((n,c)=>n+c.score,0); + assert.deepEqual(generateCombinationCandidates([],pool,2,c=>c.score,30,score),reference(pool,2,30,score)); + } +}); diff --git a/src/lib/engine/combination-preparation.ts b/src/lib/engine/combination-preparation.ts new file mode 100644 index 0000000..d9896bf --- /dev/null +++ b/src/lib/engine/combination-preparation.ts @@ -0,0 +1,384 @@ +import { + additionalAwakeningsRequired, + calculateBoardEffects, + calculateUnitPower, + createBoardEffectContext, + combinationSearchLimits, + compareEventRankings, + countOptimizationCandidates, + effectiveActiveProbability, + eventBonusBreakdown, + eventOptimizationCardCandidates, + eventChallengeSongScoreBonusPercent, + eventPointIndex, + generateAnchoredCombinationCandidates, + generateCombinationCandidates, + memoryParameterPermil, + perfectNoteScoreFromPower, + resolveCandidateAwakening, + selectMemberDiverseCandidates, + toOptimizerCard, + withinMediumProbabilityCardLimit + } from './index.ts'; +import {combinationCardCandidates} from './combination-card-filter.ts'; +import type cardSummary from '../data/cards-summary.json'; +import type exactChartCatalog from '../data/exact-chart-catalog.json'; +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'; +type CardRow=(typeof cardSummary.cards)[number]; +type ChartMetadata=(typeof exactChartCatalog.charts)[number]; +export type CollectionState = Record; +export type MemberBoards = Record; +export type BoardCards = Record>; +export type PreparedCandidate = { + cards: OptimizerCard[]; + overallPower: number; + perfectNoteScore: number; + directSongScoreBonusPercent: number; + finalScoreBonusPercent: number; + heuristic: number; + skillPotential: number; + eventBonusPercent?: number; + rankingMultiplier?: number; + optimizationObjective?: 'maximum' | 'expected'; + }; +export type RefinementRanking={result:Pick}; +export type CombinationPreparationContext={ + allCards:readonly CardRow[]; + boardSummary:BoardSummarySource; + memoryEffects:readonly MemoryCollectionEffect[]; + chartMetadata:ChartMetadata; + growth:{collection:CollectionState;memberBoards:MemberBoards;boardCards:BoardCards;memoryCount:number;memberPowerUpPercent:number}; + settings:{fixedCardIds:string[];fixedAwakenings:number[];fixedCooldownReductions:(CooldownReduction|null)[];fixedAllBlueNodes:boolean[];fixedGenerationBonusOverrides:(number|null)[];candidateAwakening:number;candidateGenerationBonus:number|null;songScoreBonusOverride:number|null;ownedCardsOnly:boolean;regularCardsOnly:boolean;maxMediumProbabilityCards:number|null;leaderCharacterId:string;leaderOutfitCardId:string}; + eventMode:boolean;eventPtMode:boolean;currentEvent:EventDefinition|null; +}; + +/** One immutable request snapshot, shared by its initial and refinement preparation. */ +export function createCombinationPreparation(context:CombinationPreparationContext){ + const {allCards,boardSummary,chartMetadata:selectedChart,eventMode,eventPtMode,currentEvent}=context; + const {collection,memberBoards,boardCards,memoryCount,memberPowerUpPercent}=context.growth; + const {fixedCardIds,fixedAwakenings,fixedCooldownReductions,fixedAllBlueNodes,fixedGenerationBonusOverrides,candidateAwakening,candidateGenerationBonus,songScoreBonusOverride,ownedCardsOnly,regularCardsOnly,maxMediumProbabilityCards,leaderCharacterId,leaderOutfitCardId}=context.settings; + const selectedSongId=selectedChart.songId; + const memorySummary={effects:context.memoryEffects}; + const cardById=new Map(allCards.map(card=>[card.id,card])); + const fixedCards=fixedCardIds.map(id=>cardById.get(id)).filter((card):card is CardRow=>Boolean(card)); + const eligibleCards=eventMode?eventOptimizationCardCandidates(allCards,currentEvent):allCards.filter(card=>card.rarity===5); + const candidateCards=combinationCardCandidates(eligibleCards,collection,ownedCardsOnly,regularCardsOnly); + const leaderOutfit=cardById.get(leaderOutfitCardId)?.details.costume??null; + 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 roughStatCache=new Map();let roughCardCache=new Map();let roughSkillCache=new Map>(); + function resetSearchCaches(): void { + roughStatCache = new Map(); + roughCardCache = new Map(); + roughSkillCache = new Map(); + boardEffectContext = createBoardEffectContext(boardSummary, { placements: boardCards, cards: connectionCards }); + } +function normalizeAwakening(value: unknown): number { + return Math.max(0, Math.min(5, Number.isFinite(value) ? Math.round(Number(value)) : 5)); + } +function fixedSlot(cardId: string): number { + return fixedCardIds.indexOf(cardId); + } +function awakeningForCard(card: CardRow): number { + const slot = fixedSlot(card.id); + if (slot >= 0) return fixedAwakenings[slot]; + const storedCard = collection[card.id]; + return resolveCandidateAwakening( + candidateAwakening, + Boolean(storedCard?.owned), + normalizeAwakening(storedCard?.awakening) + ); + } +function roughCardScore(card: CardRow): number { + let score = roughCardCache.get(card.id); + if (score === undefined) { + const statTotal = roughStatScore(card); + const { activeValue, specialValue } = roughSkillValues(card); + score = statTotal * (1 + activeValue / 100 + specialValue / 500); + roughCardCache.set(card.id, score); + } + const bonus = currentEvent && eventPtMode + ? eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow)).totalPercent : 0; + const value = score * (1 + bonus / 100); + return value; + } +function roughStatScore(card: CardRow): number { + const cached = roughStatCache.get(card.id); + if (cached !== undefined) return cached; + const value = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0); + roughStatCache.set(card.id, value); + return value; + } +function roughSkillScore(card: CardRow): number { + const { adapted, activeValue, specialValue } = roughSkillValues(card); + return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0); + } +function roughSkillValues(card: CardRow) { + let value = roughSkillCache.get(card.id); + if (!value) { + value = computeRoughSkillValues(card); + roughSkillCache.set(card.id, value); + } + return value; + } +function computeRoughSkillValues(card: CardRow) { + const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]); + const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / + Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted); + const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100; + return { adapted, activeValue, specialValue }; + } +function roughCombinationScore(cards: readonly CardRow[]): number { + const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0); + const skillPotential = cards.reduce((sum, card) => { + const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards); + const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / + Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted); + const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100; + const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100; + const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => + effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0); + return sum + activeValue + specialValue + activationValue + passiveValue; + }, 0); + const score = statTotal * (1 + skillPotential / 500); + if (!currentEvent) return score; + const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); + const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)); + return score * (eventPtMode ? 1 + bonus.totalPercent / 100 : 1) * (1 + songBonus / 100); + } +function roughEventBonusPriority(cards: readonly CardRow[]): number { + if (!currentEvent || !eventPtMode) return roughCombinationScore(cards); + const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); + // 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다. + return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards); + } +function combinationKey(cards: readonly CardRow[] | readonly OptimizerCard[]): string { + return cards.map((card) => card.id).sort().join('|'); + } +function boardsForCombination(cards: readonly CardRow[]): MemberBoards { + // Only replace selected members' arrays; saved arrays are never mutated. + const boards = { ...memberBoards }; + for (const card of cards) { + const slot = fixedSlot(card.id); + if (slot >= 0 && !fixedAllBlueNodes[slot]) continue; + const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B')); + boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])]; + } + return boards; + } +function acceptsCombination(cards: readonly CardRow[]): boolean { + return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards); + } +function screeningCombinations(): CardRow[][] { + const predicate = maxMediumProbabilityCards === null ? undefined : acceptsCombination; + const limits = combinationSearchLimits(fixedCards.length); + const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit; + if (fixedCards.length === 4) { + return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit, undefined, predicate); + } + const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8); + const anchoredBudget = screeningLimit - globalBudget; + const strategies = eventMode + ? [ + { scoreCard: roughCardScore }, + { scoreCard: roughStatScore, scoreCombination: roughCombinationScore }, + { scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority } + ] + : [ + { scoreCard: roughCardScore }, + { scoreCard: roughSkillScore }, + { scoreCard: roughStatScore }, + { scoreCard: roughStatScore, scoreCombination: roughCombinationScore } + ]; + const perStrategy = Math.ceil(globalBudget / strategies.length); + const combined = new Map(); + for (const strategy of strategies) { + for (const cards of generateCombinationCandidates( + fixedCards, + candidateCards, + 5, + strategy.scoreCard, + perStrategy, + strategy.scoreCombination, + predicate + )) { + combined.set(combinationKey(cards), cards); + } + } + if (eventMode) return Array.from(combined.values()).slice(0, screeningLimit); + const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); + const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length; + const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount))); + for (const cards of generateAnchoredCombinationCandidates( + fixedCards, + candidateCards, + 5, + strategies, + perAnchorLimit, + predicate + )) { + combined.set(combinationKey(cards), cards); + } + return Array.from(combined.values()).slice(0, screeningLimit); + } +function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] { + if (fixedCards.length === 4) return prepared; + const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length); + const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit; + const selected = new Map(); + const add = (candidates: PreparedCandidate[], count: number) => { + for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate); + }; + const diverse = selectMemberDiverseCandidates( + prepared, + (candidate) => candidate.cards, + (candidate) => candidate.heuristic, + fixedCardIds, + Math.ceil(finalistLimit / 2) + ); + add(diverse, diverse.length); + const remainingLimit = Math.max(0, finalistLimit - selected.size); + const heuristicLimit = Math.ceil(remainingLimit / 2); + const skillLimit = Math.ceil(remainingLimit / 4); + const powerLimit = remainingLimit - heuristicLimit - skillLimit; + add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit); + add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit); + add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit); + if (selected.size < finalistLimit) { + for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) { + selected.set(combinationKey(candidate.cards), candidate); + if (selected.size >= finalistLimit) break; + } + } + return Array.from(selected.values()).slice(0, finalistLimit); + } +function buildRefinementFinalists(rankings: RefinementRanking[]): PreparedCandidate[] { + if (fixedCards.length === 4 || rankings.length === 0) return []; + const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length); + const alternatives = new Map(); + const topResultNeighbors = new Map(); + const secondaryPriority = new Map(); + const fixedIds = new Set(fixedCards.map((card) => card.id)); + + for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) { + const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card)); + if (sourceCards.length !== 5) continue; + const sourceKey = combinationKey(sourceCards); + const bestByReplacementId = new Map(); + sourceCards.forEach((source, index) => { + if (fixedIds.has(source.id)) return; + const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId)); + for (const replacement of candidateCards) { + if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue; + const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card); + if (!acceptsCombination(cards)) continue; + const key = combinationKey(cards); + if (key === sourceKey) continue; + const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) }; + alternatives.set(key, candidate); + if (rankingIndex === 0) topResultNeighbors.set(key, candidate); + const previous = bestByReplacementId.get(replacement.id); + if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate); + } + }); + if (rankingIndex > 0 && rankingIndex < 3) { + for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate); + } + } + + const refinementLimit = eventMode + ? Math.min(alternativeExactFinalistLimit, 48) + : Math.max(alternativeExactFinalistLimit, topResultNeighbors.size); + const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) => + right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards))); + const chosenAlternatives = new Map(); + const rankedTopNeighbors = [...topResultNeighbors.values()].sort((left, right) => right.roughScore - left.roughScore); + for (const candidate of rankedTopNeighbors) { + chosenAlternatives.set(combinationKey(candidate.cards), candidate); + if (chosenAlternatives.size >= refinementLimit) break; + } + for (const candidate of secondaryPriority.values()) { + chosenAlternatives.set(combinationKey(candidate.cards), candidate); + if (chosenAlternatives.size >= refinementLimit) break; + } + for (const candidate of rankedAlternatives) { + chosenAlternatives.set(combinationKey(candidate.cards), candidate); + if (chosenAlternatives.size >= refinementLimit) break; + } + return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards)); + } +function prepareCandidate(cards: CardRow[]): PreparedCandidate { + const generationBonusByCardId = Object.fromEntries(cards + .filter((card) => { + const slot = fixedSlot(card.id); + return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null; + }) + .map((card) => { + const slot = fixedSlot(card.id); + return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0]; + })); + const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, { + placements: boardCards, + cards: connectionCards + }, { + generationBonusByCardId, + ...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride }) + }, boardEffectContext); + const power = calculateUnitPower( + cards.map((card) => ({ card, awakening: awakeningForCard(card) })), + leaderOutfit, + boardEffects, + memoryParameterPermil(memoryCount, memorySummary.effects), + memberPowerUpPercent + ); + const optimizerCards = cards.map((card) => { + const slot = fixedSlot(card.id); + const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null; + const adapted = toOptimizerCard( + card, + awakeningForCard(card), + fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction], + cards + ); + const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0; + return { + ...adapted, + active: { ...adapted.active, activationRateUpPercent: activationRate }, + scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent + }; + }); + const skillPotential = optimizerCards.reduce((sum, card) => sum + + card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) + + card.special.scoreUpPercent * card.special.durationSeconds / 100 + + (card.scoreSupportPercent ?? 0) + + (card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0); + const eventBonus = currentEvent + ? eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)) + : null; + const eventSongScoreBonus = currentEvent + ? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)) + : 0; + const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent; + const rankingMultiplier = eventPtMode && eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined; + return { + cards: optimizerCards, + overallPower: power.overallPower, + perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null), + directSongScoreBonusPercent, + finalScoreBonusPercent: eventSongScoreBonus, + heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100), + skillPotential, + optimizationObjective: eventPtMode ? 'expected' : 'maximum', + ...(eventBonus ? { + eventBonusPercent: eventBonus.totalPercent, + rankingMultiplier + } : {}) + }; + } + resetSearchCaches(); + return {generate:screeningCombinations,prepare:prepareCandidate,select:selectExactFinalists,refine:buildRefinementFinalists}; +} diff --git a/src/lib/engine/combination-search.ts b/src/lib/engine/combination-search.ts index 551351f..002ab41 100644 --- a/src/lib/engine/combination-search.ts +++ b/src/lib/engine/combination-search.ts @@ -6,6 +6,10 @@ export type CombinationSearchCard = { type BeamState = { cards: T[]; score: number; + mask0: number; + mask1: number; + mask2: number; + tieKey?: string; }; export type CombinationSearchLimits = { @@ -70,7 +74,18 @@ export function generateCombinationCandidates( const fixedIds = new Set(fixedCards.map((card) => card.id)); const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(card.characterId)); + const identities = [...new Set([...fixedCards, ...pool].map((card) => card.id))]; + const compactKeys = identities.length <= 96 && identities.every((id) => typeof id === 'string' && !id.includes('|')); + const flags = new Map(identities.map((id, index) => [id, [ + compactKeys && index < 32 ? 1 << index : 0, + compactKeys && index >= 32 && index < 64 ? 1 << (index - 32) : 0, + compactKeys && index >= 64 ? 1 << (index - 64) : 0 + ]])); + const entries = pool.map((card) => ({ card, flags: flags.get(card.id)! })); + const fixedMasks = fixedCards.reduce((mask, card) => mask.map((value, index) => value | flags.get(card.id)![index]), [0, 0, 0]); + const tieKey = (state: BeamState): string => state.tieKey ??= state.cards.map((card) => card.id).join(); let beam: BeamState[] = [{ + mask0: fixedMasks[0], mask1: fixedMasks[1], mask2: fixedMasks[2], tieKey: undefined, cards: Array.from(fixedCards), score: scoreCombination?.(fixedCards) ?? fixedCards.reduce((sum, card) => sum + scoreCard(card), 0) }]; @@ -80,21 +95,28 @@ export function generateCombinationCandidates( for (const state of beam) { const usedCharacters = new Set(state.cards.map((card) => card.characterId)); const usedIds = new Set(state.cards.map((card) => card.id)); - for (const card of pool) { + for (const { card, flags: bits } of entries) { if (usedIds.has(card.id)) continue; if (usedCharacters.has(card.characterId)) continue; + const mask0 = state.mask0 | bits[0], mask1 = state.mask1 | bits[1], mask2 = state.mask2 | bits[2]; + const compactKey = compactKeys ? mask0 + ':' + mask1 + ':' + mask2 : ''; + if (compactKeys && !acceptsCombination && expanded.has(compactKey)) continue; const cards = [...state.cards, card]; if (acceptsCombination && !acceptsCombination(cards)) continue; + // Preserve the first traversal's card order and Map insertion order. + // Scoring callbacks are pure; duplicate permutations need no evaluation. + const key = compactKeys ? compactKey : cards.map((entry) => entry.id).sort().join('|'); + if (expanded.has(key)) continue; const nextState = { + mask0, mask1, mask2, tieKey: undefined, cards, score: scoreCombination?.(cards) ?? state.score + scoreCard(card) }; - const key = nextState.cards.map((entry) => entry.id).sort().join('|'); - if (!expanded.has(key)) expanded.set(key, nextState); + expanded.set(key, nextState); } } beam = Array.from(expanded.values()) - .sort((left, right) => right.score - left.score || left.cards.map((card) => card.id).join().localeCompare(right.cards.map((card) => card.id).join())) + .sort((left, right) => right.score - left.score || tieKey(left).localeCompare(tieKey(right))) .slice(0, Math.max(1, beamWidth)); if (beam.length === 0) break; } diff --git a/src/lib/engine/combination-worker-session.test.ts b/src/lib/engine/combination-worker-session.test.ts new file mode 100644 index 0000000..b86c411 --- /dev/null +++ b/src/lib/engine/combination-worker-session.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict';import test from 'node:test'; +import {CombinationWorkerSession} from './combination-worker-session.ts'; +class FakeWorker {terminations=0;terminate(){this.terminations++;}} +test('cancel and restart invalidate queued messages and terminate every owned Worker',()=>{ + const session=new CombinationWorkerSession();const first=session.start(); + const a=new FakeWorker(),b=new FakeWorker();session.track(a,first);session.track(b,first); + assert.ok(session.owns(a,first));session.cancel();assert.equal(a.terminations,1);assert.equal(b.terminations,1);assert.ok(!session.owns(a,first)); + const second=session.start();const c=new FakeWorker();session.track(c,second);assert.ok(session.owns(c,second));assert.ok(!session.isCurrent(first)); + const late=new FakeWorker();assert.ok(!session.track(late,first));assert.equal(late.terminations,1); + session.release(c);session.release(c);assert.equal(c.terminations,1);assert.ok(!session.owns(c,second));session.cancel();assert.equal(c.terminations,1); +}); +test('a failed search cannot deliver results or terminate the replacement search',()=>{ + const session=new CombinationWorkerSession();const old=session.start();const failed=new FakeWorker();session.track(failed,old); + const current=session.start();const replacement=new FakeWorker();session.track(replacement,current); + if(session.owns(failed,old))session.cancel();assert.ok(session.owns(replacement,current));assert.equal(replacement.terminations,0); + session.cancel();assert.equal(replacement.terminations,1); +}); diff --git a/src/lib/engine/combination-worker-session.ts b/src/lib/engine/combination-worker-session.ts new file mode 100644 index 0000000..17b6943 --- /dev/null +++ b/src/lib/engine/combination-worker-session.ts @@ -0,0 +1,18 @@ +/** Own every Worker belonging to one search; invalidate callbacks before termination. */ +export class CombinationWorkerSession { + private version=0; + private workers=new Set(); + start():number { this.cancel();return this.version; } + isCurrent(id:number):boolean {return id===this.version;} + owns(worker:W,id:number):boolean {return this.isCurrent(id)&&this.workers.has(worker);} + track(worker:W,id:number):boolean { + if(!this.isCurrent(id)){worker.terminate();return false;} + this.workers.add(worker);return true; + } + release(worker:W):void {if(this.workers.delete(worker))worker.terminate();} + cancel():void { + this.version+=1; + const workers=[...this.workers];this.workers.clear(); + for(const worker of workers)worker.terminate(); + } +} diff --git a/src/lib/engine/exact-optimizer-expected.ts b/src/lib/engine/exact-optimizer-expected.ts new file mode 100644 index 0000000..6973cc6 --- /dev/null +++ b/src/lib/engine/exact-optimizer-expected.ts @@ -0,0 +1,323 @@ +// Keep the established expected-score path independent of maximum-only runtime specialization. +import { effectiveActiveProbability } from './active-probability.ts'; +import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts'; +import { passiveScoreSupportByCard } from './passive-support.ts'; +import { effectiveCooldownSeconds } from './timeline.ts'; +import { COOLDOWN_REDUCTIONS } from './types.ts'; +import type { + CooldownReduction, + EffectiveSkillWindow, + ExactOptimizationResult, + ExactOptimizeOptions, + ExactSongChart, + OptimizerCard, + SkillWindow +} from './types.ts'; + +type CandidateProfile = { + boost: Float64Array; + probability: Float64Array; +}; + +type NoteGroup = { note: ExactSongChart['notes'][number]; comboBonus: number; count: number }; + +/** Keep exact timing, note semantics and the original combo tier. */ +export function groupExactOptimizationNotes(chart: ExactSongChart): NoteGroup[] { + const groups = new Map(); + chart.notes.forEach((note, index) => { + const bonus = comboBonus(index + 1); + const key = `${note.timeSeconds}|${note.type === 'mid'}|${Boolean(note.isFlick)}|${Boolean(note.isHoldEnd)}|${bonus}`; + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { note, comboBonus: bonus, count: 1 }); + }); + return [...groups.values()]; +} + +function optimizationNotes(cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions): NoteGroup[] { + // Grouped integer additions are exact only within Number's integer precision. + // For unusually large/invalid input, retain every note in its original order. + const supportMagnitude = cards.reduce((sum, card) => sum + Math.abs(card.special.scoreUpPercent) + + Math.abs(card.scoreSupportPercent ?? 0) + (card.passiveScoreSupportEffects ?? []).reduce((n, effect) => n + Math.abs(effect.percent), 0), 0); + const boostMagnitude = Math.max(...cards.map((card) => Math.abs(card.active.scoreUpPercent))) / 100 * (1 + supportMagnitude / 100); + const totalMagnitude = ((Math.abs(options.perfectNoteScore) + 1) * 1.155 * (1 + boostMagnitude) * + Math.abs(1 + (options.directSongScoreBonusPercent ?? 0) / 100) + 1) * chart.notes.length; + return Number.isFinite(totalMagnitude) && totalMagnitude < Number.MAX_SAFE_INTEGER / 2 + ? groupExactOptimizationNotes(chart) + : chart.notes.map((note, index) => ({ note, comboBonus: comboBonus(index + 1), count: 1 })); +} + +function permutations(items: readonly T[]): T[][] { + if (items.length <= 1) return [Array.from(items)]; + const result: T[][] = []; + for (let index = 0; index < items.length; index += 1) { + const rest = [...items.slice(0, index), ...items.slice(index + 1)]; + for (const permutation of permutations(rest)) result.push([items[index], ...permutation]); + } + return result; +} + +function assignments(cards: readonly OptimizerCard[]): Array> { + let result: Array> = [{}]; + for (const card of cards) { + const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; + result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction }))); + } + return result; +} + +function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean { + return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds; +} + +function specialRate( + order: readonly OptimizerCard[], + chart: ExactSongChart, + time: number, + field: 'scoreUpPercent' | 'activationRateUpPercent' +): number { + let total = 0; + for (let slot = 0; slot < order.length; slot += 1) { + const card = order[slot]; + if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0; + } + return total; +} + +function comboBonus(combo: number): number { + return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0; +} + +function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array { + return Float64Array.from(groups, ({ note, comboBonus }) => { + const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1); + return base * (1 + comboBonus); + }); +} + +function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map { + const profiles = new Map(); + const noteSupport = Float64Array.from(groups, ({ note }) => specialScoreSupportForNote(order, chart, note)); + const passiveSupport = passiveScoreSupportByCard(order); + + order.forEach((card, cardIndex) => { + const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; + reductions.forEach((reduction) => { + const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); + const boost = new Float64Array(groups.length); + const probability = new Float64Array(groups.length); + const probabilityByAttempt = new Map(); + groups.forEach(({ note }, noteIndex) => { + const attempt = Math.floor((note.timeSeconds + 1e-9) / interval); + if (attempt <= 0) return; + const activationTime = attempt * interval; + if (note.timeSeconds >= activationTime + card.active.durationSeconds) return; + let effectiveProbability = probabilityByAttempt.get(attempt); + if (effectiveProbability === undefined) { + const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent'); + effectiveProbability = effectiveActiveProbability(card, activationBonus); + probabilityByAttempt.set(attempt, effectiveProbability); + } + boost[noteIndex] = card.active.scoreUpPercent / 100 * + (1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100); + probability[noteIndex] = effectiveProbability; + }); + profiles.set(`${card.id}:${reduction}`, { boost, probability }); + }); + }); + return profiles; +} + +export function buildExactSkillWindows( + order: readonly OptimizerCard[], + reductions: Readonly>, + chart: ExactSongChart, + excludedActiveWindows: readonly Pick[] = [] +): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } { + const activeWindows = order.flatMap((card) => { + const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0); + const windows: SkillWindow[] = []; + for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) { + const startSeconds = activation * interval; + windows.push({ + cardId: card.id, + startSeconds, + endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds), + scoreUpPercent: card.active.scoreUpPercent + }); + } + return windows; + }); + const specialWindows = order.flatMap((card, slot) => { + const startSeconds = chart.specialStartSeconds[slot]; + if (startSeconds === null || startSeconds === undefined) return []; + return [{ + cardId: card.id, + startSeconds, + endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds), + scoreUpPercent: card.special.scoreUpPercent + }]; + }); + const passiveSupport = passiveScoreSupportByCard(order); + const orderIndex = new Map(order.map((card, index) => [card.id, index])); + const boundaries = Array.from(new Set([ + ...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]), + ...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds]) + ])).sort((left, right) => left - right); + const effectiveActiveWindows: EffectiveSkillWindow[] = []; + + for (let index = 0; index < boundaries.length - 1; index += 1) { + const startSeconds = boundaries[index]; + const endSeconds = boundaries[index + 1]; + if (endSeconds - startSeconds < 0.01) continue; + const midpoint = (startSeconds + endSeconds) / 2; + const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent'); + const active = activeWindows + .filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds && + !excludedActiveWindows.some((excluded) => excluded.cardId === window.cardId && Math.abs(excluded.startSeconds - window.startSeconds) < 1e-9)) + .map((window) => { + const cardIndex = orderIndex.get(window.cardId) ?? 0; + const card = order[cardIndex]; + return { + window, + effectivePercent: window.scoreUpPercent * + (1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100) + }; + }) + .sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99)); + const winner = active[0]; + if (!winner) continue; + const previous = effectiveActiveWindows.at(-1); + if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) { + previous.endSeconds = endSeconds; + previous.overlapCount = Math.max(previous.overlapCount, active.length); + } else { + effectiveActiveWindows.push({ + ...winner.window, + startSeconds, + endSeconds, + baseScoreUpPercent: winner.window.scoreUpPercent, + scoreUpPercent: winner.effectivePercent, + overlapCount: active.length + }); + } + } + return { activeWindows, effectiveActiveWindows, specialWindows }; +} + +function scoreAssignment( + cards: readonly OptimizerCard[], + reductions: Readonly>, + profiles: ReadonlyMap, + base: Float64Array, + directBonus: number, + counts: Float64Array, + selected: CandidateProfile[], + boosts: Float64Array, + probabilities: Float64Array +): { expectedScore: number; theoreticalMaxScore: number } { + cards.forEach((card, index) => { + const reduction = reductions[card.id] ?? 0; + const profile = profiles.get(`${card.id}:${reduction}`); + if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`); + selected[index] = profile; + }); + let expectedScore = 0; + let theoreticalMaxScore = 0; + + for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) { + let count = 0; + for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) { + const boost = selected[memberIndex].boost[noteIndex]; + if (boost <= 0) continue; + let position = count; + while (position > 0 && boosts[position - 1] < boost) { + boosts[position] = boosts[position - 1]; + probabilities[position] = probabilities[position - 1]; + position -= 1; + } + boosts[position] = boost; + probabilities[position] = selected[memberIndex].probability[noteIndex]; + count += 1; + } + let expectedBoost = 0; + let previousFailed = 1; + for (let position = 0; position < count; position += 1) { + expectedBoost += previousFailed * probabilities[position] * boosts[position]; + previousFailed *= 1 - probabilities[position]; + } + // Round each original note before applying the group's multiplicity. + expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus) * counts[noteIndex]; + theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus) * counts[noteIndex]; + } + return { expectedScore, theoreticalMaxScore }; +} + +export function optimizeExactOrderAndCooldowns( + cards: readonly OptimizerCard[], + chart: ExactSongChart, + options: ExactOptimizeOptions +): ExactOptimizationResult { + return rankExactCooldownCombinations(cards, chart, options, 1)[0]; +} + +/** Keep the best special order for each distinct per-card cooldown assignment. */ +export function rankExactCooldownCombinations( + cards: readonly OptimizerCard[], + chart: ExactSongChart, + options: ExactOptimizeOptions, + limit = 5 +): ExactOptimizationResult[] { + if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) { + throw new Error('서로 다른 카드 5장이 필요합니다.'); + } + const reductionAssignments = assignments(cards); + const reductionTotals = new Map(reductionAssignments.map((reductions) => [reductions, + Object.values(reductions).reduce((sum, value) => sum + value, 0)])); + const groups = optimizationNotes(cards, chart, options); + const base = baseScores(groups, options.perfectNoteScore); + const counts = Float64Array.from(groups, (group) => group.count); + // Local to this call: callbacks may recursively invoke the optimizer. + const selected: CandidateProfile[] = []; + const boosts = new Float64Array(cards.length); + const probabilities = new Float64Array(cards.length); + const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1); + const totalCandidates = orderCount * reductionAssignments.length; + let evaluatedCandidates = 0; + type Candidate = { + order: OptimizerCard[]; + reductions: Record; + theoreticalMaxScore: number; + expectedScore: number; + reductionTotal: number; + }; + const bestByAssignment = new Map, Candidate>(); + const compare = (left: Candidate, right: Candidate): number => + (options.objective === 'expected' + ? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore + : right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) || + left.reductionTotal - right.reductionTotal; + + for (const order of permutations(cards)) { + const profiles = makeProfiles(order, chart, groups); + for (const reductions of reductionAssignments) { + const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, + counts, selected, boosts, probabilities); + const reductionTotal = reductionTotals.get(reductions)!; + const candidate = { ...score, order, reductions, reductionTotal }; + const previous = bestByAssignment.get(reductions); + if (!previous || compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate); + evaluatedCandidates += 1; + } + options.onProgress?.({ evaluatedCandidates, totalCandidates }); + } + + return [...bestByAssignment.values()].sort(compare).slice(0, Math.max(1, Math.floor(limit))).map((candidate) => ({ + ...calculateExactScore(candidate.order, candidate.reductions, chart, options), + order: candidate.order.map((card) => card.id), + formationOrder: cards.map((card) => card.id), + cooldownReductions: { ...candidate.reductions }, + ...buildExactSkillWindows(candidate.order, candidate.reductions, chart), + evaluatedCandidates + })); +} diff --git a/src/lib/engine/exact-optimizer.ts b/src/lib/engine/exact-optimizer.ts index 3c355c6..3cf85c4 100644 --- a/src/lib/engine/exact-optimizer.ts +++ b/src/lib/engine/exact-optimizer.ts @@ -1,3 +1,4 @@ +import { rankExactCooldownCombinations as rankExpectedCooldownCombinations } from './exact-optimizer-expected.ts'; import { effectiveActiveProbability } from './active-probability.ts'; import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts'; import { passiveScoreSupportByCard } from './passive-support.ts'; @@ -18,6 +19,34 @@ type CandidateProfile = { probability: Float64Array; }; +type NoteGroup = { note: ExactSongChart['notes'][number]; comboBonus: number; count: number }; + +/** Keep exact timing, note semantics and the original combo tier. */ +export function groupExactOptimizationNotes(chart: ExactSongChart): NoteGroup[] { + const groups = new Map(); + chart.notes.forEach((note, index) => { + const bonus = comboBonus(index + 1); + const key = `${note.timeSeconds}|${note.type === 'mid'}|${Boolean(note.isFlick)}|${Boolean(note.isHoldEnd)}|${bonus}`; + const group = groups.get(key); + if (group) group.count += 1; + else groups.set(key, { note, comboBonus: bonus, count: 1 }); + }); + return [...groups.values()]; +} + +function optimizationNotes(cards: readonly OptimizerCard[], chart: ExactSongChart, options: ExactOptimizeOptions): NoteGroup[] { + // Grouped integer additions are exact only within Number's integer precision. + // For unusually large/invalid input, retain every note in its original order. + const supportMagnitude = cards.reduce((sum, card) => sum + Math.abs(card.special.scoreUpPercent) + + Math.abs(card.scoreSupportPercent ?? 0) + (card.passiveScoreSupportEffects ?? []).reduce((n, effect) => n + Math.abs(effect.percent), 0), 0); + const boostMagnitude = Math.max(...cards.map((card) => Math.abs(card.active.scoreUpPercent))) / 100 * (1 + supportMagnitude / 100); + const totalMagnitude = ((Math.abs(options.perfectNoteScore) + 1) * 1.155 * (1 + boostMagnitude) * + Math.abs(1 + (options.directSongScoreBonusPercent ?? 0) / 100) + 1) * chart.notes.length; + return Number.isFinite(totalMagnitude) && totalMagnitude < Number.MAX_SAFE_INTEGER / 2 + ? groupExactOptimizationNotes(chart) + : chart.notes.map((note, index) => ({ note, comboBonus: comboBonus(index + 1), count: 1 })); +} + function permutations(items: readonly T[]): T[][] { if (items.length <= 1) return [Array.from(items)]; const result: T[][] = []; @@ -59,26 +88,26 @@ function comboBonus(combo: number): number { return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0; } -function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array { - return Float64Array.from(chart.notes, (note, index) => { +function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array { + return Float64Array.from(groups, ({ note, comboBonus }) => { const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1); - return base * (1 + comboBonus(index + 1)); + return base * (1 + comboBonus); }); } -function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map { +function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map { const profiles = new Map(); - const noteSupport = Float64Array.from(chart.notes, (note) => specialScoreSupportForNote(order, chart, note)); + const noteSupport = Float64Array.from(groups, ({ note }) => specialScoreSupportForNote(order, chart, note)); const passiveSupport = passiveScoreSupportByCard(order); order.forEach((card, cardIndex) => { const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; reductions.forEach((reduction) => { const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); - const boost = new Float64Array(chart.notes.length); - const probability = new Float64Array(chart.notes.length); + const boost = new Float64Array(groups.length); + const probability = new Float64Array(groups.length); const probabilityByAttempt = new Map(); - chart.notes.forEach((note, noteIndex) => { + groups.forEach(({ note }, noteIndex) => { const attempt = Math.floor((note.timeSeconds + 1e-9) / interval); if (attempt <= 0) return; const activationTime = attempt * interval; @@ -176,21 +205,100 @@ export function buildExactSkillWindows( return { activeWindows, effectiveActiveWindows, specialWindows }; } + +/** Share prefix maxima across all cooldown assignments; round each note exactly as before. */ +function scoreMaximumAssignments( + cards: readonly OptimizerCard[], profiles: ReadonlyMap, + base: Float64Array, directBonus: number, counts: Float64Array +): Float64Array | null { + if (!Number.isFinite(directBonus) || directBonus < -100 || base.some(value => !Number.isFinite(value) || value < 0)) return null; + const choices = cards.map(card => (card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS) + .map(reduction => profiles.get(card.id + ':' + reduction)!)); + if (choices.some(row => row.some(profile => !profile || profile.boost.some(value => !Number.isFinite(value))))) return null; + const widths = choices.map(row => row.length); + const sizes = [1]; for (const width of widths) sizes.push(sizes.at(-1)! * width); + const totals = new Float64Array(sizes.at(-1)!); + const prefix = sizes.slice(0, -1).map(size => new Float64Array(size)); + const offsets = [1]; for (const width of widths) offsets.push(offsets.at(-1)! + width); + // Notes with the same rounded scores for every selectable profile make identical contributions. + const states = new Map(); + let magnitude = 0; + for (let noteIndex = 0; noteIndex < base.length; noteIndex++) { + const baseline = finalNoteScore(base[noteIndex], directBonus); + const scores = [baseline]; + for (const row of choices) for (const profile of row) { + scores.push(finalNoteScore(base[noteIndex] * (1 + Math.max(0, profile.boost[noteIndex])), directBonus)); + } + const largest = Math.max(...scores); + magnitude += largest * counts[noteIndex]; + if (!Number.isSafeInteger(largest) || magnitude >= Number.MAX_SAFE_INTEGER / 2) return null; + const key = scores.join(','); const previous = states.get(key); + if (previous) previous.count += counts[noteIndex]; else states.set(key, { scores, count: counts[noteIndex] }); + } + for (const { scores, count } of states.values()) { + prefix[0][0] = scores[0]; + for (let member = 0; member < cards.length - 1; member++) { + const previous = prefix[member], next = prefix[member + 1], width = widths[member], offset = offsets[member]; + let index = 0; + for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) { + next[index++] = Math.max(previous[p], scores[offset + choice]); + } + } + const member = cards.length - 1, previous = prefix[member], width = widths[member], offset = offsets[member]; + let index = 0; + for (let p = 0; p < previous.length; p++) for (let choice = 0; choice < width; choice++) { + totals[index++] += Math.max(previous[p], scores[offset + choice]) * count; + } + } + return totals; +} + +function scoreMaximumAssignment( + cards: readonly OptimizerCard[], + reductions: Readonly>, + profiles: ReadonlyMap, + base: Float64Array, + directBonus: number, + counts: Float64Array, + selected: CandidateProfile[] +): number { + cards.forEach((card, index) => { + const profile = profiles.get(`${card.id}:${reductions[card.id] ?? 0}`); + if (!profile) throw new Error(`${card.name}의 빈도 조합 프로파일이 없습니다.`); + selected[index] = profile; + }); + let total = 0; + for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) { + let largest = 0; + for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) { + const boost = selected[memberIndex].boost[noteIndex]; + if (boost <= largest) continue; + // Invalid numerical inputs retain the original combined evaluator. + if (!Number.isFinite(boost)) return NaN; + largest = boost; + } + total += finalNoteScore(base[noteIndex] * (1 + largest), directBonus) * counts[noteIndex]; + } + return total; +} + function scoreAssignment( cards: readonly OptimizerCard[], reductions: Readonly>, profiles: ReadonlyMap, base: Float64Array, - directBonus: number + directBonus: number, + counts: Float64Array, + selected: CandidateProfile[], + boosts: Float64Array, + probabilities: Float64Array ): { expectedScore: number; theoreticalMaxScore: number } { - const selected = cards.map((card) => { + cards.forEach((card, index) => { const reduction = reductions[card.id] ?? 0; const profile = profiles.get(`${card.id}:${reduction}`); if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`); - return profile; + selected[index] = profile; }); - const boosts = new Float64Array(cards.length); - const probabilities = new Float64Array(cards.length); let expectedScore = 0; let theoreticalMaxScore = 0; @@ -215,8 +323,9 @@ function scoreAssignment( expectedBoost += previousFailed * probabilities[position] * boosts[position]; previousFailed *= 1 - probabilities[position]; } - expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus); - theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus); + // Round each original note before applying the group's multiplicity. + expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus) * counts[noteIndex]; + theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus) * counts[noteIndex]; } return { expectedScore, theoreticalMaxScore }; } @@ -235,12 +344,21 @@ export function rankExactCooldownCombinations( chart: ExactSongChart, options: ExactOptimizeOptions, limit = 5 -): ExactOptimizationResult[] { +) : ExactOptimizationResult[] { + if (options.objective === 'expected') return rankExpectedCooldownCombinations(cards, chart, options, limit); if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) { throw new Error('서로 다른 카드 5장이 필요합니다.'); } const reductionAssignments = assignments(cards); - const base = baseScores(chart, options.perfectNoteScore); + const reductionTotals = new Map(reductionAssignments.map((reductions) => [reductions, + Object.values(reductions).reduce((sum, value) => sum + value, 0)])); + const groups = optimizationNotes(cards, chart, options); + const base = baseScores(groups, options.perfectNoteScore); + const counts = Float64Array.from(groups, (group) => group.count); + // Local to this call: callbacks may recursively invoke the optimizer. + const selected: CandidateProfile[] = []; + const boosts = new Float64Array(cards.length); + const probabilities = new Float64Array(cards.length); const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1); const totalCandidates = orderCount * reductionAssignments.length; let evaluatedCandidates = 0; @@ -248,30 +366,107 @@ export function rankExactCooldownCombinations( order: OptimizerCard[]; reductions: Record; theoreticalMaxScore: number; - expectedScore: number; + expectedScore: number | undefined; reductionTotal: number; + ties?: Candidate[]; }; const bestByAssignment = new Map, Candidate>(); + const maximumObjective = true; + // Keep a bounded number of order profiles for deferred expected-score ties. + const profileCache = new Map>(); + let activeOrder: readonly OptimizerCard[] | null = null; + let activeProfiles: Map | null = null; + const profilesFor = (order: readonly OptimizerCard[]): Map => { + if (!maximumObjective) return makeProfiles(order, chart, groups); + let profiles = profileCache.get(order); + if (profiles) profileCache.delete(order); + else profiles = makeProfiles(order, chart, groups); + profileCache.set(order, profiles); + if (profileCache.size > 8) profileCache.delete(profileCache.keys().next().value!); + return profiles; + }; + const expectedFor = (candidate: Candidate): number => { + if (candidate.expectedScore === undefined) { + const profiles = candidate.order === activeOrder && activeProfiles ? activeProfiles : profilesFor(candidate.order); + candidate.expectedScore = scoreAssignment(candidate.order, candidate.reductions, profiles, base, + options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities).expectedScore; + } + return candidate.expectedScore; + }; const compare = (left: Candidate, right: Candidate): number => (options.objective === 'expected' - ? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore - : right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) || + ? expectedFor(right) - expectedFor(left) || right.theoreticalMaxScore - left.theoreticalMaxScore + : right.theoreticalMaxScore - left.theoreticalMaxScore || expectedFor(right) - expectedFor(left)) || left.reductionTotal - right.reductionTotal; + const maximumCache = new Map(); for (const order of permutations(cards)) { - const profiles = makeProfiles(order, chart); - for (const reductions of reductionAssignments) { - const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0); - const reductionTotal = Object.values(reductions).reduce((sum, value) => sum + value, 0); + const passive = passiveScoreSupportByCard(order); + const supportById = new Map(order.map((card, index) => [card.id, passive[index]])); + const inputs = [ + ...order.flatMap((card) => [card.special.durationSeconds, card.special.scoreUpPercent]), + ...cards.flatMap((card) => [card.active.cooldownSeconds, card.active.durationSeconds, + card.active.scoreUpPercent, card.scoreSupportPercent ?? 0, supportById.get(card.id)!]), + options.directSongScoreBonusPercent ?? 0 + ]; + const cacheable = inputs.every(Number.isFinite) && cards.every((card) => typeof card.id === 'string') && + chart.specialStartSeconds.every((start) => start === null || start === undefined || Number.isFinite(start)); + const signature = cacheable ? JSON.stringify([inputs, chart.specialStartSeconds]) : null; + let maxima = signature === null ? undefined : maximumCache.get(signature); + if (signature !== null && !maxima) { + maxima = new Float64Array(reductionAssignments.length).fill(NaN); + maximumCache.set(signature, maxima); + } + const profiles = profilesFor(order); + activeOrder = order; + activeProfiles = profiles; + if (!maxima || Number.isNaN(maxima[0])) { + const shared = scoreMaximumAssignments(cards, profiles, base, options.directSongScoreBonusPercent ?? 0, counts); + if (shared) { maxima = shared; if (signature !== null) maximumCache.set(signature, shared); } + } + for (let assignmentIndex = 0; assignmentIndex < reductionAssignments.length; assignmentIndex += 1) { + const reductions = reductionAssignments[assignmentIndex]; + let maximum = maxima?.[assignmentIndex]; + if (maximum === undefined || Number.isNaN(maximum)) { + maximum = scoreMaximumAssignment(order, reductions, profiles, base, + options.directSongScoreBonusPercent ?? 0, counts, selected); + if (maxima && Number.isFinite(maximum)) maxima[assignmentIndex] = maximum; + } + let score: { theoreticalMaxScore: number; expectedScore: number | undefined } = maximumObjective + ? { theoreticalMaxScore: maximum, expectedScore: undefined } + : scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0, + counts, selected, boosts, probabilities); + if (!Number.isFinite(score.theoreticalMaxScore)) score = scoreAssignment(order, reductions, profiles, base, + options.directSongScoreBonusPercent ?? 0, counts, selected, boosts, probabilities); + const reductionTotal = reductionTotals.get(reductions)!; const candidate = { ...score, order, reductions, reductionTotal }; const previous = bestByAssignment.get(reductions); - if (!previous || compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate); + if (previous && (!Number.isFinite(candidate.theoreticalMaxScore) || !Number.isFinite(previous.theoreticalMaxScore))) { + if (compare(candidate, previous) < 0) bestByAssignment.set(reductions, candidate); + } else if (!previous || candidate.theoreticalMaxScore > previous.theoreticalMaxScore) { + bestByAssignment.set(reductions, candidate); + } else if (candidate.theoreticalMaxScore === previous.theoreticalMaxScore) { + // Resolve expected-score ties only if this assignment can reach the requested ranks. + (previous.ties ??= []).push(candidate); + } evaluatedCandidates += 1; } options.onProgress?.({ evaluatedCandidates, totalCandidates }); } - return [...bestByAssignment.values()].sort(compare).slice(0, Math.max(1, Math.floor(limit))).map((candidate) => ({ + const resultCount = Math.max(1, Math.floor(limit)); + let ranked = [...bestByAssignment.values()]; + if (Number.isFinite(resultCount) && ranked.every(candidate => Number.isFinite(candidate.theoreticalMaxScore))) { + const maxima = ranked.map(candidate => candidate.theoreticalMaxScore).sort((left, right) => right - left); + const cutoff = maxima[Math.min(maxima.length, resultCount) - 1]; + ranked = ranked.filter(candidate => candidate.theoreticalMaxScore >= cutoff); + } + ranked = ranked.map(candidate => { + let best = candidate; + for (const tied of candidate.ties ?? []) if (compare(tied, best) < 0) best = tied; + return best; + }); + return ranked.sort(compare).slice(0, resultCount).map((candidate) => ({ ...calculateExactScore(candidate.order, candidate.reductions, chart, options), order: candidate.order.map((card) => card.id), formationOrder: cards.map((card) => card.id), diff --git a/src/lib/engine/exact-performance.test.ts b/src/lib/engine/exact-performance.test.ts new file mode 100644 index 0000000..42370bc --- /dev/null +++ b/src/lib/engine/exact-performance.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {calculateExactScore} from './score.ts'; +import {groupExactOptimizationNotes, rankExactCooldownCombinations} from './exact-optimizer.ts'; +import type {CooldownReduction, ExactOptimizeOptions, ExactSongChart, OptimizerCard} from './types.ts'; + +const cards:OptimizerCard[]=['e','b','d','a','c'].map((id,index)=>({id,name:id,type:index%2?'퓨어':'큐트',generation:'0기생',naturalParameterTotal:index%2?100:200, + active:{cooldownSeconds:2+index,durationSeconds:0.5+index/10,scoreUpPercent:37+index*11,probabilityPercent:46+index,activationRateUpPercent:index*3}, + special:{durationSeconds:1,scoreUpPercent:23+index,activationRateUpPercent:33}, + passiveScoreSupportEffects:index===0?[{percent:7.5,target:'group',targetGroup:'퓨어',targetCount:1}]:[{percent:index,target:'all'}], + allowedCooldownReductions:[0,4] +})); +function permutations(items:readonly T[]):T[][] {return items.length<=1?[Array.from(items)]:items.flatMap((item,i)=>permutations([...items.slice(0,i),...items.slice(i+1)]).map(rest=>[item,...rest]));} +function brute(deck:OptimizerCard[],chart:ExactSongChart,options:ExactOptimizeOptions,limit=5){ + let assignments:Record[]=[{}]; + for(const card of deck)assignments=assignments.flatMap(current=>(card.allowedCooldownReductions??[0,4,8,12]).map(value=>({...current,[card.id]:value}))); + type Candidate={order:OptimizerCard[];reductions:Record;expectedScore:number;theoreticalMaxScore:number;total:number}; + const compare=(a:Candidate,b:Candidate)=>(options.objective==='expected'?b.expectedScore-a.expectedScore||b.theoreticalMaxScore-a.theoreticalMaxScore:b.theoreticalMaxScore-a.theoreticalMaxScore||b.expectedScore-a.expectedScore)||a.total-b.total; + const best=new Map,Candidate>(); + for(const order of permutations(deck))for(const reductions of assignments){ + const score=calculateExactScore(order,reductions,chart,{...options,finalScoreBonusPercent:undefined}); + const candidate={order,reductions,expectedScore:score.expectedScore,theoreticalMaxScore:score.theoreticalMaxScore,total:Object.values(reductions).reduce((n,v)=>n+v,0)}; + const previous=best.get(reductions);if(!previous||compare(candidate,previous)<0)best.set(reductions,candidate); + } + return [...best.values()].sort(compare).slice(0,limit).map(c=>({...calculateExactScore(c.order,c.reductions,chart,options),order:c.order.map(c=>c.id),reductions:c.reductions})); +} +function check(deck:OptimizerCard[],chart:ExactSongChart,options:ExactOptimizeOptions){ + const input=JSON.stringify({deck,chart});const expected=brute(deck,chart,options);const actual=rankExactCooldownCombinations(deck,chart,options,5); + assert.equal(actual.length,expected.length); + actual.forEach((result,i)=>{ + assert.deepEqual(result.order,expected[i].order);assert.deepEqual(result.cooldownReductions,expected[i].reductions); + for(const key of ['theoreticalMaxScore','expectedScore','baselineScore','withoutSpecialStartBonusScore','notes','scoreBySecond'] as const)assert.deepEqual(result[key],expected[i][key],key); + assert.deepEqual(result.formationOrder,deck.map(c=>c.id)); + }); + assert.equal(JSON.stringify({deck,chart}),input); +} +const chart:ExactSongChart={songId:'group-boundary',difficulty:'expert',durationSeconds:8,scoreRatio:1,specialStartSeconds:[2,3,4,5,6],notes:[1.9999999999,2,2.0000000001,2.5,2.6,3,3.9999999999,4,4.5,5,7.9999999999].flatMap(timeSeconds=>[ + {timeSeconds,type:'regular',isFlick:false},{timeSeconds,type:'regular',isFlick:false},{timeSeconds,type:'regular',isFlick:true},{timeSeconds,type:'mid',isFlick:false},{timeSeconds,type:'regular',isFlick:false,isHoldEnd:true} +])}; + +test('grouped evaluator matches independent exhaustive calculator for both objectives and conditional support',()=>{ + for(const objective of ['maximum','expected'] as const)check(cards,chart,{perfectNoteScore:100.37,directSongScoreBonusPercent:9.7,finalScoreBonusPercent:7,objective}); +}); +test('original combo tiers and per-note rounding survive grouping across 100 and 1000',()=>{ + const crowded={...chart,notes:Array.from({length:1002},()=>({timeSeconds:2,type:'regular' as const,isFlick:false}))}; + const groups=groupExactOptimizationNotes(crowded);assert.equal(groups.length,11);assert.equal(groups[0].count,99);assert.equal(groups[10].count,3); + check(cards.map(c=>({...c,allowedCooldownReductions:[0]})),crowded,{perfectNoteScore:100.37,directSongScoreBonusPercent:10}); +}); +test('all 120 orders and 1024 assignments preserve the exhaustive winner and top five',()=>{ + const short={...chart,notes:chart.notes.slice(0,3)}; + check(cards.map(c=>({...c,allowedCooldownReductions:[0,4,8,12]})),short,{perfectNoteScore:117.21}); +}); +test('complete ties preserve assignment insertion order and the first special order',()=>{ + const tied=cards.map(c=>({...c,active:{...c.active,scoreUpPercent:0},special:{...c.special,scoreUpPercent:0,activationRateUpPercent:0},passiveScoreSupportEffects:[],allowedCooldownReductions:[4,0] as const})); + for(const objective of ['maximum','expected'] as const)check(tied,chart,{perfectNoteScore:101.2,objective}); +}); +test('integer precision fallback and reentrant progress callbacks preserve results',()=>{ + const locked=cards.map(c=>({...c,allowedCooldownReductions:[0] as const})); + check(locked,{...chart,notes:chart.notes.slice(0,8)},{perfectNoteScore:1e15,directSongScoreBonusPercent:10}); + const expected=rankExactCooldownCombinations(locked,chart,{perfectNoteScore:100},5);let nested=false; + const actual=rankExactCooldownCombinations(locked,chart,{perfectNoteScore:100,onProgress:()=>{if(!nested){nested=true;rankExactCooldownCombinations([...locked].reverse(),chart,{perfectNoteScore:137});}}},5); + assert.ok(nested);assert.deepEqual(actual,expected); +}); + +test('shared theoretical states retain expected-score ties with different activation probabilities',()=>{ + const shared=cards.map((card,index)=>({...card, + special:{durationSeconds:1,scoreUpPercent:31,activationRateUpPercent:index*11}, + allowedCooldownReductions:[0,4] as const + })); + for(const objective of ['maximum','expected'] as const)check(shared,chart,{perfectNoteScore:101.7,objective}); +}); + +test('maximum prefix sharing preserves mixed cooldown choices, rounding states and multiple requested ranks',()=>{ + const mixed=cards.map((card,index)=>({...card,allowedCooldownReductions:([0,4,8,12] as const).slice(0,index%4+1)})); + const repeated={...chart,notes:chart.notes.concat(chart.notes.map(note=>({...note,timeSeconds:note.timeSeconds+0.000001})))}; + check(mixed,repeated,{perfectNoteScore:101.1234567,directSongScoreBonusPercent:12.345,finalScoreBonusPercent:6.7}); +}); +test('maximum sharing safely falls back for negative scaling and out-of-range integer precision',()=>{ + const mixed=cards.map(card=>({...card,allowedCooldownReductions:[0,12] as const})); + check(mixed,chart,{perfectNoteScore:101.37,directSongScoreBonusPercent:-120}); + check(mixed,chart,{perfectNoteScore:-101.37,directSongScoreBonusPercent:8}); +}); diff --git a/src/lib/engine/skill-condition.test.ts b/src/lib/engine/skill-condition.test.ts index 74ee495..919f910 100644 --- a/src/lib/engine/skill-condition.test.ts +++ b/src/lib/engine/skill-condition.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts'; +import { plainSkillText, skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts'; const members = [ { type: '큐트', generation: 'hololive 0기생' }, @@ -10,6 +10,36 @@ const members = [ { type: '해피', generation: 'holoX' } ]; +test('memoized membership follows changed type and generation and counts repeated references', () => { + const member = { type: '퓨어', generation: 'hololive 1기생 / GAMERS' }; + assert.equal(skillConditionSatisfied('GAMERS(이)가 2명 이상이면', [member, member]), true); + assert.equal(skillTargetBelongsTo(member, '1기생'), true); + member.type = '큐트'; member.generation = 'EN Myth'; + assert.equal(skillTargetBelongsTo(member, 'GAMERS'), false); + assert.equal(skillTargetBelongsTo(member, 'Myth'), true); + assert.equal(skillConditionSatisfied('큐트 타입 1명 이상이면', [member]), true); + assert.equal(skillConditionSatisfied('퓨어 타입 1명 이상이면', [member]), false); +}); + +test('bounded text and condition cache eviction retains normalization and unknown-clause assumptions', () => { + const value = '[tag] 큐트 타입 [/tag] 2명 이상이면'; + assert.equal(plainSkillText(value), '큐트 타입 2명 이상이면'); + for (let index = 0; index < 600; index++) { + assert.equal(skillConditionSatisfied(`unknown ${index}`, []), true); + assert.equal(plainSkillText(`[tag] ${index} [/tag]`), String(index)); + } + assert.equal(plainSkillText(value), '큐트 타입 2명 이상이면'); + assert.equal(skillConditionSatisfied(value, [{type:'큐트',generation:'0기생'}, {type:'큐트',generation:'0기생'}]), true); +}); + +test('sparse member arrays retain the original filter semantics without accepting explicit undefined', () => { + const member = {type:'큐트',generation:'0기생'}; + const sparse = new Array(3);sparse[0]=member;sparse[2]=member; + assert.equal(skillConditionSatisfied('큐트 타입 2명 이상이면', sparse), true); + assert.equal(skillConditionSatisfied('큐트 타입 3명 이상이면', sparse), false); + assert.throws(()=>skillConditionSatisfied('큐트 타입 1명 이상이면', [member,undefined as unknown as typeof member])); +}); + test('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => { assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true); assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false); diff --git a/src/lib/engine/skill-condition.ts b/src/lib/engine/skill-condition.ts index 966c07b..df922b5 100644 --- a/src/lib/engine/skill-condition.ts +++ b/src/lib/engine/skill-condition.ts @@ -3,11 +3,24 @@ export type SkillConditionMember = { generation: string; }; +const textCache = new Map(); +const groupCache = new Map(); +const conditionCache = new Map(); +const memberCache = new WeakMap }>(); +function remember(cache: Map, key: string, value: T): T { + if (cache.size >= 512) cache.delete(cache.keys().next().value!); + cache.set(key, value); + return value; +} + export function plainSkillText(value: string): string { - return value.replace(/\[[^\]]+\]/g, '').replace(/\s+/g, ' ').trim(); + const cached = textCache.get(value); + return cached ?? remember(textCache, value, value.replace(/\[[^\]]+\]/g, '').replace(/\s+/g, ' ').trim()); } function normalizedGroup(value: string): string { + const cached = groupCache.get(value); + if (cached !== undefined) return cached; const normalized = value .replace(/^hololive\s+/i, '') .replace(/^(?:EN|DEV_IS)\s+/i, '') @@ -15,34 +28,45 @@ function normalizedGroup(value: string): string { .replace(/\s*타입$/, '') .trim() .toLowerCase(); - return normalized === '게이머즈' ? 'gamers' : normalized; + return remember(groupCache, value, normalized === '게이머즈' ? 'gamers' : normalized); +} + +function normalizedMemberGroups(member: SkillConditionMember): ReadonlySet { + const type = member.type, generation = member.generation; + let cached = memberCache.get(member); + if (!cached || cached.type !== type || cached.generation !== generation) { + cached = { type, generation, groups: new Set([ + normalizedGroup(type), ...generation.split('/').map(normalizedGroup).filter(Boolean) + ]) }; + memberCache.set(member, cached); + } + return cached.groups; } function memberBelongsTo(member: SkillConditionMember, group: string): boolean { - const normalized = normalizedGroup(group); - const type = normalizedGroup(member.type); - const generations = member.generation - .split('/') - .map((value) => normalizedGroup(value)) - .filter(Boolean); - return type === normalized - || generations.includes(normalized); + return normalizedMemberGroups(member).has(normalizedGroup(group)); } export function skillConditionSatisfied( condition: string, members: readonly SkillConditionMember[] ): boolean { - const description = plainSkillText(condition); - if (!description) return true; - - // 콤보와 라이프는 분석에서 최적 플레이를 전제로 항상 충족한다. - if (/\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)) return true; - - const deckCondition = description.match(/^(.+?)\s*(\d+)명 이상이면$/); - if (!deckCondition) return true; - const requiredCount = Number(deckCondition[2]); - return members.filter((member) => memberBelongsTo(member, deckCondition[1])).length >= requiredCount; + let compiled = conditionCache.get(condition); + if (compiled === undefined) { + const description = plainSkillText(condition); + // Combo/life and unrecognized clauses retain the established assumptions. + const match = !description || /\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description) + ? null : description.match(/^(.+?)\s*(\d+)명 이상이면$/); + compiled = remember(conditionCache, condition, match ? { group: normalizedGroup(match[1]), requiredCount: Number(match[2]) } : null); + } + if (!compiled) return true; + let count = 0; + // Array.filter skipped holes and captured the initial length in the original evaluator. + const length = members.length; + for (let index = 0; index < length; index++) { + if (index in members && normalizedMemberGroups(members[index]).has(compiled.group)) count++; + } + return count >= compiled.requiredCount; } export function skillTargetBelongsTo(member: SkillConditionMember, group: string): boolean { diff --git a/src/lib/workers/combination-preparation.worker.ts b/src/lib/workers/combination-preparation.worker.ts new file mode 100644 index 0000000..eadd849 --- /dev/null +++ b/src/lib/workers/combination-preparation.worker.ts @@ -0,0 +1,29 @@ +/// +import {createCombinationPreparation} from '../engine/combination-preparation.ts'; +import type {CombinationPreparationContext, RefinementRanking} from '../engine/combination-preparation.ts'; + +export type PreparationRequest={type:'prepare-search';requestId:number;context:CombinationPreparationContext} + | {type:'prepare-refinement';requestId:number;rankings:RefinementRanking[]}; +let preparation:ReturnType|null=null; +let currentRequestId:number|null=null; +self.onmessage=(event:MessageEvent)=>{ + const request=event.data; + try { + if(request.type==='prepare-search'){ + currentRequestId=request.requestId;preparation=createCombinationPreparation(request.context); + self.postMessage({type:'preparation-progress',requestId:request.requestId,pass:'primary',phase:'generation',completed:0,total:1}); + const combinations=preparation.generate();const prepared=[]; + for(let index=0;index) => { + const reply = (payload: Record) => self.postMessage({ ...payload, operationId: event.data.operationId }); try { if (event.data.type === 'optimize-exact') { const options: ExactOptimizeOptions = { perfectNoteScore: event.data.perfectNoteScore, directSongScoreBonusPercent: event.data.directSongScoreBonusPercent, finalScoreBonusPercent: event.data.finalScoreBonusPercent, - onProgress: (progress) => self.postMessage({ type: 'progress', progress }) + onProgress: (progress) => reply({ type: 'progress', progress }) }; const request = event.data; const results = rankExactCooldownCombinations(request.cards, request.chart, options, request.cooldownResultLimit === 5 ? 5 : 1); - if (request.includeAchievementProbabilities || request.includeMaximumScoreProbability) self.postMessage({ type: 'probability-start' }); + if (request.includeAchievementProbabilities || request.includeMaximumScoreProbability) reply({ type: 'probability-start' }); const cooldownRankings = results.map((result) => { const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities( cardsForResult(request.cards, result), @@ -99,7 +106,7 @@ self.onmessage = (event: MessageEvent(); for (const candidate of event.data.candidates) { const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, { perfectNoteScore: candidate.perfectNoteScore, directSongScoreBonusPercent: candidate.directSongScoreBonusPercent, finalScoreBonusPercent: candidate.finalScoreBonusPercent, objective: candidate.optimizationObjective, - onProgress: (progress) => self.postMessage({ + onProgress: (progress) => reply({ type: 'progress', progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates } }) @@ -155,14 +163,20 @@ self.onmessage = (event: MessageEvent ({ + ...ranking, result: { theoreticalMaxScore: result.theoreticalMaxScore, expectedScore: result.expectedScore, order: result.order } + })) : selected; + reply({ type: 'combination-result', ...(event.data.summaryOnly ? {} : best), rankings: responseRankings, + ...(event.data.withInputIndices ? { indices: selected.map(ranking => inputIndices.get(ranking)!) } : {}) }); } catch (error) { - self.postMessage({ + reply({ type: 'error', message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.' }); diff --git a/src/routes/combination/+page.svelte b/src/routes/combination/+page.svelte index 6a75220..c521b30 100644 --- a/src/routes/combination/+page.svelte +++ b/src/routes/combination/+page.svelte @@ -21,6 +21,7 @@ additionalAwakeningsRequired, calculateBoardEffects, calculateUnitPower, + createBoardEffectContext, combinationSearchLimits, compareEventRankings, countOptimizationCandidates, @@ -41,6 +42,11 @@ import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; + import { createCombinationPreparationWorker } from '$lib/workers/create-combination-preparation-worker.ts'; + import { CombinationEvaluationPool } from '$lib/engine/combination-evaluation-pool.ts'; + import { CombinationWorkerSession } from '$lib/engine/combination-worker-session.ts'; + import type { CombinationPreparationContext } from '$lib/engine/combination-preparation.ts'; + import type { PreparationRequest } from '$lib/workers/combination-preparation.worker.ts'; export let eventMode = false; export let eventScoreMode = false; @@ -152,10 +158,16 @@ let resultLimit = 10; let activeResultLimit = 10; let maximizeCompute = false; + let evaluationPool: CombinationEvaluationPool | null = null; let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let worker: Worker | null = null; - let parallelWorkers: Worker[] = []; + let preparationWorker: Worker | null = null; + let preparationContext: CombinationPreparationContext | null = null; + let rejectPreparation: ((error: Error) => void) | null = null; + const searchWorkers = new CombinationWorkerSession(); + let activeSearchId = 0; + let searchNotice = ''; let baselineWorker: Worker | null = null; let running = false; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; @@ -368,28 +380,6 @@ } catch { localStorage.removeItem(STORAGE_KEY); } - worker = createScoreOptimizerWorker(); - worker.onmessage = (event) => { - if (event.data.type === 'progress') progress = event.data.progress; - if (event.data.type === 'combination-result') { - const rankings = Array.isArray(event.data.rankings) - ? event.data.rankings as RankedResult[] - : [{ result: event.data.result, overallPower: event.data.overallPower }]; - completeExactPass(rankings); - } - if (event.data.type === 'achievement-progress') progress = event.data.progress; - if (event.data.type === 'achievement-result') { - rankedResults = event.data.rankings as RankedResult[]; - result = rankedResults[0]?.result ?? null; - running = false; - searchPhase = 'idle'; - } - if (event.data.type === 'error') { - errorMessage = event.data.message; - running = false; - searchPhase = 'idle'; - } - }; resetBaseline(); try { baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY)); @@ -409,8 +399,7 @@ mounted = false; window.removeEventListener('storage', syncStorage); window.clearInterval(releaseClock); - worker?.terminate(); - terminateParallelWorkers(); + clearSearchWorkers(); baselineWorker?.terminate(); }; }); @@ -621,58 +610,6 @@ result = null; } - function roughCardScore(card: CardRow): number { - const statTotal = Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0); - const { activeValue, specialValue } = roughSkillValues(card); - const score = statTotal * (1 + activeValue / 100 + specialValue / 500); - if (!currentEvent || !eventPtMode) return score; - const bonus = eventBonusBreakdown(currentEvent, [card], (candidate) => awakeningForCard(candidate as CardRow)); - return score * (1 + bonus.totalPercent / 100); - } - - function roughStatScore(card: CardRow): number { - return Object.values(card.power.statsAtMaxLevel).reduce((sum, value) => sum + value, 0); - } - - function roughSkillScore(card: CardRow): number { - const { adapted, activeValue, specialValue } = roughSkillValues(card); - return activeValue + specialValue + (adapted.scoreSupportPercent ?? 0); - } - - function roughSkillValues(card: CardRow) { - const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]); - const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / - Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted); - const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100; - return { adapted, activeValue, specialValue }; - } - - function roughCombinationScore(cards: readonly CardRow[]): number { - const statTotal = cards.reduce((sum, card) => sum + roughStatScore(card), 0); - const skillPotential = cards.reduce((sum, card) => { - const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards); - const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / - Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted); - const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100; - const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100; - const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => - effectSum + effect.percent * (effect.target === 'all' ? cards.length : effect.targetCount ?? 1), 0); - return sum + activeValue + specialValue + activationValue + passiveValue; - }, 0); - const score = statTotal * (1 + skillPotential / 500); - if (!currentEvent) return score; - const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); - const songBonus = eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)); - return score * (eventPtMode ? 1 + bonus.totalPercent / 100 : 1) * (1 + songBonus / 100); - } - - function roughEventBonusPriority(cards: readonly CardRow[]): number { - if (!currentEvent || !eventPtMode) return roughCombinationScore(cards); - const bonus = eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)); - // 최대 보너스 편성이 순수 스코어 후보에 밀려 선별 단계에서 사라지지 않도록 별도 층을 보존한다. - return bonus.totalPercent * 1_000_000_000_000 + roughCombinationScore(cards); - } - function rankingValue(ranking: RankedResult): number { return eventPtMode ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0) @@ -690,230 +627,6 @@ return cards.map((card) => card.id).sort().join('|'); } - function boardsForCombination(cards: readonly CardRow[]): MemberBoards { - const boards = Object.fromEntries(Object.entries(memberBoards).map(([characterId, groupIds]) => [characterId, Array.from(groupIds)])); - for (const card of cards) { - const slot = fixedSlot(card.id); - if (slot >= 0 && !fixedAllBlueNodes[slot]) continue; - const existingNonBlue = (boards[card.characterId] ?? []).filter((groupId) => !groupId.startsWith('B')); - boards[card.characterId] = [...existingNonBlue, ...(blueGroupsByCharacter[card.characterId] ?? [])]; - } - return boards; - } - - function acceptsCombination(cards: readonly CardRow[]): boolean { - return withinMediumProbabilityCardLimit(cards, maxMediumProbabilityCards); - } - - function screeningCombinations(): CardRow[][] { - const limits = combinationSearchLimits(fixedCards.length); - const screeningLimit = eventMode ? Math.min(limits.screeningLimit, 9_000) : limits.screeningLimit; - if (fixedCards.length === 4) { - return generateCombinationCandidates(fixedCards, candidateCards, 5, roughCardScore, screeningLimit, undefined, acceptsCombination); - } - const globalBudget = eventMode ? screeningLimit : Math.ceil(screeningLimit * 0.8); - const anchoredBudget = screeningLimit - globalBudget; - const strategies = eventMode - ? [ - { scoreCard: roughCardScore }, - { scoreCard: roughStatScore, scoreCombination: roughCombinationScore }, - { scoreCard: roughCardScore, scoreCombination: roughEventBonusPriority } - ] - : [ - { scoreCard: roughCardScore }, - { scoreCard: roughSkillScore }, - { scoreCard: roughStatScore }, - { scoreCard: roughStatScore, scoreCombination: roughCombinationScore } - ]; - const perStrategy = Math.ceil(globalBudget / strategies.length); - const combined = new Map(); - for (const strategy of strategies) { - for (const cards of generateCombinationCandidates( - fixedCards, - candidateCards, - 5, - strategy.scoreCard, - perStrategy, - strategy.scoreCombination, - acceptsCombination - )) { - combined.set(combinationKey(cards), cards); - } - } - if (eventMode) return Array.from(combined.values()).slice(0, screeningLimit); - const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); - const anchorCount = candidateCards.filter((card) => !fixedCharacters.has(card.characterId)).length; - const perAnchorLimit = Math.max(1, Math.ceil(anchoredBudget / Math.max(1, anchorCount))); - for (const cards of generateAnchoredCombinationCandidates( - fixedCards, - candidateCards, - 5, - strategies, - perAnchorLimit, - acceptsCombination - )) { - combined.set(combinationKey(cards), cards); - } - return Array.from(combined.values()).slice(0, screeningLimit); - } - - function selectExactFinalists(prepared: PreparedCandidate[]): PreparedCandidate[] { - if (fixedCards.length === 4) return prepared; - const { baseExactFinalistLimit } = combinationSearchLimits(fixedCards.length); - const finalistLimit = eventMode ? Math.min(baseExactFinalistLimit, 96) : baseExactFinalistLimit; - const selected = new Map(); - const add = (candidates: PreparedCandidate[], count: number) => { - for (const candidate of candidates.slice(0, count)) selected.set(combinationKey(candidate.cards), candidate); - }; - const diverse = selectMemberDiverseCandidates( - prepared, - (candidate) => candidate.cards, - (candidate) => candidate.heuristic, - fixedCardIds, - Math.ceil(finalistLimit / 2) - ); - add(diverse, diverse.length); - const remainingLimit = Math.max(0, finalistLimit - selected.size); - const heuristicLimit = Math.ceil(remainingLimit / 2); - const skillLimit = Math.ceil(remainingLimit / 4); - const powerLimit = remainingLimit - heuristicLimit - skillLimit; - add([...prepared].sort((left, right) => right.heuristic - left.heuristic), heuristicLimit); - add([...prepared].sort((left, right) => right.skillPotential - left.skillPotential), skillLimit); - add([...prepared].sort((left, right) => right.overallPower - left.overallPower), powerLimit); - if (selected.size < finalistLimit) { - for (const candidate of [...prepared].sort((left, right) => right.heuristic - left.heuristic)) { - selected.set(combinationKey(candidate.cards), candidate); - if (selected.size >= finalistLimit) break; - } - } - return Array.from(selected.values()).slice(0, finalistLimit); - } - - function buildRefinementFinalists(rankings: RankedResult[]): PreparedCandidate[] { - if (fixedCards.length === 4 || rankings.length === 0) return []; - const { alternativeExactFinalistLimit } = combinationSearchLimits(fixedCards.length); - const alternatives = new Map(); - const topResultNeighbors = new Map(); - const secondaryPriority = new Map(); - const fixedIds = new Set(fixedCards.map((card) => card.id)); - - for (const [rankingIndex, ranking] of rankings.slice(0, 10).entries()) { - const sourceCards = ranking.result.order.map((cardId) => cardById.get(cardId)).filter((card): card is CardRow => Boolean(card)); - if (sourceCards.length !== 5) continue; - const sourceKey = combinationKey(sourceCards); - const bestByReplacementId = new Map(); - sourceCards.forEach((source, index) => { - if (fixedIds.has(source.id)) return; - const otherCharacters = new Set(sourceCards.filter((_, cardIndex) => cardIndex !== index).map((card) => card.characterId)); - for (const replacement of candidateCards) { - if (replacement.id === source.id || otherCharacters.has(replacement.characterId)) continue; - const cards = sourceCards.map((card, cardIndex) => cardIndex === index ? replacement : card); - if (!acceptsCombination(cards)) continue; - const key = combinationKey(cards); - if (key === sourceKey) continue; - const candidate = alternatives.get(key) ?? { cards, roughScore: roughCombinationScore(cards) }; - alternatives.set(key, candidate); - if (rankingIndex === 0) topResultNeighbors.set(key, candidate); - const previous = bestByReplacementId.get(replacement.id); - if (!previous || candidate.roughScore > previous.roughScore) bestByReplacementId.set(replacement.id, candidate); - } - }); - if (rankingIndex > 0 && rankingIndex < 3) { - for (const candidate of bestByReplacementId.values()) secondaryPriority.set(combinationKey(candidate.cards), candidate); - } - } - - const refinementLimit = eventMode - ? Math.min(alternativeExactFinalistLimit, 48) - : Math.max(alternativeExactFinalistLimit, topResultNeighbors.size); - const rankedAlternatives = Array.from(alternatives.values()).sort((left, right) => - right.roughScore - left.roughScore || combinationKey(left.cards).localeCompare(combinationKey(right.cards))); - const chosenAlternatives = new Map(); - const rankedTopNeighbors = [...topResultNeighbors.values()].sort((left, right) => right.roughScore - left.roughScore); - for (const candidate of rankedTopNeighbors) { - chosenAlternatives.set(combinationKey(candidate.cards), candidate); - if (chosenAlternatives.size >= refinementLimit) break; - } - for (const candidate of secondaryPriority.values()) { - chosenAlternatives.set(combinationKey(candidate.cards), candidate); - if (chosenAlternatives.size >= refinementLimit) break; - } - for (const candidate of rankedAlternatives) { - chosenAlternatives.set(combinationKey(candidate.cards), candidate); - if (chosenAlternatives.size >= refinementLimit) break; - } - return Array.from(chosenAlternatives.values()).map((candidate) => prepareCandidate(candidate.cards)); - } - - function prepareCandidate(cards: CardRow[]): PreparedCandidate { - const generationBonusByCardId = Object.fromEntries(cards - .filter((card) => { - const slot = fixedSlot(card.id); - return slot < 0 ? candidateGenerationBonus !== null : fixedGenerationBonusOverrides[slot] !== null; - }) - .map((card) => { - const slot = fixedSlot(card.id); - return [card.id, slot < 0 ? candidateGenerationBonus ?? 0 : fixedGenerationBonusOverrides[slot] ?? 0]; - })); - const boardEffects = calculateBoardEffects(boardSummary, boardsForCombination(cards), cards, leaderCharacterId, selectedChart, { - placements: boardCards, - cards: connectionCards - }, { - generationBonusByCardId, - ...(songScoreBonusOverride === null ? {} : { directSongScoreBonusPercent: songScoreBonusOverride }) - }); - const power = calculateUnitPower( - cards.map((card) => ({ card, awakening: awakeningForCard(card) })), - leaderOutfit, - boardEffects, - memoryParameterPermil(memoryCount, memorySummary.effects), - memberPowerUpPercent - ); - const optimizerCards = cards.map((card) => { - const slot = fixedSlot(card.id); - const fixedReduction = slot >= 0 ? fixedCooldownReductions[slot] : null; - const adapted = toOptimizerCard( - card, - awakeningForCard(card), - fixedReduction === null ? boardEffects.allowedCooldownReductionsByCardId[card.id] : [fixedReduction], - cards - ); - const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0; - return { - ...adapted, - active: { ...adapted.active, activationRateUpPercent: activationRate }, - scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent - }; - }); - const skillPotential = optimizerCards.reduce((sum, card) => sum + - card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) + - card.special.scoreUpPercent * card.special.durationSeconds / 100 + - (card.scoreSupportPercent ?? 0) + - (card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0); - const eventBonus = currentEvent - ? eventBonusBreakdown(currentEvent, cards, (card) => awakeningForCard(card as CardRow)) - : null; - const eventSongScoreBonus = currentEvent - ? eventChallengeSongScoreBonusPercent(currentEvent, selectedSongId, cards.map((card) => card.id)) - : 0; - const directSongScoreBonusPercent = boardEffects.directSongScoreBonusPercent; - const rankingMultiplier = eventPtMode && eventBonus ? 1 + eventBonus.totalPercent / 100 : undefined; - return { - cards: optimizerCards, - overallPower: power.overallPower, - perfectNoteScore: perfectNoteScoreFromPower(power.overallPower, selectedChart?.scoreRatio ?? null), - directSongScoreBonusPercent, - finalScoreBonusPercent: eventSongScoreBonus, - heuristic: power.overallPower * (1 + skillPotential / 500) * (rankingMultiplier ?? 1) * (1 + eventSongScoreBonus / 100), - skillPotential, - optimizationObjective: eventPtMode ? 'expected' : 'maximum', - ...(eventBonus ? { - eventBonusPercent: eventBonus.totalPercent, - rankingMultiplier - } : {}) - }; - } - async function analyzeBaseline(): Promise { const targetWorker = baselineWorker; const state = baselineState; @@ -1010,12 +723,7 @@ return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR'); } - function terminateParallelWorkers(): void { - for (const parallelWorker of parallelWorkers) parallelWorker.terminate(); - parallelWorkers = []; - } - - function finalizeRankings(rankings: RankedResult[]): void { + function selectRankings(rankings: RankedResult[]): RankedResult[] { const bestByCombination = new Map(); for (const ranking of rankings) { const key = [...ranking.result.order].sort().join('|'); @@ -1024,90 +732,70 @@ bestByCombination.set(key, ranking); } } - rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit); - result = rankedResults[0]?.result ?? null; - if (rankedResults.length > 0 && activeSearchChart && worker && rankedResults.every((ranking) => ranking.cards)) { + return Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit); + } + + function finalizeRankings(rankings: RankedResult[]): void { + const finalRankings = selectRankings(rankings); + if (finalRankings.length > 0 && activeSearchChart && worker && finalRankings.every((ranking) => ranking.cards)) { searchPhase = 'probability'; - progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length }; + progress = { evaluatedCandidates: 0, totalCandidates: finalRankings.length }; worker.postMessage({ type: 'estimate-achievement', - rankings: rankedResults, + rankings: finalRankings, chart: activeSearchChart, sampleCount: 25_000 }); return; } - running = false; - searchPhase = 'idle'; + rankedResults = finalRankings; + result = rankedResults[0]?.result ?? null; + finishSearch(); } - function completeExactPass(rankings: RankedResult[]): void { - if (exactPass === 'primary' && activeSearchChart) { - primaryRankings = rankings; - const refinements = buildRefinementFinalists(rankings); - if (refinements.length > 0) { + async function completeExactPass(rankings: RankedResult[]): Promise { + const requestId = activeSearchId; + if (!searchWorkers.isCurrent(requestId)) return; + try { + if (exactPass === 'primary' && activeSearchChart) { + primaryRankings = rankings; exactPass = 'refinement'; - exactCombinationCount += refinements.length; - if (maximizeCompute) runParallelExact(refinements, activeSearchChart); - else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit }); - return; + progress = { evaluatedCandidates: 0, totalCandidates: 1 }; + const { finalists: refinements } = await requestPreparation({ + type: 'prepare-refinement', requestId, + rankings: rankings.map((ranking) => ({ result: { order: ranking.result.order } })) + }); + if (!searchWorkers.isCurrent(requestId)) return; + if (refinements.length > 0) { + void runParallelExact(refinements, activeSearchChart); + return; + } } + finalizeRankings([...primaryRankings, ...rankings]); + } catch (error) { + failSearch(error instanceof Error ? error.message : '추가 후보 준비에 실패했습니다.', requestId); } - finalizeRankings([...primaryRankings, ...rankings]); } - function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void { - terminateParallelWorkers(); - const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4; - const workerCount = Math.min(finalists.length, Math.max(1, reportedCores)); - const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]); - finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate)); - - const groupProgress = Array.from({ length: workerCount }, () => 0); - const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0); - const completedRankings: RankedResult[] = []; - let completedWorkers = 0; - let failed = false; - progress = { evaluatedCandidates: 0, totalCandidates }; - - const fail = (message: string) => { - if (failed) return; - failed = true; - terminateParallelWorkers(); - errorMessage = message; - running = false; - searchPhase = 'idle'; - }; - - groups.forEach((candidates, workerIndex) => { - const parallelWorker = createScoreOptimizerWorker(); - parallelWorkers.push(parallelWorker); - parallelWorker.onmessage = (event) => { - if (failed) return; - if (event.data.type === 'progress') { - groupProgress[workerIndex] = event.data.progress.evaluatedCandidates; - progress = { - evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0), - totalCandidates - }; - } - if (event.data.type === 'combination-result') { - const rankings = Array.isArray(event.data.rankings) - ? event.data.rankings as RankedResult[] - : [{ result: event.data.result, overallPower: event.data.overallPower }]; - completedRankings.push(...rankings); - completedWorkers += 1; - parallelWorker.terminate(); - if (completedWorkers !== workerCount) return; - completedRankings.sort(compareRankedResults); - parallelWorkers = []; - completeExactPass(completedRankings.slice(0, activeResultLimit)); - } - if (event.data.type === 'error') fail(event.data.message); - }; - parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.'); - parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart, resultLimit: activeResultLimit }); - }); + async function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): Promise { + const requestId = activeSearchId; + try { + if (!evaluationPool) { + const reported = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4; + evaluationPool = new CombinationEvaluationPool(createScoreOptimizerWorker, searchWorkers, requestId, + maximizeCompute ? Math.min(32, finalists.length, reported) : 1); + } + const rankings = await evaluationPool.run(finalists, chart, { + resultLimit: activeResultLimit, + onPhase: phase => { if (searchWorkers.isCurrent(requestId)) searchPhase = phase; }, + onProgress: value => { if (searchWorkers.isCurrent(requestId)) progress = value; } + }); + if (!searchWorkers.isCurrent(requestId)) return; + exactCombinationCount += evaluationPool.lastExactCount; + await completeExactPass(rankings); + } catch (error) { + failSearch(error instanceof Error ? error.message : '후보 평가에 실패했습니다.', requestId); + } } async function search(): Promise { @@ -1125,7 +813,7 @@ return; } result = null; - if (!worker || !selectedChart) return; + if (!selectedChart) return; if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) { errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.'; return; @@ -1134,11 +822,15 @@ errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.'; return; } - if (!acceptsCombination(fixedCards)) { + if (!withinMediumProbabilityCardLimit(fixedCards, maxMediumProbabilityCards)) { errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`; return; } + rankedResults = []; running = true; + const requestId = searchWorkers.start(); + activeSearchId = requestId; + searchNotice = ''; activeResultLimit = resultLimit; searchSnapshot = structuredClone({ conditions: presetSnapshot, growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent }, @@ -1149,30 +841,31 @@ primaryRankings = []; screeningProgress = 0; progress = { evaluatedCandidates: 0, totalCandidates: 1 }; - await new Promise((resolve) => setTimeout(resolve, 0)); + const metadata = selectedChart; try { - const combinations = screeningCombinations(); - screenedCombinationCount = combinations.length; - const prepared: PreparedCandidate[] = []; - for (let index = 0; index < combinations.length; index += 1) { - prepared.push(prepareCandidate(combinations[index])); - if (index % 50 === 49 || index === combinations.length - 1) { - screeningProgress = (index + 1) / combinations.length; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - const finalists = selectExactFinalists(prepared); - exactCombinationCount = finalists.length; + preparationContext = structuredClone({ + allCards, boardSummary, memoryEffects: memorySummary.effects, chartMetadata: metadata, + growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent }, + settings: { fixedCardIds, fixedAwakenings, fixedCooldownReductions, fixedAllBlueNodes, + fixedGenerationBonusOverrides, candidateAwakening, candidateGenerationBonus, songScoreBonusOverride, + ownedCardsOnly, regularCardsOnly, maxMediumProbabilityCards, leaderCharacterId, leaderOutfitCardId }, + eventMode, eventPtMode, currentEvent + }); + resetScoreWorker(requestId); + preparationWorker = createCombinationPreparationWorker(); + searchWorkers.track(preparationWorker, requestId); + const { finalists, screenedCount } = await requestPreparation({ type: 'prepare-search', requestId, context: preparationContext }); + if (!searchWorkers.isCurrent(requestId)) return; + screenedCombinationCount = screenedCount ?? 0; + exactCombinationCount = 0; if (finalists.length === 0) throw new Error('조건에 맞는 카드 조합이 없습니다.'); - const chart = await loadExactSongChart(selectedChart.path); + const chart = await loadExactSongChart(metadata.path); + if (!searchWorkers.isCurrent(requestId)) return; activeSearchChart = chart; searchPhase = 'exact'; - if (maximizeCompute) runParallelExact(finalists, chart); - else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit }); + void runParallelExact(finalists, chart); } catch (error) { - errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.'; - running = false; - searchPhase = 'idle'; + failSearch(error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.', requestId); } } @@ -1236,6 +929,84 @@ .sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline)) .slice(0, limit); } + + function clearSearchWorkers(): void { + searchWorkers.cancel(); + evaluationPool?.close(); + evaluationPool = null; + worker = null; + preparationWorker = null; + preparationContext = null; + const reject = rejectPreparation; + rejectPreparation = null; + reject?.(new Error('취소된 탐색입니다.')); + } + + function finishSearch(): void { + running = false; + searchPhase = 'idle'; + clearSearchWorkers(); + } + + function failSearch(message: string, requestId = activeSearchId): void { + if (!searchWorkers.isCurrent(requestId)) return; + errorMessage = message; + finishSearch(); + } + + function cancelSearch(): void { + if (!running) return; + searchNotice = '탐색을 중지했습니다. 다시 탐색할 수 있습니다.'; + finishSearch(); + } + + function resetScoreWorker(requestId: number): void { + const target = createScoreOptimizerWorker(); + worker = target; + searchWorkers.track(target, requestId); + target.onmessage = (event) => { + if (!searchWorkers.owns(target, requestId)) return; + if (event.data.type === 'progress' || event.data.type === 'achievement-progress') progress = event.data.progress; + if (event.data.type === 'achievement-result') { + rankedResults = event.data.rankings as RankedResult[]; + result = rankedResults[0]?.result ?? null; + finishSearch(); + } + if (event.data.type === 'error') failSearch(event.data.message, requestId); + }; + target.onerror = () => failSearch('정밀 평가 Worker에서 오류가 발생했습니다.', requestId); + } + + function requestPreparation(request: PreparationRequest): Promise<{ finalists: PreparedCandidate[]; screenedCount?: number }> { + const target = preparationWorker; + const pass = request.type === 'prepare-search' ? 'primary' : 'refinement'; + if (!target) return Promise.reject(new Error('후보 준비 Worker가 없습니다.')); + return new Promise((resolve, reject) => { + rejectPreparation = reject; + target.onmessage = (event) => { + if (!searchWorkers.owns(target, request.requestId) || event.data.requestId !== request.requestId || event.data.pass !== pass) return; + if (event.data.type === 'preparation-progress') { + screeningProgress = event.data.completed / Math.max(1, event.data.total); + } + if (event.data.type === 'preparation-result') { + if (rejectPreparation === reject) rejectPreparation = null; + resolve(event.data); + } + if (event.data.type === 'error') { + if (rejectPreparation === reject) rejectPreparation = null; + reject(new Error(event.data.message)); + failSearch(event.data.message, request.requestId); + } + }; + target.onerror = () => { + const message = '후보 준비 Worker에서 오류가 발생했습니다.'; + if (!searchWorkers.owns(target, request.requestId)) return; + reject(new Error(message)); + failSearch(message, request.requestId); + }; + target.postMessage(request); + }); + } {eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 갤산기 @@ -1401,7 +1172,7 @@

퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.

{/if} -