706 lines
26 KiB
JavaScript
706 lines
26 KiB
JavaScript
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`);
|