fix: preserve tied connection placements and show card rarity
Publish GitHub Pages output / publish (push) Successful in 43s

This commit is contained in:
2026-09-06 18:28:53 +09:00
parent 476a25caef
commit 9d2d8963b5
5 changed files with 93 additions and 12 deletions
+9 -1
View File
@@ -1,9 +1,17 @@
---
updatedAt: 2026-09-06T10:13:00+09:00
updatedAt: 2026-09-06T18:13:00+09:00
---
## 2026년 9월 6일
### 연결 카드 최적화의 동점 배치 유지
`FIXED` 일부 보드에서 파란 영역을 반대쪽으로 판정하던 오류를 수정했습니다. 액티브 발동률 UP 합계와 예상 점수가 모두 같으면 기존 연결 카드 배치를 우선 유지해 불필요한 교환을 줄입니다.
### 연결 카드 최적화 성급 표시
`IMPROVED` 배치 분석의 연결 카드 최적화에서 변경 전·후 카드의 성급을 함께 표시합니다. 긴 카드명도 잘리지 않도록 줄바꿈하여 표시합니다.
### 배치 분석 결과 저장과 성장 비교
`NEW` 배치 분석 결과와 당시 성장 상태를 저장하고 다시 열어볼 수 있습니다. 같은 편성을 현재 성장 상태와 비교해 종합력·평균 점수·고점의 변화를 확인할 수 있으며, 기록과 성장 데이터의 백업·가져오기를 지원합니다.
@@ -180,6 +180,56 @@ test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', ()
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => {
const pairSource = { ...source, members: [
{ id: 'fubuki', positionGroupId: 'model' },
{ id: 'kobo', positionGroupId: 'model' }
] };
const currentPlacements = { fubuki: { 'S-right': leftCard.id }, kobo: { 'S-right': rightCard.id } };
for (const members of [pairSource.members, [...pairSource.members].reverse()]) {
const result = optimizeBoardConnectionCards({ ...pairSource, members }, {
fubuki: ['S-right'], kobo: ['S-right']
}, [leftCard, rightCard], {
currentPlacements,
preservePlacementOnTie: (_member, socket) => socket === 'S-right',
placementPriorityWeight: (card, _member, socket) => socket === 'S-right' ? (card.id === rightCard.id ? 2 : 1) : 0,
placementWeight: () => 1
});
assert.deepEqual(result.placements, currentPlacements);
assert.equal(new Set(result.assignments.map(({ cardId }) => cardId)).size, 2);
}
});
test('기존 파란 배치 유지보다 발동률 합계의 실제 증가를 우선한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard], {
currentPlacements: { member: { 'S-right': leftCard.id } },
preservePlacementOnTie: (_member, socket) => socket === 'S-right',
placementPriorityWeight: (card, _member, socket) => socket === 'S-right' ? (card.id === rightCard.id ? 2 : 1) : 0,
placementWeight: () => 1
});
assert.equal(result.placements.member['S-right'], rightCard.id);
});
test('발동률 합계가 같아도 예상 점수가 증가하면 기존 배치를 교환한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard], {
currentPlacements: { member: { 'S-right': leftCard.id, 'S-center': rightCard.id } },
preservePlacementOnTie: () => true,
placementPriorityWeight: (_card, _member, socket) => socket === 'S-right' ? 1 : 0,
placementWeight: (card, _member, socket) => socket === 'S-right' && card.id === rightCard.id ? 1.001 : 1
});
assert.deepEqual(result.placements, { member: { 'S-center': leftCard.id, 'S-right': rightCard.id } });
});
test('파란 영역을 유지하면서 나머지 소켓은 점수로 최적화한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right'] }, [leftCard, rightCard, { ...leftCard, id: 'better' }], {
currentPlacements: { member: { 'S-right': rightCard.id, 'S-center': leftCard.id } },
preservePlacementOnTie: (_member, socket) => socket === 'S-right',
placementPriorityWeight: (_card, _member, socket) => socket === 'S-right' ? 1 : 0,
placementWeight: (card, _member, socket) => socket === 'S-center' && card.id === 'better' ? 100 : 1
});
assert.deepEqual(result.placements, { member: { 'S-center': 'better', 'S-right': rightCard.id } });
});
test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => {
const prioritySource: BoardConnectionOptimizerSource = {
members: [{ id: 'leader', positionGroupId: 'model' }],
+8 -2
View File
@@ -53,6 +53,7 @@ export type BoardConnectionOptimizationOptions = {
requireGroupSongBonus?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
preservePlacementOnTie?: (memberId: string, socketId: string) => boolean;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
};
@@ -164,6 +165,11 @@ export function optimizeBoardConnectionCards(
));
const maximumAssignments = Math.min(cards.length, slots.length);
const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments;
// Preserve user choices only when both priority and score are tied.
const retentionUnits = cards.map((card) => slots.map(({ member, socket }) =>
options.preservePlacementOnTie?.(member.id, socket.groupId)
&& options.currentPlacements?.[member.id]?.[socket.groupId] === card.id ? 1 : 0
));
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
@@ -180,7 +186,7 @@ export function optimizeBoardConnectionCards(
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized);
const matches = maximumWeightMatching(prioritized, retentionUnits);
const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0);
const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
@@ -213,7 +219,7 @@ export function optimizeBoardConnectionCards(
const key = JSON.stringify([
effect.effectPermilUp,
effect.extent.map(({ x, y }) => `${x},${y}`).sort(),
genericWeights[cardIndex], priorityUnits[cardIndex],
genericWeights[cardIndex], priorityUnits[cardIndex], retentionUnits[cardIndex],
soloBoosts[cardIndex], groupBoosts[cardIndex]
]);
const group = equivalentCards.get(key) ?? [];
+19 -5
View File
@@ -8,7 +8,7 @@ export type WeightedMatch = {
* Finds a maximum-weight one-to-one assignment for a rectangular matrix.
* Rows or columns that cannot improve the result may remain unmatched.
*/
export function maximumWeightMatching(weights: readonly (readonly number[])[]): WeightedMatch[] {
export function maximumWeightMatching(weights: readonly (readonly number[])[], tieWeights?: readonly (readonly number[])[]): WeightedMatch[] {
const rowCount = weights.length;
const columnCount = weights.reduce((maximum, row) => Math.max(maximum, row.length), 0);
if (rowCount === 0 || columnCount === 0) return [];
@@ -17,7 +17,10 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]):
const transposed = Array.from({ length: columnCount }, (_, columnIndex) =>
Array.from({ length: rowCount }, (_, rowIndex) => weights[rowIndex]?.[columnIndex] ?? 0)
);
return maximumWeightMatching(transposed).map((match) => ({
const transposedTies = tieWeights && Array.from({ length: columnCount }, (_, columnIndex) =>
Array.from({ length: rowCount }, (_, rowIndex) => tieWeights[rowIndex]?.[columnIndex] ?? 0)
);
return maximumWeightMatching(transposed, transposedTies).map((match) => ({
rowIndex: match.columnIndex,
columnIndex: match.rowIndex,
weight: match.weight
@@ -27,6 +30,8 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]):
const maximum = Math.max(0, ...weights.flatMap((row) => [...row]));
const rowPotential = new Array(rowCount + 1).fill(0);
const columnPotential = new Array(columnCount + 1).fill(0);
const rowTiePotential = new Array(rowCount + 1).fill(0);
const columnTiePotential = new Array(columnCount + 1).fill(0);
const matchedRowByColumn = new Array(columnCount + 1).fill(0);
const previousColumn = new Array(columnCount + 1).fill(0);
@@ -34,24 +39,30 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]):
matchedRowByColumn[0] = row;
let currentColumn = 0;
const minimum = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY);
const minimumTie = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY);
const used = new Array(columnCount + 1).fill(false);
do {
used[currentColumn] = true;
const currentRow = matchedRowByColumn[currentColumn];
let delta = Number.POSITIVE_INFINITY;
let deltaTie = Number.POSITIVE_INFINITY;
let nextColumn = 0;
for (let column = 1; column <= columnCount; column += 1) {
if (used[column]) continue;
const weight = weights[currentRow - 1]?.[column - 1] ?? 0;
const cost = maximum - Math.max(0, weight);
const reducedCost = cost - rowPotential[currentRow] - columnPotential[column];
if (reducedCost < minimum[column]) {
const reducedTie = -(tieWeights?.[currentRow - 1]?.[column - 1] ?? 0)
- rowTiePotential[currentRow] - columnTiePotential[column];
if (reducedCost < minimum[column] || (reducedCost === minimum[column] && reducedTie < minimumTie[column])) {
minimum[column] = reducedCost;
minimumTie[column] = reducedTie;
previousColumn[column] = currentColumn;
}
if (minimum[column] < delta) {
if (minimum[column] < delta || (minimum[column] === delta && minimumTie[column] < deltaTie)) {
delta = minimum[column];
deltaTie = minimumTie[column];
nextColumn = column;
}
}
@@ -59,8 +70,11 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]):
if (used[column]) {
rowPotential[matchedRowByColumn[column]] += delta;
columnPotential[column] -= delta;
rowTiePotential[matchedRowByColumn[column]] += deltaTie;
columnTiePotential[column] -= deltaTie;
} else {
minimum[column] -= delta;
minimumTie[column] -= deltaTie;
}
}
currentColumn = nextColumn;
@@ -78,7 +92,7 @@ export function maximumWeightMatching(weights: readonly (readonly number[])[]):
const row = matchedRowByColumn[column];
if (row === 0) continue;
const weight = weights[row - 1]?.[column - 1] ?? 0;
if (weight > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight });
if (weight > 0 || (tieWeights?.[row - 1]?.[column - 1] ?? 0) > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight });
}
return matches;
}
+7 -4
View File
@@ -511,7 +511,9 @@
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
return (socket?.x ?? 0) < 0;
if (!member || !socket || socket.x === 0) return false;
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
return positions.some((position) => position.groupId.startsWith('B-') && position.x * socket.x > 0);
}
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
@@ -600,6 +602,7 @@
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
currentPlacements: boardCards,
preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
? placementMetrics(card, memberId, socketId).activationRateUp
@@ -983,7 +986,7 @@
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률을 먼저 최대화한 뒤 예상 점수를 비교합니다.</p></div>
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률 UP 합계를 먼저 최대화한 뒤 예상 점수를 비교합니다. 발동률 UP 합계와 예상 점수가 같으면 기존 연결 카드 배치를 우선 유지합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
@@ -1009,7 +1012,7 @@
{@const member = boardMembers.find((item) => item.id === assignment.memberId)}
{@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null}
{@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null}
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? cardLabel(previousCard) : '비어 있음'} → {card ? cardLabel(card) : '배치 해제'}</strong></span>
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? `${previousCard.rarity}★ ${cardLabel(previousCard)}` : '비어 있음'} → {card ? `${card.rarity}★ ${cardLabel(card)}` : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
@@ -1197,7 +1200,7 @@
.connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; }
.connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; }
.connection-assignment-list small { color: #819287; font-size: 11px; }
.connection-assignment-list strong { overflow: hidden; color: #dbe7df; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.connection-assignment-list strong { color: #dbe7df; font-size: 12px; overflow-wrap: anywhere; }
.connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; }
.connection-proposal .apply-connection { justify-self: end; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }