fix: prioritize connection score with required blue node coverage
This commit is contained in:
+7
-1
@@ -1,7 +1,13 @@
|
|||||||
---
|
---
|
||||||
updatedAt: 2026-09-07T14:17:02+09:00
|
updatedAt: 2026-09-08T12:00:00+09:00
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026년 9월 8일
|
||||||
|
|
||||||
|
### 연결 카드 최적화의 최고점 우선 적용
|
||||||
|
|
||||||
|
`FIXED` 배치 분석의 연결 카드 최적화에서 파란 영역의 연결 카드가 인근의 큰 발동률 노드를 반드시 포함하도록 했습니다. 이 조건 안에서 최고점을 최우선으로 비교하고, 최고점이 같을 때 액티브 발동률 UP 합계를 비교합니다. 조건을 충족할 수 없거나 최종 재계산에서 기존 배치보다 최고점이 낮아지면 기존 배치를 유지합니다.
|
||||||
|
|
||||||
## 2026년 9월 7일
|
## 2026년 9월 7일
|
||||||
|
|
||||||
### 그래프 시간축 갱신 수정
|
### 그래프 시간축 갱신 수정
|
||||||
|
|||||||
@@ -31,6 +31,69 @@ const rightCard = {
|
|||||||
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
|
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('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => {
|
test('모양과 수치가 같은 카드는 기존 배치를 유지하고 중복 배치하지 않는다', () => {
|
||||||
const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id }));
|
const cards = ['a', 'b', 'c'].map((id) => ({ ...rightCard, id }));
|
||||||
const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } };
|
const currentPlacements = { member: { 'S-center': 'c', 'S-right': 'a' } };
|
||||||
@@ -180,6 +243,40 @@ test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', ()
|
|||||||
assert.equal(result.placements.member['S-right'], 'left-card');
|
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('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => {
|
test('발동률 합계와 점수가 같으면 멤버 순서와 무관하게 기존 파란 배치를 유지한다', () => {
|
||||||
const pairSource = { ...source, members: [
|
const pairSource = { ...source, members: [
|
||||||
{ id: 'fubuki', positionGroupId: 'model' },
|
{ id: 'fubuki', positionGroupId: 'model' },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type OptimizableBoardEffect = {
|
|||||||
|
|
||||||
type OptimizableBoardNode = {
|
type OptimizableBoardNode = {
|
||||||
characterIds: readonly string[];
|
characterIds: readonly string[];
|
||||||
|
grade?: number;
|
||||||
type: string;
|
type: string;
|
||||||
effect: OptimizableBoardEffect | null;
|
effect: OptimizableBoardEffect | null;
|
||||||
};
|
};
|
||||||
@@ -51,8 +52,10 @@ export type BoardConnectionOptimizationOptions = {
|
|||||||
targetMemberId?: string;
|
targetMemberId?: string;
|
||||||
requireSoloSongBonus?: boolean;
|
requireSoloSongBonus?: boolean;
|
||||||
requireGroupSongBonus?: boolean;
|
requireGroupSongBonus?: boolean;
|
||||||
|
requireBlueActivationCoverage?: boolean;
|
||||||
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
|
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
|
||||||
placementPriorityWeight?: (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;
|
preservePlacementOnTie?: (memberId: string, socketId: string) => boolean;
|
||||||
prioritizePlacementWeight?: boolean;
|
prioritizePlacementWeight?: boolean;
|
||||||
groupSingerMemberIds?: readonly string[];
|
groupSingerMemberIds?: readonly string[];
|
||||||
@@ -131,13 +134,32 @@ export function optimizeBoardConnectionCards(
|
|||||||
.map((socket) => ({ member, socket, positions, active }));
|
.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 cards = ownedCards.filter((card) => selectedEffect(card));
|
||||||
const emptyResult = {
|
const emptyResult = {
|
||||||
placements: {}, assignments: [], totalWeight: 0,
|
placements: {}, assignments: [], totalWeight: 0,
|
||||||
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
|
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
|
||||||
groupSongBonusPercent: Math.min(10, baseGroupBonus / 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;
|
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 soloBoosts = cards.map(() => slots.map(() => 0));
|
||||||
const groupBoosts = 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.preservePlacementOnTie?.(member.id, socket.groupId)
|
||||||
&& options.currentPlacements?.[member.id]?.[socket.groupId] === card.id ? 1 : 0
|
&& 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) =>
|
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
|
||||||
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
|
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
|
||||||
));
|
));
|
||||||
@@ -186,7 +214,20 @@ export function optimizeBoardConnectionCards(
|
|||||||
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
|
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
|
||||||
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
|
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
|
||||||
) * priorityMultiplier));
|
) * 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 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 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);
|
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([
|
const key = JSON.stringify([
|
||||||
effect.effectPermilUp,
|
effect.effectPermilUp,
|
||||||
effect.extent.map(({ x, y }) => `${x},${y}`).sort(),
|
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]
|
soloBoosts[cardIndex], groupBoosts[cardIndex]
|
||||||
]);
|
]);
|
||||||
const group = equivalentCards.get(key) ?? [];
|
const group = equivalentCards.get(key) ?? [];
|
||||||
|
|||||||
@@ -550,16 +550,6 @@
|
|||||||
return socket.y > 0 ? '위쪽' : '아래쪽';
|
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 {
|
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
|
||||||
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
|
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
|
||||||
}
|
}
|
||||||
@@ -598,7 +588,7 @@
|
|||||||
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
|
||||||
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
|
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
|
||||||
}
|
}
|
||||||
).expectedScore;
|
).theoreticalMaxScore;
|
||||||
const emptyScore = referenceScore(emptyContext);
|
const emptyScore = referenceScore(emptyContext);
|
||||||
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
|
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
|
||||||
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
|
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
|
||||||
@@ -646,11 +636,10 @@
|
|||||||
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,
|
currentPlacements: boardCards,
|
||||||
|
requireBlueActivationCoverage: true,
|
||||||
preservePlacementOnTie: () => true,
|
preservePlacementOnTie: () => true,
|
||||||
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)
|
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
|
||||||
? placementMetrics(card, memberId, socketId).activationRateUp
|
|
||||||
: 0
|
|
||||||
});
|
});
|
||||||
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
|
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
|
||||||
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
|
||||||
@@ -661,11 +650,11 @@
|
|||||||
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
|
||||||
});
|
});
|
||||||
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
const afterActivationRateUp = totalActivationRateUp(afterContext);
|
||||||
if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
|
if (after.theoreticalMaxScore < analysisResult.theoreticalMaxScore) {
|
||||||
throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
|
throw new Error(`현재 배치의 최고점(${analysisResult.theoreticalMaxScore.toLocaleString('ko-KR')})이 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
||||||
}
|
}
|
||||||
if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) {
|
if (after.theoreticalMaxScore === analysisResult.theoreticalMaxScore && afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
|
||||||
throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`);
|
throw new Error('최고점이 같고 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
|
||||||
}
|
}
|
||||||
const changedAssignments = boardMembers.flatMap((member) => {
|
const changedAssignments = boardMembers.flatMap((member) => {
|
||||||
const before = boardCards[member.id] ?? {};
|
const before = boardCards[member.id] ?? {};
|
||||||
@@ -1063,7 +1052,7 @@
|
|||||||
|
|
||||||
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
|
||||||
<div class="connection-optimizer-heading">
|
<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">
|
<div class="connection-optimizer-actions">
|
||||||
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
|
{#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>
|
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user