feat: release version 1.0.7

This commit is contained in:
2026-08-21 19:06:55 +09:00
parent 5d29b63f3a
commit 71dfe11bc4
406 changed files with 3086 additions and 1172 deletions
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { songOptions, type SongChartSummary, type SongSort } from '$lib/data/song-catalog.ts';
export let charts: readonly SongChartSummary[];
export let difficulty: string;
export let value: string;
const dispatch = createEventDispatcher<{ change: void }>();
let query = '';
let sort: SongSort = 'title';
$: options = songOptions(charts, difficulty, query, sort);
function selectSong(event: Event) {
const nextValue = (event.currentTarget as HTMLSelectElement).value;
if (!nextValue) return;
value = nextValue;
dispatch('change');
}
</script>
<div class="song-picker">
<label class="song-search">
<span>악곡 검색</span>
<input bind:value={query} type="search" placeholder="영문 곡명 검색" autocomplete="off">
</label>
<label class="song-sort">
<span>정렬</span>
<select bind:value={sort}>
<option value="title">알파벳순</option>
<option value="difficulty-asc">난이도 낮은순</option>
<option value="difficulty-desc">난이도 높은순</option>
</select>
</label>
<label class="song-select">
<span>악곡 <small>{options.length}곡</small></span>
<select value={value} on:change={selectSong} disabled={options.length === 0}>
{#if options.length === 0}
<option>검색 결과가 없습니다</option>
{:else}
{#if !options.some((song) => song.id === value)}
<option value="" disabled>검색 결과에서 악곡을 선택하세요</option>
{/if}
{#each options as song}
<option value={song.id}>{song.title}{song.difficultyLevel === null ? '' : ` · Lv. ${song.difficultyLevel}`}</option>
{/each}
{/if}
</select>
</label>
{#if query.trim()}
<p class:no-results={options.length === 0} class="search-status" aria-live="polite">
{options.length > 0
? `${options.length}곡을 찾았습니다. 목록에서 선택하세요.`
: '검색 결과가 없습니다.'}
</p>
{/if}
</div>
<style>
.song-picker { display: grid; grid-template-columns: minmax(180px, .9fr) minmax(140px, .55fr) minmax(260px, 1.4fr); gap: 9px; }
label { display: grid; gap: 7px; min-width: 0; color: #8f98a8; font-size: 14px; font-weight: 750; }
label > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
small { color: #737d8d; font-size: 11px; }
input, select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
input::placeholder { color: #68717f; opacity: 1; }
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
select:disabled { color: #747d8b; }
.search-status { grid-column: 1 / -1; margin: -1px 2px 0; color: #8fa99c; font-size: 12px; font-weight: 750; line-height: 1.45; }
.search-status.no-results { color: #d09494; }
@media (max-width: 900px) {
.song-picker { grid-template-columns: 1fr minmax(140px, .55fr); }
.song-select { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.song-picker { grid-template-columns: 1fr; }
.song-select { grid-column: auto; }
}
</style>
+98 -98
View File
@@ -323,7 +323,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -332,7 +332,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -484,7 +484,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -493,7 +493,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -750,7 +750,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -759,7 +759,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -1008,7 +1008,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -1017,7 +1017,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -1927,7 +1927,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -1936,7 +1936,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -2749,7 +2749,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -2758,7 +2758,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -2894,7 +2894,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -2903,7 +2903,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -3393,7 +3393,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -3402,7 +3402,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -3659,7 +3659,7 @@
"probabilityPercent": null,
"durationSeconds": 11,
"condition": "",
"effect": "스코어 서포트 효과 110%"
"effect": "스코어 서포트 효과 110% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -3668,7 +3668,7 @@
"probabilityPercent": null,
"durationSeconds": 11,
"condition": "",
"effect": "스코어 서포트 효과 130%"
"effect": "스코어 서포트 효과 130% 스킬 발동률이 45% UP"
}
],
"connect": [
@@ -3796,7 +3796,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -3805,7 +3805,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -4199,7 +4199,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -4208,7 +4208,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -4602,7 +4602,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -4611,7 +4611,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -5448,7 +5448,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -5457,7 +5457,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -6769,7 +6769,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 100%"
"effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
},
{
"level": 2,
@@ -6778,7 +6778,7 @@
"probabilityPercent": null,
"durationSeconds": 12,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}
],
"connect": [
@@ -7857,7 +7857,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -7866,7 +7866,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -7978,7 +7978,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -7987,7 +7987,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -8308,7 +8308,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -8317,7 +8317,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -9009,7 +9009,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -9018,7 +9018,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -9170,7 +9170,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 120%"
"effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
},
{
"level": 2,
@@ -9179,7 +9179,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 145%"
"effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
}
],
"connect": [
@@ -16033,7 +16033,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -16042,7 +16042,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -16270,7 +16270,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -16279,7 +16279,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -16507,7 +16507,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -16516,7 +16516,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -16586,7 +16586,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -16595,7 +16595,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -16665,7 +16665,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -16674,7 +16674,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -16902,7 +16902,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -16911,7 +16911,7 @@
"probabilityPercent": null,
"durationSeconds": 9,
"condition": "",
"effect": "스코어 서포트 효과 60%"
"effect": "스코어 서포트 효과 60% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -16981,7 +16981,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -16990,7 +16990,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -17060,7 +17060,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -17069,7 +17069,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -17139,7 +17139,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -17148,7 +17148,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -17218,7 +17218,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -17227,7 +17227,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -17376,7 +17376,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -17385,7 +17385,7 @@
"probabilityPercent": null,
"durationSeconds": 9,
"condition": "",
"effect": "스코어 서포트 효과 60%"
"effect": "스코어 서포트 효과 60% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -17534,7 +17534,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -17543,7 +17543,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -17613,7 +17613,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -17622,7 +17622,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -17850,7 +17850,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -17859,7 +17859,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -17929,7 +17929,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -17938,7 +17938,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -18245,7 +18245,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -18254,7 +18254,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -18403,7 +18403,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -18412,7 +18412,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -18561,7 +18561,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -18570,7 +18570,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -18798,7 +18798,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -18807,7 +18807,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -18956,7 +18956,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -18965,7 +18965,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -19114,7 +19114,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -19123,7 +19123,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -19193,7 +19193,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -19202,7 +19202,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -19272,7 +19272,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 45%"
"effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -19281,7 +19281,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -19430,7 +19430,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
},
{
"level": 2,
@@ -19439,7 +19439,7 @@
"probabilityPercent": null,
"durationSeconds": 7,
"condition": "",
"effect": "스코어 서포트 효과 80%"
"effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
}
],
"connect": [],
@@ -19509,7 +19509,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -19518,7 +19518,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -19588,7 +19588,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -19597,7 +19597,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
@@ -19667,7 +19667,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -19676,7 +19676,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -19825,7 +19825,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -19834,7 +19834,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -20141,7 +20141,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 50%"
"effect": "스코어 서포트 효과 50% 라이프가 300 회복"
},
{
"level": 2,
@@ -20150,7 +20150,7 @@
"probabilityPercent": null,
"durationSeconds": 8,
"condition": "",
"effect": "스코어 서포트 효과 70%"
"effect": "스코어 서포트 효과 70% 라이프가 400 회복"
}
],
"connect": [],
@@ -20220,7 +20220,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 40%"
"effect": "스코어 서포트 효과 40% 라이프가 300 회복"
},
{
"level": 2,
@@ -20229,7 +20229,7 @@
"probabilityPercent": null,
"durationSeconds": 10,
"condition": "",
"effect": "스코어 서포트 효과 55%"
"effect": "스코어 서포트 효과 55% 라이프가 400 회복"
}
],
"connect": [],
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
export type SongSort = 'title' | 'difficulty-asc' | 'difficulty-desc';
export type SongChartSummary = {
songId: string;
title: string;
difficulty: string;
difficultyLevel?: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
};
export type SongOption = {
id: string;
title: string;
difficultyLevel: number | null;
};
const englishTitleCollator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true
});
function normalizedSearch(value: string): string {
return value.normalize('NFKD').toLocaleLowerCase('en').replace(/[^a-z0-9]+/g, ' ').trim();
}
export function songOptions(
charts: readonly SongChartSummary[],
difficulty: string,
query = '',
sort: SongSort = 'title'
): SongOption[] {
const levelBySongId = new Map(charts
.filter((chart) => chart.difficulty === difficulty)
.map((chart) => [chart.songId, chart.difficultyLevel ?? null]));
const options = Array.from(new Map(charts.map((chart) => [chart.songId, {
id: chart.songId,
title: chart.title,
difficultyLevel: levelBySongId.get(chart.songId) ?? null
}])).values());
const search = normalizedSearch(query);
const filtered = search
? options.filter((song) => normalizedSearch(`${song.title} ${song.id}`).includes(search))
: options;
const titleOrder = (left: SongOption, right: SongOption) =>
englishTitleCollator.compare(left.title, right.title) || left.id.localeCompare(right.id);
return filtered.sort((left, right) => {
if (sort === 'title') return titleOrder(left, right);
if (left.difficultyLevel === null) return right.difficultyLevel === null ? titleOrder(left, right) : 1;
if (right.difficultyLevel === null) return -1;
const leftLevel = left.difficultyLevel;
const rightLevel = right.difficultyLevel;
const levelOrder = sort === 'difficulty-desc' ? rightLevel - leftLevel : leftLevel - rightLevel;
return levelOrder || titleOrder(left, right);
});
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { effectiveActiveProbability } from './active-probability.ts';
import type { OptimizerCard } from './types.ts';
function card(probabilityPercent: number, activationRateUpPercent = 0): OptimizerCard {
return {
id: 'card',
name: '카드',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent,
activationRateUpPercent
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
}
test('블루 보드와 스페셜 발동률 UP은 배율을 합산한 뒤 기본 발동률에 곱한다', () => {
assert.ok(Math.abs(effectiveActiveProbability(card(55, 30), 40) - 0.935) < 1e-12);
});
test('합산한 발동률은 100%를 넘지 않는다', () => {
assert.equal(effectiveActiveProbability(card(55, 50), 40), 1);
});
+13
View File
@@ -0,0 +1,13 @@
import type { OptimizerCard } from './types.ts';
/**
* 액티브 기본 발동률에 상시 보너스와 발동 시점의 스페셜 보너스를 합산해 적용한다.
*/
export function effectiveActiveProbability(
card: OptimizerCard,
timedActivationRateUpPercent = 0
): number {
const baseProbability = (card.active.probabilityPercent ?? 100) / 100;
const totalRateUpPercent = (card.active.activationRateUpPercent ?? 0) + timedActivationRateUpPercent;
return Math.max(0, Math.min(1, baseProbability * (1 + totalRateUpPercent / 100)));
}
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
const source: BoardConnectionOptimizerSource = {
members: [{ id: 'member', positionGroupId: 'model' }],
models: {
model: [
{ groupId: 'S-center', x: 0, y: 0 },
{ groupId: 'G-left', x: -1, y: 0 },
{ groupId: 'G-right', x: 1, y: 0 },
{ groupId: 'S-right', x: 3, y: 0 },
{ groupId: 'G-far', x: 4, y: 0 }
]
},
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-right': [{ characterIds: [], type: 'connection', effect: null }],
'G-left': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 100 } }],
'G-right': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 300 } }],
'G-far': [{ characterIds: [], type: 'all_member', effect: { type: 'all_parameter_up', value: 200 } }]
}
};
const leftCard = {
id: 'left-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }] }]
};
const rightCard = {
id: 'right-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
};
test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]);
assert.deepEqual(result.placements, {
member: { 'S-center': 'left-card', 'S-right': 'right-card' }
});
assert.equal(result.assignments.length, 2);
});
test('비활성 연결 노드와 비활성 효과 노드는 배치 점수에 포함하지 않는다', () => {
const memberBoards = { member: ['G-left'] };
const before = structuredClone(memberBoards);
const result = optimizeBoardConnectionCards(source, memberBoards, [leftCard, rightCard]);
assert.deepEqual(result.placements, { member: { 'S-center': 'left-card' } });
assert.deepEqual(memberBoards, before);
});
test('5개화 카드는 2레벨 연결 범위와 배율을 사용한다', () => {
const awakened = {
id: 'awakened', awakening: 5,
connect: [
{ level: 1, effectPermilUp: 100, extent: [{ x: -1, y: 0 }] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
]
};
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [awakened]);
assert.deepEqual(result.placements, { member: { 'S-center': 'awakened' } });
assert.equal(result.totalWeight, 60_000);
});
test('솔로와 단체 10%를 각각 우선하며 단체는 다른 멤버 보너스도 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [
{ id: 'target', positionGroupId: 'model' },
{ id: 'helper', positionGroupId: 'model' }
],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{
characterIds: ['target'], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
},
{
characterIds: ['helper'], type: 'content',
effect: {
id: 'music_singer_type-group',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}
]
}
};
const cards = ['a', 'b'].map((id) => ({
id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 4000, extent: [{ x: 1, y: 0 }] }]
}));
const result = optimizeBoardConnectionCards(
bonusSource,
{ target: ['Y'], helper: ['Y'] },
cards,
{ targetMemberId: 'target', requireSoloSongBonus: true, requireGroupSongBonus: true }
);
assert.equal(result.soloSongBonusPercent, 10);
assert.equal(result.groupSongBonusPercent, 10);
assert.equal(result.assignments.length, 2);
});
test('10%에 부족한 활성 노드만 있으면 가능한 보너스까지만 최적화한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'target', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [{
characterIds: [], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { target: ['Y'] }, [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}], { targetMemberId: 'target', requireSoloSongBonus: true });
assert.equal(result.soloSongBonusPercent, 4);
assert.deepEqual(result.placements, { target: { S: 'card' } });
});
test('편성 기반 배치 가중치를 제공하면 해당 효율을 우선한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1
});
assert.equal(result.placements.member['S-right'], 'right-card');
});
test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1,
placementPriorityWeight: (card, _memberId, socketId) => card.id === 'left-card' && socketId === 'S-right' ? 1 : 0
});
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => {
const prioritySource: BoardConnectionOptimizerSource = {
members: [{ id: 'leader', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 },
{ groupId: 'S-top', x: 0, y: 3 }, { groupId: 'R', x: 1, y: 3 }
] },
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-top': [{ characterIds: [], type: 'connection', effect: null }],
Y: [{ characterIds: [], type: 'content', effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 50
} }],
R: [{ characterIds: [], type: 'leader', effect: { type: 'performance_up', value: 100 } }]
}
};
const singleCard = [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}];
const result = optimizeBoardConnectionCards(prioritySource, { leader: ['Y', 'S-top', 'R'] }, singleCard, {
targetMemberId: 'leader',
requireSoloSongBonus: true,
prioritizePlacementWeight: true,
placementWeight: (_card, _memberId, socketId) => socketId === 'S-top' ? 100 : 0
});
assert.deepEqual(result.placements, { leader: { 'S-top': 'card' } });
assert.equal(result.soloSongBonusPercent, 5);
});
test('선택한 단체 악곡의 가창자 보드만 단체 보너스에 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'singer', positionGroupId: 'model' }, { id: 'outsider', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{ characterIds: ['singer'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20 } },
{ characterIds: ['outsider'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 80 } }
]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { singer: ['Y'], outsider: ['Y'] }, [], {
targetMemberId: 'singer', requireGroupSongBonus: true, groupSingerMemberIds: ['singer']
});
assert.equal(result.groupSongBonusPercent, 2);
});
@@ -0,0 +1,226 @@
import { maximumWeightMatching } from './maximum-weight-matching.ts';
type OptimizableBoardEffect = {
id?: string;
type: string;
value: number | null;
trigger?: string | null;
};
type OptimizableBoardNode = {
characterIds: readonly string[];
type: string;
effect: OptimizableBoardEffect | null;
};
type OptimizableBoardPosition = { groupId: string; x: number; y: number };
export type BoardConnectionOptimizerSource = {
members: readonly { id: string; positionGroupId: string }[];
models: Readonly<Record<string, readonly OptimizableBoardPosition[]>>;
nodeGroups: Readonly<Record<string, readonly OptimizableBoardNode[]>>;
};
export type OptimizableConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnectionAssignment = {
memberId: string;
socketId: string;
cardId: string;
weight: number;
};
export type BoardConnectionOptimizationResult = {
placements: Record<string, Record<string, string>>;
assignments: BoardConnectionAssignment[];
totalWeight: number;
soloSongBonusPercent: number;
groupSongBonusPercent: number;
};
export type BoardConnectionOptimizationOptions = {
targetMemberId?: string;
requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
};
function nodeForMember(
source: BoardConnectionOptimizerSource,
groupId: string,
memberId: string
): OptimizableBoardNode | null {
const nodes = source.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function effectValue(effect: OptimizableBoardEffect | null): number {
if (!effect || effect.value === null || effect.trigger === 'work') return 0;
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') return 0;
// Song bonuses are contextual and are valued only when the user requests
// the corresponding solo/group target below.
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return 0;
const value = Math.max(0, Number(effect.value) || 0);
return effect.type.startsWith('all_parameter_up') ? value * 3 : value;
}
function selectedEffect(card: OptimizableConnectionCard) {
const level = card.awakening >= 5 ? 2 : 1;
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
}
function songBonusModes(
effect: OptimizableBoardEffect | null,
ownerId: string,
targetMemberId: string,
groupSingerMemberIds?: readonly string[]
): ('solo' | 'group')[] {
if (!effect?.id || effect.type !== 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return [];
const mode = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (mode === 'all') return ['solo', 'group'];
if (mode === 'solo' && ownerId === targetMemberId) return ['solo'];
// A unit song bonus may be supplied by another participating singer's board.
if (mode === 'group' && (!groupSingerMemberIds || groupSingerMemberIds.includes(ownerId))) return ['group'];
return [];
}
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
options: BoardConnectionOptimizationOptions = {}
): BoardConnectionOptimizationResult {
const targetMemberId = options.targetMemberId ?? '';
let baseSoloBonus = 0;
let baseGroupBonus = 0;
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const centerId = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
const active = new Set(memberBoards[member.id] ?? []);
if (centerId) active.add(centerId);
for (const position of positions) {
if (!active.has(position.groupId)) continue;
const node = nodeForMember(source, position.groupId, member.id);
const value = Math.max(0, Number(node?.effect?.value) || 0);
for (const mode of songBonusModes(node?.effect ?? null, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') baseSoloBonus += value;
else baseGroupBonus += value;
}
}
return positions
.filter((position) => active.has(position.groupId) && nodeForMember(source, position.groupId, member.id)?.type === 'connection')
.map((socket) => ({ member, socket, positions, active }));
});
const cards = ownedCards.filter((card) => selectedEffect(card));
const emptyResult = {
placements: {}, assignments: [], totalWeight: 0,
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
groupSongBonusPercent: Math.min(10, baseGroupBonus / 10)
};
if (cards.length === 0 || slots.length === 0) return emptyResult;
const soloBoosts = cards.map(() => slots.map(() => 0));
const groupBoosts = cards.map(() => slots.map(() => 0));
const genericWeights = cards.map((card, cardIndex) => slots.map(({ member, socket, positions, active }, slotIndex) => {
const effect = selectedEffect(card);
if (!effect) return 0;
const positionByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position]));
const coveredValue = effect.extent.reduce((total, offset) => {
const position = positionByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (!position || !active.has(position.groupId)) return total;
const nodeEffect = nodeForMember(source, position.groupId, member.id)?.effect ?? null;
const value = effectValue(nodeEffect);
const boost = Math.max(0, Number(nodeEffect?.value) || 0) * Math.max(0, effect.effectPermilUp);
for (const mode of songBonusModes(nodeEffect, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') soloBoosts[cardIndex][slotIndex] += boost;
else groupBoosts[cardIndex][slotIndex] += boost;
}
return total + value;
}, 0);
const defaultWeight = coveredValue * Math.max(0, effect.effectPermilUp);
return Math.max(0, options.placementWeight?.(card, member.id, socket.groupId) ?? defaultWeight);
}));
const priorityUnits = cards.map((card) => slots.map(({ member, socket }) =>
Math.max(0, Math.round((options.placementPriorityWeight?.(card, member.id, socket.groupId) ?? 0) * 1000))
));
const maximumAssignments = Math.min(cards.length, slots.length);
const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments;
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
const priorityScale = Math.max(1, ...weights.flatMap((row) => row)) * Math.max(cards.length, slots.length) + 1;
const ratios = options.requireSoloSongBonus && options.requireGroupSongBonus
? [1 / 256, 1 / 16, 1 / 4, 1, 4, 16, 256]
: [1];
const priorityMultipliers = options.requireSoloSongBonus || options.requireGroupSongBonus
? [0, 1 / 1_000_000, 1 / 10_000, 1 / 100, 1, 100]
: [0];
const candidates = priorityMultipliers.flatMap((priorityMultiplier) => ratios.map((ratio) => {
const prioritized = weights.map((row, cardIndex) => row.map((weight, slotIndex) => weight + priorityScale * (
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized);
const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0);
const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const feasible = (!options.requireSoloSongBonus || soloBonus >= 100)
&& (!options.requireGroupSongBonus || groupBonus >= 100);
const fulfillment = Math.min(
options.requireSoloSongBonus ? soloBonus / 100 : Number.POSITIVE_INFINITY,
options.requireGroupSongBonus ? groupBonus / 100 : Number.POSITIVE_INFINITY
);
const totalFulfillment = (options.requireSoloSongBonus ? Math.min(1, soloBonus / 100) : 0)
+ (options.requireGroupSongBonus ? Math.min(1, groupBonus / 100) : 0);
return { matches, genericWeight, soloBonus, groupBonus, feasible, fulfillment, totalFulfillment };
}));
const best = candidates.sort((left, right) => options.prioritizePlacementWeight
? right.genericWeight - left.genericWeight
|| Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
: Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
|| right.genericWeight - left.genericWeight)[0];
const matches = best.matches;
const placements: Record<string, Record<string, string>> = {};
const assignments = matches.map((match) => {
const card = cards[match.rowIndex];
const slot = slots[match.columnIndex];
(placements[slot.member.id] ??= {})[slot.socket.groupId] = card.id;
return {
memberId: slot.member.id,
socketId: slot.socket.groupId,
cardId: card.id,
weight: genericWeights[match.rowIndex][match.columnIndex]
};
});
return {
placements,
assignments,
totalWeight: assignments.reduce((total, assignment) => total + assignment.weight, 0),
soloSongBonusPercent: Math.min(10, best.soloBonus / 10),
groupSongBonusPercent: Math.min(10, best.groupBonus / 10)
};
}
+6 -2
View File
@@ -104,7 +104,7 @@ test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적
assert.equal(result.flatByCardId['card-b'].performance, 0);
});
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => {
test('복수 소속 카드도 모든 기수 보너스를 합쳐 카드별 900까지만 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = {
nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }],
@@ -114,7 +114,11 @@ test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다',
};
const multiGroupingCards = [{ id: 'fubuki', characterId: 'fubuki', type: '퓨어', generation: 'hololive 1기생 / GAMERS' }];
const firstGenerationMaxed = calculateBoardEffects(multiGroupingSummary, { account: ['G-20'] }, multiGroupingCards, 'fubuki');
const gamersAdded = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21'] }, multiGroupingCards, 'fubuki');
const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800);
assert.equal(firstGenerationMaxed.flatByCardId.fubuki.performance, 900);
assert.equal(gamersAdded.flatByCardId.fubuki.performance, 900);
assert.equal(result.flatByCardId.fubuki.performance, 900);
});
+3 -5
View File
@@ -141,7 +141,7 @@ export function calculateBoardEffects(
let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections);
const generationBonusByCardAndGrouping: Record<string, Record<string, number>> = {};
const generationBonusByCardId: Record<string, number> = {};
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) {
@@ -176,13 +176,11 @@ export function calculateBoardEffects(
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {};
const currentBonus = cardBonuses[groupingId] ?? 0;
const currentBonus = generationBonusByCardId[card.id] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus));
cardBonuses[groupingId] = currentBonus + appliedValue;
generationBonusByCardId[card.id] = currentBonus + appliedValue;
for (const stat of stats) destination[card.id][stat] += appliedValue;
}
continue;
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
type SpecialLevel = {
description: string;
effect: string;
};
type CardSummary = {
cards: Array<{
name: string;
details: { special: SpecialLevel[] };
}>;
};
test('스페셜 설명의 추가 효과가 요약 데이터에서 누락되지 않는다', async () => {
const path = new URL('../data/cards-summary.json', import.meta.url);
const summary = JSON.parse(await readFile(path, 'utf8')) as CardSummary;
const additionalEffectMarkers = ['스킬 발동률', 'GOOD 이상이 PERFECT가 된다', '라이프가'];
const missing = summary.cards.flatMap((card) => card.details.special.flatMap((level) =>
additionalEffectMarkers
.filter((marker) => level.description.includes(marker) && !level.effect.includes(marker))
.map((marker) => `${card.name} (${marker}): ${level.description}`)));
assert.deepEqual(missing, []);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
@@ -85,7 +86,7 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
effectiveProbability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
effectiveProbability = effectiveActiveProbability(card, activationBonus);
probabilityByAttempt.set(attempt, effectiveProbability);
}
boost[noteIndex] = card.active.scoreUpPercent / 100 *
+2
View File
@@ -1,7 +1,9 @@
export * from './active-probability.ts';
export * from './card-adapter.ts';
export * from './combination-search.ts';
export * from './awakening-requirements.ts';
export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
+16
View File
@@ -74,6 +74,22 @@ test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다',
assert.equal(result.maximum, 0.75);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산한다', () => {
const main = card('main', 100, 55);
main.active.activationRateUpPercent = 30;
const support: OptimizerCard = {
...card('support', 0, 0),
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = estimateScoreAchievementProbabilities(
[main, support],
{ main: 0, support: 0 },
chart([1.1], [null, 0]),
{ perfectNoteScore: 100, sampleCount: 1_000, seed: 6 }
);
assert.ok(Math.abs(result.maximum - 0.935) < 1e-12);
});
test('확정 발동이면 모든 달성 확률이 100%다', () => {
const result = estimateScoreAchievementProbabilities(
[card('main', 100, 100)],
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import type {
@@ -84,7 +85,7 @@ function prepareModel(
let attemptIndex = attemptIndexes.get(key);
if (attemptIndex === undefined) {
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent');
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
const probability = effectiveActiveProbability(card, activationBonus);
attemptIndex = attempts.length;
attemptIndexes.set(key, attemptIndex);
attempts.push({ key, probability });
+47
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
@@ -74,3 +75,49 @@ test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
test('블루 보드와 스페셜의 발동률 증가 배율을 합산해 기대 점수에 반영한다', () => {
const main: OptimizerCard = {
id: 'main',
name: '메인',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent: 55,
activationRateUpPercent: 30
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const support: OptimizerCard = {
id: 'support',
name: '서포트',
active: { cooldownSeconds: 999, durationSeconds: 0, scoreUpPercent: 0, probabilityPercent: 0 },
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = calculateExactScore([main, support], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [null, 0]
}, { perfectNoteScore: 100 });
assert.equal(result.expectedScore, 194);
assert.equal(result.theoreticalMaxScore, 200);
});
test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => {
for (const calibration of scoreCalibrations.charts) {
for (const measurement of calibration.measurements) {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), {
perfectNoteScore: measurement.overallPower / calibration.scoreRatio,
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(
result.baselineScore,
measurement.normalPerfectScore,
`${calibration.songId} ${calibration.difficulty}`
);
}
}
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { passiveScoreSupportAt } from './passive-support.ts';
import type {
@@ -86,7 +87,7 @@ function activeCandidates(
const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100));
const probability = effectiveActiveProbability(card, activationRateBonus);
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{
memberIndex,
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { songOptions } from '../data/song-catalog.ts';
const charts = [
{ songId: 'm2', title: 'Zeta Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'hard', difficultyLevel: 18 },
{ songId: 'm3', title: 'Another Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm4', title: 'Unknown Song', difficulty: 'expert' }
];
test('악곡 목록은 기본적으로 영문 제목 알파벳순으로 정렬된다', () => {
assert.deepEqual(songOptions(charts, 'expert').map((song) => song.id), ['m1', 'm3', 'm4', 'm2']);
});
test('영문 제목은 대소문자 구분 없이 검색하고 곡 ID도 검색한다', () => {
assert.deepEqual(songOptions(charts, 'expert', 'ALPHA').map((song) => song.id), ['m1']);
assert.deepEqual(songOptions(charts, 'expert', 'm2').map((song) => song.id), ['m2']);
});
test('선택한 채보의 레벨로 난이도 낮은순과 높은순 정렬을 지원한다', () => {
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-asc').map((song) => song.id), ['m3', 'm2', 'm1', 'm4']);
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-desc').map((song) => song.id), ['m1', 'm3', 'm2', 'm4']);
assert.equal(songOptions(charts, 'hard')[0].difficultyLevel, 18);
});
+3
View File
@@ -14,6 +14,8 @@ export type OptimizerCard = {
durationSeconds: number;
scoreUpPercent: number;
probabilityPercent?: number;
/** 블루 보드처럼 액티브 기본 발동률에 더해지는 상시 발동률 UP 배율. */
activationRateUpPercent?: number;
};
special: {
durationSeconds: number;
@@ -90,6 +92,7 @@ export type ExactSongChart = {
difficulty: string;
durationSeconds: number;
scoreRatio: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;
+299 -14
View File
@@ -9,8 +9,9 @@
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import SongPicker from '$lib/components/SongPicker.svelte';
import { calculateBoardEffects, calculateExactScore, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, optimizeBoardConnectionCards, optimizeExactOrderAndCooldowns, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizableConnectionCard, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -19,6 +20,16 @@
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type ConnectionOptimizationProposal = {
placements: BoardCards;
assignments: { memberId: string; socketId: string; previousCardId: string | null; cardId: string | null }[];
beforeExpected: number;
afterExpected: number;
beforeMaximum: number;
afterMaximum: number;
beforeActivationRateUp: number;
afterActivationRateUp: number;
};
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
@@ -52,6 +63,8 @@
let charts = releasedSongCharts(allCharts, releaseNow);
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
const cardById = new Map(fiveStarCards.map((card) => [card.id, card]));
const allCardById = new Map(cardSummary.cards.map((card) => [card.id, card]));
const boardMembers = boardSummary.members;
const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
@@ -91,6 +104,11 @@
let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = '';
let analysisReady = false;
let connectionOptimizationRunning = false;
let connectionOptimizationProgress: { stage: string; completed: number; total: number } | null = null;
let connectionOptimizationError = '';
let connectionOptimizationProposal: ConnectionOptimizationProposal | null = null;
let connectionOptimizationSnapshot: BoardCards | null = null;
$: charts = releasedSongCharts(allCharts, releaseNow);
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
@@ -136,6 +154,7 @@
})();
$: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
@@ -411,14 +430,212 @@
...adapted,
active: {
...adapted.active,
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100)
activationRateUpPercent: activationRate
},
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
}
function connectionScoreContext(placements: BoardCards, chartSummary: NonNullable<typeof selectedChart>) {
const localBoardEffects = calculateBoardEffects(boardSummary, effectiveMemberBoards, selectedSourceCards, leaderCharacterId, chartSummary, {
placements,
cards: connectionCards
}, { generationBonusByCardId });
const localUnitPower = calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!, awakening: selectedAwakenings[index]
})), leaderOutfit, localBoardEffects, memoryPermil, memberPowerUpPercent);
const cards = selectedSourceCards.map((card, index) => {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? localBoardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
selectedSourceCards
);
return {
...adapted,
active: { ...adapted.active, activationRateUpPercent: localBoardEffects.activationRateUpByCardId[card.id] ?? 0 },
scoreSupportPercent: localUnitPower.outfitScoreSupportPercent + localBoardEffects.scoreSupportPercent
};
});
return { cards, boardEffects: localBoardEffects, unitPower: localUnitPower };
}
function connectionSocketName(memberId: string, socketId: string): string {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
if (!socket || (socket.x === 0 && socket.y === 0)) return '중앙';
if (socket.x < 0) return '왼쪽';
if (socket.x > 0) return '오른쪽';
return socket.y > 0 ? '위쪽' : '아래쪽';
}
function isBlueConnectionSocket(memberId: string, socketId: string): boolean {
const member = boardMembers.find((item) => item.id === memberId);
const socket = member
? (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId]?.find((position) => position.groupId === socketId)
: null;
return (socket?.x ?? 0) < 0;
}
function totalActivationRateUp(context: ReturnType<typeof connectionScoreContext>): number {
return Object.values(context.boardEffects.activationRateUpByCardId).reduce((total, value) => total + value, 0);
}
function saveBoardCardPlacements(placements: BoardCards): void {
const raw = localStorage.getItem(USER_DATA_STORAGE_KEY);
const stored = raw ? JSON.parse(raw) : {};
stored.boardCards = placements;
stored.updatedAt = new Date().toISOString();
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(stored));
boardCards = placements;
}
function clearConnectionOptimizationProposal(): void {
connectionOptimizationProposal = null;
connectionOptimizationError = '';
}
async function optimizeConnectionPlacements(): Promise<void> {
if (!result || !selectedChart || selectedSourceCards.length !== 5) return;
const analysisResult = result;
const chartSummary = selectedChart;
connectionOptimizationRunning = true;
connectionOptimizationProgress = { stage: '채보 준비 중', completed: 0, total: 0 };
connectionOptimizationError = '';
connectionOptimizationProposal = null;
try {
const chart = await loadExactSongChart(chartSummary.path);
const beforeContext = connectionScoreContext(boardCards, chartSummary);
const emptyContext = connectionScoreContext({}, chartSummary);
const orderedCards = (context: ReturnType<typeof connectionScoreContext>) => analysisResult.order.map((cardId) =>
context.cards.find((card) => card.id === cardId)!
);
const referenceScore = (context: ReturnType<typeof connectionScoreContext>) => calculateExactScore(
orderedCards(context), analysisResult.cooldownReductions, chart, {
perfectNoteScore: perfectNoteScoreFromPower(context.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: context.boardEffects.directSongScoreBonusPercent
}
).expectedScore;
const emptyScore = referenceScore(emptyContext);
const emptyActivationRateUp = totalActivationRateUp(emptyContext);
const beforeActivationRateUp = totalActivationRateUp(beforeContext);
const candidateCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0 && collection[card.id]?.owned)
.map((card) => ({ id: card.id, awakening: collection[card.id]?.awakening ?? 0, connect: card.details.connect }));
if (candidateCards.length === 0) throw new Error('최적화에 사용할 보유 연결 카드가 없습니다.');
const placementMetricCache = new Map<string, { score: number; activationRateUp: number }>();
const placementMetrics = (card: OptimizableConnectionCard, memberId: string, socketId: string) => {
const effect = card.connect.find((entry) => entry.level === (card.awakening >= 5 ? 2 : 1)) ?? card.connect.at(-1);
const effectKey = effect ? `${effect.effectPermilUp}:${effect.extent.map((offset) => `${offset.x},${offset.y}`).join(';')}` : card.id;
const key = `${memberId}:${socketId}:${effectKey}`;
const cached = placementMetricCache.get(key);
if (cached) return cached;
const context = connectionScoreContext({ [memberId]: { [socketId]: card.id } }, chartSummary);
const metrics = {
score: referenceScore(context) - emptyScore,
activationRateUp: totalActivationRateUp(context) - emptyActivationRateUp
};
placementMetricCache.set(key, metrics);
return metrics;
};
const activeConnectionSlots = boardMembers.flatMap((member) => {
const positions = (boardSummary.models as Record<string, { groupId: string; x: number; y: number }[]>)[member.positionGroupId] ?? [];
const active = new Set(effectiveMemberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0);
if (center) active.add(center.groupId);
return positions.filter((position) => active.has(position.groupId) && position.groupId.startsWith('S'))
.map((position) => ({ memberId: member.id, socketId: position.groupId }));
});
const totalPlacementChecks = candidateCards.length * activeConnectionSlots.length;
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: 0, total: totalPlacementChecks };
let completedPlacementChecks = 0;
for (const card of candidateCards) {
for (const slot of activeConnectionSlots) {
placementMetrics(card, slot.memberId, slot.socketId);
completedPlacementChecks += 1;
if (completedPlacementChecks % 8 === 0 || completedPlacementChecks === totalPlacementChecks) {
connectionOptimizationProgress = { stage: '카드별 배치 효과 계산 중', completed: completedPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
}
}
connectionOptimizationProgress = { stage: '최적 조합 선택 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const optimized = optimizeBoardConnectionCards(boardSummary, effectiveMemberBoards, candidateCards, {
placementWeight: (card, memberId, socketId) => placementMetrics(card, memberId, socketId).score,
placementPriorityWeight: (card, memberId, socketId) => isBlueConnectionSocket(memberId, socketId)
? placementMetrics(card, memberId, socketId).activationRateUp
: 0
});
if (optimized.assignments.length === 0) throw new Error('현재 활성 노드에 효과를 주는 연결 카드 배치를 찾지 못했습니다.');
connectionOptimizationProgress = { stage: '최종 점수 검증 중', completed: totalPlacementChecks, total: totalPlacementChecks };
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const afterContext = connectionScoreContext(optimized.placements, chartSummary);
const after = optimizeExactOrderAndCooldowns(afterContext.cards, chart, {
perfectNoteScore: perfectNoteScoreFromPower(afterContext.unitPower.overallPower, chartSummary.scoreRatio),
directSongScoreBonusPercent: afterContext.boardEffects.directSongScoreBonusPercent
});
const afterActivationRateUp = totalActivationRateUp(afterContext);
if (afterActivationRateUp + 1e-9 < beforeActivationRateUp) {
throw new Error('현재 배치보다 액티브 스킬 발동률 증가량이 낮아 기존 배치를 유지합니다.');
}
if (Math.abs(afterActivationRateUp - beforeActivationRateUp) < 1e-9 && after.expectedScore < analysisResult.expectedScore) {
throw new Error(`현재 배치의 예상 점수(${analysisResult.expectedScore.toLocaleString('ko-KR')})가 탐색 결과보다 높아 기존 배치를 유지합니다.`);
}
const changedAssignments = boardMembers.flatMap((member) => {
const before = boardCards[member.id] ?? {};
const afterPlacements = optimized.placements[member.id] ?? {};
return [...new Set([...Object.keys(before), ...Object.keys(afterPlacements)])]
.filter((socketId) => before[socketId] !== afterPlacements[socketId])
.map((socketId) => ({
memberId: member.id,
socketId,
previousCardId: before[socketId] ?? null,
cardId: afterPlacements[socketId] ?? null
}));
});
connectionOptimizationProposal = {
placements: optimized.placements,
assignments: changedAssignments,
beforeExpected: analysisResult.expectedScore,
afterExpected: after.expectedScore,
beforeMaximum: analysisResult.theoreticalMaxScore,
afterMaximum: after.theoreticalMaxScore,
beforeActivationRateUp,
afterActivationRateUp
};
} catch (error) {
connectionOptimizationError = error instanceof Error ? error.message : '연결 카드 최적화를 완료하지 못했습니다.';
} finally {
connectionOptimizationRunning = false;
connectionOptimizationProgress = null;
}
}
async function applyConnectionOptimization(): Promise<void> {
if (!connectionOptimizationProposal) return;
connectionOptimizationSnapshot = Object.fromEntries(Object.entries(boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
saveBoardCardPlacements(connectionOptimizationProposal.placements);
connectionOptimizationProposal = null;
await analyze();
}
async function undoConnectionOptimization(): Promise<void> {
if (!connectionOptimizationSnapshot) return;
saveBoardCardPlacements(connectionOptimizationSnapshot);
connectionOptimizationSnapshot = null;
connectionOptimizationProposal = null;
await analyze();
}
async function analyze(): Promise<void> {
errorMessage = '';
clearConnectionOptimizationProposal();
result = null;
achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return;
@@ -561,11 +778,7 @@
</div>
<div class="song-fields">
<label>악곡
<select bind:value={selectedSongId} on:change={() => result = null}>
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
</select>
</label>
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도
<select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option>
@@ -573,7 +786,7 @@
</select>
</label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점 · {scoreCalibrationLabel}</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
</div>
<div class="card-fields">
@@ -684,6 +897,45 @@
</div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p>
<section class="connection-optimizer" aria-labelledby="connection-optimizer-title">
<div class="connection-optimizer-heading">
<div><h3 id="connection-optimizer-title">연결 카드 최적화</h3><p>활성 노드는 유지하며, 파란색 영역은 액티브 발동률을 먼저 최대화한 뒤 예상 점수를 비교합니다.</p></div>
<div class="connection-optimizer-actions">
{#if connectionOptimizationSnapshot}<button type="button" disabled={running || connectionOptimizationRunning} on:click={undoConnectionOptimization}>직전 배치 복원</button>{/if}
<button type="button" class="primary" disabled={running || connectionOptimizationRunning} on:click={optimizeConnectionPlacements}>{connectionOptimizationRunning ? '배치 탐색 중…' : '보유 카드로 최적화'}</button>
</div>
</div>
{#if connectionOptimizationProgress}
<div class="connection-optimizer-progress" aria-live="polite">
<div><strong>{connectionOptimizationProgress.stage}</strong>{#if connectionOptimizationProgress.total > 0}<span>{connectionOptimizationProgress.completed.toLocaleString('ko-KR')} / {connectionOptimizationProgress.total.toLocaleString('ko-KR')}</span>{/if}</div>
{#if connectionOptimizationProgress.total > 0}<progress max={connectionOptimizationProgress.total} value={connectionOptimizationProgress.completed}></progress>{/if}
</div>
{/if}
{#if connectionOptimizationError}<p class="connection-optimizer-error" role="alert">{connectionOptimizationError}</p>{/if}
{#if connectionOptimizationProposal}
<div class="connection-proposal" aria-live="polite">
<div class="connection-score-comparison">
<span>예상 점수 <strong>{connectionOptimizationProposal.beforeExpected.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterExpected.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterExpected >= connectionOptimizationProposal.beforeExpected ? '+' : ''}{(connectionOptimizationProposal.afterExpected - connectionOptimizationProposal.beforeExpected).toLocaleString('ko-KR')}</b></span>
<span>이론상 고점 <strong>{connectionOptimizationProposal.beforeMaximum.toLocaleString('ko-KR')} → {connectionOptimizationProposal.afterMaximum.toLocaleString('ko-KR')}</strong><b>{connectionOptimizationProposal.afterMaximum >= connectionOptimizationProposal.beforeMaximum ? '+' : ''}{(connectionOptimizationProposal.afterMaximum - connectionOptimizationProposal.beforeMaximum).toLocaleString('ko-KR')}</b></span>
<span>액티브 발동률 UP 합계 <strong>{connectionOptimizationProposal.beforeActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}% → {connectionOptimizationProposal.afterActivationRateUp.toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%</strong><b>{connectionOptimizationProposal.afterActivationRateUp >= connectionOptimizationProposal.beforeActivationRateUp ? '+' : ''}{(connectionOptimizationProposal.afterActivationRateUp - connectionOptimizationProposal.beforeActivationRateUp).toLocaleString('ko-KR', { maximumFractionDigits: 2 })}%p</b></span>
</div>
{#if connectionOptimizationProposal.assignments.length > 0}
<div class="connection-assignment-list">
{#each connectionOptimizationProposal.assignments as assignment (`${assignment.memberId}:${assignment.socketId}`)}
{@const member = boardMembers.find((item) => item.id === assignment.memberId)}
{@const previousCard = assignment.previousCardId ? allCardById.get(assignment.previousCardId) : null}
{@const card = assignment.cardId ? allCardById.get(assignment.cardId) : null}
<span><small>{member?.name ?? assignment.memberId} · {connectionSocketName(assignment.memberId, assignment.socketId)}</small><strong>{previousCard ? cardLabel(previousCard) : '비어 있음'} → {card ? cardLabel(card) : '배치 해제'}</strong></span>
{/each}
</div>
<button type="button" class="apply-connection" on:click={applyConnectionOptimization}>변경 배치를 적용하고 다시 분석</button>
{:else}
<p class="connection-no-changes">현재 연결 카드 배치가 이미 최적입니다.</p>
{/if}
</div>
{/if}
</section>
<section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
@@ -774,11 +1026,12 @@
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: end; gap: 12px; margin-top: 22px; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) clamp(140px, 14vw, 220px); align-items: start; gap: 12px; margin-top: 22px; }
.song-picker-field { min-width: 0; }
.power-summary { display: grid; gap: 4px; min-height: 42px; margin-top: 25px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 12px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; }
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
.chart-meta { display: flex; grid-column: 1 / -1; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field-header, .card-field { display: grid; grid-template-columns: minmax(280px, 1fr) clamp(120px, 10vw, 160px) clamp(280px, 24vw, 360px) clamp(180px, 16vw, 240px); gap: 9px; }
@@ -838,6 +1091,34 @@
.probability-grid strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; }
.probability-guide { margin: 9px 2px 0; color: #7f8998; font-size: 12px; line-height: 1.55; }
.connection-optimizer { margin-top: 18px; padding: 18px; border: 1px solid #3b5445; border-radius: 13px; background: #151e19; }
.connection-optimizer-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.connection-optimizer-heading h3 { margin: 0; color: #dce8e0; font-size: 17px; }
.connection-optimizer-heading p { margin: 5px 0 0; color: #8fa095; font-size: 13px; }
.connection-optimizer-actions { display: flex; flex: 0 0 auto; gap: 7px; }
.connection-optimizer button { min-height: 38px; padding: 8px 12px; border: 1px solid #46574c; border-radius: 8px; color: #becbc2; background: #202a23; font-weight: 800; cursor: pointer; }
.connection-optimizer button.primary, .connection-optimizer button.apply-connection { border-color: #69b184; color: #101913; background: #78c493; }
.connection-optimizer button:disabled { opacity: .55; cursor: default; }
.connection-optimizer-progress { display: grid; gap: 7px; margin-top: 14px; padding: 10px 12px; border: 1px solid #314238; border-radius: 9px; background: #111a15; }
.connection-optimizer-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.connection-optimizer-progress strong { color: #b9dac5; font-size: 13px; }
.connection-optimizer-progress span { color: #86a391; font-size: 12px; font-weight: 800; }
.connection-optimizer-progress progress { width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: 999px; color: #78c493; background: #27322c; appearance: none; }
.connection-optimizer-progress progress::-webkit-progress-bar { border-radius: 999px; background: #27322c; }
.connection-optimizer-progress progress::-webkit-progress-value { border-radius: 999px; background: #78c493; }
.connection-optimizer-progress progress::-moz-progress-bar { border-radius: 999px; background: #78c493; }
.connection-optimizer-error { margin: 12px 0 0; color: #e3a0a0; font-size: 13px; }
.connection-proposal { display: grid; gap: 12px; margin-top: 14px; padding-top: 14px; border-top: 1px solid rgb(120 196 147 / 18%); }
.connection-score-comparison { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.connection-score-comparison span { display: grid; gap: 5px; padding: 10px 12px; border-radius: 8px; color: #91a59a; background: #111813; font-size: 12px; }
.connection-score-comparison strong { color: #e0ebe4; font-size: 14px; }
.connection-score-comparison b { color: #82d09e; font-size: 13px; }
.connection-assignment-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 6px; max-height: 240px; overflow-y: auto; }
.connection-assignment-list > span { display: grid; gap: 3px; padding: 8px 10px; border: 1px solid #304038; border-radius: 7px; background: #121915; }
.connection-assignment-list small { color: #819287; font-size: 11px; }
.connection-assignment-list strong { overflow: hidden; color: #dbe7df; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.connection-no-changes { margin: 0; padding: 10px 12px; border-radius: 8px; color: #a9cdb6; background: #18271d; font-size: 13px; font-weight: 750; }
.connection-proposal .apply-connection { justify-self: end; }
.empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
@@ -881,8 +1162,8 @@
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.power-summary { grid-column: 1 / -1; }
.song-fields { grid-template-columns: minmax(0, 1fr) 100px; }
.power-summary { grid-column: 1 / -1; margin-top: 0; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field-header { display: none; }
@@ -896,6 +1177,10 @@
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.connection-optimizer-heading { align-items: stretch; flex-direction: column; }
.connection-optimizer-actions { display: grid; }
.connection-score-comparison { grid-template-columns: 1fr; }
.connection-proposal .apply-connection { justify-self: stretch; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
+14 -10
View File
@@ -10,12 +10,14 @@
import { releasedSongCharts } from '$lib/data/song-release.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import PresetManager from '$lib/components/PresetManager.svelte';
import SongPicker from '$lib/components/SongPicker.svelte';
import {
additionalAwakeningsRequired,
calculateBoardEffects,
calculateUnitPower,
combinationSearchLimits,
countOptimizationCandidates,
effectiveActiveProbability,
generateAnchoredCombinationCandidates,
generateCombinationCandidates,
memoryParameterPermil,
@@ -146,6 +148,7 @@
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
$: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined;
@@ -492,7 +495,7 @@
function roughSkillValues(card: CardRow) {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
return { adapted, activeValue, specialValue };
}
@@ -502,7 +505,7 @@
const skillPotential = cards.reduce((sum, card) => {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds /
Math.max(1, adapted.active.cooldownSeconds) * ((adapted.active.probabilityPercent ?? 100) / 100);
Math.max(1, adapted.active.cooldownSeconds) * effectiveActiveProbability(adapted);
const specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
@@ -687,12 +690,12 @@
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
const skillPotential = optimizerCards.reduce((sum, card) => sum +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * ((card.active.probabilityPercent ?? 100) / 100) +
card.active.scoreUpPercent * card.active.durationSeconds / Math.max(1, card.active.cooldownSeconds) * effectiveActiveProbability(card) +
card.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 0);
@@ -748,7 +751,7 @@
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) },
active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
});
@@ -1035,9 +1038,9 @@
<section class="panel" aria-labelledby="conditions-title">
<div class="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '5성 전체 대상'}</strong></div>
<div class="song-fields">
<label>악곡<select bind:value={selectedSongId} on:change={() => result = null}>{#each songs as song}<option value={song.id}>{song.title}</option>{/each}</select></label>
<div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<label>난이도<select bind:value={difficulty} on:change={() => result = null}><option value="expert">Expert</option><option value="hard">Hard</option></select></label>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span></div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>점수 계수 · {scoreCalibrationLabel}</span></div>
</div>
<div class="candidate-fields">
<div class="candidate-awakening">
@@ -1199,8 +1202,9 @@
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
select, .number-input { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus, .number-input:focus, .generation-input:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: end; gap: 12px; margin-top: 22px; }
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; }
.song-fields { display: grid; grid-template-columns: minmax(0, 4fr) clamp(110px, 11vw, 170px) minmax(220px, .8fr); align-items: start; gap: 12px; margin-top: 22px; }
.song-picker-field { min-width: 0; }
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; margin-top: 25px; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 13px; white-space: nowrap; }
.candidate-fields { display: grid; grid-template-columns: clamp(130px, 12vw, 180px) clamp(260px, 24vw, 380px); gap: 9px; margin-top: 18px; }
.candidate-awakening { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; }
@@ -1305,7 +1309,7 @@
main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.chart-meta { grid-column: 1 / -1; height: auto; margin-top: 0; }
.candidate-fields { grid-template-columns: 1fr; }
.fixed-column-headings { display: none; }
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
+44 -49
View File
@@ -58,6 +58,8 @@
type AutomaticPlacementResult = {
memberId: string;
memberName: string;
socketId: string;
socketName: string;
cardId: string;
cardName: string;
rarity: number;
@@ -155,7 +157,8 @@
: [];
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
&& efficiencyFormation.cardIds.every((id) => cardById.has(id))
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5;
&& new Set(efficiencyFormation.cardIds.map((id) => cardById.get(id)?.characterId)).size === 5
&& efficiencyFormation.cardIds.some((id) => cardById.get(id)?.characterId === efficiencyFormation.leaderCharacterId);
$: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
onMount(() => {
try {
@@ -241,11 +244,12 @@
function importAnalysisFormation() {
if (!savedAnalysis) return;
const analysis = savedAnalysis;
saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId,
cardIds: [...analysis.cardIds],
awakenings: analysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: analysis.leaderCharacterId,
leaderOutfitCardId: analysis.leaderOutfitCardId,
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
});
}
@@ -570,7 +574,7 @@
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 });
}
function currentFormationPower(memberBoards: Record<string, string[]>): number {
function currentFormationPower(memberBoards: Record<string, string[]>, boardCards: BoardCardPlacements = userData.boardCards): number {
if (!efficiencyFormationReady) return 0;
const members = efficiencyFormation.cardIds.map((id, index) => ({
card: cardById.get(id)!,
@@ -584,7 +588,7 @@
connect: card.details.connect
}));
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, {
placements: userData.boardCards,
placements: boardCards,
cards: connections
});
const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null;
@@ -626,15 +630,6 @@
|| right.cost.greenCubes - left.cost.greenCubes;
}
function upgradeBeatsRemoval(upgrade: GreenNodeEfficiency, removal: GreenNodeEfficiency): boolean {
const cubeDifference = upgrade.greenCubeEfficiency - removal.greenCubeEfficiency;
if (Math.abs(cubeDifference) > 1e-9) return cubeDifference > 0;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes > 0) return true;
if (upgrade.cost.greenCoreCubes > 0 && removal.cost.greenCoreCubes === 0) return false;
if (upgrade.cost.greenCoreCubes === 0 && removal.cost.greenCoreCubes === 0) return false;
return upgrade.greenCoreCubeEfficiency > removal.greenCoreCubeEfficiency;
}
async function analyzeGreenNodeEfficiency() {
if (!efficiencyFormationReady) {
greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.';
@@ -702,11 +697,9 @@
}
const sortedRemovals = removals.sort(compareRemovalEfficiency);
const removalThreshold = sortedRemovals[0];
greenEfficiencyResult = {
basePower,
upgrades: upgrades.sort(compareUpgradeEfficiency)
.filter((upgrade) => !removalThreshold || upgradeBeatsRemoval(upgrade, removalThreshold)),
upgrades: upgrades.sort(compareUpgradeEfficiency),
removals: sortedRemovals
};
} catch (error) {
@@ -818,45 +811,37 @@
if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return [];
return [{ member, socket }];
});
if (candidateCards.length === 0) {
showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true);
return;
}
if (emptyGreenSlots.length === 0) {
showMessage('비어 있는 초록색 카드 슬롯이 없습니다. 기존 배치는 변경하지 않았습니다.');
showMessage('비어 있는 중앙 연결 노드가 없습니다. 기존 배치는 변경하지 않았습니다.');
return;
}
const weights = candidateCards.map((card) => emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card)));
const matches = maximumWeightMatching(weights);
const matches = maximumWeightMatching(candidateCards.map((card) =>
emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))
));
if (matches.length === 0) {
showMessage('활성화된 초록 노드에 효과를 주는 배치 조합을 찾지 못했습니다.', true);
showMessage('활성화된 초록 노드에 효과를 주는 중앙 배치를 찾지 못했습니다.', true);
return;
}
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
const boardCards = cloneBoardCards(userData.boardCards);
const placementResult: AutomaticPlacementResult[] = [];
for (const match of matches) {
automaticPlacementResult = matches.map((match) => {
const card = candidateCards[match.rowIndex];
const slot = emptyGreenSlots[match.columnIndex];
if (!card || !slot) continue;
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id };
placementResult.push({
memberId: slot.member.id,
memberName: slot.member.name,
cardId: card.id,
cardName: cardLabel(card),
rarity: card.rarity
});
}
automaticPlacementResult = placementResult.sort((left, right) =>
const { member, socket } = emptyGreenSlots[match.columnIndex];
boardCards[member.id] = { ...(boardCards[member.id] ?? {}), [socket.groupId]: card.id };
return {
memberId: member.id, memberName: member.name, socketId: socket.groupId, socketName: '중앙',
cardId: card.id, cardName: cardLabel(card), rarity: card.rarity
};
}).sort((left, right) =>
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
);
persist({ ...userData, boardCards });
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`);
showMessage(`기존 배치를 유지하고 중앙 연결 노드 ${matches.length}곳에 보유 카드를 자동 배치했습니다.`);
}
function undoAutomaticPlacement() {
@@ -865,7 +850,7 @@
automaticPlacementSnapshot = null;
automaticPlacementResult = [];
persist({ ...userData, boardCards });
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.');
showMessage('방금 실행한 중앙 연결 노드 자동 배치를 취소했습니다.');
}
function cardLabel(card: CardSummary): string {
@@ -875,7 +860,7 @@
function placedCardName(cardId: string | undefined): string {
if (!cardId) return '연결 노드';
const card = connectCardById.get(cardId);
return card ? cardLabel(card) : cardId;
return card ? `${card.rarity}★ ${cardLabel(card)}` : cardId;
}
function connectionBoostsForMember(
@@ -917,6 +902,7 @@
function assignBoardCard(cardId: string) {
if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
for (const [memberId, placements] of Object.entries(boardCards)) {
for (const [socketId, placedCardId] of Object.entries(placements)) {
@@ -932,6 +918,7 @@
function removeBoardCard() {
if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) };
delete placements[selectedSocketGroupId];
const boardCards = { ...userData.boardCards };
@@ -1168,7 +1155,7 @@
<div class="automatic-placement">
<div>
<strong>간편 설정</strong>
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 보유 카드를 중앙 연결 노드의 빈 슬롯에 자동 배치합니다.</small>
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small>
</div>
<div class="automatic-placement-actions">
<button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button>
@@ -1271,7 +1258,7 @@
<div class="efficiency-columns">
<section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title">
<div class="efficiency-column-heading">
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 검토 1순위보다 효율이 높은 노드만 표시</p></div>
<div><h3 id="upgrade-efficiency-title">우선 활성화</h3><p>초기화 효율보다 낮은 후보도 포함해 효율순으로 최대 20개 표시</p></div>
</div>
{#if greenEfficiencyResult.upgrades.length > 0}
<div class="efficiency-list">
@@ -1367,7 +1354,7 @@
class:efficiency-focus={position.groupId === efficiencyFocusGroupId}
style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`}
title={`${boardNodeLabel(node.type)} · ${boardEffectDescription(node.effect, selectedBoardMember.name) || placedCardName(selectedBoardCardPlacements[position.groupId])}${selectedConnectionBoosts[position.groupId] ? ` · ${active ? '연결 효과 적용' : '연결 범위'} +${selectedConnectionBoosts[position.groupId].percent}%` : ''}`}
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
aria-label={`${boardNodeLabel(node.type)} ${node.grade}등급 노드, ${active ? '활성화됨' : '비활성화됨'}${node.type === 'connection' && selectedBoardCardPlacements[position.groupId] ? `, 배치 카드 ${placedCardName(selectedBoardCardPlacements[position.groupId])}` : ''}${selectedConnectionBoosts[position.groupId] ? `, 연결 ${active ? '효과 적용 중' : '범위에 포함'}` : ''}`}
aria-pressed={active}
on:click={() => handleBoardNodeClick(position.groupId, node)}
>
@@ -1380,7 +1367,12 @@
{#if boardEffectIcon(node)}<img class="board-node-icon" src={boardEffectIcon(node) ?? ''} alt="" draggable="false" />{/if}
{#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]}
{@const placedCard = connectCardById.get(selectedBoardCardPlacements[position.groupId])}
{#if placedCard}<span class="socket-card" aria-hidden="true">{placedCard.name.slice(0, 2)}</span>{/if}
{#if placedCard}
<span class="socket-card" aria-hidden="true">
<span class="socket-card-name">{placedCard.name.slice(0, 2)}</span>
<small class:rarity-five={placedCard.rarity === 5}>{placedCard.rarity}★</small>
</span>
{/if}
{/if}
</button>
{/if}
@@ -1564,7 +1556,7 @@
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; }
.automatic-placement-result-item b { display: flex; align-items: center; gap: 7px; min-width: 0; color: #dbe8df; font-size: 13px; }
.automatic-placement-result-item i { flex: 0 0 auto; color: #e7bd70; font-size: 11px; font-style: normal; }
.green-efficiency { order: 2; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
.green-efficiency { order: 3; margin: 18px 0 0; padding: 0 16px 16px; border: 1px solid #394453; border-radius: 16px; background: #151b23; }
.green-efficiency > summary { display: flex; align-items: center; min-height: 62px; margin: 0 -16px; padding: 12px 16px; color: #dce6e0; cursor: pointer; list-style: none; }
.green-efficiency > summary::-webkit-details-marker { display: none; }
.green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); }
@@ -1657,7 +1649,10 @@
.board-node.connect-applied::after { inset: -8px; border: 4px solid #fff; }
.board-node.connect-applied::before { content: '+'; position: absolute; z-index: 4; top: -13px; right: -13px; display: grid; place-items: center; width: 19px; height: 19px; border: 2px solid #151a22; border-radius: 50%; color: #111820; background: #fff; font-size: 13px; font-weight: 950; line-height: 1; pointer-events: none; }
.board-node.efficiency-focus { z-index: 6; outline: 4px solid #fff; outline-offset: 6px; border-radius: 50%; }
.socket-card { position: absolute; inset: 19%; z-index: 2; display: grid; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; pointer-events: none; }
.socket-card { position: absolute; inset: 19%; z-index: 2; pointer-events: none; }
.socket-card-name { display: grid; width: 100%; height: 100%; place-items: center; overflow: hidden; border-radius: 50%; color: #fff; background: #3b2d46; font-size: 12px; font-weight: 900; line-height: 1; }
.socket-card small { position: absolute; right: -12px; bottom: -8px; min-width: 24px; padding: 2px 4px; border: 2px solid #151a22; border-radius: 999px; color: #fff; background: #7562c9; font-size: 9px; font-weight: 950; line-height: 1.1; white-space: nowrap; }
.socket-card small.rarity-five { background: #d96f9d; }
.board-node:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
.board-node.center { cursor: default; }
.board-node:focus-visible { z-index: 5; outline: 3px solid rgb(255 255 255 / 55%); outline-offset: 3px; border-radius: 50%; }