fix: preserve equivalent cards in board optimization
Publish GitHub Pages output / publish (push) Successful in 47s

This commit is contained in:
2026-09-05 11:27:52 +09:00
parent 94984b9387
commit b484daf956
4 changed files with 84 additions and 1 deletions
+4
View File
@@ -4,6 +4,10 @@ updatedAt: 2026-09-05T10:20:00+09:00
## 2026년 9월 5일 ## 2026년 9월 5일
### 보드 최적화의 불필요한 교체 제거
`FIXED` 배치 분석의 보드 최적화에서 연결 모양과 강화 수치가 같은 카드로 교체하도록 안내하던 문제를 수정했습니다. 효과가 같으면 기존에 배치한 카드를 유지합니다.
### 게이머즈 소속 조건 판정 수정 ### 게이머즈 소속 조건 판정 수정
`FIXED` 게이머즈 멤버가 소속 조건에서 누락되던 문제를 수정했습니다. 수영복 오카유, 일반 5성 미오, 4성 오카유·코로네의 게이머즈 조건 및 대상 지정이 있는 리더 의상·패시브·스킬 효과가 정상 적용됩니다. `FIXED` 게이머즈 멤버가 소속 조건에서 누락되던 문제를 수정했습니다. 수영복 오카유, 일반 5성 미오, 4성 오카유·코로네의 게이머즈 조건 및 대상 지정이 있는 리더 의상·패시브·스킬 효과가 정상 적용됩니다.
@@ -31,6 +31,46 @@ const rightCard = {
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }] connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}; };
test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => {
const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id }));
const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } };
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-right', 'G-far'] }, cards, { currentPlacements });
assert.deepEqual(result.placements, currentPlacements);
assert.equal(new Set(result.assignments.map((assignment) => assignment.cardId)).size, 2);
assert.equal(result.totalWeight, 900_000);
});
test('개화에 따른 실제 효과와 좌표 순서에 무관한 모양으로 기존 카드를 유지한다', () => {
const effect = { level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }, { x: 1, y: 0 }] };
const cards = [
{ id: 'a', awakening: 0, connect: [effect] },
{ id: 'b', awakening: 5, connect: [
{ ...effect, effectPermilUp: 100 },
{ ...effect, level: 2, extent: [...effect.extent].reverse() }
] }
];
const currentPlacements = { member: { 'S-center': 'b' } };
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, cards, { currentPlacements });
assert.deepEqual(result.placements, currentPlacements);
});
test('기존 카드와 모양 또는 수치가 다르면 실제 개선 배치를 유지한다', () => {
for (const previous of [leftCard, { ...rightCard, id: 'weak', connect: [{ ...rightCard.connect[0], effectPermilUp: 100 }] }]) {
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [previous, rightCard], {
currentPlacements: { member: { 'S-center': previous.id } }
});
assert.equal(result.placements.member['S-center'], rightCard.id);
}
});
test('기존 카드를 유지해도 다른 소켓에 필요한 동등 카드를 중복 사용하지 않는다', () => {
const cards = ['a', 'b'].map((id) => ({ ...rightCard, id }));
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-right', 'G-far'] }, cards, {
currentPlacements: { member: { 'S-center': 'b' } }
});
assert.deepEqual(result.placements, { member: { 'S-center': 'b', 'S-right': 'a' } });
});
test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => { test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]); const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]);
assert.deepEqual(result.placements, { assert.deepEqual(result.placements, {
+39 -1
View File
@@ -47,6 +47,7 @@ export type BoardConnectionOptimizationResult = {
}; };
export type BoardConnectionOptimizationOptions = { export type BoardConnectionOptimizationOptions = {
currentPlacements?: Readonly<Record<string, Readonly<Record<string, string>>>>;
targetMemberId?: string; targetMemberId?: string;
requireSoloSongBonus?: boolean; requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean; requireGroupSongBonus?: boolean;
@@ -202,7 +203,44 @@ export function optimizeBoardConnectionCards(
|| right.fulfillment - left.fulfillment || right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment || right.totalFulfillment - left.totalFulfillment
|| right.genericWeight - left.genericWeight)[0]; || right.genericWeight - left.genericWeight)[0];
const matches = best.matches; const matches = best.matches.map((match) => ({ ...match }));
if (options.currentPlacements) {
// Equivalent cards are interchangeable. Reserve existing cards first, then
// distribute the remaining cards so preserving one socket cannot duplicate a card.
const equivalentCards = new Map<string, number[]>();
cards.forEach((card, cardIndex) => {
const effect = selectedEffect(card)!;
const key = JSON.stringify([
effect.effectPermilUp,
effect.extent.map(({ x, y }) => `${x},${y}`).sort(),
genericWeights[cardIndex], priorityUnits[cardIndex],
soloBoosts[cardIndex], groupBoosts[cardIndex]
]);
const group = equivalentCards.get(key) ?? [];
group.push(cardIndex);
equivalentCards.set(key, group);
});
for (const group of equivalentCards.values()) {
const available = new Set(group);
const groupMatches = matches.filter((match) => available.has(match.rowIndex));
const pending = [];
for (const match of groupMatches) {
const { member, socket } = slots[match.columnIndex];
const currentId = options.currentPlacements[member.id]?.[socket.groupId];
const currentIndex = group.find((index) => cards[index].id === currentId);
if (currentIndex !== undefined && available.delete(currentIndex)) {
match.rowIndex = currentIndex;
} else {
pending.push(match);
}
}
for (const match of pending) {
const index = available.has(match.rowIndex) ? match.rowIndex : available.values().next().value!;
match.rowIndex = index;
available.delete(index);
}
}
}
const placements: Record<string, Record<string, string>> = {}; const placements: Record<string, Record<string, string>> = {};
const assignments = matches.map((match) => { const assignments = matches.map((match) => {
const card = cards[match.rowIndex]; const card = cards[match.rowIndex];
+1
View File
@@ -592,6 +592,7 @@
connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks }; connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, { const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
currentPlacements: boardCards,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId) placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
? placementMetrics(card, memberId, socketId).activationRateUp ? placementMetrics(card, memberId, socketId).activationRateUp