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일
|
||||
|
||||
### 4성 리더 의상 선택 지원
|
||||
|
||||
`IMPROVED` 점수 분석, 조합 탐색, 이벤트 Pt 최적화와 보드 효율 계산에서 4성 리더 의상을 선택하고 효과를 반영할 수 있습니다.
|
||||
|
||||
### NAKAMAKA 실측 기반 점수 보정
|
||||
|
||||
`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 { 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 @@
|
||||
</div>
|
||||
<div class="outfit-effect">
|
||||
<span>리더 의상 효과</span>
|
||||
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
|
||||
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
<div class="outfit-effect">
|
||||
<span>리더 의상 효과</span>
|
||||
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
|
||||
<p>{leaderOutfitDescription || '적용할 수 있는 의상이 없습니다.'}</p>
|
||||
</div>
|
||||
<p class="hint">기수 보너스와 가창 보너스 덮어쓰기는 빈칸이면 저장된 실제 보드를 사용하고, 숫자를 입력하면 해당 값으로 대체합니다. 자동 후보는 파란 노드 전체 활성화·기수 보너스 {candidateGenerationBonus === null ? '저장값' : candidateGenerationBonus}·가창 보너스 {songScoreBonusOverride === null ? '실제 보드값' : `${songScoreBonusOverride}%`} 조건으로 비교합니다.</p>
|
||||
<PresetManager storageKey={PRESET_STORAGE_KEY} snapshot={presetSnapshot} disabled={running} on:apply={applyPreset} />
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user