feat: support four-star leader outfits
Publish GitHub Pages output / publish (push) Successful in 48s

This commit is contained in:
2026-09-03 10:38:05 +09:00
parent 307e0effe5
commit cda25fdcb1
6 changed files with 78 additions and 11 deletions
+5 -1
View File
@@ -1,9 +1,13 @@
--- ---
updatedAt: 2026-09-03T09:43:39+09:00 updatedAt: 2026-09-03T10:37:43+09:00
--- ---
## 2026년 9월 3일 ## 2026년 9월 3일
### 4성 리더 의상 선택 지원
`IMPROVED` 점수 분석, 조합 탐색, 이벤트 Pt 최적화와 보드 효율 계산에서 4성 리더 의상을 선택하고 효과를 반영할 수 있습니다.
### NAKAMAKA 실측 기반 점수 보정 ### NAKAMAKA 실측 기반 점수 보정
`DATA` NAKAMAKA Expert의 점수 계산에 제보된 실측값을 반영했습니다. `DATA` NAKAMAKA Expert의 점수 계산에 제보된 실측값을 반영했습니다.
+44
View File
@@ -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<typeof outfitCard>;
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);
});
+18
View File
@@ -0,0 +1,18 @@
type OutfitCard = {
characterId: string;
rarity: number;
details: { costume: { name: string; description: string } | null };
};
export function leaderOutfitCards<T extends OutfitCard>(cards: readonly T[], characterId: string): T[] {
const seen = new Set<string>();
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;
});
}
+6 -5
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json'; 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 boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.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 REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5); 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 allCharts = exactChartCatalog.charts;
const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow); const initialReleaseClock = createInitialReleaseClock($page.data?.serverNow);
let currentReleaseClock = initialReleaseClock; let currentReleaseClock = initialReleaseClock;
@@ -120,10 +122,9 @@
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty); $: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember $: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume) ? leaderOutfitCards(cardSummary.cards, leaderCharacterId)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
: []; : [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null; $: leaderOutfit = outfitCardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '') $: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '') .replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -327,7 +328,7 @@
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) { if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId; leaderCharacterId = state.leaderCharacterId;
const validOutfit = typeof state.leaderOutfitCardId === 'string' const validOutfit = typeof state.leaderOutfitCardId === 'string'
? cardById.get(state.leaderOutfitCardId) ? outfitCardById.get(state.leaderOutfitCardId)
: undefined; : undefined;
leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume
? validOutfit.id ? validOutfit.id
@@ -877,7 +878,7 @@
</div> </div>
<div class="outfit-effect"> <div class="outfit-effect">
<span>리더 의상 효과</span> <span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p> <p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</div> </div>
<p class="power-detail">최대 레벨 기준 · 기본 {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')}%)</p> <p class="power-detail">최대 레벨 기준 · 기본 {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')}%)</p>
+3 -3
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import cardSummary from '$lib/data/cards-summary.json'; 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 boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json'; import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
@@ -197,8 +198,7 @@
: eligibleCards; : eligibleCards;
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId); $: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember $: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume) ? leaderOutfitCards(allCards, leaderCharacterId)
.filter((card, index, cards) => cards.findIndex((entry) => entry.details.costume?.name === card.details.costume?.name) === index)
: []; : [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null; $: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '') $: leaderOutfitDescription = (leaderOutfit?.description ?? '')
@@ -1240,7 +1240,7 @@
</div> </div>
<div class="outfit-effect"> <div class="outfit-effect">
<span>리더 의상 효과</span> <span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p> <p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
</div> </div>
<p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p> <p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p>
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} /> <PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
+2 -2
View File
@@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json'; 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 boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json'; import memorySummary from '$lib/data/memory-summary.json';
import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte'; import MemberBoardEffectSummary from '$lib/components/MemberBoardEffectSummary.svelte';
@@ -166,8 +167,7 @@
$: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards); $: allBoardMaterialTotals = boardMaterialTotals(userData.memberBoards);
$: allBoardPointTotal = boardPointTotal(userData.memberBoards); $: allBoardPointTotal = boardPointTotal(userData.memberBoards);
$: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId $: efficiencyLeaderOutfitOptions = efficiencyFormation.leaderCharacterId
? fiveStarCards.filter((card) => card.characterId === efficiencyFormation.leaderCharacterId && card.details.costume) ? leaderOutfitCards(allCards, efficiencyFormation.leaderCharacterId)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
: []; : [];
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5 $: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
&& efficiencyFormation.cardIds.every((id) => cardById.has(id)) && efficiencyFormation.cardIds.every((id) => cardById.has(id))