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
@@ -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;
}