feat: complete placement analysis workflow
This commit is contained in:
@@ -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}개 정확 채보를 저장했습니다.`);
|
||||
@@ -10,6 +10,7 @@ const rawRoot = path.join(projectRoot, 'data', 'raw', 'holodoridb');
|
||||
const outputRoot = path.join(projectRoot, 'data', 'holodoridb');
|
||||
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
|
||||
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
|
||||
const offline = process.argv.includes('--offline');
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const values = [];
|
||||
@@ -97,12 +98,18 @@ const masterTableNames = [
|
||||
'LiveSkillTrigger',
|
||||
'LiveSkillEffectTarget',
|
||||
'LiveSkillEffectFilterCategory',
|
||||
'Poster',
|
||||
'PosterCollectEffect',
|
||||
'SkillTreeConnectEffect',
|
||||
'SkillTreeConnectEffectExtent',
|
||||
'SkillTreeEffect',
|
||||
'SkillTreeEffectPassiveTrigger',
|
||||
'SkillTreeEffectTarget',
|
||||
'SkillTreeEffectValueLimit'
|
||||
'SkillTreeEffectValueLimit',
|
||||
'SkillTreeNode',
|
||||
'SkillTreeNodeAutoSelection',
|
||||
'SkillTreeNodePosition',
|
||||
'SkillTreePoint'
|
||||
];
|
||||
|
||||
const localizationTableNames = [
|
||||
@@ -148,8 +155,12 @@ async function prepareSource(locale, source) {
|
||||
|
||||
try {
|
||||
gitAt(repositoryPath, ['rev-parse', '--git-dir']);
|
||||
process.stdout.write(`Updating ${locale} source...\n`);
|
||||
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
|
||||
if (offline) {
|
||||
process.stdout.write(`Using cached ${locale} source...\n`);
|
||||
} else {
|
||||
process.stdout.write(`Updating ${locale} source...\n`);
|
||||
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
|
||||
}
|
||||
} catch {
|
||||
process.stdout.write(`Cloning ${locale} source...\n`);
|
||||
await mkdir(path.dirname(repositoryPath), { recursive: true });
|
||||
@@ -331,6 +342,9 @@ const triggersByGroup = groupBy(tables.LiveSkillTrigger, 'groupId');
|
||||
const effectTargetsById = indexBy(tables.LiveSkillEffectTarget);
|
||||
const connectEffectsById = groupBy(tables.SkillTreeConnectEffect, 'id');
|
||||
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 sdCostumesById = indexBy(tables.SdCostume);
|
||||
const leaderSkillsById = indexBy(tables.LiveLeaderSkill);
|
||||
@@ -588,9 +602,17 @@ const cardSummary = {
|
||||
const cardOverride = cardOverrides.get(card.id);
|
||||
const highestStat = Object.entries(statLabels)
|
||||
.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 {
|
||||
id: card.id,
|
||||
characterId: card.characterId,
|
||||
memberOrder: character.order,
|
||||
rarity: Number(compactEnum(card.rarity)),
|
||||
type: typeLabels[compactEnum(card.attributeType)],
|
||||
@@ -598,11 +620,24 @@ const cardSummary = {
|
||||
versionName: cardOverride?.versionName || '',
|
||||
generation: textLookups.kor.get(character.regularCharacterGroupingNameLangId) || '',
|
||||
highestStat,
|
||||
power: {
|
||||
maxLevel: Number(maxLevel?.level ?? 0),
|
||||
statsAtMaxLevel
|
||||
},
|
||||
details: {
|
||||
title: textLookups.kor.get(card.nameLangId),
|
||||
passive: summarizeSkillLevels(cardRecord.skills.passive.levels),
|
||||
active: summarizeConditionalSkillLevels(cardRecord.skills.active.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
|
||||
? {
|
||||
name: cardRecord.rewards.costume.translations?.name?.kor ?? '',
|
||||
@@ -622,16 +657,103 @@ const cardSummary = {
|
||||
.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(appDataRoot, { recursive: true });
|
||||
await Promise.all([
|
||||
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, '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(`Rarities: ${JSON.stringify(rarityCounts)}\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`);
|
||||
|
||||
@@ -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}개 채보에 가창자 정보를 추가했습니다.`);
|
||||
@@ -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}에 저장했습니다.`);
|
||||
Reference in New Issue
Block a user