data: switch to recovered game DB and refresh cards and charts
Publish GitHub Pages output / publish (push) Successful in 44s

This commit is contained in:
2026-09-29 22:48:08 +09:00
parent 5783072b12
commit 07b98db520
1259 changed files with 11228 additions and 6809 deletions
+14
View File
@@ -0,0 +1,14 @@
import { execFileSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameDbRoot, prepareGameDbSource } from './game-db-source.mjs';
const source = prepareGameDbSource();
for (const script of ['collect-game-db.mjs', 'collect-holo-work-achievements.mjs']) {
execFileSync(process.execPath, [fileURLToPath(new URL(script, import.meta.url))], { stdio: 'inherit' });
}
if (prepareGameDbSource().manifestSha256 !== source.manifestSha256) throw new Error('DB changed during collection');
// Only a successful application advances the review baseline.
writeFileSync(path.join(gameDbRoot, 'applied.json'), JSON.stringify({ schemaVersion: 1,
snapshot: source.snapshot, manifestSha256: source.manifestSha256 }, null, 2) + '\n');
+4 -4
View File
@@ -3,12 +3,12 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import vm from 'node:vm';
import { execFileSync } from 'node:child_process';
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
import { prepareGameDbSource, readGameDbText } from './game-db-source.mjs';
import { susScoreWeight } from './sus-score-weight.mjs';
const source = prepareHolodoriDbSource('kor', { review: true });
const music = new Map(JSON.parse(readHolodoriDbText(source, 'Music.json')).map(({ data }) => [data.id, data]));
const titles = new Map(JSON.parse(readHolodoriDbText(source, 'LangMusic_Kor.json')).map(({ data }) => [data.id, data.text]));
const source = prepareGameDbSource('kor', { review: true });
const music = new Map(JSON.parse(readGameDbText(source, 'Music.json')).map(({ data }) => [data.id, data]));
const titles = new Map(JSON.parse(readGameDbText(source, 'LangMusic_Kor.json')).map(({ data }) => [data.id, data.text]));
const catalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
// Keep the original faulty parser fixed for reproducible historical comparison.
const converter = execFileSync('git', ['show', 'bb3573c:scripts/sus-to-exact-chart.mjs'], { encoding: 'utf8' });
+2 -2
View File
@@ -2,9 +2,9 @@
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import vm from 'node:vm';
import { prepareHolodoriDbSource } from './holodoridb-source.mjs';
import { prepareGameDbSource } from './game-db-source.mjs';
const source = prepareHolodoriDbSource('kor', { review: true });
const source = prepareGameDbSource('kor', { review: true });
const origin = 'https://dreams.wf-calc.net';
const directory = 'data/wf-audit';
await mkdir(`${directory}/charts`, { recursive: true });
+41
View File
@@ -0,0 +1,41 @@
// Extract display geometry only. Never change score notes or collection baselines.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { prepareGameDbSource } from './game-db-source.mjs';
import { parseSusTimeline } from './sus-timeline.mjs';
prepareGameDbSource('kor');
const catalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
const outputs = [];
for (const entry of catalog.charts) {
const key = `${entry.songId}.${entry.difficulty}`;
const score = JSON.parse(await readFile(`static${entry.path}`, 'utf8'));
const raw = await readFile(`data/raw/sus/chart_${entry.songId}_${entry.difficulty}.sus`);
const sha256 = createHash('sha256').update(raw).digest('hex');
let visual;
let source;
if (sha256 === score.source.susSha256) {
const parsed = parseSusTimeline(raw.toString('utf8'));
if (parsed.songId !== entry.songId || parsed.notes.length !== score.notes.length ||
parsed.notes.some((note, i) => Math.abs(note.timeSeconds - score.notes[i].timeSeconds) > 0.00001)) {
throw new Error(`${key}: visual and score timing differ`);
}
visual = parsed.visual;
source = { kind: 'sus', susSha256: sha256 };
} else {
throw new Error(`${key}: regenerate score and visual from the same game SUS first`);
}
for (const point of [...visual.notes, ...visual.holds.flat()]) {
if (![point.timeSeconds, point.lane, point.width].every(Number.isFinite) || point.width <= 0 || point.lane < -0.001 || point.lane + point.width > 12.001) {
throw new Error(`${key}: invalid chart geometry ${JSON.stringify(point)}`);
}
// Match the six-decimal timestamps of the existing score source at note boundaries.
const near = score.notes.find(n => Math.abs(n.timeSeconds - point.timeSeconds) < 0.000001);
point.timeSeconds = near?.timeSeconds ?? point.timeSeconds;
}
outputs.push([key, { schemaVersion: 1, songId: entry.songId, difficulty: entry.difficulty, durationSeconds: score.durationSeconds, ...visual, source }]);
}
// Validate the entire catalog before writing any output.
await mkdir('static/data/chart-visuals', { recursive: true });
for (const [key, data] of outputs) await writeFile(`static/data/chart-visuals/${key}.json`, JSON.stringify(data));
console.log(`채보 시각화 ${outputs.length}개 생성 (동일 게임 SUS 검증 완료)`);
+2 -291
View File
@@ -1,291 +1,2 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { readHolodoriDbTable as readHolodoriDbSource } from './holodoridb-source.mjs';
import path from 'node:path';
import { formulaScoreMetadata } from './song-score-formulas.mjs';
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(['EASY', 'NORMAL', 'HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296;
const scoreCalibrations = JSON.parse(await readFile(path.resolve('data/score-calibrations.json'), 'utf8'));
const scoreCalibrationByKey = new Map(scoreCalibrations.charts.map((row) => [
`${row.songId}::${row.difficulty}`,
row
]));
const musicMaster = readHolodoriDbSource('kor', 'Music.json');
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const koreanMusicText = readHolodoriDbSource('kor', 'LangMusic_Kor.json');
const koreanTextById = new Map(koreanMusicText.map((row) => [row.data?.id ?? row.id, row.data?.text ?? row.text]));
const musicDifficultyMaster = readHolodoriDbSource('kor', 'MusicDifficulty.json');
const difficultyLevelByKey = new Map(musicDifficultyMaster.map((row) => {
const data = row.data ?? row;
return [`${data.musicId}::${data.difficultyType.split('_').at(-1)?.toLowerCase()}`, data.difficultyLevel];
}));
const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
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 greenBlocks = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.map((rawBlock) => Array.isArray(rawBlock) ? rawBlock : [rawBlock]);
const pink = noteEvents.pink ?? [];
const internalGreenKeys = new Set(greenBlocks.flatMap((block) => block.slice(1, -1)
.map((note) => `${note.time_seconds}|${note.lane}`)));
const beatGap = (end, flick) => flick.beat - end.beat;
const sameShape = (end, flick) =>
end.lane_center === flick.lane_center && end.lane_half_width === flick.lane_half_width;
const shapeOverlapRatio = (end, flick) => {
const left = Math.max(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const right = Math.min(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
const unionLeft = Math.min(end.lane_center - end.lane_half_width, flick.lane_center - flick.lane_half_width);
const unionRight = Math.max(end.lane_center + end.lane_half_width, flick.lane_center + flick.lane_half_width);
return Math.max(0, right - left) / Math.max(Number.EPSILON, unionRight - unionLeft);
};
const isDirectContinuation = (end, flick) =>
beatGap(end, flick) > 0 && beatGap(end, flick) <= 0.25 + 1e-9 && sameShape(end, flick);
const continuesIntoFlick = (note) => pink.some((flick) => isDirectContinuation(note, flick));
// 롱노트 끝 플릭은 색상이 아니라 직전 green block과의 연결 관계로 구분한다.
// 끝이 mid인 블록은 다음 플릭이 끝 판정을 대신하며, 아주 짧은 동일 형상 연결도 같은 경우다.
const isHoldEndFlick = (flick) => greenBlocks.some((block) => {
const end = block.at(-1);
if (!end) return false;
const gap = beatGap(end, flick);
return isDirectContinuation(end, flick) ||
(end.note_type === 'mid' && gap > 0 && gap <= 0.5 + 1e-9 && shapeOverlapRatio(end, flick) >= 0.8);
});
const green = greenBlocks
.flatMap((rawBlock) => {
const block = Array.isArray(rawBlock) ? rawBlock : [rawBlock];
return block.map((note, index) => ({
...note,
isHoldEnd: index > 0 && index === block.length - 1,
// 롱노트 안쪽 판정은 점 모양이어도 0.1배다. 시작·끝의 일반 판정만 1배다.
type: (index === 0 || index === block.length - 1) &&
note.note_type !== 'mid' &&
!internalGreenKeys.has(`${note.time_seconds}|${note.lane}`) &&
!(index === block.length - 1 && continuesIntoFlick(note))
? 'regular'
: 'mid'
}));
});
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green,
// 롱노트 끝에 붙은 플릭은 판정 모양과 달리 1.0배, 독립 플릭만 1.05배다.
...pink.map((note) => ({
...note,
type: note.note_type ?? 'regular',
is_flick: !isHoldEndFlick(note),
isHoldEnd: isHoldEndFlick(note)
}))
]
.map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true,
...(note.isHoldEnd ? { isHoldEnd: true } : {})
}))
.filter((note) => Number.isFinite(note.timeSeconds))
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
}
function legacyCalibrationNotes(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
}));
}
function calibratedScoreRatio(chartData) {
const notes = legacyCalibrationNotes(chartData);
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 [difficultyName, difficultyLevelText] = difficultyAndLevel.split('-');
const difficulty = difficultyName.toUpperCase();
const difficultyLevel = Number.parseInt(difficultyLevelText, 10);
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, difficultyLevel, chartFile, assetFile });
}
if (imports.length === 0) throw new Error('수집할 채보 모듈을 찾지 못했습니다.');
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 releaseTime = Number(music?.startTime);
const notes = flattenEvents(chartData);
const measuredCalibration = scoreCalibrationByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`);
const difficultyLevel = difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null);
const formulaMetadata = formulaScoreMetadata(item.songId, item.difficulty, difficultyLevel, notes.length, music?.liveScoreCoefficientPermil, chartData.source?.sha256);
const output = {
schemaVersion: 1,
songId: item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
// 검증된 공식 우선. 참고 분모가 없는 채보만 기존 실측/추정으로 대체한다.
scoreRatio: measuredCalibration?.scoreRatio ?? calibratedScoreRatio(chartData),
scoreCalibrationSource: measuredCalibration ? 'measured' : 'local-estimated-v1',
...formulaMetadata,
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: koreanTextById.get(musicById.get(item.songId)?.titleLangId ?? `la-music_title-${item.songId}`) ?? item.songId,
difficulty: item.difficulty.toLowerCase(),
difficultyLevel: difficultyLevelByKey.get(`${item.songId}::${item.difficulty.toLowerCase()}`) ??
(Number.isFinite(item.difficultyLevel) ? item.difficultyLevel : null),
durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio,
scoreCalibrationSource: output.scoreCalibrationSource,
...(output.scoreFormula ? { scoreFormula: output.scoreFormula } : {}),
totalNotes: notes.length,
singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds,
releaseTime: Number.isFinite(releaseTime) ? releaseTime : null,
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}; hololive Dreams CDN via https://github.com/HolodoriDB/holodori-asset-tools`,
charts: normalizedCharts
}, null, 2)}\n`, 'utf8');
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);
// The default collector uses local game assets, with no community-site fallback.
import './sus-to-exact-chart.mjs';
+705
View File
@@ -0,0 +1,705 @@
import { createHash } from 'node:crypto';
import { prepareGameDbSource, readGameDbText } from './game-db-source.mjs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputRoot = path.join(projectRoot, 'data', 'game-db', 'derived');
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
function parseCsvLine(line) {
const values = [];
let value = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === '"') {
if (quoted && line[index + 1] === '"') {
value += '"';
index += 1;
} else {
quoted = !quoted;
}
} else if (character === ',' && !quoted) {
values.push(value.trim());
value = '';
} else {
value += character;
}
}
values.push(value.trim());
return values;
}
async function loadCardOverrides() {
const csv = await readFile(cardOverridesPath, 'utf8');
const lines = csv.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
const headers = parseCsvLine(lines.shift() ?? '');
const cardIdIndex = headers.indexOf('cardId');
const versionNameIndex = headers.indexOf('versionName');
const displayNameIndex = headers.indexOf('displayName');
if (cardIdIndex < 0 || versionNameIndex < 0 || displayNameIndex < 0) {
throw new Error('data/card-overrides.csv must contain cardId, versionName, and displayName columns');
}
return new Map(lines.map((line) => {
const values = parseCsvLine(line);
return [values[cardIdIndex], {
versionName: values[versionNameIndex] ?? '',
displayName: values[displayNameIndex] ?? ''
}];
}));
}
const sources = { kor: { suffix: 'Kor' } };
// Tables needed to reconstruct every card and every effect exposed by a card.
const masterTableNames = [
'Card',
'CardLevel',
'CardLevelLimit',
'CardPotential',
'CardPotentialUpgradeItem',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'LiveLeaderSkill',
'LivePassiveSkill',
'LivePassiveSkillLevel',
'LivePassiveSkillEffect',
'LiveActiveSkill',
'LiveActiveSkillLevel',
'LiveActiveSkillEffect',
'LiveSpecialSkill',
'LiveSpecialSkillLevel',
'LiveSkillTrigger',
'LiveSkillEffectTarget',
'LiveSkillEffectFilterCategory',
'Poster',
'PosterCollectEffect',
'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent',
'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit',
'SkillTreeNode',
'SkillTreeNodeAutoSelection',
'SkillTreeNodePosition',
'SkillTreePoint'
];
const localizationTableNames = [
'Card',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'GeneratedLiveActiveSkillEffect',
'GeneratedLiveActiveSkillLevel',
'GeneratedLiveLeaderSkill',
'GeneratedLivePassiveSkillEffect',
'GeneratedLivePassiveSkillLevel',
'GeneratedLiveSkillEffectTarget',
'GeneratedLiveSkillTrigger',
'GeneratedLiveSpecialSkillLevel',
'GeneratedSkillTreeConnectEffect',
'GeneratedSkillTreeEffect',
'GeneratedSkillTreeEffectPassiveTrigger',
'GeneratedSkillTreeEffectTarget',
'LiveSkillEffectFilterCategory',
'SkillTreeEffect',
'SkillTreePoint'
];
function prepareSource(locale, source) {
return { ...source, ...prepareGameDbSource(locale) };
}
function readSourceFile(source, filename) {
return readGameDbText(source, filename);
}
function parseSourceTable(source, filename) {
const text = readSourceFile(source, filename);
try {
const value = JSON.parse(text);
if (!Array.isArray(value)) {
throw new Error('top-level value is not an array');
}
return { value, text };
} catch (error) {
throw new Error(`Invalid JSON in ${filename} from ${source.masterDataVersion}: ${error.message}`);
}
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function unwrap(row) {
return row?.data ?? row;
}
function indexBy(rows, property = 'id') {
return new Map(rows.map((row) => {
const value = unwrap(row);
return [value[property], value];
}));
}
function groupBy(rows, property) {
const groups = new Map();
for (const row of rows) {
const value = unwrap(row);
const key = value[property];
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}
function sortByNumber(values, property) {
return [...values].sort((left, right) => Number(left[property] ?? 0) - Number(right[property] ?? 0));
}
function compactEnum(value) {
if (typeof value !== 'string') return value;
return value.split('_').at(-1)?.toLowerCase() ?? value;
}
function buildTextLookups(localizations) {
return Object.fromEntries(Object.entries(localizations).map(([locale, tables]) => {
const entries = Object.values(tables)
.flat()
.map(unwrap)
.filter((row) => typeof row.id === 'string' && typeof row.text === 'string')
.map((row) => [row.id, row.text]);
return [locale, new Map(entries)];
}));
}
function translateId(textLookups, id) {
if (!id) return {};
return Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [locale, lookup.get(id) ?? null]));
}
function withTranslations(value, textLookups) {
if (!value) return null;
const translations = {};
for (const [key, entry] of Object.entries(value)) {
if (key.endsWith('LangId') && typeof entry === 'string') {
translations[key.slice(0, -6)] = translateId(textLookups, entry);
}
if (key.endsWith('LangIds') && Array.isArray(entry)) {
translations[key.slice(0, -7)] = entry.map((id) => translateId(textLookups, id));
}
}
return Object.keys(translations).length > 0 ? { ...value, translations } : { ...value };
}
function resolveEffects(rows, targetsById, textLookups) {
return rows.map((effect) => ({
...withTranslations(effect, textLookups),
target: effect.liveSkillEffectTargetId
? withTranslations(targetsById.get(effect.liveSkillEffectTargetId), textLookups)
: null
}));
}
function assert(condition, message) {
if (!condition) throw new Error(`Validation failed: ${message}`);
}
const preparedSources = {};
for (const [locale, source] of Object.entries(sources)) {
preparedSources[locale] = await prepareSource(locale, source);
}
const masterVersions = new Set(
Object.values(preparedSources).map((source) => source.masterDataVersion)
);
assert(masterVersions.size === 1, 'game DB inputs are not on the same master data version');
const tables = {};
const tableManifest = {};
for (const tableName of masterTableNames) {
const filename = `${tableName}.json`;
const canonical = parseSourceTable(preparedSources.kor, filename);
const hashes = {};
for (const [locale, source] of Object.entries(preparedSources)) {
const sourceText = readSourceFile(source, filename);
hashes[locale] = sha256(sourceText);
}
assert(new Set(Object.values(hashes)).size === 1, `${filename} differs between game DB inputs`);
tables[tableName] = canonical.value;
tableManifest[tableName] = { filename, count: canonical.value.length, sha256: hashes.kor };
}
const localizations = {};
const localizationManifest = {};
for (const [locale, source] of Object.entries(preparedSources)) {
localizations[locale] = {};
localizationManifest[locale] = {};
for (const tableName of localizationTableNames) {
const filename = `Lang${tableName}_${source.suffix}.json`;
const parsed = parseSourceTable(source, filename);
localizations[locale][tableName] = parsed.value;
localizationManifest[locale][tableName] = {
filename,
count: parsed.value.length,
sha256: sha256(parsed.text)
};
}
}
const textLookups = buildTextLookups(localizations);
const cards = tables.Card.map(unwrap);
const charactersById = indexBy(tables.Character);
const cardLevelsByGroup = groupBy(tables.CardLevel, 'groupId');
const cardLevelLimitsByGroup = groupBy(tables.CardLevelLimit, 'groupId');
const cardPotentialsByGroup = groupBy(tables.CardPotential, 'groupId');
const passiveSkillsById = indexBy(tables.LivePassiveSkill);
const passiveLevelsBySkill = groupBy(tables.LivePassiveSkillLevel, 'livePassiveSkillId');
const passiveEffectsByGroup = groupBy(tables.LivePassiveSkillEffect, 'groupId');
const activeSkillsById = indexBy(tables.LiveActiveSkill);
const activeLevelsBySkill = groupBy(tables.LiveActiveSkillLevel, 'liveActiveSkillId');
const activeEffectsByGroup = groupBy(tables.LiveActiveSkillEffect, 'groupId');
const specialSkillsById = indexBy(tables.LiveSpecialSkill);
const specialLevelsBySkill = groupBy(tables.LiveSpecialSkillLevel, 'liveSpecialSkillId');
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);
function resolvePassiveSkill(skillId) {
const skill = passiveSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(passiveLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(level.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
}))
};
}
function resolveActiveSkill(skillId) {
const skill = activeSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(activeLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveSpecialSkill(skillId) {
const skill = specialSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(specialLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveLeaderSkill(skillId) {
const skill = leaderSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(skill.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
};
}
function resolveCostume(costumeId, costumeIndex) {
const costume = costumeIndex.get(costumeId);
if (!costume) return null;
return {
...withTranslations(costume, textLookups),
leaderSkill: costume.liveLeaderSkillId ? resolveLeaderSkill(costume.liveLeaderSkillId) : null
};
}
const cardRecords = cards
.map((card) => {
const character = charactersById.get(card.characterId);
const connectEffects = sortByNumber(connectEffectsById.get(card.skillTreeConnectEffectId) ?? [], 'level');
return {
id: card.id,
order: card.order,
rarity: compactEnum(card.rarity),
attribute: compactEnum(card.attributeType),
card: withTranslations(card, textLookups),
character: withTranslations(character, textLookups),
progression: {
levels: sortByNumber(cardLevelsByGroup.get(card.cardLevelGroupId) ?? [], 'level'),
levelLimits: sortByNumber(cardLevelLimitsByGroup.get(card.cardLevelLimitGroupId) ?? [], 'levelLimit'),
potentials: sortByNumber(cardPotentialsByGroup.get(card.cardPotentialGroupId) ?? [], 'upgradeCount')
},
skills: {
passive: resolvePassiveSkill(card.livePassiveSkillId),
active: resolveActiveSkill(card.liveActiveSkillId),
special: resolveSpecialSkill(card.liveSpecialSkillId)
},
skillTreeConnectEffects: connectEffects.map((effect) => ({
...withTranslations(effect, textLookups),
extent: sortByNumber(
connectExtentsByGroup.get(effect.skillTreeConnectEffectExtentGroupId) ?? [],
'positionY'
)
})),
rewards: {
costume: resolveCostume(card.rewardCostumeId, costumesById),
sdCostume: resolveCostume(card.rewardSdCostumeId, sdCostumesById),
potentialAlternativeReward: card.potentialAlternativeReward ?? null,
acquireReward: card.acquireReward ?? null
}
};
})
.sort((left, right) => Number(left.order) - Number(right.order));
assert(cards.length > 0, 'no cards were collected');
assert(new Set(cards.map((card) => card.id)).size === cards.length, 'card IDs are not unique');
assert(cards.every((card) => charactersById.has(card.characterId)), 'one or more cards have no character');
assert(cards.every((card) => passiveSkillsById.has(card.livePassiveSkillId)), 'one or more cards have no passive skill');
assert(cards.every((card) => activeSkillsById.has(card.liveActiveSkillId)), 'one or more cards have no active skill');
assert(cards.every((card) => specialSkillsById.has(card.liveSpecialSkillId)), 'one or more cards have no special skill');
const cardNameCoverage = Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [
locale,
cards.filter((card) => lookup.has(card.nameLangId)).length
]));
const rarityCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.rarity))).map(([key, values]) => [key, values.length])
);
const attributeCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.attributeType))).map(([key, values]) => [key, values.length])
);
const generatedAt = new Date().toISOString();
const sourceMetadata = { kor: {
kind: preparedSources.kor.kind, masterDataVersion: preparedSources.kor.masterDataVersion,
capturedAt: preparedSources.kor.capturedAt, manifestSha256: preparedSources.kor.manifestSha256
} };
const manifest = {
schemaVersion: 1,
generatedAt,
source: 'Decrypted Android game master databases',
sourceLicense: null,
assetsIncluded: false,
sources: sourceMetadata,
counts: {
cards: cards.length,
rarities: rarityCounts,
attributes: attributeCounts,
localizedCardNames: cardNameCoverage
},
tables: tableManifest,
localizations: localizationManifest
};
const masterBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
tables,
localizations
};
const cardBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
cards: cardRecords
};
const cardRecordsById = new Map(cardRecords.map((card) => [card.id, card]));
const cardOverrides = await loadCardOverrides();
for (const cardId of cardOverrides.keys()) {
assert(cardRecordsById.has(cardId), `card override references unknown card: ${cardId}`);
}
function summarizeSkillLevels(levels) {
return levels.map((level) => ({
level: level.level,
description: level.translations?.description?.kor ?? '',
coolTimeSeconds: level.coolTimeMillisecond == null ? null : level.coolTimeMillisecond / 1000,
probabilityPercent: level.activationProbabilityPermilMultiply == null
? null
: level.activationProbabilityPermilMultiply / 10,
durationSeconds: level.effectDurationMillisecond == null ? null : level.effectDurationMillisecond / 1000
}));
}
function koreanDescriptions(records) {
return (records ?? [])
.map((record) => record.translations?.description?.kor ?? '')
.filter(Boolean)
.join(' ');
}
function summarizeConditionalSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: condition ? conditionalEffect : baseEffect
};
});
}
function summarizeSpecialSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: [baseEffect, conditionalEffect].filter(Boolean).join(' ')
};
});
}
const typeLabels = { '1': '큐트', '2': '퓨어', '3': '해피' };
const statLabels = {
performancePermilMultiply: '퍼포먼스',
techniquePermilMultiply: '테크닉',
sensePermilMultiply: '센스'
};
const cardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
cards: cards
.map((card) => {
const character = charactersById.get(card.characterId);
const cardRecord = cardRecordsById.get(card.id);
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);
// Round each base stat up before applying awakening and other bonuses.
// Verified against the in-game unit power breakdown (2026-09-06).
const statsAtMaxLevel = {
performance: Math.ceil(parameterBaseValue * Number(card.performancePermilMultiply ?? 0) / 1000),
technique: Math.ceil(parameterBaseValue * Number(card.techniquePermilMultiply ?? 0) / 1000),
sense: Math.ceil(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)],
name: cardOverride?.displayName || textLookups.kor.get(character.nameLangId),
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 ?? '',
description: cardRecord.rewards.costume.leaderSkill?.translations?.description?.kor
?? cardRecord.rewards.costume.leaderSkill?.effects?.[0]?.translations?.description?.kor
?? ''
}
: null
}
};
})
.sort((left, right) =>
right.rarity - left.rarity
|| left.memberOrder - right.memberOrder
|| left.id.localeCompare(right.id)
)
.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');
const boardPositionCounts = Object.values(boardSummary.models).map((positions) => positions.length);
assert(boardPositionCounts.every((count) => count > 0), 'one or more board position models are empty');
assert(new Set(boardPositionCounts).size === 1, 'board position models have inconsistent position counts');
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, '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`);
+6 -6
View File
@@ -1,12 +1,12 @@
import { writeFileSync } from 'node:fs';
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
import { prepareGameDbSource, readGameDbText, sourceMetadata } from './game-db-source.mjs';
const source = prepareHolodoriDbSource('kor');
const missions = JSON.parse(readHolodoriDbText(source, 'Mission.json')).map((row) => row.data)
const source = prepareGameDbSource('kor');
const missions = JSON.parse(readGameDbText(source, 'Mission.json')).map((row) => row.data)
.filter((mission) => mission.characterId
&& mission.categoryType === 'MissionCategoryType_MISSION_CATEGORY_TYPE_ACHIEVEMENT'
&& mission.type === 'MissionType_MISSION_TYPE_INCREMENT_WORK_FINISH_COUNT');
const progress = JSON.parse(readHolodoriDbText(source, 'MissionProgress.json')).map((row) => row.data);
const progress = JSON.parse(readGameDbText(source, 'MissionProgress.json')).map((row) => row.data);
if (!missions.length) throw new Error('No member Holo-work achievements found.');
const thresholdsByMember = Object.fromEntries(missions.sort((a, b) => a.characterId.localeCompare(b.characterId)).map((mission) => {
const thresholds = [...new Set(progress.filter((row) => row.missionId === mission.id).map((row) => Number(row.threshold)))].sort((a, b) => a - b);
@@ -16,7 +16,7 @@ const thresholdsByMember = Object.fromEntries(missions.sort((a, b) => a.characte
return [mission.characterId, thresholds];
}));
writeFileSync(new URL('../src/lib/data/holo-work-achievements.json', import.meta.url), JSON.stringify({
source: { repository: source.repository, commit: source.commit, files: ['Mission.json', 'MissionProgress.json'] },
source: { ...sourceMetadata(source), files: ['Mission.json', 'MissionProgress.json'] },
thresholdsByMember
}, null, 2) + '\n');
console.log(`Collected Holo-work achievements for ${missions.length} members at ${source.commit}.`);
console.log(`Collected Holo-work achievements for ${missions.length} members at ${source.masterDataVersion}.`);
+2 -721
View File
@@ -1,721 +1,2 @@
import { createHash } from 'node:crypto';
import { prepareHolodoriDbSource, readHolodoriDbText } from './holodoridb-source.mjs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const outputRoot = path.join(projectRoot, 'data', 'holodoridb');
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
function parseCsvLine(line) {
const values = [];
let value = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === '"') {
if (quoted && line[index + 1] === '"') {
value += '"';
index += 1;
} else {
quoted = !quoted;
}
} else if (character === ',' && !quoted) {
values.push(value.trim());
value = '';
} else {
value += character;
}
}
values.push(value.trim());
return values;
}
async function loadCardOverrides() {
const csv = await readFile(cardOverridesPath, 'utf8');
const lines = csv.replace(/^\uFEFF/, '').split(/\r?\n/).filter((line) => line.trim() && !line.trimStart().startsWith('#'));
const headers = parseCsvLine(lines.shift() ?? '');
const cardIdIndex = headers.indexOf('cardId');
const versionNameIndex = headers.indexOf('versionName');
const displayNameIndex = headers.indexOf('displayName');
if (cardIdIndex < 0 || versionNameIndex < 0 || displayNameIndex < 0) {
throw new Error('data/card-overrides.csv must contain cardId, versionName, and displayName columns');
}
return new Map(lines.map((line) => {
const values = parseCsvLine(line);
return [values[cardIdIndex], {
versionName: values[versionNameIndex] ?? '',
displayName: values[displayNameIndex] ?? ''
}];
}));
}
const sources = {
eng: {
repository: 'https://github.com/HolodoriDB/holodori-db-eng-diff.git',
suffix: 'Eng'
},
kor: {
repository: 'https://github.com/HolodoriDB/holodori-db-kor-diff.git',
suffix: 'Kor'
},
jpn: {
repository: 'https://github.com/HolodoriDB/holodori-db-jpn-diff.git',
suffix: 'Jpn'
}
};
// Tables needed to reconstruct every card and every effect exposed by a card.
const masterTableNames = [
'Card',
'CardLevel',
'CardLevelLimit',
'CardPotential',
'CardPotentialUpgradeItem',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'LiveLeaderSkill',
'LivePassiveSkill',
'LivePassiveSkillLevel',
'LivePassiveSkillEffect',
'LiveActiveSkill',
'LiveActiveSkillLevel',
'LiveActiveSkillEffect',
'LiveSpecialSkill',
'LiveSpecialSkillLevel',
'LiveSkillTrigger',
'LiveSkillEffectTarget',
'LiveSkillEffectFilterCategory',
'Poster',
'PosterCollectEffect',
'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent',
'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit',
'SkillTreeNode',
'SkillTreeNodeAutoSelection',
'SkillTreeNodePosition',
'SkillTreePoint'
];
const localizationTableNames = [
'Card',
'Character',
'CharacterGrouping',
'CharacterProduction',
'Costume',
'SdCostume',
'GeneratedLiveActiveSkillEffect',
'GeneratedLiveActiveSkillLevel',
'GeneratedLiveLeaderSkill',
'GeneratedLivePassiveSkillEffect',
'GeneratedLivePassiveSkillLevel',
'GeneratedLiveSkillEffectTarget',
'GeneratedLiveSkillTrigger',
'GeneratedLiveSpecialSkillLevel',
'GeneratedSkillTreeConnectEffect',
'GeneratedSkillTreeEffect',
'GeneratedSkillTreeEffectPassiveTrigger',
'GeneratedSkillTreeEffectTarget',
'LiveSkillEffectFilterCategory',
'SkillTreeEffect',
'SkillTreePoint'
];
function prepareSource(locale, source) {
return { ...source, ...prepareHolodoriDbSource(locale) };
}
function readSourceFile(source, filename) {
return readHolodoriDbText(source, filename);
}
function parseSourceTable(source, filename) {
const text = readSourceFile(source, filename);
try {
const value = JSON.parse(text);
if (!Array.isArray(value)) {
throw new Error('top-level value is not an array');
}
return { value, text };
} catch (error) {
throw new Error(`Invalid JSON in ${filename} from ${source.commit}: ${error.message}`);
}
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function unwrap(row) {
return row?.data ?? row;
}
function indexBy(rows, property = 'id') {
return new Map(rows.map((row) => {
const value = unwrap(row);
return [value[property], value];
}));
}
function groupBy(rows, property) {
const groups = new Map();
for (const row of rows) {
const value = unwrap(row);
const key = value[property];
const group = groups.get(key) ?? [];
group.push(value);
groups.set(key, group);
}
return groups;
}
function sortByNumber(values, property) {
return [...values].sort((left, right) => Number(left[property] ?? 0) - Number(right[property] ?? 0));
}
function compactEnum(value) {
if (typeof value !== 'string') return value;
return value.split('_').at(-1)?.toLowerCase() ?? value;
}
function buildTextLookups(localizations) {
return Object.fromEntries(Object.entries(localizations).map(([locale, tables]) => {
const entries = Object.values(tables)
.flat()
.map(unwrap)
.filter((row) => typeof row.id === 'string' && typeof row.text === 'string')
.map((row) => [row.id, row.text]);
return [locale, new Map(entries)];
}));
}
function translateId(textLookups, id) {
if (!id) return {};
return Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [locale, lookup.get(id) ?? null]));
}
function withTranslations(value, textLookups) {
if (!value) return null;
const translations = {};
for (const [key, entry] of Object.entries(value)) {
if (key.endsWith('LangId') && typeof entry === 'string') {
translations[key.slice(0, -6)] = translateId(textLookups, entry);
}
if (key.endsWith('LangIds') && Array.isArray(entry)) {
translations[key.slice(0, -7)] = entry.map((id) => translateId(textLookups, id));
}
}
return Object.keys(translations).length > 0 ? { ...value, translations } : { ...value };
}
function resolveEffects(rows, targetsById, textLookups) {
return rows.map((effect) => ({
...withTranslations(effect, textLookups),
target: effect.liveSkillEffectTargetId
? withTranslations(targetsById.get(effect.liveSkillEffectTargetId), textLookups)
: null
}));
}
function assert(condition, message) {
if (!condition) throw new Error(`Validation failed: ${message}`);
}
const preparedSources = {};
for (const [locale, source] of Object.entries(sources)) {
preparedSources[locale] = await prepareSource(locale, source);
}
const masterVersions = new Set(
Object.values(preparedSources).map((source) => source.commitMessage.replace(/^Master data version\s+/, ''))
);
assert(masterVersions.size === 1, 'locale repositories are not on the same master data version');
const tables = {};
const tableManifest = {};
for (const tableName of masterTableNames) {
const filename = `${tableName}.json`;
const canonical = parseSourceTable(preparedSources.eng, filename);
const hashes = {};
for (const [locale, source] of Object.entries(preparedSources)) {
const sourceText = readSourceFile(source, filename);
hashes[locale] = sha256(sourceText);
}
assert(new Set(Object.values(hashes)).size === 1, `${filename} differs between locale repositories`);
tables[tableName] = canonical.value;
tableManifest[tableName] = { filename, count: canonical.value.length, sha256: hashes.eng };
}
const localizations = {};
const localizationManifest = {};
for (const [locale, source] of Object.entries(preparedSources)) {
localizations[locale] = {};
localizationManifest[locale] = {};
for (const tableName of localizationTableNames) {
const filename = `Lang${tableName}_${source.suffix}.json`;
const parsed = parseSourceTable(source, filename);
localizations[locale][tableName] = parsed.value;
localizationManifest[locale][tableName] = {
filename,
count: parsed.value.length,
sha256: sha256(parsed.text)
};
}
}
const textLookups = buildTextLookups(localizations);
const cards = tables.Card.map(unwrap);
const charactersById = indexBy(tables.Character);
const cardLevelsByGroup = groupBy(tables.CardLevel, 'groupId');
const cardLevelLimitsByGroup = groupBy(tables.CardLevelLimit, 'groupId');
const cardPotentialsByGroup = groupBy(tables.CardPotential, 'groupId');
const passiveSkillsById = indexBy(tables.LivePassiveSkill);
const passiveLevelsBySkill = groupBy(tables.LivePassiveSkillLevel, 'livePassiveSkillId');
const passiveEffectsByGroup = groupBy(tables.LivePassiveSkillEffect, 'groupId');
const activeSkillsById = indexBy(tables.LiveActiveSkill);
const activeLevelsBySkill = groupBy(tables.LiveActiveSkillLevel, 'liveActiveSkillId');
const activeEffectsByGroup = groupBy(tables.LiveActiveSkillEffect, 'groupId');
const specialSkillsById = indexBy(tables.LiveSpecialSkill);
const specialLevelsBySkill = groupBy(tables.LiveSpecialSkillLevel, 'liveSpecialSkillId');
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);
function resolvePassiveSkill(skillId) {
const skill = passiveSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(passiveLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(level.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
}))
};
}
function resolveActiveSkill(skillId) {
const skill = activeSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(activeLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveSpecialSkill(skillId) {
const skill = specialSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
levels: sortByNumber(specialLevelsBySkill.get(skillId) ?? [], 'level').map((level) => ({
...withTranslations(level, textLookups),
effects: resolveEffects(
activeEffectsByGroup.get(level.liveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
additionalEffects: resolveEffects(
activeEffectsByGroup.get(level.additionalLiveActiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
),
triggers: (triggersByGroup.get(level.additionalLiveSkillTriggerGroupId) ?? [])
.map((trigger) => withTranslations(trigger, textLookups))
}))
};
}
function resolveLeaderSkill(skillId) {
const skill = leaderSkillsById.get(skillId);
if (!skill) return null;
return {
...withTranslations(skill, textLookups),
effects: resolveEffects(
passiveEffectsByGroup.get(skill.livePassiveSkillEffectGroupId) ?? [],
effectTargetsById,
textLookups
)
};
}
function resolveCostume(costumeId, costumeIndex) {
const costume = costumeIndex.get(costumeId);
if (!costume) return null;
return {
...withTranslations(costume, textLookups),
leaderSkill: costume.liveLeaderSkillId ? resolveLeaderSkill(costume.liveLeaderSkillId) : null
};
}
const cardRecords = cards
.map((card) => {
const character = charactersById.get(card.characterId);
const connectEffects = sortByNumber(connectEffectsById.get(card.skillTreeConnectEffectId) ?? [], 'level');
return {
id: card.id,
order: card.order,
rarity: compactEnum(card.rarity),
attribute: compactEnum(card.attributeType),
card: withTranslations(card, textLookups),
character: withTranslations(character, textLookups),
progression: {
levels: sortByNumber(cardLevelsByGroup.get(card.cardLevelGroupId) ?? [], 'level'),
levelLimits: sortByNumber(cardLevelLimitsByGroup.get(card.cardLevelLimitGroupId) ?? [], 'levelLimit'),
potentials: sortByNumber(cardPotentialsByGroup.get(card.cardPotentialGroupId) ?? [], 'upgradeCount')
},
skills: {
passive: resolvePassiveSkill(card.livePassiveSkillId),
active: resolveActiveSkill(card.liveActiveSkillId),
special: resolveSpecialSkill(card.liveSpecialSkillId)
},
skillTreeConnectEffects: connectEffects.map((effect) => ({
...withTranslations(effect, textLookups),
extent: sortByNumber(
connectExtentsByGroup.get(effect.skillTreeConnectEffectExtentGroupId) ?? [],
'positionY'
)
})),
rewards: {
costume: resolveCostume(card.rewardCostumeId, costumesById),
sdCostume: resolveCostume(card.rewardSdCostumeId, sdCostumesById),
potentialAlternativeReward: card.potentialAlternativeReward ?? null,
acquireReward: card.acquireReward ?? null
}
};
})
.sort((left, right) => Number(left.order) - Number(right.order));
assert(cards.length > 0, 'no cards were collected');
assert(new Set(cards.map((card) => card.id)).size === cards.length, 'card IDs are not unique');
assert(cards.every((card) => charactersById.has(card.characterId)), 'one or more cards have no character');
assert(cards.every((card) => passiveSkillsById.has(card.livePassiveSkillId)), 'one or more cards have no passive skill');
assert(cards.every((card) => activeSkillsById.has(card.liveActiveSkillId)), 'one or more cards have no active skill');
assert(cards.every((card) => specialSkillsById.has(card.liveSpecialSkillId)), 'one or more cards have no special skill');
const cardNameCoverage = Object.fromEntries(Object.entries(textLookups).map(([locale, lookup]) => [
locale,
cards.filter((card) => lookup.has(card.nameLangId)).length
]));
const rarityCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.rarity))).map(([key, values]) => [key, values.length])
);
const attributeCounts = Object.fromEntries(
Object.entries(Object.groupBy(cards, (card) => compactEnum(card.attributeType))).map(([key, values]) => [key, values.length])
);
const generatedAt = new Date().toISOString();
const sourceMetadata = Object.fromEntries(Object.entries(preparedSources).map(([locale, source]) => [locale, {
repository: source.repository.replace(/\.git$/, ''),
commit: source.commit,
commitDate: source.commitDate,
masterDataVersion: source.commitMessage.replace(/^Master data version\s+/, '')
}]));
const manifest = {
schemaVersion: 1,
generatedAt,
source: 'HolodoriDB community master-data diff repositories',
sourceLicense: null,
sourceLicenseNote: 'No LICENSE file was present in the three source repositories at collection time.',
assetsIncluded: false,
sources: sourceMetadata,
counts: {
cards: cards.length,
rarities: rarityCounts,
attributes: attributeCounts,
localizedCardNames: cardNameCoverage
},
tables: tableManifest,
localizations: localizationManifest
};
const masterBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
tables,
localizations
};
const cardBundle = {
schemaVersion: 1,
generatedAt,
sources: sourceMetadata,
cards: cardRecords
};
const cardRecordsById = new Map(cardRecords.map((card) => [card.id, card]));
const cardOverrides = await loadCardOverrides();
for (const cardId of cardOverrides.keys()) {
assert(cardRecordsById.has(cardId), `card override references unknown card: ${cardId}`);
}
function summarizeSkillLevels(levels) {
return levels.map((level) => ({
level: level.level,
description: level.translations?.description?.kor ?? '',
coolTimeSeconds: level.coolTimeMillisecond == null ? null : level.coolTimeMillisecond / 1000,
probabilityPercent: level.activationProbabilityPermilMultiply == null
? null
: level.activationProbabilityPermilMultiply / 10,
durationSeconds: level.effectDurationMillisecond == null ? null : level.effectDurationMillisecond / 1000
}));
}
function koreanDescriptions(records) {
return (records ?? [])
.map((record) => record.translations?.description?.kor ?? '')
.filter(Boolean)
.join(' ');
}
function summarizeConditionalSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: condition ? conditionalEffect : baseEffect
};
});
}
function summarizeSpecialSkillLevels(levels) {
return summarizeSkillLevels(levels).map((summary, index) => {
const condition = koreanDescriptions(levels[index].triggers);
const baseEffect = koreanDescriptions(levels[index].effects);
const conditionalEffect = koreanDescriptions(levels[index].additionalEffects);
return {
...summary,
condition,
effect: [baseEffect, conditionalEffect].filter(Boolean).join(' ')
};
});
}
const typeLabels = { '1': '큐트', '2': '퓨어', '3': '해피' };
const statLabels = {
performancePermilMultiply: '퍼포먼스',
techniquePermilMultiply: '테크닉',
sensePermilMultiply: '센스'
};
const cardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
cards: cards
.map((card) => {
const character = charactersById.get(card.characterId);
const cardRecord = cardRecordsById.get(card.id);
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);
// Round each base stat up before applying awakening and other bonuses.
// Verified against the in-game unit power breakdown (2026-09-06).
const statsAtMaxLevel = {
performance: Math.ceil(parameterBaseValue * Number(card.performancePermilMultiply ?? 0) / 1000),
technique: Math.ceil(parameterBaseValue * Number(card.techniquePermilMultiply ?? 0) / 1000),
sense: Math.ceil(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)],
name: cardOverride?.displayName || textLookups.kor.get(character.nameLangId),
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 ?? '',
description: cardRecord.rewards.costume.leaderSkill?.translations?.description?.kor
?? cardRecord.rewards.costume.leaderSkill?.effects?.[0]?.translations?.description?.kor
?? ''
}
: null
}
};
})
.sort((left, right) =>
right.rarity - left.rarity
|| left.memberOrder - right.memberOrder
|| left.id.localeCompare(right.id)
)
.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');
const boardPositionCounts = Object.values(boardSummary.models).map((positions) => positions.length);
assert(boardPositionCounts.every((count) => count > 0), 'one or more board position models are empty');
assert(new Set(boardPositionCounts).size === 1, 'board position models have inconsistent position counts');
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, '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`);
// Compatibility command: the authoritative source is now the local game DB.
import './collect-game-db.mjs';
+3 -3
View File
@@ -3,7 +3,7 @@
import { createDecipheriv, createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { prepareHolodoriDbSource } from './holodoridb-source.mjs';
import { prepareGameDbSource, sourceMetadata } from './game-db-source.mjs';
import { susScoreWeight } from './sus-score-weight.mjs';
const OCTO_API_KEY = 'B46OtKlGGHoz6sxbOWDe3VUvBsagXxr5av38IQIKUKo=';
@@ -101,8 +101,8 @@ const collectAll = requested.length === 1 && requested[0] === '--all';
if (!collectAll && requested.some((value) => !/^m\d{4}$|^chart_m\d{4}_(easy|normal|hard|expert)\.sus$/.test(value))) {
throw new Error('잘못된 SUS 수집 대상입니다.');
}
// Pin every reviewed locale before downloading. Never use stale refs after a failed fetch.
const sources = Object.fromEntries(['eng', 'kor', 'jpn'].map((locale) => [locale, prepareHolodoriDbSource(locale)]));
// Official game assets supplement the DB; no community repository is required.
const sources = { kor: sourceMetadata(prepareGameDbSource('kor')) };
const requestedNames = new Set(requested.flatMap((value) =>
/^m\d{4}$/.test(value) ? ['easy', 'normal', 'hard', 'expert'].map((difficulty) => `chart_${value}_${difficulty}.sus`) : [value]
));
+6 -8
View File
@@ -1,22 +1,20 @@
import { readHolodoriDbTable } from './holodoridb-source.mjs';
import { readGameDbTable } from './game-db-source.mjs';
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 musicRows = readHolodoriDbTable('kor', 'Music.json');
const musicRows = readGameDbTable('kor', 'Music.json');
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 ?? [];
if (!music) throw new Error(`Missing DB song: ${entry.songId}`);
entry.singerType = compactSingerType(music?.musicSingerType);
entry.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;
Binary file not shown.
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { gunzipSync } from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const gameDbRoot = fileURLToPath(new URL('../data/game-db/', import.meta.url));
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
export function openGameDb(pointer, root = gameDbRoot) {
if (!/^snapshots\/[a-zA-Z0-9]{64}$/.test(pointer.snapshot ?? '')) throw new Error('Invalid game DB snapshot path');
const directory = path.join(root, pointer.snapshot);
const bytes = readFileSync(path.join(directory, 'manifest.json'));
if (sha256(bytes) !== pointer.manifestSha256) throw new Error('Game DB manifest hash mismatch');
const manifest = JSON.parse(bytes);
if (manifest.schemaVersion !== 1 || manifest.kind !== 'android-master-db' || manifest.locale !== 'kor' ||
pointer.snapshot !== `snapshots/${manifest.masterDataVersion}` || !Object.keys(manifest.tables ?? {}).length) {
throw new Error('Invalid game DB manifest');
}
return { ...manifest, ...pointer, directory };
}
export function sourceMetadata(source) {
return { kind: source.kind, masterDataVersion: source.masterDataVersion, locale: source.locale,
capturedAt: source.capturedAt, manifestSha256: source.manifestSha256 };
}
export function prepareGameDbSource(locale = 'kor', { review = false } = {}) {
if (locale !== 'kor') throw new Error(`Current game DB contains Korean only; no external fallback for ${locale}`);
const pointer = JSON.parse(readFileSync(path.join(gameDbRoot, 'current.json'), 'utf8'));
const source = openGameDb(pointer);
if (!review) {
let report;
try { report = JSON.parse(readFileSync(path.join(gameDbRoot, 'review/latest.json'), 'utf8')); }
catch { throw new Error('Run npm run data:sync and report its changes before data:apply.'); }
if (report.target.manifestSha256 !== source.manifestSha256) throw new Error('Game DB changed after review; run data:sync again.');
}
return source;
}
export function readGameDbText(source, filename) {
const name = filename.replace(/\.json$/, '');
const table = source.tables[name];
if (!table || table.file !== `${name}.json.gz` || !/^[A-Za-z0-9_]+$/.test(name)) throw new Error(`Missing game DB table: ${filename}`);
const packed = readFileSync(path.join(source.directory, table.file));
if (sha256(packed) !== table.gzipSha256) throw new Error(`Game DB archive hash mismatch: ${name}`);
const raw = gunzipSync(packed);
if (sha256(raw) !== table.sha256) throw new Error(`Game DB table hash mismatch: ${name}`);
const rows = JSON.parse(raw);
if (!Array.isArray(rows) || rows.length !== table.rows) throw new Error(`Game DB row count mismatch: ${name}`);
return raw.toString('utf8');
}
export function readGameDbTable(locale, filename) {
return JSON.parse(readGameDbText(prepareGameDbSource(locale), filename));
}
+53
View File
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { gzipSync, gunzipSync } from 'node:zlib';
import { openGameDb, readGameDbText, prepareGameDbSource, sha256 } from './game-db-source.mjs';
test('snapshot reader rejects tampering, path traversal and missing tables', () => {
const root = mkdtempSync(path.join(tmpdir(), 'holocalc-db-test-'));
try {
const version = 'a'.repeat(64), snapshot = `snapshots/${version}`;
const dir = path.join(root, snapshot); mkdirSync(dir, { recursive: true });
const raw = Buffer.from('[{"data":{"id":"test"}}]'), packed = gzipSync(raw);
const manifest = JSON.stringify({ schemaVersion: 1, kind: 'android-master-db', locale: 'kor',
masterDataVersion: version, tables: { Card: { file: 'Card.json.gz', rows: 1,
sha256: sha256(raw), gzipSha256: sha256(packed) } } });
writeFileSync(path.join(dir, 'manifest.json'), manifest);
writeFileSync(path.join(dir, 'Card.json.gz'), packed);
const pointer = { snapshot, manifestSha256: sha256(manifest) };
const source = openGameDb(pointer, root);
assert.equal(JSON.parse(readGameDbText(source, 'Card.json'))[0].data.id, 'test');
assert.throws(() => readGameDbText(source, 'Missing.json'), /Missing game DB table/);
assert.throws(() => openGameDb({ ...pointer, snapshot: '../outside' }, root), /Invalid/);
assert.throws(() => openGameDb({ ...pointer, manifestSha256: 'bad' }, root), /hash mismatch/);
writeFileSync(path.join(dir, 'Card.json.gz'), gzipSync('[]'));
assert.throws(() => readGameDbText(source, 'Card.json'), /hash mismatch/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('every captured table verifies, and unsupported locales cannot use external fallback', () => {
const source = prepareGameDbSource('kor', { review: true });
assert.ok(Object.keys(source.tables).length > 0);
let rows = 0;
for (const name of Object.keys(source.tables)) rows += JSON.parse(readGameDbText(source, name + '.json')).length;
assert.ok(rows > 0);
assert.throws(() => prepareGameDbSource('eng'), /no external fallback/);
const cards = JSON.parse(readFileSync(new URL('../src/lib/data/cards-summary.json', import.meta.url)));
assert.equal(cards.masterDataVersion, source.masterDataVersion);
assert.equal(cards.cards.length, JSON.parse(readGameDbText(source, 'Card.json')).length);
});
// Independent game assets pin the two corrections to the old community reference table.
test('m0337 game SUS supplies the corrected denominator and matches service provenance', async () => {
const { susScoreWeight } = await import('./sus-score-weight.mjs');
for (const [difficulty, expected] of [['hard', 539500], ['expert', 884700]]) {
const raw = gunzipSync(readFileSync(new URL('./fixtures/game-sus/m0337.' + difficulty + '.sus.gz', import.meta.url)));
const chart = JSON.parse(readFileSync(new URL('../static/data/charts/m0337.' + difficulty + '.json', import.meta.url)));
assert.equal(susScoreWeight(raw.toString('utf8')).perfectNoteWeight, expected);
assert.equal(chart.source.susSha256, sha256(raw));
assert.equal(chart.scoreFormula.perfectNoteWeight, expected);
}
});
+5 -66
View File
@@ -1,66 +1,5 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const prepared = new Map();
export function holodoriGit(repositoryPath, args) {
return execFileSync('git', ['-c', `safe.directory=${repositoryPath.replaceAll('\\', '/')}`, '-C', repositoryPath, ...args], {
encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
const git = holodoriGit;
// Each process fetches the authoritative remote once per locale, then pins all
// reads to that commit. Neither checkout files nor stale remote refs are inputs.
export function prepareHolodoriDbSource(locale, { review = false } = {}) {
if (!['eng', 'kor', 'jpn'].includes(locale)) throw new Error(`Unknown HolodoriDB locale: ${locale}`);
if (process.argv.includes('--offline')) throw new Error('HolodoriDB collection requires the remote repository; --offline is not supported.');
if (prepared.has(locale)) {
const source = prepared.get(locale);
if (!review) requireReviewedSource(locale, source);
return source;
}
const repository = `https://github.com/HolodoriDB/holodori-db-${locale}-diff.git`;
const repositoryPath = path.join(projectRoot, 'data', 'raw', 'holodoridb-remote', locale);
mkdirSync(repositoryPath, { recursive: true });
git(repositoryPath, ['init', '--bare']);
try {
git(repositoryPath, ['fetch', '--depth', '1', '--no-tags', repository, '+refs/heads/main:refs/heads/source-main']);
} catch (error) {
throw new Error(`Cannot fetch HolodoriDB ${locale} remote main; collection stopped without using cached data.`, { cause: error });
}
const commit = git(repositoryPath, ['rev-parse', 'refs/heads/source-main']);
const source = {
repository, repositoryPath, commit,
commitDate: git(repositoryPath, ['show', '-s', '--format=%cI', commit]),
commitMessage: git(repositoryPath, ['show', '-s', '--format=%s', commit])
};
prepared.set(locale, source);
if (!review) requireReviewedSource(locale, source);
return source;
}
function requireReviewedSource(locale, source) {
let report;
try {
report = JSON.parse(readFileSync(path.join(projectRoot, 'data/holodoridb-review/latest.json'), 'utf8'));
} catch {
throw new Error('Run npm run data:sync and present the full change report before collecting calculator data.');
}
const manifest = JSON.parse(readFileSync(path.join(projectRoot, 'data/holodoridb/manifest.json'), 'utf8'));
const entry = report.sources?.[locale];
if (entry?.to !== source.commit || ![entry.from, entry.to].includes(manifest.sources[locale].commit)) {
throw new Error(`HolodoriDB ${locale} changed since review. Run npm run data:sync and present the updated report first.`);
}
}
export function readHolodoriDbText(source, filename) {
return git(source.repositoryPath, ['show', `${source.commit}:${filename}`]);
}
export function readHolodoriDbTable(locale, filename) {
return JSON.parse(readHolodoriDbText(prepareHolodoriDbSource(locale), filename));
}
// Retired remote source. Keep imports explicit so old collectors cannot fetch or fall back.
export function prepareHolodoriDbSource() { throw new Error('HolodoriDB collection retired; use game-db-source.mjs and npm run data:sync.'); }
export const readHolodoriDbText = prepareHolodoriDbSource;
export const readHolodoriDbTable = prepareHolodoriDbSource;
export const holodoriGit = prepareHolodoriDbSource;
+109
View File
@@ -0,0 +1,109 @@
"""Export verified, decrypted game master databases; never exports keys or account data.
Usage: python scripts/import-game-db.py <recovery-directory>
Requires protobuf (use PYTHONPATH for an isolated installation).
The input contains decrypted-master/, descriptors.json and decrypt-manifest.json.
"""
import base64
import gzip
import hashlib
import json
from pathlib import Path
import sqlite3
import sys
from google.protobuf import descriptor_pool, message_factory, json_format
root = Path(__file__).resolve().parents[1]
recovery = Path(sys.argv[1]).resolve()
manifest = json.loads((recovery / 'decrypt-manifest.json').read_text(encoding='utf-8'))
version = manifest['masterVersion']
if not version.isalnum() or len(version) != 64:
raise ValueError('Invalid master version')
pool = descriptor_pool.DescriptorPool()
descriptors = (recovery / 'descriptors.json').read_bytes()
pending = {n: base64.b64decode(v) for n, v in json.loads(descriptors).items()}
while pending:
before = len(pending)
for name, raw in list(pending.items()):
try:
pool.AddSerializedFile(raw)
except Exception:
continue
del pending[name]
if len(pending) == before:
raise ValueError('Unresolved protobuf dependencies')
def digest(value):
return hashlib.sha256(value).hexdigest()
def collector_enums(value, descriptor):
"""Keep the existing collector's qualified enum spelling, without legacy input."""
fields = {f.json_name: f for f in descriptor.fields}
result = {}
for name, item in value.items():
field = fields[name]
def convert(v):
if field.enum_type and isinstance(v, str):
return field.enum_type.name + '_' + v
return collector_enums(v, field.message_type) if field.message_type else v
result[name] = [convert(v) for v in item] if field.is_repeated else convert(item)
return result
tables, outputs = {}, {}
for entry in sorted(manifest['files'], key=lambda e: e['name']):
name = entry['name']
if not name.replace('_', '').isalnum() or name in tables:
raise ValueError('Invalid or duplicate table name')
file = recovery / 'decrypted-master' / (name + '.sqlite')
if digest(file.read_bytes()) != entry['decryptedSha256']:
raise ValueError('SQLite hash mismatch: ' + name)
if entry['verifiedPages'] * manifest['pageSize'] != file.stat().st_size:
raise ValueError('Incomplete page verification: ' + name)
with sqlite3.connect(file.as_uri() + '?mode=ro', uri=True) as con:
con.row_factory = sqlite3.Row
if [r[0] for r in con.execute('PRAGMA integrity_check')] != ['ok']:
raise ValueError('SQLite integrity failure: ' + name)
sqltables = [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")]
if len(sqltables) != 1:
raise ValueError('Expected one master table: ' + name)
table = sqltables[0]
desc = pool.FindMessageTypeByName('entity.master.' + table)
cls = message_factory.GetMessageClass(desc)
rows = []
for row in con.execute('SELECT * FROM "' + table.replace('"', '""') + '"'):
message = cls.FromString(row['data'])
original = message.SerializeToString(deterministic=True)
message.DiscardUnknownFields()
if message.SerializeToString(deterministic=True) != original:
raise ValueError('Unknown protobuf fields: ' + name)
keys = {k: row[k] for k in row.keys() if k != 'data'}
# SQL index values can be int64; preserve exact integers for JavaScript.
keys = {k: str(v) if isinstance(v, int) and abs(v) > 2**53 - 1 else v for k, v in keys.items()}
rows.append({**keys, 'data': collector_enums(json_format.MessageToDict(message), desc)})
raw = (json.dumps(rows, ensure_ascii=False, separators=(',', ':')) + '\n').encode()
packed = gzip.compress(raw, mtime=0)
tables[name] = {'file': name + '.json.gz', 'rows': len(rows), 'sha256': digest(raw),
'gzipSha256': digest(packed), 'sqliteSha256': entry['decryptedSha256'],
'encryptedSha256': entry['encryptedSha256']}
outputs[name + '.json.gz'] = packed
snapshot = {'schemaVersion': 1, 'kind': 'android-master-db', 'masterDataVersion': version,
'package': 'game.qualiarts.hololive.dreams.com', 'locale': 'kor',
'capturedAt': manifest['generatedAt'], 'descriptorSha256': digest(descriptors),
'tables': tables}
raw_manifest = (json.dumps(snapshot, ensure_ascii=False, indent=2) + '\n').encode()
destination = root / 'data/game-db/snapshots' / version
# Validate the complete snapshot before writing, and keep versions immutable.
if destination.exists():
if (destination / 'manifest.json').read_bytes() != raw_manifest or any(
(destination / file).read_bytes() != data for file, data in outputs.items()):
raise ValueError('A different snapshot already uses this master version')
else:
destination.mkdir(parents=True)
for file, data in outputs.items():
(destination / file).write_bytes(data)
(destination / 'manifest.json').write_bytes(raw_manifest)
pointer = {'schemaVersion': 1, 'snapshot': 'snapshots/' + version,
'manifestSha256': digest(raw_manifest)}
(root / 'data/game-db/current.json').write_text(json.dumps(pointer, indent=2) + '\n', encoding='utf-8')
print(f'Imported {len(tables)} tables / {sum(t["rows"] for t in tables.values())} rows from {version}')
+34
View File
@@ -0,0 +1,34 @@
import { mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { gameDbRoot, openGameDb, prepareGameDbSource, readGameDbText, sourceMetadata } from './game-db-source.mjs';
import { jsonChanges } from './holodoridb-diff.mjs';
const current = prepareGameDbSource('kor', { review: true });
const baselineFile = path.join(gameDbRoot, 'applied.json');
const previous = existsSync(baselineFile) ? openGameDb(JSON.parse(readFileSync(baselineFile, 'utf8'))) : null;
const tables = [];
for (const name of [...new Set([...Object.keys(previous?.tables ?? {}), ...Object.keys(current.tables)])].sort()) {
const before = previous?.tables[name] ? JSON.parse(readGameDbText(previous, name + '.json')) : [];
const after = current.tables[name] ? JSON.parse(readGameDbText(current, name + '.json')) : [];
tables.push({ name, beforeCount: before.length, afterCount: after.length,
changes: jsonChanges(before, after) });
}
const report = { schemaVersion: 1, generatedAt: new Date().toISOString(), initialImport: !previous,
baseline: previous ? sourceMetadata(previous) : null, target: sourceMetadata(current), tables };
const lines = ['# 게임 DB 검토', '', `원본: ${current.masterDataVersion}`, '',
previous ? `이전 수집: ${previous.masterDataVersion}` : '첫 DB 등록입니다. 아래 수량은 신규 콘텐츠 수가 아니라 전체 스냅샷 수량입니다.', '',
'변경 전후의 전체 값은 latest.json에 저장합니다. 일정·배율 등 DB에 없는 서버 데이터는 추정하지 않습니다.', ''];
const categories = [
['카드/스킬/보드/메모리', /Card|Character|Costume|Live.*Skill|SkillTree|Poster/],
['악곡', /Music/], ['이벤트', /Event|Marathon|Mission/], ['번역', /^Lang/], ['기타 시스템', /./]
];
const remaining = new Set(tables.filter(t => t.changes.length).map(t => t.name));
for (const [label, pattern] of categories) {
lines.push(`## ${label}`, '');
const changed = tables.filter(t => remaining.has(t.name) && pattern.test(t.name));
lines.push(...(changed.length ? changed.map(t => { remaining.delete(t.name); return `- ${t.name}: ${t.beforeCount} → ${t.afterCount}행, 변경 ${t.changes.length}곳 (의미는 전체 값 검토 필요)`; }) : ['변경 없음']), '');
}
mkdirSync(path.join(gameDbRoot, 'review'), { recursive: true });
writeFileSync(path.join(gameDbRoot, 'review/latest.json'), JSON.stringify(report, null, 2) + '\n');
writeFileSync(path.join(gameDbRoot, 'review/latest.md'), lines.join('\n'));
console.log(`Verified ${tables.length} tables; ${tables.filter(t => t.changes.length).length} changed. Review: data/game-db/review/latest.md`);
+2 -86
View File
@@ -1,86 +1,2 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { prepareHolodoriDbSource, holodoriGit as git } from './holodoridb-source.mjs';
import { jsonChanges } from './holodoridb-diff.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const output = path.join(root, 'data/holodoridb-review');
const baselineIndex = process.argv.indexOf('--baseline-ref');
const baselineRef = baselineIndex < 0 ? null : process.argv[baselineIndex + 1];
if (baselineIndex >= 0 && (!baselineRef || baselineRef.startsWith('-'))) throw new Error('--baseline-ref requires a git revision.');
const manifest = JSON.parse(baselineRef
? git(root, ['show', `${baselineRef}:data/holodoridb/manifest.json`])
: await readFile(path.join(root, 'data/holodoridb/manifest.json'), 'utf8'));
const report = { generatedAt: new Date().toISOString(), baselineCollectedAt: manifest.generatedAt, sources: {} };
const lines = ['# HolodoriDB 전체 변경 검토', '', `마지막 수집: ${manifest.generatedAt}`, `검토 시각: ${report.generatedAt}`, '',
'계산기 필요 여부로 필터링하지 않은 전체 원격 변경입니다. 검토는 수집 기준 커밋이나 계산기 데이터를 갱신하지 않습니다.', ''];
const patches = [];
for (const locale of ['eng', 'kor', 'jpn']) {
const source = prepareHolodoriDbSource(locale, { review: true });
const from = manifest.sources[locale].commit;
const to = source.commit;
// Full history is necessary: endpoint diffs alone miss changes later reverted.
if (from !== to) {
const shallow = git(source.repositoryPath, ['rev-parse', '--is-shallow-repository']) === 'true';
git(source.repositoryPath, ['fetch', ...(shallow ? ['--unshallow'] : []), '--no-tags', source.repository, 'refs/heads/main']);
try {
git(source.repositoryPath, ['merge-base', '--is-ancestor', from, to]);
} catch (error) {
throw new Error(`${locale}: previous collection is not an ancestor of remote main; cannot certify a complete change report.`, { cause: error });
}
}
const commits = from === to ? [] : git(source.repositoryPath, ['rev-list', '--reverse', '--topo-order', `${from}..${to}`]).split('\n').filter(Boolean);
const entry = { from, to, repository: source.repository, commitDate: source.commitDate, commits: [], netDiff: '' };
lines.push(`## ${locale}`, '', `기준: ${from}`, `최신: ${to}`, '');
if (!commits.length) lines.push('변경 없음.', '');
for (const commit of commits) {
const title = git(source.repositoryPath, ['show', '-s', '--format=%s', commit]);
const date = git(source.repositoryPath, ['show', '-s', '--format=%cI', commit]);
const files = git(source.repositoryPath, ['diff-tree', '--root', '-m', '--no-commit-id', '--name-status', '-r', '--no-renames', commit]);
const numstat = git(source.repositoryPath, ['show', '--format=', '--numstat', '--no-renames', '-m', commit]);
const patch = git(source.repositoryPath, ['show', '--format=fuller', '--binary', '--no-ext-diff', '--no-renames', '-m', commit]);
const parents = git(source.repositoryPath, ['show', '-s', '--format=%P', commit]).split(' ').filter(Boolean);
const details = [];
for (const parent of parents.length ? parents : [null]) {
const changed = parent
? git(source.repositoryPath, ['diff', '--name-status', '--no-renames', parent, commit])
: files;
for (const line of changed.split('\n').filter(Boolean)) {
const [status, filename] = line.split('\t');
if (!filename.endsWith('.json')) continue;
const before = status === 'A' ? undefined : JSON.parse(git(source.repositoryPath, ['show', `${parent}:${filename}`]));
const after = status === 'D' ? undefined : JSON.parse(git(source.repositoryPath, ['show', `${commit}:${filename}`]));
details.push({ parent, filename, changes: jsonChanges(before, after) });
}
}
entry.commits.push({ commit, title, date, files, numstat, details });
patches.push(`Repository: ${source.repository}\n${patch}\n`);
const fileLines = files.split('\n').filter(Boolean);
const strings = fileLines.filter(line => /\tLang[^\t]*\.json$/.test(line));
const masters = fileLines.filter(line => /\.json$/.test(line) && !strings.includes(line));
const other = fileLines.filter(line => !strings.includes(line) && !masters.includes(line));
lines.push(`### ${commit.slice(0, 8)} — ${title}`, '', `시각: ${date}`, '',
`문자열 ${strings.length}건 · 마스터 JSON ${masters.length}건 · 기타 ${other.length}건`, '');
for (const [label, group] of [['문자열', strings], ['마스터 JSON', masters], ['기타', other]]) {
if (group.length) lines.push(`**${label}** (A 추가 / M 수정 / D 삭제)`, '', '```text', ...group, '```', '');
}
lines.push('파일별 추가·삭제 줄 수:', '', '```text', numstat, '```', '');
for (const detail of details) {
const counts = Object.fromEntries(['added', 'deleted', 'modified'].map(type => [type, detail.changes.filter(change => change.type === type).length]));
lines.push(`- ${detail.filename}: 항목/필드 추가 ${counts.added}, 삭제 ${counts.deleted}, 수정 ${counts.modified}`);
}
lines.push('', '전체 변경 항목의 ID·필드 경로·변경 전후 값은 latest.json의 details에 기록했습니다.', '');
}
entry.netDiff = from === to ? '' : git(source.repositoryPath, ['diff', '--stat', from, to]);
report.sources[locale] = entry;
}
lines.push('## 후속 작업', '', 'changes.patch의 전체 필드·문자열 변경을 읽고 사용자에게 의미와 영향을 요약해 먼저 제공합니다. 그 다음 계산기 데이터 확보를 진행합니다.', '',
'계산기 반영: `npm run data:apply`. 원격 커밋이 검토 이후 바뀌면 다시 검토해야 합니다.', '');
await mkdir(output, { recursive: true });
await writeFile(path.join(output, 'changes.patch'), patches.join('\n'), 'utf8');
await writeFile(path.join(output, 'latest.md'), lines.join('\n'), 'utf8');
// Publish the machine-readable review marker only after all three locales succeed.
await writeFile(path.join(output, 'latest.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(lines.join('\n'));
console.log(`Full changes: ${path.join(output, 'changes.patch')}`);
// Compatibility command: no network fetch or legacy fallback.
import './review-game-db.mjs';
+19 -21
View File
@@ -1,27 +1,25 @@
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { readGameDbTable } from './game-db-source.mjs';
import { susScoreWeight } from './sus-score-weight.mjs';
import { scoreRatioFromFormula } from '../src/lib/engine/score.ts';
// Pinned research snapshot, with source URL, retrieval time and SHA-256.
const reference = JSON.parse(await readFile(new URL('../docs/research/song-score-formula-reference.json', import.meta.url), 'utf8'));
const susReference = JSON.parse(await readFile(new URL('../docs/research/sus-score-formulas.json', import.meta.url), 'utf8'));
// Newly recovered charts carry their full reproducibility record in validationCharts until
// the next research snapshot is consolidated.
const rows = [...reference.charts, ...susReference.charts, ...susReference.validationCharts.filter((row) => row.fullComboNoteCount)];
const formulas = new Map(rows.map((row) => [`${row.songId}.${row.difficulty}`, row]));
if (formulas.size !== rows.length) throw new Error('중복 악곡 점수 공식입니다.');
const music = new Map(readGameDbTable('kor', 'Music.json').map(({ data }) => [data.id, data]));
const levels = new Map(readGameDbTable('kor', 'MusicDifficulty.json').map(({ data }) =>
[data.musicId + '.' + data.difficultyType.split('_').at(-1).toLowerCase(), data.difficultyLevel]));
export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, liveScoreCoefficientPermil, susSha256) {
const row = formulas.get(`${songId}.${difficulty.toLowerCase()}`);
if (!row) return null;
if (row.susSha256 && row.susSha256 !== susSha256) throw new Error(`${songId}.${difficulty}: 원본 SUS 해시가 다릅니다.`);
if (row.difficultyLevel !== difficultyLevel || row.fullComboNoteCount !== totalNotes ||
(liveScoreCoefficientPermil !== undefined && row.liveScoreCoefficientPermil !== liveScoreCoefficientPermil)) {
throw new Error(`${songId}.${difficulty}: 채보 또는 마스터 데이터가 점수 공식 참고값과 다릅니다.`);
export function formulaScoreMetadata(songId, difficulty, difficultyLevel, totalNotes, coefficient, expectedHash) {
const master = music.get(songId), key = songId + '.' + difficulty;
if (!master || levels.get(key) !== difficultyLevel) throw new Error(key + ': DB metadata mismatch');
const bytes = readFileSync(new URL('../data/raw/sus/chart_' + songId + '_' + difficulty + '.sus', import.meta.url));
const hash = createHash('sha256').update(bytes).digest('hex');
if (expectedHash && hash !== expectedHash) throw new Error(key + ': SUS hash mismatch');
const weight = susScoreWeight(bytes.toString('utf8'));
if (weight.songId !== songId || weight.fullComboNoteCount !== totalNotes ||
(coefficient !== undefined && coefficient !== master.liveScoreCoefficientPermil)) {
throw new Error(key + ': SUS / DB score inputs mismatch');
}
const scoreFormula = {
perfectNoteWeight: row.perfectNoteWeight,
liveScoreCoefficientPermil: row.liveScoreCoefficientPermil,
difficultyLevel: row.difficultyLevel
};
const scoreFormula = { perfectNoteWeight: weight.perfectNoteWeight,
liveScoreCoefficientPermil: master.liveScoreCoefficientPermil, difficultyLevel };
return { scoreRatio: scoreRatioFromFormula(scoreFormula), scoreCalibrationSource: 'formula-v1', scoreFormula };
}
+21 -1
View File
@@ -171,5 +171,25 @@ export function parseSusTimeline(text) {
throw new Error(`${headers.get('MUSIC_ID')}: SUS category mismatch: regular ${regularCount}/${expectedRegular}, mid ${midCount}/${expectedMid}, flick ${flickCount}/${headers.get('FLICK_NOTE_COUNT')}`);
}
notes.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt) };
// Keep visual geometry separate from score judgements (invisible continuation ticks
// are not tap notes, and a hold-end flick still has regular score weight).
const directional = new Map(flicks.filter(note => [1, 3, 4].includes(note.kind)).map(note => [key(note), note.kind === 3 ? 'left' : note.kind === 4 ? 'right' : 'up']));
const criticals = new Set(taps.filter(note => [2, 6].includes(note.kind)).map(key));
const attached = new Set(taps.filter(note => note.kind === 3).map(key));
const point = note => ({ timeSeconds: secondsAt(note.beat), lane: note.lane - 2, width: note.width });
const visualNote = (note, kind) => ({ ...point(note), kind,
...(directional.has(key(note)) ? { direction: directional.get(key(note)) } : {}),
...(criticals.has(key(note)) ? { critical: true } : {}) });
const visualNotes = taps.filter(note => [1, 2].includes(note.kind) && !holdStartKeys.has(key(note)) && !holdEndKeys.has(key(note)))
.map(note => visualNote(note, directional.has(key(note)) ? 'flick' : 'tap'));
visualNotes.push(...flicks.filter(note => [1, 3, 4].includes(note.kind) && !tapKeys.has(key(note)) && !holdStartKeys.has(key(note)) && !holdEndKeys.has(key(note)))
.map(note => visualNote(note, 'flick')));
for (const hold of holds) {
visualNotes.push(visualNote(hold[0], 'hold'), visualNote(hold.at(-1), 'hold'));
}
visualNotes.push(...visibleRelays.map(note => visualNote(note, 'relay')));
const visualHolds = holds.map(hold => hold.filter((note, i) => i === 0 || i === hold.length - 1 || !attached.has(key(note)))
.map(note => ({ ...point(note), ...(note.kind === 4 ? { control: true } : {}) })));
return { songId: headers.get('MUSIC_ID'), notes, specialStartSeconds: specialStarts.sort((left, right) => left - right).map(secondsAt),
visual: { laneCount: 12, notes: visualNotes.sort((a, b) => a.timeSeconds - b.timeSeconds), holds: visualHolds } };
}
+12
View File
@@ -49,6 +49,18 @@ test('헤더 불일치를 후반 삭제나 임의 중간 노트 추가로 숨기
assert.throws(()=>parseSusTimeline(fixture.replace('#FLICK_NOTE_COUNT 1','#FLICK_NOTE_COUNT 0')), /category mismatch/);
});
test('채보에는 레인·너비·끝 플릭과 숨은 연결점을 보존하고 자동 판정은 그리지 않는다', () => {
const { visual } = parseSusTimeline(fixture);
assert.equal(visual.laneCount, 12);
assert.equal(visual.notes.length, 5);
assert.deepEqual(visual.notes[0], { timeSeconds: 0.25, lane: 0, width: 3, kind: 'hold' });
assert.deepEqual(visual.notes[2], { timeSeconds: 1.25, lane: 0, width: 3, kind: 'hold', direction: 'up' });
assert.equal(visual.holds[0].length, 4);
const curved = parseSusTimeline(fixture.replace('53', '43')).visual;
assert.equal(curved.holds[0][2].control, true);
assert.equal(curved.notes.some(note => note.timeSeconds === 1), false);
});
test('마디 길이 변경은 다음 변경까지 유지한다', () => {
const text = `#MUSIC_ID m0002
#BASEBPM 60
+47 -60
View File
@@ -1,76 +1,63 @@
// Convert Hololive Dreams' decrypted SUS dialect to this application's compact score timeline.
import { readFile, writeFile } from 'node:fs/promises';
// Generate score timelines from game SUS assets and metadata from the captured DB.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { readHolodoriDbTable as readMaster, prepareHolodoriDbSource } from './holodoridb-source.mjs';
import path from 'node:path';
import { readGameDbTable as readMaster, prepareGameDbSource, sourceMetadata } from './game-db-source.mjs';
import { susScoreWeight } from './sus-score-weight.mjs';
import { formulaScoreMetadata } from './song-score-formulas.mjs';
import { parseSusTimeline } from './sus-timeline.mjs';
const difficulties = ['easy', 'normal', 'hard', 'expert'];
const overrides = JSON.parse(await readFile(new URL('../data/song-metadata-overrides.json', import.meta.url), 'utf8')).songs;
const music = new Map(readMaster('kor', 'Music.json').map((row) => { const data = row.data ?? row; return [data.id, data]; }));
const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map((row) => { const data = row.data ?? row; return [data.id, data.text]; }));
const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map((row) => { const data = row.data ?? row; return [`${data.musicId}.${data.difficultyType.split('_').at(-1).toLowerCase()}`, data.difficultyLevel]; }));
const reviewedSource = prepareHolodoriDbSource('kor');
const masterDataSource = { repository: reviewedSource.repository, commit: reviewedSource.commit, locale: 'kor' };
const songIds = process.argv.slice(2);
if (songIds.length === 0) throw new Error('곡 ID 또는 곡 ID.난이도를 지정하세요.');
const selected = songIds.flatMap((value) => {
const difficulties = ['easy', 'normal', 'hard', 'expert'];
const music = new Map(readMaster('kor', 'Music.json').map(({ data }) => [data.id, data]));
const translation = new Map(readMaster('kor', 'LangMusic_Kor.json').map(({ data }) => [data.id, data.text]));
const key = data => data.musicId + '.' + data.difficultyType.split('_').at(-1).toLowerCase();
const levels = new Map(readMaster('kor', 'MusicDifficulty.json').map(({ data }) => [key(data), data.difficultyLevel]));
const counts = new Map(readMaster('kor', 'MusicDifficultyChart.json').map(({ data }) => [key(data), data.fullComboNoteCount]));
const masterDataSource = sourceMetadata(prepareGameDbSource());
const args = process.argv.slice(2);
const all = args.length === 0 || (args.length === 1 && args[0] === '--all');
const now = Date.now();
const existingOnly = args.length === 1 && args[0] === '--existing';
const existingCatalog = JSON.parse(await readFile('src/lib/data/exact-chart-catalog.json', 'utf8'));
const ids = existingOnly ? [...new Set(existingCatalog.charts.map(e => e.songId))] : all ? [...music.values()].filter(m => Number(m.startTime) <= now).map(m => m.id) : args;
const selected = ids.flatMap(value => {
const match = value.match(/^(m\d{4})(?:\.(easy|normal|hard|expert))?$/);
if (!match) throw new Error(`잘못된 채보 선택: ${value}`);
return (match[2] ? [match[2]] : difficulties).map((difficulty) => ({ songId: match[1], difficulty }));
if (!match) throw new Error('Invalid chart selection: ' + value);
return (match[2] ? [match[2]] : difficulties).map(difficulty => ({ songId: match[1], difficulty }));
});
const generatedCatalogEntries = [];
const outputs = [];
const outputs = [], entries = [];
for (const { songId, difficulty } of selected) {
const file = path.resolve(`data/raw/sus/chart_${songId}_${difficulty}.sus`);
const bytes = await readFile(file);
const master = music.get(songId), chartKey = songId + '.' + difficulty;
const bytes = await readFile('data/raw/sus/chart_' + songId + '_' + difficulty + '.sus');
const timeline = parseSusTimeline(bytes.toString('utf8'));
const weight = susScoreWeight(bytes.toString('utf8'));
if (timeline.songId !== songId || timeline.notes.length !== weight.fullComboNoteCount) {
const mids = timeline.notes.filter((note) => note.type === 'mid').length;
throw new Error(`${songId}.${difficulty}: 파서 노트 수 ${timeline.notes.length}/${weight.fullComboNoteCount} 불일치 (중간 ${mids}, 헤더 ${weight.counts.LONG_RELAY + weight.counts.LONG_CONTINUE})`);
}
const officialMaster = music.get(songId);
const metadataSource = !officialMaster ? overrides[songId] : undefined;
let supplemental;
if (metadataSource) {
const metadataBytes = await readFile(path.resolve(metadataSource.file));
if (createHash('sha256').update(metadataBytes).digest('hex') !== metadataSource.sha256) {
throw new Error(songId + ': 보충 메타데이터 해시가 다릅니다.');
}
supplemental = JSON.parse(metadataBytes.toString('utf8'));
if (supplemental.id !== songId) throw new Error(songId + ': 보충 메타데이터 곡 ID가 다릅니다.');
}
// Fresh official master takes precedence over the pinned supplementary response.
const master = officialMaster ?? supplemental;
const supplementalDifficulty = supplemental?.difficulties.find((row) => row.difficultyType.split('_').at(-1).toLowerCase() === difficulty);
const difficultyLevel = levels.get(songId + '.' + difficulty) ?? supplementalDifficulty?.difficultyLevel;
const releaseTime = Number(master?.startTime);
const difficultyLevel = levels.get(chartKey), releaseTime = Number(master?.startTime);
const durationSeconds = master?.playingSeconds;
if (!master || !Number.isInteger(difficultyLevel) || difficultyLevel < 1 || !Number.isFinite(releaseTime) ||
!Number.isFinite(durationSeconds) || durationSeconds < timeline.notes.at(-1).timeSeconds ||
!Number.isFinite(master.liveScoreCoefficientPermil) || !Array.isArray(master.characterIds) || !master.musicSingerType) {
throw new Error(songId + '.' + difficulty + ': 검증된 곡 메타데이터가 필요합니다.');
if (!master || !Number.isInteger(difficultyLevel) || !Number.isFinite(releaseTime) || releaseTime > now ||
!Number.isFinite(durationSeconds) || durationSeconds + 1 < timeline.notes.at(-1)?.timeSeconds ||
!Number.isFinite(master.liveScoreCoefficientPermil) || !master.musicSingerType || !translation.has(master.titleLangId)) {
throw new Error(chartKey + ': complete, released DB metadata required');
}
if (supplementalDifficulty && (supplementalDifficulty.fullComboNoteCount !== weight.fullComboNoteCount || supplementalDifficulty.normalNoteCount !== weight.counts.NORMAL)) {
throw new Error(songId + '.' + difficulty + ': 메타데이터와 SUS 노트 수가 다릅니다.');
if (timeline.songId !== songId || timeline.notes.length !== weight.fullComboNoteCount || counts.get(chartKey) !== weight.fullComboNoteCount) {
throw new Error(chartKey + ': DB / SUS / timeline note counts differ (' + [counts.get(chartKey), weight.fullComboNoteCount, timeline.notes.length].join('/') + ')');
}
const susSha256 = createHash('sha256').update(bytes).digest('hex');
const formula = formulaScoreMetadata(songId, difficulty, difficultyLevel, timeline.notes.length, master.liveScoreCoefficientPermil, susSha256);
if (!formula) throw new Error(songId + '.' + difficulty + ': 검증된 점수 공식이 필요합니다.');
const output = { schemaVersion: 1, songId, difficulty, durationSeconds, ...formula, notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds, singerType: master.musicSingerType.split('_').at(-1).toLowerCase(), singerCharacterIds: master.characterIds, source: { page: 'Hololive Dreams CDN via holodori-asset-tools', transcriptionStatus: 'exact_from_decrypted_sus', susSha256, ...(metadataSource ? { metadataSource } : { masterDataSource }) } };
outputs.push({ filename: path.resolve('static/data/charts/' + songId + '.' + difficulty + '.json'), output });
generatedCatalogEntries.push({ songId, title: translation.get(master.titleLangId) ?? supplemental?.title?.kor ?? songId, difficulty, difficultyLevel, durationSeconds, scoreRatio: output.scoreRatio, scoreCalibrationSource: output.scoreCalibrationSource, scoreFormula: output.scoreFormula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds, releaseTime, path: '/data/charts/' + songId + '.' + difficulty + '.json' });
console.log(`${songId}.${difficulty}: ${timeline.notes.length} notes ${timeline.specialStartSeconds.join(', ')}`);
const output = { schemaVersion: 1, songId, difficulty, durationSeconds, ...formula,
notes: timeline.notes, specialStartSeconds: timeline.specialStartSeconds,
singerType: master.musicSingerType.split('_').at(-1).toLowerCase(), singerCharacterIds: master.characterIds ?? [],
source: { kind: 'game-sus', transcriptionStatus: 'exact_from_decrypted_sus', susSha256, masterDataSource } };
outputs.push(['static/data/charts/' + chartKey + '.json', output]);
entries.push({ songId, title: translation.get(master.titleLangId), difficulty, difficultyLevel, durationSeconds,
...formula, totalNotes: output.notes.length, singerType: output.singerType, singerCharacterIds: output.singerCharacterIds,
releaseTime, path: '/data/charts/' + chartKey + '.json' });
}
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
const generatedKeys = new Set(generatedCatalogEntries.map((entry) => `${entry.songId}.${entry.difficulty}`));
catalog.charts = [...catalog.charts.filter((entry) => !generatedKeys.has(`${entry.songId}.${entry.difficulty}`)), ...generatedCatalogEntries]
.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
// Finish validation of every selected chart before writing any service file.
for (const { filename, output } of outputs) await writeFile(filename, JSON.stringify(output));
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
const catalogPath = 'src/lib/data/exact-chart-catalog.json';
const old = JSON.parse(await readFile(catalogPath, 'utf8'));
const keys = new Set(entries.map(e => e.songId + '.' + e.difficulty));
const charts = [...(all ? [] : old.charts.filter(e => !keys.has(e.songId + '.' + e.difficulty))), ...entries]
.sort((a, b) => a.songId.localeCompare(b.songId) || a.difficulty.localeCompare(b.difficulty));
await mkdir('static/data/charts', { recursive: true });
// Nothing is written until every selected chart passes validation.
for (const [file, data] of outputs) await writeFile(file, JSON.stringify(data));
await writeFile(catalogPath, JSON.stringify({ schemaVersion: 1, source: 'Game master DB and game SUS assets', masterDataSource, charts }, null, 2) + '\n');
console.log('Generated ' + outputs.length + ' game charts; catalog ' + charts.length + '. Future songs remain in the DB snapshot.');