feat: release version 1.0.7

This commit is contained in:
2026-08-21 19:06:55 +09:00
parent 5d29b63f3a
commit 71dfe11bc4
406 changed files with 3086 additions and 1172 deletions
+1
View File
@@ -9,6 +9,7 @@ release
data/* data/*
!data/.gitkeep !data/.gitkeep
!data/card-overrides.csv !data/card-overrides.csv
!data/score-calibrations.json
!data/holodoridb/ !data/holodoridb/
!data/holodoridb/** !data/holodoridb/**
data/holodoridb/*.tmp data/holodoridb/*.tmp
+86
View File
@@ -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 }
]
}
]
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.6", "version": "1.0.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.6", "version": "1.0.7",
"dependencies": { "dependencies": {
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "holodream-tools", "name": "holodream-tools",
"version": "1.0.6", "version": "1.0.7",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -18,6 +18,7 @@
"data:sync": "node scripts/collect-holodoridb.mjs", "data:sync": "node scripts/collect-holodoridb.mjs",
"data:import-songs": "node scripts/import-legacy-song-data.mjs", "data:import-songs": "node scripts/import-legacy-song-data.mjs",
"data:collect-charts": "node scripts/collect-exact-charts.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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio"
+25
View File
@@ -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 압축파일은 실행용 배포본이 아닙니다.
+31
View File
@@ -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`);
+24 -7
View File
@@ -6,8 +6,20 @@ const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json'); const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']); const selectedDifficulties = new Set(['HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296; 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 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 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 = { const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] } 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)) { for (const match of simulatorModule.matchAll(importPattern)) {
const [, chartFile, assetFile] = match; const [, chartFile, assetFile] = match;
const [songId, difficultyAndLevel] = chartFile.split('.', 2); const [songId, difficultyAndLevel] = chartFile.split('.', 2);
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase(); const [difficultyName, difficultyLevelText] = difficultyAndLevel.split('-');
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile }); 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 채보 모듈을 찾지 못했습니다.'); if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
@@ -211,15 +225,15 @@ async function worker() {
const music = musicById.get(item.songId); const music = musicById.get(item.songId);
const singerOverride = singerMetadataOverrides[item.songId]; const singerOverride = singerMetadataOverrides[item.songId];
const notes = flattenEvents(chartData); const notes = flattenEvents(chartData);
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
const output = { const output = {
schemaVersion: 1, schemaVersion: 1,
songId: item.songId, songId: item.songId,
difficulty: item.difficulty.toLowerCase(), difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0), durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
// 실측으로 보정된 1노트 기본점수 분모는 기존 채보 가중치 모델을 유지한다. // 실측이 있으면 우선하고, 없을 때만 채보 가중치와 계산기 고유 상수로 추정한다.
// 실제 점수 타임라인의 롱노트 분류와는 별개의 보정값이다. scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
scoreRatio: calibratedScoreRatio(chartData), scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
scoreCalibrationSource: 'measured-formula-v1',
notes, notes,
specialStartSeconds: (chartData.sp_activation_events ?? []) specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot) .sort((left, right) => left.slot - right.slot)
@@ -236,10 +250,13 @@ async function worker() {
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8'); await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
normalizedCharts.push({ normalizedCharts.push({
songId: item.songId, songId: item.songId,
title: sourceEntry?.title ?? item.songId, title: englishTextById.get(`la-music_title-${item.songId}`) ?? sourceEntry?.title ?? item.songId,
difficulty: item.difficulty.toLowerCase(), difficulty: item.difficulty.toLowerCase(),
difficultyLevel: difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null),
durationSeconds: output.durationSeconds, durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio, scoreRatio: output.scoreRatio,
scoreCalibrationSource: output.scoreCalibrationSource,
totalNotes: notes.length, totalNotes: notes.length,
singerType: output.singerType, singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds, singerCharacterIds: output.singerCharacterIds,
+1 -1
View File
@@ -581,7 +581,7 @@ function summarizeSpecialSkillLevels(levels) {
return { return {
...summary, ...summary,
condition, condition,
effect: condition ? [baseEffect, conditionalEffect].filter(Boolean).join(' ') : baseEffect effect: [baseEffect, conditionalEffect].filter(Boolean).join(' ')
}; };
}); });
} }
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { songOptions, type SongChartSummary, type SongSort } from '$lib/data/song-catalog.ts';
export let charts: readonly SongChartSummary[];
export let difficulty: string;
export let value: string;
const dispatch = createEventDispatcher<{ change: void }>();
let query = '';
let sort: SongSort = 'title';
$: options = songOptions(charts, difficulty, query, sort);
function selectSong(event: Event) {
const nextValue = (event.currentTarget as HTMLSelectElement).value;
if (!nextValue) return;
value = nextValue;
dispatch('change');
}
</script>
<div class="song-picker">
<label class="song-search">
<span>악곡 검색</span>
<input bind:value={query} type="search" placeholder="영문 곡명 검색" autocomplete="off">
</label>
<label class="song-sort">
<span>정렬</span>
<select bind:value={sort}>
<option value="title">알파벳순</option>
<option value="difficulty-asc">난이도 낮은순</option>
<option value="difficulty-desc">난이도 높은순</option>
</select>
</label>
<label class="song-select">
<span>악곡 <small>{options.length}곡</small></span>
<select value={value} on:change={selectSong} disabled={options.length === 0}>
{#if options.length === 0}
<option>검색 결과가 없습니다</option>
{:else}
{#if !options.some((song) => song.id === value)}
<option value="" disabled>검색 결과에서 악곡을 선택하세요</option>
{/if}
{#each options as song}
<option value={song.id}>{song.title}{song.difficultyLevel === null ? '' : ` · Lv. ${song.difficultyLevel}`}</option>
{/each}
{/if}
</select>
</label>
{#if query.trim()}
<p class:no-results={options.length === 0} class="search-status" aria-live="polite">
{options.length > 0
? `${options.length}곡을 찾았습니다. 목록에서 선택하세요.`
: '검색 결과가 없습니다.'}
</p>
{/if}
</div>
<style>
.song-picker { display: grid; grid-template-columns: minmax(180px, .9fr) minmax(140px, .55fr) minmax(260px, 1.4fr); gap: 9px; }
label { display: grid; gap: 7px; min-width: 0; color: #8f98a8; font-size: 14px; font-weight: 750; }
label > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
small { color: #737d8d; font-size: 11px; }
input, select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
input::placeholder { color: #68717f; opacity: 1; }
input:focus, select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
select:disabled { color: #747d8b; }
.search-status { grid-column: 1 / -1; margin: -1px 2px 0; color: #8fa99c; font-size: 12px; font-weight: 750; line-height: 1.45; }
.search-status.no-results { color: #d09494; }
@media (max-width: 900px) {
.song-picker { grid-template-columns: 1fr minmax(140px, .55fr); }
.song-select { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
.song-picker { grid-template-columns: 1fr; }
.song-select { grid-column: auto; }
}
</style>
+98 -98
View File
@@ -323,7 +323,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -332,7 +332,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -484,7 +484,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -493,7 +493,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -750,7 +750,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -759,7 +759,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -1008,7 +1008,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -1017,7 +1017,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -1927,7 +1927,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -1936,7 +1936,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -2749,7 +2749,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -2758,7 +2758,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -2894,7 +2894,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -2903,7 +2903,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -3393,7 +3393,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -3402,7 +3402,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -3659,7 +3659,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 11, "durationSeconds": 11,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 110%" "effect": "스코어 서포트 효과 110% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -3668,7 +3668,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 11, "durationSeconds": 11,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 130%" "effect": "스코어 서포트 효과 130% 스킬 발동률이 45% UP"
} }
], ],
"connect": [ "connect": [
@@ -3796,7 +3796,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -3805,7 +3805,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -4199,7 +4199,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -4208,7 +4208,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -4602,7 +4602,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -4611,7 +4611,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -5448,7 +5448,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -5457,7 +5457,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -6769,7 +6769,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 100%" "effect": "스코어 서포트 효과 100% 스킬 발동률이 35% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -6778,7 +6778,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 12, "durationSeconds": 12,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
} }
], ],
"connect": [ "connect": [
@@ -7857,7 +7857,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -7866,7 +7866,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -7978,7 +7978,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -7987,7 +7987,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -8308,7 +8308,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -8317,7 +8317,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -9009,7 +9009,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -9018,7 +9018,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -9170,7 +9170,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 120%" "effect": "스코어 서포트 효과 120% 스킬 발동률이 40% UP"
}, },
{ {
"level": 2, "level": 2,
@@ -9179,7 +9179,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 145%" "effect": "스코어 서포트 효과 145% 스킬 발동률이 50% UP"
} }
], ],
"connect": [ "connect": [
@@ -16033,7 +16033,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -16042,7 +16042,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -16270,7 +16270,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -16279,7 +16279,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -16507,7 +16507,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -16516,7 +16516,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -16586,7 +16586,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -16595,7 +16595,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -16665,7 +16665,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -16674,7 +16674,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -16902,7 +16902,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -16911,7 +16911,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 9, "durationSeconds": 9,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 60%" "effect": "스코어 서포트 효과 60% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -16981,7 +16981,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -16990,7 +16990,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -17060,7 +17060,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -17069,7 +17069,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -17139,7 +17139,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -17148,7 +17148,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -17218,7 +17218,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -17227,7 +17227,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -17376,7 +17376,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -17385,7 +17385,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 9, "durationSeconds": 9,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 60%" "effect": "스코어 서포트 효과 60% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -17534,7 +17534,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -17543,7 +17543,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -17613,7 +17613,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -17622,7 +17622,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -17850,7 +17850,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -17859,7 +17859,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -17929,7 +17929,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -17938,7 +17938,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -18245,7 +18245,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -18254,7 +18254,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -18403,7 +18403,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -18412,7 +18412,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -18561,7 +18561,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -18570,7 +18570,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -18798,7 +18798,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -18807,7 +18807,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -18956,7 +18956,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -18965,7 +18965,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19114,7 +19114,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19123,7 +19123,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19193,7 +19193,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19202,7 +19202,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19272,7 +19272,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 45%" "effect": "스코어 서포트 효과 45% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -19281,7 +19281,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -19430,7 +19430,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% GOOD 이상이 PERFECT가 된다"
}, },
{ {
"level": 2, "level": 2,
@@ -19439,7 +19439,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 7, "durationSeconds": 7,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 80%" "effect": "스코어 서포트 효과 80% GOOD 이상이 PERFECT가 된다"
} }
], ],
"connect": [], "connect": [],
@@ -19509,7 +19509,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19518,7 +19518,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19588,7 +19588,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19597,7 +19597,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19667,7 +19667,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19676,7 +19676,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -19825,7 +19825,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -19834,7 +19834,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -20141,7 +20141,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 50%" "effect": "스코어 서포트 효과 50% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -20150,7 +20150,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 8, "durationSeconds": 8,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 70%" "effect": "스코어 서포트 효과 70% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
@@ -20220,7 +20220,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 40%" "effect": "스코어 서포트 효과 40% 라이프가 300 회복"
}, },
{ {
"level": 2, "level": 2,
@@ -20229,7 +20229,7 @@
"probabilityPercent": null, "probabilityPercent": null,
"durationSeconds": 10, "durationSeconds": 10,
"condition": "", "condition": "",
"effect": "스코어 서포트 효과 55%" "effect": "스코어 서포트 효과 55% 라이프가 400 회복"
} }
], ],
"connect": [], "connect": [],
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
export type SongSort = 'title' | 'difficulty-asc' | 'difficulty-desc';
export type SongChartSummary = {
songId: string;
title: string;
difficulty: string;
difficultyLevel?: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
};
export type SongOption = {
id: string;
title: string;
difficultyLevel: number | null;
};
const englishTitleCollator = new Intl.Collator('en', {
sensitivity: 'base',
numeric: true
});
function normalizedSearch(value: string): string {
return value.normalize('NFKD').toLocaleLowerCase('en').replace(/[^a-z0-9]+/g, ' ').trim();
}
export function songOptions(
charts: readonly SongChartSummary[],
difficulty: string,
query = '',
sort: SongSort = 'title'
): SongOption[] {
const levelBySongId = new Map(charts
.filter((chart) => chart.difficulty === difficulty)
.map((chart) => [chart.songId, chart.difficultyLevel ?? null]));
const options = Array.from(new Map(charts.map((chart) => [chart.songId, {
id: chart.songId,
title: chart.title,
difficultyLevel: levelBySongId.get(chart.songId) ?? null
}])).values());
const search = normalizedSearch(query);
const filtered = search
? options.filter((song) => normalizedSearch(`${song.title} ${song.id}`).includes(search))
: options;
const titleOrder = (left: SongOption, right: SongOption) =>
englishTitleCollator.compare(left.title, right.title) || left.id.localeCompare(right.id);
return filtered.sort((left, right) => {
if (sort === 'title') return titleOrder(left, right);
if (left.difficultyLevel === null) return right.difficultyLevel === null ? titleOrder(left, right) : 1;
if (right.difficultyLevel === null) return -1;
const leftLevel = left.difficultyLevel;
const rightLevel = right.difficultyLevel;
const levelOrder = sort === 'difficulty-desc' ? rightLevel - leftLevel : leftLevel - rightLevel;
return levelOrder || titleOrder(left, right);
});
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { effectiveActiveProbability } from './active-probability.ts';
import type { OptimizerCard } from './types.ts';
function card(probabilityPercent: number, activationRateUpPercent = 0): OptimizerCard {
return {
id: 'card',
name: '카드',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent,
activationRateUpPercent
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
}
test('블루 보드와 스페셜 발동률 UP은 배율을 합산한 뒤 기본 발동률에 곱한다', () => {
assert.ok(Math.abs(effectiveActiveProbability(card(55, 30), 40) - 0.935) < 1e-12);
});
test('합산한 발동률은 100%를 넘지 않는다', () => {
assert.equal(effectiveActiveProbability(card(55, 50), 40), 1);
});
+13
View File
@@ -0,0 +1,13 @@
import type { OptimizerCard } from './types.ts';
/**
* 액티브 기본 발동률에 상시 보너스와 발동 시점의 스페셜 보너스를 합산해 적용한다.
*/
export function effectiveActiveProbability(
card: OptimizerCard,
timedActivationRateUpPercent = 0
): number {
const baseProbability = (card.active.probabilityPercent ?? 100) / 100;
const totalRateUpPercent = (card.active.activationRateUpPercent ?? 0) + timedActivationRateUpPercent;
return Math.max(0, Math.min(1, baseProbability * (1 + totalRateUpPercent / 100)));
}
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { optimizeBoardConnectionCards, type BoardConnectionOptimizerSource } from './board-connection-optimizer.ts';
const source: BoardConnectionOptimizerSource = {
members: [{ id: 'member', positionGroupId: 'model' }],
models: {
model: [
{ groupId: 'S-center', x: 0, y: 0 },
{ groupId: 'G-left', x: -1, y: 0 },
{ groupId: 'G-right', x: 1, y: 0 },
{ groupId: 'S-right', x: 3, y: 0 },
{ groupId: 'G-far', x: 4, y: 0 }
]
},
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-right': [{ characterIds: [], type: 'connection', effect: null }],
'G-left': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 100 } }],
'G-right': [{ characterIds: [], type: 'all_member', effect: { type: 'performance_up', value: 300 } }],
'G-far': [{ characterIds: [], type: 'all_member', effect: { type: 'all_parameter_up', value: 200 } }]
}
};
const leftCard = {
id: 'left-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: -1, y: 0 }] }]
};
const rightCard = {
id: 'right-card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
};
test('활성 연결 노드 전체에서 보유 카드의 최대 가중치 배치를 찾는다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard]);
assert.deepEqual(result.placements, {
member: { 'S-center': 'left-card', 'S-right': 'right-card' }
});
assert.equal(result.assignments.length, 2);
});
test('비활성 연결 노드와 비활성 효과 노드는 배치 점수에 포함하지 않는다', () => {
const memberBoards = { member: ['G-left'] };
const before = structuredClone(memberBoards);
const result = optimizeBoardConnectionCards(source, memberBoards, [leftCard, rightCard]);
assert.deepEqual(result.placements, { member: { 'S-center': 'left-card' } });
assert.deepEqual(memberBoards, before);
});
test('5개화 카드는 2레벨 연결 범위와 배율을 사용한다', () => {
const awakened = {
id: 'awakened', awakening: 5,
connect: [
{ level: 1, effectPermilUp: 100, extent: [{ x: -1, y: 0 }] },
{ level: 2, effectPermilUp: 200, extent: [{ x: 1, y: 0 }] }
]
};
const result = optimizeBoardConnectionCards(source, { member: ['G-left', 'G-right'] }, [awakened]);
assert.deepEqual(result.placements, { member: { 'S-center': 'awakened' } });
assert.equal(result.totalWeight, 60_000);
});
test('솔로와 단체 10%를 각각 우선하며 단체는 다른 멤버 보너스도 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [
{ id: 'target', positionGroupId: 'model' },
{ id: 'helper', positionGroupId: 'model' }
],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{
characterIds: ['target'], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
},
{
characterIds: ['helper'], type: 'content',
effect: {
id: 'music_singer_type-group',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}
]
}
};
const cards = ['a', 'b'].map((id) => ({
id, awakening: 0,
connect: [{ level: 1, effectPermilUp: 4000, extent: [{ x: 1, y: 0 }] }]
}));
const result = optimizeBoardConnectionCards(
bonusSource,
{ target: ['Y'], helper: ['Y'] },
cards,
{ targetMemberId: 'target', requireSoloSongBonus: true, requireGroupSongBonus: true }
);
assert.equal(result.soloSongBonusPercent, 10);
assert.equal(result.groupSongBonusPercent, 10);
assert.equal(result.assignments.length, 2);
});
test('10%에 부족한 활성 노드만 있으면 가능한 보너스까지만 최적화한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'target', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [{
characterIds: [], type: 'content',
effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20
}
}]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { target: ['Y'] }, [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}], { targetMemberId: 'target', requireSoloSongBonus: true });
assert.equal(result.soloSongBonusPercent, 4);
assert.deepEqual(result.placements, { target: { S: 'card' } });
});
test('편성 기반 배치 가중치를 제공하면 해당 효율을 우선한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1
});
assert.equal(result.placements.member['S-right'], 'right-card');
});
test('우선 배치 가중치는 일반 점수보다 먼저 최대화한다', () => {
const result = optimizeBoardConnectionCards(source, { member: ['S-right', 'G-left', 'G-right', 'G-far'] }, [leftCard, rightCard], {
placementWeight: (card, _memberId, socketId) => card.id === 'right-card' && socketId === 'S-right' ? 10_000 : 1,
placementPriorityWeight: (card, _memberId, socketId) => card.id === 'left-card' && socketId === 'S-right' ? 1 : 0
});
assert.equal(result.placements.member['S-right'], 'left-card');
});
test('리더와 보너스 대상이 같으면 악곡 보너스보다 편성 효율을 우선한다', () => {
const prioritySource: BoardConnectionOptimizerSource = {
members: [{ id: 'leader', positionGroupId: 'model' }],
models: { model: [
{ groupId: 'S-center', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 },
{ groupId: 'S-top', x: 0, y: 3 }, { groupId: 'R', x: 1, y: 3 }
] },
nodeGroups: {
'S-center': [{ characterIds: [], type: 'connection', effect: null }],
'S-top': [{ characterIds: [], type: 'connection', effect: null }],
Y: [{ characterIds: [], type: 'content', effect: {
id: 'music_singer_type-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 50
} }],
R: [{ characterIds: [], type: 'leader', effect: { type: 'performance_up', value: 100 } }]
}
};
const singleCard = [{
id: 'card', awakening: 0,
connect: [{ level: 1, effectPermilUp: 1000, extent: [{ x: 1, y: 0 }] }]
}];
const result = optimizeBoardConnectionCards(prioritySource, { leader: ['Y', 'S-top', 'R'] }, singleCard, {
targetMemberId: 'leader',
requireSoloSongBonus: true,
prioritizePlacementWeight: true,
placementWeight: (_card, _memberId, socketId) => socketId === 'S-top' ? 100 : 0
});
assert.deepEqual(result.placements, { leader: { 'S-top': 'card' } });
assert.equal(result.soloSongBonusPercent, 5);
});
test('선택한 단체 악곡의 가창자 보드만 단체 보너스에 합산한다', () => {
const bonusSource: BoardConnectionOptimizerSource = {
members: [{ id: 'singer', positionGroupId: 'model' }, { id: 'outsider', positionGroupId: 'model' }],
models: { model: [{ groupId: 'S', x: 0, y: 0 }, { groupId: 'Y', x: 1, y: 0 }] },
nodeGroups: {
S: [{ characterIds: [], type: 'connection', effect: null }],
Y: [
{ characterIds: ['singer'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 20 } },
{ characterIds: ['outsider'], type: 'content', effect: { id: 'music_singer_type-group', type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 80 } }
]
}
};
const result = optimizeBoardConnectionCards(bonusSource, { singer: ['Y'], outsider: ['Y'] }, [], {
targetMemberId: 'singer', requireGroupSongBonus: true, groupSingerMemberIds: ['singer']
});
assert.equal(result.groupSongBonusPercent, 2);
});
@@ -0,0 +1,226 @@
import { maximumWeightMatching } from './maximum-weight-matching.ts';
type OptimizableBoardEffect = {
id?: string;
type: string;
value: number | null;
trigger?: string | null;
};
type OptimizableBoardNode = {
characterIds: readonly string[];
type: string;
effect: OptimizableBoardEffect | null;
};
type OptimizableBoardPosition = { groupId: string; x: number; y: number };
export type BoardConnectionOptimizerSource = {
members: readonly { id: string; positionGroupId: string }[];
models: Readonly<Record<string, readonly OptimizableBoardPosition[]>>;
nodeGroups: Readonly<Record<string, readonly OptimizableBoardNode[]>>;
};
export type OptimizableConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnectionAssignment = {
memberId: string;
socketId: string;
cardId: string;
weight: number;
};
export type BoardConnectionOptimizationResult = {
placements: Record<string, Record<string, string>>;
assignments: BoardConnectionAssignment[];
totalWeight: number;
soloSongBonusPercent: number;
groupSongBonusPercent: number;
};
export type BoardConnectionOptimizationOptions = {
targetMemberId?: string;
requireSoloSongBonus?: boolean;
requireGroupSongBonus?: boolean;
placementWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
placementPriorityWeight?: (card: OptimizableConnectionCard, memberId: string, socketId: string) => number;
prioritizePlacementWeight?: boolean;
groupSingerMemberIds?: readonly string[];
};
function nodeForMember(
source: BoardConnectionOptimizerSource,
groupId: string,
memberId: string
): OptimizableBoardNode | null {
const nodes = source.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function effectValue(effect: OptimizableBoardEffect | null): number {
if (!effect || effect.value === null || effect.trigger === 'work') return 0;
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') return 0;
// Song bonuses are contextual and are valued only when the user requests
// the corresponding solo/group target below.
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return 0;
const value = Math.max(0, Number(effect.value) || 0);
return effect.type.startsWith('all_parameter_up') ? value * 3 : value;
}
function selectedEffect(card: OptimizableConnectionCard) {
const level = card.awakening >= 5 ? 2 : 1;
return card.connect.find((effect) => effect.level === level) ?? card.connect.at(-1) ?? null;
}
function songBonusModes(
effect: OptimizableBoardEffect | null,
ownerId: string,
targetMemberId: string,
groupSingerMemberIds?: readonly string[]
): ('solo' | 'group')[] {
if (!effect?.id || effect.type !== 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') return [];
const mode = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (mode === 'all') return ['solo', 'group'];
if (mode === 'solo' && ownerId === targetMemberId) return ['solo'];
// A unit song bonus may be supplied by another participating singer's board.
if (mode === 'group' && (!groupSingerMemberIds || groupSingerMemberIds.includes(ownerId))) return ['group'];
return [];
}
/**
* Reassigns owned connection cards across active connection sockets without
* changing the supplied node activation state.
*/
export function optimizeBoardConnectionCards(
source: BoardConnectionOptimizerSource,
memberBoards: Readonly<Record<string, readonly string[]>>,
ownedCards: readonly OptimizableConnectionCard[],
options: BoardConnectionOptimizationOptions = {}
): BoardConnectionOptimizationResult {
const targetMemberId = options.targetMemberId ?? '';
let baseSoloBonus = 0;
let baseGroupBonus = 0;
const slots = source.members.flatMap((member) => {
const positions = source.models[member.positionGroupId] ?? [];
const centerId = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
const active = new Set(memberBoards[member.id] ?? []);
if (centerId) active.add(centerId);
for (const position of positions) {
if (!active.has(position.groupId)) continue;
const node = nodeForMember(source, position.groupId, member.id);
const value = Math.max(0, Number(node?.effect?.value) || 0);
for (const mode of songBonusModes(node?.effect ?? null, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') baseSoloBonus += value;
else baseGroupBonus += value;
}
}
return positions
.filter((position) => active.has(position.groupId) && nodeForMember(source, position.groupId, member.id)?.type === 'connection')
.map((socket) => ({ member, socket, positions, active }));
});
const cards = ownedCards.filter((card) => selectedEffect(card));
const emptyResult = {
placements: {}, assignments: [], totalWeight: 0,
soloSongBonusPercent: Math.min(10, baseSoloBonus / 10),
groupSongBonusPercent: Math.min(10, baseGroupBonus / 10)
};
if (cards.length === 0 || slots.length === 0) return emptyResult;
const soloBoosts = cards.map(() => slots.map(() => 0));
const groupBoosts = cards.map(() => slots.map(() => 0));
const genericWeights = cards.map((card, cardIndex) => slots.map(({ member, socket, positions, active }, slotIndex) => {
const effect = selectedEffect(card);
if (!effect) return 0;
const positionByCoordinate = new Map(positions.map((position) => [`${position.x},${position.y}`, position]));
const coveredValue = effect.extent.reduce((total, offset) => {
const position = positionByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (!position || !active.has(position.groupId)) return total;
const nodeEffect = nodeForMember(source, position.groupId, member.id)?.effect ?? null;
const value = effectValue(nodeEffect);
const boost = Math.max(0, Number(nodeEffect?.value) || 0) * Math.max(0, effect.effectPermilUp);
for (const mode of songBonusModes(nodeEffect, member.id, targetMemberId, options.groupSingerMemberIds)) {
if (mode === 'solo') soloBoosts[cardIndex][slotIndex] += boost;
else groupBoosts[cardIndex][slotIndex] += boost;
}
return total + value;
}, 0);
const defaultWeight = coveredValue * Math.max(0, effect.effectPermilUp);
return Math.max(0, options.placementWeight?.(card, member.id, socket.groupId) ?? defaultWeight);
}));
const priorityUnits = cards.map((card) => slots.map(({ member, socket }) =>
Math.max(0, Math.round((options.placementPriorityWeight?.(card, member.id, socket.groupId) ?? 0) * 1000))
));
const maximumAssignments = Math.min(cards.length, slots.length);
const genericBudget = Math.max(1, ...genericWeights.flatMap((row) => row)) * maximumAssignments;
const weights = genericWeights.map((row, cardIndex) => row.map((weight, slotIndex) =>
priorityUnits[cardIndex][slotIndex] * (genericBudget + 1) + weight
));
const priorityScale = Math.max(1, ...weights.flatMap((row) => row)) * Math.max(cards.length, slots.length) + 1;
const ratios = options.requireSoloSongBonus && options.requireGroupSongBonus
? [1 / 256, 1 / 16, 1 / 4, 1, 4, 16, 256]
: [1];
const priorityMultipliers = options.requireSoloSongBonus || options.requireGroupSongBonus
? [0, 1 / 1_000_000, 1 / 10_000, 1 / 100, 1, 100]
: [0];
const candidates = priorityMultipliers.flatMap((priorityMultiplier) => ratios.map((ratio) => {
const prioritized = weights.map((row, cardIndex) => row.map((weight, slotIndex) => weight + priorityScale * (
(options.requireSoloSongBonus ? soloBoosts[cardIndex][slotIndex] * ratio : 0)
+ (options.requireGroupSongBonus ? groupBoosts[cardIndex][slotIndex] : 0)
) * priorityMultiplier));
const matches = maximumWeightMatching(prioritized);
const genericWeight = matches.reduce((total, match) => total + weights[match.rowIndex][match.columnIndex], 0);
const soloBonus = baseSoloBonus + matches.reduce((total, match) => total + soloBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const groupBonus = baseGroupBonus + matches.reduce((total, match) => total + groupBoosts[match.rowIndex][match.columnIndex] / 1000, 0);
const feasible = (!options.requireSoloSongBonus || soloBonus >= 100)
&& (!options.requireGroupSongBonus || groupBonus >= 100);
const fulfillment = Math.min(
options.requireSoloSongBonus ? soloBonus / 100 : Number.POSITIVE_INFINITY,
options.requireGroupSongBonus ? groupBonus / 100 : Number.POSITIVE_INFINITY
);
const totalFulfillment = (options.requireSoloSongBonus ? Math.min(1, soloBonus / 100) : 0)
+ (options.requireGroupSongBonus ? Math.min(1, groupBonus / 100) : 0);
return { matches, genericWeight, soloBonus, groupBonus, feasible, fulfillment, totalFulfillment };
}));
const best = candidates.sort((left, right) => options.prioritizePlacementWeight
? right.genericWeight - left.genericWeight
|| Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
: Number(right.feasible) - Number(left.feasible)
|| right.fulfillment - left.fulfillment
|| right.totalFulfillment - left.totalFulfillment
|| right.genericWeight - left.genericWeight)[0];
const matches = best.matches;
const placements: Record<string, Record<string, string>> = {};
const assignments = matches.map((match) => {
const card = cards[match.rowIndex];
const slot = slots[match.columnIndex];
(placements[slot.member.id] ??= {})[slot.socket.groupId] = card.id;
return {
memberId: slot.member.id,
socketId: slot.socket.groupId,
cardId: card.id,
weight: genericWeights[match.rowIndex][match.columnIndex]
};
});
return {
placements,
assignments,
totalWeight: assignments.reduce((total, assignment) => total + assignment.weight, 0),
soloSongBonusPercent: Math.min(10, best.soloBonus / 10),
groupSongBonusPercent: Math.min(10, best.groupBonus / 10)
};
}
+6 -2
View File
@@ -104,7 +104,7 @@ test('같은 기수의 보드 보너스는 카드별로 최대 900까지만 적
assert.equal(result.flatByCardId['card-b'].performance, 0); assert.equal(result.flatByCardId['card-b'].performance, 0);
}); });
test('복수 소속 카드는 각 기수의 900 상한을 따로 적용한다', () => { test('복수 소속 카드도 모든 기수 보너스를 합쳐 카드별 900까지만 적용한다', () => {
const multiGroupingSummary: BoardSummarySource = { const multiGroupingSummary: BoardSummarySource = {
nodeGroups: { nodeGroups: {
'G-20': [{ characterIds: [], effect: { type: 'all_parameter_up_for_character_grouping', value: 900, targetId: 'skill_tree_effect_target-character_grouping-grp-gen_1' } }], '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 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'); const result = calculateBoardEffects(multiGroupingSummary, { account: ['G-20', 'G-21', 'G-22'] }, multiGroupingCards, 'fubuki');
assert.equal(result.flatByCardId.fubuki.performance, 1800); assert.equal(firstGenerationMaxed.flatByCardId.fubuki.performance, 900);
assert.equal(gamersAdded.flatByCardId.fubuki.performance, 900);
assert.equal(result.flatByCardId.fubuki.performance, 900);
}); });
+3 -5
View File
@@ -141,7 +141,7 @@ export function calculateBoardEffects(
let scoreSupportPercent = 0; let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0; let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections); 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 [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) { for (const groupId of activeGroupIds) {
@@ -176,13 +176,11 @@ export function calculateBoardEffects(
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value; const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
const effectTargets = targets(effect, ownerId, cards); const effectTargets = targets(effect, ownerId, cards);
if (effect.type === 'all_parameter_up_for_character_grouping') { if (effect.type === 'all_parameter_up_for_character_grouping') {
const groupingId = effect.targetId ?? 'unknown-grouping';
for (const card of effectTargets) { for (const card of effectTargets) {
if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue; if (overrides?.generationBonusByCardId?.[card.id] !== undefined) continue;
const cardBonuses = generationBonusByCardAndGrouping[card.id] ??= {}; const currentBonus = generationBonusByCardId[card.id] ?? 0;
const currentBonus = cardBonuses[groupingId] ?? 0;
const appliedValue = Math.max(0, Math.min(normalizedValue, generationBonusCap - currentBonus)); 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; for (const stat of stats) destination[card.id][stat] += appliedValue;
} }
continue; continue;
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
type SpecialLevel = {
description: string;
effect: string;
};
type CardSummary = {
cards: Array<{
name: string;
details: { special: SpecialLevel[] };
}>;
};
test('스페셜 설명의 추가 효과가 요약 데이터에서 누락되지 않는다', async () => {
const path = new URL('../data/cards-summary.json', import.meta.url);
const summary = JSON.parse(await readFile(path, 'utf8')) as CardSummary;
const additionalEffectMarkers = ['스킬 발동률', 'GOOD 이상이 PERFECT가 된다', '라이프가'];
const missing = summary.cards.flatMap((card) => card.details.special.flatMap((level) =>
additionalEffectMarkers
.filter((marker) => level.description.includes(marker) && !level.effect.includes(marker))
.map((marker) => `${card.name} (${marker}): ${level.description}`)));
assert.deepEqual(missing, []);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { calculateExactScore } from './score.ts'; import { calculateExactScore } from './score.ts';
import { passiveScoreSupportByCard } from './passive-support.ts'; import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
@@ -85,7 +86,7 @@ function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): M
let effectiveProbability = probabilityByAttempt.get(attempt); let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) { if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent'); 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); probabilityByAttempt.set(attempt, effectiveProbability);
} }
boost[noteIndex] = card.active.scoreUpPercent / 100 * boost[noteIndex] = card.active.scoreUpPercent / 100 *
+2
View File
@@ -1,7 +1,9 @@
export * from './active-probability.ts';
export * from './card-adapter.ts'; export * from './card-adapter.ts';
export * from './combination-search.ts'; export * from './combination-search.ts';
export * from './awakening-requirements.ts'; export * from './awakening-requirements.ts';
export * from './board-effects.ts'; export * from './board-effects.ts';
export * from './board-connection-optimizer.ts';
export * from './exact-optimizer.ts'; export * from './exact-optimizer.ts';
export * from './memory.ts'; export * from './memory.ts';
export * from './maximum-weight-matching.ts'; export * from './maximum-weight-matching.ts';
+16
View File
@@ -74,6 +74,22 @@ test('스페셜의 액티브 발동률 증가를 발동 시점에 적용한다',
assert.equal(result.maximum, 0.75); 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%다', () => { test('확정 발동이면 모든 달성 확률이 100%다', () => {
const result = estimateScoreAchievementProbabilities( const result = estimateScoreAchievementProbabilities(
[card('main', 100, 100)], [card('main', 100, 100)],
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { passiveScoreSupportByCard } from './passive-support.ts'; import { passiveScoreSupportByCard } from './passive-support.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import type { import type {
@@ -84,7 +85,7 @@ function prepareModel(
let attemptIndex = attemptIndexes.get(key); let attemptIndex = attemptIndexes.get(key);
if (attemptIndex === undefined) { if (attemptIndex === undefined) {
const activationBonus = specialRate(cards, chart, activationTime, 'activationRateUpPercent'); 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; attemptIndex = attempts.length;
attemptIndexes.set(key, attemptIndex); attemptIndexes.set(key, attemptIndex);
attempts.push({ key, probability }); attempts.push({ key, probability });
+47
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import scoreCalibrations from '../../../data/score-calibrations.json' with { type: 'json' };
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts'; import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts'; import type { ExactSongChart, OptimizerCard } from './types.ts';
@@ -74,3 +75,49 @@ test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수
assert.equal(result.expectedScore, 1_200); assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800); assert.equal(result.theoreticalMaxScore, 1_800);
}); });
test('블루 보드와 스페셜의 발동률 증가 배율을 합산해 기대 점수에 반영한다', () => {
const main: OptimizerCard = {
id: 'main',
name: '메인',
active: {
cooldownSeconds: 1,
durationSeconds: 1,
scoreUpPercent: 100,
probabilityPercent: 55,
activationRateUpPercent: 30
},
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
const support: OptimizerCard = {
id: 'support',
name: '서포트',
active: { cooldownSeconds: 999, durationSeconds: 0, scoreUpPercent: 0, probabilityPercent: 0 },
special: { durationSeconds: 3, scoreUpPercent: 0, activationRateUpPercent: 40 }
};
const result = calculateExactScore([main, support], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [null, 0]
}, { perfectNoteScore: 100 });
assert.equal(result.expectedScore, 194);
assert.equal(result.theoreticalMaxScore, 200);
});
test('등록된 실측 ratio가 모든 1노트 측정값을 재현한다', () => {
for (const calibration of scoreCalibrations.charts) {
for (const measurement of calibration.measurements) {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), {
perfectNoteScore: measurement.overallPower / calibration.scoreRatio,
directSongScoreBonusPercent: measurement.directSongScoreBonusPercent
});
assert.equal(
result.baselineScore,
measurement.normalPerfectScore,
`${calibration.songId} ${calibration.difficulty}`
);
}
}
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { effectiveActiveProbability } from './active-probability.ts';
import { effectiveCooldownSeconds } from './timeline.ts'; import { effectiveCooldownSeconds } from './timeline.ts';
import { passiveScoreSupportAt } from './passive-support.ts'; import { passiveScoreSupportAt } from './passive-support.ts';
import type { import type {
@@ -86,7 +87,7 @@ function activeCandidates(
const activationTime = attempt * interval; const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return []; if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime); 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); const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{ return [{
memberIndex, memberIndex,
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { songOptions } from '../data/song-catalog.ts';
const charts = [
{ songId: 'm2', title: 'Zeta Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'expert', difficultyLevel: 30 },
{ songId: 'm1', title: 'Alpha Song', difficulty: 'hard', difficultyLevel: 18 },
{ songId: 'm3', title: 'Another Song', difficulty: 'expert', difficultyLevel: 25 },
{ songId: 'm4', title: 'Unknown Song', difficulty: 'expert' }
];
test('악곡 목록은 기본적으로 영문 제목 알파벳순으로 정렬된다', () => {
assert.deepEqual(songOptions(charts, 'expert').map((song) => song.id), ['m1', 'm3', 'm4', 'm2']);
});
test('영문 제목은 대소문자 구분 없이 검색하고 곡 ID도 검색한다', () => {
assert.deepEqual(songOptions(charts, 'expert', 'ALPHA').map((song) => song.id), ['m1']);
assert.deepEqual(songOptions(charts, 'expert', 'm2').map((song) => song.id), ['m2']);
});
test('선택한 채보의 레벨로 난이도 낮은순과 높은순 정렬을 지원한다', () => {
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-asc').map((song) => song.id), ['m3', 'm2', 'm1', 'm4']);
assert.deepEqual(songOptions(charts, 'expert', '', 'difficulty-desc').map((song) => song.id), ['m1', 'm3', 'm2', 'm4']);
assert.equal(songOptions(charts, 'hard')[0].difficultyLevel, 18);
});
+3
View File
@@ -14,6 +14,8 @@ export type OptimizerCard = {
durationSeconds: number; durationSeconds: number;
scoreUpPercent: number; scoreUpPercent: number;
probabilityPercent?: number; probabilityPercent?: number;
/** 블루 보드처럼 액티브 기본 발동률에 더해지는 상시 발동률 UP 배율. */
activationRateUpPercent?: number;
}; };
special: { special: {
durationSeconds: number; durationSeconds: number;
@@ -90,6 +92,7 @@ export type ExactSongChart = {
difficulty: string; difficulty: string;
durationSeconds: number; durationSeconds: number;
scoreRatio: number | null; scoreRatio: number | null;
scoreCalibrationSource?: 'measured' | 'local-estimated-v1' | string;
notes: readonly ExactChartNote[]; notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[]; specialStartSeconds: readonly (number | null)[];
singerType?: string | null; singerType?: string | null;
+299 -14
View File
@@ -9,8 +9,9 @@
import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts'; import { createInitialReleaseClock, synchronizeReleaseClock } from '$lib/data/release-clock.ts';
import { releasedSongCharts } from '$lib/data/song-release.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts'; import SongPicker from '$lib/components/SongPicker.svelte';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, ScoreAchievementProbabilities, SkillWindow } from '$lib/engine/index.ts'; 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 { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts'; import { createScoreOptimizerWorker } from '$lib/workers/create-score-optimizer-worker.ts';
@@ -19,6 +20,16 @@
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>; type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>; type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, 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 & { type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number; baseScoreUpPercent: number;
minScoreUpPercent: number; minScoreUpPercent: number;
@@ -52,6 +63,8 @@
let charts = releasedSongCharts(allCharts, releaseNow); let charts = releasedSongCharts(allCharts, releaseNow);
let songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values()); 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 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) => [ const blueGroupsByCharacter = Object.fromEntries(boardSummary.members.map((member) => [
member.id, member.id,
(boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId] (boardSummary.models as Record<string, { groupId: string }[]>)[member.positionGroupId]
@@ -91,6 +104,11 @@
let achievementProbabilities: ScoreAchievementProbabilities | undefined; let achievementProbabilities: ScoreAchievementProbabilities | undefined;
let errorMessage = ''; let errorMessage = '';
let analysisReady = false; 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); $: charts = releasedSongCharts(allCharts, releaseNow);
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values()); $: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values());
@@ -136,6 +154,7 @@
})(); })();
$: overallPower = unitPower.overallPower; $: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null); $: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: timelineDuration = selectedChart?.durationSeconds ?? 0; $: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : []; $: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []); $: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
@@ -411,14 +430,212 @@
...adapted, ...adapted,
active: { active: {
...adapted.active, ...adapted.active,
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) activationRateUpPercent: activationRate
}, },
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent 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> { async function analyze(): Promise<void> {
errorMessage = ''; errorMessage = '';
clearConnectionOptimizationProposal();
result = null; result = null;
achievementProbabilities = undefined; achievementProbabilities = undefined;
if (!worker || !selectedSong || !selectedChart) return; if (!worker || !selectedSong || !selectedChart) return;
@@ -561,11 +778,7 @@
</div> </div>
<div class="song-fields"> <div class="song-fields">
<label>악곡 <div class="song-picker-field"><SongPicker {charts} {difficulty} bind:value={selectedSongId} on:change={() => result = null} /></div>
<select bind:value={selectedSongId} on:change={() => result = null}>
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
</select>
</label>
<label>난이도 <label>난이도
<select bind:value={difficulty} on:change={() => result = null}> <select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option> <option value="expert">Expert</option>
@@ -573,7 +786,7 @@
</select> </select>
</label> </label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div> <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>
<div class="card-fields"> <div class="card-fields">
@@ -684,6 +897,45 @@
</div> </div>
<p class="probability-guide">모든 노트를 PERFECT로 처리한다고 가정하고 액티브 발동 확률만 반영합니다. 100%는 정확 계산, 99%·95% 이상은 25,000회 반복 계산값이며 최고점에 영향을 주지 않는 발동 실패는 100% 달성 확률을 낮추지 않습니다.</p> <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"> <section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading"> <div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div> <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; } 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 { 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%); } 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; } .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; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; } .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 span { color: #929cab; font-size: 12px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; } .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; } .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-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; } .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 strong { color: #e5e9ef; font-size: 20px; }
.probability-grid small { color: #8d96a5; font-size: 12px; font-weight: 700; } .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; } .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; } .empty { color: #858f9f; font-size: 14px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; } .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; } .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); } main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.page-header { align-items: flex-start; } .page-header { align-items: flex-start; }
.panel { padding: 17px; } .panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; } .song-fields { grid-template-columns: minmax(0, 1fr) 100px; }
.power-summary { grid-column: 1 / -1; } .power-summary { grid-column: 1 / -1; margin-top: 0; }
.chart-meta { grid-column: 1 / -1; height: auto; } .chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; } .card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field-header { display: none; } .card-field-header { display: none; }
@@ -896,6 +1177,10 @@
.order-list li { grid-template-columns: 30px minmax(0, 1fr); } .order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; } .reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; } .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-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; } .timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; } .lane-label { flex-basis: 118px; min-width: 118px; }
+14 -10
View File
@@ -10,12 +10,14 @@
import { releasedSongCharts } from '$lib/data/song-release.ts'; import { releasedSongCharts } from '$lib/data/song-release.ts';
import AnalysisResultView from '$lib/components/AnalysisResultView.svelte'; import AnalysisResultView from '$lib/components/AnalysisResultView.svelte';
import PresetManager from '$lib/components/PresetManager.svelte'; import PresetManager from '$lib/components/PresetManager.svelte';
import SongPicker from '$lib/components/SongPicker.svelte';
import { import {
additionalAwakeningsRequired, additionalAwakeningsRequired,
calculateBoardEffects, calculateBoardEffects,
calculateUnitPower, calculateUnitPower,
combinationSearchLimits, combinationSearchLimits,
countOptimizationCandidates, countOptimizationCandidates,
effectiveActiveProbability,
generateAnchoredCombinationCandidates, generateAnchoredCombinationCandidates,
generateCombinationCandidates, generateCombinationCandidates,
memoryParameterPermil, memoryParameterPermil,
@@ -146,6 +148,7 @@
$: songs = Array.from(new Map(charts.map((chart) => [chart.songId, { id: chart.songId, title: chart.title }])).values()); $: 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 ?? ''; $: if (!songs.some((song) => song.id === selectedSongId)) selectedSongId = songs[0]?.id ?? '';
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty); $: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: scoreCalibrationLabel = selectedChart?.scoreCalibrationSource === 'measured' ? '실측값' : '추정값';
$: baselineChartMetadata = baselineState $: baselineChartMetadata = baselineState
? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty) ? charts.find((chart) => chart.songId === baselineState!.songId && chart.difficulty === baselineState!.difficulty)
: undefined; : undefined;
@@ -492,7 +495,7 @@
function roughSkillValues(card: CardRow) { function roughSkillValues(card: CardRow) {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]); const adapted = toOptimizerCard(card, awakeningForCard(card), [0], [card]);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / 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 specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
return { adapted, activeValue, specialValue }; return { adapted, activeValue, specialValue };
} }
@@ -502,7 +505,7 @@
const skillPotential = cards.reduce((sum, card) => { const skillPotential = cards.reduce((sum, card) => {
const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards); const adapted = toOptimizerCard(card, awakeningForCard(card), [0], cards);
const activeValue = adapted.active.scoreUpPercent * adapted.active.durationSeconds / 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 specialValue = adapted.special.scoreUpPercent * adapted.special.durationSeconds / 100;
const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100; const activationValue = (adapted.special.activationRateUpPercent ?? 0) * adapted.special.durationSeconds / 100;
const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => const passiveValue = (adapted.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) =>
@@ -687,12 +690,12 @@
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0; const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return { return {
...adapted, ...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) }, active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
}; };
}); });
const skillPotential = optimizerCards.reduce((sum, card) => sum + 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.special.scoreUpPercent * card.special.durationSeconds / 100 +
(card.scoreSupportPercent ?? 0) + (card.scoreSupportPercent ?? 0) +
(card.passiveScoreSupportEffects ?? []).reduce((effectSum, effect) => effectSum + effect.percent * (effect.target === 'all' ? 5 : effect.targetCount ?? 1), 0), 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; const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return { return {
...adapted, ...adapted,
active: { ...adapted.active, probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100) }, active: { ...adapted.active, activationRateUpPercent: activationRate },
scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent scoreSupportPercent: power.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
}; };
}); });
@@ -1035,9 +1038,9 @@
<section class="panel" aria-labelledby="conditions-title"> <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="section-title"><div><span>01</span><h2 id="conditions-title">탐색 조건</h2></div><strong>{ownedCardsOnly ? '보유 5성 대상' : '5성 전체 대상'}</strong></div>
<div class="song-fields"> <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> <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>
<div class="candidate-fields"> <div class="candidate-fields">
<div class="candidate-awakening"> <div class="candidate-awakening">
@@ -1199,8 +1202,9 @@
label { display: grid; gap: 7px; color: #8f98a8; font-size: 14px; font-weight: 750; } 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, .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%); } 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; } .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; }
.chart-meta { display: flex; align-items: center; gap: 8px; height: 42px; } .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; } .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-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; } .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); } main { width: calc(100% - 24px); padding-top: var(--page-content-top); }
.panel { padding: 17px; } .panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; } .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; } .candidate-fields { grid-template-columns: 1fr; }
.fixed-column-headings { display: none; } .fixed-column-headings { display: none; }
.fixed-field { grid-template-columns: minmax(0, 1fr) 102px; } .fixed-field { grid-template-columns: minmax(0, 1fr) 102px; }
+44 -49
View File
@@ -58,6 +58,8 @@
type AutomaticPlacementResult = { type AutomaticPlacementResult = {
memberId: string; memberId: string;
memberName: string; memberName: string;
socketId: string;
socketName: string;
cardId: string; cardId: string;
cardName: string; cardName: string;
rarity: number; rarity: number;
@@ -155,7 +157,8 @@
: []; : [];
$: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5 $: efficiencyFormationReady = efficiencyFormation.cardIds.length === 5
&& efficiencyFormation.cardIds.every((id) => cardById.has(id)) && 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'; $: dataView = $page.url.pathname.endsWith('/cards') ? 'cards' : $page.url.pathname.endsWith('/boards') ? 'boards' : 'home';
onMount(() => { onMount(() => {
try { try {
@@ -241,11 +244,12 @@
function importAnalysisFormation() { function importAnalysisFormation() {
if (!savedAnalysis) return; if (!savedAnalysis) return;
const analysis = savedAnalysis;
saveEfficiencyFormation({ saveEfficiencyFormation({
cardIds: [...savedAnalysis.cardIds], cardIds: [...analysis.cardIds],
awakenings: savedAnalysis.awakenings.map(normalizeEfficiencyAwakening), awakenings: analysis.awakenings.map(normalizeEfficiencyAwakening),
leaderCharacterId: savedAnalysis.leaderCharacterId, leaderCharacterId: analysis.leaderCharacterId,
leaderOutfitCardId: savedAnalysis.leaderOutfitCardId, leaderOutfitCardId: analysis.leaderOutfitCardId,
coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly coreCubeFreeOnly: efficiencyFormation.coreCubeFreeOnly
}); });
} }
@@ -570,7 +574,7 @@
}, { points: 0, greenCubes: 0, greenCoreCubes: 0 }); }, { 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; if (!efficiencyFormationReady) return 0;
const members = efficiencyFormation.cardIds.map((id, index) => ({ const members = efficiencyFormation.cardIds.map((id, index) => ({
card: cardById.get(id)!, card: cardById.get(id)!,
@@ -584,7 +588,7 @@
connect: card.details.connect connect: card.details.connect
})); }));
const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, { const boardEffects = calculateBoardEffects(boardSummary, memberBoards, sourceCards, efficiencyFormation.leaderCharacterId, null, {
placements: userData.boardCards, placements: boardCards,
cards: connections cards: connections
}); });
const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null; const leaderOutfit = cardById.get(efficiencyFormation.leaderOutfitCardId)?.details.costume ?? null;
@@ -626,15 +630,6 @@
|| right.cost.greenCubes - left.cost.greenCubes; || 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() { async function analyzeGreenNodeEfficiency() {
if (!efficiencyFormationReady) { if (!efficiencyFormationReady) {
greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.'; greenEfficiencyError = '서로 다른 멤버의 5성 카드 5장을 모두 선택해 주세요.';
@@ -702,11 +697,9 @@
} }
const sortedRemovals = removals.sort(compareRemovalEfficiency); const sortedRemovals = removals.sort(compareRemovalEfficiency);
const removalThreshold = sortedRemovals[0];
greenEfficiencyResult = { greenEfficiencyResult = {
basePower, basePower,
upgrades: upgrades.sort(compareUpgradeEfficiency) upgrades: upgrades.sort(compareUpgradeEfficiency),
.filter((upgrade) => !removalThreshold || upgradeBeatsRemoval(upgrade, removalThreshold)),
removals: sortedRemovals removals: sortedRemovals
}; };
} catch (error) { } catch (error) {
@@ -818,45 +811,37 @@
if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return []; if (!socket || userData.boardCards[member.id]?.[socket.groupId]) return [];
return [{ member, socket }]; return [{ member, socket }];
}); });
if (candidateCards.length === 0) { if (candidateCards.length === 0) {
showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true); showMessage('자동 배치에 사용할 수 있는 미배치 보유 카드가 없습니다.', true);
return; return;
} }
if (emptyGreenSlots.length === 0) { if (emptyGreenSlots.length === 0) {
showMessage('비어 있는 초록색 카드 슬롯이 없습니다. 기존 배치는 변경하지 않았습니다.'); showMessage('비어 있는 중앙 연결 노드가 없습니다. 기존 배치는 변경하지 않았습니다.');
return; return;
} }
const matches = maximumWeightMatching(candidateCards.map((card) =>
const weights = candidateCards.map((card) => emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))); emptyGreenSlots.map(({ member, socket }) => greenSlotScore(member, socket, card))
const matches = maximumWeightMatching(weights); ));
if (matches.length === 0) { if (matches.length === 0) {
showMessage('활성화된 초록 노드에 효과를 주는 배치 조합을 찾지 못했습니다.', true); showMessage('활성화된 초록 노드에 효과를 주는 중앙 배치를 찾지 못했습니다.', true);
return; return;
} }
automaticPlacementSnapshot = cloneBoardCards(userData.boardCards); automaticPlacementSnapshot = cloneBoardCards(userData.boardCards);
const boardCards = cloneBoardCards(userData.boardCards); const boardCards = cloneBoardCards(userData.boardCards);
const placementResult: AutomaticPlacementResult[] = []; automaticPlacementResult = matches.map((match) => {
for (const match of matches) {
const card = candidateCards[match.rowIndex]; const card = candidateCards[match.rowIndex];
const slot = emptyGreenSlots[match.columnIndex]; const { member, socket } = emptyGreenSlots[match.columnIndex];
if (!card || !slot) continue; boardCards[member.id] = { ...(boardCards[member.id] ?? {}), [socket.groupId]: card.id };
boardCards[slot.member.id] = { ...(boardCards[slot.member.id] ?? {}), [slot.socket.groupId]: card.id }; return {
placementResult.push({ memberId: member.id, memberName: member.name, socketId: socket.groupId, socketName: '중앙',
memberId: slot.member.id, cardId: card.id, cardName: cardLabel(card), rarity: card.rarity
memberName: slot.member.name, };
cardId: card.id, }).sort((left, right) =>
cardName: cardLabel(card),
rarity: card.rarity
});
}
automaticPlacementResult = placementResult.sort((left, right) =>
(boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER) (boardMembers.find((member) => member.id === left.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
- (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER) - (boardMembers.find((member) => member.id === right.memberId)?.order ?? Number.MAX_SAFE_INTEGER)
); );
persist({ ...userData, boardCards }); persist({ ...userData, boardCards });
showMessage(`기존 배치를 유지하고 초록색 빈 슬롯 ${matches.length}곳에 보유 카드를 최적 배치했습니다.`); showMessage(`기존 배치를 유지하고 중앙 연결 노드 ${matches.length}곳에 보유 카드를 자동 배치했습니다.`);
} }
function undoAutomaticPlacement() { function undoAutomaticPlacement() {
@@ -865,7 +850,7 @@
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = []; automaticPlacementResult = [];
persist({ ...userData, boardCards }); persist({ ...userData, boardCards });
showMessage('방금 실행한 초록색 카드 자동 배치를 취소했습니다.'); showMessage('방금 실행한 중앙 연결 노드 자동 배치를 취소했습니다.');
} }
function cardLabel(card: CardSummary): string { function cardLabel(card: CardSummary): string {
@@ -875,7 +860,7 @@
function placedCardName(cardId: string | undefined): string { function placedCardName(cardId: string | undefined): string {
if (!cardId) return '연결 노드'; if (!cardId) return '연결 노드';
const card = connectCardById.get(cardId); const card = connectCardById.get(cardId);
return card ? cardLabel(card) : cardId; return card ? `${card.rarity}★ ${cardLabel(card)}` : cardId;
} }
function connectionBoostsForMember( function connectionBoostsForMember(
@@ -917,6 +902,7 @@
function assignBoardCard(cardId: string) { function assignBoardCard(cardId: string) {
if (!selectedSocketGroupId) return; if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }])); const boardCards = Object.fromEntries(Object.entries(userData.boardCards).map(([memberId, placements]) => [memberId, { ...placements }]));
for (const [memberId, placements] of Object.entries(boardCards)) { for (const [memberId, placements] of Object.entries(boardCards)) {
for (const [socketId, placedCardId] of Object.entries(placements)) { for (const [socketId, placedCardId] of Object.entries(placements)) {
@@ -932,6 +918,7 @@
function removeBoardCard() { function removeBoardCard() {
if (!selectedSocketGroupId) return; if (!selectedSocketGroupId) return;
automaticPlacementSnapshot = null; automaticPlacementSnapshot = null;
automaticPlacementResult = [];
const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) }; const placements = { ...(userData.boardCards[selectedMemberId] ?? {}) };
delete placements[selectedSocketGroupId]; delete placements[selectedSocketGroupId];
const boardCards = { ...userData.boardCards }; const boardCards = { ...userData.boardCards };
@@ -1168,7 +1155,7 @@
<div class="automatic-placement"> <div class="automatic-placement">
<div> <div>
<strong>간편 설정</strong> <strong>간편 설정</strong>
<small>모든 멤버의 기본 초록 노드를 활성화하거나, 보유 카드를 중앙 연결 노드의 빈 슬롯에 자동 배치합니다.</small> <small>모든 멤버의 기본 초록 노드를 활성화하거나, 기존 배치를 유지한 채 중앙 연결 노드의 빈 슬롯에 보유 카드를 자동 배치합니다.</small>
</div> </div>
<div class="automatic-placement-actions"> <div class="automatic-placement-actions">
<button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button> <button type="button" on:click={activateCommonGreenNodesForAllMembers}>모든 멤버 초록 3칸 활성화</button>
@@ -1271,7 +1258,7 @@
<div class="efficiency-columns"> <div class="efficiency-columns">
<section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title"> <section class="efficiency-column upgrade" aria-labelledby="upgrade-efficiency-title">
<div class="efficiency-column-heading"> <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> </div>
{#if greenEfficiencyResult.upgrades.length > 0} {#if greenEfficiencyResult.upgrades.length > 0}
<div class="efficiency-list"> <div class="efficiency-list">
@@ -1367,7 +1354,7 @@
class:efficiency-focus={position.groupId === efficiencyFocusGroupId} class:efficiency-focus={position.groupId === efficiencyFocusGroupId}
style={`left: ${point.x}px; top: ${point.y}px; width: ${size}px; height: ${size}px;`} 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}%` : ''}`} 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} aria-pressed={active}
on:click={() => handleBoardNodeClick(position.groupId, node)} 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 boardEffectIcon(node)}<img class="board-node-icon" src={boardEffectIcon(node) ?? ''} alt="" draggable="false" />{/if}
{#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]} {#if node.type === 'connection' && selectedBoardCardPlacements[position.groupId]}
{@const placedCard = connectCardById.get(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} {/if}
</button> </button>
{/if} {/if}
@@ -1564,7 +1556,7 @@
.automatic-placement-result-item span { color: #91a59a; font-size: 12px; } .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 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; } .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 { 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::-webkit-details-marker { display: none; }
.green-efficiency > summary:hover { background: rgb(255 255 255 / 3%); } .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::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.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%; } .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:hover { z-index: 4; transform: translate(-50%, -50%) scale(1.1); }
.board-node.center { cursor: default; } .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%; } .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