From cda25fdcb10d0a293acd75952cc3cf44a3562a57 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 3 Sep 2026 10:38:05 +0900 Subject: [PATCH] feat: support four-star leader outfits --- UPDATES.md | 6 +++- src/lib/engine/leader-outfits.test.ts | 44 +++++++++++++++++++++++++++ src/lib/engine/leader-outfits.ts | 18 +++++++++++ src/routes/analysis/+page.svelte | 11 ++++--- src/routes/combination/+page.svelte | 6 ++-- src/routes/data/+page.svelte | 4 +-- 6 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 src/lib/engine/leader-outfits.test.ts create mode 100644 src/lib/engine/leader-outfits.ts diff --git a/UPDATES.md b/UPDATES.md index 046ecab..caf872c 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,9 +1,13 @@ --- -updatedAt: 2026-09-03T09:43:39+09:00 +updatedAt: 2026-09-03T10:37:43+09:00 --- ## 2026년 9월 3일 +### 4성 리더 의상 선택 지원 + +`IMPROVED` 점수 분석, 조합 탐색, 이벤트 Pt 최적화와 보드 효율 계산에서 4성 리더 의상을 선택하고 효과를 반영할 수 있습니다. + ### NAKAMAKA 실측 기반 점수 보정 `DATA` NAKAMAKA Expert의 점수 계산에 제보된 실측값을 반영했습니다. diff --git a/src/lib/engine/leader-outfits.test.ts b/src/lib/engine/leader-outfits.test.ts new file mode 100644 index 0000000..b824cad --- /dev/null +++ b/src/lib/engine/leader-outfits.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { leaderOutfitCards } from './leader-outfits.ts'; +import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts'; + +function outfitCard(id: string, rarity: number, name: string, characterId = 'leader') { + return { id, rarity, characterId, details: { costume: { name, description: '전원의 전체 파라미터가 35% UP' } } }; +} + +test('리더의 4·5성 의상만 선택하고 기존 5성 순서와 이름 중복 제거를 유지한다', () => { + const cards = [outfitCard('four', 4, '드리미 드롭'), outfitCard('five', 5, '기본 의상'), + outfitCard('duplicate', 5, '기본 의상'), outfitCard('other', 4, '다른 멤버', 'other'), + outfitCard('three', 3, '제외'), { ...outfitCard('none', 4, ''), details: { costume: null } }]; + assert.deepEqual(leaderOutfitCards(cards, 'leader').map((card) => card.id), ['five', 'four']); + assert.deepEqual(leaderOutfitCards(cards, 'unknown'), []); + assert.equal(cards[0].id, 'four'); +}); + +test('4성 의상만 있는 멤버도 선택할 수 있다', () => { + assert.equal(leaderOutfitCards([outfitCard('four', 4, '드리미 드롭')], 'leader')[0].id, 'four'); +}); + +test('실제 4성 의상을 선택해 파라미터와 스코어 서포트에 반영한다', () => { + type DataCard = ReturnType; + const { cards }: { cards: DataCard[] } = JSON.parse(readFileSync(new URL('../data/cards-summary.json', import.meta.url), 'utf8')); + const outfits = cards.filter((card) => card.rarity === 4 && card.details.costume); + assert.ok(outfits.length > 0); + for (const card of outfits) { + assert.ok(leaderOutfitCards(cards, card.characterId).some((option) => option.id === card.id)); + } + const members = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ + card: { id, characterId: id, name: id, type: '큐트', generation: '0기생', + power: { maxLevel: 80, statsAtMaxLevel: { performance: 100, technique: 100, sense: 100 } }, + details: { passive: [] } } satisfies UnitPowerCard, + awakening: 0 + })); + const parameter = outfits.find((card) => card.details.costume.description.trim() === '전원의 전체 파라미터가 35% UP'); + assert.ok(parameter); + assert.equal(calculateUnitPower(members, parameter.details.costume).outfitTotal, 525); + const support = outfits.find((card) => card.details.costume.description.trim() === '전원의 스코어 서포트 효과 50%'); + assert.ok(support); + assert.equal(calculateUnitPower(members, support.details.costume).outfitScoreSupportPercent, 50); +}); diff --git a/src/lib/engine/leader-outfits.ts b/src/lib/engine/leader-outfits.ts new file mode 100644 index 0000000..3455bff --- /dev/null +++ b/src/lib/engine/leader-outfits.ts @@ -0,0 +1,18 @@ +type OutfitCard = { + characterId: string; + rarity: number; + details: { costume: { name: string; description: string } | null }; +}; + +export function leaderOutfitCards(cards: readonly T[], characterId: string): T[] { + const seen = new Set(); + return cards + .filter((card) => card.characterId === characterId && (card.rarity === 4 || card.rarity === 5) && card.details.costume) + .sort((left, right) => right.rarity - left.rarity) + .filter((card) => { + const name = card.details.costume!.name; + if (seen.has(name)) return false; + seen.add(name); + return true; + }); +} diff --git a/src/routes/analysis/+page.svelte b/src/routes/analysis/+page.svelte index e085577..02e0bda 100644 --- a/src/routes/analysis/+page.svelte +++ b/src/routes/analysis/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import cardSummary from '$lib/data/cards-summary.json'; + import { leaderOutfitCards } from '$lib/engine/leader-outfits.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'; @@ -58,6 +59,7 @@ const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const; const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5); + const outfitCardById = new Map(cardSummary.cards.map((card) => [card.id, card])); const allCharts = exactChartCatalog.charts; const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow); let currentReleaseClock = initialReleaseClock; @@ -120,10 +122,9 @@ $: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty); $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderOutfitOptions = leaderMember - ? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume) - .filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index) + ? leaderOutfitCards(cardSummary.cards, leaderCharacterId) : []; - $: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null; + $: leaderOutfit = outfitCardById.get(leaderOutfitCardId)?.details.costume ?? null; $: leaderOutfitDescription = (leaderOutfit?.description ?? '') .replace(/\[[^\]]+\]/g, '') .replace(/\s+/g, ' ') @@ -327,7 +328,7 @@ if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) { leaderCharacterId = state.leaderCharacterId; const validOutfit = typeof state.leaderOutfitCardId === 'string' - ? cardById.get(state.leaderOutfitCardId) + ? outfitCardById.get(state.leaderOutfitCardId) : undefined; leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume ? validOutfit.id @@ -877,7 +878,7 @@
리더 의상 효과 -

{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}

+

{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}

최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)

diff --git a/src/routes/combination/+page.svelte b/src/routes/combination/+page.svelte index a7ab8b4..fc22b42 100644 --- a/src/routes/combination/+page.svelte +++ b/src/routes/combination/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import cardSummary from '$lib/data/cards-summary.json'; + import { leaderOutfitCards } from '$lib/engine/leader-outfits.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'; @@ -197,8 +198,7 @@ : eligibleCards; $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderOutfitOptions = leaderMember - ? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume) - .filter((card, index, cards) => cards.findIndex((entry) => entry.details.costume?.name === card.details.costume?.name) === index) + ? leaderOutfitCards(allCards, leaderCharacterId) : []; $: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null; $: leaderOutfitDescription = (leaderOutfit?.description ?? '') @@ -1240,7 +1240,7 @@
리더 의상 효과 -

{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}

+

{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}

기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.

diff --git a/src/routes/data/+page.svelte b/src/routes/data/+page.svelte index 158605b..e61d4a5 100644 --- a/src/routes/data/+page.svelte +++ b/src/routes/data/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/stores'; import { onMount, tick } from 'svelte'; import cardSummary from '$lib/data/cards-summary.json'; + import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts'; import boardSummary from '$lib/data/boards-summary.json'; import memorySummary from '$lib/data/memory-summary.json'; import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte'; @@ -166,8 +167,7 @@ $: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards); $: allBoardPointTotal = boardPointTotal(userData.memberBoards); $: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId - ? fiveStarCards.filter((card) => card.characterId === efficiencyFormation.leaderCharacterId && card.details.costume) - .filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index) + ? leaderOutfitCards(allCards, efficiencyFormation.leaderCharacterId) : []; $: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5 && efficiencyFormation.cardIds.every((id) => cardById.has(id))