feat: complete placement analysis workflow

This commit is contained in:
2026-08-18 14:22:04 +09:00
parent dd3ea911f6
commit 7b713bffe3
452 changed files with 97115 additions and 24 deletions
+126 -4
View File
@@ -10,6 +10,7 @@ const rawRoot = path.join(projectRoot, 'data', 'raw', 'holodoridb');
const outputRoot = path.join(projectRoot, 'data', 'holodoridb');
const appDataRoot = path.join(projectRoot, 'src', 'lib', 'data');
const cardOverridesPath = path.join(projectRoot, 'data', 'card-overrides.csv');
const offline = process.argv.includes('--offline');
function parseCsvLine(line) {
const values = [];
@@ -97,12 +98,18 @@ const masterTableNames = [
'LiveSkillTrigger',
'LiveSkillEffectTarget',
'LiveSkillEffectFilterCategory',
'Poster',
'PosterCollectEffect',
'SkillTreeConnectEffect',
'SkillTreeConnectEffectExtent',
'SkillTreeEffect',
'SkillTreeEffectPassiveTrigger',
'SkillTreeEffectTarget',
'SkillTreeEffectValueLimit'
'SkillTreeEffectValueLimit',
'SkillTreeNode',
'SkillTreeNodeAutoSelection',
'SkillTreeNodePosition',
'SkillTreePoint'
];
const localizationTableNames = [
@@ -148,8 +155,12 @@ async function prepareSource(locale, source) {
try {
gitAt(repositoryPath, ['rev-parse', '--git-dir']);
process.stdout.write(`Updating ${locale} source...\n`);
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
if (offline) {
process.stdout.write(`Using cached ${locale} source...\n`);
} else {
process.stdout.write(`Updating ${locale} source...\n`);
gitAt(repositoryPath, ['fetch', '--depth', '1', 'origin', 'main']);
}
} catch {
process.stdout.write(`Cloning ${locale} source...\n`);
await mkdir(path.dirname(repositoryPath), { recursive: true });
@@ -331,6 +342,9 @@ const triggersByGroup = groupBy(tables.LiveSkillTrigger, 'groupId');
const effectTargetsById = indexBy(tables.LiveSkillEffectTarget);
const connectEffectsById = groupBy(tables.SkillTreeConnectEffect, 'id');
const connectExtentsByGroup = groupBy(tables.SkillTreeConnectEffectExtent, 'groupId');
const skillTreeEffectsById = indexBy(tables.SkillTreeEffect);
const skillTreeNodesByGroup = groupBy(tables.SkillTreeNode, 'groupId');
const skillTreePositionsByGroup = groupBy(tables.SkillTreeNodePosition, 'groupId');
const costumesById = indexBy(tables.Costume);
const sdCostumesById = indexBy(tables.SdCostume);
const leaderSkillsById = indexBy(tables.LiveLeaderSkill);
@@ -588,9 +602,17 @@ const cardSummary = {
const cardOverride = cardOverrides.get(card.id);
const highestStat = Object.entries(statLabels)
.sort(([left], [right]) => Number(card[right]) - Number(card[left]))[0][1];
const maxLevel = cardRecord.progression.levels.at(-1);
const parameterBaseValue = Number(maxLevel?.parameterBaseValue ?? 0);
const statsAtMaxLevel = {
performance: Math.round(parameterBaseValue * Number(card.performancePermilMultiply ?? 0) / 1000),
technique: Math.round(parameterBaseValue * Number(card.techniquePermilMultiply ?? 0) / 1000),
sense: Math.round(parameterBaseValue * Number(card.sensePermilMultiply ?? 0) / 1000)
};
return {
id: card.id,
characterId: card.characterId,
memberOrder: character.order,
rarity: Number(compactEnum(card.rarity)),
type: typeLabels[compactEnum(card.attributeType)],
@@ -598,11 +620,24 @@ const cardSummary = {
versionName: cardOverride?.versionName || '',
generation: textLookups.kor.get(character.regularCharacterGroupingNameLangId) || '',
highestStat,
power: {
maxLevel: Number(maxLevel?.level ?? 0),
statsAtMaxLevel
},
details: {
title: textLookups.kor.get(card.nameLangId),
passive: summarizeSkillLevels(cardRecord.skills.passive.levels),
active: summarizeConditionalSkillLevels(cardRecord.skills.active.levels),
special: summarizeSpecialSkillLevels(cardRecord.skills.special.levels),
connect: cardRecord.skillTreeConnectEffects.map((effect) => ({
level: Number(effect.level ?? 1),
description: effect.translations?.description?.kor ?? '',
effectPermilUp: Number(effect.effectPermilUp ?? 0),
extent: effect.extent.map((position) => ({
x: Number(position.positionX ?? 0),
y: Number(position.positionY ?? 0)
}))
})),
costume: cardRecord.rewards.costume
? {
name: cardRecord.rewards.costume.translations?.name?.kor ?? '',
@@ -622,16 +657,103 @@ const cardSummary = {
.map(({ memberOrder: _memberOrder, ...card }) => card)
};
function skillTreeNodeForCharacter(characterId, groupId) {
const candidates = skillTreeNodesByGroup.get(groupId) ?? [];
return candidates.find((node) => node.characterIds?.includes(characterId))
?? candidates.find((node) => !node.characterIds?.length)
?? null;
}
function stripEnumPrefix(value, prefix) {
return typeof value === 'string' ? value.replace(prefix, '').toLowerCase() : value;
}
function summarizeBoardNode(node) {
const effect = skillTreeEffectsById.get(node.skillTreeEffectId);
return {
number: node.number,
characterIds: node.characterIds ?? [],
grade: node.grade,
type: stripEnumPrefix(node.type, 'SkillTreeNodeType_SKILL_TREE_NODE_TYPE_'),
skillTreePointCost: node.consumptionSkillTreePointQuantity ?? 0,
consumptions: node.consumptions ?? [],
viewConditionGroupId: node.viewConditionGroupId ?? null,
effect: effect ? {
id: effect.id,
type: stripEnumPrefix(effect.effectType, 'SkillTreeEffectType_SKILL_TREE_EFFECT_TYPE_'),
value: effect.value == null ? null : Number(effect.value),
targetId: effect.skillTreeEffectTargetId ?? null,
trigger: stripEnumPrefix(effect.characterTriggerType, 'SkillTreeEffectCharacterTriggerType_SKILL_TREE_EFFECT_CHARACTER_TRIGGER_TYPE_'),
description: textLookups.kor.get(effect.descriptionLangId) ?? ''
} : null
};
}
const boardSummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
members: tables.Character
.map(unwrap)
.filter((character) => character.isPlayable && character.skillTreeNodePositionGroupId)
.map((character) => ({
id: character.id,
name: textLookups.kor.get(character.nameLangId) ?? character.nameEng,
order: character.order,
positionGroupId: character.skillTreeNodePositionGroupId
}))
.sort((left, right) => left.order - right.order),
models: Object.fromEntries(
[...new Set(tables.Character.map(unwrap).map((character) => character.skillTreeNodePositionGroupId).filter(Boolean))]
.sort()
.map((groupId) => [groupId, (skillTreePositionsByGroup.get(groupId) ?? [])
.map((position) => ({
groupId: position.skillTreeNodeGroupId,
x: position.positionX ?? 0,
y: position.positionY ?? 0
}))
.sort((left, right) => left.y - right.y || left.x - right.x || left.groupId.localeCompare(right.groupId))])
),
nodeGroups: Object.fromEntries(
[...skillTreeNodesByGroup.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([groupId, nodes]) => [groupId, sortByNumber(nodes, 'number').map(summarizeBoardNode)])
)
};
const memorySummary = {
schemaVersion: 1,
masterDataVersion: [...masterVersions][0],
maxCount: tables.Poster.length,
effects: tables.PosterCollectEffect
.map(unwrap)
.map((effect) => ({
count: Number(effect.threshold),
permil: Number(effect.liveDeckAllParameterUpPermilUp)
}))
.sort((left, right) => left.count - right.count)
};
assert(boardSummary.members.length > 0, 'no member boards were collected');
assert(Object.keys(boardSummary.models).length > 0, 'no board position models were collected');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId]?.length === 152), 'one or more members have incomplete board positions');
assert(boardSummary.members.every((member) => boardSummary.models[member.positionGroupId].every((position) => skillTreeNodeForCharacter(member.id, position.groupId))), 'one or more members have unresolved board nodes');
assert(memorySummary.maxCount > 0, 'no memory posters were collected');
assert(memorySummary.effects.length > 0, 'no memory collection effects were collected');
await mkdir(outputRoot, { recursive: true });
await mkdir(appDataRoot, { recursive: true });
await Promise.all([
writeFile(path.join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`),
writeFile(path.join(outputRoot, 'card-master.json'), `${JSON.stringify(masterBundle, null, 2)}\n`),
writeFile(path.join(outputRoot, 'cards.json'), `${JSON.stringify(cardBundle, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'cards-summary.json'), `${JSON.stringify(cardSummary, null, 2)}\n`)
writeFile(path.join(appDataRoot, 'cards-summary.json'), `${JSON.stringify(cardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'boards-summary.json'), `${JSON.stringify(boardSummary, null, 2)}\n`),
writeFile(path.join(appDataRoot, 'memory-summary.json'), `${JSON.stringify(memorySummary, null, 2)}\n`)
]);
process.stdout.write(`Collected ${cards.length} cards.\n`);
process.stdout.write(`Rarities: ${JSON.stringify(rarityCounts)}\n`);
process.stdout.write(`Localized names: ${JSON.stringify(cardNameCoverage)}\n`);
process.stdout.write(`Member boards: ${boardSummary.members.length}\n`);
process.stdout.write(`Memory posters: ${memorySummary.maxCount}\n`);
process.stdout.write(`Output: ${outputRoot}\n`);