feat: add town unit score optimizer with saved formations
Publish GitHub Pages output / publish (push) Successful in 41s
Publish GitHub Pages output / publish (push) Successful in 41s
This commit is contained in:
1 parent
e87dd3b1f3
commit
3cdea75ff7
7 files changed
+420
-2
No files matched your search
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ToolsNav from '$lib/components/ToolsNav.svelte';
|
||||
import catalog from '$lib/data/cards-summary.json';
|
||||
import boards from '$lib/data/boards-summary.json';
|
||||
import memories from '$lib/data/memory-summary.json';
|
||||
import { defaultPreferences } from '$lib/models/default-preferences.ts';
|
||||
import { createUserData } from '$lib/models/user-data.ts';
|
||||
import { parseUserData, USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
|
||||
import { calculateBoardEffects, calculateUnitPower, memoryParameterPermil } from '$lib/engine/index.ts';
|
||||
import { prepareTownUnitScore, searchTownUnitScore, type TownScore } from '$lib/engine/town-unit-score.ts';
|
||||
import { leaderOutfitCards } from '$lib/engine/leader-outfits.ts';
|
||||
import { parseTownForm, TOWN_FORM_STORAGE_KEY } from '$lib/storage/town-unit-score.ts';
|
||||
|
||||
let growth = createUserData(defaultPreferences());
|
||||
let ids = ['', '', '', '', ''];
|
||||
let awakenings = [0, 0, 0, 0, 0];
|
||||
let outfitId = '';
|
||||
let leaderCharacterId = '';
|
||||
let overridePower: number | undefined;
|
||||
let results: TownScore[] = [];
|
||||
let names: string[] = [];
|
||||
let expanded = false;
|
||||
let error = '';
|
||||
let growthError = '';
|
||||
let storageError = '';
|
||||
let formReady = false;
|
||||
let running = false;
|
||||
let resultPower = 0;
|
||||
const cards = catalog.cards;
|
||||
const byId = new Map(cards.map(card => [card.id, card]));
|
||||
const leaders = [...new Map(cards.filter(card => card.rarity === 5).map(card => [card.characterId, card])).values()];
|
||||
$: outfits = leaderOutfitCards(cards, leaderCharacterId);
|
||||
$: selected = ids.map(id => byId.get(id)).filter((card): card is typeof cards[number] => Boolean(card));
|
||||
$: outfit = byId.get(outfitId);
|
||||
$: outfitDescription = outfit?.details.costume?.description.replace(/\[[^\]]+\]/g, '') ?? '';
|
||||
$: connections = cards.filter(card => card.details.connect.length).map(card => ({ id: card.id, awakening: growth.collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
|
||||
$: effects = calculateBoardEffects(boards, growth.memberBoards, selected, outfit?.characterId ?? '', null, { placements: growth.boardCards, cards: connections });
|
||||
$: power = selected.length === 5 ? calculateUnitPower(selected.map((card, i) => ({ card, awakening: awakenings[i] })), outfit?.details.costume, effects,
|
||||
memoryParameterPermil(growth.memoryCount, memories.effects), growth.memberPowerUpPercent) : null;
|
||||
$: inputKey = JSON.stringify([ids, awakenings, outfitId, overridePower, growth]);
|
||||
$: if (inputKey) { results = []; error = ''; expanded = false; }
|
||||
$: if (formReady) saveForm(JSON.stringify({ ids, awakenings, leaderCharacterId, outfitId, overridePower }));
|
||||
|
||||
function saveForm(value: string) {
|
||||
try {
|
||||
localStorage.setItem(TOWN_FORM_STORAGE_KEY, value);
|
||||
storageError = '';
|
||||
} catch { storageError = '편성을 저장하지 못했습니다. 브라우저 저장 공간과 권한을 확인해 주세요.'; }
|
||||
}
|
||||
|
||||
function readGrowth() {
|
||||
try {
|
||||
const text = localStorage.getItem(USER_DATA_STORAGE_KEY);
|
||||
growth = text ? parseUserData(text, defaultPreferences()) : createUserData(defaultPreferences());
|
||||
growthError = '';
|
||||
} catch { growthError = '사용자 데이터를 읽지 못했습니다. 기존 입력을 유지합니다.'; }
|
||||
}
|
||||
function selectLeader(id: string) {
|
||||
leaderCharacterId = id;
|
||||
outfitId = leaderOutfitCards(cards, id)[0]?.id ?? '';
|
||||
}
|
||||
function selectCard(slot: number, id: string) {
|
||||
ids = ids.map((value, i) => i === slot ? id : value);
|
||||
setAwakening(slot, growth.collection[id]?.awakening ?? 0);
|
||||
}
|
||||
function setAwakening(slot: number, value: number) {
|
||||
awakenings = awakenings.map((previous, i) => i === slot ? Math.max(0, Math.min(5, value)) : previous);
|
||||
}
|
||||
async function calculate() {
|
||||
error = ''; results = []; expanded = false; running = true;
|
||||
try {
|
||||
if (selected.length !== 5 || !outfit?.details.costume || !power) throw new Error('멤버 5명과 리더 의상을 선택해 주세요.');
|
||||
const total = overridePower ?? power.overallPower;
|
||||
const model = prepareTownUnitScore(selected, awakenings, total, power.outfitScoreSupportPercent, effects.scoreSupportPercent,
|
||||
selected.map(card => effects.activationRateUpByCardId[card.id] ?? 0));
|
||||
const key = inputKey;
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const ranked = searchTownUnitScore(model);
|
||||
if (key !== inputKey) return;
|
||||
results = ranked; names = model.cards.map(card => card.name); resultPower = total;
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : '계산하지 못했습니다.'; }
|
||||
finally { running = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
readGrowth();
|
||||
try {
|
||||
const saved = parseTownForm(localStorage.getItem(TOWN_FORM_STORAGE_KEY), cards);
|
||||
ids = saved.ids;
|
||||
awakenings = saved.awakenings;
|
||||
leaderCharacterId = saved.leaderCharacterId;
|
||||
outfitId = saved.outfitId;
|
||||
overridePower = saved.overridePower;
|
||||
} catch { storageError = '저장된 편성을 불러오지 못했습니다.'; }
|
||||
formReady = true;
|
||||
const refresh = (event: StorageEvent) => { if (event.key === USER_DATA_STORAGE_KEY || event.key === null) readGrowth(); };
|
||||
window.addEventListener('storage', refresh);
|
||||
return () => window.removeEventListener('storage', refresh);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>마을 유닛 스코어 - 갤산기</title></svelte:head>
|
||||
<main>
|
||||
<header><span class="eyebrow">UNIT SCORE</span><h1>마을 유닛 스코어</h1><p>직접 지정한 편성으로 악곡 입장 전 예상 유닛 스코어가 가장 높은 빈도 설정을 찾습니다.</p></header>
|
||||
<ToolsNav active="unit-score" />
|
||||
<section>
|
||||
<h2><span>01</span> 편성 및 성장</h2>
|
||||
{#if growthError}<p class="error" role="alert">{growthError}</p>{/if}
|
||||
{#if storageError}<p class="error" role="alert">{storageError}</p>{/if}
|
||||
<form on:submit|preventDefault={calculate}>
|
||||
<fieldset disabled={running}>
|
||||
<div class="members">
|
||||
<div class="card-field-header" aria-hidden="true"><span>카드 선택</span><span>개화</span></div>
|
||||
{#each ids as id, i}
|
||||
<div class="card-field">
|
||||
<label><span>{i + 1}번 선택</span><select value={id} on:change={event => selectCard(i, event.currentTarget.value)} required><option value="">카드 선택</option>{#each cards as card}<option value={card.id} disabled={ids.some((other, slot) => slot !== i && byId.get(other)?.characterId === card.characterId)}>{growth.collection[card.id]?.owned ? '● ' : ''}{card.rarity}★ {card.name}{card.versionName ? ` · ${card.versionName}` : ''}</option>{/each}</select></label>
|
||||
<div class="awakening"><div class="awakening-stepper">
|
||||
<button type="button" disabled={awakenings[i] <= 0} aria-label={`${i + 1}번 카드 개화 단계 낮추기`} on:click={() => setAwakening(i, awakenings[i] - 1)}>‹</button>
|
||||
<strong aria-live="polite">{awakenings[i]}</strong>
|
||||
<button type="button" disabled={awakenings[i] >= 5} aria-label={`${i + 1}번 카드 개화 단계 높이기`} on:click={() => setAwakening(i, awakenings[i] + 1)}>›</button>
|
||||
</div></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="settings">
|
||||
<label>리더<select value={leaderCharacterId} on:change={event => selectLeader(event.currentTarget.value)} required><option value="">리더 선택</option>{#each leaders as member}<option value={member.characterId}>{member.name}</option>{/each}</select></label>
|
||||
<label>리더 의상<select bind:value={outfitId} required disabled={!leaderCharacterId}><option value="" disabled>의상 선택</option>{#each outfits as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}</select></label>
|
||||
</div>
|
||||
{#if outfitDescription}<div class="outfit-effect"><span>리더 의상 효과</span><p>{outfitDescription}</p></div>{/if}
|
||||
<label class="power-override">인게임 종합력 덮어쓰기 (선택)<input type="number" min="1" max="1000000000" step="1" bind:value={overridePower} placeholder="비워두면 자동 계산" /></label>
|
||||
<div class="power"><span>자동 계산 종합력</span><strong>{power?.overallPower.toLocaleString('ko-KR') ?? '—'}</strong><small>보드 서포트 {effects.scoreSupportPercent}% · 의상 서포트 {power?.outfitScoreSupportPercent ?? 0}%</small></div>
|
||||
<button class="primary" type="submit">{running ? '1,024개 조합 비교 중…' : '최적 빈도 찾기'}</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
</section>
|
||||
{#if results.length}
|
||||
<section aria-label="빈도 분석 결과">
|
||||
<h2><span>02</span> 빈도 감소 추천</h2>
|
||||
<div class="power"><span>예상 최고 유닛 스코어</span><strong>{results[0].unitScore.toLocaleString('ko-KR')}</strong><small>종합력 {resultPower.toLocaleString('ko-KR')} 기준 · 1,024개 비교</small></div>
|
||||
<div class="table-wrap"><table><thead><tr><th>순위</th>{#each names as name}<th>{name}<small>빈도 감소</small></th>{/each}<th>예상 유닛 스코어</th></tr></thead><tbody>{#each results.slice(0, expanded ? 1024 : 10) as row, i}<tr class:best={i === 0}><td>{i + 1}</td>{#each row.reductions as reduction}<td>{reduction}%</td>{/each}<td><strong>{row.unitScore.toLocaleString('ko-KR')}</strong></td></tr>{/each}</tbody></table></div>
|
||||
<button class="reload" on:click={() => expanded = !expanded} aria-expanded={expanded}>{expanded ? '상위 10개만 보기' : '전체 1,024개 보기'}</button>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.card-field,.card-field-header{display:grid;grid-template-columns:minmax(0,1fr) 132px;gap:16px;align-items:end}.card-field > label{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center;gap:12px}.card-field > label > span{white-space:nowrap}.card-field-header{font-size:13px;font-weight:800;color:#8f98a8;align-items:center}.awakening{display:grid;gap:7px;font-size:13px;color:#aab2c0}.awakening-stepper{display:flex;align-items:center;overflow:hidden;border:1px solid #343e4c;border-radius:9px;background:#10141b;height:44px}.awakening-stepper button{border:0;border-radius:0;padding:0;width:36px;height:44px;font-size:24px}.awakening-stepper strong{flex:1;text-align:center;color:#e8ebf1;font-size:14px}.outfit-effect{display:flex;gap:20px;align-items:center;padding:14px 18px;background:#10141b;border:1px solid #343e4c;border-radius:9px;margin-top:12px;font-size:14px}.outfit-effect span{color:#edabc8;white-space:nowrap;font-size:13px;font-weight:850}.outfit-effect p{margin:0;color:#b9c1cd;font-size:14px;line-height:1.55}.power-override{margin-top:20px}.primary{margin-top:20px}@media(max-width:600px){.card-field,.card-field-header{grid-template-columns:minmax(0,1fr) 106px;gap:10px}.card-field > label{grid-template-columns:54px minmax(0,1fr);gap:6px}.awakening-stepper button{width:30px}.outfit-effect{align-items:flex-start;flex-direction:column;gap:8px}}
|
||||
main{max-width:1120px;margin:0 auto;padding:32px 24px 60px;color:#dfe4eb}h1{font-size:var(--page-title-size);line-height:1.15;letter-spacing:-.045em;margin:8px 0 12px}header p{color:#929cab;font-size:var(--page-description-size);line-height:1.6}.eyebrow{color:#ed8bb5;font-size:12px;font-weight:800;letter-spacing:2px}section{margin-top:24px;padding:24px;background:#171c24;border:1px solid #2c3441;border-radius:16px}h2{font-size:18px;margin:0 0 18px}h2 span{font-size:13px;font-weight:900;color:#ed8bb5;margin-right:10px}fieldset{border:0;padding:0;margin:0;min-width:0}.members{display:grid;gap:10px}label{display:grid;gap:7px;font-size:14px;font-weight:750;color:#8f98a8}input,select{width:100%;min-width:0;box-sizing:border-box;padding:12px;background:#10141b;color:#e4e8ef;border:1px solid #343e4c;border-radius:9px;font:inherit}.settings{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:24px}.power{display:flex;flex-wrap:wrap;gap:12px;align-items:center;background:#24212e;border-radius:12px;padding:20px;margin-top:20px}.power span,.power small{color:#aeb5c2}.power strong{color:#edabc8;font-size:26px}button{font:inherit;cursor:pointer;border:1px solid #424957;border-radius:8px;padding:10px 16px;color:#dfe4eb;background:#242b36}button:disabled{opacity:.5;cursor:wait}.primary{width:100%;background:#eda0c0;color:#251723;font-weight:800;border:0;padding:15px}.reload{margin-top:12px}.error{color:#ffb2ab}.table-wrap{overflow:auto;margin-top:20px}table{width:100%;border-collapse:collapse;font-size:13px;text-align:right}th,td{padding:14px 10px;border-bottom:1px solid #303744;white-space:nowrap}th{color:#a2abba}th small{display:block;font-weight:400;margin-top:4px}.best{background:#322435;color:#f5b8d2}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #ed8bb5;outline-offset:2px}@media(max-width:600px){main{padding:24px 12px}section{padding:16px}.settings{grid-template-columns:1fr}}
|
||||
</style>
|
||||
Reference in new issue
Block a user