fix: optimize connection card placements globally with parallel evaluation
Publish GitHub Pages output / publish (push) Successful in 32s

This commit is contained in:
2026-09-17 19:05:30 +09:00
parent 238b9a9913
commit 9ba5f73500
5 changed files with 601 additions and 68 deletions
@@ -0,0 +1,200 @@
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);
});
+179 -1
View File
@@ -88,6 +88,182 @@ 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,
@@ -105,7 +281,9 @@ function songBonusModes(
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state.
* changing the supplied node activation state, using additive placement weights.
* This does not optimize a nonlinear whole-board score; use
* optimizeBoardConnectionPlacementsExact for that objective.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
@@ -0,0 +1,128 @@
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';
function fixture(sisters = false) {
const singers = sisters ? ['chr-04016', 'chr-04017'] : ['a', 'b'];
const members = [...singers, 'c'];
const source = {
members: members.map((id) => ({ id, positionGroupId: id })),
models: Object.fromEntries(members.map((id) => [id, [
{ groupId: 'S-1', x: 0, y: 0 }, { groupId: id === 'c' ? 'G-1' : 'Y-1', x: 1, y: 0 }
]])),
nodeGroups: {
'S-1': [{ characterIds: [], type: 'connection', effect: null }],
'Y-1': [{ characterIds: [], type: 'normal', effect: {
id: sisters ? 'music_singer_type-solo-fuwamoko' : 'music_singer_type-group',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 45
} }],
'G-1': [{ characterIds: [], type: 'normal', effect: { type: 'performance_up', value: 60 } }]
}
} satisfies BoardConnectionOptimizerSource;
const boards = Object.fromEntries(members.map((id) => [id, ['S-1', id === 'c' ? 'G-1' : 'Y-1']]));
const cards = ['one', 'two'].map((id) => ({ id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }] }));
const evaluate = (placements: Record<string, Record<string, string>>, override?: number) => {
const effects = calculateBoardEffects(source, boards,
[{ id: 'unit', characterId: 'c', type: '', generation: '' }], '',
{ singerType: 'group', singerCharacterIds: singers }, { placements, cards },
override === undefined ? undefined : { directSongScoreBonusPercent: override });
return { score: (10000 + effects.flatByCardId.unit.performance) * (1 + effects.directSongScoreBonusPercent / 100),
activationRateUp: 0 };
};
return { source, boards, cards, evaluate, singers };
}
for (const sisters of [false, true]) {
test(`${sisters ? '후와모코' : '유닛'} 보너스 상한을 넘기는 노란 카드를 다른 보드로 이동한다`, async () => {
const { source, boards, cards, evaluate, singers } = fixture(sisters);
const baseline = evaluate({}).score;
const initial = optimizeBoardConnectionCards(source, boards, cards, {
placementWeight: (card, member, socket) => evaluate({ [member]: { [socket]: card.id } }).score - baseline
}).placements;
assert.equal(initial.c, undefined);
let progress = 0;
const refined = await optimizeBoardConnectionPlacementsExact(source, boards, cards, initial, {
evaluate: (p) => evaluate(p), onProgress: async (checks) => { progress = checks; }
});
assert.ok(refined.c?.['S-1']);
assert.equal(singers.filter((id) => refined[id]?.['S-1']).length, 1);
assert.ok(evaluate(refined).score > evaluate(initial).score);
// Exhaust all legal assignments of these two cards to prove the fixture optimum.
const members = [...singers, 'c'];
const scores = members.flatMap((a) => members.filter((b) => b !== a).map((b) =>
evaluate({ [a]: { 'S-1': 'one' }, [b]: { 'S-1': 'two' } }).score));
assert.equal(evaluate(refined).score, Math.max(...scores));
assert.ok(progress > 0);
});
}
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 }] }] });
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) });
assert.notEqual(refined.c['S-1'], 'weak');
assert.ok(evaluate(refined).score > evaluate(initial).score);
const placed = Object.values(refined).flatMap(Object.values);
assert.equal(new Set(placed).size, placed.length);
});
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,
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
{ evaluate: (placements) => evaluate(placements, 10), currentPlacements: current });
assert.deepEqual(refined, current);
});
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 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
'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: 'awakened', awakening: 5, connect: [
{ level: 1, effectPermilUp: 100, extent: [] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
] }
];
const refined = await optimizeBoardConnectionPlacementsExact(source, { a: ['S'] }, cards, { a: { S: 'old' } }, {
requireBlueActivationCoverage: ['a'],
evaluate: (placements) => ({ score: placements.a?.S === 'invalid' ? 100 : placements.a?.S === 'awakened' ? 20 : 10, activationRateUp: 0 })
});
assert.deepEqual(refined, { a: { S: 'awakened' } });
});
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, {
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, {
currentPlacements: initial,
evaluate: (placements) => ({ score: 10, activationRateUp: placements.c ? 50 : 0 })
});
assert.deepEqual(tied, { c: { 'S-1': 'one' } });
});
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,
{ [singers[0]]: { 'S-1': 'one' }, [singers[1]]: { 'S-1': 'two' } },
{ currentPlacements: current, evaluate: (p) => evaluate(p) });
assert.deepEqual(refined, current);
});