mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-27 16:20:37 +00:00
3ac06bffaa
Closes deferred phases 04 + 05 of loldle-new-modes plan. - loldle-ability: 5 guesses, DDragon ability icon as photo. State pins slot (P/Q/W/E/R) so the same icon shows every turn. Abilities pulled from DDragon per-champion — same source loldle.net uses at runtime. - loldle-splash: 4 guesses, random skin splash as photo. Skin pool scraped from loldle.net bundle (var Ad=[…] — 172 champs × 1939 skins, non-chroma, matches their splash mode exactly). URLs from Riot DDragon CDN (no version segment, stable across patches). - fetch-ddragon-data.js: extended to write all four JSONs in one run. Shares a single DDragon per-champion fetch cycle (concurrency 10). - Credits loldle.net + Riot Games in all loldle-family READMEs. 19 new tests (503 total). Lint clean. register:dry reports 12 loldle_* commands with no conflicts.
426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @file Builds emoji + quote pools for loldle-emoji and loldle-quote modules.
|
|
*
|
|
* Data sources:
|
|
* - classic's src/modules/loldle/champions.json (already scraped) — feeds
|
|
* the algorithmic emoji derivation (species/region/resource mapping).
|
|
* - Data Dragon latest patch — fetched once for champion `title` + `lore`
|
|
* blurb, used to seed quote text.
|
|
*
|
|
* Why algorithmic emoji instead of scrape:
|
|
* loldle.net's JS bundle contains zero emoji code points — emoji sequences
|
|
* are stored encrypted (daily rotation only) in cache.loldle.net. Scraping
|
|
* a full pool from them is not feasible. We derive a loldle-style
|
|
* 3-emoji sequence from champion metadata (gender/species/region/resource/
|
|
* position). The mapping is handcrafted but deterministic.
|
|
*
|
|
* Why lore-blurb for quote:
|
|
* Voice-line transcripts are not in a public official feed. Champion
|
|
* `title` (e.g. "the Nine-Tailed Fox") + first sentence of `lore` gives
|
|
* recognizable per-champion text for all 165+ champions.
|
|
*
|
|
* Usage: node scripts/fetch-ddragon-data.js
|
|
*/
|
|
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import championsData from "../src/modules/loldle/champions.json" with { type: "json" };
|
|
|
|
// ───── emoji mapping tables ─────
|
|
// Each category yields one emoji. A champion's sequence picks the strongest
|
|
// signal from species → region → position + resource/range. Short (3 emojis)
|
|
// by design — loldle.net typically shows 3.
|
|
|
|
const SPECIES_EMOJI = {
|
|
Yordle: "🧚",
|
|
Darkin: "⚔️",
|
|
Demon: "😈",
|
|
Dragon: "🐉",
|
|
Cat: "🐱",
|
|
Dog: "🐕",
|
|
"Void-Being": "👁️",
|
|
Void: "👁️",
|
|
Undead: "💀",
|
|
Spirit: "👻",
|
|
Celestial: "🌟",
|
|
Aspect: "🌟",
|
|
God: "⚜️",
|
|
"God-Warrior": "⚜️",
|
|
Yeti: "❄️",
|
|
Troll: "🧌",
|
|
Minotaur: "🐂",
|
|
Cyborg: "🤖",
|
|
Golem: "🗿",
|
|
Plant: "🌱",
|
|
Rat: "🐀",
|
|
Revenant: "👻",
|
|
Iceborn: "🥶",
|
|
Vastayan: "🦊",
|
|
Brackern: "🦂",
|
|
"Magically Altered": "✨",
|
|
Magicborn: "🔮",
|
|
"Chemically Altered": "🧪",
|
|
Baccai: "💀",
|
|
Spiritualist: "👻",
|
|
Human: "🧝",
|
|
Unknown: "❓",
|
|
};
|
|
|
|
const REGION_EMOJI = {
|
|
Demacia: "🛡️",
|
|
Noxus: "🗡️",
|
|
Shurima: "🏜️",
|
|
Freljord: "❄️",
|
|
Ionia: "🏯",
|
|
Piltover: "⚙️",
|
|
Zaun: "🧪",
|
|
"Bandle City": "🏡",
|
|
Bilgewater: "⚓",
|
|
"Shadow Isles": "🌫️",
|
|
Targon: "⛰️",
|
|
Ixtal: "🌿",
|
|
Void: "👾",
|
|
Icathia: "👾",
|
|
Camavor: "⚜️",
|
|
Runeterra: "🌍",
|
|
};
|
|
|
|
const RESOURCE_EMOJI = {
|
|
Mana: "🔮",
|
|
Manaless: "💪",
|
|
Energy: "⚡",
|
|
Fury: "💢",
|
|
Rage: "😡",
|
|
Ferocity: "🔥",
|
|
Bloodthirst: "🩸",
|
|
Flow: "💧",
|
|
Heat: "🔥",
|
|
Courage: "🦁",
|
|
Grit: "🪨",
|
|
Shield: "🛡️",
|
|
"Health costs": "❤️🩹",
|
|
};
|
|
|
|
const POSITION_EMOJI = {
|
|
Top: "⛰️",
|
|
Middle: "✨",
|
|
Jungle: "🌲",
|
|
Bottom: "🏹",
|
|
Support: "💕",
|
|
};
|
|
|
|
function pickEmoji(table, keys, fallback = "") {
|
|
for (const k of keys) if (table[k]) return table[k];
|
|
return fallback;
|
|
}
|
|
|
|
function deriveEmoji(champ) {
|
|
const species = pickEmoji(SPECIES_EMOJI, champ.species ?? [], "");
|
|
const region = pickEmoji(REGION_EMOJI, champ.regions ?? [], "🌍");
|
|
const resource = RESOURCE_EMOJI[champ.resource] ?? "";
|
|
const position = pickEmoji(POSITION_EMOJI, champ.positions ?? [], "");
|
|
|
|
// always 3 emojis — drop the weakest signals if we overflow
|
|
const parts = [species, region, resource || position].filter(Boolean);
|
|
if (parts.length < 3) parts.push(position || "🎮");
|
|
return parts.slice(0, 3).join(" ");
|
|
}
|
|
|
|
// ───── DDragon champion name mapping ─────
|
|
|
|
function ddragonKey(championName) {
|
|
// DDragon uses PascalCase, but only capitalizes the first letter of the
|
|
// first word — e.g. "Kai'Sa" → "Kaisa", "Cho'Gath" → "Chogath".
|
|
// Multi-word names keep internal capitalization: "Miss Fortune" → "MissFortune".
|
|
const overrides = {
|
|
Wukong: "MonkeyKing",
|
|
"Nunu & Willump": "Nunu",
|
|
"Renata Glasc": "Renata",
|
|
"Dr. Mundo": "DrMundo",
|
|
};
|
|
if (overrides[championName]) return overrides[championName];
|
|
// Strip apostrophes/periods within a word (fold "Kai'Sa" → "KaiSa" → "Kaisa").
|
|
// If the name has no internal spaces but has an apostrophe/period, lowercase
|
|
// everything after the first letter: "Kai'Sa" → "K" + "aisa".
|
|
const stripped = championName.replace(/['.]/g, "");
|
|
if (!stripped.includes(" ") && !stripped.includes("&")) {
|
|
return stripped[0].toUpperCase() + stripped.slice(1).toLowerCase();
|
|
}
|
|
return stripped.replace(/[\s&]/g, "").replace(/^(.)/, (c) => c.toUpperCase());
|
|
}
|
|
|
|
async function fetchJson(url) {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`fetch ${url}: ${r.status} ${r.statusText}`);
|
|
return r.json();
|
|
}
|
|
|
|
function firstSentence(text) {
|
|
if (!text) return "";
|
|
const clean = text
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
const m = clean.match(/^[^.!?]+[.!?]/);
|
|
return (m ? m[0] : clean).trim();
|
|
}
|
|
|
|
async function buildQuotes() {
|
|
const versions = await fetchJson("https://ddragon.leagueoflegends.com/api/versions.json");
|
|
const v = versions[0];
|
|
console.log(` DDragon version: ${v}`);
|
|
const summary = await fetchJson(
|
|
`https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion.json`,
|
|
);
|
|
const summaryByKey = summary.data;
|
|
// Secondary lookup: normalize(ddragonKey) → actual key. Handles DDragon's
|
|
// inconsistent casing ("Kaisa" vs "KSante" vs "KogMaw") by folding to
|
|
// lowercase-alphanumeric for comparison.
|
|
const normMap = new Map();
|
|
for (const k of Object.keys(summaryByKey)) {
|
|
normMap.set(k.toLowerCase().replace(/[^a-z0-9]/g, ""), k);
|
|
}
|
|
|
|
const out = [];
|
|
const missing = [];
|
|
for (const champ of championsData) {
|
|
const attempted = ddragonKey(champ.championName);
|
|
let entry = summaryByKey[attempted];
|
|
if (!entry) {
|
|
const norm = attempted.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
const realKey = normMap.get(norm);
|
|
if (realKey) entry = summaryByKey[realKey];
|
|
}
|
|
if (!entry) {
|
|
missing.push(`${champ.championName} → ${attempted}`);
|
|
continue;
|
|
}
|
|
// Strip the champion's own name from the quote so it isn't a giveaway.
|
|
// Handle first names too ("Ahri" in "Ahri is a fox-like vastaya").
|
|
const nameParts = new Set(
|
|
[champ.championName, ...champ.championName.split(/[\s'.]+/)].filter(
|
|
(s) => s && s.length >= 3,
|
|
),
|
|
);
|
|
const nameRx = new RegExp(`\\b(${[...nameParts].join("|")})\\b`, "gi");
|
|
const sentence = firstSentence(entry.blurb).replace(nameRx, "___");
|
|
out.push({
|
|
championName: champ.championName,
|
|
// Title already includes "the" prefix: "the Nine-Tailed Fox — …"
|
|
quote: `${entry.title} — ${sentence}`,
|
|
});
|
|
}
|
|
if (missing.length) {
|
|
console.warn(` WARN: ${missing.length} champions missing from DDragon:`);
|
|
for (const m of missing) console.warn(` ${m}`);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ───── shared DDragon per-champion cache ─────
|
|
// Ability and splash builders both need per-champion details. One fetch
|
|
// cycle populates a shared cache to avoid 340 redundant HTTP requests.
|
|
|
|
async function mapWithConcurrency(items, limit, fn) {
|
|
const out = new Array(items.length);
|
|
let next = 0;
|
|
const workers = Array.from({ length: limit }, async () => {
|
|
while (true) {
|
|
const i = next++;
|
|
if (i >= items.length) return;
|
|
out[i] = await fn(items[i], i);
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return out;
|
|
}
|
|
|
|
async function fetchDdragonDetails() {
|
|
const versions = await fetchJson("https://ddragon.leagueoflegends.com/api/versions.json");
|
|
const v = versions[0];
|
|
console.log(` DDragon version: ${v}`);
|
|
const summary = await fetchJson(
|
|
`https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion.json`,
|
|
);
|
|
const summaryByKey = summary.data;
|
|
const normMap = new Map();
|
|
for (const k of Object.keys(summaryByKey)) {
|
|
normMap.set(k.toLowerCase().replace(/[^a-z0-9]/g, ""), k);
|
|
}
|
|
function resolveKey(championName) {
|
|
const attempted = ddragonKey(championName);
|
|
if (summaryByKey[attempted]) return attempted;
|
|
const norm = attempted.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
return normMap.get(norm) ?? null;
|
|
}
|
|
// Fetch per-champion in parallel with a polite concurrency cap. ~172
|
|
// requests to a CDN — finishes in ~15-25s.
|
|
const tasks = championsData.map((c) => ({ champ: c, key: resolveKey(c.championName) }));
|
|
console.log(` fetching ${tasks.length} champion details (concurrency 10)…`);
|
|
const details = await mapWithConcurrency(tasks, 10, async ({ champ, key }) => {
|
|
if (!key) return { champ, key: null, data: null };
|
|
const full = await fetchJson(
|
|
`https://ddragon.leagueoflegends.com/cdn/${v}/data/en_US/champion/${key}.json`,
|
|
);
|
|
return { champ, key, data: full.data[key] };
|
|
});
|
|
return { version: v, details };
|
|
}
|
|
|
|
// ───── loldle.net splash-pool scrape ─────
|
|
|
|
async function scrapeLoldleSplashPool() {
|
|
const pageRes = await fetch("https://loldle.net/splash");
|
|
const page = await pageRes.text();
|
|
const bundleMatch = page.match(/js\/index\.[^"]+\.js/);
|
|
if (!bundleMatch) throw new Error("loldle.net: could not find index.js bundle");
|
|
const bundleRes = await fetch(`https://loldle.net/${bundleMatch[0]}`);
|
|
const bundle = await bundleRes.text();
|
|
// `Ad=[{championName:"…",splashes:[{name:"…",translations:[…]}…]}…]` —
|
|
// the bundle's splash pool used by loldle.net's guess validator. We pull
|
|
// names only; translations and champion order can be ignored.
|
|
const championStart = bundle.indexOf('Ad=[{championName:"Aatrox"');
|
|
if (championStart < 0) {
|
|
throw new Error("loldle.net: splash pool variable `Ad` not found — bundle format drifted");
|
|
}
|
|
// Each champion block has nested translations arrays, so a naïve
|
|
// non-greedy `[…]` match terminates too early. Instead, slice the
|
|
// splash-pool region and split on `championName:"` — each segment is
|
|
// one champion's block; skin names inside are `{name:"…",translations:`.
|
|
const endMarker = bundle.indexOf("];", championStart);
|
|
if (endMarker < 0) throw new Error("loldle.net: could not find end of splash pool");
|
|
const region = bundle.slice(championStart + 3, endMarker); // skip `Ad=` prefix
|
|
const championRx = /championName:"([^"]+)"/g;
|
|
const pool = new Map();
|
|
const championMatches = [...region.matchAll(championRx)];
|
|
for (let i = 0; i < championMatches.length; i++) {
|
|
const name = championMatches[i][1];
|
|
const start = championMatches[i].index;
|
|
const end = i + 1 < championMatches.length ? championMatches[i + 1].index : region.length;
|
|
const slice = region.slice(start, end);
|
|
const skinNames = [...slice.matchAll(/\{name:"([^"]+)",translations:/g)].map((x) => x[1]);
|
|
pool.set(name, skinNames);
|
|
}
|
|
if (pool.size === 0) {
|
|
throw new Error("loldle.net: parsed zero splash entries — regex drifted");
|
|
}
|
|
console.log(` scraped ${pool.size} champions from loldle.net splash pool`);
|
|
return pool;
|
|
}
|
|
|
|
// ───── abilities.json builder ─────
|
|
|
|
function abilityIconUrl(version, imageFull, isPassive) {
|
|
const segment = isPassive ? "passive" : "spell";
|
|
return `https://ddragon.leagueoflegends.com/cdn/${version}/img/${segment}/${imageFull}`;
|
|
}
|
|
|
|
function buildAbilities(version, details) {
|
|
const out = [];
|
|
const missing = [];
|
|
const SLOTS = ["Q", "W", "E", "R"];
|
|
for (const { champ, key, data } of details) {
|
|
if (!data) {
|
|
missing.push(champ.championName);
|
|
continue;
|
|
}
|
|
const abilities = [
|
|
{
|
|
slot: "P",
|
|
name: data.passive.name,
|
|
icon: abilityIconUrl(version, data.passive.image.full, true),
|
|
},
|
|
];
|
|
data.spells.slice(0, 4).forEach((s, i) => {
|
|
abilities.push({
|
|
slot: SLOTS[i],
|
|
name: s.name,
|
|
icon: abilityIconUrl(version, s.image.full, false),
|
|
});
|
|
});
|
|
out.push({ championName: champ.championName, key, abilities });
|
|
}
|
|
if (missing.length) console.warn(` WARN: abilities missing for ${missing.join(", ")}`);
|
|
return out.sort((a, b) => a.championName.localeCompare(b.championName));
|
|
}
|
|
|
|
// ───── splashes.json builder ─────
|
|
|
|
function splashUrl(championKey, skinNum) {
|
|
// DDragon splash URLs have NO version segment. Stable across patches.
|
|
return `https://ddragon.leagueoflegends.com/cdn/img/champion/splash/${championKey}_${skinNum}.jpg`;
|
|
}
|
|
|
|
function buildSplashes(details, loldlePool) {
|
|
const out = [];
|
|
const missing = [];
|
|
for (const { champ, key, data } of details) {
|
|
if (!data) {
|
|
missing.push(champ.championName);
|
|
continue;
|
|
}
|
|
// Loldle's pool defines WHICH skins are in play (names). DDragon gives
|
|
// URL num + "default" skin normalization. Normalize both to match.
|
|
const allowedNames = loldlePool.get(champ.championName);
|
|
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
const allowedSet = allowedNames ? new Set(allowedNames.map(norm)) : null;
|
|
const skins = [];
|
|
for (const s of data.skins) {
|
|
// DDragon labels base skin "default" — loldle.net calls it "Default".
|
|
const displayName = s.name === "default" ? "Default" : s.name;
|
|
if (allowedSet && !allowedSet.has(norm(displayName))) continue;
|
|
skins.push({
|
|
id: Number(s.num),
|
|
name: displayName,
|
|
url: splashUrl(key, s.num),
|
|
});
|
|
}
|
|
if (skins.length === 0) {
|
|
missing.push(`${champ.championName} (no skins matched pool)`);
|
|
continue;
|
|
}
|
|
out.push({ championName: champ.championName, skins });
|
|
}
|
|
if (missing.length) console.warn(` WARN: splashes missing for ${missing.join(", ")}`);
|
|
return out.sort((a, b) => a.championName.localeCompare(b.championName));
|
|
}
|
|
|
|
// ───── main ─────
|
|
|
|
const root = resolve(import.meta.dirname, "..");
|
|
|
|
console.log("deriving emoji sequences from champion metadata…");
|
|
const emojis = championsData
|
|
.map((c) => ({ championName: c.championName, emojis: deriveEmoji(c) }))
|
|
.sort((a, b) => a.championName.localeCompare(b.championName));
|
|
const emojisPath = resolve(root, "src/modules/loldle-emoji/emojis.json");
|
|
mkdirSync(resolve(emojisPath, ".."), { recursive: true });
|
|
writeFileSync(emojisPath, `${JSON.stringify(emojis, null, 4)}\n`);
|
|
console.log(`wrote ${emojisPath} (${emojis.length} champions)`);
|
|
|
|
console.log("fetching DDragon for quote text…");
|
|
const quotes = (await buildQuotes()).sort((a, b) => a.championName.localeCompare(b.championName));
|
|
const quotesPath = resolve(root, "src/modules/loldle-quote/quotes.json");
|
|
mkdirSync(resolve(quotesPath, ".."), { recursive: true });
|
|
writeFileSync(quotesPath, `${JSON.stringify(quotes, null, 4)}\n`);
|
|
console.log(`wrote ${quotesPath} (${quotes.length} champions)`);
|
|
|
|
console.log("fetching DDragon champion details (spells, passives, skins)…");
|
|
const { version: ddragonVersion, details } = await fetchDdragonDetails();
|
|
|
|
const abilities = buildAbilities(ddragonVersion, details);
|
|
const abilitiesPath = resolve(root, "src/modules/loldle-ability/abilities.json");
|
|
mkdirSync(resolve(abilitiesPath, ".."), { recursive: true });
|
|
writeFileSync(abilitiesPath, `${JSON.stringify(abilities, null, 4)}\n`);
|
|
console.log(`wrote ${abilitiesPath} (${abilities.length} champions)`);
|
|
|
|
console.log("scraping loldle.net splash pool…");
|
|
const loldleSplashPool = await scrapeLoldleSplashPool();
|
|
|
|
const splashes = buildSplashes(details, loldleSplashPool);
|
|
const splashesPath = resolve(root, "src/modules/loldle-splash/splashes.json");
|
|
mkdirSync(resolve(splashesPath, ".."), { recursive: true });
|
|
writeFileSync(splashesPath, `${JSON.stringify(splashes, null, 4)}\n`);
|
|
console.log(`wrote ${splashesPath} (${splashes.length} champions)`);
|