fix: restore fast connection matching with yellow bonus correction
Publish GitHub Pages output / publish (push) Successful in 32s
Publish GitHub Pages output / publish (push) Successful in 32s
This commit is contained in:
+4
-6
@@ -1,16 +1,14 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-17T19:00:14+09:00
|
updatedAt: 2026-09-17T20:37:06+09:00
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026년 9월 17일
|
## 2026년 9월 17일
|
||||||
|
|
||||||
### 연결 카드 전역 최적화
|
### 노란 보드 연결 카드 배치 보정
|
||||||
|
|
||||||
`FIXED` 연결 카드 최적화가 현재 편성·보드·필수 범위 조건에서 가능한 모든 배치를 비교하도록 변경했습니다. 배치마다 악곡 보너스 10% 상한을 반영하고 스페셜 순서와 쿨타임을 다시 최적화해 이론상 최고점이 가장 높은 배치를 찾습니다. 동점이면 액티브 발동률 UP 합계, 기존 배치 유지 순으로 비교합니다.
|
`FIXED` 악곡 보너스 10% 상한에 걸린 노란 보드 카드의 효율을 과대평가하던 배치를 보정합니다. 기존 빠른 배정 방식으로 추천한 뒤, 노란 보드 카드의 이동·교환을 전체 배치 점수로 최대 3차례 비교합니다. 후와모코 가창 조건, 직접 지정한 악곡 보너스, 파란 보드 필수 범위 조건을 함께 반영합니다.
|
||||||
|
|
||||||
전체 탐색을 마친 결과만 적용할 수 있습니다. 탐색 중단 버튼을 추가했으며, 탐색 도중 설정을 변경하면 중단하고 기존 배치를 유지합니다.
|
전체 배치 전수 탐색은 사용하지 않으며, 전역 최적해를 보장하는 기능은 아닙니다. 최종 검증에서 기존 점수보다 낮은 배치는 적용하지 않습니다.
|
||||||
|
|
||||||
`IMPROVED` 같은 연결 효과의 카드 교환과 동일한 점수 계산 입력의 중복 계산을 줄였습니다. ‘최대 컴퓨팅 자원 활용’을 켜면 기기의 논리 코어 수에 맞춰 여러 배치를 동시에 계산합니다. 병렬 계산 여부와 관계없이 최적화 기준과 결과는 같습니다.
|
|
||||||
|
|
||||||
## 2026년 9월 14일
|
## 2026년 9월 14일
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -88,182 +88,6 @@ function selectedEffect(card: OptimizableConnectionCard) {
|
|||||||
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
|
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(
|
function songBonusModes(
|
||||||
effect: OptimizableBoardEffect | null,
|
effect: OptimizableBoardEffect | null,
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
@@ -281,9 +105,7 @@ function songBonusModes(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reassigns owned connection cards across active connection sockets without
|
* Reassigns owned connection cards across active connection sockets without
|
||||||
* changing the supplied node activation state, using additive placement weights.
|
* changing the supplied node activation state.
|
||||||
* This does not optimize a nonlinear whole-board score; use
|
|
||||||
* optimizeBoardConnectionPlacementsExact for that objective.
|
|
||||||
*/
|
*/
|
||||||
export function optimizeBoardConnectionCards(
|
export function optimizeBoardConnectionCards(
|
||||||
source: BoardConnectionOptimizerSource,
|
source: BoardConnectionOptimizerSource,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { refineBoardConnectionSongBonus } from './board-connection-song-refinement.ts';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { calculateBoardEffects } from './board-effects.ts';
|
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) {
|
function fixture(sisters = false) {
|
||||||
const singers = sisters ? ['chr-04016', 'chr-04017'] : ['a', 'b'];
|
const singers = sisters ? ['chr-04016', 'chr-04017'] : ['a', 'b'];
|
||||||
@@ -43,7 +44,7 @@ for (const sisters of [false, true]) {
|
|||||||
}).placements;
|
}).placements;
|
||||||
assert.equal(initial.c, undefined);
|
assert.equal(initial.c, undefined);
|
||||||
let progress = 0;
|
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; }
|
evaluate: (p) => evaluate(p), onProgress: async (checks) => { progress = checks; }
|
||||||
});
|
});
|
||||||
assert.ok(refined.c?.['S-1']);
|
assert.ok(refined.c?.['S-1']);
|
||||||
@@ -60,9 +61,9 @@ for (const sisters of [false, true]) {
|
|||||||
|
|
||||||
test('상한으로 불필요해진 카드를 이미 채워진 다른 소켓의 카드와 교환한다', async () => {
|
test('상한으로 불필요해진 카드를 이미 채워진 다른 소켓의 카드와 교환한다', async () => {
|
||||||
const { source, boards, cards, evaluate, singers } = fixture();
|
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 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.notEqual(refined.c['S-1'], 'weak');
|
||||||
assert.ok(evaluate(refined).score > evaluate(initial).score);
|
assert.ok(evaluate(refined).score > evaluate(initial).score);
|
||||||
const placed = Object.values(refined).flatMap(Object.values);
|
const placed = Object.values(refined).flatMap(Object.values);
|
||||||
@@ -72,7 +73,7 @@ test('상한으로 불필요해진 카드를 이미 채워진 다른 소켓의
|
|||||||
test('악곡 보너스 덮어쓰기는 노란 카드 효과를 무효화하고 동률이면 기존 배치를 유지한다', async () => {
|
test('악곡 보너스 덮어쓰기는 노란 카드 효과를 무효화하고 동률이면 기존 배치를 유지한다', async () => {
|
||||||
const { source, boards, cards, evaluate, singers } = fixture();
|
const { source, boards, cards, evaluate, singers } = fixture();
|
||||||
const current = { [singers[0]]: { 'S-1': 'one' }, c: { 'S-1': 'two' } };
|
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' } },
|
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
|
||||||
{ evaluate: (placements) => evaluate(placements, 10), currentPlacements: current });
|
{ evaluate: (placements) => evaluate(placements, 10), currentPlacements: current });
|
||||||
assert.deepEqual(refined, current);
|
assert.deepEqual(refined, current);
|
||||||
@@ -81,22 +82,23 @@ test('악곡 보너스 덮어쓰기는 노란 카드 효과를 무효화하고
|
|||||||
test('미사용 카드를 선택하되 파란 보드의 필수 범위와 개화 조건을 지킨다', async () => {
|
test('미사용 카드를 선택하되 파란 보드의 필수 범위와 개화 조건을 지킨다', async () => {
|
||||||
const source: BoardConnectionOptimizerSource = {
|
const source: BoardConnectionOptimizerSource = {
|
||||||
members: [{ id: 'a', positionGroupId: 'model' }],
|
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: {
|
nodeGroups: {
|
||||||
S: [{ characterIds: [], type: 'connection', effect: null }],
|
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,
|
'B-large': [{ characterIds: [], type: 'normal', grade: 2,
|
||||||
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 10 } }]
|
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 10 } }]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const cards = [
|
const cards = [
|
||||||
{ id: 'old', awakening: 0, connect: [{ level: 1, effectPermilUp: 100, extent: [{ x: 1, y: 0 }] }] },
|
{ 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: [
|
{ id: 'awakened', awakening: 5, connect: [
|
||||||
{ level: 1, effectPermilUp: 100, extent: [] },
|
{ 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'],
|
requireBlueActivationCoverage: ['a'],
|
||||||
evaluate: (placements) => ({ score: placements.a?.S === 'invalid' ? 100 : placements.a?.S === 'awakened' ? 20 : 10, activationRateUp: 0 })
|
evaluate: (placements) => ({ score: placements.a?.S === 'invalid' ? 100 : placements.a?.S === 'awakened' ? 20 : 10, activationRateUp: 0 })
|
||||||
});
|
});
|
||||||
@@ -106,12 +108,12 @@ test('미사용 카드를 선택하되 파란 보드의 필수 범위와 개화
|
|||||||
test('배치 전체 점수를 우선하고 동점일 때만 발동률을 비교한다', async () => {
|
test('배치 전체 점수를 우선하고 동점일 때만 발동률을 비교한다', async () => {
|
||||||
const { source, boards, cards, singers } = fixture();
|
const { source, boards, cards, singers } = fixture();
|
||||||
const initial = { [singers[0]]: { 'S-1': 'one' } };
|
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,
|
currentPlacements: initial,
|
||||||
evaluate: (placements) => ({ score: placements.c ? 20 : 10, activationRateUp: placements.c ? 0 : 50 })
|
evaluate: (placements) => ({ score: placements.c ? 20 : 10, activationRateUp: placements.c ? 0 : 50 })
|
||||||
});
|
});
|
||||||
assert.deepEqual(refined, { c: { 'S-1': 'one' } });
|
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,
|
currentPlacements: initial,
|
||||||
evaluate: (placements) => ({ score: 10, activationRateUp: placements.c ? 50 : 0 })
|
evaluate: (placements) => ({ score: 10, activationRateUp: placements.c ? 50 : 0 })
|
||||||
});
|
});
|
||||||
@@ -121,8 +123,26 @@ test('배치 전체 점수를 우선하고 동점일 때만 발동률을 비교
|
|||||||
test('현재 배치가 더 좋으면 단독 평가 결과 대신 현재 배치에서 개선을 시작한다', async () => {
|
test('현재 배치가 더 좋으면 단독 평가 결과 대신 현재 배치에서 개선을 시작한다', async () => {
|
||||||
const { source, boards, cards, singers, evaluate } = fixture();
|
const { source, boards, cards, singers, evaluate } = fixture();
|
||||||
const current = { [singers[0]]: { 'S-1': 'one' }, c: { 'S-1': 'two' } };
|
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' } },
|
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
|
||||||
{ currentPlacements: current, evaluate: (p) => evaluate(p) });
|
{ currentPlacements: current, evaluate: (p) => evaluate(p) });
|
||||||
assert.deepEqual(refined, current);
|
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);
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ function affectedStats(type: string): readonly UnitStat[] {
|
|||||||
return [];
|
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;
|
if (!effect.id) return false;
|
||||||
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
|
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
|
||||||
if (expectedType === 'all') return true;
|
if (expectedType === 'all') return true;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { ANALYSIS_TRANSFER_KEY, parseAnalysisTransfer, transferredAnalysisKey } from '$lib/models/analysis-transfer.ts';
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
|
import { activeWindowKey, calculateActiveImpacts } from '$lib/engine/active-impact.ts';
|
||||||
@@ -20,8 +21,8 @@
|
|||||||
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
|
import ScoreTimelineChart from '$lib/components/ScoreTimelineChart.svelte';
|
||||||
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
|
import { calculateMaximumScoreProbabilityTimeline } from '$lib/engine/score-probability.ts';
|
||||||
import SongPicker from '$lib/components/SongPicker.svelte';
|
import SongPicker from '$lib/components/SongPicker.svelte';
|
||||||
import { buildExactSkillWindows, calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, finalNoteScore, memoryParameterPermil, optimizeBoardConnectionPlacementsExact, perfectNoteScoreFromPower, toOptimizerCard } 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, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } 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 { 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';
|
||||||
|
|
||||||
@@ -196,10 +197,6 @@
|
|||||||
let errorMessage = '';
|
let errorMessage = '';
|
||||||
let analysisReady = false;
|
let analysisReady = false;
|
||||||
let connectionOptimizationRunning = 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 connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
|
||||||
let connectionOptimizationError = '';
|
let connectionOptimizationError = '';
|
||||||
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
|
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
|
||||||
@@ -292,9 +289,6 @@
|
|||||||
}
|
}
|
||||||
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
|
$: currentAnalysisKey = JSON.stringify({ songId: selectedSongId, difficulty, input: ratingInputKey });
|
||||||
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
|
$: resultStale = mode === 'analysis' && Boolean(result) && resultInputKey !== currentAnalysisKey;
|
||||||
$: if (connectionOptimizationRunning && connectionOptimizationInputKey !== currentAnalysisKey) {
|
|
||||||
connectionOptimizationController?.abort(new Error('설정이 변경되어 탐색을 중단했습니다. 다시 분석한 뒤 최적화해 주세요.'));
|
|
||||||
}
|
|
||||||
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
|
$: pendingStale = mode === 'analysis' && running && pendingInputKey !== currentAnalysisKey;
|
||||||
$: if (analysisReady) saveAnalysisState({
|
$: if (analysisReady) saveAnalysisState({
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
@@ -311,8 +305,6 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const cores = navigator.hardwareConcurrency || 4;
|
|
||||||
connectionReportedCores = Number.isFinite(cores) ? Math.max(1, Math.floor(cores)) : 1;
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
void synchronizeReleaseClock().then((clock) => {
|
void synchronizeReleaseClock().then((clock) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -427,7 +419,6 @@
|
|||||||
window.clearInterval(releaseClock);
|
window.clearInterval(releaseClock);
|
||||||
window.removeEventListener('storage', syncStorage);
|
window.removeEventListener('storage', syncStorage);
|
||||||
worker?.terminate();
|
worker?.terminate();
|
||||||
connectionOptimizationController?.abort(new Error('페이지를 떠나 탐색을 중단했습니다.'));
|
|
||||||
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
|
for (const comparisonWorker of comparisonWorkers) comparisonWorker.terminate();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -671,80 +662,100 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function optimizeConnectionPlacements(): Promise<void> {
|
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 analysisResult = result;
|
||||||
const chartSummary = selectedChart;
|
const chartSummary = selectedChart;
|
||||||
const controller = new AbortController();
|
|
||||||
connectionOptimizationController = controller;
|
|
||||||
connectionOptimizationInputKey = currentAnalysisKey;
|
|
||||||
connectionOptimizationRunning = true;
|
connectionOptimizationRunning = true;
|
||||||
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
|
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
|
||||||
connectionOptimizationError = '';
|
connectionOptimizationError = '';
|
||||||
connectionOptimizationProposal = null;
|
connectionOptimizationProposal = null;
|
||||||
const connectionWorkers = new Map<number, Worker>();
|
|
||||||
const concurrency = connectionMaximizeCompute ? connectionReportedCores : 1;
|
|
||||||
try {
|
try {
|
||||||
const chart = await loadExactSongChart(chartSummary.path);
|
const chart = await loadExactSongChart(chartSummary.path);
|
||||||
controller.signal.throwIfAborted();
|
|
||||||
const beforeContext = connectionScoreContext(boardCards, chartSummary);
|
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 beforeActivationRateUp = totalActivationRateUp(beforeContext);
|
||||||
const currentPlacements = structuredClone(boardCards);
|
|
||||||
const activeBoards = structuredClone(effectiveMemberBoards);
|
|
||||||
const candidateCards = cardSummary.cards
|
const candidateCards = cardSummary.cards
|
||||||
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
|
.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 }));
|
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
||||||
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
|
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
|
||||||
const evaluateContext = (context: ReturnType<typeof connectionScoreContext>, workerIndex = 0) => new Promise<ExactOptimizationResult>((resolve, reject) => {
|
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
|
||||||
controller.signal.throwIfAborted();
|
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
|
||||||
const evaluatorWorker = connectionWorkers.get(workerIndex) ?? createScoreOptimizerWorker();
|
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
|
||||||
connectionWorkers.set(workerIndex, evaluatorWorker);
|
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
|
||||||
comparisonWorkers.add(evaluatorWorker);
|
const key = `${memberId}:${socketId}:${effectKey}`;
|
||||||
const abort = () => { evaluatorWorker.terminate(); reject(controller.signal.reason); };
|
const cached = placementMetricCache.get(key);
|
||||||
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)); }
|
|
||||||
};
|
|
||||||
evaluatorWorker.postMessage({ type: 'optimize-exact', cards: context.cards, chart,
|
|
||||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
|
||||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent });
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
|
|
||||||
signal: controller.signal,
|
|
||||||
concurrency,
|
|
||||||
groupEquivalentEffects: true,
|
|
||||||
evaluate: async (placements, workerIndex) => {
|
|
||||||
controller.signal.throwIfAborted();
|
|
||||||
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;
|
if (cached) return cached;
|
||||||
const pending = evaluateContext(context, workerIndex).then((evaluated) => ({
|
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
|
||||||
score: evaluated.theoreticalMaxScore, activationRateUp: totalActivationRateUp(context)
|
const metrics = {
|
||||||
}));
|
score: referenceScore(context) - emptyScore,
|
||||||
if (evaluationCache.size >= 2048) evaluationCache.clear();
|
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
|
||||||
evaluationCache.set(key, pending);
|
};
|
||||||
return pending;
|
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 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),
|
||||||
|
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);
|
||||||
|
return { score: referenceScore(context), activationRateUp: totalActivationRateUp(context) };
|
||||||
},
|
},
|
||||||
onProgress: async (checks) => {
|
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()));
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
controller.signal.throwIfAborted();
|
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||||
connectionOptimizationProgress = { stage: '전역 탐색 완료 · 최종 결과 준비 중', completed: 0, total: 0 };
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
const afterContext = connectionScoreContext(exactPlacements, chartSummary);
|
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
|
||||||
const after = await evaluateContext(afterContext);
|
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
|
||||||
controller.signal.throwIfAborted();
|
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
|
||||||
|
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
||||||
|
});
|
||||||
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
||||||
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
|
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
|
||||||
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||||
@@ -754,7 +765,7 @@
|
|||||||
}
|
}
|
||||||
const changedAssignments = boardMembers.flatMap((member) => {
|
const changedAssignments = boardMembers.flatMap((member) => {
|
||||||
const before = boardCards[member.id] ?? {};
|
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)])]
|
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
|
||||||
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
|
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
|
||||||
.map((socketId) => ({
|
.map((socketId) => ({
|
||||||
@@ -765,7 +776,7 @@
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
connectionOptimizationProposal = {
|
connectionOptimizationProposal = {
|
||||||
placements: exactPlacements,
|
placements: optimized.placements,
|
||||||
assignments: changedAssignments,
|
assignments: changedAssignments,
|
||||||
beforeExpected: analysisResult.expectedScore,
|
beforeExpected: analysisResult.expectedScore,
|
||||||
afterExpected: after.expectedScore,
|
afterExpected: after.expectedScore,
|
||||||
@@ -777,9 +788,6 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
|
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
|
||||||
} finally {
|
} finally {
|
||||||
controller.abort(new Error('연결 카드 탐색을 종료했습니다.'));
|
|
||||||
for (const connectionWorker of connectionWorkers.values()) { connectionWorker.terminate(); comparisonWorkers.delete(connectionWorker); }
|
|
||||||
connectionOptimizationController = null;
|
|
||||||
connectionOptimizationRunning = false;
|
connectionOptimizationRunning = false;
|
||||||
connectionOptimizationProgress = null;
|
connectionOptimizationProgress = null;
|
||||||
}
|
}
|
||||||
@@ -1155,19 +1163,12 @@
|
|||||||
|
|
||||||
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
||||||
<div class="connection-optimizer-heading">
|
<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">
|
<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}
|
{#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>
|
<button type="button" class="primary" disabled={resultStale || running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#if connectionOptimizationProgress}
|
||||||
<div class="connection-optimizer-progress" aria-live="polite">
|
<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>
|
<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 connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
|
||||||
{#if connectionOptimizationProposal}
|
{#if connectionOptimizationProposal}
|
||||||
<div class="connection-proposal" aria-live="polite">
|
<div class="connection-proposal" aria-live="polite">
|
||||||
<p>현재 편성·보드·필수 범위 조건에서 전역 최적 배치 탐색을 완료했습니다.</p>
|
|
||||||
<div class="connection-score-comparison">
|
<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.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>
|
<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>
|
</div>
|
||||||
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
|
<button type="button" class="apply-connection" disabled={resultStale || running} on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
|
<p class="connection-no-changes">이번 보정에서는 기존 배치보다 좋은 배치를 찾지 못했습니다.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1395,8 +1395,6 @@
|
|||||||
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
.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 h3 { margin: 0; color: #dce8e0; font-size: 17px; }
|
||||||
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
|
.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-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 { 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; }
|
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
|
||||||
|
|||||||
Reference in New Issue
Block a user