feat: complete placement analysis workflow

This commit is contained in:
2026-08-18 14:22:04 +09:00
parent dd3ea911f6
commit 7b713bffe3
452 changed files with 97115 additions and 24 deletions
+1
View File
@@ -12,4 +12,5 @@ data/*
data/holodoridb/*.tmp data/holodoridb/*.tmp
npm-debug.log* npm-debug.log*
.DS_Store .DS_Store
.codex-remote-attachments/
*.tsbuildinfo *.tsbuildinfo
+11
View File
@@ -25,6 +25,17 @@ card-06002-5-uniq-0066-00,수영복,
수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다. 수정 후 `npm run data:sync`를 실행하면 카드 목록에 반영됩니다. 이 파일은 원본 데이터를 다시 수집해도 덮어쓰지 않습니다.
## 브라우저 데이터
사용자 데이터는 서버로 전송하지 않고 현재 브라우저의 `localStorage`에 자동 저장합니다. 별도의 `사용자 데이터` 페이지에서 JSON 백업을 내보내거나 가져오고, 전체 데이터를 초기화할 수 있습니다.
- 현재 카드 목록의 출력 조건을 자동으로 복원합니다.
- `백업 내보내기`로 버전이 포함된 JSON 파일을 내려받을 수 있습니다.
- `백업 가져오기`로 다른 브라우저에서 데이터를 복원할 수 있습니다.
- `초기화`는 현재 브라우저에 저장된 수집 내역, 저장 덱, 설정을 모두 기본값으로 되돌립니다.
향후 보유 카드와 저장 덱 기능도 `src/lib/models/user-data.ts`의 동일한 버전 데이터에 저장합니다.
홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다. 홀로드림스 덱 최적화와 스킬 로스 분석을 통합할 웹사이트의 개발 기반입니다. 현재 단계에는 게임 기능을 포함하지 않습니다.
## 구성 ## 구성
+1
View File
@@ -5,6 +5,7 @@ This directory is generated by `npm run data:sync`.
- `manifest.json`: source commits, master-data version, checksums, and validation counts - `manifest.json`: source commits, master-data version, checksums, and validation counts
- `card-master.json`: lossless copies of the card-related master tables and Korean/English/Japanese localization tables - `card-master.json`: lossless copies of the card-related master tables and Korean/English/Japanese localization tables
- `cards.json`: the same data joined into one record per card for application use - `cards.json`: the same data joined into one record per card for application use
- `src/lib/data/boards-summary.json`: member board layouts with node coordinates, levels, effects, and costs for application use
The collection script also creates `src/lib/data/cards-summary.json`, a small Korean-only projection used by the card list page. The collection script also creates `src/lib/data/cards-summary.json`, a small Korean-only projection used by the card list page.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-08-17T10:58:53.307Z", "generatedAt": "2026-08-18T01:01:34.166Z",
"sources": { "sources": {
"eng": { "eng": {
"repository": "https://github.com/HolodoriDB/holodori-db-eng-diff", "repository": "https://github.com/HolodoriDB/holodori-db-eng-diff",
+31 -1
View File
@@ -1,6 +1,6 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"generatedAt": "2026-08-17T10:58:53.307Z", "generatedAt": "2026-08-18T01:01:34.166Z",
"source": "HolodoriDB community master-data diff repositories", "source": "HolodoriDB community master-data diff repositories",
"sourceLicense": null, "sourceLicense": null,
"sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.", "sourceLicenseNote": "No LICENSE file was present in the three source repositories at collection time.",
@@ -154,6 +154,16 @@
"count": 10, "count": 10,
"sha256": "2f359d8c43d583e060b4fa24702690d683ae51602ead49fd9abc743510db1513" "sha256": "2f359d8c43d583e060b4fa24702690d683ae51602ead49fd9abc743510db1513"
}, },
"Poster": {
"filename": "Poster.json",
"count": 28,
"sha256": "a75e14e4a75091c41d6d0790d30a96d743eedad50806fc7f85697ca86a56b664"
},
"PosterCollectEffect": {
"filename": "PosterCollectEffect.json",
"count": 50,
"sha256": "d2dbe4f2fbc49a4243e1b77bf2eb4ed4921777849bc13842a54077dd57d482e1"
},
"SkillTreeConnectEffect": { "SkillTreeConnectEffect": {
"filename": "SkillTreeConnectEffect.json", "filename": "SkillTreeConnectEffect.json",
"count": 40, "count": 40,
@@ -183,6 +193,26 @@
"filename": "SkillTreeEffectValueLimit.json", "filename": "SkillTreeEffectValueLimit.json",
"count": 2, "count": 2,
"sha256": "b89c4b3b8e37658a63d9487562bed9aaaf194274b72f9161916c409c45a9380f" "sha256": "b89c4b3b8e37658a63d9487562bed9aaaf194274b72f9161916c409c45a9380f"
},
"SkillTreeNode": {
"filename": "SkillTreeNode.json",
"count": 323,
"sha256": "30d2479d6516c96c214caec98380454963733b1ef5f3ab75a171f4f461924605"
},
"SkillTreeNodeAutoSelection": {
"filename": "SkillTreeNodeAutoSelection.json",
"count": 15,
"sha256": "a44de989a6718584e6f151a50f17fefee84e1669987c96d518f810670a3db184"
},
"SkillTreeNodePosition": {
"filename": "SkillTreeNodePosition.json",
"count": 608,
"sha256": "973e1efd5a35a291ddbc0c963b15068ba6df3e504ed18d4cf9246b831c94dffa"
},
"SkillTreePoint": {
"filename": "SkillTreePoint.json",
"count": 54,
"sha256": "7e3236507fd0a4d72ecce6f51f54e77ada8c1bd3b679dff29fca89cae945163a"
} }
}, },
"localizations": { "localizations": {
+3
View File
@@ -11,8 +11,11 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:engine": "node --test src/lib/engine/*.test.ts",
"start": "node build", "start": "node build",
"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:collect-charts": "node scripts/collect-exact-charts.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"
+200
View File
@@ -0,0 +1,200 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296;
const 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 singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
function singerType(value) {
return typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 요청 실패: ${response.status}`);
return response.text();
}
function requiredMatch(text, pattern, label) {
const match = text.match(pattern);
if (!match) throw new Error(`${label}을 찾을 수 없습니다.`);
return match[1];
}
function decodeTemplateLiteral(value) {
return value.replaceAll('\\\\', '\\').replaceAll('\\`', '`').replaceAll('\\${', '${');
}
function decodeSingleQuotedLiteral(value) {
let decoded = '';
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== '\\') {
decoded += character;
continue;
}
const escaped = value[index + 1];
index += 1;
if (escaped === '\\' || escaped === "'") decoded += escaped;
else if (escaped === 'n') decoded += '\n';
else if (escaped === 'r') decoded += '\r';
else if (escaped === 't') decoded += '\t';
else if (escaped === 'b') decoded += '\b';
else if (escaped === 'f') decoded += '\f';
else if (escaped === 'u') {
const code = value.slice(index + 1, index + 5);
decoded += String.fromCodePoint(Number.parseInt(code, 16));
index += 4;
} else decoded += escaped;
}
return decoded;
}
function extractJsonParseArgument(moduleText) {
const prefix = "JSON.parse('";
const start = moduleText.indexOf(prefix);
if (start < 0) throw new Error('채보 모듈에서 JSON 데이터를 찾을 수 없습니다.');
const valueStart = start + prefix.length;
for (let index = valueStart; index < moduleText.length; index += 1) {
if (moduleText[index] !== "'") continue;
let slashCount = 0;
for (let cursor = index - 1; cursor >= valueStart && moduleText[cursor] === '\\'; cursor -= 1) {
slashCount += 1;
}
if (slashCount % 2 === 0) {
return decodeSingleQuotedLiteral(moduleText.slice(valueStart, index));
}
}
throw new Error('채보 JSON 문자열의 끝을 찾을 수 없습니다.');
}
function flattenEvents(chartData) {
const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]);
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
]
.map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true
}))
.filter((note) => Number.isFinite(note.timeSeconds))
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
}
function scoreRatioFromNotes(notes) {
const countedMidTimes = new Set();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
} else {
weight += note.isFlick ? 1.05 : 1;
}
}
return weight * chartScoreWeightCoefficient;
}
const home = await fetchText(sourceOrigin);
const mainAsset = requiredMatch(home, /<script type="module" crossorigin src="([^"]+)"/, '메인 스크립트');
const mainModule = await fetchText(new URL(mainAsset, sourceOrigin));
const simulatorAsset = requiredMatch(mainModule, /\.\/UnitSimulatorPage-([^"']+\.js)/, '점수 시뮬레이터 모듈');
const simulatorModule = await fetchText(new URL(`/assets/UnitSimulatorPage-${simulatorAsset}`, sourceOrigin));
const catalogPrefix = 'us=JSON.parse(`';
const catalogStart = simulatorModule.indexOf(catalogPrefix);
const catalogEnd = simulatorModule.indexOf('`)', catalogStart + catalogPrefix.length);
if (catalogStart < 0 || catalogEnd < 0) throw new Error('채보 카탈로그를 찾을 수 없습니다.');
const sourceCatalog = JSON.parse(
decodeTemplateLiteral(simulatorModule.slice(catalogStart + catalogPrefix.length, catalogEnd))
);
const catalogByKey = new Map(sourceCatalog.map((chart) => [chart.key, chart]));
const imports = [];
const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g;
for (const match of simulatorModule.matchAll(importPattern)) {
const [, chartFile, assetFile] = match;
const [songId, difficultyAndLevel] = chartFile.split('.', 2);
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase();
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile });
}
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
await mkdir(outputDirectory, { recursive: true });
let completed = 0;
const normalizedCharts = [];
const queue = [...imports];
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) return;
const moduleText = await fetchText(new URL(`/assets/${item.assetFile}`, sourceOrigin));
const chartData = JSON.parse(extractJsonParseArgument(moduleText));
const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`);
const music = musicById.get(item.songId);
const singerOverride = singerMetadataOverrides[item.songId];
const notes = flattenEvents(chartData);
const output = {
schemaVersion: 1,
songId: item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
scoreRatio: scoreRatioFromNotes(notes),
scoreCalibrationSource: 'measured-formula-v1',
notes,
specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot)
.map((event) => event.time_seconds),
singerType: singerOverride?.singerType ?? singerType(music?.musicSingerType),
singerCharacterIds: singerOverride?.singerCharacterIds ?? music?.characterIds ?? [],
source: {
page: sourceOrigin,
transcriptionStatus: chartData.transcription_status,
susSha256: chartData.source?.sha256 ?? null
}
};
const fileName = `${item.songId}.${item.difficulty.toLowerCase()}.json`;
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
normalizedCharts.push({
songId: item.songId,
title: sourceEntry?.title ?? item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio,
totalNotes: notes.length,
singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds,
path: `/data/charts/${fileName}`
});
completed += 1;
if (completed % 20 === 0 || completed === imports.length) {
console.log(`${completed}/${imports.length} 채보 수집 완료`);
}
}
}
await Promise.all(Array.from({ length: 6 }, () => worker()));
normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
await writeFile(catalogPath, `${JSON.stringify({
schemaVersion: 1,
source: sourceOrigin,
charts: normalizedCharts
}, null, 2)}\n`, 'utf8');
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);
+124 -2
View File
@@ -10,6 +10,7 @@ const rawRoot = path.join(projectRoot, 'data', 'raw', 'holodoridb');
const outputRoot = path.join(projectRoot, 'data', 'holodoridb'); const outputRoot = path.join(projectRoot, 'data', 'holodoridb');
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data'); const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv'); const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
const offline = process.argv.includes('--offline');
function parseCsvLine(line) { function parseCsvLine(line) {
const values = []; const values = [];
@@ -97,12 +98,18 @@ const masterTableNames = [
'LiveSkillTrigger', 'LiveSkillTrigger',
'LiveSkillEffectTarget', 'LiveSkillEffectTarget',
'LiveSkillEffectFilterCategory', 'LiveSkillEffectFilterCategory',
'Poster',
'PosterCollectEffect',
'SkillTreeConnectEffect', 'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent', 'SkillTreeConnectEffectExtent',
'SkillTreeEffect', 'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger', 'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget', 'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit' 'SkillTreeEffectValueLimit',
'SkillTreeNode',
'SkillTreeNodeAutoSelection',
'SkillTreeNodePosition',
'SkillTreePoint'
]; ];
const localizationTableNames = [ const localizationTableNames = [
@@ -148,8 +155,12 @@ async function prepareSource(locale, source) {
try { try {
gitAt(repositoryPath, ['rev-parse', '--git-dir']); gitAt(repositoryPath, ['rev-parse', '--git-dir']);
if (offline) {
process.stdout.write(`Using cached ${locale} source...\n`);
} else {
process.stdout.write(`Updating ${locale} source...\n`); process.stdout.write(`Updating ${locale} source...\n`);
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']); gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
}
} catch { } catch {
process.stdout.write(`Cloning ${locale} source...\n`); process.stdout.write(`Cloning ${locale} source...\n`);
await mkdir(path.dirname(repositoryPath), { recursive: true }); await mkdir(path.dirname(repositoryPath), { recursive: true });
@@ -331,6 +342,9 @@ const triggersByGroup = groupBy(tables.LiveSkillTrigger, 'groupId');
const effectTargetsById = indexBy(tables.LiveSkillEffectTarget); const effectTargetsById = indexBy(tables.LiveSkillEffectTarget);
const connectEffectsById = groupBy(tables.SkillTreeConnectEffect, 'id'); const connectEffectsById = groupBy(tables.SkillTreeConnectEffect, 'id');
const connectExtentsByGroup = groupBy(tables.SkillTreeConnectEffectExtent, 'groupId'); const connectExtentsByGroup = groupBy(tables.SkillTreeConnectEffectExtent, 'groupId');
const skillTreeEffectsById = indexBy(tables.SkillTreeEffect);
const skillTreeNodesByGroup = groupBy(tables.SkillTreeNode, 'groupId');
const skillTreePositionsByGroup = groupBy(tables.SkillTreeNodePosition, 'groupId');
const costumesById = indexBy(tables.Costume); const costumesById = indexBy(tables.Costume);
const sdCostumesById = indexBy(tables.SdCostume); const sdCostumesById = indexBy(tables.SdCostume);
const leaderSkillsById = indexBy(tables.LiveLeaderSkill); const leaderSkillsById = indexBy(tables.LiveLeaderSkill);
@@ -588,9 +602,17 @@ const cardSummary = {
const cardOverride = cardOverrides.get(card.id); const cardOverride = cardOverrides.get(card.id);
const highestStat = Object.entries(statLabels) const highestStat = Object.entries(statLabels)
.sort(([left], [right]) => Number(card[right]) - Number(card[left]))[0][1]; .sort(([left], [right]) => Number(card[right]) - Number(card[left]))[0][1];
const maxLevel = cardRecord.progression.levels.at(-1);
const parameterBaseValue = Number(maxLevel?.parameterBaseValue ?? 0);
const statsAtMaxLevel = {
performance: Math.round(parameterBaseValue * Number(card.performancePermilMultiply ?? 0) / 1000),
technique: Math.round(parameterBaseValue * Number(card.techniquePermilMultiply ?? 0) / 1000),
sense: Math.round(parameterBaseValue * Number(card.sensePermilMultiply ?? 0) / 1000)
};
return { return {
id: card.id, id: card.id,
characterId: card.characterId,
memberOrder: character.order, memberOrder: character.order,
rarity: Number(compactEnum(card.rarity)), rarity: Number(compactEnum(card.rarity)),
type: typeLabels[compactEnum(card.attributeType)], type: typeLabels[compactEnum(card.attributeType)],
@@ -598,11 +620,24 @@ const cardSummary = {
versionName: cardOverride?.versionName || '', versionName: cardOverride?.versionName || '',
generation: textLookups.kor.get(character.regularCharacterGroupingNameLangId) || '', generation: textLookups.kor.get(character.regularCharacterGroupingNameLangId) || '',
highestStat, highestStat,
power: {
maxLevel: Number(maxLevel?.level ?? 0),
statsAtMaxLevel
},
details: { details: {
title: textLookups.kor.get(card.nameLangId), title: textLookups.kor.get(card.nameLangId),
passive: summarizeSkillLevels(cardRecord.skills.passive.levels), passive: summarizeSkillLevels(cardRecord.skills.passive.levels),
active: summarizeConditionalSkillLevels(cardRecord.skills.active.levels), active: summarizeConditionalSkillLevels(cardRecord.skills.active.levels),
special: summarizeSpecialSkillLevels(cardRecord.skills.special.levels), special: summarizeSpecialSkillLevels(cardRecord.skills.special.levels),
connect: cardRecord.skillTreeConnectEffects.map((effect) => ({
level: Number(effect.level ?? 1),
description: effect.translations?.description?.kor ?? '',
effectPermilUp: Number(effect.effectPermilUp ?? 0),
extent: effect.extent.map((position) => ({
x: Number(position.positionX ?? 0),
y: Number(position.positionY ?? 0)
}))
})),
costume: cardRecord.rewards.costume costume: cardRecord.rewards.costume
? { ? {
name: cardRecord.rewards.costume.translations?.name?.kor ?? '', name: cardRecord.rewards.costume.translations?.name?.kor ?? '',
@@ -622,16 +657,103 @@ const cardSummary = {
.map(({ memberOrder: _memberOrder, ...card }) => card) .map(({ memberOrder: _memberOrder, ...card }) => card)
}; };
function skillTreeNodeForCharacter(characterId, groupId) {
const candidates = skillTreeNodesByGroup.get(groupId) ?? [];
return candidates.find((node) => node.characterIds?.includes(characterId))
?? candidates.find((node) => !node.characterIds?.length)
?? null;
}
function stripEnumPrefix(value, prefix) {
return typeof value === 'string' ? value.replace(prefix, '').toLowerCase() : value;
}
function summarizeBoardNode(node) {
const effect = skillTreeEffectsById.get(node.skillTreeEffectId);
return {
number: node.number,
characterIds: node.characterIds ?? [],
grade: node.grade,
type: stripEnumPrefix(node.type, 'SkillTreeNodeType_SKILL_TREE_NODE_TYPE_'),
skillTreePointCost: node.consumptionSkillTreePointQuantity ?? 0,
consumptions: node.consumptions ?? [],
viewConditionGroupId: node.viewConditionGroupId ?? null,
effect: effect ? {
id: effect.id,
type: stripEnumPrefix(effect.effectType, 'SkillTreeEffectType_SKILL_TREE_EFFECT_TYPE_'),
value: effect.value == null ? null : Number(effect.value),
targetId: effect.skillTreeEffectTargetId ?? null,
trigger: stripEnumPrefix(effect.characterTriggerType, 'SkillTreeEffectCharacterTriggerType_SKILL_TREE_EFFECT_CHARACTER_TRIGGER_TYPE_'),
description: textLookups.kor.get(effect.descriptionLangId) ?? ''
} : null
};
}
const boardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
members: tables.Character
.map(unwrap)
.filter((character) => character.isPlayable && character.skillTreeNodePositionGroupId)
.map((character) => ({
id: character.id,
name: textLookups.kor.get(character.nameLangId) ?? character.nameEng,
order: character.order,
positionGroupId: character.skillTreeNodePositionGroupId
}))
.sort((left, right) => left.order - right.order),
models: Object.fromEntries(
[...new Set(tables.Character.map(unwrap).map((character) => character.skillTreeNodePositionGroupId).filter(Boolean))]
.sort()
.map((groupId) => [groupId, (skillTreePositionsByGroup.get(groupId) ?? [])
.map((position) => ({
groupId: position.skillTreeNodeGroupId,
x: position.positionX ?? 0,
y: position.positionY ?? 0
}))
.sort((left, right) => left.y - right.y || left.x - right.x || left.groupId.localeCompare(right.groupId))])
),
nodeGroups: Object.fromEntries(
[...skillTreeNodesByGroup.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([groupId, nodes]) => [groupId, sortByNumber(nodes, 'number').map(summarizeBoardNode)])
)
};
const memorySummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
maxCount: tables.Poster.length,
effects: tables.PosterCollectEffect
.map(unwrap)
.map((effect) => ({
count: Number(effect.threshold),
permil: Number(effect.liveDeckAllParameterUpPermilUp)
}))
.sort((left, right) => left.count - right.count)
};
assert(boardSummary.members.length > 0, 'no member boards were collected');
assert(Object.keys(boardSummary.models).length > 0, 'no board position models were collected');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId]?.length === 152), 'one or more members have incomplete board positions');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId].every((position) => skillTreeNodeForCharacter(member.id, position.groupId))), 'one or more members have unresolved board nodes');
assert(memorySummary.maxCount > 0, 'no memory posters were collected');
assert(memorySummary.effects.length > 0, 'no memory collection effects were collected');
await mkdir(outputRoot, { recursive: true }); await mkdir(outputRoot, { recursive: true });
await mkdir(appDataRoot, { recursive: true }); await mkdir(appDataRoot, { recursive: true });
await Promise.all([ await Promise.all([
writeFile(path.join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`), writeFile(path.join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`),
writeFile(path.join(outputRoot, 'card-master.json'), `${JSON.stringify(masterBundle, null, 2)}\n`), writeFile(path.join(outputRoot, 'card-master.json'), `${JSON.stringify(masterBundle, null, 2)}\n`),
writeFile(path.join(outputRoot, 'cards.json'), `${JSON.stringify(cardBundle, null, 2)}\n`), writeFile(path.join(outputRoot, 'cards.json'), `${JSON.stringify(cardBundle, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'cards-summary.json'), `${JSON.stringify(cardSummary, null, 2)}\n`) writeFile(path.join(appDataRoot, 'cards-summary.json'), `${JSON.stringify(cardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'boards-summary.json'), `${JSON.stringify(boardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'memory-summary.json'), `${JSON.stringify(memorySummary, null, 2)}\n`)
]); ]);
process.stdout.write(`Collected ${cards.length} cards.\n`); process.stdout.write(`Collected ${cards.length} cards.\n`);
process.stdout.write(`Rarities: ${JSON.stringify(rarityCounts)}\n`); process.stdout.write(`Rarities: ${JSON.stringify(rarityCounts)}\n`);
process.stdout.write(`Localized names: ${JSON.stringify(cardNameCoverage)}\n`); process.stdout.write(`Localized names: ${JSON.stringify(cardNameCoverage)}\n`);
process.stdout.write(`Member boards: ${boardSummary.members.length}\n`);
process.stdout.write(`Memory posters: ${memorySummary.maxCount}\n`);
process.stdout.write(`Output: ${outputRoot}\n`); process.stdout.write(`Output: ${outputRoot}\n`);
+28
View File
@@ -0,0 +1,28 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(?=[A-Za-z]:)/, '')), '..');
const catalogPath = path.join(projectRoot, 'src/lib/data/exact-chart-catalog.json');
const musicPath = path.join(projectRoot, 'data/raw/holodoridb/kor/Music.json');
const musicRows = JSON.parse(await readFile(musicPath, 'utf8'));
const musicById = new Map(musicRows.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
const compactSingerType = (value) => typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
for (const entry of catalog.charts) {
const music = musicById.get(entry.songId);
const singerOverride = singerMetadataOverrides[entry.songId];
entry.singerType = singerOverride?.singerType ?? compactSingerType(music?.musicSingerType);
entry.singerCharacterIds = singerOverride?.singerCharacterIds ?? music?.characterIds ?? [];
const chartPath = path.join(projectRoot, 'static', entry.path.replace(/^\//, '').replace(/^data[\\/]charts[\\/]/, 'data/charts/'));
const chart = JSON.parse(await readFile(chartPath, 'utf8'));
chart.singerType = entry.singerType;
chart.singerCharacterIds = entry.singerCharacterIds;
await writeFile(chartPath, JSON.stringify(chart), 'utf8');
}
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf8');
console.log(`${catalog.charts.length}개 채보에 가창자 정보를 추가했습니다.`);
+71
View File
@@ -0,0 +1,71 @@
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
const sourcePath = process.argv[2];
if (!sourcePath) {
throw new Error('사용법: npm run data:import-songs -- <기존 계산기 HTML 경로>');
}
const source = await readFile(sourcePath, 'utf8');
function parseConstant(name) {
const prefix = `const ${name} = `;
const start = source.indexOf(prefix);
if (start < 0) throw new Error(`${name} 데이터를 찾을 수 없습니다.`);
const valueStart = start + prefix.length;
const valueEnd = source.indexOf(';\n', valueStart);
if (valueEnd < 0) throw new Error(`${name} 데이터의 끝을 찾을 수 없습니다.`);
return JSON.parse(source.slice(valueStart, valueEnd));
}
const songs = parseConstant('SONGS');
const specials = parseConstant('SONG_SPECIAL');
const noteHistograms = parseConstant('SONG_NOTE_HIST');
const difficultyNames = ['hard', 'expert'];
function parseNotes(value) {
if (typeof value !== 'string' || value.length === 0) return [];
return value.split(',').map((noteCount) => Number(noteCount));
}
function specialStarts(songId, difficultyIndex) {
const starts = Array(5).fill(null);
for (const entry of specials[songId]?.[difficultyIndex] ?? []) {
const [slot, startSeconds] = entry;
if (Number.isInteger(slot) && slot >= 0 && slot < starts.length && Number.isFinite(startSeconds)) {
starts[slot] = startSeconds;
}
}
return starts;
}
const normalizedSongs = songs.flatMap(([id, title, durationSeconds]) => {
const histograms = noteHistograms[id];
if (!histograms) return [];
const difficulties = Object.fromEntries(
difficultyNames.flatMap((difficulty, difficultyIndex) => {
const notesPerSecond = parseNotes(histograms[difficultyIndex]);
if (notesPerSecond.length === 0) return [];
return [[difficulty, {
notesPerSecond,
specialStartSeconds: specialStarts(id, difficultyIndex),
totalNotes: notesPerSecond.reduce((sum, noteCount) => sum + noteCount, 0)
}]];
})
);
if (Object.keys(difficulties).length === 0) return [];
return [{ id, title, durationSeconds, difficulties }];
});
const output = {
schemaVersion: 1,
source: path.basename(sourcePath),
songs: normalizedSongs
};
const outputPath = path.resolve('src/lib/data/song-charts.json');
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
console.log(`${normalizedSongs.length}곡의 채보 밀도 데이터를 ${outputPath}에 저장했습니다.`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
{
"schemaVersion": 1,
"masterDataVersion": "71e11fbd082eec83d10cff35da7179cbaf319097f0021aa5747fe5a5392b549c",
"maxCount": 28,
"effects": [
{
"count": 1,
"permil": 2
},
{
"count": 2,
"permil": 4
},
{
"count": 3,
"permil": 6
},
{
"count": 4,
"permil": 8
},
{
"count": 5,
"permil": 10
},
{
"count": 6,
"permil": 12
},
{
"count": 7,
"permil": 14
},
{
"count": 8,
"permil": 16
},
{
"count": 9,
"permil": 18
},
{
"count": 10,
"permil": 20
},
{
"count": 11,
"permil": 22
},
{
"count": 12,
"permil": 24
},
{
"count": 13,
"permil": 26
},
{
"count": 14,
"permil": 28
},
{
"count": 15,
"permil": 30
},
{
"count": 16,
"permil": 32
},
{
"count": 17,
"permil": 34
},
{
"count": 18,
"permil": 36
},
{
"count": 19,
"permil": 38
},
{
"count": 20,
"permil": 40
},
{
"count": 21,
"permil": 42
},
{
"count": 22,
"permil": 44
},
{
"count": 23,
"permil": 46
},
{
"count": 24,
"permil": 48
},
{
"count": 25,
"permil": 50
},
{
"count": 26,
"permil": 52
},
{
"count": 27,
"permil": 54
},
{
"count": 28,
"permil": 56
},
{
"count": 29,
"permil": 58
},
{
"count": 30,
"permil": 60
},
{
"count": 31,
"permil": 61
},
{
"count": 32,
"permil": 62
},
{
"count": 33,
"permil": 63
},
{
"count": 34,
"permil": 64
},
{
"count": 35,
"permil": 65
},
{
"count": 36,
"permil": 66
},
{
"count": 37,
"permil": 67
},
{
"count": 38,
"permil": 68
},
{
"count": 39,
"permil": 69
},
{
"count": 40,
"permil": 70
},
{
"count": 41,
"permil": 71
},
{
"count": 42,
"permil": 72
},
{
"count": 43,
"permil": 73
},
{
"count": 44,
"permil": 74
},
{
"count": 45,
"permil": 75
},
{
"count": 46,
"permil": 76
},
{
"count": 47,
"permil": 77
},
{
"count": 48,
"permil": 78
},
{
"count": 49,
"permil": 79
},
{
"count": 50,
"permil": 80
}
]
}
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,7 +0,0 @@
# Calculation engine
기존 스킬 로스 계산기의 계산 로직을 UI와 분리해 둘 위치입니다.
- Svelte 컴포넌트나 SvelteKit 서버 모듈을 import하지 않습니다.
- 브라우저, Web Worker, Node.js, 단위 테스트에서 공유할 수 있는 순수 TypeScript로 작성합니다.
- 공용 데이터 타입은 `src/lib/models`에서 가져옵니다.
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateBoardEffects, type BoardSummarySource } from './board-effects.ts';
const summary: BoardSummarySource = {
members: [{ id: 'a', positionGroupId: 'model-a' }],
models: { 'model-a': [{ groupId: 'S-1', x: 0, y: 0 }, { groupId: 'B-1', x: 1, y: 0 }] },
nodeGroups: {
'B-1': [{ characterIds: [], effect: { type: 'performance_up', value: 100, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'B-2': [{ characterIds: [], effect: { type: 'live_active_skill_cool_time_shorten_permil_up', value: 40, targetId: 'skill_tree_effect_target-skill_tree_character', trigger: 'always' } }],
'R-1': [{ characterIds: [], effect: { type: 'all_parameter_up_permil_up', value: 30, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader' } }],
'R-2': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_active_skill_effect_up_permil_up-100-skill_tree_effect_target-all-skill_tree_effect_passive_trigger-music_skill_tree_character-set_live_leader',
type: 'live_active_skill_effect_up_permil_up', value: 100, targetId: 'skill_tree_effect_target-all', trigger: 'set_live_leader'
} }],
'G-1': [{ characterIds: [], effect: { type: 'all_parameter_up', value: 5, targetId: 'skill_tree_effect_target-all', trigger: 'always' } }],
'Y-1': [{ characterIds: [], effect: {
id: 'skill_tree_effect-live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type-5-skill_tree_effect_passive_trigger-music_skill_tree_character_and_music_singer_type-solo-always-solo',
type: 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type', value: 5, trigger: 'always'
} }]
}
};
const cards = [
{ id: 'card-a', characterId: 'a', type: '큐트', generation: '0기생' },
{ id: 'card-b', characterId: 'b', type: '퓨어', generation: '1기생' }
];
test('편성 멤버의 파란 보드, 리더의 빨간 보드와 공통 초록 보드를 합산한다', () => {
const result = calculateBoardEffects(summary, {
a: ['B-1', 'B-2', 'Y-1'],
leader: ['R-1', 'R-2'],
account: ['G-1']
}, cards, 'leader', { singerType: 'solo', singerCharacterIds: ['a'] });
assert.equal(result.flatByCardId['card-a'].performance, 105);
assert.equal(result.flatByCardId['card-b'].performance, 5);
assert.equal(result.rateByCardId['card-a'].sense, 3);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-a'], [0, 4, 8, 12]);
assert.deepEqual(result.allowedCooldownReductionsByCardId['card-b'], [0, 4, 8, 12]);
assert.equal(result.directSongScoreBonusPercent, 0.5);
assert.equal(result.scoreSupportPercent, 0);
});
test('리더가 악곡 가창자이면 조건부 빨간 스코어 서포트를 적용한다', () => {
const result = calculateBoardEffects(summary, { leader: ['R-2'] }, cards, 'leader', {
singerType: 'group',
singerCharacterIds: ['leader']
});
assert.equal(result.scoreSupportPercent, 10);
});
test('연결 카드가 범위 안의 활성 노드 효과를 증폭한다', () => {
const result = calculateBoardEffects(summary, { a: ['B-1'] }, cards, 'a', null, {
placements: { a: { 'S-1': 'connect-card' } },
cards: [{
id: 'connect-card',
awakening: 0,
connect: [{ level: 1, effectPermilUp: 2000, extent: [{ x: 1, y: 0 }] }]
}]
});
assert.equal(result.flatByCardId['card-a'].performance, 300);
});
+189
View File
@@ -0,0 +1,189 @@
import { skillTargetBelongsTo } from './skill-condition.ts';
import type { CooldownReduction } from './types.ts';
import type { UnitStat } from './unit-power.ts';
type BoardEffect = { id?: string; type: string; value: number | null; targetId?: string | null; trigger?: string | null };
type BoardNode = { characterIds: string[]; effect: BoardEffect | null };
type BoardPosition = { groupId: string; x: number; y: number };
type BoardMember = { id: string; positionGroupId: string };
export type BoardSummarySource = {
nodeGroups: Record<string, readonly BoardNode[]>;
models?: Record<string, readonly BoardPosition[]>;
members?: readonly BoardMember[];
};
export type BoardEffectCard = { id: string; characterId: string; type: string; generation: string };
export type BoardEffectSong = { singerType?: string | null; singerCharacterIds?: readonly string[] };
export type BoardConnectionCard = {
id: string;
awakening: number;
connect: readonly {
level: number;
effectPermilUp: number;
extent: readonly { x: number; y: number }[];
}[];
};
export type BoardConnections = {
placements: Readonly<Record<string, Readonly<Record<string, string>>>>;
cards: readonly BoardConnectionCard[];
};
export type BoardEffectsResult = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
activationRateUpByCardId: Record<string, number>;
allowedCooldownReductionsByCardId: Record<string, readonly CooldownReduction[]>;
scoreSupportPercent: number;
directSongScoreBonusPercent: number;
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
const emptyStats = (): Record<UnitStat, number> => ({ performance: 0, technique: 0, sense: 0 });
const groupingLabels: Record<string, string> = {
'grp-gen_0': '0기생', 'grp-gen_1': '1기생', 'grp-gen_2': '2기생', 'grp-gen_3': '3기생',
'grp-gen_4': '4기생', 'grp-gen_5': '5기생', 'grp-gamers': 'GAMERS', 'grp-holox': 'holoX',
'grp-myth': 'Myth', 'grp-promise': 'Promise', 'grp-advent': 'Advent', 'grp-regloss': 'ReGLOSS',
'grp-indonesia-gen_1': 'ID 1기생', 'grp-indonesia-gen_2': 'ID 2기생', 'grp-indonesia-gen_3': 'ID 3기생'
};
function nodeForMember(summary: BoardSummarySource, groupId: string, memberId: string): BoardNode | null {
const nodes = summary.nodeGroups[groupId] ?? [];
return nodes.find((node) => node.characterIds.includes(memberId))
?? nodes.find((node) => node.characterIds.length === 0)
?? null;
}
function targets(effect: BoardEffect, ownerId: string, cards: readonly BoardEffectCard[]): BoardEffectCard[] {
if (effect.targetId === 'skill_tree_effect_target-skill_tree_character') return cards.filter((card) => card.characterId === ownerId);
const grouping = effect.targetId?.replace('skill_tree_effect_target-character_grouping-', '');
if (grouping && groupingLabels[grouping]) return cards.filter((card) => skillTargetBelongsTo(card, groupingLabels[grouping]));
return [...cards];
}
function affectedStats(type: string): readonly UnitStat[] {
if (type.startsWith('all_parameter_up')) return stats;
if (type.startsWith('performance_up')) return ['performance'];
if (type.startsWith('technique_up')) return ['technique'];
if (type.startsWith('sense_up')) return ['sense'];
return [];
}
function songBonusApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
if (!effect.id) return false;
const expectedType = effect.id.match(/music_singer_type-(all|group|solo)/)?.[1];
if (expectedType === 'all') return true;
if (!song || expectedType !== song.singerType) return false;
const singers = song.singerCharacterIds ?? [];
if (effect.id.endsWith('-fuwamoko')) {
return singers.length === 2 && singers.includes('chr-04016') && singers.includes('chr-04017');
}
return singers.includes(ownerId);
}
function singerConditionApplies(effect: BoardEffect, ownerId: string, song?: BoardEffectSong | null): boolean {
if (!effect.id?.includes('passive_trigger-music_skill_tree_character')) return true;
return Boolean(song?.singerCharacterIds?.includes(ownerId));
}
function connectionMultipliers(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
connections?: BoardConnections | null
): Record<string, Record<string, number>> {
if (!connections || !summary.models || !summary.members) return {};
const cards = new Map(connections.cards.map((card) => [card.id, card]));
const usedCards = new Set<string>();
const result: Record<string, Record<string, number>> = {};
for (const member of summary.members) {
const positions: readonly BoardPosition[] = summary.models[member.positionGroupId] ?? [];
const positionById = new Map<string, BoardPosition>(positions.map((position) => [position.groupId, position]));
const groupIdByCoordinate = new Map<string, string>(positions.map((position) => [`${position.x},${position.y}`, position.groupId]));
const active = new Set(memberBoards[member.id] ?? []);
const center = positions.find((position) => position.x === 0 && position.y === 0)?.groupId;
if (center) active.add(center);
for (const [socketId, cardId] of Object.entries(connections.placements[member.id] ?? {})) {
if (usedCards.has(cardId) || !active.has(socketId)) continue;
const socket = positionById.get(socketId);
const card = cards.get(cardId);
if (!socket || !card) continue;
const level = card.awakening >= 5 ? 2 : 1;
const effect = card.connect.find((entry) => entry.level === level) ?? card.connect.at(-1);
if (!effect) continue;
usedCards.add(cardId);
const memberResult = result[member.id] ??= {};
for (const offset of effect.extent) {
const groupId = groupIdByCoordinate.get(`${socket.x + offset.x},${socket.y + offset.y}`);
if (groupId) memberResult[groupId] = 1 + effect.effectPermilUp / 1000;
}
}
}
return result;
}
export function calculateBoardEffects(
summary: BoardSummarySource,
memberBoards: Readonly<Record<string, readonly string[]>>,
cards: readonly BoardEffectCard[],
leaderCharacterId: string,
song?: BoardEffectSong | null,
connections?: BoardConnections | null
): BoardEffectsResult {
const flatByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const rateByCardId = Object.fromEntries(cards.map((card) => [card.id, emptyStats()]));
const activationRateUpByCardId = Object.fromEntries(cards.map((card) => [card.id, 0]));
const unitCharacterIds = new Set(cards.map((card) => card.characterId));
let scoreSupportPercent = 0;
let directSongScoreBonusPercent = 0;
const multipliers = connectionMultipliers(summary, memberBoards, connections);
for (const [ownerId, activeGroupIds] of Object.entries(memberBoards)) {
for (const groupId of activeGroupIds) {
const area = groupId[0];
if (area === 'B' && !unitCharacterIds.has(ownerId)) continue;
if (area === 'R' && ownerId !== leaderCharacterId) continue;
if (area !== 'B' && area !== 'R' && area !== 'G' && area !== 'Y') continue;
const effect = nodeForMember(summary, groupId, ownerId)?.effect;
if (!effect || effect.trigger === 'work' || effect.value === null) continue;
if (!singerConditionApplies(effect, ownerId, song)) continue;
const rawValue = Number(effect.value) * (multipliers[ownerId]?.[groupId] ?? 1);
const value = affectedStats(effect.type).length > 0 ? Math.ceil(rawValue) : rawValue;
if (effect.type === 'live_score_bonus_add_permil_up_by_music_skill_tree_character_and_music_singer_type') {
if (songBonusApplies(effect, ownerId, song)) directSongScoreBonusPercent += value / 10;
continue;
}
if (effect.type === 'live_active_skill_cool_time_shorten_permil_up') {
continue;
}
if (effect.type === 'live_active_skill_activation_probability_up_permil_up') {
for (const card of targets(effect, ownerId, cards)) activationRateUpByCardId[card.id] += value / 10;
continue;
}
if (effect.type === 'live_active_skill_effect_up_permil_up') {
scoreSupportPercent += value / 10;
continue;
}
const destination = effect.type.endsWith('_permil_up') ? rateByCardId : flatByCardId;
const normalizedValue = effect.type.endsWith('_permil_up') ? value / 10 : value;
for (const card of targets(effect, ownerId, cards)) {
for (const stat of affectedStats(effect.type)) destination[card.id][stat] += normalizedValue;
}
}
}
// 쿨타임 감소는 현재 보드 상태가 아니라 최적화 변수다. 모든 멤버가 네 경우를 항상 탐색한다.
const allowedCooldownReductionsByCardId = Object.fromEntries(cards.map((card) => [
card.id,
[0, 4, 8, 12] satisfies CooldownReduction[]
]));
return {
flatByCardId,
rateByCardId,
activationRateUpByCardId,
allowedCooldownReductionsByCardId,
scoreSupportPercent,
directSongScoreBonusPercent: Math.min(10, directSongScoreBonusPercent)
};
}
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { toOptimizerCard, type OptimizerSourceCard } from './card-adapter.ts';
const source: OptimizerSourceCard = {
id: 'card',
name: '카드',
details: {
active: [
{ level: 1, coolTimeSeconds: 10, durationSeconds: 1, effect: '스코어가 10% UP' },
{ level: 2, coolTimeSeconds: 8, durationSeconds: 2, effect: '스코어가 20% UP' }
],
special: [
{ level: 1, coolTimeSeconds: null, durationSeconds: 1, effect: '스코어가 30% UP' },
{ level: 2, coolTimeSeconds: null, durationSeconds: 2, effect: '스코어가 40% UP' }
]
}
};
test('액티브는 1개화, 스페셜은 3개화부터 2레벨을 적용한다', () => {
const base = toOptimizerCard(source, 0);
const activeAwakened = toOptimizerCard(source, 1);
const specialAwakened = toOptimizerCard(source, 3);
assert.equal(base.active.scoreUpPercent, 10);
assert.equal(base.special.scoreUpPercent, 30);
assert.equal(activeAwakened.active.scoreUpPercent, 20);
assert.equal(activeAwakened.special.scoreUpPercent, 30);
assert.equal(specialAwakened.special.scoreUpPercent, 40);
});
test('4개화 패시브의 타입별 스코어 서포트 대상과 수치를 변환한다', () => {
const passiveSource: OptimizerSourceCard = {
...source,
type: '퓨어',
generation: 'Promise',
details: {
...source.details,
passive: [
{ level: 1, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 8%' },
{ level: 2, description: '[attribute=pure]퓨어 타입[/attribute] 2명의 스코어 서포트 효과 11%' }
]
}
};
assert.deepEqual(toOptimizerCard(passiveSource, 4, undefined, [{ type: '퓨어', generation: 'Promise' }]).passiveScoreSupportEffects, [{
percent: 11,
target: 'group',
targetGroup: '퓨어 타입',
targetCount: 2
}]);
});
+117
View File
@@ -0,0 +1,117 @@
import type { CooldownReduction, OptimizerCard } from './types.ts';
import { plainSkillText, skillConditionSatisfied, type SkillConditionMember } from './skill-condition.ts';
type SkillLevel = {
level: number;
coolTimeSeconds: number | null;
durationSeconds: number | null;
effect?: string;
probabilityPercent?: number | null;
condition?: string;
};
export type OptimizerSourceCard = {
id: string;
name: string;
type?: string;
generation?: string;
versionName?: string;
details: {
passive?: readonly { level: number; description: string }[];
active: readonly SkillLevel[];
special: readonly SkillLevel[];
};
};
function selectedLevel(
levels: readonly SkillLevel[],
awakening: number,
levelUpAwakening: number
): SkillLevel | undefined {
const targetLevel = awakening >= levelUpAwakening ? 2 : 1;
return levels.find((skill) => skill.level === targetLevel) ?? levels.at(-1);
}
function passiveScoreSupportEffects(
source: OptimizerSourceCard,
awakening: number,
unitMembers: readonly SkillConditionMember[]
): NonNullable<OptimizerCard['passiveScoreSupportEffects']> {
const level = awakening >= 4 ? 2 : 1;
const description = plainSkillText(source.details.passive?.find((entry) => entry.level === level)?.description ?? '');
const marker = '스코어 서포트 효과';
const markerIndex = description.indexOf(marker);
if (markerIndex < 0) return [];
const before = description.slice(0, markerIndex).trim();
const percent = Number(description.slice(markerIndex + marker.length).match(/(\d+(?:\.\d+)?)%/)?.[1] ?? 0);
if (percent <= 0) return [];
const conditionSource = before.split('%').at(-1)?.trim() ?? before;
const condition = conditionSource.match(/(.+?\d+명 이상이면)/)?.[1] ?? '';
if (condition && !skillConditionSatisfied(condition, unitMembers)) return [];
const targetText = (before.includes('이면') ? before.slice(before.lastIndexOf('이면') + 2) : before).trim();
if (/전원의?$/.test(targetText)) return [{ percent, target: 'all' }];
if (/자신의?$/.test(targetText)) return [{ percent, target: 'self' }];
const groupTarget = targetText.match(/(.+?)\s*(\d+)명의?$/);
if (!groupTarget) return [];
return [{
percent,
target: 'group',
targetGroup: groupTarget[1].trim(),
targetCount: Number(groupTarget[2])
}];
}
function scorePercent(effect: string | undefined): number | null {
const match = effect?.match(/스코어(?:가|\s+서포트\s+효과)\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : null;
}
function activationRatePercent(effect: string | undefined): number {
const match = effect?.match(/스킬\s*발동률이?\s*(\d+(?:\.\d+)?)%/);
return match ? Number(match[1]) : 0;
}
export function toOptimizerCard(
source: OptimizerSourceCard,
awakening: number,
allowedCooldownReductions?: readonly CooldownReduction[],
unitMembers: readonly SkillConditionMember[] = []
): OptimizerCard {
const active = selectedLevel(source.details.active, awakening, 1);
const special = selectedLevel(source.details.special, awakening, 3);
const activeConditionMet = skillConditionSatisfied(active?.condition ?? '', unitMembers);
const specialConditionMet = skillConditionSatisfied(special?.condition ?? '', unitMembers);
const activeScore = activeConditionMet ? scorePercent(active?.effect) : 0;
const specialScore = specialConditionMet ? scorePercent(special?.effect) : 0;
if (!active || active.coolTimeSeconds === null || active.durationSeconds === null || activeScore === null) {
throw new Error(`${source.name}의 액티브 스코어 정보를 계산에 사용할 수 없습니다.`);
}
if (!special || special.durationSeconds === null || specialScore === null) {
throw new Error(`${source.name}의 스페셜 스코어 정보를 계산에 사용할 수 없습니다.`);
}
return {
id: source.id,
name: source.versionName ? `${source.name} (${source.versionName})` : source.name,
type: source.type,
generation: source.generation,
active: {
cooldownSeconds: active.coolTimeSeconds,
durationSeconds: active.durationSeconds,
scoreUpPercent: activeScore,
probabilityPercent: active.probabilityPercent ?? 100
},
special: {
durationSeconds: special.durationSeconds,
scoreUpPercent: specialScore,
activationRateUpPercent: specialConditionMet ? activationRatePercent(special.effect) : 0
},
passiveScoreSupportEffects: passiveScoreSupportEffects(source, awakening, unitMembers),
allowedCooldownReductions
};
}
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { optimizeExactOrderAndCooldowns } from './exact-optimizer.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const cards: OptimizerCard[] = ['a', 'b', 'c', 'd', 'e'].map((id, index) => ({
id,
name: id,
active: { cooldownSeconds: 2 + index, durationSeconds: 0.5, scoreUpPercent: 10 + index },
special: { durationSeconds: 1, scoreUpPercent: 20 + index },
allowedCooldownReductions: [0]
}));
const chart: ExactSongChart = {
songId: 'timeline-test',
difficulty: 'expert',
durationSeconds: 8,
scoreRatio: 1,
notes: Array.from({ length: 8 }, (_, timeSeconds) => ({ timeSeconds, type: 'regular', isFlick: false })),
specialStartSeconds: [1, 2, 3, 4, 5]
};
test('정확 분석 결과에 최적 조합의 액티브와 스페셜 지속 구간을 포함한다', () => {
const result = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
assert.ok(result.activeWindows.length > 0);
assert.ok(result.effectiveActiveWindows.length > 0);
assert.ok(result.effectiveActiveWindows.every((window) => window.baseScoreUpPercent > 0 && window.scoreUpPercent >= window.baseScoreUpPercent));
assert.equal(result.specialWindows.length, 5);
assert.deepEqual(new Set(result.specialWindows.map((window) => window.cardId)), new Set(result.order));
assert.ok(result.activeWindows.every((window) => window.endSeconds > window.startSeconds));
});
test('패시브 스코어 서포트는 순서상 앞선 대상 타입의 지정 인원에게 적용한다', () => {
const supported = cards.map((card, index) => ({
...card,
type: index < 3 ? '퓨어' : '해피',
passiveScoreSupportEffects: index === 4
? [{ percent: 20, target: 'group' as const, targetGroup: '퓨어 타입', targetCount: 2 }]
: []
}));
const baseline = optimizeExactOrderAndCooldowns(cards, chart, { perfectNoteScore: 100 });
const result = optimizeExactOrderAndCooldowns(supported, chart, { perfectNoteScore: 100 });
assert.ok(result.theoreticalMaxScore > baseline.theoreticalMaxScore);
});
+280
View File
@@ -0,0 +1,280 @@
import { calculateExactScore } from './score.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import { effectiveCooldownSeconds } from './timeline.ts';
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
EffectiveSkillWindow,
ExactOptimizationResult,
ExactOptimizeOptions,
ExactSongChart,
OptimizerCard,
SkillWindow
} from './types.ts';
type CandidateProfile = {
boost: Float64Array;
probability: Float64Array;
};
function passiveScoreSupportByCard(order: readonly OptimizerCard[]): number[] {
const totals = order.map(() => 0);
order.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = order.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = order
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? order.length)
.map(({ index }) => index);
for (const index of indexes) totals[index] += effect.percent;
}
});
return totals;
}
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function assignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let result: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
result = result.flatMap((current) => allowed.map((reduction) => ({ ...current, [card.id]: reduction })));
}
return result;
}
function specialActive(card: OptimizerCard, start: number | null | undefined, time: number): boolean {
return start !== null && start !== undefined && start <= time && time < start + card.special.durationSeconds;
}
function specialRate(
order: readonly OptimizerCard[],
chart: ExactSongChart,
time: number,
field: 'scoreUpPercent' | 'activationRateUpPercent'
): number {
let total = 0;
for (let slot = 0; slot < order.length; slot += 1) {
const card = order[slot];
if (specialActive(card, chart.specialStartSeconds[slot], time)) total += card.special[field] ?? 0;
}
return total;
}
function comboBonus(combo: number): number {
return combo >= 1_000 ? 0.1 : combo >= 100 ? Math.floor(combo / 100) * 0.01 : 0;
}
function baseScores(chart: ExactSongChart, perfectNoteScore: number): Float64Array {
return Float64Array.from(chart.notes, (note, index) => {
const base = note.type === 'mid' ? Math.ceil(perfectNoteScore * 0.1) : perfectNoteScore * (note.isFlick ? 1.05 : 1);
return base * (1 + comboBonus(index + 1));
});
}
function makeProfiles(order: readonly OptimizerCard[], chart: ExactSongChart): Map<string, CandidateProfile> {
const profiles = new Map<string, CandidateProfile>();
const noteSupport = Float64Array.from(chart.notes, (note) => specialRate(order, chart, note.timeSeconds, 'scoreUpPercent'));
const passiveSupport = passiveScoreSupportByCard(order);
order.forEach((card, cardIndex) => {
const reductions = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
reductions.forEach((reduction) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const boost = new Float64Array(chart.notes.length);
const probability = new Float64Array(chart.notes.length);
const probabilityByAttempt = new Map<number, number>();
chart.notes.forEach((note, noteIndex) => {
const attempt = Math.floor((note.timeSeconds + 1e-9) / interval);
if (attempt <= 0) return;
const activationTime = attempt * interval;
if (note.timeSeconds >= activationTime + card.active.durationSeconds) return;
let effectiveProbability = probabilityByAttempt.get(attempt);
if (effectiveProbability === undefined) {
const activationBonus = specialRate(order, chart, activationTime, 'activationRateUpPercent');
effectiveProbability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationBonus / 100));
probabilityByAttempt.set(attempt, effectiveProbability);
}
boost[noteIndex] = card.active.scoreUpPercent / 100 *
(1 + (noteSupport[noteIndex] + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100);
probability[noteIndex] = effectiveProbability;
});
profiles.set(`${card.id}:${reduction}`, { boost, probability });
});
});
return profiles;
}
function resultWindows(
order: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart
): { activeWindows: SkillWindow[]; effectiveActiveWindows: EffectiveSkillWindow[]; specialWindows: SkillWindow[] } {
const activeWindows = order.flatMap((card) => {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reductions[card.id] ?? 0);
const windows: SkillWindow[] = [];
for (let activation = 1; activation * interval < chart.durationSeconds - 1e-9; activation += 1) {
const startSeconds = activation * interval;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
});
const specialWindows = order.flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
const passiveSupport = passiveScoreSupportByCard(order);
const orderIndex = new Map(order.map((card, index) => [card.id, index]));
const boundaries = Array.from(new Set([
...activeWindows.flatMap((window) => [window.startSeconds, window.endSeconds]),
...specialWindows.flatMap((window) => [window.startSeconds, window.endSeconds])
])).sort((left, right) => left - right);
const effectiveActiveWindows: EffectiveSkillWindow[] = [];
for (let index = 0; index < boundaries.length - 1; index += 1) {
const startSeconds = boundaries[index];
const endSeconds = boundaries[index + 1];
if (endSeconds - startSeconds < 0.01) continue;
const midpoint = (startSeconds + endSeconds) / 2;
const specialSupport = specialRate(order, chart, midpoint, 'scoreUpPercent');
const active = activeWindows
.filter((window) => window.startSeconds <= midpoint && midpoint < window.endSeconds)
.map((window) => {
const cardIndex = orderIndex.get(window.cardId) ?? 0;
const card = order[cardIndex];
return {
window,
effectivePercent: window.scoreUpPercent *
(1 + (specialSupport + (card.scoreSupportPercent ?? 0) + passiveSupport[cardIndex]) / 100)
};
})
.sort((left, right) => right.effectivePercent - left.effectivePercent || (orderIndex.get(left.window.cardId) ?? 99) - (orderIndex.get(right.window.cardId) ?? 99));
const winner = active[0];
if (!winner) continue;
const previous = effectiveActiveWindows.at(-1);
if (previous && previous.cardId === winner.window.cardId && Math.abs(previous.scoreUpPercent - winner.effectivePercent) < 1e-9 && Math.abs(previous.endSeconds - startSeconds) < 0.01) {
previous.endSeconds = endSeconds;
previous.overlapCount = Math.max(previous.overlapCount, active.length);
} else {
effectiveActiveWindows.push({
...winner.window,
startSeconds,
endSeconds,
baseScoreUpPercent: winner.window.scoreUpPercent,
scoreUpPercent: winner.effectivePercent,
overlapCount: active.length
});
}
}
return { activeWindows, effectiveActiveWindows, specialWindows };
}
function scoreAssignment(
cards: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
profiles: ReadonlyMap<string, CandidateProfile>,
base: Float64Array,
directBonus: number
): number {
const selected = cards.map((card) => {
const reduction = reductions[card.id] ?? 0;
const profile = profiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 ${reduction}% 감소 프로필이 없습니다.`);
return profile;
});
const boosts = new Float64Array(cards.length);
const probabilities = new Float64Array(cards.length);
let rawScore = 0;
for (let noteIndex = 0; noteIndex < base.length; noteIndex += 1) {
let count = 0;
for (let memberIndex = 0; memberIndex < selected.length; memberIndex += 1) {
const boost = selected[memberIndex].boost[noteIndex];
if (boost <= 0) continue;
let position = count;
while (position > 0 && boosts[position - 1] < boost) {
boosts[position] = boosts[position - 1];
probabilities[position] = probabilities[position - 1];
position -= 1;
}
boosts[position] = boost;
probabilities[position] = selected[memberIndex].probability[noteIndex];
count += 1;
}
let expectedBoost = 0;
let previousFailed = 1;
for (let position = 0; position < count; position += 1) {
expectedBoost += previousFailed * probabilities[position] * boosts[position];
previousFailed *= 1 - probabilities[position];
}
rawScore += base[noteIndex] * (1 + expectedBoost);
}
return Math.floor(Math.floor(rawScore) * (1 + directBonus / 100) + Number.EPSILON * Math.max(1, rawScore) * 4);
}
export function optimizeExactOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: ExactSongChart,
options: ExactOptimizeOptions
): ExactOptimizationResult {
if (cards.length !== 5 || new Set(cards.map((card) => card.id)).size !== 5) {
throw new Error('서로 다른 카드 5장이 필요합니다.');
}
const orders = permutations(cards);
const reductionAssignments = assignments(cards);
const base = baseScores(chart, options.perfectNoteScore);
const totalCandidates = orders.length * reductionAssignments.length;
let evaluatedCandidates = 0;
let bestScore = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const order of orders) {
const profiles = makeProfiles(order, chart);
for (const reductions of reductionAssignments) {
const score = scoreAssignment(order, reductions, profiles, base, options.directSongScoreBonusPercent ?? 0);
const reductionTotal = Object.values(reductions).reduce<number>((sum, value) => sum + value, 0);
if (score > bestScore || (score === bestScore && reductionTotal < bestReductionTotal)) {
bestScore = score;
bestOrder = order;
bestReductions = reductions;
bestReductionTotal = reductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석 결과를 만들지 못했습니다.');
const windows = resultWindows(bestOrder, bestReductions, chart);
return {
...calculateExactScore(bestOrder, bestReductions, chart, options),
order: bestOrder.map((card) => card.id),
cooldownReductions: { ...bestReductions },
...windows,
evaluatedCandidates
};
}
+11
View File
@@ -0,0 +1,11 @@
export * from './card-adapter.ts';
export * from './board-effects.ts';
export * from './exact-optimizer.ts';
export * from './memory.ts';
export * from './maximum-weight-matching.ts';
export * from './optimizer.ts';
export * from './score.ts';
export * from './skill-condition.ts';
export * from './timeline.ts';
export * from './types.ts';
export * from './unit-power.ts';
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { maximumWeightMatching } from './maximum-weight-matching.ts';
test('전체 합이 가장 큰 일대일 배치를 선택한다', () => {
const matches = maximumWeightMatching([
[9, 8, 1],
[8, 1, 1]
]);
assert.deepEqual(matches.map(({ rowIndex, columnIndex }) => [rowIndex, columnIndex]).sort(), [[0, 1], [1, 0]]);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('카드 수가 슬롯보다 많아도 각 슬롯과 카드를 한 번만 사용한다', () => {
const matches = maximumWeightMatching([
[7, 0],
[6, 5],
[0, 9]
]);
assert.equal(matches.length, 2);
assert.equal(new Set(matches.map((match) => match.rowIndex)).size, 2);
assert.equal(new Set(matches.map((match) => match.columnIndex)).size, 2);
assert.equal(matches.reduce((sum, match) => sum + match.weight, 0), 16);
});
test('효과가 없는 배치는 결과에서 제외한다', () => {
assert.deepEqual(maximumWeightMatching([[0, 0]]), []);
});
+84
View File
@@ -0,0 +1,84 @@
export type WeightedMatch = {
rowIndex: number;
columnIndex: number;
weight: number;
};
/**
* Finds a maximum-weight one-to-one assignment for a rectangular matrix.
* Rows or columns that cannot improve the result may remain unmatched.
*/
export function maximumWeightMatching(weights: readonly (readonly number[])[]): WeightedMatch[] {
const rowCount = weights.length;
const columnCount = weights.reduce((maximum, row) => Math.max(maximum, row.length), 0);
if (rowCount === 0 || columnCount === 0) return [];
if (rowCount > columnCount) {
const transposed = Array.from({ length: columnCount }, (_, columnIndex) =>
Array.from({ length: rowCount }, (_, rowIndex) => weights[rowIndex]?.[columnIndex] ?? 0)
);
return maximumWeightMatching(transposed).map((match) => ({
rowIndex: match.columnIndex,
columnIndex: match.rowIndex,
weight: match.weight
}));
}
const maximum = Math.max(0, ...weights.flatMap((row) => [...row]));
const rowPotential = new Array(rowCount + 1).fill(0);
const columnPotential = new Array(columnCount + 1).fill(0);
const matchedRowByColumn = new Array(columnCount + 1).fill(0);
const previousColumn = new Array(columnCount + 1).fill(0);
for (let row = 1; row <= rowCount; row += 1) {
matchedRowByColumn[0] = row;
let currentColumn = 0;
const minimum = new Array(columnCount + 1).fill(Number.POSITIVE_INFINITY);
const used = new Array(columnCount + 1).fill(false);
do {
used[currentColumn] = true;
const currentRow = matchedRowByColumn[currentColumn];
let delta = Number.POSITIVE_INFINITY;
let nextColumn = 0;
for (let column = 1; column <= columnCount; column += 1) {
if (used[column]) continue;
const weight = weights[currentRow - 1]?.[column - 1] ?? 0;
const cost = maximum - Math.max(0, weight);
const reducedCost = cost - rowPotential[currentRow] - columnPotential[column];
if (reducedCost < minimum[column]) {
minimum[column] = reducedCost;
previousColumn[column] = currentColumn;
}
if (minimum[column] < delta) {
delta = minimum[column];
nextColumn = column;
}
}
for (let column = 0; column <= columnCount; column += 1) {
if (used[column]) {
rowPotential[matchedRowByColumn[column]] += delta;
columnPotential[column] -= delta;
} else {
minimum[column] -= delta;
}
}
currentColumn = nextColumn;
} while (matchedRowByColumn[currentColumn] !== 0);
do {
const nextColumn = previousColumn[currentColumn];
matchedRowByColumn[currentColumn] = matchedRowByColumn[nextColumn];
currentColumn = nextColumn;
} while (currentColumn !== 0);
}
const matches: WeightedMatch[] = [];
for (let column = 1; column <= columnCount; column += 1) {
const row = matchedRowByColumn[column];
if (row === 0) continue;
const weight = weights[row - 1]?.[column - 1] ?? 0;
if (weight > 0) matches.push({ rowIndex: row - 1, columnIndex: column - 1, weight });
}
return matches;
}
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { memoryParameterPermil } from './memory.ts';
const effects = [
{ count: 1, permil: 2 },
{ count: 2, permil: 4 },
{ count: 3, permil: 6 }
];
test('보유 메모리 수에 해당하는 누적 파라미터 보너스를 찾는다', () => {
assert.equal(memoryParameterPermil(0, effects), 0);
assert.equal(memoryParameterPermil(2, effects), 4);
assert.equal(memoryParameterPermil(10, effects), 6);
});
test('메모리 수는 음수를 0으로, 소수는 버림해 판정한다', () => {
assert.equal(memoryParameterPermil(-1, effects), 0);
assert.equal(memoryParameterPermil(2.9, effects), 4);
});
+15
View File
@@ -0,0 +1,15 @@
export type MemoryCollectionEffect = {
count: number;
permil: number;
};
export function memoryParameterPermil(
count: number,
effects: readonly MemoryCollectionEffect[]
): number {
const owned = Math.max(0, Math.floor(Number.isFinite(count) ? count : 0));
return effects.reduce(
(rate, effect) => effect.count <= owned && effect.permil > rate ? effect.permil : rate,
0
);
}
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { countOptimizationCandidates, effectiveCooldownSeconds, optimizeOrderAndCooldowns } from './index.ts';
import type { OptimizerCard, SongChartDensity } from './types.ts';
const cards: OptimizerCard[] = [
{ id: 'a', name: 'A', active: { cooldownSeconds: 2, durationSeconds: 0.8, scoreUpPercent: 100 }, special: { durationSeconds: 1, scoreUpPercent: 200 } },
{ id: 'b', name: 'B', active: { cooldownSeconds: 3, durationSeconds: 0.8, scoreUpPercent: 90 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'c', name: 'C', active: { cooldownSeconds: 4, durationSeconds: 0.8, scoreUpPercent: 80 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'd', name: 'D', active: { cooldownSeconds: 5, durationSeconds: 0.8, scoreUpPercent: 70 }, special: { durationSeconds: 1, scoreUpPercent: 20 } },
{ id: 'e', name: 'E', active: { cooldownSeconds: 6, durationSeconds: 0.8, scoreUpPercent: 60 }, special: { durationSeconds: 1, scoreUpPercent: 20 } }
];
const chart: SongChartDensity = {
songId: 'test-song',
difficulty: 'expert',
durationSeconds: 8,
notesPerSecond: [0, 0, 1, 1, 2, 2, 12, 12],
specialStartSeconds: [6, 0, 1, 2, 3]
};
test('쿨타임 감소는 기존 계산기의 나눗셈 규칙을 따른다', () => {
assert.equal(effectiveCooldownSeconds(20, 0), 20);
assert.equal(effectiveCooldownSeconds(20, 4), 20 / 1.04);
assert.equal(effectiveCooldownSeconds(20, 12), 20 / 1.12);
});
test('5장과 네 가지 감소율은 122,880개 후보를 만든다', () => {
assert.equal(countOptimizationCandidates(cards), 122_880);
});
test('노트가 몰린 첫 스페셜 슬롯에 가장 강한 스페셜 카드를 배치한다', () => {
const fixedReductionCards = cards.map((card) => ({ ...card, allowedCooldownReductions: [0] as const }));
const result = optimizeOrderAndCooldowns(fixedReductionCards, chart);
assert.equal(result.order[0], 'a');
assert.ok(result.scoreWeight > 0);
});
+148
View File
@@ -0,0 +1,148 @@
import { COOLDOWN_REDUCTIONS } from './types.ts';
import type {
CooldownReduction,
OptimizeOrderOptions,
OptimizerCard,
SimulationResult,
SongChartDensity
} from './types.ts';
import { buildActiveWindows, buildSpecialWindows, simulateOrder } from './timeline.ts';
function permutations<T>(items: readonly T[]): T[][] {
if (items.length <= 1) return [Array.from(items)];
const result: T[][] = [];
for (let index = 0; index < items.length; index += 1) {
const rest = [...items.slice(0, index), ...items.slice(index + 1)];
for (const permutation of permutations(rest)) result.push([items[index], ...permutation]);
}
return result;
}
function reductionAssignments(cards: readonly OptimizerCard[]): Array<Record<string, CooldownReduction>> {
let assignments: Array<Record<string, CooldownReduction>> = [{}];
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
assignments = assignments.flatMap((assignment) =>
allowed.map((reduction) => ({ ...assignment, [card.id]: reduction }))
);
}
return assignments;
}
function reductionTotal(reductions: Readonly<Record<string, CooldownReduction>>): number {
return Object.values(reductions).reduce<number>((sum, reduction) => sum + reduction, 0);
}
function fillWindowProfile(
profile: Float64Array,
windows: ReturnType<typeof buildActiveWindows>,
stepSeconds: number
): void {
for (const window of windows) {
const startTick = Math.max(0, Math.ceil(window.startSeconds / stepSeconds - 1e-9));
const endTick = Math.min(profile.length, Math.ceil(window.endSeconds / stepSeconds - 1e-9));
for (let tick = startTick; tick < endTick; tick += 1) {
profile[tick] = Math.max(profile[tick], window.scoreUpPercent);
}
}
}
function makeNoteWeights(chart: SongChartDensity, stepSeconds: number, tickCount: number): Float64Array {
const weights = new Float64Array(tickCount);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
weights[tick] =
(chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
}
return weights;
}
export function optimizeOrderAndCooldowns(
cards: readonly OptimizerCard[],
chart: SongChartDensity,
options: OptimizeOrderOptions = {}
): SimulationResult {
if (cards.length !== 5) throw new Error('최고점 순서 분석에는 정확히 5장의 카드가 필요합니다.');
if (new Set(cards.map((card) => card.id)).size !== cards.length) throw new Error('카드 ID는 서로 달라야 합니다.');
const stepSeconds = options.stepSeconds ?? 0.1;
if (!Number.isFinite(stepSeconds) || stepSeconds <= 0) throw new Error('계산 간격은 0보다 커야 합니다.');
const orders = permutations(cards);
const assignments = reductionAssignments(cards);
const totalCandidates = orders.length * assignments.length;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
const noteWeights = makeNoteWeights(chart, stepSeconds, tickCount);
const activeProfiles = new Map<string, Float64Array>();
for (const card of cards) {
const allowed = card.allowedCooldownReductions?.length ? card.allowedCooldownReductions : COOLDOWN_REDUCTIONS;
for (const reduction of allowed) {
const profile = new Float64Array(tickCount);
fillWindowProfile(
profile,
buildActiveWindows(card, reduction, chart.durationSeconds, stepSeconds),
stepSeconds
);
activeProfiles.set(`${card.id}:${reduction}`, profile);
}
}
const specialMultipliers = orders.map((order) => {
const profile = new Float64Array(tickCount);
fillWindowProfile(profile, buildSpecialWindows(order, chart), stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) profile[tick] = 1 + profile[tick] / 100;
return profile;
});
let evaluatedCandidates = 0;
let bestScoreWeight = Number.NEGATIVE_INFINITY;
let bestOrder: OptimizerCard[] | null = null;
let bestReductions: Record<string, CooldownReduction> | null = null;
let bestReductionTotal = Number.POSITIVE_INFINITY;
for (const reductions of assignments) {
const activeScore = new Float64Array(tickCount);
for (const card of cards) {
const reduction = reductions[card.id] ?? 0;
const profile = activeProfiles.get(`${card.id}:${reduction}`);
if (!profile) throw new Error(`${card.name}의 쿨타임 감소 ${reduction}% 계산값이 없습니다.`);
for (let tick = 0; tick < tickCount; tick += 1) {
activeScore[tick] = Math.max(activeScore[tick], profile[tick]);
}
}
const currentReductionTotal = reductionTotal(reductions);
for (let orderIndex = 0; orderIndex < orders.length; orderIndex += 1) {
const specialMultiplier = specialMultipliers[orderIndex];
let scoreWeight = 0;
for (let tick = 0; tick < tickCount; tick += 1) {
scoreWeight += noteWeights[tick] * activeScore[tick] * specialMultiplier[tick];
}
const isBetterScore = scoreWeight > bestScoreWeight + 1e-9;
const isCheaperTie =
Math.abs(scoreWeight - bestScoreWeight) <= 1e-9 && currentReductionTotal < bestReductionTotal;
if (isBetterScore || isCheaperTie) {
bestScoreWeight = scoreWeight;
bestOrder = orders[orderIndex];
bestReductions = reductions;
bestReductionTotal = currentReductionTotal;
}
evaluatedCandidates += 1;
}
options.onProgress?.({ evaluatedCandidates, totalCandidates });
}
if (!bestOrder || !bestReductions) throw new Error('분석할 수 있는 후보가 없습니다.');
return simulateOrder(bestOrder, bestReductions, chart, stepSeconds);
}
export function countOptimizationCandidates(cards: readonly OptimizerCard[]): number {
const orderCount = cards.reduce((value, _, index) => value * (index + 1), 1);
const reductionCount = cards.reduce(
(value, card) => value * (card.allowedCooldownReductions?.length || COOLDOWN_REDUCTIONS.length),
1
);
return orderCount * reductionCount;
}
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { calculateExactScore, perfectNoteScoreFromPower, scoreRatioFromNotes } from './index.ts';
import type { ExactSongChart, OptimizerCard } from './types.ts';
const inactiveCard: OptimizerCard = {
id: 'card',
name: '카드',
active: { cooldownSeconds: 999, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 100 },
special: { durationSeconds: 0, scoreUpPercent: 0 }
};
function chart(notes: ExactSongChart['notes']): ExactSongChart {
return { songId: 'test', difficulty: 'expert', durationSeconds: 120, scoreRatio: 100, notes, specialStartSeconds: [] };
}
test('종합력과 악곡 비율로 PERFECT 1노트 기본점수를 계산한다', () => {
assert.equal(perfectNoteScoreFromPower(120_000, 200), 600);
assert.equal(perfectNoteScoreFromPower(120_100, 200), 600.5);
assert.equal(perfectNoteScoreFromPower(0, 200, 777), 777);
});
test('플릭 1.05배와 같은 시각 홀드 중간 판정의 단일 가중치로 환산비율을 계산한다', () => {
const ratio = scoreRatioFromNotes([
{ timeSeconds: 1, type: 'regular', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true },
{ timeSeconds: 3, type: 'mid', isFlick: false },
{ timeSeconds: 3, type: 'mid', isFlick: false }
]);
assert.ok(Math.abs(ratio - 2.15 * 0.3713416563352296) < 1e-12);
});
test('기본점수의 소수점을 유지하고 가창 보너스 적용 후 노트별로 올림한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'regular', isFlick: false }
]), { perfectNoteScore: 1_354.507, directSongScoreBonusPercent: 10 });
assert.equal(result.baselineScore, 1_490);
});
test('100콤보부터 1% 콤보 보너스를 적용한다', () => {
const notes = Array.from({ length: 100 }, (_, index) => ({ timeSeconds: index, type: 'regular' as const, isFlick: false }));
const result = calculateExactScore([inactiveCard], {}, chart(notes), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 99 * 600 + 606);
});
test('홀드 중간 노트는 10%, 플릭은 1.05배로 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 2, type: 'regular', isFlick: true }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60 + 630);
});
test('같은 시각에 중복된 홀드 중간 판정은 한 번만 계산한다', () => {
const result = calculateExactScore([inactiveCard], {}, chart([
{ timeSeconds: 1, type: 'mid', isFlick: false },
{ timeSeconds: 1, type: 'mid', isFlick: false }
]), { perfectNoteScore: 600 });
assert.equal(result.baselineScore, 60);
assert.equal(result.notes.length, 1);
});
test('액티브 발동 확률과 스페셜 스코어 서포트를 기대 점수에 반영한다', () => {
const card: OptimizerCard = {
id: 'score',
name: '스코어 카드',
active: { cooldownSeconds: 1, durationSeconds: 1, scoreUpPercent: 100, probabilityPercent: 50 },
special: { durationSeconds: 2, scoreUpPercent: 100 }
};
const result = calculateExactScore([card], {}, {
...chart([{ timeSeconds: 1, type: 'regular', isFlick: false }]),
specialStartSeconds: [0]
}, { perfectNoteScore: 600 });
assert.equal(result.expectedScore, 1_200);
assert.equal(result.theoreticalMaxScore, 1_800);
});
+187
View File
@@ -0,0 +1,187 @@
import { effectiveCooldownSeconds } from './timeline.ts';
import { skillTargetBelongsTo } from './skill-condition.ts';
import type {
CooldownReduction,
ExactScoreOptions,
ExactScoreResult,
ExactSongChart,
OptimizerCard
} from './types.ts';
type ActiveCandidate = {
memberIndex: number;
probability: number;
boost: number;
};
// QUEEN 및 Our Promise Expert의 인게임 실측값으로 보정한 전역 환산 상수다.
export const CHART_SCORE_WEIGHT_COEFFICIENT = 0.3713416563352296;
export function scoreRatioFromNotes(notes: ExactSongChart['notes']): number {
const countedMidTimes = new Set<number>();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
continue;
}
weight += note.isFlick ? 1.05 : 1;
}
return weight * CHART_SCORE_WEIGHT_COEFFICIENT;
}
function passiveScoreSupportAt(cards: readonly OptimizerCard[], targetIndex: number): number {
let total = 0;
cards.forEach((source, sourceIndex) => {
for (const effect of source.passiveScoreSupportEffects ?? []) {
let indexes: number[];
if (effect.target === 'all') indexes = cards.map((_, index) => index);
else if (effect.target === 'self') indexes = [sourceIndex];
else indexes = cards
.map((card, index) => ({ card, index }))
.filter(({ card }) => skillTargetBelongsTo({ type: card.type ?? '', generation: card.generation ?? '' }, effect.targetGroup ?? ''))
.slice(0, effect.targetCount ?? cards.length)
.map(({ index }) => index);
if (indexes.includes(targetIndex)) total += effect.percent;
}
});
return total;
}
function comboBonus(combo: number): number {
if (combo >= 1_000) return 0.1;
if (combo >= 100) return Math.floor(combo / 100) * 0.01;
return 0;
}
function activeAttempt(card: OptimizerCard, reduction: CooldownReduction, timeSeconds: number): number {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
return Math.floor((timeSeconds + 1e-9) / interval);
}
function specialIsActive(
card: OptimizerCard,
startSeconds: number | null | undefined,
timeSeconds: number
): boolean {
return startSeconds !== null && startSeconds !== undefined &&
startSeconds <= timeSeconds && timeSeconds < startSeconds + card.special.durationSeconds;
}
function specialSupportAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds) ? card.special.scoreUpPercent : 0), 0);
}
function activationRateBonusAt(
cardsInOrder: readonly OptimizerCard[],
chart: ExactSongChart,
timeSeconds: number
): number {
return cardsInOrder.reduce((sum, card, slot) =>
sum + (specialIsActive(card, chart.specialStartSeconds[slot], timeSeconds)
? card.special.activationRateUpPercent ?? 0
: 0), 0);
}
function activeCandidates(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
timeSeconds: number
): ActiveCandidate[] {
const specialSupport = specialSupportAt(cardsInOrder, chart, timeSeconds);
const candidates = cardsInOrder.flatMap((card, memberIndex) => {
const reduction = reductions[card.id] ?? 0;
const attempt = activeAttempt(card, reduction, timeSeconds);
if (attempt <= 0) return [];
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const activationTime = attempt * interval;
if (timeSeconds >= activationTime + card.active.durationSeconds) return [];
const activationRateBonus = activationRateBonusAt(cardsInOrder, chart, activationTime);
const probability = Math.min(1, ((card.active.probabilityPercent ?? 100) / 100) * (1 + activationRateBonus / 100));
const support = specialSupport + (card.scoreSupportPercent ?? 0) + passiveScoreSupportAt(cardsInOrder, memberIndex);
return [{
memberIndex,
probability,
boost: card.active.scoreUpPercent / 100 * (1 + support / 100)
}];
});
return candidates.sort((left, right) => right.boost - left.boost || left.memberIndex - right.memberIndex);
}
function expectedStrongestBoost(candidates: readonly ActiveCandidate[]): number {
let expectedBoost = 0;
let previousFailed = 1;
for (const candidate of candidates) {
expectedBoost += previousFailed * candidate.probability * candidate.boost;
previousFailed *= 1 - candidate.probability;
}
return expectedBoost;
}
function finalNoteScore(score: number, percent: number): number {
const scaled = score * (1 + percent / 100);
return Math.ceil(scaled - Number.EPSILON * Math.max(1, scaled) * 4);
}
export function perfectNoteScoreFromPower(overallPower: number, scoreRatio: number | null, fallback = 600): number {
if (overallPower > 0 && scoreRatio !== null && scoreRatio > 0) {
return Math.max(1, overallPower / scoreRatio);
}
return fallback;
}
export function calculateExactScore(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: ExactSongChart,
options: ExactScoreOptions
): ExactScoreResult {
const directBonus = options.directSongScoreBonusPercent ?? 0;
const countedMidTimes = new Set<number>();
const scoreNotes = chart.notes.filter((note) => {
if (note.type !== 'mid') return true;
if (countedMidTimes.has(note.timeSeconds)) return false;
countedMidTimes.add(note.timeSeconds);
return true;
});
const notePoints = scoreNotes.map((note, index) => {
const combo = index + 1;
const baseNoteScore = note.type === 'mid'
? Math.ceil(options.perfectNoteScore * 0.1)
: options.perfectNoteScore * (note.isFlick ? 1.05 : 1);
const rawBaselineScore = baseNoteScore * (1 + comboBonus(combo));
const candidates = activeCandidates(cardsInOrder, reductions, chart, note.timeSeconds);
const baselineScore = finalNoteScore(rawBaselineScore, directBonus);
const expectedScore = finalNoteScore(rawBaselineScore * (1 + expectedStrongestBoost(candidates)), directBonus);
const theoreticalMaxScore = finalNoteScore(rawBaselineScore * (1 + (candidates[0]?.boost ?? 0)), directBonus);
return { timeSeconds: note.timeSeconds, combo, expectedScore, theoreticalMaxScore, baselineScore };
});
const rawExpected = notePoints.reduce((sum, note) => sum + note.expectedScore, 0);
const rawMaximum = notePoints.reduce((sum, note) => sum + note.theoreticalMaxScore, 0);
const rawBaseline = notePoints.reduce((sum, note) => sum + note.baselineScore, 0);
const scoreBySecond = Array.from({ length: Math.ceil(chart.durationSeconds) }, () => 0);
notePoints.forEach((note) => {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < scoreBySecond.length) scoreBySecond[second] += note.expectedScore;
});
return {
expectedScore: rawExpected,
theoreticalMaxScore: rawMaximum,
baselineScore: rawBaseline,
perfectNoteScore: options.perfectNoteScore,
scoreBySecond,
notes: notePoints
};
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
const members = [
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '큐트', generation: 'hololive 0기생' },
{ type: '퓨어', generation: 'Myth' },
{ type: '해피', generation: 'GAMERS' },
{ type: '해피', generation: 'holoX' }
];
test('타입과 기수 조건은 현재 멤버 조합으로 판정한다', () => {
assert.equal(skillConditionSatisfied('[attribute=cute]큐트 타입[/attribute] 2명 이상이면', members), true);
assert.equal(skillConditionSatisfied('Myth(이)가 2명 이상이면', members), false);
assert.equal(skillConditionSatisfied('0기생(이)가 2명 이상이면', members), true);
});
test('콤보와 라이프 조건은 항상 충족한 것으로 판정한다', () => {
assert.equal(skillConditionSatisfied('100콤보 이상이면', members), true);
assert.equal(skillConditionSatisfied('라이프 1000 이상이면', members), true);
});
test('복수 소속 멤버는 표기된 각 기수에 모두 속한다', () => {
const fubuki = { type: '퓨어', generation: 'hololive 1기생 / GAMERS' };
assert.equal(skillTargetBelongsTo(fubuki, '1기생'), true);
assert.equal(skillTargetBelongsTo(fubuki, 'GAMERS'), true);
assert.equal(skillConditionSatisfied('1기생(이)가 1명 이상이면', [fubuki]), true);
});
+51
View File
@@ -0,0 +1,51 @@
export type SkillConditionMember = {
type: string;
generation: string;
};
export function plainSkillText(value: string): string {
return value.replace(/\[[^\]]+\]/g, '').replace(/\s+/g, ' ').trim();
}
function normalizedGroup(value: string): string {
return value
.replace(/^hololive\s+/i, '')
.replace(/\(이\)가$/, '')
.replace(/\s*타입$/, '')
.trim()
.toLowerCase();
}
function memberBelongsTo(member: SkillConditionMember, group: string): boolean {
const normalized = normalizedGroup(group);
const type = normalizedGroup(member.type);
const generation = normalizedGroup(member.generation);
const generations = member.generation
.split('/')
.map((value) => normalizedGroup(value))
.filter(Boolean);
return type === normalized
|| generation === normalized
|| generation.endsWith(normalized)
|| generations.some((value) => value === normalized || value.endsWith(normalized));
}
export function skillConditionSatisfied(
condition: string,
members: readonly SkillConditionMember[]
): boolean {
const description = plainSkillText(condition);
if (!description) return true;
// 콤보와 라이프는 분석에서 최적 플레이를 전제로 항상 충족한다.
if (/\d+콤보 이상이면/.test(description) || /라이프 \d+ 이상이면/.test(description)) return true;
const deckCondition = description.match(/^(.+?)\s*(\d+)명 이상이면$/);
if (!deckCondition) return true;
const requiredCount = Number(deckCondition[2]);
return members.filter((member) => memberBelongsTo(member, deckCondition[1])).length >= requiredCount;
}
export function skillTargetBelongsTo(member: SkillConditionMember, group: string): boolean {
return memberBelongsTo(member, group);
}
+107
View File
@@ -0,0 +1,107 @@
import type {
CooldownReduction,
OptimizerCard,
SimulationResult,
SkillWindow,
SongChartDensity,
TimelinePoint
} from './types.ts';
const EPSILON = 1e-9;
export function ceilToStep(value: number, stepSeconds: number): number {
return Math.ceil(value / stepSeconds - EPSILON) * stepSeconds;
}
export function effectiveCooldownSeconds(baseCooldownSeconds: number, reduction: CooldownReduction): number {
return baseCooldownSeconds / (1 + reduction / 100);
}
export function buildActiveWindows(
card: OptimizerCard,
reduction: CooldownReduction,
durationSeconds: number,
stepSeconds = 0.1
): SkillWindow[] {
const interval = effectiveCooldownSeconds(card.active.cooldownSeconds, reduction);
const windows: SkillWindow[] = [];
for (let activation = 1; ; activation += 1) {
const startSeconds = ceilToStep(activation * interval, stepSeconds);
if (startSeconds > durationSeconds + EPSILON) break;
windows.push({
cardId: card.id,
startSeconds,
endSeconds: Math.min(durationSeconds, startSeconds + card.active.durationSeconds),
scoreUpPercent: card.active.scoreUpPercent
});
}
return windows;
}
export function buildSpecialWindows(cardsInOrder: readonly OptimizerCard[], chart: SongChartDensity): SkillWindow[] {
return cardsInOrder.slice(0, chart.specialStartSeconds.length).flatMap((card, slot) => {
const startSeconds = chart.specialStartSeconds[slot];
if (startSeconds === null || startSeconds === undefined) return [];
return [{
cardId: card.id,
startSeconds,
endSeconds: Math.min(chart.durationSeconds, startSeconds + card.special.durationSeconds),
scoreUpPercent: card.special.scoreUpPercent
}];
});
}
function strongestWindowAt(windows: readonly SkillWindow[], timeSeconds: number): SkillWindow | null {
let strongest: SkillWindow | null = null;
for (const window of windows) {
if (window.startSeconds <= timeSeconds && timeSeconds < window.endSeconds) {
if (!strongest || window.scoreUpPercent > strongest.scoreUpPercent) strongest = window;
}
}
return strongest;
}
export function simulateOrder(
cardsInOrder: readonly OptimizerCard[],
reductions: Readonly<Record<string, CooldownReduction>>,
chart: SongChartDensity,
stepSeconds = 0.1
): SimulationResult {
const activeWindows = cardsInOrder.flatMap((card) =>
buildActiveWindows(card, reductions[card.id] ?? 0, chart.durationSeconds, stepSeconds)
);
const specialWindows = buildSpecialWindows(cardsInOrder, chart);
const timeline: TimelinePoint[] = [];
let scoreWeight = 0;
const tickCount = Math.ceil(chart.durationSeconds / stepSeconds);
for (let tick = 0; tick < tickCount; tick += 1) {
const timeSeconds = tick * stepSeconds;
const second = Math.floor(timeSeconds);
const noteWeight = (chart.notesPerSecond[second] ?? 0) * Math.min(stepSeconds, chart.durationSeconds - timeSeconds);
const active = strongestWindowAt(activeWindows, timeSeconds);
const special = strongestWindowAt(specialWindows, timeSeconds);
const activeScoreUpPercent = active?.scoreUpPercent ?? 0;
const specialScoreUpPercent = special?.scoreUpPercent ?? 0;
const pointScoreWeight = noteWeight * activeScoreUpPercent * (1 + specialScoreUpPercent / 100);
scoreWeight += pointScoreWeight;
timeline.push({
timeSeconds,
noteWeight,
activeCardId: active?.cardId ?? null,
activeScoreUpPercent,
specialCardId: special?.cardId ?? null,
specialScoreUpPercent,
scoreWeight: pointScoreWeight
});
}
return {
scoreWeight,
order: cardsInOrder.map((card) => card.id),
cooldownReductions: { ...reductions },
activeWindows,
specialWindows,
timeline
};
}
+130
View File
@@ -0,0 +1,130 @@
export const COOLDOWN_REDUCTIONS = [0, 4, 8, 12] as const;
export type CooldownReduction = (typeof COOLDOWN_REDUCTIONS)[number];
export type OptimizerCard = {
id: string;
name: string;
type?: string;
generation?: string;
active: {
cooldownSeconds: number;
durationSeconds: number;
scoreUpPercent: number;
probabilityPercent?: number;
};
special: {
durationSeconds: number;
scoreUpPercent: number;
activationRateUpPercent?: number;
};
scoreSupportPercent?: number;
passiveScoreSupportEffects?: readonly {
percent: number;
target: 'all' | 'self' | 'group';
targetGroup?: string;
targetCount?: number;
}[];
allowedCooldownReductions?: readonly CooldownReduction[];
};
export type SongChartDensity = {
songId: string;
difficulty: string;
durationSeconds: number;
notesPerSecond: readonly number[];
specialStartSeconds: readonly (number | null)[];
};
export type SkillWindow = {
cardId: string;
startSeconds: number;
endSeconds: number;
scoreUpPercent: number;
};
export type EffectiveSkillWindow = SkillWindow & {
baseScoreUpPercent: number;
overlapCount: number;
};
export type TimelinePoint = {
timeSeconds: number;
noteWeight: number;
activeCardId: string | null;
activeScoreUpPercent: number;
specialCardId: string | null;
specialScoreUpPercent: number;
scoreWeight: number;
};
export type SimulationResult = {
scoreWeight: number;
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
specialWindows: readonly SkillWindow[];
timeline: readonly TimelinePoint[];
};
export type OptimizationProgress = {
evaluatedCandidates: number;
totalCandidates: number;
};
export type OptimizeOrderOptions = {
stepSeconds?: number;
onProgress?: (progress: OptimizationProgress) => void;
};
export type ExactChartNote = {
timeSeconds: number;
type: 'regular' | 'mid';
isFlick: boolean;
};
export type ExactSongChart = {
songId: string;
difficulty: string;
durationSeconds: number;
scoreRatio: number | null;
notes: readonly ExactChartNote[];
specialStartSeconds: readonly (number | null)[];
singerType?: string | null;
singerCharacterIds?: readonly string[];
};
export type NoteScorePoint = {
timeSeconds: number;
combo: number;
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
};
export type ExactScoreResult = {
expectedScore: number;
theoreticalMaxScore: number;
baselineScore: number;
perfectNoteScore: number;
scoreBySecond: readonly number[];
notes: readonly NoteScorePoint[];
};
export type ExactScoreOptions = {
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
export type ExactOptimizationResult = ExactScoreResult & {
order: readonly string[];
cooldownReductions: Readonly<Record<string, CooldownReduction>>;
activeWindows: readonly SkillWindow[];
effectiveActiveWindows: readonly EffectiveSkillWindow[];
specialWindows: readonly SkillWindow[];
evaluatedCandidates: number;
};
export type ExactOptimizeOptions = ExactScoreOptions & {
onProgress?: (progress: OptimizationProgress) => void;
};
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { calculateUnitPower, type UnitPowerCard } from './unit-power.ts';
function card(id: string, passive: string, awakenedPassive = passive): UnitPowerCard {
return {
id,
characterId: id,
name: id,
type: '큐트',
generation: '0기생',
power: { maxLevel: 80, statsAtMaxLevel: { performance: 100, technique: 100, sense: 100 } },
details: { passive: [{ level: 1, description: passive }, { level: 2, description: awakenedPassive }] }
};
}
test('최대 레벨 기본 스탯, 개화 보너스와 패시브로 종합력을 계산한다', () => {
const result = calculateUnitPower([
{ card: card('a', '0기생(이)가 2명 이상이면 자신의 전체 파라미터가 20% UP'), awakening: 5 },
...['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 5 }))
]);
assert.equal(result.baseParameterTotal, 1500);
assert.equal(result.potentialTotal, 150);
assert.equal(result.passiveTotal, 66);
assert.equal(result.overallPower, 1716);
});
test('패시브 2레벨은 4개화부터 적용한다', () => {
const passiveCard = card('a', '자신의 전체 파라미터가 10% UP', '자신의 전체 파라미터가 20% UP');
const otherCards = ['b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }));
const before = calculateUnitPower([{ card: passiveCard, awakening: 3 }, ...otherCards]);
const after = calculateUnitPower([{ card: passiveCard, awakening: 4 }, ...otherCards]);
assert.equal(before.passiveTotal, 33);
assert.equal(after.passiveTotal, 66);
});
test('편성 조건을 만족한 리더 의상 효과를 종합력에 더한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
{ name: '테스트 의상', description: '0기생(이)가 2명 이상이면 전원의 전체 파라미터가 50% UP' }
);
assert.equal(result.outfitTotal, 750);
assert.equal(result.overallPower, 2250);
});
test('보유 메모리 효과는 개화 적용 후 각 멤버의 능력치마다 올림한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 2 })),
null,
null,
46
);
assert.equal(result.potentialTotal, 150);
assert.equal(result.memoryTotal, 90);
assert.equal(result.overallPower, 1740);
});
test('멤버 강화는 메모리를 제외한 파라미터·패시브·의상·보드 합계에 적용한다', () => {
const result = calculateUnitPower(
['a', 'b', 'c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 })),
null,
null,
100,
10
);
assert.equal(result.memoryTotal, 150);
assert.equal(result.memberPowerUpTotal, 150);
assert.equal(result.overallPower, 1800);
});
test('조건부 기수 패시브는 복수 소속 멤버도 대상으로 삼는다', () => {
const fubuki = {
...card('fubuki', '1기생(이)가 2명 이상이면 1기생2명의 퍼포먼스가 10% UP'),
generation: 'hololive 1기생 / GAMERS'
};
const matsuri = {
...card('matsuri', ''),
generation: 'hololive 1기생'
};
const result = calculateUnitPower([
{ card: fubuki, awakening: 0 },
{ card: matsuri, awakening: 0 },
...['c', 'd', 'e'].map((id) => ({ card: card(id, ''), awakening: 0 }))
]);
assert.equal(result.passiveTotal, 20);
});
+169
View File
@@ -0,0 +1,169 @@
import { plainSkillText, skillConditionSatisfied, skillTargetBelongsTo } from './skill-condition.ts';
export type UnitStat = 'performance' | 'technique' | 'sense';
export type UnitPowerCard = {
id: string;
characterId: string;
name: string;
type: string;
generation: string;
power: {
maxLevel: number;
statsAtMaxLevel: Record<UnitStat, number>;
};
details: {
passive: readonly { level: number; description: string }[];
};
};
export type UnitPowerMember = {
card: UnitPowerCard;
awakening: number;
};
export type UnitPowerResult = {
overallPower: number;
baseParameterTotal: number;
potentialTotal: number;
passiveTotal: number;
outfitTotal: number;
boardTotal: number;
memoryTotal: number;
memberPowerUpTotal: number;
outfitScoreSupportPercent: number;
};
export type UnitPowerOutfit = {
name: string;
description: string;
};
export type UnitPowerBoardEffects = {
flatByCardId: Record<string, Record<UnitStat, number>>;
rateByCardId: Record<string, Record<UnitStat, number>>;
};
const stats: readonly UnitStat[] = ['performance', 'technique', 'sense'];
function selectedPassive(member: UnitPowerMember): string {
const level = member.awakening >= 4 ? 2 : 1;
return plainSkillText(member.card.details.passive.find((entry) => entry.level === level)?.description ?? '');
}
function conditionSatisfied(description: string, members: readonly UnitPowerMember[]): boolean {
const match = description.match(/^(.+?\d+명 이상이면)/);
if (!match) return true;
return skillConditionSatisfied(match[1], members.map(({ card }) => card));
}
function affectedStats(description: string): readonly UnitStat[] {
if (description.includes('전체 파라미터')) return stats;
if (description.includes('퍼포먼스')) return ['performance'];
if (description.includes('테크닉')) return ['technique'];
if (description.includes('센스')) return ['sense'];
return [];
}
function effectRate(description: string): number {
const matches = [...description.matchAll(/(\d+(?:\.\d+)?)%\s*UP/g)];
return Number(matches.at(-1)?.[1] ?? 0);
}
function targetIndexes(
description: string,
sourceIndex: number,
stat: UnitStat,
members: readonly UnitPowerMember[],
parameters: readonly Record<UnitStat, number>[]
): number[] {
if (description.includes('자신의')) return [sourceIndex];
if (description.includes('전원의')) return members.map((_, index) => index);
const effectText = description.includes('이면')
? description.slice(description.lastIndexOf('이면') + 2).trim()
: description;
const target = effectText.match(/^(.+?)\s*(\d+)명의\s*(?:전체 파라미터|퍼포먼스|테크닉|센스)/);
if (!target) return [];
const count = Number(target[2]);
return members
.map((member, index) => ({ member, index }))
.filter(({ member }) => skillTargetBelongsTo(member.card, target[1]))
.sort((left, right) => parameters[right.index][stat] - parameters[left.index][stat] || left.index - right.index)
.slice(0, count)
.map(({ index }) => index);
}
export function calculateUnitPower(
members: readonly UnitPowerMember[],
outfit?: UnitPowerOutfit | null,
board?: UnitPowerBoardEffects | null,
memoryPermil = 0,
memberPowerUpPercent = 0
): UnitPowerResult {
if (members.length !== 5) throw new Error('유닛 종합력 계산에는 카드 5장이 필요합니다.');
const baseParameters = members.map(({ card }) => ({ ...card.power.statsAtMaxLevel }));
const parameters = baseParameters.map((base, index) => {
const potentialRate = members[index].awakening >= 2 ? 10 : 0;
return Object.fromEntries(stats.map((stat) => [stat, Math.ceil(base[stat] * (100 + potentialRate) / 100)])) as Record<UnitStat, number>;
});
const passiveRates = members.map(() => ({ performance: 0, technique: 0, sense: 0 }));
members.forEach((member, sourceIndex) => {
const description = selectedPassive(member);
if (!conditionSatisfied(description, members)) return;
const rate = effectRate(description);
for (const stat of affectedStats(description)) {
for (const targetIndex of targetIndexes(description, sourceIndex, stat, members, parameters)) {
passiveRates[targetIndex][stat] += rate;
}
}
});
const baseParameterTotal = baseParameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const parameterTotal = parameters.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const passiveBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => [
stat,
Math.ceil(member[stat] * passiveRates[index][stat] / 100)
])) as Record<UnitStat, number>);
const passiveTotal = passiveBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const outfitDescription = plainSkillText(outfit?.description ?? '');
const outfitActive = conditionSatisfied(outfitDescription, members);
const outfitRate = outfitActive ? effectRate(outfitDescription) : 0;
const outfitStats = outfitActive ? affectedStats(outfitDescription) : [];
const outfitBonuses = parameters.map((member) => Object.fromEntries(stats.map((stat) => [
stat,
outfitStats.includes(stat) ? Math.ceil(member[stat] * outfitRate / 100) : 0
])) as Record<UnitStat, number>);
const outfitTotal = outfitBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const supportMatch = outfitDescription.match(/스코어 서포트 효과\s*(\d+(?:\.\d+)?)%/);
const outfitScoreSupportPercent = outfitActive ? Number(supportMatch?.[1] ?? 0) : 0;
const boardBonuses = parameters.map((member, index) => Object.fromEntries(stats.map((stat) => {
const cardId = members[index].card.id;
const flat = board?.flatByCardId[cardId]?.[stat] ?? 0;
const rate = board?.rateByCardId[cardId]?.[stat] ?? 0;
return [stat, flat + Math.ceil(member[stat] * rate / 100)];
})) as Record<UnitStat, number>);
const boardTotal = boardBonuses.reduce((total, member) => total + stats.reduce((sum, stat) => sum + member[stat], 0), 0);
const memoryTotal = parameters.reduce((total, member) => total + stats.reduce(
(sum, stat) => sum + Math.ceil(member[stat] * memoryPermil / 1000),
0
), 0);
const memberPowerUpTotal = parameters.reduce((total, member, index) => {
const memberBase = stats.reduce((sum, stat) => sum + member[stat] + passiveBonuses[index][stat] + outfitBonuses[index][stat] + boardBonuses[index][stat], 0);
return total + Math.ceil(memberBase * memberPowerUpPercent / 100);
}, 0);
return {
overallPower: parameterTotal + passiveTotal + outfitTotal + boardTotal + memoryTotal + memberPowerUpTotal,
baseParameterTotal,
potentialTotal: parameterTotal - baseParameterTotal,
passiveTotal,
outfitTotal,
boardTotal,
memoryTotal,
memberPowerUpTotal,
outfitScoreSupportPercent
};
}
+53
View File
@@ -0,0 +1,53 @@
export type CollectionCardState = {
owned: boolean;
awakening: number;
};
export type SavedDeck = {
id: string;
name: string;
cardIds: string[];
settings: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type CardListPreferences = {
types: string[];
stats: string[];
activeConditions: string[];
specialConditions: string[];
activeProbabilities: string[];
cardVariants: string[];
generations: string[];
};
export type BoardCardPlacements = Record<string, Record<string, string>>;
export type UserDataV1 = {
schemaVersion: 1;
updatedAt: string;
memoryCount: number;
memberPowerUpPercent: number;
collection: Record<string, CollectionCardState>;
memberBoards: Record<string, string[]>;
boardCards: BoardCardPlacements;
savedDecks: SavedDeck[];
preferences: CardListPreferences;
};
export type UserData = UserDataV1;
export function createUserData(preferences: CardListPreferences): UserDataV1 {
return {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
memoryCount: 0,
memberPowerUpPercent: 0,
collection: {},
memberBoards: {},
boardCards: {},
savedDecks: [],
preferences
};
}
+100
View File
@@ -0,0 +1,100 @@
import type { CardListPreferences, CollectionCardState, UserData } from '$lib/models/user-data';
import { createUserData } from '$lib/models/user-data';
export const USER_DATA_STORAGE_KEY = 'holodori:user-data:v1';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stringArray(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string')
? [...value]
: [...fallback];
}
function finiteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function parseCollection(value: Record<string, unknown>): UserData['collection'] {
return Object.fromEntries(
Object.entries(value).flatMap(([cardId, state]) => {
if (!isRecord(state)) return [];
const parsed: CollectionCardState = {
owned: state.owned === true,
awakening: Math.max(0, Math.min(5, Math.round(finiteNumber(state.awakening))))
};
return [[cardId, parsed]];
})
);
}
function parseMemberBoards(value: unknown): UserData['memberBoards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, string[]] =>
Array.isArray(entry[1]) && entry[1].every((nodeId) => typeof nodeId === 'string')
)
);
}
function parseBoardCards(value: unknown): UserData['boardCards'] {
if (!isRecord(value)) return {};
return Object.fromEntries(Object.entries(value).flatMap(([memberId, placements]) => {
if (!isRecord(placements)) return [];
return [[memberId, Object.fromEntries(
Object.entries(placements).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
)]];
}));
}
export function parseUserData(text: string, fallbackPreferences: CardListPreferences): UserData {
const value: unknown = JSON.parse(text);
if (!isRecord(value) || value.schemaVersion !== 1) {
throw new Error('지원하지 않는 백업 파일입니다.');
}
if (!isRecord(value.preferences) || !isRecord(value.collection) || !Array.isArray(value.savedDecks)) {
throw new Error('올바른 홀로도리 백업 파일이 아닙니다.');
}
const preferences = value.preferences;
const defaults = createUserData(fallbackPreferences);
return {
schemaVersion: 1,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : defaults.updatedAt,
memoryCount: Math.max(0, Math.round(finiteNumber(value.memoryCount))),
memberPowerUpPercent: Math.max(0, Math.min(50, finiteNumber(value.memberPowerUpPercent))),
collection: parseCollection(value.collection),
memberBoards: parseMemberBoards(value.memberBoards),
boardCards: parseBoardCards(value.boardCards),
savedDecks: value.savedDecks as UserData['savedDecks'],
preferences: {
types: stringArray(preferences.types, fallbackPreferences.types),
stats: stringArray(preferences.stats, fallbackPreferences.stats),
activeConditions: stringArray(preferences.activeConditions, fallbackPreferences.activeConditions),
specialConditions: stringArray(preferences.specialConditions, fallbackPreferences.specialConditions),
activeProbabilities: stringArray(preferences.activeProbabilities, fallbackPreferences.activeProbabilities),
cardVariants: stringArray(preferences.cardVariants, fallbackPreferences.cardVariants),
generations: stringArray(preferences.generations, fallbackPreferences.generations)
}
};
}
export function loadBrowserData(fallbackPreferences: CardListPreferences): UserData {
const stored = localStorage.getItem(USER_DATA_STORAGE_KEY);
return stored ? parseUserData(stored, fallbackPreferences) : createUserData(fallbackPreferences);
}
export function saveBrowserData(data: UserData): UserData {
const saved = { ...data, updatedAt: new Date().toISOString() } satisfies UserData;
localStorage.setItem(USER_DATA_STORAGE_KEY, JSON.stringify(saved));
return saved;
}
export function clearBrowserData(): void {
localStorage.removeItem(USER_DATA_STORAGE_KEY);
}
+33
View File
@@ -0,0 +1,33 @@
/// <reference lib="webworker" />
import { optimizeExactOrderAndCooldowns } from '../engine/index.ts';
import type { ExactOptimizeOptions, ExactSongChart, OptimizerCard } from '../engine/index.ts';
type OptimizeRequest = {
type: 'optimize-exact';
cards: OptimizerCard[];
chart: ExactSongChart;
perfectNoteScore: number;
directSongScoreBonusPercent?: number;
};
self.onmessage = (event: MessageEvent<OptimizeRequest>) => {
if (event.data.type !== 'optimize-exact') return;
try {
const options: ExactOptimizeOptions = {
perfectNoteScore: event.data.perfectNoteScore,
directSongScoreBonusPercent: event.data.directSongScoreBonusPercent,
onProgress: (progress) => self.postMessage({ type: 'progress', progress })
};
const result = optimizeExactOrderAndCooldowns(event.data.cards, event.data.chart, options);
self.postMessage({ type: 'result', result });
} catch (error) {
self.postMessage({
type: 'error',
message: error instanceof Error ? error.message : '분석 중 알 수 없는 오류가 발생했습니다.'
});
}
};
export {};
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<header class="site-nav">
<div class="nav-inner">
<a class="brand" href="/" aria-label="홀로도리 홈">홀로도리</a>
<nav aria-label="주요 메뉴">
<a href="/" aria-current={$page.url.pathname === '/' ? 'page' : undefined}>카드 목록</a>
<a href="/analysis" aria-current={$page.url.pathname.startsWith('/analysis') ? 'page' : undefined}>배치 분석</a>
<a href="/data" aria-current={$page.url.pathname.startsWith('/data') ? 'page' : undefined}>사용자 데이터</a>
</nav>
</div>
</header>
<slot />
<style>
:global(*) { box-sizing: border-box; }
:global(body) { margin: 0; background: #101319; color: #e8ebf1; font-family: Pretendard, "Noto Sans KR", sans-serif; }
.site-nav {
position: sticky;
z-index: 20;
top: 0;
border-bottom: 1px solid rgb(255 255 255 / 8%);
background: rgb(16 19 25 / 88%);
backdrop-filter: blur(14px);
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
width: min(1120px, calc(100% - 40px));
min-height: 58px;
margin: 0 auto;
}
.brand {
color: #f3f5f8;
font-size: 16px;
font-weight: 850;
letter-spacing: -.03em;
text-decoration: none;
}
nav { display: flex; align-items: center; gap: 4px; }
nav a {
padding: 8px 12px;
border-radius: 9px;
color: #929cad;
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
nav a:hover { color: #e2e6ed; background: rgb(255 255 255 / 5%); }
nav a[aria-current='page'] { color: #f1a1c3; background: rgb(239 139 182 / 12%); }
a:focus-visible { outline: 3px solid rgb(239 139 182 / 28%); outline-offset: 2px; }
@media (max-width: 560px) {
.nav-inner { width: calc(100% - 24px); min-height: 54px; }
.brand { font-size: 15px; }
nav a { padding: 7px 9px; font-size: 12px; }
}
</style>
+94 -2
View File
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json'; import cardSummary from '$lib/data/cards-summary.json';
import type { CardListPreferences, UserData } from '$lib/models/user-data';
import { loadBrowserData, saveBrowserData } from '$lib/storage/browser-storage';
type CardType = '큐트' | '퓨어' | '해피'; type CardType = '큐트' | '퓨어' | '해피';
type HighestStat = '퍼포먼스' | '테크닉' | '센스'; type HighestStat = '퍼포먼스' | '테크닉' | '센스';
@@ -58,6 +61,39 @@
let selectedGenerations = [...generationOptions]; let selectedGenerations = [...generationOptions];
let expandedCardId: string | null = null; let expandedCardId: string | null = null;
let selectedSkillLevel = 1; let selectedSkillLevel = 1;
let collection: UserData['collection'] = {};
let memberBoards: UserData['memberBoards'] = {};
let boardCards: UserData['boardCards'] = {};
let savedDecks: UserData['savedDecks'] = [];
let memoryCount = 0;
let memberPowerUpPercent = 0;
let storageReady = false;
let currentUserData: UserData;
$: currentUserData = {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
memoryCount,
memberPowerUpPercent,
collection,
memberBoards,
boardCards,
savedDecks,
preferences: currentPreferences()
};
$: if (storageReady) {
try {
saveBrowserData(currentUserData);
} catch {}
}
onMount(() => {
try {
applyUserData(loadBrowserData(defaultPreferences()));
storageReady = true;
} catch {}
});
$: filteredCards = cards.filter((card) => $: filteredCards = cards.filter((card) =>
selectedTypes.includes(card.type) selectedTypes.includes(card.type)
@@ -91,6 +127,46 @@
selectedGenerations = [...generationOptions]; selectedGenerations = [...generationOptions];
} }
function defaultPreferences(): CardListPreferences {
return {
types: [...typeOptions],
stats: [...statOptions],
activeConditions: [...skillConditionOptions],
specialConditions: [...skillConditionOptions],
activeProbabilities: [...activeProbabilityOptions],
cardVariants: [...cardVariantOptions],
generations: [...generationOptions]
};
}
function currentPreferences(): CardListPreferences {
return {
types: [...selectedTypes],
stats: [...selectedStats],
activeConditions: [...selectedActiveConditions],
specialConditions: [...selectedSpecialConditions],
activeProbabilities: [...selectedActiveProbabilities],
cardVariants: [...selectedCardVariants],
generations: [...selectedGenerations]
};
}
function applyUserData(data: UserData) {
selectedTypes = data.preferences.types.filter((value): value is CardType => typeOptions.includes(value as CardType));
selectedStats = data.preferences.stats.filter((value): value is HighestStat => statOptions.includes(value as HighestStat));
selectedActiveConditions = data.preferences.activeConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
selectedSpecialConditions = data.preferences.specialConditions.filter((value): value is SkillConditionStatus => skillConditionOptions.includes(value as SkillConditionStatus));
selectedActiveProbabilities = data.preferences.activeProbabilities.filter((value): value is ActiveProbability => activeProbabilityOptions.includes(value as ActiveProbability));
selectedCardVariants = data.preferences.cardVariants.filter((value) => cardVariantOptions.includes(value));
selectedGenerations = data.preferences.generations.filter((value) => generationOptions.includes(value));
collection = data.collection;
memberBoards = data.memberBoards;
boardCards = data.boardCards;
savedDecks = data.savedDecks;
memoryCount = data.memoryCount;
memberPowerUpPercent = data.memberPowerUpPercent;
}
function cardVariantStatus(card: CardSummary): string { function cardVariantStatus(card: CardSummary): string {
return card.versionName || '일반'; return card.versionName || '일반';
} }
@@ -192,6 +268,7 @@
<h1>카드 목록</h1> <h1>카드 목록</h1>
<p class="description">카드의 기본 정보를 한눈에 확인하세요.</p> <p class="description">카드의 기본 정보를 한눈에 확인하세요.</p>
</div> </div>
<div class="header-side">
<p class="total"> <p class="total">
<strong>{filteredCards.length}</strong> <strong>{filteredCards.length}</strong>
{#if filteredCards.length === cards.length} {#if filteredCards.length === cards.length}
@@ -200,6 +277,7 @@
<span>/ {cards.length}장</span> <span>/ {cards.length}장</span>
{/if} {/if}
</p> </p>
</div>
</header> </header>
<section class="filters" aria-labelledby="filter-heading"> <section class="filters" aria-labelledby="filter-heading">
@@ -547,7 +625,7 @@
main { main {
width: min(1120px, calc(100% - 40px)); width: min(1120px, calc(100% - 40px));
margin: 0 auto; margin: 0 auto;
padding: 64px 0 80px; padding: 36px 0 80px;
} }
.page-header { .page-header {
@@ -601,6 +679,13 @@
color: #8a92a2; color: #8a92a2;
} }
.header-side {
display: grid;
justify-items: end;
gap: 9px;
}
.filters { .filters {
margin-bottom: 20px; margin-bottom: 20px;
padding: 22px 24px 24px; padding: 22px 24px 24px;
@@ -1321,6 +1406,7 @@
color: #747e90; color: #747e90;
} }
.filters { .filters {
border-color: rgb(255 255 255 / 8%); border-color: rgb(255 255 255 / 8%);
background: rgb(22 28 39 / 84%); background: rgb(22 28 39 / 84%);
@@ -1511,6 +1597,7 @@
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column;
} }
.description { .description {
@@ -1518,7 +1605,12 @@
} }
.total { .total {
margin-top: 4px; margin: 0;
}
.header-side {
width: 100%;
justify-items: start;
} }
.filters { .filters {
+730
View File
@@ -0,0 +1,730 @@
<script lang="ts">
import { onMount } from 'svelte';
import cardSummary from '$lib/data/cards-summary.json';
import boardSummary from '$lib/data/boards-summary.json';
import memorySummary from '$lib/data/memory-summary.json';
import exactChartCatalog from '$lib/data/exact-chart-catalog.json';
import { calculateBoardEffects, calculateUnitPower, countOptimizationCandidates, memoryParameterPermil, perfectNoteScoreFromPower, toOptimizerCard } from '$lib/engine/index.ts';
import type { CooldownReduction, EffectiveSkillWindow, ExactOptimizationResult, ExactSongChart, OptimizationProgress, SkillWindow } from '$lib/engine/index.ts';
import { USER_DATA_STORAGE_KEY } from '$lib/storage/browser-storage';
type CardRow = (typeof cardSummary.cards)[number];
type DifficultyKey = 'hard' | 'expert';
type CollectionState = Record<string, { owned?: boolean; awakening?: number }>;
type MemberBoards = Record<string, string[]>;
type BoardCards = Record<string, Record<string, string>>;
type DisplayEffectiveWindow = SkillWindow & {
baseScoreUpPercent: number;
minScoreUpPercent: number;
maxScoreUpPercent: number;
maxOverlapCount: number;
};
type SavedAnalysisState = {
schemaVersion: 2;
songId: string;
difficulty: DifficultyKey;
cardIds: string[];
awakenings: number[];
fixedCooldownReductions?: (CooldownReduction | null)[];
leaderCharacterId: string;
leaderOutfitCardId: string;
};
const ANALYSIS_STORAGE_KEY = 'holodori.analysis.v2';
const LEGACY_ANALYSIS_STORAGE_KEY = 'holodori.analysis.v1';
const MEMBER_COLORS = ['#f08db8', '#64cbe4', '#f2b85b', '#a995f5', '#69cf9b'] as const;
const REDUCTION_LOCK_OPTIONS = [null, 0, 4, 8, 12] as const;
const fiveStarCards = cardSummary.cards.filter((card) => card.rarity === 5);
const charts = exactChartCatalog.charts;
const 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]));
function uniqueMemberCards(cards: readonly CardRow[], limit = Number.POSITIVE_INFINITY): CardRow[] {
const memberIds = new Set<string>();
return cards.filter((card) => {
if (memberIds.has(card.characterId)) return false;
memberIds.add(card.characterId);
return true;
}).slice(0, limit);
}
const leaderMembers = uniqueMemberCards(fiveStarCards);
let collection: CollectionState = {};
let memberBoards: MemberBoards = {};
let boardCards: BoardCards = {};
let memoryCount = 0;
let memberPowerUpPercent = 0;
let selectedSongId = songs[0]?.id ?? '';
let difficulty: DifficultyKey = 'expert';
let selectedCardIds = uniqueMemberCards(fiveStarCards, 5).map((card) => card.id);
let selectedAwakenings = selectedCardIds.map(() => 5);
let fixedCooldownReductions: (CooldownReduction | null)[] = selectedCardIds.map(() => null);
let leaderCharacterId = leaderMembers[0]?.characterId ?? '';
let leaderOutfitCardId = leaderMembers[0]?.id ?? '';
let worker: Worker | null = null;
let running = false;
let progress: OptimizationProgress = { evaluatedCandidates: 0, totalCandidates: 0 };
let result: ExactOptimizationResult | null = null;
let errorMessage = '';
let analysisReady = false;
$: selectedSong = songs.find((song) => song.id === selectedSongId) ?? songs[0];
$: selectedChart = charts.find((chart) => chart.songId === selectedSongId && chart.difficulty === difficulty);
$: leaderMember = leaderMembers.find((card) => card.characterId === leaderCharacterId);
$: leaderOutfitOptions = leaderMember
? fiveStarCards.filter((card) => card.characterId === leaderCharacterId && card.details.costume)
.filter((card, index, cards) => cards.findIndex((candidate) => candidate.details.costume?.name === card.details.costume?.name) === index)
: [];
$: leaderOutfit = cardById.get(leaderOutfitCardId)?.details.costume ?? null;
$: leaderOutfitDescription = (leaderOutfit?.description ?? '')
.replace(/\[[^\]]+\]/g, '')
.replace(/\s+/g, ' ')
.trim();
$: selectedSourceCards = selectedCardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
$: connectionCards = cardSummary.cards
.filter((card) => card.details.connect.length > 0)
.map((card) => ({
id: card.id,
awakening: collection[card.id]?.awakening ?? 0,
connect: card.details.connect
}));
$: boardEffects = calculateBoardEffects(boardSummary, memberBoards, selectedSourceCards, leaderCharacterId, selectedChart, {
placements: boardCards,
cards: connectionCards
});
$: memoryPermil = memoryParameterPermil(memoryCount, memorySummary.effects);
$: unitPower = (() => {
try {
return calculateUnitPower(selectedCardIds.map((id, index) => ({
card: cardById.get(id)!,
awakening: selectedAwakenings[index]
})), leaderOutfit, boardEffects, memoryPermil, memberPowerUpPercent);
} catch {
return { overallPower: 0, baseParameterTotal: 0, potentialTotal: 0, passiveTotal: 0, outfitTotal: 0, boardTotal: 0, memoryTotal: 0, memberPowerUpTotal: 0, outfitScoreSupportPercent: 0 };
}
})();
$: overallPower = unitPower.overallPower;
$: perfectNoteScore = perfectNoteScoreFromPower(overallPower, selectedChart?.scoreRatio ?? null);
$: timelineDuration = selectedChart?.durationSeconds ?? 0;
$: noteCountsBySecond = result ? countNotesBySecond(result, timelineDuration) : [];
$: displayedEffectiveActiveWindows = mergeEffectiveActiveWindows(result?.effectiveActiveWindows ?? []);
$: maxNotesPerSecond = Math.max(1, ...noteCountsBySecond);
$: timelineTicks = buildTimelineTicks(timelineDuration);
$: timelineWidth = Math.max(720, timelineDuration * 6);
$: candidateCount = (() => {
fixedCooldownReductions;
try {
const sourceCards = selectedSourceCards;
if (new Set(sourceCards.map((card) => card.characterId)).size !== 5) return 0;
const cards = sourceCards.map((card, index) => optimizerCardWithBoard(card, index, sourceCards));
return cards.length === 5 ? countOptimizationCandidates(cards) : 0;
} catch {
return 0;
}
})();
$: if (analysisReady) saveAnalysisState({
schemaVersion: 2,
songId: selectedSongId,
difficulty,
cardIds: selectedCardIds,
awakenings: selectedAwakenings,
fixedCooldownReductions,
leaderCharacterId,
leaderOutfitCardId
});
onMount(() => {
const applyStoredUserData = (stored: string | null) => {
const parsed = stored ? JSON.parse(stored) : null;
collection = parsed?.collection && typeof parsed.collection === 'object' ? parsed.collection : {};
memberBoards = parsed?.memberBoards && typeof parsed.memberBoards === 'object' ? parsed.memberBoards : {};
boardCards = parsed?.boardCards && typeof parsed.boardCards === 'object' ? parsed.boardCards : {};
memoryCount = typeof parsed?.memoryCount === 'number' && Number.isFinite(parsed.memoryCount)
? Math.max(0, Math.min(memorySummary.maxCount, Math.round(parsed.memoryCount)))
: 0;
memberPowerUpPercent = typeof parsed?.memberPowerUpPercent === 'number' && Number.isFinite(parsed.memberPowerUpPercent)
? Math.max(0, Math.min(50, parsed.memberPowerUpPercent))
: 0;
return parsed;
};
try {
const parsed = applyStoredUserData(localStorage.getItem(USER_DATA_STORAGE_KEY));
const owned = uniqueMemberCards(fiveStarCards.filter((card) => collection[card.id]?.owned), 5);
if (owned.length === 5) {
selectedCardIds = owned.map((card) => card.id);
selectedAwakenings = owned.map((card) => collection[card.id]?.awakening ?? 0);
}
} catch {
collection = {};
memberBoards = {};
boardCards = {};
memoryCount = 0;
memberPowerUpPercent = 0;
}
try {
const currentState = localStorage.getItem(ANALYSIS_STORAGE_KEY);
const legacyState = currentState ? null : localStorage.getItem(LEGACY_ANALYSIS_STORAGE_KEY);
restoreAnalysisState(currentState ?? legacyState, Boolean(legacyState));
if (legacyState) localStorage.removeItem(LEGACY_ANALYSIS_STORAGE_KEY);
} catch {
localStorage.removeItem(ANALYSIS_STORAGE_KEY);
} finally {
analysisReady = true;
}
worker = new Worker(new URL('../../lib/workers/score-optimizer.worker.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (event) => {
if (event.data.type === 'progress') progress = event.data.progress;
if (event.data.type === 'result') {
result = event.data.result;
running = false;
}
if (event.data.type === 'error') {
errorMessage = event.data.message;
running = false;
}
};
const syncStorage = (event: StorageEvent) => {
if (event.key !== USER_DATA_STORAGE_KEY) return;
try {
applyStoredUserData(event.newValue);
result = null;
} catch {
// 다른 탭에서 쓰기가 끝나지 않은 순간에는 기존 상태를 유지한다.
}
};
window.addEventListener('storage', syncStorage);
return () => {
window.removeEventListener('storage', syncStorage);
worker?.terminate();
};
});
function cardLabel(card: CardRow, awakening?: number): string {
const variant = card.versionName ? ` · ${card.versionName}` : '';
const growth = awakening === undefined ? '' : ` · ${awakening === 0 ? '명함' : `${awakening}개화`}`;
return `${card.name}${variant}${growth}`;
}
function saveAnalysisState(state: SavedAnalysisState): void {
try {
localStorage.setItem(ANALYSIS_STORAGE_KEY, JSON.stringify(state));
} catch {
// 저장소 사용이 차단된 환경에서도 분석 기능은 계속 동작한다.
}
}
function normalizeAwakening(value: unknown): number {
const awakening = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
return Math.max(0, Math.min(5, awakening));
}
function restoreAnalysisState(stored: string | null, legacy = false): void {
if (!stored) return;
const state = JSON.parse(stored) as Partial<SavedAnalysisState>;
if (!legacy && state.schemaVersion !== 2) return;
if (typeof state.songId === 'string' && songs.some((song) => song.id === state.songId)) selectedSongId = state.songId;
if (state.difficulty === 'hard' || state.difficulty === 'expert') difficulty = state.difficulty;
if (Array.isArray(state.cardIds) && state.cardIds.length === 5) {
const restoredCards = state.cardIds.map((id) => cardById.get(id)).filter((card): card is CardRow => Boolean(card));
if (restoredCards.length === 5 && new Set(restoredCards.map((card) => card.characterId)).size === 5) {
selectedCardIds = restoredCards.map((card) => card.id);
selectedAwakenings = restoredCards.map((card, index) => {
if (legacy && collection[card.id]?.owned) return normalizeAwakening(collection[card.id]?.awakening);
return normalizeAwakening(state.awakenings?.[index]);
});
fixedCooldownReductions = restoredCards.map((_, index) => normalizeFixedReduction(state.fixedCooldownReductions?.[index]));
}
}
if (typeof state.leaderCharacterId === 'string' && leaderMembers.some((member) => member.characterId === state.leaderCharacterId)) {
leaderCharacterId = state.leaderCharacterId;
const validOutfit = typeof state.leaderOutfitCardId === 'string'
? cardById.get(state.leaderOutfitCardId)
: undefined;
leaderOutfitCardId = validOutfit?.characterId === leaderCharacterId && validOutfit.details.costume
? validOutfit.id
: fiveStarCards.find((card) => card.characterId === leaderCharacterId && card.details.costume)?.id ?? '';
}
}
function updateAwakening(slot: number, awakening: number): void {
const normalized = normalizeAwakening(awakening);
selectedAwakenings = selectedAwakenings.map((value, index) => index === slot ? normalized : value);
result = null;
}
function normalizeFixedReduction(value: unknown): CooldownReduction | null {
return value === 0 || value === 4 || value === 8 || value === 12 ? value : null;
}
function updateFixedReduction(slot: number, reduction: CooldownReduction | null): void {
fixedCooldownReductions = fixedCooldownReductions.map((value, index) => index === slot ? reduction : value);
result = null;
}
function memberSelectedInAnotherSlot(card: CardRow, slot: number): boolean {
return selectedCardIds.some((id, index) => index !== slot && cardById.get(id)?.characterId === card.characterId);
}
function updateCard(slot: number, cardId: string): void {
selectedCardIds = selectedCardIds.map((id, index) => (index === slot ? cardId : id));
if (collection[cardId]?.owned) updateAwakening(slot, normalizeAwakening(collection[cardId]?.awakening));
result = null;
}
function updateLeader(characterId: string): void {
leaderCharacterId = characterId;
const outfit = fiveStarCards.find((card) => card.characterId === characterId && card.details.costume);
leaderOutfitCardId = outfit?.id ?? '';
result = null;
}
function updateOutfit(cardId: string): void {
leaderOutfitCardId = cardId;
result = null;
}
function optimizerCardWithBoard(card: CardRow, index: number, unitMembers: readonly CardRow[]) {
const fixedReduction = fixedCooldownReductions[index];
const adapted = toOptimizerCard(
card,
selectedAwakenings[index] ?? 0,
fixedReduction === null || fixedReduction === undefined
? boardEffects.allowedCooldownReductionsByCardId[card.id]
: [fixedReduction],
unitMembers
);
const activationRate = boardEffects.activationRateUpByCardId[card.id] ?? 0;
return {
...adapted,
active: {
...adapted.active,
probabilityPercent: (adapted.active.probabilityPercent ?? 100) * (1 + activationRate / 100)
},
scoreSupportPercent: unitPower.outfitScoreSupportPercent + boardEffects.scoreSupportPercent
};
}
async function analyze(): Promise<void> {
errorMessage = '';
result = null;
if (!worker || !selectedSong || !selectedChart) return;
if (new Set(selectedCardIds).size !== 5) {
errorMessage = '서로 다른 카드 5장을 선택해 주세요.';
return;
}
const selectedCards = selectedCardIds.map((id) => cardById.get(id));
if (new Set(selectedCards.map((card) => card?.characterId)).size !== 5) {
errorMessage = '같은 멤버의 일반 카드와 이격 카드는 함께 편성할 수 없습니다.';
return;
}
const unitMembers = selectedCards.filter((card): card is CardRow => Boolean(card));
try {
const cards = selectedCardIds.map((id, index) => {
const card = cardById.get(id);
if (!card) throw new Error('선택한 카드 정보를 찾을 수 없습니다.');
return optimizerCardWithBoard(card, index, unitMembers);
});
if (!Number.isFinite(overallPower) || overallPower <= 0) throw new Error('선택한 카드의 종합력을 계산하지 못했습니다.');
const response = await fetch(selectedChart.path);
if (!response.ok) throw new Error('정확 채보 데이터를 불러오지 못했습니다.');
const chart = await response.json() as ExactSongChart;
running = true;
progress = { evaluatedCandidates: 0, totalCandidates: countOptimizationCandidates(cards) };
worker.postMessage({
type: 'optimize-exact',
cards,
chart,
perfectNoteScore,
directSongScoreBonusPercent: boardEffects.directSongScoreBonusPercent
});
} catch (error) {
errorMessage = error instanceof Error ? error.message : '분석 입력을 준비하지 못했습니다.';
}
}
function resultCardName(cardId: string): string {
const card = cardById.get(cardId);
return card ? cardLabel(card) : cardId;
}
function countNotesBySecond(scoreResult: ExactOptimizationResult, durationSeconds: number): number[] {
const counts = Array.from({ length: Math.ceil(durationSeconds) }, () => 0);
for (const note of scoreResult.notes) {
const second = Math.floor(note.timeSeconds);
if (second >= 0 && second < counts.length) counts[second] += 1;
}
return counts;
}
function buildTimelineTicks(durationSeconds: number): number[] {
if (durationSeconds <= 0) return [];
const interval = 10;
const ticks = Array.from({ length: Math.floor(durationSeconds / interval) + 1 }, (_, index) => index * interval);
if (ticks.at(-1) !== durationSeconds) ticks.push(durationSeconds);
return ticks;
}
function timelinePosition(seconds: number): number {
return timelineDuration > 0 ? Math.max(0, Math.min(100, seconds / timelineDuration * 100)) : 0;
}
function skillWindowStyle(startSeconds: number, endSeconds: number): string {
return `left: ${timelinePosition(startSeconds)}%; width: ${Math.max(0, timelinePosition(endSeconds) - timelinePosition(startSeconds))}%;`;
}
function timelineTrackStyle(): string {
return `width: ${timelineWidth}px; --time-grid-step: ${timelinePosition(10)}%;`;
}
function memberColor(cardId: string): string {
const index = result?.order.indexOf(cardId) ?? -1;
return MEMBER_COLORS[index >= 0 ? index % MEMBER_COLORS.length : 0];
}
function coloredWindowStyle(window: SkillWindow): string {
return `${skillWindowStyle(window.startSeconds, window.endSeconds)} --member-color: ${memberColor(window.cardId)};`;
}
function mergeEffectiveActiveWindows(windows: readonly EffectiveSkillWindow[]): DisplayEffectiveWindow[] {
const merged: DisplayEffectiveWindow[] = [];
for (const window of windows) {
const previous = merged.at(-1);
if (previous?.cardId === window.cardId && Math.abs(previous.endSeconds - window.startSeconds) < 0.001) {
previous.endSeconds = window.endSeconds;
previous.minScoreUpPercent = Math.min(previous.minScoreUpPercent, window.scoreUpPercent);
previous.maxScoreUpPercent = Math.max(previous.maxScoreUpPercent, window.scoreUpPercent);
previous.maxOverlapCount = Math.max(previous.maxOverlapCount, window.overlapCount);
continue;
}
merged.push({
...window,
minScoreUpPercent: window.scoreUpPercent,
maxScoreUpPercent: window.scoreUpPercent,
maxOverlapCount: window.overlapCount
});
}
return merged;
}
function effectivePercentLabel(window: DisplayEffectiveWindow): string {
const minimum = Number(window.minScoreUpPercent.toFixed(1));
const maximum = Number(window.maxScoreUpPercent.toFixed(1));
return minimum === maximum ? `${minimum}%` : `${minimum}~${maximum}%`;
}
function formatSeconds(seconds: number): string {
return `${Number(seconds.toFixed(1)).toLocaleString('ko-KR')}초`;
}
</script>
<svelte:head><title>배치 분석 · 홀로도리</title></svelte:head>
<main>
<header class="page-header">
<div>
<h1>배치 분석</h1>
</div>
</header>
<section class="panel inputs" aria-labelledby="input-title">
<div class="section-title">
<div><span>01</span><h2 id="input-title">분석 조건</h2></div>
<strong>{candidateCount.toLocaleString('ko-KR')}개 후보</strong>
</div>
<div class="song-fields">
<label>악곡
<select bind:value={selectedSongId} on:change={() => result = null}>
{#each songs as song}<option value={song.id}>{song.title}</option>{/each}
</select>
</label>
<label>난이도
<select bind:value={difficulty} on:change={() => result = null}>
<option value="expert">Expert</option>
<option value="hard">Hard</option>
</select>
</label>
<div class="power-summary"><span>자동 계산 종합력</span><strong>{overallPower.toLocaleString('ko-KR')}</strong></div>
<div class="chart-meta"><span>길이 {selectedChart?.durationSeconds ?? 0}초</span><span>노트 {selectedChart?.totalNotes?.toLocaleString('ko-KR') ?? 0}개</span><span>1노트 {Math.ceil(perfectNoteScore).toLocaleString('ko-KR')}점</span>{#if boardEffects.directSongScoreBonusPercent > 0}<span>가창 보너스 {boardEffects.directSongScoreBonusPercent}%</span>{/if}</div>
</div>
<div class="card-fields">
{#each selectedCardIds as selectedId, slot}
<div class="card-field">
<label><span>{slot + 1}번 선택</span>
<select value={selectedId} on:change={(event) => updateCard(slot, event.currentTarget.value)}>
{#each fiveStarCards as card}
<option value={card.id} disabled={memberSelectedInAnotherSlot(card, slot)}>{collection[card.id]?.owned ? '● ' : ''}{cardLabel(card)}</option>
{/each}
</select>
</label>
<div class="awakening">
<span>개화</span>
<div class="awakening-stepper">
<button
type="button"
disabled={selectedAwakenings[slot] <= 0}
aria-label={`${slot + 1}번 카드 개화 단계 낮추기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] - 1)}
>‹</button>
<strong aria-live="polite">{selectedAwakenings[slot]}</strong>
<button
type="button"
disabled={selectedAwakenings[slot] >= 5}
aria-label={`${slot + 1}번 카드 개화 단계 높이기`}
on:click={() => updateAwakening(slot, selectedAwakenings[slot] + 1)}
>›</button>
</div>
</div>
<div class="cooldown-lock">
<span>빈도 감소 고정</span>
<div class="reduction-options">
{#each REDUCTION_LOCK_OPTIONS as reduction}
<button
type="button"
class:active={fixedCooldownReductions[slot] === reduction}
aria-pressed={fixedCooldownReductions[slot] === reduction}
on:click={() => updateFixedReduction(slot, reduction)}
>{reduction === null ? '미사용' : `${reduction}%`}</button>
{/each}
</div>
</div>
</div>
{/each}
</div>
<div class="leader-fields">
<label>리더
<select value={leaderCharacterId} on:change={(event) => updateLeader(event.currentTarget.value)}>
{#each leaderMembers as member}<option value={member.characterId}>{member.name}</option>{/each}
</select>
</label>
<label>리더 의상
<select value={leaderOutfitCardId} on:change={(event) => updateOutfit(event.currentTarget.value)}>
{#each leaderOutfitOptions as card}<option value={card.id}>{card.details.costume?.name}</option>{/each}
</select>
</label>
</div>
<div class="outfit-effect">
<span>리더 의상 효과</span>
<p>{leaderOutfitDescription || '적용할 수 있는 5성 의상이 없습니다.'}</p>
</div>
<p class="power-detail">최대 레벨 기준 · 기본 {unitPower.baseParameterTotal.toLocaleString('ko-KR')} + 개화 {unitPower.potentialTotal.toLocaleString('ko-KR')} + 패시브 {unitPower.passiveTotal.toLocaleString('ko-KR')} + 의상 {unitPower.outfitTotal.toLocaleString('ko-KR')} + 보드 {unitPower.boardTotal.toLocaleString('ko-KR')} + 메모리 {unitPower.memoryTotal.toLocaleString('ko-KR')} ({memoryCount}개) + 멤버 강화 {unitPower.memberPowerUpTotal.toLocaleString('ko-KR')} ({memberPowerUpPercent.toLocaleString('ko-KR')}%)</p>
{#if errorMessage}<p class="error" role="alert">{errorMessage}</p>{/if}
<button class="analyze" type="button" disabled={running || candidateCount === 0} on:click={analyze}>
{running ? `분석 중 ${Math.round(progress.evaluatedCandidates / Math.max(1, progress.totalCandidates) * 100)}%` : '최적 배치 분석'}
</button>
{#if running}<progress value={progress.evaluatedCandidates} max={progress.totalCandidates}></progress>{/if}
</section>
<section class="panel result" aria-labelledby="result-title">
<div class="section-title"><div><span>02</span><h2 id="result-title">분석 결과</h2></div></div>
{#if result}
<ol class="order-list">
{#each result.order as cardId, slot}
<li><span class="slot">{slot + 1}</span><strong>{resultCardName(cardId)}</strong><span class="reduction">쿨타임 {result.cooldownReductions[cardId]}% 감소</span></li>
{/each}
</ol>
<div class="score-grid">
<div class="score-summary"><span>예상 점수</span><strong>{result.expectedScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>이론상 고점</span><strong>{result.theoreticalMaxScore.toLocaleString('ko-KR')}</strong></div>
<div class="score-summary"><span>스킬 미발동 기준</span><strong>{result.baselineScore.toLocaleString('ko-KR')}</strong></div>
</div>
<section class="skill-timeline" aria-labelledby="timeline-title">
<div class="timeline-heading">
<div><h3 id="timeline-title">스킬 발동과 노트 분포</h3><p>멤버별 액티브와 실제 적용 구간, 스페셜 순서를 같은 시간축에서 비교합니다.</p></div>
<div class="timeline-legend" aria-label="멤버 색상 범례">
{#each result.order as cardId}<span><i style={`background: ${memberColor(cardId)};`}></i>{resultCardName(cardId)}</span>{/each}
</div>
</div>
<div class="timeline-scroll" role="region" aria-label="스킬 발동 시간대 그래프">
<div class="timeline-canvas" style={`width: ${timelineWidth + 156}px;`}>
<div class="timeline-row note-row">
<strong class="lane-label">초당 노트</strong>
<div class="timeline-track note-track" style={timelineTrackStyle()} aria-label={`최대 초당 ${maxNotesPerSecond}개`}>
{#each noteCountsBySecond as count, second}
<span class="note-bar" style={`left: ${timelinePosition(second)}%; width: ${100 / Math.max(1, noteCountsBySecond.length)}%; height: ${count / maxNotesPerSecond * 100}%; opacity: ${count === 0 ? 0 : 0.18 + count / maxNotesPerSecond * 0.82};`} title={`${second}~${second + 1}초 · ${count}개`}></span>
{/each}
</div>
</div>
{#each result.order as cardId, slot}
<div class="timeline-row member-row">
<strong class="lane-label"><small style={`--member-color: ${memberColor(cardId)};`}>{slot + 1}</small>{resultCardName(cardId)}</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.activeWindows.filter((window) => window.cardId === cardId) as window}
<span
class="skill-window active-window"
style={coloredWindowStyle(window)}
title={`액티브 ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img"
aria-label={`액티브 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지`}
></span>
{/each}
</div>
</div>
{/each}
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">최종 액티브</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each displayedEffectiveActiveWindows as window}
<span
class="skill-window effective-window"
style={coloredWindowStyle(window)}
title={`${resultCardName(window.cardId)} 액티브 적용 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · 기본 ${window.baseScoreUpPercent}% → 서포트 반영 ${effectivePercentLabel(window)}${window.maxOverlapCount > 1 ? ` · 최대 ${window.maxOverlapCount}명 중 가장 높은 효과` : ''}`}
role="img"
aria-label={`${resultCardName(window.cardId)} 액티브가 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row summary-row">
<strong class="lane-label summary-label">스페셜</strong>
<div class="timeline-track skill-track" style={timelineTrackStyle()}>
{#each result.specialWindows as window}
<span
class="skill-window special-window"
style={coloredWindowStyle(window)}
title={`${resultCardName(window.cardId)} 스페셜 · ${formatSeconds(window.startSeconds)}~${formatSeconds(window.endSeconds)} · ${window.scoreUpPercent}%`}
role="img"
aria-label={`${resultCardName(window.cardId)} 스페셜이 ${formatSeconds(window.startSeconds)}부터 ${formatSeconds(window.endSeconds)}까지 적용`}
></span>
{/each}
</div>
</div>
<div class="timeline-row axis-row" aria-hidden="true">
<span class="lane-label axis-label"></span>
<div class="timeline-track time-axis" style={timelineTrackStyle()}>
{#each timelineTicks as second}<span style={`left: ${timelinePosition(second)}%;`}>{second}초</span>{/each}
</div>
</div>
</div>
</div>
</section>
{:else}
<div class="empty">조건을 선택하고 분석을 시작하면 최적 배치와 각 멤버의 쿨타임 감소량이 여기에 표시됩니다.</div>
{/if}
</section>
</main>
<style>
main { width: min(1080px, calc(100% - 40px)); margin: 0 auto; padding: 46px 0 80px; }
.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
h1 { margin: 0; color: #f3f5f8; font-size: clamp(28px, 5vw, 42px); letter-spacing: -.045em; }
.panel { margin-top: 16px; padding: 24px; border: 1px solid #2a303a; border-radius: 16px; background: #171b22; }
.section-title, .section-title > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.section-title > div { justify-content: flex-start; }
.section-title span { color: #ef8cb6; font-size: 11px; font-weight: 900; }
h2 { margin: 0; font-size: 18px; }
.section-title > strong { color: #aeb6c4; font-size: 12px; }
label { display: grid; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
select { width: 100%; min-width: 0; height: 42px; padding: 0 12px; border: 1px solid #343c49; border-radius: 9px; color: #e8ebf1; background: #11151b; font: inherit; }
select:focus { border-color: #d86f9c; outline: 3px solid rgb(216 111 156 / 14%); }
.song-fields { display: grid; grid-template-columns: minmax(0, 2fr) minmax(110px, .55fr) minmax(140px, .7fr); align-items: end; gap: 12px; margin-top: 22px; }
.power-summary { display: grid; gap: 4px; min-height: 42px; padding: 7px 11px; border-radius: 9px; background: #22202a; }
.power-summary span { color: #929cab; font-size: 10px; font-weight: 750; }
.power-summary strong { color: #f2a3c4; font-size: 16px; }
.chart-meta { display: flex; gap: 8px; height: 42px; align-items: center; }
.chart-meta span { padding: 7px 9px; border-radius: 7px; color: #adb5c2; background: #202631; font-size: 11px; white-space: nowrap; }
.card-fields { display: grid; gap: 9px; margin-top: 18px; }
.card-field { display: grid; grid-template-columns: minmax(0, 1fr) 130px 304px; gap: 9px; }
.card-field > label { grid-template-columns: 74px minmax(0, 1fr); align-items: center; }
.card-field > .awakening { display: grid; grid-template-columns: 32px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
.awakening-stepper { display: grid; grid-template-columns: 32px minmax(0, 1fr) 32px; align-items: center; height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.awakening-stepper button { align-self: stretch; padding: 0; border: 0; color: #c1c8d3; background: #202630; font-size: 24px; line-height: 1; cursor: pointer; }
.awakening-stepper button:hover:not(:disabled) { color: #f5afcc; background: #2d2530; }
.awakening-stepper button:focus-visible { position: relative; z-index: 1; outline: 2px solid #df74a2; outline-offset: -2px; }
.awakening-stepper button:disabled { color: #525b69; background: #171c24; cursor: default; }
.awakening-stepper strong { color: #e8ebf1; font-size: 12px; text-align: center; white-space: nowrap; }
.cooldown-lock { display: grid; grid-template-columns: 88px minmax(0, 1fr); align-items: center; gap: 7px; color: #8f98a8; font-size: 12px; font-weight: 750; }
.reduction-options { display: grid; grid-template-columns: 1.35fr repeat(4, 1fr); height: 42px; overflow: hidden; border: 1px solid #343c49; border-radius: 9px; background: #11151b; }
.reduction-options button { min-width: 0; padding: 0 6px; border: 0; border-left: 1px solid #2d3440; color: #8f98a8; background: transparent; font-size: 11px; font-weight: 750; cursor: pointer; }
.reduction-options button:first-child { border-left: 0; }
.reduction-options button:hover { color: #e9edf3; background: #202630; }
.reduction-options button.active { color: #171019; background: #ed8bb5; }
.reduction-options button:focus-visible { position: relative; z-index: 1; outline: 2px solid #f1a0c2; outline-offset: -2px; }
.leader-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 16px; }
.outfit-effect { display: grid; grid-template-columns: 100px minmax(0, 1fr); gap: 12px; margin-top: 9px; padding: 11px 13px; border: 1px solid #2d3440; border-radius: 9px; background: #12161c; }
.outfit-effect span { color: #d993b1; font-size: 11px; font-weight: 850; }
.outfit-effect p { margin: 0; color: #b9c1cd; font-size: 12px; line-height: 1.55; }
.power-detail { margin: 10px 0 0; color: #858f9f; font-size: 11px; text-align: right; }
.analyze { width: 100%; height: 46px; margin-top: 20px; border: 0; border-radius: 10px; color: #171019; background: #ed8bb5; font-weight: 900; cursor: pointer; }
.analyze:hover:not(:disabled) { background: #f2a0c2; }
.analyze:disabled { opacity: .55; cursor: default; }
progress { display: block; width: 100%; height: 4px; margin-top: 8px; accent-color: #ed8bb5; }
.error { margin: 16px 0 0; color: #ff9f9f; font-size: 13px; }
.order-list { display: grid; gap: 8px; padding: 0; margin: 22px 0 0; list-style: none; }
.order-list li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 12px; min-height: 50px; padding: 8px 12px; border: 1px solid #2d3440; border-radius: 10px; background: #12161c; }
.slot { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #171019; background: #e98ab2; font-weight: 900; }
.order-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reduction { padding: 6px 8px; border-radius: 7px; color: #b9c1cd; background: #252c36; font-size: 11px; }
.score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 14px; }
.score-summary { display: grid; gap: 7px; padding: 16px; border-radius: 10px; background: #22202a; }
.score-summary span { color: #a9b1be; font-size: 12px; }
.score-summary strong { color: #f2a3c4; font-size: 20px; }
.empty { color: #858f9f; font-size: 12px; line-height: 1.65; }
.skill-timeline { margin-top: 22px; padding-top: 20px; border-top: 1px solid #2b313c; }
.timeline-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 14px; }
.timeline-heading h3 { margin: 0; color: #e8ebf1; font-size: 16px; }
.timeline-heading p { margin: 6px 0 0; color: #838d9d; font-size: 11px; }
.timeline-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; color: #99a2b1; font-size: 10px; }
.timeline-legend span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.timeline-legend i { display: inline-block; width: 9px; height: 9px; border-radius: 50%; }
.timeline-scroll { overflow-x: auto; border: 1px solid #2c3440; border-radius: 11px; background: #10141a; scrollbar-width: thin; }
.timeline-canvas { min-width: 100%; padding: 12px 12px 8px 0; }
.timeline-row { display: flex; min-height: 42px; }
.timeline-row + .timeline-row { border-top: 1px solid rgb(255 255 255 / 5%); }
.lane-label { position: sticky; left: 0; z-index: 4; display: flex; align-items: center; gap: 7px; flex: 0 0 144px; min-width: 144px; padding: 0 10px 0 12px; overflow: hidden; color: #bac2ce; background: #141920; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.lane-label small { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border: 1px solid color-mix(in srgb, var(--member-color) 65%, transparent); border-radius: 5px; color: var(--member-color); background: color-mix(in srgb, var(--member-color) 12%, transparent); font-size: 9px; }
.timeline-track { position: relative; flex: none; }
.timeline-track::before { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgb(255 255 255 / 9%) 1px, transparent 1px); background-size: var(--time-grid-step, 10%) 100%; pointer-events: none; }
.note-row { min-height: 78px; }
.note-track { height: 62px; align-self: center; border-bottom: 1px solid #465062; }
.note-bar { position: absolute; bottom: 0; min-height: 1px; border-right: 1px solid #10141a; background: #8ca9d7; }
.skill-track { height: 42px; }
.skill-window { position: absolute; min-width: 2px; border-radius: 3px; background: var(--member-color); }
.active-window { top: 14px; height: 13px; opacity: .72; }
.summary-row { background: rgb(255 255 255 / 1.5%); }
.summary-label { color: #e1e5ec; font-weight: 800; }
.effective-window { top: 11px; height: 20px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 28%); }
.special-window { top: 13px; height: 16px; box-shadow: inset 0 0 0 1px rgb(255 255 255 / 20%); }
.axis-row { min-height: 28px; border-top: 1px solid #343c49 !important; }
.axis-label { color: #778293; font-size: 10px; font-weight: 750; }
.time-axis { height: 28px; }
.time-axis::before { display: none; }
.time-axis span { position: absolute; top: 7px; color: #747f90; font-size: 9px; transform: translateX(-50%); white-space: nowrap; }
.time-axis span:first-child { transform: none; }
.time-axis span:last-child { transform: translateX(-100%); }
.empty { display: grid; place-items: center; min-height: 150px; margin-top: 18px; border: 1px dashed #303744; border-radius: 12px; padding: 28px; text-align: center; }
@media (max-width: 700px) {
main { width: calc(100% - 24px); padding-top: 28px; }
.page-header { align-items: flex-start; }
.panel { padding: 17px; }
.song-fields { grid-template-columns: 1fr 100px; }
.power-summary { grid-column: 1 / -1; }
.chart-meta { grid-column: 1 / -1; height: auto; }
.card-field { grid-template-columns: minmax(0, 1fr) 102px; }
.card-field > label { grid-template-columns: 58px minmax(0, 1fr); }
.card-field > .awakening { grid-template-columns: 1fr; }
.awakening-stepper { grid-template-columns: 28px minmax(0, 1fr) 28px; }
.cooldown-lock { grid-column: 1 / -1; grid-template-columns: 88px minmax(0, 1fr); }
.leader-fields { grid-template-columns: 1fr; }
.outfit-effect { grid-template-columns: 1fr; gap: 5px; }
.order-list li { grid-template-columns: 30px minmax(0, 1fr); }
.reduction { grid-column: 2; justify-self: start; }
.score-grid { grid-template-columns: 1fr; }
.timeline-heading { align-items: flex-start; flex-direction: column; }
.timeline-legend { justify-content: flex-start; }
.lane-label { flex-basis: 118px; min-width: 118px; }
.timeline-canvas { padding-right: 8px; }
}
</style>
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import UserDataPage from '../+page.svelte';
</script>
<UserDataPage />
+5
View File
@@ -0,0 +1,5 @@
<script lang="ts">
import UserDataPage from '../+page.svelte';
</script>
<UserDataPage />
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