feat: release version 1.0.7
This commit is contained in:
@@ -9,6 +9,7 @@ release
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
!data/card-overrides.csv
|
||||
!data/score-calibrations.json
|
||||
!data/holodoridb/
|
||||
!data/holodoridb/**
|
||||
data/holodoridb/*.tmp
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"charts": [
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "easy",
|
||||
"scoreRatio": 55.72941713590017,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 6571 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "normal",
|
||||
"scoreRatio": 89.51307748716695,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 4091 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "hard",
|
||||
"scoreRatio": 159.56339869281047,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 2295 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0157",
|
||||
"difficulty": "expert",
|
||||
"scoreRatio": 251.68247422680412,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 5, "normalPerfectScore": 1455 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0121",
|
||||
"difficulty": "hard",
|
||||
"scoreRatio": 189.63717251606528,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 2023 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0121",
|
||||
"difficulty": "expert",
|
||||
"scoreRatio": 318.8994181213633,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1203 },
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 8.26, "normalPerfectScore": 1184 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0182",
|
||||
"difficulty": "hard",
|
||||
"scoreRatio": 234.92712798530317,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1633 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0182",
|
||||
"difficulty": "expert",
|
||||
"scoreRatio": 380.5912698412699,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 10, "normalPerfectScore": 1008 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0201",
|
||||
"difficulty": "expert",
|
||||
"scoreRatio": 208.21492537313435,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 4, "normalPerfectScore": 1742 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"songId": "m0319",
|
||||
"difficulty": "expert",
|
||||
"scoreRatio": 363.4372745490982,
|
||||
"measurements": [
|
||||
{ "overallPower": 348760, "directSongScoreBonusPercent": 4, "normalPerfectScore": 998 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "holodream-tools",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"data:sync": "node scripts/collect-holodoridb.mjs",
|
||||
"data:import-songs": "node scripts/import-legacy-song-data.mjs",
|
||||
"data:collect-charts": "node scripts/collect-exact-charts.mjs",
|
||||
"data:apply-score-calibrations": "node scripts/apply-score-calibrations.mjs",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 홀로도리 계산기 v1.0.7
|
||||
|
||||
점수 계산의 근거를 더 명확하게 표시하고, 악곡 선택과 연결 카드 배치를 편리하게 개선한 업데이트입니다.
|
||||
|
||||
## 주요 변경 사항
|
||||
|
||||
- 배치 분석에 보유 연결 카드 자동 최적화를 추가했습니다. 활성 노드는 유지하면서 액티브 발동률과 예상 점수를 기준으로 배치를 탐색하고, 변경 전후 결과를 비교한 뒤 적용할 수 있습니다.
|
||||
- 블루 보드의 상시 액티브 발동률 증가와 스페셜의 시간제 발동률 증가를 합산해 계산하도록 수정했습니다.
|
||||
- 복수 기수 소속 카드도 모든 기수 보너스의 합계를 카드별 최대 `900`까지만 적용하도록 수정했습니다.
|
||||
- 등록된 실측 자료가 있는 Hard/Expert 채보 8개의 점수 계수를 보정하고, 모든 채보에 점수 계수가 `실측값`인지 `추정값`인지 표시합니다.
|
||||
- 악곡을 영문 제목이나 곡 ID로 검색하고 알파벳순 또는 난이도순으로 정렬할 수 있습니다.
|
||||
- 모든 Hard/Expert 채보에 난이도 레벨을 추가하고, 가능한 악곡은 공식 영문 제목으로 표시합니다.
|
||||
- 카드 49장의 스페셜에서 발동률 증가, 판정 강화 또는 라이프 회복과 같은 추가 효과가 누락되지 않도록 데이터 요약을 수정했습니다.
|
||||
- 보드 화면에서 연결 카드의 희귀도를 표시하고 중앙 연결 노드 자동 배치와 초록 노드 효율 목록을 개선했습니다.
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- 점수 계수가 보정된 채보와 복수 기수 카드의 보드 보너스는 이전 버전과 계산 결과가 달라질 수 있습니다.
|
||||
- 현재 실측 점수 계수가 등록된 채보는 일부입니다. 계산 정확도 향상을 위해 대부분의 악곡에서 실측값 제보가 필요합니다.
|
||||
- 실측값 제보: `[제보 링크 입력]`
|
||||
- 연결 카드 최적화는 결과를 확인한 뒤 적용할 때만 사용자 데이터의 배치를 변경하며, 적용 직후에는 이전 배치로 복원할 수 있습니다.
|
||||
|
||||
## 다운로드
|
||||
|
||||
Release의 Assets에서 `HolodoriCalc-offline-v1.0.7.zip`을 내려받아 완전히 압축 해제한 뒤 `index.html`을 실행해 주세요. GitHub가 자동으로 제공하는 Source code 압축파일은 실행용 배포본이 아닙니다.
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const calibrationPath = path.resolve('data/score-calibrations.json');
|
||||
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
|
||||
const chartDirectory = path.resolve('static/data/charts');
|
||||
|
||||
const calibrations = JSON.parse(await readFile(calibrationPath, 'utf8'));
|
||||
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
|
||||
const catalogRows = Array.isArray(catalog) ? catalog : catalog.charts;
|
||||
|
||||
for (const calibration of calibrations.charts) {
|
||||
const difficulty = calibration.difficulty.toLowerCase();
|
||||
const catalogRow = catalogRows.find((row) =>
|
||||
row.songId === calibration.songId && row.difficulty.toLowerCase() === difficulty
|
||||
);
|
||||
|
||||
// Easy/Normal 실측도 보관하되 현재 생성 대상인 Hard/Expert만 즉시 동기화한다.
|
||||
if (!catalogRow) continue;
|
||||
|
||||
catalogRow.scoreRatio = calibration.scoreRatio;
|
||||
catalogRow.scoreCalibrationSource = 'measured';
|
||||
|
||||
const chartPath = path.join(chartDirectory, `${calibration.songId}.${difficulty}.json`);
|
||||
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
|
||||
chart.scoreRatio = calibration.scoreRatio;
|
||||
chart.scoreCalibrationSource = 'measured';
|
||||
await writeFile(chartPath, `${JSON.stringify(chart)}\n`);
|
||||
}
|
||||
|
||||
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
@@ -6,8 +6,20 @@ const outputDirectory = path.resolve('static/data/charts');
|
||||
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
|
||||
const selectedDifficulties = new Set(['HARD', 'EXPERT']);
|
||||
const chartScoreWeightCoefficient = 0.3713416563352296;
|
||||
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
|
||||
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
|
||||
`${row.songId}::${row.difficulty}`,
|
||||
row
|
||||
]));
|
||||
const musicMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/Music.json'), 'utf8'));
|
||||
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
|
||||
const englishMusicText = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/eng/LangMusic_Eng.json'), 'utf8'));
|
||||
const englishTextById = new Map(englishMusicText.map((row) => [row.data?.id ?? row.id, row.data?.text ?? row.text]));
|
||||
const musicDifficultyMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/MusicDifficulty.json'), 'utf8'));
|
||||
const difficultyLevelByKey = new Map(musicDifficultyMaster.map((row) => {
|
||||
const data = row.data ?? row;
|
||||
return [`${data.musicId}::${data.difficultyType.split('_').at(-1)?.toLowerCase()}`, data.difficultyLevel];
|
||||
}));
|
||||
const singerMetadataOverrides = {
|
||||
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
|
||||
};
|
||||
@@ -190,8 +202,10 @@ const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\
|
||||
for (const match of simulatorModule.matchAll(importPattern)) {
|
||||
const [, chartFile, assetFile] = match;
|
||||
const [songId, difficultyAndLevel] = chartFile.split('.', 2);
|
||||
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase();
|
||||
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile });
|
||||
const [difficultyName, difficultyLevelText] = difficultyAndLevel.split('-');
|
||||
const difficulty = difficultyName.toUpperCase();
|
||||
const difficultyLevel = Number.parseInt(difficultyLevelText, 10);
|
||||
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
|
||||
}
|
||||
|
||||
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
|
||||
@@ -211,15 +225,15 @@ async function worker() {
|
||||
const music = musicById.get(item.songId);
|
||||
const singerOverride = singerMetadataOverrides[item.songId];
|
||||
const notes = flattenEvents(chartData);
|
||||
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
|
||||
const output = {
|
||||
schemaVersion: 1,
|
||||
songId: item.songId,
|
||||
difficulty: item.difficulty.toLowerCase(),
|
||||
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
|
||||
// 실측으로 보정된 1노트 기본점수 분모는 기존 채보 가중치 모델을 유지한다.
|
||||
// 실제 점수 타임라인의 롱노트 분류와는 별개의 보정값이다.
|
||||
scoreRatio: calibratedScoreRatio(chartData),
|
||||
scoreCalibrationSource: 'measured-formula-v1',
|
||||
// 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
|
||||
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
|
||||
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
|
||||
notes,
|
||||
specialStartSeconds: (chartData.sp_activation_events ?? [])
|
||||
.sort((left, right) => left.slot - right.slot)
|
||||
@@ -236,10 +250,13 @@ async function worker() {
|
||||
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
|
||||
normalizedCharts.push({
|
||||
songId: item.songId,
|
||||
title: sourceEntry?.title ?? item.songId,
|
||||
title: englishTextById.get(`la-music_title-${item.songId}`) ?? sourceEntry?.title ?? item.songId,
|
||||
difficulty: item.difficulty.toLowerCase(),
|
||||
difficultyLevel: difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
|
||||
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null),
|
||||
durationSeconds: output.durationSeconds,
|
||||
scoreRatio: output.scoreRatio,
|
||||
scoreCalibrationSource: output.scoreCalibrationSource,
|
||||
totalNotes: notes.length,
|
||||
singerType: output.singerType,
|
||||
singerCharacterIds: output.singerCharacterIds,
|
||||
|
||||
@@ -581,7 +581,7 @@ function summarizeSpecialSkillLevels(levels) {
|
||||
return {
|
||||
...summary,
|
||||
condition,
|
||||
effect: condition ? [baseEffect, conditionalEffect].filter(Boolean).join(' ') : baseEffect
|
||||
effect: [baseEffect, conditionalEffect].filter(Boolean).join(' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user