fix: prioritize connection score with required blue node coverage

This commit is contained in:
2026-09-08 10:09:18 +09:00
parent 7d79e0ab97
commit 782ed91d35
4 changed files with 155 additions and 22 deletions
@@ -31,6 +31,69 @@ const rightCard = {
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
};
function blueSource(side: number): BoardConnectionOptimizerSource {
return {
members: [{ id: 'member', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 },
{ groupId: 'S-blue', x: side * 7, y: 0 },
{ groupId: 'B-large', x: side * 5, y: 0 },
{ groupId: 'B-small', x: side * 9, y: 0 }
] },
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-blue': [{ characterIds: [], type: 'connection', effect: null }],
'B-large': [{ characterIds: [], type: 'card', grade: 2,
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 60 } }],
'B-small': [{ characterIds: [], type: 'card', grade: 1,
effect: { type: 'live_active_skill_activation_probability_up_permil_up', value: 20 } }]
}
};
}
test('좌우 파란 소켓 모두 작은 노드만 덮는 고득점 카드 대신 큰 발동률 노드를 포함한다', () => {
for (const side of [-1, 1]) {
const cards = [
{ ...leftCard, id: 'covers-large', connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -side * 2, y: 0 }] }] },
{ ...rightCard, id: 'covers-small', connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: side * 2, y: 0 }] }] }
];
const result = optimizeBoardConnectionCards(blueSource(side), { member: ['S-blue', 'B-large', 'B-small'] }, cards, {
requireBlueActivationCoverage: true,
currentPlacements: { member: { 'S-blue': 'covers-small' } },
preservePlacementOnTie: () => true,
placementWeight: (card, _member, socket) => card.id === 'covers-small' && socket === 'S-blue' ? 10000 : 1,
placementTieWeight: (card) => card.id === 'covers-small' ? 100 : 0
});
assert.equal(result.placements.member['S-blue'], 'covers-large');
assert.equal(result.placements.member['S-center'], 'covers-small');
}
});
test('큰 노드의 활성 여부와 관계없이 개화 단계의 실제 범위로 포함 조건을 검사한다', () => {
const card = { id: 'awakened', awakening: 5, connect: [
{ level: 1, effectPermilUp: 1000, extent: [{ x: -2, y: 0 }] },
{ level: 2, effectPermilUp: 1500, extent: [{ x: 2, y: 0 }] }
] };
const result = optimizeBoardConnectionCards(blueSource(-1), { member: ['S-blue'] }, [card], {
requireBlueActivationCoverage: true, placementWeight: () => 0
});
assert.equal(result.placements.member['S-blue'], card.id);
assert.throws(() => optimizeBoardConnectionCards(blueSource(-1), { member: ['S-blue'] }, [{ ...card, awakening: 0 }], {
requireBlueActivationCoverage: true
}), /큰 발동률 노드를 포함할 수 없어/);
});
test('큰 노드를 덮는 카드가 부족하면 파란 소켓을 비우거나 카드를 중복 사용하지 않는다', () => {
const pair = blueSource(-1);
pair.members = [...pair.members, { id: 'other', positionGroupId: 'model' }];
const card = { ...rightCard, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 2, y: 0 }] }] };
for (const cards of [[], [card]]) {
assert.throws(() => optimizeBoardConnectionCards(pair, { member: ['S-blue'], other: ['S-blue'] }, cards, {
requireBlueActivationCoverage: true
}), /큰 발동률 노드를 포함할 수 없어/);
}
});
test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => {
const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id }));
const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } };
@@ -180,6 +243,40 @@ test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', ()
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('최고점 우선 최적화는 발동률이 낮아져도 더 높은 점수를 선택한다', () => {
const result = optimizeBoardConnectionCards(source, { member: [] }, [leftCard, rightCard], {
currentPlacements: { member: { 'S-center': leftCard.id } },
preservePlacementOnTie: () => true,
placementWeight: (card) => card.id === rightCard.id ? 1.001 : 1,
placementTieWeight: (card) => card.id === leftCard.id ? 100 : 0
});
assert.equal(result.placements.member['S-center'], rightCard.id);
});
test('최고점이 같으면 발동률을 높이고 두 값이 같으면 기존 배치를 유지한다', () => {
for (const increase of [0, 0.001]) {
const currentPlacements = { member: { 'S-center': leftCard.id } };
const result = optimizeBoardConnectionCards(source, { member: [] }, [rightCard, leftCard], {
currentPlacements,
preservePlacementOnTie: () => true,
placementWeight: () => 100,
placementTieWeight: (card) => card.id === rightCard.id ? 1 + increase : 1
});
assert.equal(result.placements.member['S-center'], increase > 0 ? rightCard.id : leftCard.id);
}
});
test('발동률이 높아져도 기존 배치보다 최고점이 낮은 카드는 선택하지 않는다', () => {
const currentPlacements = { member: { 'S-center': leftCard.id } };
const result = optimizeBoardConnectionCards(source, { member: [] }, [rightCard, leftCard], {
currentPlacements,
preservePlacementOnTie: () => true,
placementWeight: (card) => card.id === leftCard.id ? 101 : 100,
placementTieWeight: (card) => card.id === rightCard.id ? 100 : 0
});
assert.deepEqual(result.placements, currentPlacements);
});
test('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => {
const pairSource = { ...source, members: [
{ id: 'fubuki', positionGroupId: 'model' },
+43 -2
View File
@@ -9,6 +9,7 @@ type OptimizableBoardEffect = {
type OptimizableBoardNode = {
characterIds: readonly string[];
grade?: number;
type: string;
effect: OptimizableBoardEffect | null;
};
@@ -51,8 +52,10 @@ export type BoardConnectionOptimizationOptions = {
targetMemberId?: string;
requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean;
requireBlueActivationCoverage?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementTieWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
preservePlacementOnTie?: (memberId: string, socketId: string) => boolean;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
@@ -131,13 +134,32 @@ export function optimizeBoardConnectionCards(
.map((socket) => ({ member, socket, positions, active }));
});
// Large blue activation nodes must be in the card's actual extent, even
// when inactive. Identify the blue side from coordinates, not left/right.
const requiredNodes = slots.map(({ member, socket, positions }) =>
options.requireBlueActivationCoverage && 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';
})
: []
);
const coverageError = () => new Error('보유 연결 카드로 모든 파란 연결 노드의 큰 발동률 노드를 포함할 수 없어 기존 배치를 유지합니다.');
const cards = ownedCards.filter((card) => selectedEffect(card));
const emptyResult = {
placements: {}, assignments: [], totalWeight: 0,
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
groupSongBonusPercent: Math.min(10, baseGroupBonus / 10)
};
if (cards.length === 0 && requiredNodes.some((nodes) => nodes.length > 0)) throw coverageError();
if (cards.length === 0 || slots.length === 0) return emptyResult;
const allowed = cards.map((card) => slots.map(({ socket }, slotIndex) =>
requiredNodes[slotIndex].every((node) => selectedEffect(card)!.extent.some((offset) =>
socket.x + offset.x === node.x && socket.y + offset.y === node.y
))
));
const soloBoosts = cards.map(() => slots.map(() => 0));
const groupBoosts = cards.map(() => slots.map(() => 0));
@@ -170,6 +192,12 @@ export function optimizeBoardConnectionCards(
options.preservePlacementOnTie?.(member.id, socket.groupId)
&& options.currentPlacements?.[member.id]?.[socket.groupId] === card.id ? 1 : 0
));
// Compare secondary effects only on an exact primary-score tie. Integer
// permil units leave retention strictly behind any activation-rate increase.
const tieWeights = cards.map((card, cardIndex) => slots.map(({ member, socket }, slotIndex) =>
Math.max(0, Math.round((options.placementTieWeight?.(card, member.id, socket.groupId) ?? 0) * 1000))
* (maximumAssignments + 1) + retentionUnits[cardIndex][slotIndex]
));
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
@@ -186,7 +214,20 @@ export function optimizeBoardConnectionCards(
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized, retentionUnits);
// Satisfy every required socket before maximizing score. Disallowed edges
// have no weight or tie value and are never returned as placements.
const coverageBonus = Math.max(1, ...prioritized.flat()) * maximumAssignments + 1;
const constrained = prioritized.map((row, cardIndex) => row.map((weight, slotIndex) =>
!allowed[cardIndex][slotIndex] ? 0
: weight + (requiredNodes[slotIndex].length > 0 ? coverageBonus : 0)
));
const constrainedTies = tieWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
allowed[cardIndex][slotIndex] ? weight : 0
));
const matches = maximumWeightMatching(constrained, constrainedTies)
.filter((match) => allowed[match.rowIndex][match.columnIndex]);
if (requiredNodes.some((nodes, slotIndex) => nodes.length > 0
&& !matches.some((match) => match.columnIndex === slotIndex))) throw coverageError();
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);
@@ -219,7 +260,7 @@ export function optimizeBoardConnectionCards(
const key = JSON.stringify([
effect.effectPermilUp,
effect.extent.map(({ x, y }) => `${x},${y}`).sort(),
genericWeights[cardIndex], priorityUnits[cardIndex], retentionUnits[cardIndex],
genericWeights[cardIndex], priorityUnits[cardIndex], tieWeights[cardIndex], allowed[cardIndex],
soloBoosts[cardIndex], groupBoosts[cardIndex]
]);
const group = equivalentCards.get(key) ?? [];
+8 -19
View File
@@ -550,16 +550,6 @@
return socket.y > 0 ? '위쪽' : '아래쪽';
}
function isBlueConnectionSocket(memberId: string, socketId: string): boolean {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
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 {
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
}
@@ -598,7 +588,7 @@
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
).expectedScore;
).theoreticalMaxScore;
const emptyScore = referenceScore(emptyContext);
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
@@ -646,11 +636,10 @@
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
currentPlacements: boardCards,
requireBlueActivationCoverage: true,
preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
? placementMetrics(card, memberId, socketId).activationRateUp
: 0
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
@@ -661,11 +650,11 @@
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) {
throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`);
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
const changedAssignments = boardMembers.flatMap((member) => {
const before = boardCards[member.id] ?? {};
@@ -1063,7 +1052,7 @@
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률 UP 합계를 먼저 최대화한 뒤 예상 점수를 비교합니다. 발동률 UP 합계와 예상 점수가 같으면 기존 연결 카드 배치를 우선 유지합니다.</p></div>
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란 영역의 연결 카드는 반드시 인근의 큰 발동률 노드를 포함합니다. 이 조건 안에서 최고점을 최우선으로 비교하고 최고점이 같으면 액티브 발동률 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>