179 lines
10 KiB
JavaScript
179 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
// tools/build-emotes.mjs — data/emotes.json: the 36 official in-match emotes of 卫戍协议:盟约 (research 09 §4).
|
||
//
|
||
// Sources (official gamedata, cached under .cache/gamedata/ like tools/build-data.mjs and downloaded from the same
|
||
// Kengxxiao zh_CN mirror when missing):
|
||
// excel/display_meta_table.json emoticonData: emojiDataDict (id, type, sortId, picId, desc), emoticonThemeDataDict
|
||
// (theme → emoji ids), emoticonThemeTypeDict (theme sortId, isBasic)
|
||
// excel/activity_table.json autoChessData.enabledEmoticonThemeIdList (the 6 themes of the mode = page order),
|
||
// autoChessData.constData.chatCD / chatTime (cooldown / bubble seconds)
|
||
// excel/item_table.json theme item names ("表情套组:虫动", …; optional)
|
||
//
|
||
// Output (deterministic, no timestamps):
|
||
// { version, source, chatCD, chatTime,
|
||
// themes: [{ themeId, dir, sortId, isBasic, name, emotes: [id…] }], pages, enabled-list order
|
||
// emotes: [{ id, themeId, sortId, picId, art, label }] } 36, page order then sortId
|
||
// `art` = /assets/local/emoticon/<dir>/<picId>.png (tools/local-extract; key by picId — fooldoctor_03…06 use pics
|
||
// 04/05/06/08); the mirror copy that tools/fetch-assets.mjs downloads is data/assets.json ui['emoticon/<dir>/<picId>'].
|
||
// Official emotes carry no text (desc is null); `label` is ours and only ever an aria-label.
|
||
// shared/constants.js EMOTE_THEMES mirrors this file (test/ui/emotes.test.js keeps them identical).
|
||
//
|
||
// Usage: node tools/build-emotes.mjs [--offline] [--check] [--out data/emotes.json] [--cache .cache/gamedata]
|
||
// --check compare with the existing output file instead of writing it (exit 1 when it would change)
|
||
|
||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
||
import { existsSync } from 'node:fs';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const GAMEDATA_URL = 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/';
|
||
const SCENE = 'AUTOCHESS_BATTLE';
|
||
|
||
/** Output dir per theme (tools/local-extract/extract.py EMOTE_THEMES). */
|
||
export const THEME_DIRS = Object.freeze({
|
||
emoticon_autochess_basic: 'basic',
|
||
emoticon_originium_slug: 'slug',
|
||
emoticon_autochess_basic_2: 'basic_2',
|
||
emoticon_foolsday_doctor: 'fooldoctor',
|
||
emoticon_foolsday_amiya: 'foolamiya',
|
||
emoticon_foolsday_wisdel: 'foolwisdel',
|
||
});
|
||
|
||
/** Our aria-labels (research 09 §4.2: BWIKI wording where it exists, else ours). Never displayed. */
|
||
export const LABELS = Object.freeze({
|
||
autochess_battle_happy: '开心', autochess_battle_scared: '害怕', autochess_battle_sorry: '对不起',
|
||
autochess_battle_thanks: '谢谢', autochess_battle_thinking: '思考', autochess_battle_nice_cooperate: '合作愉快',
|
||
autochess_battle_noproblem: '没问题!', autochess_battle_respect: '敬礼!', autochess_battle_call: '欢呼!',
|
||
autochess_battle_playingcool: '酷!', autochess_battle_sad: '伤心', autochess_battle_dying: '快死了',
|
||
slug_autochess_battle_nice_work: '合作愉快!', slug_autochess_battle_thanks: '谢谢!', slug_autochess_battle_sorry: '对不起!',
|
||
slug_autochess_battle_bye: '再见!', slug_autochess_battle_distrust: '???', slug_autochess_battle_very_soon: '很快就好!',
|
||
});
|
||
|
||
const themeDir = (themeId) => THEME_DIRS[themeId] || themeId.replace(/^emo?ticon_(autochess_)?/, '');
|
||
const shortName = (name, themeId) => String(name || themeId).replace(/^表情套组[::]/, '');
|
||
|
||
/**
|
||
* Build the emote catalog from the official tables (pure; exported for tests).
|
||
* @param {{ display: any, activity: any, items?: any }} src parsed display_meta_table / activity_table / item_table
|
||
* @returns {{ doc: object, warnings: string[] }}
|
||
*/
|
||
export function buildEmotes({ display, activity, items }) {
|
||
const warnings = [];
|
||
const emo = display?.emoticonData;
|
||
const ac = activity?.autoChessData;
|
||
if (!emo?.emojiDataDict || !emo?.emoticonThemeDataDict) throw new Error('display_meta_table: emoticonData missing');
|
||
if (!Array.isArray(ac?.enabledEmoticonThemeIdList)) throw new Error('activity_table: autoChessData.enabledEmoticonThemeIdList missing');
|
||
const themes = [];
|
||
const emotes = [];
|
||
const seenIds = new Set();
|
||
const seenDirs = new Set();
|
||
const own = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
|
||
for (const themeId of ac.enabledEmoticonThemeIdList) {
|
||
if (themes.some((t) => t.themeId === themeId)) { warnings.push(`theme ${themeId}: listed twice, kept once`); continue; }
|
||
const ids = own(emo.emoticonThemeDataDict, themeId) ? emo.emoticonThemeDataDict[themeId] : null;
|
||
if (!Array.isArray(ids)) { warnings.push(`theme ${themeId}: not in emoticonThemeDataDict`); continue; }
|
||
const type = (own(emo.emoticonThemeTypeDict, themeId) && emo.emoticonThemeTypeDict[themeId]) || {};
|
||
const recs = ids.map((id) => (own(emo.emojiDataDict, id) ? emo.emojiDataDict[id] : null))
|
||
.filter((e) => e && e.type === SCENE && typeof e.id === 'string' && typeof e.picId === 'string' && e.picId)
|
||
.filter((e) => {
|
||
// one wheel cell and one protocol id per emoji: an emoji listed by two themes stays on the first page only
|
||
if (seenIds.has(e.id)) { warnings.push(`${e.id}: already on an earlier page, not repeated in ${themeId}`); return false; }
|
||
seenIds.add(e.id);
|
||
return true;
|
||
})
|
||
.sort((a, b) => a.sortId - b.sortId || (a.id < b.id ? -1 : 1));
|
||
if (!recs.length) { warnings.push(`theme ${themeId}: no ${SCENE} emoji`); continue; }
|
||
const dir = themeDir(themeId);
|
||
if (seenDirs.has(dir)) throw new Error(`theme ${themeId}: art dir emoticon/${dir} is already used by another theme`);
|
||
seenDirs.add(dir);
|
||
const name = items?.items?.[themeId]?.name || themeId;
|
||
themes.push({ themeId, dir, sortId: type.sortId ?? null, isBasic: !!type.isBasic, name, emotes: recs.map((e) => e.id) });
|
||
recs.forEach((e, i) => {
|
||
if (e.desc) warnings.push(`${e.id}: has a desc (${e.desc}); the UI still shows the picture only`);
|
||
emotes.push({
|
||
id: e.id, themeId, sortId: e.sortId, picId: e.picId, art: `/assets/local/emoticon/${dir}/${e.picId}.png`,
|
||
label: LABELS[e.id] || `${shortName(name, themeId)} ${i + 1}`,
|
||
});
|
||
});
|
||
}
|
||
const cd = ac.constData || {};
|
||
const doc = {
|
||
version: 1,
|
||
source: 'display_meta_table.emoticonData (type AUTOCHESS_BATTLE) × activity_table autoChessData.enabledEmoticonThemeIdList',
|
||
chatCD: Number.isFinite(cd.chatCD) ? cd.chatCD : null,
|
||
chatTime: Number.isFinite(cd.chatTime) ? cd.chatTime : null,
|
||
themes,
|
||
emotes,
|
||
};
|
||
return { doc, warnings };
|
||
}
|
||
|
||
/** Pretty JSON with one emote / theme per line (stable, diff-friendly). */
|
||
export function formatEmotes(doc) {
|
||
const line = (o) => JSON.stringify(o);
|
||
return `{\n "version": ${doc.version},\n "source": ${line(doc.source)},\n "chatCD": ${line(doc.chatCD)},\n "chatTime": ${line(doc.chatTime)},\n`
|
||
+ ` "themes": [\n${doc.themes.map((t) => ` ${line(t)}`).join(',\n')}\n ],\n`
|
||
+ ` "emotes": [\n${doc.emotes.map((e) => ` ${line(e)}`).join(',\n')}\n ]\n}\n`;
|
||
}
|
||
|
||
async function ensureGamedata(cache, rel, { offline, optional = false }) {
|
||
const abs = join(cache, rel);
|
||
if (existsSync(abs)) return abs;
|
||
if (offline) { if (optional) return null; throw new Error(`missing cached file ${rel} (offline mode)`); }
|
||
await mkdir(dirname(abs), { recursive: true });
|
||
let lastErr;
|
||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||
try {
|
||
const res = await fetch(GAMEDATA_URL + rel, { signal: AbortSignal.timeout(180_000) });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const text = await res.text();
|
||
JSON.parse(text); // never cache a truncated file
|
||
const tmp = `${abs}.tmp-${process.pid}`;
|
||
await writeFile(tmp, text);
|
||
await rename(tmp, abs);
|
||
return abs;
|
||
} catch (e) {
|
||
lastErr = e;
|
||
await new Promise((r) => setTimeout(r, 500 * attempt));
|
||
}
|
||
}
|
||
if (optional) return null;
|
||
throw new Error(`download failed for ${rel}: ${lastErr?.message}`);
|
||
}
|
||
|
||
async function main(argv) {
|
||
const opts = { offline: false, check: false, out: join(ROOT, 'data', 'emotes.json'), cache: join(ROOT, '.cache', 'gamedata') };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--offline') opts.offline = true;
|
||
else if (a === '--check') opts.check = true;
|
||
else if (a === '--out' || a === '--cache') {
|
||
const v = argv[++i];
|
||
if (!v || v.startsWith('--')) throw new Error(`${a} needs a path`);
|
||
opts[a.slice(2)] = resolve(v);
|
||
} else throw new Error(`unknown option ${a}\nusage: node tools/build-emotes.mjs [--offline] [--check] [--out <file>] [--cache <dir>]`);
|
||
}
|
||
const read = async (p) => (p ? JSON.parse(await readFile(p, 'utf8')) : null);
|
||
const display = await read(await ensureGamedata(opts.cache, 'excel/display_meta_table.json', opts));
|
||
const activity = await read(await ensureGamedata(opts.cache, 'excel/activity_table.json', opts));
|
||
const items = await read(await ensureGamedata(opts.cache, 'excel/item_table.json', { ...opts, optional: true }));
|
||
if (!items) console.warn('build-emotes: item_table unavailable, theme names fall back to theme ids');
|
||
const { doc, warnings } = buildEmotes({ display, activity, items });
|
||
for (const w of warnings) console.warn(`build-emotes: ${w}`);
|
||
const text = formatEmotes(doc);
|
||
if (opts.check) {
|
||
const old = existsSync(opts.out) ? await readFile(opts.out, 'utf8') : null;
|
||
if (old !== text) { console.error(`build-emotes: ${opts.out} is out of date (run node tools/build-emotes.mjs)`); return 1; }
|
||
console.log(`build-emotes: ${opts.out} is up to date (${doc.themes.length} themes, ${doc.emotes.length} emotes)`);
|
||
return 0;
|
||
}
|
||
await writeFile(opts.out, text);
|
||
console.log(`build-emotes: wrote ${opts.out} (${doc.themes.length} themes, ${doc.emotes.length} emotes)`);
|
||
return 0;
|
||
}
|
||
|
||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (e) => { console.error(`build-emotes: ${e.message}`); process.exitCode = 2; });
|
||
}
|