fix: restore fast connection matching with yellow bonus correction
Publish GitHub Pages output / publish (push) Successful in 32s

This commit is contained in:
2026-09-17 20:38:18 +09:00
parent 9ba5f73500
commit ba21e8f1c1
7 changed files with 223 additions and 478 deletions
@@ -1,200 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizeBoardConnectionPlacementsExact, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import { calculateExactScore } from './score.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const source: BoardConnectionOptimizerSource = {
members: ['a', 'b', 'c'].map((id) => ({ id, positionGroupId: 'm' })),
models: { m: [{ groupId: 'S', x: 0, y: 0 }] },
nodeGroups: { S: [{ characterIds: [], type: 'connection', effect: null }] }
};
const cards = ['one', 'two', 'three'].map((id, i) => ({ id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 100 * (i + 1), extent: [{ x: 1, y: 0 }] }] }));
type Placements = Record<string, Record<string, string>>;
const key = (p: Placements) => ['a', 'b', 'c'].map((id) => p[id]?.S ?? '-').join(',');
// Independent oracle: enumerate a Cartesian product, then reject reused IDs.
function allPlacements(): Placements[] {
const choices = [undefined, 'one', 'two', 'three'];
const all: Placements[] = [];
for (const a of choices) for (const b of choices) for (const c of choices) {
const ids = [a, b, c].filter((id) => id !== undefined);
if (new Set(ids).size !== ids.length) continue;
const p: Placements = {};
if (a) p.a = { S: a };
if (b) p.b = { S: b };
if (c) p.c = { S: c };
all.push(p);
}
return all;
}
test('일시적인 점수 하락을 거치는 세 카드 순환 교환도 전역 최적해로 찾는다', async () => {
const initial = { a: { S: 'one' }, b: { S: 'two' }, c: { S: 'three' } };
const target = { a: { S: 'two' }, b: { S: 'three' }, c: { S: 'one' } };
const seen = new Set<string>();
const result = await optimizeBoardConnectionPlacementsExact(source, {}, cards, initial, {
currentPlacements: initial,
evaluate: async (p) => {
seen.add(key(p));
return { score: key(p) === key(target) ? 20 : key(p) === key(initial) ? 10 : 0, activationRateUp: 0 };
}
});
assert.deepEqual(result, target);
assert.deepEqual(seen, new Set(allPlacements().map(key)));
assert.equal(seen.size, 34);
});
test('비선형 평가 30개에서 독립 완전 열거의 점수·발동률·유지 우선순위와 일치한다', async () => {
const all = allPlacements();
let random = 8123;
const next = () => { random = (Math.imul(random, 1664525) + 1013904223) >>> 0; return random; };
for (let trial = 0; trial < 30; trial += 1) {
const current = all[next() % all.length];
const values = new Map(all.map((p) => [key(p), { score: next() % 7, activationRateUp: next() % 3 }]));
const retained = (p: Placements) => ['a', 'b', 'c'].filter((id) => p[id]?.S === current[id]?.S).length;
const sorted = [...all].sort((a, b) => values.get(key(b))!.score - values.get(key(a))!.score
|| values.get(key(b))!.activationRateUp - values.get(key(a))!.activationRateUp || retained(b) - retained(a));
const result = await optimizeBoardConnectionPlacementsExact(source, {}, cards, current, {
currentPlacements: current, evaluate: (p) => values.get(key(p))!
});
assert.deepEqual(values.get(key(result)), values.get(key(sorted[0])));
assert.equal(retained(result), retained(sorted[0]));
}
});
test('카드를 비우는 것이 더 좋으면 빈 배치도 최적해로 반환한다', async () => {
const result = await optimizeBoardConnectionPlacementsExact(source, {}, cards, { a: { S: 'one' } }, {
evaluate: (p) => ({ score: -Object.keys(p).length, activationRateUp: 0 })
});
assert.deepEqual(result, {});
});
test('병렬 작업은 지정한 개수를 넘지 않고 완료 순서와 무관하게 동일한 최적해를 반환한다', async () => {
const values = new Map(allPlacements().map((p, index) => [key(p), { score: index % 5, activationRateUp: index % 3 }]));
const serial = await optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, { evaluate: (p) => values.get(key(p))! });
let active = 0;
let peak = 0;
const occupied = new Set<number>();
const parallel = await optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, {
concurrency: 3,
evaluate: async (p, workerIndex) => {
assert.ok(workerIndex >= 0 && workerIndex < 3);
assert.ok(!occupied.has(workerIndex));
occupied.add(workerIndex);
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 3 - workerIndex));
active -= 1;
occupied.delete(workerIndex);
return values.get(key(p))!;
}
});
assert.equal(peak, 3);
assert.equal(active, 0);
assert.deepEqual(parallel, serial);
});
test('동일 효과 카드의 중복 탐색을 줄여도 점수와 기존 카드 유지 결과가 같다', async () => {
const equivalent = cards.map((card) => ({ ...card, connect: cards[0].connect }));
const current = { a: { S: 'three' }, c: { S: 'one' } };
let fullCount = 0;
let groupedCount = 0;
const evaluate = (p: Placements) => ({ score: Object.keys(p).length === 2 ? 100 : 0, activationRateUp: 0 });
const full = await optimizeBoardConnectionPlacementsExact(source, {}, equivalent, current, {
currentPlacements: current, evaluate: (p) => { fullCount += 1; return evaluate(p); }
});
const grouped = await optimizeBoardConnectionPlacementsExact(source, {}, equivalent, current, {
currentPlacements: current, groupEquivalentEffects: true, concurrency: 3,
evaluate: (p) => { groupedCount += 1; return evaluate(p); }
});
assert.deepEqual(grouped, full);
assert.deepEqual(grouped, current);
assert.equal(fullCount, 34);
assert.equal(groupedCount, 8);
});
test('효과별 보유 수량을 지키며 여러 동등 카드 그룹의 정확한 최대값을 찾는다', async () => {
const mixed = cards.map((card, index) => ({ ...card, connect: index < 2 ? cards[0].connect : card.connect }));
const effect = (id?: string) => id === 'three' ? 3 : id ? 1 : 0;
const evaluate = (p: Placements) => ({ score: (effect(p.a?.S) + effect(p.b?.S)) * effect(p.c?.S), activationRateUp: 0 });
const grouped = await optimizeBoardConnectionPlacementsExact(source, {}, mixed, {}, {
groupEquivalentEffects: true, evaluate
});
assert.equal(evaluate(grouped).score, Math.max(...allPlacements().map((p) => evaluate(p).score)));
const ids = Object.values(grouped).flatMap(Object.values);
assert.equal(new Set(ids).size, ids.length);
});
test('병렬 탐색 중 중단하면 최적 배치를 반환하지 않는다', async () => {
const controller = new AbortController();
let calls = 0;
await assert.rejects(optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, {
concurrency: 3, signal: controller.signal,
evaluate: async () => {
const call = ++calls;
await new Promise((resolve) => setTimeout(resolve, 1));
if (call === 2) controller.abort(new Error('parallel cancelled'));
return { score: 100, activationRateUp: 0 };
}
}), /parallel cancelled/);
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(calls <= 4); // One seed and at most one batch, with no later jobs.
});
test('중단·평가 오류·유효하지 않은 점수는 부분 최적해로 반환하지 않는다', async () => {
const controller = new AbortController();
let checks = 0;
await assert.rejects(optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, {
signal: controller.signal,
evaluate: async () => {
if (++checks === 3) controller.abort(new Error('cancelled'));
return { score: 100, activationRateUp: 0 };
}
}), /cancelled/);
assert.equal(checks, 3);
await assert.rejects(optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, {
evaluate: () => { throw new Error('evaluation failed'); }
}), /evaluation failed/);
await assert.rejects(optimizeBoardConnectionPlacementsExact(source, {}, cards, {}, {
evaluate: () => ({ score: NaN, activationRateUp: 0 })
}), /유효하지/);
});
test('필수 소켓을 채울 수 없으면 성공 결과를 반환하지 않는다', async () => {
const mandatory: BoardConnectionOptimizerSource = {
...source, models: { m: [{ groupId: 'S', x: 1, y: 0 }, { groupId: 'B-large', x: 2, y: 0 }] },
nodeGroups: { ...source.nodeGroups, 'B-large': [{ characterIds: [], type: 'normal', grade: 2,
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 10 } }] }
};
await assert.rejects(optimizeBoardConnectionPlacementsExact(mandatory, { a: ['S'], b: ['S'], c: ['S'] }, cards.slice(0, 2), {}, {
requireBlueActivationCoverage: true, evaluate: () => ({ score: 0, activationRateUp: 0 })
}), /필수 범위/);
});
test('배치마다 순서와 쿨타임을 재탐색한 값이 배치×순서×쿨타임 독립 열거와 일치한다', async () => {
const chart: ExactSongChart = { songId: 'joint', difficulty: 'expert', durationSeconds: 8, scoreRatio: 1,
notes: [1.1, 2.5, 4.2, 6.5].map((timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [1, 2, 3, 4, 5] };
const formation = (p: Placements): OptimizerCard[] => ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
id, name: id,
active: { cooldownSeconds: 2 + index, durationSeconds: 0.5, scoreUpPercent: p[id] ? 100 : 10 },
special: { durationSeconds: 1, scoreUpPercent: p[id] ? 80 : 20 },
allowedCooldownReductions: index === 0 ? [0, 4] : [0]
}));
const layouts: Placements[] = [{}, { a: { S: 'one' } }, { b: { S: 'one' } }, { c: { S: 'one' } }];
function permutations<T>(items: T[]): T[][] {
return items.length ? items.flatMap((item, i) => permutations(items.filter((_, j) => j !== i)).map((rest) => [item, ...rest])) : [[]];
}
let oracle = -Infinity;
for (const layout of layouts) for (const order of permutations(formation(layout))) for (const reduction of [0, 4] as const) {
oracle = Math.max(oracle, calculateExactScore(order, { a: reduction, b: 0, c: 0, d: 0, e: 0 }, chart,
{ perfectNoteScore: 100 }).theoreticalMaxScore);
}
const evaluate = (p: Placements) => ({ score: optimizeExactOrderAndCooldowns(formation(p), chart,
{ perfectNoteScore: 100 }).theoreticalMaxScore, activationRateUp: 0 });
const result = await optimizeBoardConnectionPlacementsExact(source, {}, cards.slice(0, 1), {}, { evaluate });
assert.equal(evaluate(result).score, oracle);
});
+1 -179
View File
@@ -88,182 +88,6 @@ function selectedEffect(card: OptimizableConnectionCard) {
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
}
/** Enumerate every legal complete assignment, including empty optional sockets.
* Returns only after exhaustive search; cancellation/errors never return a partial optimum.
* The evaluator must optimize any additional variables (such as order and cooldowns).
*/
export async function optimizeBoardConnectionPlacementsExact(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
initialPlacements: Readonly<Record<string, Readonly<Record<string, string>>>>,
options: Pick<BoardConnectionOptimizationOptions, 'requireBlueActivationCoverage' | 'currentPlacements'> & {
evaluate: (placements: Record<string, Record<string, string>>, workerIndex: number) =>
{ score: number; activationRateUp: number } | Promise<{ score: number; activationRateUp: number }>;
concurrency?: number;
/** Enable only when evaluation depends on connection effects, not card IDs. */
groupEquivalentEffects?: boolean;
signal?: AbortSignal;
onProgress?: (checks: number) => Promise<void>;
}
): Promise<Record<string, Record<string, string>>> {
const cards = ownedCards.filter((card) => selectedEffect(card));
const cardIndex = new Map(cards.map((card, index) => [card.id, index]));
if (cardIndex.size !== cards.length) throw new Error('보유 연결 카드 ID가 중복되었습니다.');
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const active = new Set(memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId)
&& nodeForMember(source, position.groupId, member.id)?.type === 'connection').map((socket) => {
const required = (options.requireBlueActivationCoverage === true
|| (Array.isArray(options.requireBlueActivationCoverage) && options.requireBlueActivationCoverage.includes(member.id)))
&& socket.x !== 0 ? positions.filter((position) => {
const node = nodeForMember(source, position.groupId, member.id);
return position.groupId.startsWith('B-') && position.x * socket.x > 0
&& (node?.grade ?? 0) >= 2
&& node?.effect?.type === 'live_active_skill_activation_probability_up_permil_up';
}) : [];
return { memberId: member.id, socketId: socket.groupId, required: required.length > 0,
allowed: cards.map((card) => required.every((position) => selectedEffect(card)!.extent.some((offset) =>
socket.x + offset.x === position.x && socket.y + offset.y === position.y))) };
});
});
const decode = (placements: Readonly<Record<string, Readonly<Record<string, string>>>>) => slots.map((slot) =>
cardIndex.get(placements[slot.memberId]?.[slot.socketId]) ?? -1);
const encode = (assignment: number[]) => {
const placements: Record<string, Record<string, string>> = {};
assignment.forEach((index, slotIndex) => {
if (index >= 0) (placements[slots[slotIndex].memberId] ??= {})[slots[slotIndex].socketId] = cards[index].id;
});
return placements;
};
const allowed = (index: number, slot: number) => index < 0 ? !slots[slot].required : slots[slot].allowed[index];
const valid = (assignment: number[]) => new Set(assignment.filter((index) => index >= 0)).size
=== assignment.filter((index) => index >= 0).length && assignment.every(allowed);
const current = options.currentPlacements ? decode(options.currentPlacements) : null;
const signatures = cards.map((card, index) => {
if (!options.groupEquivalentEffects) return String(index);
const effect = selectedEffect(card)!;
return JSON.stringify([effect.effectPermilUp, effect.extent.map(({ x, y }) => `${x},${y}`).sort()]);
});
const groups = new Map<string, number[]>();
signatures.forEach((signature, index) => {
const group = groups.get(signature) ?? [];
group.push(index);
groups.set(signature, group);
});
// Enumerate effect patterns once, then choose concrete IDs with maximum retention.
// Reserving every possible current ID first avoids stealing a later slot's card.
const canonicalize = (assignment: number[]) => {
const result = [...assignment];
for (const [signature, group] of groups) {
const selectedSlots = assignment.flatMap((index, slot) => index >= 0 && signatures[index] === signature ? [slot] : []);
const available = new Set(group);
const pending: number[] = [];
for (const slot of selectedSlots) {
const previous = current?.[slot] ?? -1;
if (available.delete(previous)) result[slot] = previous;
else pending.push(slot);
}
for (const slot of pending) {
const index = available.values().next().value!;
result[slot] = index;
available.delete(index);
}
}
return result;
};
const requestedConcurrency = options.concurrency ?? 1;
const concurrency = Number.isFinite(requestedConcurrency) ? Math.max(1, Math.floor(requestedConcurrency)) : 1;
type Metric = { score: number; activationRateUp: number; retained: number };
const better = (left: Metric, right: Metric) => left.score > right.score
|| (left.score === right.score && (left.activationRateUp > right.activationRateUp
|| (left.activationRateUp === right.activationRateUp && left.retained > right.retained)));
let bestAssignment: number[] | null = null;
let bestMetric: Metric | null = null;
let checks = 0;
let lastProgressAt = Date.now();
const evaluate = async (assignment: number[], workerIndex: number) => {
options.signal?.throwIfAborted();
const value = await options.evaluate(encode(assignment), workerIndex);
options.signal?.throwIfAborted();
if (!Number.isFinite(value.score) || !Number.isFinite(value.activationRateUp)) {
throw new Error('연결 카드 배치 평가값이 유효하지 않습니다.');
}
return { ...value, retained: current ? assignment.filter((index, slot) => index === current[slot]).length : 0 };
};
const accept = (assignment: number[], metric: Metric) => {
if (!bestMetric || better(metric, bestMetric)) {
bestAssignment = [...assignment];
bestMetric = metric;
}
checks += 1;
};
// Seeds provide an incumbent only; they never restrict the search space.
const initial = decode(initialPlacements);
const seeded = new Set<string>();
for (const seed of [initial, current]) {
if (!seed || !valid(seed)) continue;
const canonical = canonicalize(seed);
const key = canonical.join(',');
if (seeded.has(key)) continue;
seeded.add(key);
accept(canonical, await evaluate(canonical, 0));
}
let batch: number[][] = [];
const flush = async () => {
const pending = batch;
batch = [];
const metrics = await Promise.all(pending.map(evaluate));
options.signal?.throwIfAborted();
// Consume in traversal order, so task completion order cannot change tie results.
pending.forEach((candidate, index) => accept(candidate, metrics[index]));
};
const assignment = slots.map(() => -1);
const used = new Set<number>();
const requiredRemaining = slots.map((_, slot) => slots.slice(slot).filter((entry) => entry.required).length);
const visit = async (slot: number): Promise<void> => {
options.signal?.throwIfAborted();
if (Date.now() - lastProgressAt >= 50) {
await options.onProgress?.(checks);
lastProgressAt = Date.now();
options.signal?.throwIfAborted();
}
if (slot === slots.length) {
const canonical = canonicalize(assignment);
if (!seeded.has(canonical.join(','))) batch.push(canonical);
if (batch.length >= concurrency) await flush();
return;
}
// Only prune provably infeasible branches; never prune on an approximate score.
if (requiredRemaining[slot] > cards.length - used.size) return;
if (slots.slice(slot).some((entry) => entry.required && !entry.allowed.some((ok, index) => ok && !used.has(index)))) return;
if (!slots[slot].required) {
assignment[slot] = -1;
await visit(slot + 1);
}
const seenEffects = new Set<string>();
for (let index = 0; index < cards.length; index += 1) {
if (used.has(index) || !allowed(index, slot)) continue;
if (seenEffects.has(signatures[index])) continue;
seenEffects.add(signatures[index]);
assignment[slot] = index;
used.add(index);
await visit(slot + 1);
used.delete(index);
}
assignment[slot] = -1;
};
await visit(0);
await flush();
if (!bestAssignment) throw new Error('보유 연결 카드로 필수 범위 조건을 만족하는 배치를 만들 수 없습니다.');
await options.onProgress?.(checks);
options.signal?.throwIfAborted();
return encode(bestAssignment);
}
function songBonusModes(
effect: OptimizableBoardEffect | null,
ownerId: string,
@@ -281,9 +105,7 @@ function songBonusModes(
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state, using additive placement weights.
* This does not optimize a nonlinear whole-board score; use
* optimizeBoardConnectionPlacementsExact for that objective.
* changing the supplied node activation state.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
@@ -1,7 +1,8 @@
import { refineBoardConnectionSongBonus } from './board-connection-song-refinement.ts';
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateBoardEffects } from './board-effects.ts';
import { optimizeBoardConnectionCards, optimizeBoardConnectionPlacementsExact, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
function fixture(sisters = false) {
const singers = sisters ? ['chr-04016', 'chr-04017'] : ['a', 'b'];
@@ -43,7 +44,7 @@ for (const sisters of [false, true]) {
}).placements;
assert.equal(initial.c, undefined);
let progress = 0;
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards, initial, {
const refined = await refineBoardConnectionSongBonus(source, boards, cards, initial, {
evaluate: (p) => evaluate(p), onProgress: async (checks) => { progress = checks; }
});
assert.ok(refined.c?.['S-1']);
@@ -60,9 +61,9 @@ for (const sisters of [false, true]) {
test('상한으로 불필요해진 카드를 이미 채워진 다른 소켓의 카드와 교환한다', async () => {
const { source, boards, cards, evaluate, singers } = fixture();
cards.push({ id: 'weak', awakening: 0, connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }] }] });
cards.push({ id: 'weak', awakening: 0, connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }, { x: 0, y: 1 }] }] });
const initial = { [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' }, c: { 'S-1': 'weak' } };
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards, initial, { evaluate: (p) => evaluate(p) });
const refined = await refineBoardConnectionSongBonus(source, boards, cards, initial, { evaluate: (p) => evaluate(p) });
assert.notEqual(refined.c['S-1'], 'weak');
assert.ok(evaluate(refined).score > evaluate(initial).score);
const placed = Object.values(refined).flatMap(Object.values);
@@ -72,7 +73,7 @@ test('상한으로 불필요해진 카드를 이미 채워진 다른 소켓의
test('악곡 보너스 덮어쓰기는 노란 카드 효과를 무효화하고 동률이면 기존 배치를 유지한다', async () => {
const { source, boards, cards, evaluate, singers } = fixture();
const current = { [singers[0]]: { 'S-1': 'one' }, c: { 'S-1': 'two' } };
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards,
const refined = await refineBoardConnectionSongBonus(source, boards, cards,
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
{ evaluate: (placements) => evaluate(placements, 10), currentPlacements: current });
assert.deepEqual(refined, current);
@@ -81,22 +82,23 @@ test('악곡 보너스 덮어쓰기는 노란 카드 효과를 무효화하고
test('미사용 카드를 선택하되 파란 보드의 필수 범위와 개화 조건을 지킨다', async () => {
const source: BoardConnectionOptimizerSource = {
members: [{ id: 'a', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 1, y: 0 }, { groupId: 'B-large', x: 2, y: 0 }] },
models: { model: [{ groupId: 'S', x: 1, y: 0 }, { groupId: 'B-large', x: 2, y: 0 }, { groupId: 'Y-test', x: 1, y: 1 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
'Y-test': [{ characterIds: [], type: 'normal', effect: { id: 'music_singer_type-all', value: 5, type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type' } }],
'B-large': [{ characterIds: [], type: 'normal', grade: 2,
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 10 } }]
}
};
const cards = [
{ id: 'old', awakening: 0, connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }] }] },
{ id: 'invalid', awakening: 0, connect: [{ level: 1, effectPermilUp: 900, extent: [] }] },
{ id: 'invalid', awakening: 0, connect: [{ level: 1, effectPermilUp: 900, extent: [{ x: 0, y: 1 }] }] },
{ id: 'awakened', awakening: 5, connect: [
{ level: 1, effectPermilUp: 100, extent: [] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }, { x: 0, y: 1 }] }
] }
];
const refined = await optimizeBoardConnectionPlacementsExact(source, { a: ['S'] }, cards, { a: { S: 'old' } }, {
const refined = await refineBoardConnectionSongBonus(source, { a: ['S', 'Y-test'] }, cards, { a: { S: 'old' } }, {
requireBlueActivationCoverage: ['a'],
evaluate: (placements) => ({ score: placements.a?.S === 'invalid' ? 100 : placements.a?.S === 'awakened' ? 20 : 10, activationRateUp: 0 })
});
@@ -106,12 +108,12 @@ test('미사용 카드를 선택하되 파란 보드의 필수 범위와 개화
test('배치 전체 점수를 우선하고 동점일 때만 발동률을 비교한다', async () => {
const { source, boards, cards, singers } = fixture();
const initial = { [singers[0]]: { 'S-1': 'one' } };
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards.slice(0, 1), initial, {
const refined = await refineBoardConnectionSongBonus(source, boards, cards.slice(0, 1), initial, {
currentPlacements: initial,
evaluate: (placements) => ({ score: placements.c ? 20 : 10, activationRateUp: placements.c ? 0 : 50 })
});
assert.deepEqual(refined, { c: { 'S-1': 'one' } });
const tied = await optimizeBoardConnectionPlacementsExact(source, boards, cards.slice(0, 1), initial, {
const tied = await refineBoardConnectionSongBonus(source, boards, cards.slice(0, 1), initial, {
currentPlacements: initial,
evaluate: (placements) => ({ score: 10, activationRateUp: placements.c ? 50 : 0 })
});
@@ -121,8 +123,26 @@ test('배치 전체 점수를 우선하고 동점일 때만 발동률을 비교
test('현재 배치가 더 좋으면 단독 평가 결과 대신 현재 배치에서 개선을 시작한다', async () => {
const { source, boards, cards, singers, evaluate } = fixture();
const current = { [singers[0]]: { 'S-1': 'one' }, c: { 'S-1': 'two' } };
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards,
const refined = await refineBoardConnectionSongBonus(source, boards, cards,
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
{ currentPlacements: current, evaluate: (p) => evaluate(p) });
assert.deepEqual(refined, current);
});
test('54개 소켓과 카드 100장에서도 노란 소켓 주변만 정해진 횟수 안에서 비교한다', async () => {
const { source, boards, cards, evaluate, singers } = fixture();
for (let index = 0; index < 51; index += 1) {
const id = `green-${index}`;
source.members.push({ id, positionGroupId: 'c' });
boards[id] = ['S-1', 'G-1'];
}
for (let index = 2; index < 100; index += 1) cards.push({ ...cards[0], id: `card-${index}` });
const initial = { [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } };
let checks = 0;
const refined = await refineBoardConnectionSongBonus(source, boards, cards, initial, {
song: { singerType: 'group', singerCharacterIds: singers },
evaluate: (placements) => { checks += 1; return evaluate(placements); }
});
assert.ok(evaluate(refined).score >= evaluate(initial).score);
assert.ok(checks <= 1 + 3 * 2 * (54 + 100 + 1));
});
@@ -0,0 +1,107 @@
import { songBonusApplies, type BoardEffectSong } from './board-effects.ts';
import type { BoardConnectionOptimizerSource, OptimizableConnectionCard } from './board-connection-optimizer.ts';
type Placements = Record<string, Record<string, string>>;
type Metric = { score: number; activationRateUp: number };
/** Bounded local correction for song-bonus saturation, not a global search.
* Keep the original matching result and try moves/swaps involving song sockets.
* Every comparison scores the whole board, including caps and rounding.
*/
export async function refineBoardConnectionSongBonus(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
initial: Readonly<Record<string, Readonly<Record<string, string>>>>,
options: {
evaluate: (placements: Placements) => Metric;
currentPlacements?: Readonly<Record<string, Readonly<Record<string, string>>>>;
requireBlueActivationCoverage?: boolean | readonly string[];
song?: BoardEffectSong | null;
songBonusEnabled?: boolean;
onProgress?: (checks: number) => Promise<void>;
}
): Promise<Placements> {
const effect = (card: OptimizableConnectionCard) => card.connect.find((value) => value.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const cards = ownedCards.filter((card) => effect(card));
const node = (group: string, member: string) => source.nodeGroups[group]?.find((entry) => entry.characterIds.includes(member))
?? source.nodeGroups[group]?.find((entry) => !entry.characterIds.length);
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const active = new Set(memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && node(position.groupId, member.id)?.type === 'connection').map((socket) => {
const covers = (card: OptimizableConnectionCard, position: { x: number; y: number }) => effect(card)!.extent.some((offset) =>
socket.x + offset.x === position.x && socket.y + offset.y === position.y);
const required = (options.requireBlueActivationCoverage === true || (Array.isArray(options.requireBlueActivationCoverage) && options.requireBlueActivationCoverage.includes(member.id))) && socket.x !== 0
? positions.filter((position) => position.groupId.startsWith('B-') && position.x * socket.x > 0
&& (node(position.groupId, member.id)?.grade ?? 0) >= 2
&& node(position.groupId, member.id)?.effect?.type === 'live_active_skill_activation_probability_up_permil_up') : [];
const yellow = positions.filter((position) => {
const value = node(position.groupId, member.id)?.effect;
return options.songBonusEnabled !== false && active.has(position.groupId) && position.groupId.startsWith('Y-')
&& value?.trigger !== 'work' && value?.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type'
&& (!options.song || songBonusApplies(value, member.id, options.song));
});
return { member: member.id, socket: socket.groupId, required: required.length > 0,
allowed: cards.map((card) => required.every((position) => covers(card, position))),
focus: cards.some((card) => yellow.some((position) => covers(card, position))) };
});
});
const encode = (assignment: number[]) => {
const result: Placements = {};
assignment.forEach((index, slot) => { if (index >= 0) (result[slots[slot].member] ??= {})[slots[slot].socket] = cards[index].id; });
return result;
};
const decode = (placements: typeof initial) => slots.map((slot) => cards.findIndex((card) => card.id === placements[slot.member]?.[slot.socket]));
const valid = (assignment: number[]) => new Set(assignment.filter((index) => index >= 0)).size === assignment.filter((index) => index >= 0).length
&& assignment.every((index, slot) => index < 0 ? !slots[slot].required : slots[slot].allowed[index]);
const current = options.currentPlacements ? decode(options.currentPlacements) : null;
let checks = 0;
const evaluate = (assignment: number[]) => {
const metric = options.evaluate(encode(assignment));
if (!Number.isFinite(metric.score) || !Number.isFinite(metric.activationRateUp)) throw new Error('연결 카드 배치 평가값이 유효하지 않습니다.');
checks += 1;
return { ...metric, retained: current ? assignment.filter((index, slot) => index === current[slot]).length : 0 };
};
type Ranked = ReturnType<typeof evaluate>;
const better = (a: Ranked, b: Ranked) => a.score > b.score || (a.score === b.score
&& (a.activationRateUp > b.activationRateUp || (a.activationRateUp === b.activationRateUp && a.retained > b.retained)));
let best = decode(initial);
if (!valid(best)) throw new Error('연결 카드 초기 배치가 필수 범위 조건을 충족하지 않습니다.');
let metric = evaluate(best);
if (current && valid(current)) { const value = evaluate(current); if (better(value, metric)) { best = current; metric = value; } }
const focus = slots.flatMap((slot, index) => slot.focus ? [index] : []);
// At most three passes: O(3 * yellow sockets * (all sockets + owned cards)).
for (let pass = 0; pass < 3 && focus.length; pass += 1) {
let next = best;
let nextMetric = metric;
const seen = new Set<string>();
const inspect = async (candidate: number[]) => {
const key = candidate.join(',');
if (seen.has(key) || !valid(candidate)) return;
seen.add(key);
const value = evaluate(candidate);
if (better(value, nextMetric)) { next = candidate; nextMetric = value; }
if (checks % 32 === 0) await options.onProgress?.(checks);
};
for (const slot of focus) {
for (let other = 0; other < slots.length; other += 1) {
if (other === slot) continue;
const candidate = [...best];
[candidate[slot], candidate[other]] = [candidate[other], candidate[slot]];
await inspect(candidate);
}
for (let index = -1; index < cards.length; index += 1) {
if (index >= 0 && best.includes(index)) continue;
const candidate = [...best]; candidate[slot] = index;
await inspect(candidate);
}
}
if (next === best) break;
best = next; metric = nextMetric;
}
await options.onProgress?.(checks);
return encode(best);
}
+1 -1
View File
@@ -71,7 +71,7 @@ function affectedStats(type: string): readonly UnitStat[] {
return [];
}
function songBonusApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
export function songBonusApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
if (!effect.id) return false;
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (expectedType === 'all') return true;
+78 -80
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { refineBoardConnectionSongBonus } from '$lib/engine/board-connection-song-refinement';
import { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
import { onMount, tick } from 'svelte';
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
@@ -20,8 +21,8 @@
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
import SongPicker from '$lib/components/SongPicker.svelte';
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionPlacementsExact, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { ExactScoreOptions, OptimizerCard, CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -196,10 +197,6 @@
let errorMessage = '';
let analysisReady = false;
let connectionOptimizationRunning = false;
let connectionMaximizeCompute = false;
let connectionReportedCores = 1;
let connectionOptimizationController: AbortController | null = null;
let connectionOptimizationInputKey = '';
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
@@ -292,9 +289,6 @@
}
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
$: if (connectionOptimizationRunning && connectionOptimizationInputKey !== currentAnalysisKey) {
connectionOptimizationController?.abort(new Error('설정이 변경되어 탐색을 중단했습니다. 다시 분석한 뒤 최적화해 주세요.'));
}
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
@@ -311,8 +305,6 @@
});
onMount(() => {
const cores = navigator.hardwareConcurrency || 4;
connectionReportedCores = Number.isFinite(cores) ? Math.max(1, Math.floor(cores)) : 1;
let mounted = true;
void synchronizeReleaseClock().then((clock) => {
if (!mounted) return;
@@ -427,7 +419,6 @@
window.clearInterval(releaseClock);
window.removeEventListener('storage', syncStorage);
worker?.terminate();
connectionOptimizationController?.abort(new Error('페이지를 떠나 탐색을 중단했습니다.'));
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
};
});
@@ -671,80 +662,100 @@
}
async function optimizeConnectionPlacements(): Promise<void> {
if (connectionOptimizationRunning || resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
if (resultStale || !result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
const controller = new AbortController();
connectionOptimizationController = controller;
connectionOptimizationInputKey = currentAnalysisKey;
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
const connectionWorkers = new Map<number, Worker>();
const concurrency = connectionMaximizeCompute ? connectionReportedCores : 1;
try {
const chart = await loadExactSongChart(chartSummary.path);
controller.signal.throwIfAborted();
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
).theoreticalMaxScore;
const emptyScore = referenceScore(emptyContext);
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
const currentPlacements = structuredClone(boardCards);
const activeBoards = structuredClone(effectiveMemberBoards);
const candidateCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
const evaluateContext = (context: ReturnType<typeof connectionScoreContext>, workerIndex = 0) => new Promise<ExactOptimizationResult>((resolve, reject) => {
controller.signal.throwIfAborted();
const evaluatorWorker = connectionWorkers.get(workerIndex) ?? createScoreOptimizerWorker();
connectionWorkers.set(workerIndex, evaluatorWorker);
comparisonWorkers.add(evaluatorWorker);
const abort = () => { evaluatorWorker.terminate(); reject(controller.signal.reason); };
controller.signal.throwIfAborted();
controller.signal.addEventListener('abort', abort, { once: true });
const finish = () => controller.signal.removeEventListener('abort', abort);
evaluatorWorker.onerror = () => { finish(); reject(new Error('배치별 점수 최적화 중 오류가 발생했습니다.')); };
evaluatorWorker.onmessage = (event) => {
if (event.data.type === 'result') { finish(); resolve(event.data.result); }
if (event.data.type === 'error') { finish(); reject(new Error(event.data.message)); }
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
};
evaluatorWorker.postMessage({ type: 'optimize-exact', cards: context.cards, chart,
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent });
placementMetricCache.set(key, metrics);
return metrics;
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
});
const evaluationCache = new Map<string, Promise<{ score: number; activationRateUp: number }>>();
connectionOptimizationProgress = { stage: '전역 최적 배치 탐색 중 · 완료 전에는 적용할 수 없습니다', completed: 0, total: 0 };
const exactPlacements = await optimizeBoardConnectionPlacementsExact(boardSummary, activeBoards, candidateCards, currentPlacements, {
currentPlacements,
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
let completedPlacementChecks = 0;
for (const card of candidateCards) {
for (const slot of activeConnectionSlots) {
placementMetrics(card, slot.memberId, slot.socketId);
completedPlacementChecks += 1;
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
}
}
connectionOptimizationProgress = { stage: '추천 배치 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
signal: controller.signal,
concurrency,
groupEquivalentEffects: true,
evaluate: async (placements, workerIndex) => {
controller.signal.throwIfAborted();
preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
optimized.placements = await refineBoardConnectionSongBonus(boardSummary, effectiveMemberBoards, candidateCards, optimized.placements, {
currentPlacements: boardCards,
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
song: chartSummary,
songBonusEnabled: songScoreBonusOverride === null,
evaluate: (placements) => {
const context = connectionScoreContext(placements, chartSummary);
const key = JSON.stringify([context.cards,
perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
context.boardEffects.directSongScoreBonusPercent, totalActivationRateUp(context)]);
const cached = evaluationCache.get(key);
if (cached) return cached;
const pending = evaluateContext(context, workerIndex).then((evaluated) => ({
score: evaluated.theoreticalMaxScore, activationRateUp: totalActivationRateUp(context)
}));
if (evaluationCache.size >= 2048) evaluationCache.clear();
evaluationCache.set(key, pending);
return pending;
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
},
onProgress: async (checks) => {
connectionOptimizationProgress = { stage: '전역 최적 배치 탐색 중 · ' + checks.toLocaleString('ko-KR') + '개 확인 · 최대 ' + concurrency + '개 동시 계산', completed: 0, total: 0 };
connectionOptimizationProgress = { stage: '노란 보너스 상한 보정 · ' + checks.toLocaleString('ko-KR') + '회 비교', completed: 0, total: 0 };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
});
controller.signal.throwIfAborted();
connectionOptimizationProgress = { stage: '전역 탐색 완료 · 최종 결과 준비 중', completed: 0, total: 0 };
const afterContext = connectionScoreContext(exactPlacements, chartSummary);
const after = await evaluateContext(afterContext);
controller.signal.throwIfAborted();
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
@@ -754,7 +765,7 @@
}
const changedAssignments = boardMembers.flatMap((member) => {
const before = boardCards[member.id] ?? {};
const afterPlacements = exactPlacements[member.id] ?? {};
const afterPlacements = optimized.placements[member.id] ?? {};
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
.map((socketId) => ({
@@ -765,7 +776,7 @@
}));
});
connectionOptimizationProposal = {
placements: exactPlacements,
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
afterExpected: after.expectedScore,
@@ -777,9 +788,6 @@
} catch (error) {
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
} finally {
controller.abort(new Error('연결 카드 탐색을 종료했습니다.'));
for (const connectionWorker of connectionWorkers.values()) { connectionWorker.terminate(); comparisonWorkers.delete(connectionWorker); }
connectionOptimizationController = null;
connectionOptimizationRunning = false;
connectionOptimizationProgress = null;
}
@@ -1155,19 +1163,12 @@
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 편성 멤버의 파란 영역 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 편성 밖 멤버의 보드도 현재 편성의 점수에 기여하는 효과는 최적화에 반영합니다. 빠른 배정 후 노란 보너스 상한에 따른 이동·교환을 최대 3차례 보정하며, 전역 최적해를 보장하지 않습니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 UP 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationRunning}<button type="button" on:click={() => connectionOptimizationController?.abort(new Error('탐색을 중단했습니다. 최적해가 확정되지 않아 배치를 변경하지 않았습니다.'))}>탐색 중단</button>{/if}
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
<label class="connection-compute-option">
<input type="checkbox" bind:checked={connectionMaximizeCompute} disabled={connectionOptimizationRunning} aria-describedby="connection-compute-help">
<strong>최대 컴퓨팅 자원 활용</strong>
<span>{connectionMaximizeCompute ? `최대 ${connectionReportedCores}개 동시 계산` : '한 번에 1개 계산'}</span>
</label>
<p id="connection-compute-help" class="board-guide">기기의 논리 코어 수에 맞춰 연결 카드 배치를 동시에 계산합니다. 켜거나 꺼도 최적화 기준과 결과는 같습니다.</p>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
@@ -1177,7 +1178,6 @@
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
<p>현재 편성·보드·필수 범위 조건에서 전역 최적 배치 탐색을 완료했습니다.</p>
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
@@ -1194,7 +1194,7 @@
</div>
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
{:else}
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
{/if}
</div>
{/if}
@@ -1395,8 +1395,6 @@
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
.connection-compute-option { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.connection-compute-option span { color: var(--muted); font-size: 0.85rem; }
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }