// Official emotes (research 09 §4 / §6.6): the 36 in-match emotes of 盟约 (6 themes × 6), their generated reference // data/emotes.json (tools/build-emotes.mjs), the local-client extraction (tools/local-extract/extract.py), the protocol // whitelist and the pure helpers of public/js/ui/emotes.js. Browser behaviour (picture-only bubbles, pager, cooldown) // is covered by test/ui/emotes.e2e.test.js (SP_E2E=1). import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { EMOTES, EMOTE_THEMES, EMOTE_CATALOG, EMOTE_THEME, EMOTE_LABEL, EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS, emoteInfo, emoteArtPath, emoteArtGroup, } from '../../shared/constants.js'; import { validateC2S } from '../../shared/protocol.js'; import { buildEmotes, formatEmotes, THEME_DIRS } from '../../tools/build-emotes.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8')); const tryJson = (rel) => { try { return readJson(rel); } catch { return null; } }; const EMOTES_JSON = readJson('data/emotes.json'); const ENABLED = ['emoticon_autochess_basic', 'emoticon_originium_slug', 'emoticon_autochess_basic_2', 'emoticon_foolsday_doctor', 'emoticon_foolsday_amiya', 'emoticon_foolsday_wisdel']; describe('emote catalog (shared/constants.js)', () => { test('6 themes × 6 battle emotes = 36 unique official ids, in wheel order', () => { assert.deepEqual(EMOTE_THEMES.map((t) => t.themeId), ENABLED, 'pages follow autoChessData.enabledEmoticonThemeIdList'); for (const t of EMOTE_THEMES) { assert.equal(t.emotes.length, 6, t.themeId); const sorts = t.emotes.map((e) => e.sortId); assert.deepEqual(sorts, [...sorts].sort((a, b) => a - b), `${t.themeId} by sortId`); } assert.equal(EMOTES.length, 36); assert.equal(new Set(EMOTES).size, 36); assert.deepEqual([...EMOTES], EMOTE_CATALOG.map((e) => e.id)); for (const id of EMOTES) assert.match(id, /^(slug_)?autochess_battle_[a-z0-9_]+$/, id); assert.ok(Object.isFrozen(EMOTES) && Object.isFrozen(EMOTE_THEMES) && Object.isFrozen(EMOTE_THEMES[0].emotes)); }); test('picId comes from the table, not from the id (fooldoctor 03–06 → pics 04/05/06/08)', () => { const doc = EMOTE_THEMES.find((t) => t.themeId === 'emoticon_foolsday_doctor'); assert.deepEqual(doc.emotes.map((e) => e.picId), ['pic_fooldoctor_01_battle', 'pic_fooldoctor_02_battle', 'pic_fooldoctor_04_battle', 'pic_fooldoctor_05_battle', 'pic_fooldoctor_06_battle', 'pic_fooldoctor_08_battle']); assert.equal(emoteArtPath('autochess_battle_fooldoctor_06'), '/assets/local/emoticon/fooldoctor/pic_fooldoctor_08_battle.png'); assert.equal(emoteArtPath('autochess_battle_nice_cooperate'), '/assets/local/emoticon/basic/pic_cooperate_battle.png'); assert.equal(emoteArtPath('slug_autochess_battle_thanks'), '/assets/local/emoticon/slug/pic_thanks_battle.png'); assert.equal(emoteArtGroup('autochess_battle_dying'), 'emoticon/basic_2'); for (const e of EMOTE_CATALOG) assert.match(e.picId, /^pic_.+_battle$/, e.id); }); test('lookups are safe for any input', () => { for (const bad of ['happy', 'autochess_room_hello', '__proto__', 'constructor', 'toString', '', null, undefined, 42, {}]) { assert.equal(emoteInfo(bad), null, String(bad)); assert.equal(emoteArtPath(bad), null, String(bad)); assert.equal(emoteArtGroup(bad), null, String(bad)); } assert.equal(emoteInfo('autochess_battle_call').themeId, 'emoticon_autochess_basic_2'); assert.equal(EMOTE_THEME.slug_autochess_battle_bye, 'emoticon_originium_slug'); assert.equal(typeof EMOTE_LABEL.autochess_battle_happy, 'string'); for (const bad of ['__proto__', 'constructor', 'toString', 'hasOwnProperty']) { assert.equal(EMOTE_THEME[bad], undefined, `EMOTE_THEME[${bad}]`); assert.equal(EMOTE_LABEL[bad], undefined, `EMOTE_LABEL[${bad}]`); } assert.equal(Object.keys(EMOTE_THEME).length, 36); assert.ok(Object.isFrozen(EMOTE_THEME) && Object.isFrozen(EMOTE_LABEL)); }); test('protocol accepts exactly the 36 official battle ids (g.emote { id })', () => { for (const id of EMOTES) assert.equal(validateC2S({ t: 'g.emote', id }), null, id); for (const id of ['happy', 'thanks', 'autochess_room_hello', 'multiv3_battle_thanks', 'duel_battle_happy', '__proto__', '', 7, null]) { assert.notEqual(validateC2S({ t: 'g.emote', id }), null, String(id)); } }); test('cooldown and bubble time are the official chatCD / chatTime', () => { assert.equal(EMOTE_COOLDOWN_MS, EMOTES_JSON.chatCD * 1000); assert.equal(EMOTE_BUBBLE_MS, EMOTES_JSON.chatTime * 1000); assert.equal(EMOTE_COOLDOWN_MS, 1000); assert.equal(EMOTE_BUBBLE_MS, 3000); // the client reads the bubble time from config.timers (game.js) and the server rate-limits with the constant const timers = tryJson('data/config.json')?.timers; if (timers) assert.deepEqual([timers.chatCd * 1000, timers.chatBubble * 1000], [EMOTE_COOLDOWN_MS, EMOTE_BUBBLE_MS]); }); }); describe('data/emotes.json (tools/build-emotes.mjs)', () => { test('is identical to the shared/constants.js table', () => { assert.deepEqual(EMOTES_JSON.themes.map(({ themeId, dir, sortId, isBasic, name, emotes }) => ({ themeId, dir, sortId, isBasic, name, emotes })), EMOTE_THEMES.map((t) => ({ themeId: t.themeId, dir: t.dir, sortId: t.sortId, isBasic: t.isBasic, name: t.name, emotes: t.emotes.map((e) => e.id) }))); assert.deepEqual(EMOTES_JSON.emotes, EMOTE_CATALOG.map((e) => ({ id: e.id, themeId: e.themeId, sortId: e.sortId, picId: e.picId, art: emoteArtPath(e.id), label: e.label, }))); }); test('regenerating from the cached official tables reproduces it byte for byte', { skip: !existsSync(path.join(ROOT, '.cache/gamedata/excel/display_meta_table.json')) && 'no cached display_meta_table.json', }, () => { const { doc, warnings } = buildEmotes({ display: readJson('.cache/gamedata/excel/display_meta_table.json'), activity: readJson('.cache/gamedata/excel/activity_table.json'), items: tryJson('.cache/gamedata/excel/item_table.json'), }); assert.deepEqual(warnings, []); assert.equal(formatEmotes(doc), readFileSync(path.join(ROOT, 'data/emotes.json'), 'utf8')); }); test('builder: scene filter, enabled-list page order, sortId order, picId from the table, fallbacks', () => { const e = (id, type, sortId, picId) => ({ id, type, sortId, picId, desc: null }); const display = { emoticonData: { emojiDataDict: { a_room: e('a_room', 'AUTOCHESS_ROOM', 1, 'pic_a'), a_2: e('a_2', 'AUTOCHESS_BATTLE', 20, 'pic_a2_battle'), a_1: e('a_1', 'AUTOCHESS_BATTLE', 10, 'pic_a9_battle'), b_1: e('b_1', 'AUTOCHESS_BATTLE', 5, 'pic_b_battle'), b_multi: e('b_multi', 'ACTMULTIV3_BATTLE', 1, 'pic_m_battle'), autochess_battle_happy: e('autochess_battle_happy', 'AUTOCHESS_BATTLE', 1, 'pic_happy_battle'), }, emoticonThemeDataDict: { emoticon_x: ['a_room', 'a_2', 'a_1'], emoticon_originium_slug: ['b_multi', 'b_1'], emoticon_empty: ['a_room'] }, emoticonThemeTypeDict: { emoticon_x: { sortId: 7, isBasic: true }, emoticon_originium_slug: { sortId: 1001, isBasic: false } }, } }; const activity = { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_originium_slug', 'emoticon_missing', 'emoticon_x', 'emoticon_empty'], constData: { chatCD: 1, chatTime: 3 }, } }; const items = { items: { emoticon_originium_slug: { name: '表情套组:虫动' } } }; const { doc, warnings } = buildEmotes({ display, activity, items }); assert.deepEqual(doc.themes.map((t) => [t.themeId, t.dir, t.emotes]), [ ['emoticon_originium_slug', 'slug', ['b_1']], ['emoticon_x', 'x', ['a_1', 'a_2']], ]); assert.equal(doc.emotes.find((x) => x.id === 'a_1').art, '/assets/local/emoticon/x/pic_a9_battle.png'); assert.equal(doc.emotes.find((x) => x.id === 'a_1').label, 'emoticon_x 1', 'no official text: generic aria label'); assert.equal(doc.emotes.find((x) => x.id === 'b_1').label, '虫动 1'); assert.equal(doc.themes[0].name, '表情套组:虫动'); assert.deepEqual([doc.chatCD, doc.chatTime], [1, 3]); assert.equal(warnings.length, 2, warnings.join('; ')); assert.throws(() => buildEmotes({ display: {}, activity }), /emoticonData/); assert.throws(() => buildEmotes({ display, activity: {} }), /enabledEmoticonThemeIdList/); const text = formatEmotes(doc); assert.deepEqual(JSON.parse(text), doc); assert.equal(formatEmotes(buildEmotes({ display, activity, items }).doc), text, 'deterministic'); }); test('builder: an emoji listed by two themes / a theme listed twice is kept once; art dirs must be unique', () => { const e = (id, sortId, picId = `pic_${id}_battle`) => ({ id, type: 'AUTOCHESS_BATTLE', sortId, picId, desc: null }); const display = { emoticonData: { emojiDataDict: { a: e('a', 1), b: e('b', 2), c: e('c', 3), nopic: { id: 'nopic', type: 'AUTOCHESS_BATTLE', sortId: 4, picId: null } }, emoticonThemeDataDict: { emoticon_one: ['a', 'b', 'nopic', 'toString'], emoticon_two: ['b', 'c', '__proto__'] }, emoticonThemeTypeDict: {}, } }; const activity = { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_one', 'emoticon_two', 'emoticon_one', 'constructor'] } }; const { doc, warnings } = buildEmotes({ display, activity }); assert.deepEqual(doc.themes.map((t) => [t.themeId, t.emotes]), [['emoticon_one', ['a', 'b']], ['emoticon_two', ['c']]]); assert.deepEqual(doc.emotes.map((x) => x.id), ['a', 'b', 'c'], 'every protocol id once'); assert.equal(new Set(doc.emotes.map((x) => x.art)).size, 3); assert.ok(warnings.some((w) => /^b: already on an earlier page/.test(w)), warnings.join('; ')); assert.ok(warnings.some((w) => /emoticon_one: listed twice/.test(w))); assert.ok(warnings.some((w) => /constructor: not in emoticonThemeDataDict/.test(w)), 'prototype keys are not themes'); assert.throws(() => buildEmotes({ display: { emoticonData: { ...display.emoticonData, emoticonThemeDataDict: { emoticon_x: ['a'], emticon_x: ['c'] } } }, activity: { autoChessData: { enabledEmoticonThemeIdList: ['emoticon_x', 'emticon_x'] } } }), /already used/); }); test('theme dirs agree between the builder, the constants and data/emotes.json', () => { for (const t of EMOTE_THEMES) assert.equal(THEME_DIRS[t.themeId], t.dir, t.themeId); assert.equal(Object.keys(THEME_DIRS).length, EMOTE_THEMES.length); }); }); const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0); const PY_ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' }; describe('local-client extraction (tools/local-extract/extract.py)', () => { test('extracts every emote theme (battle sprites only) into emoticon/