import path from 'node:path' import config from '@aklive2d/config' import { githubDownload } from '@aklive2d/downloader' import { file } from '@aklive2d/libs' import operators, { getOperatorAlternativeId, getOperatorId, OPERATOR_SOURCE_FOLDER, } from '@aklive2d/operator' import type { CharwordTable, CharwordTableJson, InfoRegionObject, OperatorCharwordTable, Region, VoiceRegionObject, } from './types.ts' // zh_TW uses an older version of charword_table.json // zh_TW is removed const REGIONS: Region[] = ['zh_CN', 'en_US', 'ja_JP', 'ko_KR'] const REGION_URLS = { zh_CN: 'Kengxxiao/ArknightsGameData', en_US: 'Kengxxiao/ArknightsGameData_YoStar', ja_JP: 'Kengxxiao/ArknightsGameData_YoStar', ko_KR: 'Kengxxiao/ArknightsGameData_YoStar', } const DEFAULT_REGION: Region = REGIONS[0] export const defaultRegion = DEFAULT_REGION.replace('_', '-') const NICKNAME = { zh_CN: '博士', en_US: 'Doctor', ja_JP: 'ドクター', ko_KR: '박사', zh_TW: '博士', } const OPERATOR_IDS = Object.values(operators).map((operator) => { return getOperatorId(operator.filename) }) const AUTO_UPDATE_FOLDER = path.resolve( import.meta.dirname, config.dir_name.auto_update ) const DIST_DIR = path.resolve(import.meta.dirname, config.dir_name.dist) const lookup = (operatorName: string, charwordTable: CharwordTable) => { const operatorId = getOperatorId(operators[operatorName].filename) const operatorBlock = charwordTable[operatorId] return operatorBlock.ref ? charwordTable[operatorBlock.alternativeId] : operatorBlock } const getDistDir = (name: string) => { return path.join( DIST_DIR, name, config.module.charword_table.charword_table_json ) } export const getLangs = ( name: string, voiceJson: OperatorCharwordTable | null = null ) => { let data: OperatorCharwordTable if (!voiceJson) { const text = file.readSync(getDistDir(name)) if (!text) throw new Error(`File not found: ${getDistDir(name)}`) data = JSON.parse(text) } else { data = voiceJson } const voiceLangs = Object.keys(data.voiceLangs['zh-CN']) const subtitleLangs = Object.keys(data.subtitleLangs) return { voiceLangs, subtitleLangs } } export const build = async (namesToBuild: string[]) => { const err: string[] = [] const names = !namesToBuild.length ? Object.keys(operators) : namesToBuild console.log('Generating charword_table for', names.length, 'operators') const charwordTable = await updateFn(true) for (const name of names) { const charwordTableLookup = lookup(name, charwordTable) const voiceJson = {} as OperatorCharwordTable voiceJson.voiceLangs = {} voiceJson.subtitleLangs = {} const subtitleInfo = Object.keys(charwordTableLookup.info) as Region[] const voiceList = {} as { [key: string]: string[] } subtitleInfo.forEach((item) => { if (Object.keys(charwordTableLookup.info[item]).length > 0) { const key = item.replace('_', '-') voiceJson.subtitleLangs[key] = {} for (const [id, subtitles] of Object.entries( charwordTableLookup.voice[item] )) { const match = id.replace(/(.+?)([A-Z]\w+)/, '$2') if (match === id) { voiceJson.subtitleLangs[key].default = subtitles voiceList[key] = Object.keys(subtitles) } else { voiceJson.subtitleLangs[key][match] = subtitles } } voiceJson.voiceLangs[key] = {} Object.values(charwordTableLookup.info[item]).forEach( (item) => { voiceJson.voiceLangs[key] = { ...voiceJson.voiceLangs[key], ...item, } } ) } }) let voiceLangs = [] as string[] try { voiceLangs = getLangs(name, voiceJson).voiceLangs file.writeSync(JSON.stringify(voiceJson), getDistDir(name)) } catch (e) { console.log(`charword_table is not available`, e) } // check whether voice files has been added const customVoiceName = voiceLangs.filter( (i) => !config.dir_name.voice.sub.map((e) => e.lang).includes(i) )[0] const voiceLangMapping = config.dir_name.voice.sub .filter((e) => { return ( voiceLangs.includes(e.lang) || (e.lang === 'CUSTOM' && typeof customVoiceName !== 'undefined') ) }) .map((e) => { return { name: e.name, lang: e.lang === 'CUSTOM' ? customVoiceName : e.lang, lookup_region: e.lookup_region.replace('_', '-'), } }) for (const voiceSubFolderMapping of voiceLangMapping) { const voiceSubFolder = path.join( OPERATOR_SOURCE_FOLDER, name, config.dir_name.voice.main, voiceSubFolderMapping.name ) const voiceFileList = file.readdirSync(voiceSubFolder) voiceList[voiceSubFolderMapping.lookup_region].map((item) => { // an exception for detecting file existence if ( item.endsWith('043') && voiceFileList.includes(`${item}.ogg`) && (voiceSubFolderMapping.name === 'kr' || voiceSubFolderMapping.name === 'en') ) { console.log( `Voice folder ${voiceSubFolderMapping.name} for ${name} has ${item}.ogg` ) } if ( !voiceFileList.includes(`${item}.ogg`) && // make an exception !item.endsWith('043') ) { err.push( `Voice folder ${voiceSubFolderMapping.name} for ${name} is missing ${item}.ogg` ) } }) } } return err } export const update = async () => { await updateFn() } const updateFn = async (isLocalOnly = false) => { const regionObject = REGIONS.reduce( (acc, cur) => ({ ...acc, [cur]: {} }), {} ) const charwordTable = {} as CharwordTable OPERATOR_IDS.forEach((id) => { charwordTable[id] = { alternativeId: getOperatorAlternativeId(id), voice: structuredClone(regionObject) as VoiceRegionObject, info: structuredClone(regionObject) as InfoRegionObject, infile: false, ref: false, } }) await load(DEFAULT_REGION, charwordTable, isLocalOnly) await Promise.all( REGIONS.slice(1).map(async (region) => { await load(region, charwordTable, isLocalOnly) }) ) return charwordTable } const load = async ( region: Region, charwordTable: CharwordTable, isLocalOnly = false ) => { const basename = `charword_table_${region}` const filename = file .readdirSync(AUTO_UPDATE_FOLDER) .filter((item) => item.startsWith(`charword_table_${region}`))[0] const localFilePath = path.join(AUTO_UPDATE_FOLDER, filename) let data: CharwordTableJson const getOnlineData = async () => { return await download( region, path.join(path.dirname(localFilePath), `${basename}.json`) ) } if (isLocalOnly) { const text = file.readSync(localFilePath) if (!text) { data = await getOnlineData() } else { data = JSON.parse(text) } } else { data = await getOnlineData() } // put voice actor info into charword_table for (const [id, element] of Object.entries(charwordTable)) { let operatorId = id let useAlternativeId = false if (typeof data.voiceLangDict[operatorId] === 'undefined') { operatorId = element.alternativeId useAlternativeId = true } if (region === DEFAULT_REGION) { element.infile = OPERATOR_IDS.includes(operatorId) element.ref = useAlternativeId && element.infile } // not available in other region if (typeof data.voiceLangDict[operatorId] === 'undefined') { console.log( `Voice actor info of ${id} is not available in ${region}.` ) continue } if (element.infile && useAlternativeId) { // if using alternative id and infile is true, means data can be // refered inside the file // if infile is false, useAlternativeId is always true // if useAlternativeId is false, infile is always true // | case | infile | useAlternativeId | Note | // | ------------------- | ------ | ---------------- | --------------- | // | lee_trust_your_eyes | false | true | skin only | // | nearl_relight | true | true | skin, operator, no voice | // | nearl | true | false | operator only | // | w_fugue | true | false | skin, operator, voice | continue } Object.values(data.voiceLangDict[operatorId].dict).forEach((item) => { if (typeof element.info[region][item.wordkey] === 'undefined') { element.info[region][item.wordkey] = {} } element.info[region][item.wordkey][item.voiceLangType] = [ ...(typeof item.cvName === 'string' ? [item.cvName] : item.cvName), ] }) } // put voice lines into charword_table Object.values(data.charWords).forEach((item) => { const operatorInfo = Object.values(charwordTable).filter( (element) => element.info[region][item.wordKey] ) if (operatorInfo.length > 0) { for (const operator of operatorInfo) { if ( typeof operator.voice[region][item.wordKey] === 'undefined' ) { operator.voice[region][item.wordKey] = {} } operator.voice[region][item.wordKey][item.voiceId] = { title: item.voiceTitle, text: item.voiceText.replace( /{@nickname}/g, NICKNAME[region] ), } } } }) return charwordTable } const download = async ( region: keyof typeof REGION_URLS, targetFilePath: string ) => { return await githubDownload( `https://api.github.com/repos/${REGION_URLS[region]}/commits?path=${region}/gamedata/excel/charword_table.json`, `https://raw.githubusercontent.com/${REGION_URLS[region]}/master/${region}/gamedata/excel/charword_table.json`, targetFilePath ) }