fix: scope blue board coverage to selected unit members
Publish GitHub Pages output / publish (push) Successful in 48s

This commit is contained in:
2026-09-09 12:09:47 +09:00
parent cf1ec84af4
commit 96bd5c4514
4 changed files with 57 additions and 5 deletions
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-09-09T00:00:00+09:00 updatedAt: 2026-09-09T12:00:00+09:00
--- ---
## 2026년 9월 9일 ## 2026년 9월 9일
### 연결 카드 최적화의 편성 대상 수정
`FIXED` 배치 분석의 연결 카드 최적화에서 편성에 없는 멤버의 파란 보드에도 카드를 필수 배치하던 문제를 수정했습니다. 큰 발동률 노드를 포함하는 필수 배치 조건은 편성 멤버에게만 적용하며, 편성 밖 보드의 전체 능력치·가창 보너스 등 점수에 기여하는 효과는 계속 반영합니다.
### Illusion Night 악곡 추가 ### Illusion Night 악곡 추가
`DATA` 파볼리아 레이네의 Illusion Night를 추가했습니다. Easy·Normal·Hard·Expert 채보와 곡별 점수 계산을 지원합니다. `DATA` 파볼리아 레이네의 Illusion Night를 추가했습니다. Easy·Normal·Hard·Expert 채보와 곡별 점수 계산을 지원합니다.
@@ -94,6 +94,51 @@ test('큰 노드를 덮는 카드가 부족하면 파란 소켓을 비우거나
} }
}); });
test('편성 밖 파란 소켓은 필수 배치에서 제외하고 여분 카드로도 빈 슬롯을 채우지 않는다', () => {
for (const side of [-1, 1]) {
const scopedSource = blueSource(side);
scopedSource.members = ['okayu', 'korone', 'member'].map((id) => ({ id, positionGroupId: 'model' }));
const boards = Object.fromEntries(scopedSource.members.map(({ id }) => [id, ['S-blue', 'B-large']]));
const coveringCard = { ...rightCard, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -side * 2, y: 0 }] }] };
for (const cards of [[coveringCard], [coveringCard, { ...coveringCard, id: 'spare' }]]) {
const result = optimizeBoardConnectionCards(scopedSource, boards, cards, {
requireBlueActivationCoverage: ['member'],
preservePlacementOnTie: () => true,
placementWeight: () => 0,
placementTieWeight: (_card, memberId, socketId) => memberId === 'member' && socketId === 'S-blue' ? 6 : 0
});
assert.equal(result.assignments.length, 1);
assert.equal(result.assignments[0].memberId, 'member');
assert.equal(result.assignments[0].socketId, 'S-blue');
}
assert.throws(() => optimizeBoardConnectionCards(scopedSource, boards, [], {
requireBlueActivationCoverage: ['member'], placementWeight: () => 0
}), /큰 발동률 노드를 포함할 수 없어/);
}
});
test('편성 밖 보드의 점수 기여와 동률인 기존 배치는 유지한다', () => {
const scopedSource = blueSource(1);
scopedSource.members = ['member', 'outsider'].map((id) => ({ id, positionGroupId: 'model' }));
const cards = ['required', 'global', 'existing'].map((id) => ({
id, awakening: 0, connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -2, y: 0 }] }]
}));
const result = optimizeBoardConnectionCards(scopedSource, {
member: ['S-blue', 'B-large'], outsider: ['S-blue', 'B-large']
}, cards, {
requireBlueActivationCoverage: ['member'],
currentPlacements: { outsider: { 'S-blue': 'existing' } },
preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) =>
card.id === 'global' && memberId === 'outsider' && socketId === 'S-center' ? 100 : 0,
placementTieWeight: (_card, memberId, socketId) => memberId === 'member' && socketId === 'S-blue' ? 6 : 0
});
assert.deepEqual(result.placements, {
member: { 'S-blue': 'required' }, outsider: { 'S-center': 'global', 'S-blue': 'existing' }
});
assert.equal(new Set(result.assignments.map(({ cardId }) => cardId)).size, 3);
});
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' } };
+5 -2
View File
@@ -52,7 +52,8 @@ export type BoardConnectionOptimizationOptions = {
targetMemberId?: string; targetMemberId?: string;
requireSoloSongBonus?: boolean; requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean; requireGroupSongBonus?: boolean;
requireBlueActivationCoverage?: boolean; /** true covers every member; a member ID list scopes coverage to the current unit. */
requireBlueActivationCoverage?: boolean | readonly string[];
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; placementTieWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
@@ -137,7 +138,9 @@ export function optimizeBoardConnectionCards(
// Large blue activation nodes must be in the card's actual extent, even // Large blue activation nodes must be in the card's actual extent, even
// when inactive. Identify the blue side from coordinates, not left/right. // when inactive. Identify the blue side from coordinates, not left/right.
const requiredNodes = slots.map(({ member, socket, positions }) => const requiredNodes = slots.map(({ member, socket, positions }) =>
options.requireBlueActivationCoverage && socket.x !== 0 (options.requireBlueActivationCoverage === true
|| (Array.isArray(options.requireBlueActivationCoverage) && options.requireBlueActivationCoverage.includes(member.id)))
&& socket.x !== 0
? positions.filter((position) => { ? positions.filter((position) => {
const node = nodeForMember(source, position.groupId, member.id); const node = nodeForMember(source, position.groupId, member.id);
return position.groupId.startsWith('B-') && position.x * socket.x > 0 return position.groupId.startsWith('B-') && position.x * socket.x > 0
+2 -2
View File
@@ -687,7 +687,7 @@
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, requireBlueActivationCoverage: selectedSourceCards.map((card) => card.characterId),
preservePlacementOnTie: () => true, preservePlacementOnTie: () => true,
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score, placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp placementTieWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).activationRateUp
@@ -1104,7 +1104,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 합계를 비교합니다. 두 값이 모두 같으면 기존 연결 카드 배치를 우선 유지합니다. 조건을 충족할 수 없거나 최종 검증에서 최고점이 낮아지면 기존 배치를 유지합니다.</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>