카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다. 준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다. 업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
1 parent
f009a7fd6b
commit
da40ec4238
18 files changed
+1615
-459
No files matched your search
+5
-1
@@ -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를 대조해 반영했습니다.
|
||||
|
||||
@@ -26,6 +26,32 @@ export type BoardConnections = {
|
||||
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
||||
cards: readonly BoardConnectionCard[];
|
||||
};
|
||||
|
||||
type BoardModelIndex = {
|
||||
positionById: ReadonlyMap<string, BoardPosition>;
|
||||
groupIdByCoordinate: ReadonlyMap<string, string>;
|
||||
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<string, BoardModelIndex>;
|
||||
readonly cards: ReadonlyMap<string, BoardConnectionCard>;
|
||||
};
|
||||
|
||||
export function createBoardEffectContext(summary: BoardSummarySource, connections?: BoardConnections | null): BoardEffectContext {
|
||||
const models = new Map<string, BoardModelIndex>();
|
||||
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<Record<string, number>>;
|
||||
directSongScoreBonusPercent?: number;
|
||||
@@ -95,19 +121,21 @@ function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: Boa
|
||||
function connectionMultipliers(
|
||||
summary: BoardSummarySource,
|
||||
memberBoards: Readonly<Record<string, readonly string[]>>,
|
||||
connections?: BoardConnections | null
|
||||
connections?: BoardConnections | null,
|
||||
context?: BoardEffectContext
|
||||
): Record<string, Record<string, number>> {
|
||||
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<string>();
|
||||
const result: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const member of summary.members) {
|
||||
const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? [];
|
||||
const positionById = new Map<string, BoardPosition>(positions.map((position) => [position.groupId, position]));
|
||||
const groupIdByCoordinate = new Map<string, string>(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<string, number> = {};
|
||||
|
||||
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
|
||||
|
||||
@@ -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<Worker>();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<Worker>();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<Worker>();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<Worker>();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));
|
||||
});
|
||||
@@ -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<Worker, Pending>();
|
||||
private sequence = 0;
|
||||
private closed = false;
|
||||
lastExactCount = 0;
|
||||
private session: CombinationWorkerSession<Worker>;
|
||||
private requestId: number;
|
||||
constructor(factory: () => Worker, session: CombinationWorkerSession<Worker>, 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<any> {
|
||||
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<IndexedRanking[]> {
|
||||
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<EvaluationRanking[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<target;size++) {
|
||||
const expanded=new Map<string,typeof beam[number]>();
|
||||
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<referenceCalls);
|
||||
});
|
||||
|
||||
test('board indexes preserve shared models, duplicate placements, inactive sockets and per-node rounding',()=>{
|
||||
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));
|
||||
}
|
||||
});
|
||||
@@ -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<string, { owned?: boolean; awakening?: number }>;
|
||||
export type MemberBoards = Record<string, string[]>;
|
||||
export type BoardCards = Record<string, Record<string, string>>;
|
||||
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<ExactOptimizationResult,'order'>};
|
||||
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<typeof createBoardEffectContext>|undefined;
|
||||
let roughStatCache=new Map<string,number>();let roughCardCache=new Map<string,number>();let roughSkillCache=new Map<string,ReturnType<typeof computeRoughSkillValues>>();
|
||||
function resetSearchCaches(): void {
|
||||
roughStatCache = new Map();
|
||||
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<string, CardRow[]>();
|
||||
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<string, PreparedCandidate>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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};
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export type CombinationSearchCard = {
|
||||
type BeamState<T extends CombinationSearchCard> = {
|
||||
cards: T[];
|
||||
score: number;
|
||||
mask0: number;
|
||||
mask1: number;
|
||||
mask2: number;
|
||||
tieKey?: string;
|
||||
};
|
||||
|
||||
export type CombinationSearchLimits = {
|
||||
@@ -70,7 +74,18 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
|
||||
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<T>): string => state.tieKey ??= state.cards.map((card) => card.id).join();
|
||||
let beam: BeamState<T>[] = [{
|
||||
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<T extends CombinationSearchCard>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<FakeWorker>();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<FakeWorker>();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);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Own every Worker belonging to one search; invalidate callbacks before termination. */
|
||||
export class CombinationWorkerSession<W extends {terminate():void}> {
|
||||
private version=0;
|
||||
private workers=new Set<W>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, NoteGroup>();
|
||||
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<T>(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<Record<string, CooldownReduction>> {
|
||||
let result: Array<Record<string, CooldownReduction>> = [{}];
|
||||
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<string, CandidateProfile> {
|
||||
const profiles = new Map<string, CandidateProfile>();
|
||||
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<number, number>();
|
||||
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<Record<string, CooldownReduction>>,
|
||||
chart: ExactSongChart,
|
||||
excludedActiveWindows: readonly Pick<SkillWindow, 'cardId' | 'startSeconds'>[] = []
|
||||
): { 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<Record<string, CooldownReduction>>,
|
||||
profiles: ReadonlyMap<string, CandidateProfile>,
|
||||
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<number>((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<string, CooldownReduction>;
|
||||
theoreticalMaxScore: number;
|
||||
expectedScore: number;
|
||||
reductionTotal: number;
|
||||
};
|
||||
const bestByAssignment = new Map<Record<string, CooldownReduction>, 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
|
||||
}));
|
||||
}
|
||||
@@ -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<string, NoteGroup>();
|
||||
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<T>(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<string, CandidateProfile> {
|
||||
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart, groups: readonly NoteGroup[]): Map<string, CandidateProfile> {
|
||||
const profiles = new Map<string, CandidateProfile>();
|
||||
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<number, number>();
|
||||
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<string, CandidateProfile>,
|
||||
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<string, { scores: number[]; count: number }>();
|
||||
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<Record<string, CooldownReduction>>,
|
||||
profiles: ReadonlyMap<string, CandidateProfile>,
|
||||
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<Record<string, CooldownReduction>>,
|
||||
profiles: ReadonlyMap<string, CandidateProfile>,
|
||||
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<number>((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<string, CooldownReduction>;
|
||||
theoreticalMaxScore: number;
|
||||
expectedScore: number;
|
||||
expectedScore: number | undefined;
|
||||
reductionTotal: number;
|
||||
ties?: Candidate[];
|
||||
};
|
||||
const bestByAssignment = new Map<Record<string, CooldownReduction>, Candidate>();
|
||||
const maximumObjective = true;
|
||||
// Keep a bounded number of order profiles for deferred expected-score ties.
|
||||
const profileCache = new Map<readonly OptimizerCard[], Map<string, CandidateProfile>>();
|
||||
let activeOrder: readonly OptimizerCard[] | null = null;
|
||||
let activeProfiles: Map<string, CandidateProfile> | null = null;
|
||||
const profilesFor = (order: readonly OptimizerCard[]): Map<string, CandidateProfile> => {
|
||||
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<string, Float64Array>();
|
||||
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<number>((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),
|
||||
|
||||
@@ -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<T>(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<string,CooldownReduction>[]=[{}];
|
||||
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<string,CooldownReduction>;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<Record<string,CooldownReduction>,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<number>((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});
|
||||
});
|
||||
@@ -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<typeof member>(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);
|
||||
|
||||
@@ -3,11 +3,24 @@ export type SkillConditionMember = {
|
||||
generation: string;
|
||||
};
|
||||
|
||||
const textCache = new Map<string, string>();
|
||||
const groupCache = new Map<string, string>();
|
||||
const conditionCache = new Map<string, { group: string; requiredCount: number } | null>();
|
||||
const memberCache = new WeakMap<SkillConditionMember, { type: string; generation: string; groups: ReadonlySet<string> }>();
|
||||
function remember<T>(cache: Map<string, T>, 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<string> {
|
||||
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 {
|
||||
let compiled = conditionCache.get(condition);
|
||||
if (compiled === undefined) {
|
||||
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;
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference lib="webworker" />
|
||||
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<typeof createCombinationPreparation>|null=null;
|
||||
let currentRequestId:number|null=null;
|
||||
self.onmessage=(event:MessageEvent<PreparationRequest>)=>{
|
||||
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<combinations.length;index++){
|
||||
prepared.push(preparation.prepare(combinations[index]));
|
||||
if(index%50===49||index===combinations.length-1)self.postMessage({type:'preparation-progress',requestId:request.requestId,pass:'primary',phase:'preparation',completed:index+1,total:combinations.length});
|
||||
}
|
||||
const finalists=preparation.select(prepared);
|
||||
self.postMessage({type:'preparation-result',requestId:request.requestId,pass:'primary',screenedCount:combinations.length,finalists});
|
||||
}else{
|
||||
if(request.requestId!==currentRequestId)return;
|
||||
if(!preparation)throw new Error('탐색 준비 상태가 없습니다.');
|
||||
const finalists=preparation.refine(request.rankings);
|
||||
self.postMessage({type:'preparation-result',requestId:request.requestId,pass:'refinement',finalists});
|
||||
}
|
||||
}catch(error){self.postMessage({type:'error',requestId:request.requestId,pass:request.type==='prepare-search'?'primary':'refinement',message:error instanceof Error?error.message:'후보 준비 Worker에서 오류가 발생했습니다.'});}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
import CombinationPreparationWorker from './combination-preparation.worker.ts?worker&inline';
|
||||
export function createCombinationPreparationWorker():Worker {return new CombinationPreparationWorker();}
|
||||
@@ -5,6 +5,7 @@ import { rankExactCooldownCombinations } from '../engine/exact-optimizer.ts';
|
||||
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
|
||||
|
||||
type OptimizeRequest = {
|
||||
operationId?: number;
|
||||
type: 'optimize-exact';
|
||||
cards: OptimizerCard[];
|
||||
chart: ExactSongChart;
|
||||
@@ -28,7 +29,11 @@ type CombinationCandidate = {
|
||||
};
|
||||
|
||||
type CombinationRequest = {
|
||||
operationId?: number;
|
||||
type: 'optimize-combinations';
|
||||
returnAllRankings?: boolean;
|
||||
summaryOnly?: boolean;
|
||||
withInputIndices?: boolean;
|
||||
resultLimit?: number;
|
||||
candidates: CombinationCandidate[];
|
||||
chart: ExactSongChart;
|
||||
@@ -59,6 +64,7 @@ function compareRankings(left: RankedCombination, right: RankedCombination): num
|
||||
}
|
||||
|
||||
type AchievementRequest = {
|
||||
operationId?: number;
|
||||
type: 'estimate-achievement';
|
||||
rankings: RankedCombination[];
|
||||
chart: ExactSongChart;
|
||||
@@ -76,17 +82,18 @@ function cardsForResult(cards: readonly OptimizerCard[], result: ExactOptimizati
|
||||
|
||||
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => {
|
||||
|
||||
const reply = (payload: Record<string, unknown>) => 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<OptimizeRequest | CombinationRequest | Ach
|
||||
: undefined;
|
||||
return { result, achievementProbabilities, maximumScoreProbability };
|
||||
});
|
||||
self.postMessage({ type: 'result', ...cooldownRankings[0], cooldownRankings });
|
||||
reply({ type: 'result', ...cooldownRankings[0], cooldownRankings });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,13 +126,13 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
|
||||
sampleCount: request.sampleCount
|
||||
}
|
||||
);
|
||||
self.postMessage({
|
||||
reply({
|
||||
type: 'achievement-progress',
|
||||
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
|
||||
});
|
||||
return { ...ranking, achievementProbabilities };
|
||||
});
|
||||
self.postMessage({ type: 'achievement-result', rankings });
|
||||
reply({ type: 'achievement-result', rankings });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,13 +140,14 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
|
||||
let completedCandidates = 0;
|
||||
let best: RankedCombination | null = null;
|
||||
const rankings: RankedCombination[] = [];
|
||||
const inputIndices = new Map<RankedCombination, number>();
|
||||
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<OptimizeRequest | CombinationRequest | Ach
|
||||
rankingMultiplier: candidate.rankingMultiplier
|
||||
};
|
||||
if (!best || compareRankings(ranking, best) < 0) best = ranking;
|
||||
inputIndices.set(ranking, rankings.length);
|
||||
rankings.push(ranking);
|
||||
}
|
||||
if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
|
||||
rankings.sort(compareRankings);
|
||||
const resultLimit = [10, 20, 30, 50].includes(event.data.resultLimit ?? 10) ? event.data.resultLimit ?? 10 : 10;
|
||||
self.postMessage({ type: 'combination-result', ...best, rankings: rankings.slice(0, resultLimit) });
|
||||
const selected = event.data.returnAllRankings ? rankings : rankings.slice(0, resultLimit);
|
||||
const responseRankings = event.data.summaryOnly ? selected.map(({ cards, result, ...ranking }) => ({
|
||||
...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 : '분석 중 알 수 없는 오류가 발생했습니다.'
|
||||
});
|
||||
|
||||
+167
-392
@@ -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<Worker>();
|
||||
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<string, CardRow[]>();
|
||||
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<string, PreparedCandidate>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const topResultNeighbors = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
const secondaryPriority = new Map<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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<string, { cards: CardRow[]; roughScore: number }>();
|
||||
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<void> {
|
||||
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<string, RankedResult>();
|
||||
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 {
|
||||
async function completeExactPass(rankings: RankedResult[]): Promise<void> {
|
||||
const requestId = activeSearchId;
|
||||
if (!searchWorkers.isCurrent(requestId)) return;
|
||||
try {
|
||||
if (exactPass === 'primary' && activeSearchChart) {
|
||||
primaryRankings = rankings;
|
||||
const refinements = buildRefinementFinalists(rankings);
|
||||
if (refinements.length > 0) {
|
||||
exactPass = 'refinement';
|
||||
exactCombinationCount += refinements.length;
|
||||
if (maximizeCompute) runParallelExact(refinements, activeSearchChart);
|
||||
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit });
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
async function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): Promise<void> {
|
||||
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);
|
||||
}
|
||||
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 });
|
||||
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<void> {
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 갤산기</title></svelte:head>
|
||||
@@ -1409,10 +1180,13 @@
|
||||
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
|
||||
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
|
||||
</fieldset>
|
||||
{#if running}<button class="search cancel-search" type="button" on:click={cancelSearch}>탐색 중지</button>{/if}
|
||||
{#if searchNotice}<p class="baseline-guide" role="status">{searchNotice}</p>{/if}
|
||||
</section>
|
||||
|
||||
{#if !running}
|
||||
<section class="panel" aria-labelledby="result-title">
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 후보 평가 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
|
||||
<div class="section-title"><div><span>02</span><h2 id="result-title">탐색 결과</h2></div>{#if result}<strong>{screenedCombinationCount.toLocaleString('ko-KR')}개 선별 · {exactCombinationCount}개 정밀 평가 · {comparableToBaseline ? '기준 조합 포함 ' : ''}상위 {displayedRankings.length}개</strong>{/if}</div>
|
||||
{#if userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
|
||||
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
|
||||
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
|
||||
@@ -1485,6 +1259,7 @@
|
||||
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in new issue
Block a user