feat: add town unit score optimizer with saved formations
Publish GitHub Pages output / publish (push) Successful in 41s

This commit is contained in:
dal4segno committed 2026-10-09 11:17:18 +09:00
1 parent e87dd3b1f3
commit 3cdea75ff7
7 files changed
+420 -2

No files matched your search

+150
View File
@@ -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>