feat: release version 1.0.7
This commit is contained in:
@@ -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>
|
||||
@@ -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": [],
|
||||
|
||||
+1356
-604
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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 *
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
Reference in New Issue
Block a user