perf: 조합 탐색 정밀 계산과 Worker 처리 최적화
Publish GitHub Pages output / publish (push) Successful in 34s

카드 조건과 빈도별 공통 계산을 재사용하고 준비·평가 Worker를 재사용합니다.
준비된 후보를 모두 정밀 평가하고 최종 결과를 한 번 표시합니다.
업데이트 문구를 한 항목으로 통합합니다.
This commit is contained in:
dal4segno committed 2026-10-13 12:03:10 +09:00
1 parent f009a7fd6b
commit da40ec4238
18 files changed
+1623 -467

No files matched your search

+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-10-12T15:37:11.134Z updatedAt: 2026-10-13T02:39:27.450Z
--- ---
## 2026년 10월 13일 ## 2026년 10월 13일
### 조합 탐색 속도 개선
`IMPROVED` 반복 계산을 줄여 조합 탐색 속도를 개선했습니다.
### NEW[zom]BIE!! 추가 ### NEW[zom]BIE!! 추가
`NEW` 신곡 NEW[zom]BIE!!의 Easy·Normal·Hard·Expert 네 채보와 채보 표시 데이터, 재킷 이미지를 추가했습니다. 난이도는 6/13/19/26이며, 공식 게임 채보와 현재 Android 게임 DB를 대조해 반영했습니다. `NEW` 신곡 NEW[zom]BIE!!의 Easy·Normal·Hard·Expert 네 채보와 채보 표시 데이터, 재킷 이미지를 추가했습니다. 난이도는 6/13/19/26이며, 공식 게임 채보와 현재 Android 게임 DB를 대조해 반영했습니다.
+37 -8
View File
@@ -26,6 +26,32 @@ export type BoardConnections = {
placements: Readonly<Record<string, Readonly<Record<string, string>>>>; placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
cards: readonly BoardConnectionCard[]; 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 = { export type BoardEffectOverrides = {
generationBonusByCardId?: Readonly<Record<string, number>>; generationBonusByCardId?: Readonly<Record<string, number>>;
directSongScoreBonusPercent?: number; directSongScoreBonusPercent?: number;
@@ -95,19 +121,21 @@ function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: Boa
function connectionMultipliers( function connectionMultipliers(
summary: BoardSummarySource, summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>, memberBoards: Readonly<Record<string, readonly string[]>>,
connections?: BoardConnections | null connections?: BoardConnections | null,
context?: BoardEffectContext
): Record<string, Record<string, number>> { ): Record<string, Record<string, number>> {
if (!connections || !summary.models || !summary.members) return {}; 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 usedCards = new Set<string>();
const result: Record<string, Record<string, number>> = {}; const result: Record<string, Record<string, number>> = {};
for (const member of summary.members) { for (const member of summary.members) {
const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? []; const model = indexes.models.get(member.positionGroupId);
const positionById = new Map<string, BoardPosition>(positions.map((position) => [position.groupId, position])); if (!model) continue;
const groupIdByCoordinate = new Map<string, string>(positions.map((position) => [`${position.x},${position.y}`, position.groupId])); const { positionById, groupIdByCoordinate, center } = model;
const active = new Set(memberBoards[member.id] ?? []); const active = new Set(memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
if (center) active.add(center); if (center) active.add(center);
for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) { for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) {
@@ -247,7 +275,8 @@ export function calculateBoardEffects(
leaderCharacterId: string, leaderCharacterId: string,
song?: BoardEffectSong | null, song?: BoardEffectSong | null,
connections?: BoardConnections | null, connections?: BoardConnections | null,
overrides?: BoardEffectOverrides | null overrides?: BoardEffectOverrides | null,
context?: BoardEffectContext
): BoardEffectsResult { ): BoardEffectsResult {
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()])); const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const rateByCardId = 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)); const unitCharacterIds = new Set(cards.map((card) => card.characterId));
let scoreSupportPercent = 0; let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0; let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections); const multipliers = connectionMultipliers(summary, memberBoards, connections, context);
const generationBonusByCardId: Record<string, number> = {}; const generationBonusByCardId: Record<string, number> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) { 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));
}
});
+384
View File
@@ -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};
}
+26 -4
View File
@@ -6,6 +6,10 @@ export type CombinationSearchCard = {
type BeamState<T extends CombinationSearchCard> = { type BeamState<T extends CombinationSearchCard> = {
cards: T[]; cards: T[];
score: number; score: number;
mask0: number;
mask1: number;
mask2: number;
tieKey?: string;
}; };
export type CombinationSearchLimits = { export type CombinationSearchLimits = {
@@ -70,7 +74,18 @@ export function generateCombinationCandidates<T extends CombinationSearchCard>(
const fixedIds = new Set(fixedCards.map((card) => card.id)); const fixedIds = new Set(fixedCards.map((card) => card.id));
const fixedCharacters = new Set(fixedCards.map((card) => card.characterId)); const fixedCharacters = new Set(fixedCards.map((card) => card.characterId));
const pool = candidateCards.filter((card) => !fixedIds.has(card.id) && !fixedCharacters.has(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>[] = [{ let beam: BeamState<T>[] = [{
mask0: fixedMasks[0], mask1: fixedMasks[1], mask2: fixedMasks[2], tieKey: undefined,
cards: Array.from(fixedCards), cards: Array.from(fixedCards),
score: scoreCombination?.(fixedCards) ?? fixedCards.reduce((sum, card) => sum + scoreCard(card), 0) 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) { for (const state of beam) {
const usedCharacters = new Set(state.cards.map((card) => card.characterId)); const usedCharacters = new Set(state.cards.map((card) => card.characterId));
const usedIds = new Set(state.cards.map((card) => card.id)); 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 (usedIds.has(card.id)) continue;
if (usedCharacters.has(card.characterId)) 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]; const cards = [...state.cards, card];
if (acceptsCombination && !acceptsCombination(cards)) continue; 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 = { const nextState = {
mask0, mask1, mask2, tieKey: undefined,
cards, cards,
score: scoreCombination?.(cards) ?? state.score + scoreCard(card) score: scoreCombination?.(cards) ?? state.score + scoreCard(card)
}; };
const key = nextState.cards.map((entry) => entry.id).sort().join('|'); expanded.set(key, nextState);
if (!expanded.has(key)) expanded.set(key, nextState);
} }
} }
beam = Array.from(expanded.values()) 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)); .slice(0, Math.max(1, beamWidth));
if (beam.length === 0) break; 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();
}
}
+323
View File
@@ -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
}));
}
+221 -26
View File
@@ -1,3 +1,4 @@
import { rankExactCooldownCombinations as rankExpectedCooldownCombinations } from './exact-optimizer-expected.ts';
import { effectiveActiveProbability } from './active-probability.ts'; import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts'; import { calculateExactScore, finalNoteScore, specialScoreSupportForNote } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts'; import { passiveScoreSupportByCard } from './passive-support.ts';
@@ -18,6 +19,34 @@ type CandidateProfile = {
probability: 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[][] { function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)]; if (items.length <= 1) return [Array.from(items)];
const result: T[][] = []; 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; return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
} }
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array { function baseScores(groups: readonly NoteGroup[], perfectNoteScore: number): Float64Array {
return Float64Array.from(chart.notes, (note, index) => { return Float64Array.from(groups, ({ note, comboBonus }) => {
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1); 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 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); const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => { order.forEach((card, cardIndex) => {
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS; const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
reductions.forEach((reduction) => { reductions.forEach((reduction) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction); const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const boost = new Float64Array(chart.notes.length); const boost = new Float64Array(groups.length);
const probability = new Float64Array(chart.notes.length); const probability = new Float64Array(groups.length);
const probabilityByAttempt = new Map<number, number>(); const probabilityByAttempt = new Map<number, number>();
chart.notes.forEach((note, noteIndex) => { groups.forEach(({ note }, noteIndex) => {
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval); const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attempt <= 0) return; if (attempt <= 0) return;
const activationTime = attempt * interval; const activationTime = attempt * interval;
@@ -176,21 +205,100 @@ export function buildExactSkillWindows(
return { activeWindows, effectiveActiveWindows, specialWindows }; 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( function scoreAssignment(
cards: readonly OptimizerCard[], cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>, reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>, profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array, base: Float64Array,
directBonus: number directBonus: number,
counts: Float64Array,
selected: CandidateProfile[],
boosts: Float64Array,
probabilities: Float64Array
): { expectedScore: number; theoreticalMaxScore: number } { ): { expectedScore: number; theoreticalMaxScore: number } {
const selected = cards.map((card) => { cards.forEach((card, index) => {
const reduction = reductions[card.id] ?? 0; const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`); const profile = profiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 ${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 expectedScore = 0;
let theoreticalMaxScore = 0; let theoreticalMaxScore = 0;
@@ -215,8 +323,9 @@ function scoreAssignment(
expectedBoost += previousFailed * probabilities[position] * boosts[position]; expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position]; previousFailed *= 1 - probabilities[position];
} }
expectedScore += finalNoteScore(base[noteIndex] * (1 + expectedBoost), directBonus); // Round each original note before applying the group's multiplicity.
theoreticalMaxScore += finalNoteScore(base[noteIndex] * (1 + (count > 0 ? boosts[0] : 0)), directBonus); 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 }; return { expectedScore, theoreticalMaxScore };
} }
@@ -235,12 +344,21 @@ export function rankExactCooldownCombinations(
chart: ExactSongChart, chart: ExactSongChart,
options: ExactOptimizeOptions, options: ExactOptimizeOptions,
limit = 5 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) { if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.'); throw new Error('서로 다른 카드 5장이 필요합니다.');
} }
const reductionAssignments = assignments(cards); 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 orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const totalCandidates = orderCount * reductionAssignments.length; const totalCandidates = orderCount * reductionAssignments.length;
let evaluatedCandidates = 0; let evaluatedCandidates = 0;
@@ -248,30 +366,107 @@ export function rankExactCooldownCombinations(
order: OptimizerCard[]; order: OptimizerCard[];
reductions: Record<string, CooldownReduction>; reductions: Record<string, CooldownReduction>;
theoreticalMaxScore: number; theoreticalMaxScore: number;
expectedScore: number; expectedScore: number | undefined;
reductionTotal: number; reductionTotal: number;
ties?: Candidate[];
}; };
const bestByAssignment = new Map<Record<string, CooldownReduction>, 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 => const compare = (left: Candidate, right: Candidate): number =>
(options.objective === 'expected' (options.objective === 'expected'
? right.expectedScore - left.expectedScore || right.theoreticalMaxScore - left.theoreticalMaxScore ? expectedFor(right) - expectedFor(left) || right.theoreticalMaxScore - left.theoreticalMaxScore
: right.theoreticalMaxScore - left.theoreticalMaxScore || right.expectedScore - left.expectedScore) || : right.theoreticalMaxScore - left.theoreticalMaxScore || expectedFor(right) - expectedFor(left)) ||
left.reductionTotal - right.reductionTotal; left.reductionTotal - right.reductionTotal;
const maximumCache = new Map<string, Float64Array>();
for (const order of permutations(cards)) { for (const order of permutations(cards)) {
const profiles = makeProfiles(order, chart); const passive = passiveScoreSupportByCard(order);
for (const reductions of reductionAssignments) { const supportById = new Map(order.map((card, index) => [card.id, passive[index]]));
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0); const inputs = [
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0); ...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 candidate = { ...score, order, reductions, reductionTotal };
const previous = bestByAssignment.get(reductions); 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; evaluatedCandidates += 1;
} }
options.onProgress?.({ evaluatedCandidates, totalCandidates }); 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), ...calculateExactScore(candidate.order, candidate.reductions, chart, options),
order: candidate.order.map((card) => card.id), order: candidate.order.map((card) => card.id),
formationOrder: cards.map((card) => card.id), formationOrder: cards.map((card) => card.id),
+82
View File
@@ -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});
});
+31 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts'; import { plainSkillText, skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
const members = [ const members = [
{ type: '큐트', generation: 'hololive 0기생' }, { type: '큐트', generation: 'hololive 0기생' },
@@ -10,6 +10,36 @@ const members = [
{ type: '해피', generation: 'holoX' } { 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('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => { test('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => {
assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true); assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true);
assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false); assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false);
+44 -20
View File
@@ -3,11 +3,24 @@ export type SkillConditionMember = {
generation: string; 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 { 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 { function normalizedGroup(value: string): string {
const cached = groupCache.get(value);
if (cached !== undefined) return cached;
const normalized = value const normalized = value
.replace(/^hololive\s+/i, '') .replace(/^hololive\s+/i, '')
.replace(/^(?:EN|DEV_IS)\s+/i, '') .replace(/^(?:EN|DEV_IS)\s+/i, '')
@@ -15,34 +28,45 @@ function normalizedGroup(value: string): string {
.replace(/\s*타입$/, '') .replace(/\s*타입$/, '')
.trim() .trim()
.toLowerCase(); .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 { function memberBelongsTo(member: SkillConditionMember, group: string): boolean {
const normalized = normalizedGroup(group); return normalizedMemberGroups(member).has(normalizedGroup(group));
const type = normalizedGroup(member.type);
const generations = member.generation
.split('/')
.map((value) => normalizedGroup(value))
.filter(Boolean);
return type === normalized
|| generations.includes(normalized);
} }
export function skillConditionSatisfied( export function skillConditionSatisfied(
condition: string, condition: string,
members: readonly SkillConditionMember[] members: readonly SkillConditionMember[]
): boolean { ): boolean {
const description = plainSkillText(condition); let compiled = conditionCache.get(condition);
if (!description) return true; if (compiled === undefined) {
const description = plainSkillText(condition);
// 콤보와 라이프는 분석에서 최적 플레이를 전제로 항상 충족한다. // Combo/life and unrecognized clauses retain the established assumptions.
if (/\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)) return true; const match = !description || /\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)
? null : description.match(/^(.+?)\s*(\d+)명 이상이면$/);
const deckCondition = description.match(/^(.+?)\s*(\d+)명 이상이면$/); compiled = remember(conditionCache, condition, match ? { group: normalizedGroup(match[1]), requiredCount: Number(match[2]) } : null);
if (!deckCondition) return true; }
const requiredCount = Number(deckCondition[2]); if (!compiled) return true;
return members.filter((member) => memberBelongsTo(member, deckCondition[1])).length >= requiredCount; 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 { 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();}
+22 -8
View File
@@ -5,6 +5,7 @@ import { rankExactCooldownCombinations } from '../engine/exact-optimizer.ts';
import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts'; import type { ExactOptimizationResult, ExactOptimizeOptions, ExactSongChart, OptimizerCard, ScoreAchievementProbabilities } from '../engine/index.ts';
type OptimizeRequest = { type OptimizeRequest = {
operationId?: number;
type: 'optimize-exact'; type: 'optimize-exact';
cards: OptimizerCard[]; cards: OptimizerCard[];
chart: ExactSongChart; chart: ExactSongChart;
@@ -28,7 +29,11 @@ type CombinationCandidate = {
}; };
type CombinationRequest = { type CombinationRequest = {
operationId?: number;
type: 'optimize-combinations'; type: 'optimize-combinations';
returnAllRankings?: boolean;
summaryOnly?: boolean;
withInputIndices?: boolean;
resultLimit?: number; resultLimit?: number;
candidates: CombinationCandidate[]; candidates: CombinationCandidate[];
chart: ExactSongChart; chart: ExactSongChart;
@@ -59,6 +64,7 @@ function compareRankings(left: RankedCombination, right: RankedCombination): num
} }
type AchievementRequest = { type AchievementRequest = {
operationId?: number;
type: 'estimate-achievement'; type: 'estimate-achievement';
rankings: RankedCombination[]; rankings: RankedCombination[];
chart: ExactSongChart; chart: ExactSongChart;
@@ -76,17 +82,18 @@ function cardsForResult(cards: readonly OptimizerCard[], result: ExactOptimizati
self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => { self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | AchievementRequest>) => {
const reply = (payload: Record<string, unknown>) => self.postMessage({ ...payload, operationId: event.data.operationId });
try { try {
if (event.data.type === 'optimize-exact') { if (event.data.type === 'optimize-exact') {
const options: ExactOptimizeOptions = { const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore, perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent, directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
finalScoreBonusPercent: event.data.finalScoreBonusPercent, finalScoreBonusPercent: event.data.finalScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress }) onProgress: (progress) => reply({ type: 'progress', progress })
}; };
const request = event.data; const request = event.data;
const results = rankExactCooldownCombinations(request.cards, request.chart, options, request.cooldownResultLimit === 5 ? 5 : 1); 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 cooldownRankings = results.map((result) => {
const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities( const achievementProbabilities = request.includeAchievementProbabilities ? estimateScoreAchievementProbabilities(
cardsForResult(request.cards, result), cardsForResult(request.cards, result),
@@ -99,7 +106,7 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
: undefined; : undefined;
return { result, achievementProbabilities, maximumScoreProbability }; return { result, achievementProbabilities, maximumScoreProbability };
}); });
self.postMessage({ type: 'result', ...cooldownRankings[0], cooldownRankings }); reply({ type: 'result', ...cooldownRankings[0], cooldownRankings });
return; return;
} }
@@ -119,13 +126,13 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
sampleCount: request.sampleCount sampleCount: request.sampleCount
} }
); );
self.postMessage({ reply({
type: 'achievement-progress', type: 'achievement-progress',
progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length } progress: { evaluatedCandidates: rankingIndex + 1, totalCandidates: request.rankings.length }
}); });
return { ...ranking, achievementProbabilities }; return { ...ranking, achievementProbabilities };
}); });
self.postMessage({ type: 'achievement-result', rankings }); reply({ type: 'achievement-result', rankings });
return; return;
} }
@@ -133,13 +140,14 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
let completedCandidates = 0; let completedCandidates = 0;
let best: RankedCombination | null = null; let best: RankedCombination | null = null;
const rankings: RankedCombination[] = []; const rankings: RankedCombination[] = [];
const inputIndices = new Map<RankedCombination, number>();
for (const candidate of event.data.candidates) { for (const candidate of event.data.candidates) {
const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, { const result = optimizeExactOrderAndCooldowns(candidate.cards, event.data.chart, {
perfectNoteScore: candidate.perfectNoteScore, perfectNoteScore: candidate.perfectNoteScore,
directSongScoreBonusPercent: candidate.directSongScoreBonusPercent, directSongScoreBonusPercent: candidate.directSongScoreBonusPercent,
finalScoreBonusPercent: candidate.finalScoreBonusPercent, finalScoreBonusPercent: candidate.finalScoreBonusPercent,
objective: candidate.optimizationObjective, objective: candidate.optimizationObjective,
onProgress: (progress) => self.postMessage({ onProgress: (progress) => reply({
type: 'progress', type: 'progress',
progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates } progress: { evaluatedCandidates: completedCandidates + progress.evaluatedCandidates, totalCandidates }
}) })
@@ -155,14 +163,20 @@ self.onmessage = (event: MessageEvent<OptimizeRequest | CombinationRequest | Ach
rankingMultiplier: candidate.rankingMultiplier rankingMultiplier: candidate.rankingMultiplier
}; };
if (!best || compareRankings(ranking, best) < 0) best = ranking; if (!best || compareRankings(ranking, best) < 0) best = ranking;
inputIndices.set(ranking, rankings.length);
rankings.push(ranking); rankings.push(ranking);
} }
if (!best) throw new Error('정밀 분석할 조합이 없습니다.'); if (!best) throw new Error('정밀 분석할 조합이 없습니다.');
rankings.sort(compareRankings); rankings.sort(compareRankings);
const resultLimit = [10, 20, 30, 50].includes(event.data.resultLimit ?? 10) ? event.data.resultLimit ?? 10 : 10; 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) { } catch (error) {
self.postMessage({ reply({
type: 'error', type: 'error',
message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.' message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.'
}); });
+174 -399
View File
@@ -21,6 +21,7 @@
additionalAwakeningsRequired, additionalAwakeningsRequired,
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
createBoardEffectContext,
combinationSearchLimits, combinationSearchLimits,
compareEventRankings, compareEventRankings,
countOptimizationCandidates, countOptimizationCandidates,
@@ -41,6 +42,11 @@
import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts'; import type { CooldownReduction, ExactOptimizationResult, ExactSongChart, OptimizationProgress, OptimizerCard, ScoreAchievementProbabilities } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage'; import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; 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 eventMode = false;
export let eventScoreMode = false; export let eventScoreMode = false;
@@ -152,10 +158,16 @@
let resultLimit = 10; let resultLimit = 10;
let activeResultLimit = 10; let activeResultLimit = 10;
let maximizeCompute = false; let maximizeCompute = false;
let evaluationPool: CombinationEvaluationPool | null = null;
let leaderCharacterId = leaderMembers[0]?.characterId ?? ''; let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? ''; let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null; 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 baselineWorker: Worker | null = null;
let running = false; let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 }; let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
@@ -368,28 +380,6 @@
} catch { } catch {
localStorage.removeItem(STORAGE_KEY); 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(); resetBaseline();
try { try {
baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY)); baselineState = readAnalysisState(localStorage.getItem(ANALYSIS_STORAGE_KEY));
@@ -409,8 +399,7 @@
mounted = false; mounted = false;
window.removeEventListener('storage', syncStorage); window.removeEventListener('storage', syncStorage);
window.clearInterval(releaseClock); window.clearInterval(releaseClock);
worker?.terminate(); clearSearchWorkers();
terminateParallelWorkers();
baselineWorker?.terminate(); baselineWorker?.terminate();
}; };
}); });
@@ -621,58 +610,6 @@
result = null; 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 { function rankingValue(ranking: RankedResult): number {
return eventPtMode return eventPtMode
? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0) ? eventPointIndex(ranking.result.expectedScore, ranking.eventBonusPercent ?? 0)
@@ -690,230 +627,6 @@
return cards.map((card) => card.id).sort().join('|'); 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> { async function analyzeBaseline(): Promise<void> {
const targetWorker = baselineWorker; const targetWorker = baselineWorker;
const state = baselineState; const state = baselineState;
@@ -1010,12 +723,7 @@
return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR'); return Math.ceil(maximumScore * ratio).toLocaleString('ko-KR');
} }
function terminateParallelWorkers(): void { function selectRankings(rankings: RankedResult[]): RankedResult[] {
for (const parallelWorker of parallelWorkers) parallelWorker.terminate();
parallelWorkers = [];
}
function finalizeRankings(rankings: RankedResult[]): void {
const bestByCombination = new Map<string, RankedResult>(); const bestByCombination = new Map<string, RankedResult>();
for (const ranking of rankings) { for (const ranking of rankings) {
const key = [...ranking.result.order].sort().join('|'); const key = [...ranking.result.order].sort().join('|');
@@ -1024,90 +732,70 @@
bestByCombination.set(key, ranking); bestByCombination.set(key, ranking);
} }
} }
rankedResults = Array.from(bestByCombination.values()).sort(compareRankedResults).slice(0, activeResultLimit); return 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)) {
function finalizeRankings(rankings: RankedResult[]): void {
const finalRankings = selectRankings(rankings);
if (finalRankings.length > 0 && activeSearchChart && worker && finalRankings.every((ranking) => ranking.cards)) {
searchPhase = 'probability'; searchPhase = 'probability';
progress = { evaluatedCandidates: 0, totalCandidates: rankedResults.length }; progress = { evaluatedCandidates: 0, totalCandidates: finalRankings.length };
worker.postMessage({ worker.postMessage({
type: 'estimate-achievement', type: 'estimate-achievement',
rankings: rankedResults, rankings: finalRankings,
chart: activeSearchChart, chart: activeSearchChart,
sampleCount: 25_000 sampleCount: 25_000
}); });
return; return;
} }
running = false; rankedResults = finalRankings;
searchPhase = 'idle'; result = rankedResults[0]?.result ?? null;
finishSearch();
} }
function completeExactPass(rankings: RankedResult[]): void { async function completeExactPass(rankings: RankedResult[]): Promise<void> {
if (exactPass === 'primary' && activeSearchChart) { const requestId = activeSearchId;
primaryRankings = rankings; if (!searchWorkers.isCurrent(requestId)) return;
const refinements = buildRefinementFinalists(rankings); try {
if (refinements.length > 0) { if (exactPass === 'primary' && activeSearchChart) {
primaryRankings = rankings;
exactPass = 'refinement'; exactPass = 'refinement';
exactCombinationCount += refinements.length; progress = { evaluatedCandidates: 0, totalCandidates: 1 };
if (maximizeCompute) runParallelExact(refinements, activeSearchChart); const { finalists: refinements } = await requestPreparation({
else worker?.postMessage({ type: 'optimize-combinations', candidates: refinements, chart: activeSearchChart, resultLimit: activeResultLimit }); type: 'prepare-refinement', requestId,
return; rankings: rankings.map((ranking) => ({ result: { order: ranking.result.order } }))
});
if (!searchWorkers.isCurrent(requestId)) return;
if (refinements.length > 0) {
void runParallelExact(refinements, activeSearchChart);
return;
}
} }
finalizeRankings([...primaryRankings, ...rankings]);
} catch (error) {
failSearch(error instanceof Error ? error.message : '추가 후보 준비에 실패했습니다.', requestId);
} }
finalizeRankings([...primaryRankings, ...rankings]);
} }
function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): void { async function runParallelExact(finalists: PreparedCandidate[], chart: ExactSongChart): Promise<void> {
terminateParallelWorkers(); const requestId = activeSearchId;
const reportedCores = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4; try {
const workerCount = Math.min(finalists.length, Math.max(1, reportedCores)); if (!evaluationPool) {
const groups = Array.from({ length: workerCount }, () => [] as PreparedCandidate[]); const reported = typeof navigator === 'undefined' ? 1 : navigator.hardwareConcurrency || 4;
finalists.forEach((candidate, index) => groups[index % workerCount].push(candidate)); evaluationPool = new CombinationEvaluationPool(createScoreOptimizerWorker, searchWorkers, requestId,
maximizeCompute ? Math.min(32, finalists.length, reported) : 1);
const groupProgress = Array.from({ length: workerCount }, () => 0); }
const totalCandidates = finalists.reduce((sum, candidate) => sum + countOptimizationCandidates(candidate.cards), 0); const rankings = await evaluationPool.run(finalists, chart, {
const completedRankings: RankedResult[] = []; resultLimit: activeResultLimit,
let completedWorkers = 0; onPhase: phase => { if (searchWorkers.isCurrent(requestId)) searchPhase = phase; },
let failed = false; onProgress: value => { if (searchWorkers.isCurrent(requestId)) progress = value; }
progress = { evaluatedCandidates: 0, totalCandidates }; });
if (!searchWorkers.isCurrent(requestId)) return;
const fail = (message: string) => { exactCombinationCount += evaluationPool.lastExactCount;
if (failed) return; await completeExactPass(rankings);
failed = true; } catch (error) {
terminateParallelWorkers(); failSearch(error instanceof Error ? error.message : '후보 평가에 실패했습니다.', requestId);
errorMessage = message; }
running = false;
searchPhase = 'idle';
};
groups.forEach((candidates, workerIndex) => {
const parallelWorker = createScoreOptimizerWorker();
parallelWorkers.push(parallelWorker);
parallelWorker.onmessage = (event) => {
if (failed) return;
if (event.data.type === 'progress') {
groupProgress[workerIndex] = event.data.progress.evaluatedCandidates;
progress = {
evaluatedCandidates: groupProgress.reduce((sum, value) => sum + value, 0),
totalCandidates
};
}
if (event.data.type === 'combination-result') {
const rankings = Array.isArray(event.data.rankings)
? event.data.rankings as RankedResult[]
: [{ result: event.data.result, overallPower: event.data.overallPower }];
completedRankings.push(...rankings);
completedWorkers += 1;
parallelWorker.terminate();
if (completedWorkers !== workerCount) return;
completedRankings.sort(compareRankedResults);
parallelWorkers = [];
completeExactPass(completedRankings.slice(0, activeResultLimit));
}
if (event.data.type === 'error') fail(event.data.message);
};
parallelWorker.onerror = () => fail('병렬 분석 Worker에서 오류가 발생했습니다.');
parallelWorker.postMessage({ type: 'optimize-combinations', candidates, chart, resultLimit: activeResultLimit });
});
} }
async function search(): Promise<void> { async function search(): Promise<void> {
@@ -1125,7 +813,7 @@
return; return;
} }
result = null; result = null;
if (!worker || !selectedChart) return; if (!selectedChart) return;
if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) { if (ownedCardsOnly && new Set(candidateCards.map((card) => card.characterId)).size < 5) {
errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.'; errorMessage = '서로 다른 멤버의 보유 5성 카드가 5장 이상 필요합니다.';
return; return;
@@ -1134,11 +822,15 @@
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.'; errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 고정할 수 없습니다.';
return; return;
} }
if (!acceptsCombination(fixedCards)) { if (!withinMediumProbabilityCardLimit(fixedCards, maxMediumProbabilityCards)) {
errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`; errorMessage = `고정 카드의 액티브 발동 확률 ‘중간’ 카드가 최대 ${maxMediumProbabilityCards}장 제한을 초과합니다. 고정 카드나 제한을 변경해 주세요.`;
return; return;
} }
rankedResults = [];
running = true; running = true;
const requestId = searchWorkers.start();
activeSearchId = requestId;
searchNotice = '';
activeResultLimit = resultLimit; activeResultLimit = resultLimit;
searchSnapshot = structuredClone({ conditions: presetSnapshot, searchSnapshot = structuredClone({ conditions: presetSnapshot,
growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent }, growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
@@ -1149,30 +841,31 @@
primaryRankings = []; primaryRankings = [];
screeningProgress = 0; screeningProgress = 0;
progress = { evaluatedCandidates: 0, totalCandidates: 1 }; progress = { evaluatedCandidates: 0, totalCandidates: 1 };
await new Promise((resolve) => setTimeout(resolve, 0)); const metadata = selectedChart;
try { try {
const combinations = screeningCombinations(); preparationContext = structuredClone({
screenedCombinationCount = combinations.length; allCards, boardSummary, memoryEffects: memorySummary.effects, chartMetadata: metadata,
const prepared: PreparedCandidate[] = []; growth: { collection, memberBoards, boardCards, memoryCount, memberPowerUpPercent },
for (let index = 0; index < combinations.length; index += 1) { settings: { fixedCardIds, fixedAwakenings, fixedCooldownReductions, fixedAllBlueNodes,
prepared.push(prepareCandidate(combinations[index])); fixedGenerationBonusOverrides, candidateAwakening, candidateGenerationBonus, songScoreBonusOverride,
if (index % 50 === 49 || index === combinations.length - 1) { ownedCardsOnly, regularCardsOnly, maxMediumProbabilityCards, leaderCharacterId, leaderOutfitCardId },
screeningProgress = (index + 1) / combinations.length; eventMode, eventPtMode, currentEvent
await new Promise((resolve) => setTimeout(resolve, 0)); });
} resetScoreWorker(requestId);
} preparationWorker = createCombinationPreparationWorker();
const finalists = selectExactFinalists(prepared); searchWorkers.track(preparationWorker, requestId);
exactCombinationCount = finalists.length; 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('조건에 맞는 카드 조합이 없습니다.'); 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; activeSearchChart = chart;
searchPhase = 'exact'; searchPhase = 'exact';
if (maximizeCompute) runParallelExact(finalists, chart); void runParallelExact(finalists, chart);
else worker.postMessage({ type: 'optimize-combinations', candidates: finalists, chart, resultLimit: activeResultLimit });
} catch (error) { } catch (error) {
errorMessage = error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.'; failSearch(error instanceof Error ? error.message : '조합 탐색을 준비하지 못했습니다.', requestId);
running = false;
searchPhase = 'idle';
} }
} }
@@ -1236,6 +929,84 @@
.sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline)) .sort((left, right) => compareRankedResults(left, right) || Number(right.isBaseline) - Number(left.isBaseline))
.slice(0, limit); .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> </script>
<svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 갤산기</title></svelte:head> <svelte:head><title>{eventScoreMode ? '이벤트 점수 탐색' : eventMode ? '이벤트 PT 비교' : '조합 탐색'} · 갤산기</title></svelte:head>
@@ -1401,7 +1172,7 @@
<p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p> <p class="candidate-guide">퍼센트는 현재 단계의 진행률입니다. 확장 후보가 없으면 3단계는 건너뜁니다.</p>
{/if} {/if}
<button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button> <button class="search" type="button" disabled={running} on:click={search}>{running ? searchPhase === 'screening' ? `후보 선별 중 ${Math.round(screeningProgress * 100)}%` : searchPhase === 'probability' ? `달성 확률 계산 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : `${exactPass === 'refinement' ? '확장 후보 검증' : '선별 후보 평가'} ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 조합 탐색'}</button>
<label class="compute-option"> <label class="compute-option">
<input type="checkbox" bind:checked={maximizeCompute} disabled={running}> <input type="checkbox" bind:checked={maximizeCompute} disabled={running}>
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<strong>최대 컴퓨팅 자원 활용</strong> <strong>최대 컴퓨팅 자원 활용</strong>
@@ -1409,10 +1180,13 @@
<p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p> <p class="compute-warning">탐색 속도는 빨라지지만, 분석 중 브라우저 및 기기 사용이 불편할 수 있습니다.</p>
{#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if} {#if running}<progress value={searchPhase === 'screening' ? screeningProgress : progress.evaluatedCandidates} max={searchPhase === 'screening' ? 1 : progress.totalCandidates}></progress>{/if}
</fieldset> </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> </section>
{#if !running}
<section class="panel" aria-labelledby="result-title"> <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 userDataChanged}<p class="baseline-guide" role="status">사용자 데이터가 변경되었습니다. 진행 중인 탐색과 아래 결과는 이전 데이터 기준이며, 다시 탐색하면 최신 데이터를 적용합니다.</p>{/if}
{#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if} {#if !eventMode}<p class="baseline-guide">상세 점수·그래프·액티브 미발동 비교는 각 결과의 ‘배치 분석에서 보기’를 눌러 새 탭에서 확인할 수 있습니다.</p>{/if}
{#if transferError}<p class="error" role="alert">{transferError}</p>{/if} {#if transferError}<p class="error" role="alert">{transferError}</p>{/if}
@@ -1485,6 +1259,7 @@
<div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div> <div class="empty">고정할 카드와 악곡을 선택한 뒤 조합 탐색을 시작해 주세요.</div>
{/if} {/if}
</section> </section>
{/if}
</main> </main>
<style> <style>