mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-28 00:20:38 +00:00
feat(loldle): add emoji and quote champion-guessing modules
Ship two new loldle-family modules mirroring loldle.net's non-classic modes. Text-only MVP (ability/splash phases stay deferred). - loldle-emoji: 5 guesses, emoji-sequence clue. Pool derived algorithmically from classic's champions.json metadata (species/region/resource mapping table) since loldle.net's bundle has no static emoji pool. - loldle-quote: 6 guesses, lore-blurb clue. Pool seeded from Data Dragon champion title + first lore sentence; champion name redacted to ___. - scripts/fetch-ddragon-data.js: single generator for both JSONs. - src/util/normalize-name.js: shared lookup helper; loldle/lookup.js refactored to import it. 35 new tests (484 total passing). Lint clean.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
// ───── 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)`);
|
||||
Reference in New Issue
Block a user