diff --git a/UPDATES.md b/UPDATES.md index f107f6a..57b1113 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,7 +1,13 @@ --- -updatedAt: 2026-10-06T03:11:36.779Z +updatedAt: 2026-10-07T00:01:58.945Z --- +## 2026년 10월 7일 + +### 조합 탐색에서 이격 제외 + +`NEW` 보유 카드 제한을 해제한 조합 탐색에 ‘이격 제외’ 옵션을 추가했습니다. 수영복 등 다른 의상을 제외하고 일반 카드만 선택할 수 있습니다. 고정 카드와 자동 후보에 함께 적용되며 탐색 조건·프리셋에 저장됩니다. 리더 의상과 연결 카드는 제한하지 않으며, 실제 모집·천장 획득 가능 여부를 보장하는 필터는 아닙니다. + ## 2026년 10월 6일 ### 파란 보드 개방 가정으로 연결 카드 추천 diff --git a/src/lib/engine/combination-card-filter.test.ts b/src/lib/engine/combination-card-filter.test.ts new file mode 100644 index 0000000..03a8281 --- /dev/null +++ b/src/lib/engine/combination-card-filter.test.ts @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { combinationCardCandidates } from './combination-card-filter.ts'; + +const cards = [ + { id: 'regular', versionName: '' }, + { id: 'swim', versionName: '수영복' }, + { id: 'future', versionName: '다른 의상' } +]; +const collection = { swim: { owned: true } }; + +test('전체 후보는 기존대로 유지하고 일반 옵션은 일반 카드만 허용한다', () => { + assert.deepEqual(combinationCardCandidates(cards, collection, false, false), cards); + assert.deepEqual(combinationCardCandidates(cards, collection, false, true), [cards[0]]); + assert.equal(cards.length, 3); +}); + +test('보유 제한에서는 일반 옵션을 무시해 보유 수영복도 사용할 수 있다', () => { + assert.deepEqual(combinationCardCandidates(cards, collection, true, true), [cards[1]]); + assert.deepEqual(combinationCardCandidates(cards, {}, true, false), []); +}); diff --git a/src/lib/engine/combination-card-filter.ts b/src/lib/engine/combination-card-filter.ts new file mode 100644 index 0000000..ac16d79 --- /dev/null +++ b/src/lib/engine/combination-card-filter.ts @@ -0,0 +1,11 @@ +/** Matches the card catalog's 일반 category, not current gacha availability. */ +export function combinationCardCandidates( + cards: readonly T[], + collection: Record, + ownedOnly: boolean, + regularOnly: boolean +): T[] { + return cards.filter((card) => ownedOnly + ? collection[card.id]?.owned === true + : !regularOnly || card.versionName === ''); +} diff --git a/src/routes/combination/+page.svelte b/src/routes/combination/+page.svelte index 5c726df..6a75220 100644 --- a/src/routes/combination/+page.svelte +++ b/src/routes/combination/+page.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import cardSummary from '$lib/data/cards-summary.json'; import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts'; + import { combinationCardCandidates } from '$lib/engine/combination-card-filter.ts'; import boardSummary from '$lib/data/boards-summary.json'; import memorySummary from '$lib/data/memory-summary.json'; import exactChartCatalog from '$lib/data/exact-chart-catalog.json'; @@ -87,6 +88,7 @@ fixedGenerationBonusOverrides?: (number | null)[]; songScoreBonusOverride?: number | null; ownedCardsOnly?: boolean; + regularCardsOnly?: boolean; maxMediumProbabilityCards?: number | null; maximizeCompute?: boolean; resultLimit?: number; @@ -145,6 +147,7 @@ let fixedGenerationBonusOverrides: (number | null)[] = [null, null, null, null]; let songScoreBonusOverride: number | null = 10; let ownedCardsOnly = false; + let regularCardsOnly = false; let maxMediumProbabilityCards: number | null = null; let resultLimit = 10; let activeResultLimit = 10; @@ -283,9 +286,7 @@ $: if (ready && connectionCards && baselineState && baselineMatchesConditions && !baselineResult && !baselineRunning && !baselineError) void analyzeBaseline(); $: if (!result && rankedResults.length > 0) rankedResults = []; $: fixedCards = fixedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card)); - $: candidateCards = ownedCardsOnly - ? eligibleCards.filter((card) => collection[card.id]?.owned === true) - : eligibleCards; + $: candidateCards = combinationCardCandidates(eligibleCards, collection, ownedCardsOnly, regularCardsOnly); $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderOutfitOptions = leaderMember ? leaderOutfitCards(allCards, leaderCharacterId) @@ -313,6 +314,7 @@ fixedGenerationBonusOverrides, songScoreBonusOverride, ownedCardsOnly, + regularCardsOnly, maxMediumProbabilityCards, resultLimit, maximizeCompute, @@ -332,6 +334,7 @@ fixedGenerationBonusOverrides, songScoreBonusOverride, ownedCardsOnly, + regularCardsOnly, maxMediumProbabilityCards, resultLimit, maximizeCompute, @@ -475,11 +478,12 @@ ? null : normalizeSongScoreBonusOverride(state.songScoreBonusOverride) ?? 10; ownedCardsOnly = state.ownedCardsOnly === true; + regularCardsOnly = state.regularCardsOnly === true; resultLimit = [10, 20, 30, 50].includes(state.resultLimit ?? 10) ? state.resultLimit ?? 10 : 10; maxMediumProbabilityCards = typeof state.maxMediumProbabilityCards === 'number' && Number.isInteger(state.maxMediumProbabilityCards) && state.maxMediumProbabilityCards >= 0 && state.maxMediumProbabilityCards < 5 ? state.maxMediumProbabilityCards : null; - if (ownedCardsOnly) toggleOwnedCardsOnly(true); + toggleOwnedCardsOnly(ownedCardsOnly); maximizeCompute = state.maximizeCompute === true; if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) { leaderCharacterId = state.leaderCharacterId; @@ -585,8 +589,9 @@ function toggleOwnedCardsOnly(enabled: boolean): void { ownedCardsOnly = enabled; - if (enabled) { - const removedSlots = fixedCardIds.map((cardId) => Boolean(cardId && !collection[cardId]?.owned)); + if (enabled || regularCardsOnly) { + const allowedIds = new Set(combinationCardCandidates(allCards, collection, enabled, regularCardsOnly).map((card) => card.id)); + const removedSlots = fixedCardIds.map((cardId) => Boolean(cardId && !allowedIds.has(cardId))); fixedCardIds = fixedCardIds.map((cardId, slot) => removedSlots[slot] ? '' : cardId); fixedCooldownReductions = fixedCooldownReductions.map((value, slot) => removedSlots[slot] ? null : value); fixedAllBlueNodes = fixedAllBlueNodes.map((value, slot) => removedSlots[slot] ? false : value); @@ -1315,6 +1320,14 @@ 내가 보유한 카드만 사용하기 + {#if !ownedCardsOnly} + +

수영복 등 다른 의상의 이격 카드를 제외하고, 카드 목록의 ‘일반’ 분류만 고정 카드·자동 후보로 사용합니다. 제외되는 고정 카드는 해제됩니다. 현재 모집·천장 획득 가능 여부를 보장하지 않으며, 리더 의상과 연결 카드는 제한하지 않습니다.

+ {/if}

‘내가 보유한 카드만 사용하기’를 켜면 고정 카드와 자동 후보 모두 {eventMode ? '보유한 이벤트 후보 카드' : '보유 5성 카드'}로 제한됩니다.
보유 카드의 개화가 자동 후보 개화보다 높으면 실제 보유 개화를 우선 적용합니다.