fix: preserve tied connection placements and show card rarity
Publish GitHub Pages output / publish (push) Successful in 43s
Publish GitHub Pages output / publish (push) Successful in 43s
This commit is contained in:
@@ -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' }],
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user