feat: support four-star leader outfits
Publish GitHub Pages output / publish (push) Successful in 48s
Publish GitHub Pages output / publish (push) Successful in 48s
This commit is contained in:
+5
-1
@@ -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의 점수 계산에 제보된 실측값을 반영했습니다.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,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))
|
||||||
|
|||||||
Reference in New Issue
Block a user