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:
+7
-1
@@ -1,7 +1,13 @@
|
||||
---
|
||||
updatedAt: 2026-10-08T10:07:05.000Z
|
||||
updatedAt: 2026-10-09T00:49:57.362Z
|
||||
---
|
||||
|
||||
## 2026년 10월 9일
|
||||
|
||||
### 마을 유닛 스코어 빈도 최적화
|
||||
|
||||
`NEW` 도구에 마을 유닛 스코어를 추가했습니다. 직접 지정한 5인 편성과 개화·리더 의상에 저장된 보드·연결 카드·메모리·멤버 강화를 반영하고, 빈도 감소 1,024개 조합을 비교합니다. 200초 균일 기준의 추정 모델이며 가창 보너스는 제외합니다. 편성과 종합력 입력은 자동 저장되어 페이지 이동·새로고침 후에도 유지됩니다. 실제 빈도 설정 가능 여부는 게임에서 확인해야 하며 사용자 성장 데이터를 자동 변경하지 않습니다.
|
||||
|
||||
## 2026년 10월 8일
|
||||
|
||||
### 악곡 랜덤 결과 표시·복사 통일
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import BoardAwakeningPage from '../src/routes/tools/board-awakening/+page.svelte';
|
||||
import ToolsPage from '../src/routes/tools/+page.svelte';
|
||||
import SongListPage from '../src/routes/tools/song-list/+page.svelte';
|
||||
import UnitScorePage from '../src/routes/tools/unit-score/+page.svelte';
|
||||
import ComparePage from '../src/routes/compare/+page.svelte';
|
||||
import EventPage from '../src/routes/event/+page.svelte';
|
||||
import DataPage from '../src/routes/data/+page.svelte';
|
||||
@@ -35,6 +36,7 @@
|
||||
['/compare', ComparePage],
|
||||
['/tools/random', ToolsPage],
|
||||
['/tools/song-list', SongListPage],
|
||||
['/tools/unit-score', UnitScorePage],
|
||||
['/tools/holo-work', HoloWorkPage],
|
||||
['/tools/board-awakening', BoardAwakeningPage],
|
||||
['/random', ToolsPage],
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let active: 'random' | 'song-list' | 'holo-work' | 'board-awakening' | 'ai';
|
||||
export let active: 'random' | 'song-list' | 'holo-work' | 'board-awakening' | 'ai' | 'unit-score';
|
||||
</script>
|
||||
|
||||
<nav aria-label="도구 메뉴">
|
||||
<a href="/tools/random" aria-current={active === 'random' ? 'page' : undefined}>악곡 랜덤</a>
|
||||
<a href="/tools/song-list" aria-current={active === 'song-list' ? 'page' : undefined}>악곡 목록</a>
|
||||
<a href="/tools/unit-score" aria-current={active === 'unit-score' ? 'page' : undefined}>마을 유닛 스코어</a>
|
||||
<a href="/tools/holo-work" aria-current={active === 'holo-work' ? 'page' : undefined}>홀로워크 관리</a>
|
||||
<a href="/tools/board-awakening" aria-current={active === 'board-awakening' ? 'page' : undefined}>연결노드 개화 추천</a>
|
||||
<a href="/tools/ai" aria-current={active === 'ai' ? 'page' : undefined}>AI 연결</a>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type catalog from '../data/cards-summary.json';
|
||||
import { prepareTownUnitScore, searchTownUnitScore, townActiveMean } from './town-unit-score.ts';
|
||||
import { parseTownForm, TOWN_FORM_STORAGE_KEY } from '../storage/town-unit-score.ts';
|
||||
|
||||
const data = JSON.parse(readFileSync('src/lib/data/cards-summary.json', 'utf8')) as typeof catalog;
|
||||
const ids = ['card-00001-5-uniq-0000-00', 'card-00002-5-uniq-0001-00', 'card-00013-5-uniq-0002-00', 'card-00015-5-uniq-0003-00', 'card-00018-5-uniq-0004-00'];
|
||||
const cards = ids.map(id => data.cards.find(c => c.id === id)!);
|
||||
|
||||
test('마을 편성은 개화·리더 의상·종합력 입력과 함께 저장 후 복원된다', () => {
|
||||
const form = { ids, awakenings: [5, 4, 3, 2, 1], leaderCharacterId: cards[0].characterId,
|
||||
outfitId: cards[0].id, overridePower: 345678 };
|
||||
assert.deepEqual(parseTownForm(JSON.stringify(form), data.cards), form);
|
||||
assert.deepEqual(parseTownForm(JSON.stringify({ ...form, overridePower: undefined }), data.cards).overridePower, undefined);
|
||||
const partial = parseTownForm(JSON.stringify({ ...form, ids: [ids[0], '', ids[2], '', ''] }), data.cards);
|
||||
assert.deepEqual(partial.ids, [ids[0], '', ids[2], '', '']);
|
||||
assert.equal(TOWN_FORM_STORAGE_KEY, 'holodori.town-unit-score.v1');
|
||||
});
|
||||
|
||||
test('잘못되거나 오래된 저장 편성은 안전하게 정리한다', () => {
|
||||
for (const raw of [null, '{', 'null', '[]', '42']) {
|
||||
assert.deepEqual(parseTownForm(raw, data.cards).ids, Array(5).fill(''));
|
||||
}
|
||||
const saved = parseTownForm(JSON.stringify({ ids: [ids[0], ids[0], 'removed', ids[3]],
|
||||
awakenings: [9, 2, 3, -1], leaderCharacterId: cards[0].characterId, outfitId: cards[1].id,
|
||||
overridePower: -1 }), data.cards);
|
||||
assert.deepEqual(saved.ids, [ids[0], '', '', ids[3], '']);
|
||||
assert.deepEqual(saved.awakenings, [0, 0, 0, 0, 0]);
|
||||
assert.equal(saved.outfitId, cards[0].id);
|
||||
assert.equal(saved.overridePower, undefined);
|
||||
});
|
||||
|
||||
test('초기 편성이 저장된 값을 덮어쓰지 않도록 복원 이후에만 자동 저장한다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.match(page, /if \(formReady\) saveForm\(JSON.stringify\(\{ ids, awakenings, leaderCharacterId, outfitId, overridePower \}\)\)/);
|
||||
assert.ok(page.indexOf('const saved = parseTownForm') < page.indexOf('formReady = true'));
|
||||
assert.match(page, /localStorage.setItem\(TOWN_FORM_STORAGE_KEY, value\)/);
|
||||
});
|
||||
|
||||
test('200초 개화 0·5 기준값 및 1024개 전수 정렬을 재현한다', () => {
|
||||
for (const [bloom, maximum, zero] of [[0, 1381928, 1329976], [5, 1617241, 1553065]]) {
|
||||
const model = prepareTownUnitScore(cards, Array(5).fill(bloom), 300000, 0, 10, [10, 20, 30, 40, 50]);
|
||||
const before = JSON.stringify(model);
|
||||
const rows = searchTownUnitScore(model);
|
||||
assert.equal(rows.length, 1024);
|
||||
assert.equal(new Set(rows.map(r => r.reductions.join())).size, 1024);
|
||||
assert.equal(rows[0].unitScore, maximum);
|
||||
assert.deepEqual(rows[0].reductions, [12, 8, 12, 4, 8]);
|
||||
assert.equal(rows.find(r => r.reductions.every(v => v === 0))?.unitScore, zero);
|
||||
assert.ok(rows.every((r, i) => i === 0 || r.unitScore <= rows[i - 1].unitScore));
|
||||
assert.equal(JSON.stringify(model), before);
|
||||
}
|
||||
});
|
||||
test('200秒 integer sampling excludes end boundary and normalizes overlapping probability', () => {
|
||||
const model = prepareTownUnitScore(cards, Array(5).fill(0), 300000, 0, 0, Array(5).fill(0));
|
||||
model.cards = [model.cards[0]];
|
||||
Object.assign(model.cards[0], { cooldown: 20, duration: 10, probability: 1000, mag: 1000, base: 1000, onset: 0 });
|
||||
// Nine complete 10s windows (20..29 through 180..189), plus second 200.
|
||||
assert.equal(townActiveMean(model, [0], false), 91000 / 200);
|
||||
model.cards.push({ ...model.cards[0] });
|
||||
assert.equal(townActiveMean(model, [0, 0], false), 91000 / 200);
|
||||
});
|
||||
test('중복 멤버·잘못된 입력·미지원 스킬을 조용히 계산하지 않는다', () => {
|
||||
const make = (c = cards, power = 300000) => prepareTownUnitScore(c, Array(5).fill(0), power, 0, 0, Array(5).fill(0));
|
||||
assert.throws(() => make([cards[0], cards[0], ...cards.slice(2)]));
|
||||
assert.throws(() => make(cards, NaN));
|
||||
const unsupported = structuredClone(cards);
|
||||
unsupported[0].details.active[0].effect = '라이프 회복';
|
||||
assert.throws(() => make(unsupported));
|
||||
});
|
||||
test('Pages route and tool navigation expose the online unit-score page', () => {
|
||||
assert.match(readFileSync('offline/OfflineApp.svelte', 'utf8'), /\['\/tools\/unit-score', UnitScorePage\]/);
|
||||
assert.match(readFileSync('src/lib/components/ToolsNav.svelte', 'utf8'), /href="\/tools\/unit-score"/);
|
||||
});
|
||||
|
||||
test('개화 입력은 반복문 인덱스 양방향 바인딩 대신 명시적인 불변 업데이트를 사용한다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.doesNotMatch(page, /bind:value=\{awakenings\[i\]\}/);
|
||||
assert.match(page, /setAwakening\(i, awakenings\[i\] \+ 1\)/);
|
||||
assert.match(page, /setAwakening\(i, awakenings\[i\] - 1\)/);
|
||||
assert.match(page, /awakenings = awakenings.map/);
|
||||
});
|
||||
|
||||
test('성장 데이터는 자동으로 읽으며 수동 불러오기 버튼을 표시하지 않는다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.match(page, /onMount\(\(\) => \{\s*readGrowth\(\)/);
|
||||
assert.match(page, /window.addEventListener\('storage', refresh\)/);
|
||||
assert.doesNotMatch(page, /on:click=\{readGrowth\}/);
|
||||
});
|
||||
|
||||
test('리더별 의상과 효과를 표시하고 상시 주의·안내 문구는 표시하지 않는다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.match(page, /outfits = leaderOutfitCards\(cards, leaderCharacterId\)/);
|
||||
assert.match(page, /outfitId = leaderOutfitCards\(cards, id\)\[0\]\?\.id/);
|
||||
assert.match(page, /리더 의상 효과/);
|
||||
assert.doesNotMatch(page, /<aside>|class="hint"|성장 데이터 다시 불러오기/);
|
||||
});
|
||||
|
||||
test('카드 선택과 개화는 공통 열 제목을 한 번만 표시한다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.equal((page.match(/<span>개화<\/span>/g) ?? []).length, 1);
|
||||
assert.match(page, /class="card-field-header"/);
|
||||
assert.match(page, /\.card-field > label\{display:grid;grid-template-columns:72px minmax\(0,1fr\)/);
|
||||
});
|
||||
|
||||
test('편성 입력의 글자 크기와 굵기는 배치 분석과 동일하다', () => {
|
||||
const page = readFileSync('src/routes/tools/unit-score/+page.svelte', 'utf8');
|
||||
assert.match(page, /label\{display:grid;gap:7px;font-size:14px;font-weight:750/);
|
||||
assert.match(page, /\.awakening-stepper strong\{[^}]*font-size:14px/);
|
||||
assert.match(page, /\.card-field-header\{font-size:13px;font-weight:800/);
|
||||
assert.match(page, /\.outfit-effect span\{[^}]*font-size:13px;font-weight:850/);
|
||||
assert.doesNotMatch(page, /\.card-field > label\{[^}]*font-size:12px/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type catalog from '../data/cards-summary.json';
|
||||
import { plainSkillText as clean, skillTargetBelongsTo as matches } from './skill-condition.ts';
|
||||
|
||||
type Card = (typeof catalog.cards)[number];
|
||||
type PreparedCard = {
|
||||
name: string; mag: number; base: number; onset: number; probability: number;
|
||||
cooldown: number; duration: number; effect: number; passive: number;
|
||||
probabilityUp: number; specialMag: number; specialDuration: number; specialRider: number;
|
||||
};
|
||||
export type TownModel = { overallPower: number; cards: PreparedCard[]; outfit: number; board: number };
|
||||
export type TownScore = { reductions: number[]; unitScore: number; bonus: number; uncertain: boolean };
|
||||
export const TOWN_DURATION_SECONDS = 200;
|
||||
const ceil = (value: number) => Math.ceil(value - Number.EPSILON * Math.max(1, Math.abs(value)) * 4);
|
||||
function magnitude(text: string): number {
|
||||
const match = clean(text).match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
|
||||
if (!match) throw new Error('이 카드의 스킬은 유닛 스코어 모델에서 지원하지 않습니다.');
|
||||
return Math.round(Number(match[1]) * 10);
|
||||
}
|
||||
function gate(condition: string, cards: Card[]): boolean {
|
||||
const text = clean(condition);
|
||||
if (!text || /콤보|라이프/.test(text)) return true;
|
||||
const match = text.match(/^(.+?)\s*(\d+)명 이상이면$/);
|
||||
if (!match) throw new Error(`지원하지 않는 스킬 조건: ${text}`);
|
||||
return cards.filter(card => matches(card, match[1])).length >= Number(match[2]);
|
||||
}
|
||||
function finite(value: number): boolean { return Number.isFinite(value) && value >= 0; }
|
||||
|
||||
/** Port of the user-supplied unit-score.html model, not a verified game formula.
|
||||
* Samples integer seconds 1..200; 40-combo onset is 20s. Rates are per mille.
|
||||
* Preserve its probability normalization, rounding and category correction as-is.
|
||||
*/
|
||||
export function prepareTownUnitScore(cards: Card[], awakenings: number[], overallPower: number,
|
||||
outfitSupport: number, boardSupport: number, boardRates: number[]): TownModel {
|
||||
if (cards.length !== 5 || new Set(cards.map(c => c.characterId)).size !== 5) throw new Error('서로 다른 멤버 5명을 선택해 주세요.');
|
||||
if (!finite(overallPower) || overallPower <= 0 || !finite(outfitSupport) || !finite(boardSupport)
|
||||
|| awakenings.length !== 5 || boardRates.length !== 5 || !boardRates.every(finite)
|
||||
|| !awakenings.every(a => Number.isInteger(a) && a >= 0 && a <= 5)) throw new Error('종합력·개화·보드 입력을 확인해 주세요.');
|
||||
const passive = [0, 0, 0, 0, 0];
|
||||
const natural = cards.map((card, i) => Object.values(card.power.statsAtMaxLevel).reduce((sum, v) => sum + Math.ceil(v * (awakenings[i] >= 2 ? 110 : 100) / 100), 0));
|
||||
cards.forEach((card, i) => {
|
||||
const text = clean(card.details.passive.find(s => s.level === (awakenings[i] >= 4 ? 2 : 1))?.description ?? '');
|
||||
const at = text.indexOf('스코어 서포트 효과');
|
||||
if (at < 0) return;
|
||||
const prefix = text.slice(0, at).trim();
|
||||
const condition = prefix.split('%').at(-1)?.trim().match(/(.+?\d+명 이상이면)/)?.[1];
|
||||
if (condition && !gate(condition, cards)) return;
|
||||
const target = (prefix.includes('이면') ? prefix.slice(prefix.lastIndexOf('이면') + 2) : prefix).trim();
|
||||
let recipients = cards.map((_, index) => index);
|
||||
if (/자신의?$/.test(target)) recipients = [i];
|
||||
else if (!/전원의?$/.test(target)) {
|
||||
const group = target.match(/(.+?)\s*(\d+)명의?$/);
|
||||
if (!group) throw new Error(`패시브 적용 대상 미지원: ${card.name}`);
|
||||
recipients = recipients.filter(index => matches(cards[index], group[1])).sort((a, b) => natural[b] - natural[a] || a - b).slice(0, Number(group[2]));
|
||||
}
|
||||
recipients.forEach(index => passive[index] += magnitude(text.slice(at)));
|
||||
});
|
||||
const prepared = cards.map((card, i): PreparedCard => {
|
||||
const a = card.details.active.find(s => s.level === (awakenings[i] >= 1 ? 2 : 1));
|
||||
const s = card.details.special.find(s => s.level === (awakenings[i] >= 3 ? 2 : 1));
|
||||
if (!a || !s) throw new Error(`스킬 데이터 없음: ${card.name}`);
|
||||
const combo = clean(a.condition ?? '').match(/(\d+)콤보/);
|
||||
if (combo && Number(combo[1]) !== 40) throw new Error(`검증되지 않은 콤보 조건: ${card.name}`);
|
||||
const baseText = a.condition ? clean(a.description).split(clean(a.condition))[0] : a.effect;
|
||||
const base = /스코어(?:가|\s+서포트\s+효과)\s*\d/.test(baseText) ? magnitude(baseText) : 0;
|
||||
const rider = clean(s.effect).match(/발동률이?\s*(\d+(?:\.\d+)?)%/);
|
||||
if (!(Number(a.coolTimeSeconds) > 0 && Number(a.durationSeconds) > 0 && Number(a.probabilityPercent) > 0 && Number(s.durationSeconds) > 0)) throw new Error(`스킬 수치 미지원: ${card.name}`);
|
||||
return { name: card.name + (card.versionName ? ` · ${card.versionName}` : ''),
|
||||
mag: gate(a.condition ?? '', cards) ? magnitude(a.effect) : base, base, onset: combo ? 20 : 0,
|
||||
probability: Number(a.probabilityPercent) * 10, cooldown: Number(a.coolTimeSeconds), duration: Number(a.durationSeconds),
|
||||
effect: (outfitSupport + boardSupport) * 10 + passive[i], passive: passive[i], probabilityUp: boardRates[i] * 10,
|
||||
specialMag: magnitude(s.effect), specialDuration: Number(s.durationSeconds),
|
||||
specialRider: gate(s.condition ?? '', cards) && rider ? Number(rider[1]) * 10 : 0 };
|
||||
});
|
||||
return { cards: prepared, overallPower, outfit: outfitSupport * 10, board: boardSupport * 10 };
|
||||
}
|
||||
|
||||
export function townActiveMean(model: TownModel, reductions: number[], boosted: boolean): number {
|
||||
let sum = 0;
|
||||
for (let t = 1; t <= TOWN_DURATION_SECONDS; t++) {
|
||||
let weighted = 0, probability = 0;
|
||||
model.cards.forEach((card, i) => {
|
||||
const cd = card.cooldown / (1 + (boosted ? reductions[i] : 0) / 100);
|
||||
const k = Math.floor((t + 1e-9) / cd);
|
||||
if (k < 1 || t >= k * cd + card.duration - 1e-9) return;
|
||||
let mag = t < card.onset ? card.base : card.mag, p = card.probability;
|
||||
if (boosted) { mag = ceil(mag * (1 + card.effect / 1000)); p = ceil(p * (1 + card.probabilityUp / 1000)); }
|
||||
weighted += mag * p; probability += p;
|
||||
});
|
||||
sum += weighted / Math.max(1000, probability);
|
||||
}
|
||||
return sum / TOWN_DURATION_SECONDS;
|
||||
}
|
||||
export function searchTownUnitScore(model: TownModel): TownScore[] {
|
||||
const active = ceil(townActiveMean(model, [0, 0, 0, 0, 0], false));
|
||||
const special = ceil(active * model.cards.reduce((sum, c) => sum + c.specialMag * c.specialDuration * (1 + c.specialRider / 2000), 0) / 120000);
|
||||
const results: TownScore[] = [];
|
||||
for (let code = 0; code < 1024; code++) {
|
||||
const reductions = Array.from({ length: 5 }, (_, i) => (Math.floor(code / 4 ** i) % 4) * 4);
|
||||
const n = Number(model.outfit > 0) + Number(model.cards.some(c => c.passive > 0))
|
||||
+ Number(model.board > 0 || model.cards.some((c, i) => c.probabilityUp > 0 || reductions[i] > 0));
|
||||
const bonus = ceil(townActiveMean(model, reductions, true)) + Math.max(0, n - 1) + special;
|
||||
results.push({ reductions, bonus, unitScore: ceil(model.overallPower * 2.03734 * (1 + bonus / 1000)), uncertain: n === 1 || n === 3 });
|
||||
}
|
||||
return results.sort((a, b) => b.unitScore - a.unitScore || a.reductions.reduce((s, v) => s + v, 0) - b.reductions.reduce((s, v) => s + v, 0) || a.reductions.join().localeCompare(b.reductions.join()));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { leaderOutfitCards } from '../engine/leader-outfits.ts';
|
||||
import type catalog from '../data/cards-summary.json';
|
||||
|
||||
export const TOWN_FORM_STORAGE_KEY = 'holodori.town-unit-score.v1';
|
||||
export type TownForm = {
|
||||
ids: string[];
|
||||
awakenings: number[];
|
||||
leaderCharacterId: string;
|
||||
outfitId: string;
|
||||
overridePower?: number;
|
||||
};
|
||||
|
||||
export function parseTownForm(raw: string | null, cards: typeof catalog.cards): TownForm {
|
||||
let value: Partial<TownForm> = {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw ?? '{}');
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) value = parsed;
|
||||
} catch { /* Invalid saved drafts fall back to an empty form. */ }
|
||||
const byId = new Map(cards.map(card => [card.id, card]));
|
||||
const seen = new Set<string>();
|
||||
const ids = Array.from({ length: 5 }, (_, i) => {
|
||||
const id = Array.isArray(value.ids) ? value.ids[i] : '';
|
||||
const card = byId.get(id);
|
||||
if (!card || seen.has(card.characterId)) return '';
|
||||
seen.add(card.characterId);
|
||||
return card.id;
|
||||
});
|
||||
const awakenings = ids.map((id, i) => {
|
||||
const a = Array.isArray(value.awakenings) ? value.awakenings[i] : 0;
|
||||
return id && Number.isInteger(a) && a >= 0 && a <= 5 ? a : 0;
|
||||
});
|
||||
const leaderCharacterId = cards.some(card => card.rarity === 5 && card.characterId === value.leaderCharacterId)
|
||||
? value.leaderCharacterId! : '';
|
||||
const outfits = leaderOutfitCards(cards, leaderCharacterId);
|
||||
const outfitId = outfits.find(card => card.id === value.outfitId)?.id ?? outfits[0]?.id ?? '';
|
||||
const p = value.overridePower;
|
||||
return { ids, awakenings, leaderCharacterId, outfitId,
|
||||
overridePower: typeof p === 'number' && Number.isInteger(p) && p > 0 && p <= 1000000000 ? p : undefined };
|
||||
}
|
||||
@@ -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