refactor(loldle): consume loldle.net's raw schema directly

Drop the in-scraper normalization step — champions.json now mirrors the
exact shape emitted by loldle.net's JS bundle. Records use _id,
championId, championName, arrays for positions/species/regions/
range_type, "Male"/"Female"/"Other" gender strings, and a full
YYYY-MM-DD release_date.

Comparison is schema-aware: multi-value keys accept arrays directly,
the year axis parses YYYY out of the ISO date, and exact compares stay
case-insensitive.
This commit is contained in:
2026-04-22 13:29:55 +07:00
parent 615dc8174c
commit df46e4ee22
10 changed files with 3193 additions and 1509 deletions
+26 -34
View File
@@ -22,18 +22,8 @@ import { resolve } from "node:path";
const LOLDLE_CLASSIC = "https://loldle.net/classic";
const LANE_MAP = {
top: "top",
jungle: "jungle",
middle: "mid",
bottom: "bottom",
support: "support",
};
const GENDER_MAP = { male: "male", female: "female", other: "divers" };
const CHAMPION_RECORD_RX =
/\{_id:"[a-f0-9]+",championId:"[^"]+",championName:"([^"]+)",gender:"([^"]+)",positions:\[([^\]]+)\],species:\[([^\]]+)\],resource:"([^"]+)",range_type:\[([^\]]+)\],regions:\[([^\]]+)\],release_date:"(\d{4})-\d{2}-\d{2}"\}/g;
/\{_id:"([a-f0-9]+)",championId:"([^"]+)",championName:"([^"]+)",gender:"([^"]+)",positions:\[([^\]]+)\],species:\[([^\]]+)\],resource:"([^"]+)",range_type:\[([^\]]+)\],regions:\[([^\]]+)\],release_date:"(\d{4}-\d{2}-\d{2})"\}/g;
async function fetchText(url) {
const res = await fetch(url);
@@ -45,10 +35,6 @@ function parseJsArrayStrings(inner) {
return [...inner.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
}
function normalizeRegion(name) {
return name.toLowerCase().replace(/\s+/g, "-");
}
async function scrapeLoldle() {
const html = await fetchText(LOLDLE_CLASSIC);
const scriptMatch = html.match(/<script\s+src="(js\/index\.[^"]+\.js)"/);
@@ -59,27 +45,33 @@ async function scrapeLoldle() {
const seen = new Set();
const records = [];
for (const m of bundle.matchAll(CHAMPION_RECORD_RX)) {
const [, name, gender, positionsRaw, speciesRaw, resource, rangeTypeRaw, regionsRaw, year] = m;
if (seen.has(name)) continue;
seen.add(name);
const lanes = parseJsArrayStrings(positionsRaw)
.map((p) => LANE_MAP[p.toLowerCase()])
.filter(Boolean);
const regions = parseJsArrayStrings(regionsRaw).map(normalizeRegion);
const species = parseJsArrayStrings(speciesRaw).map((s) => s.toLowerCase());
const rangeType = parseJsArrayStrings(rangeTypeRaw)[0]?.toLowerCase();
const [
,
_id,
championId,
championName,
gender,
positions,
species,
resource,
rangeType,
regions,
releaseDate,
] = m;
if (seen.has(championName)) continue;
seen.add(championName);
records.push({
id: name,
name,
gender: GENDER_MAP[gender.toLowerCase()] ?? "divers",
species: species.join(","),
_id,
championId,
championName,
gender,
positions: parseJsArrayStrings(positions),
species: parseJsArrayStrings(species),
resource,
attackType: rangeType === "melee" ? "close" : "range",
region: regions.join(","),
lane: lanes.join(","),
releaseDate: Number(year),
range_type: parseJsArrayStrings(rangeType),
regions: parseJsArrayStrings(regions),
release_date: releaseDate,
});
}
@@ -88,7 +80,7 @@ async function scrapeLoldle() {
"loldle.net: zero champion records parsed — bundle format changed, update CHAMPION_RECORD_RX",
);
}
records.sort((a, b) => a.name.localeCompare(b.name));
records.sort((a, b) => a.championName.localeCompare(b.championName));
return records;
}
+2 -1
View File
@@ -22,7 +22,8 @@ immediately rolls into a fresh round — no manual "new round" command needed.
## Architecture
- `compare.js` — pure attribute comparison across 7 classic-mode attributes
(gender, species, range, resource, region, lane, year). Returns `correct`,
matching loldle.net's raw schema (`gender`, `species`, `range_type`,
`resource`, `regions`, `positions`, `release_date`). Returns `correct`,
`partial`, or `wrong` per attribute, plus a `direction` hint for year.
- `lookup.js` — normalizes user input and resolves it to a champion record.
- `daily.js``pickRandom` / `pickDaily` (djb2-hashed date seed for future
File diff suppressed because it is too large Load Diff
+40 -58
View File
@@ -1,16 +1,21 @@
/**
* @file Classic-mode champion comparison — ported from tiennm99/loldle
* (lib/classic-mode.js). Pure functions, no DOM/React.
* @file Classic-mode champion comparison against the raw loldle.net schema.
* Pure functions, no DOM/React.
*
* Champion records use the shape emitted by scripts/scrape-loldle-data.js
* (identical to loldle.net's JS bundle): gender is a string ("Male"), the
* multi-value axes (positions, species, regions, range_type) are arrays,
* and release_date is an ISO "YYYY-MM-DD" string.
*/
export const CLASSIC_ATTRIBUTES = [
{ key: "gender", label: "Gender", type: "exact" },
{ key: "species", label: "Species", type: "multi" },
{ key: "attackType", label: "Range", type: "exact" },
{ key: "range_type", label: "Range", type: "multi" },
{ key: "resource", label: "Resource", type: "exact" },
{ key: "region", label: "Region", type: "multi" },
{ key: "lane", label: "Lane", type: "multi" },
{ key: "releaseDate", label: "Year", type: "year" },
{ key: "regions", label: "Region", type: "multi" },
{ key: "positions", label: "Lane", type: "multi" },
{ key: "release_date", label: "Year", type: "year" },
];
/**
@@ -20,32 +25,32 @@ export const CLASSIC_ATTRIBUTES = [
*/
export function compareChampions(guess, target) {
return CLASSIC_ATTRIBUTES.map((attr) => {
const guessVal = guess[attr.key] ?? "";
const targetVal = target[attr.key] ?? "";
const guessVal = guess[attr.key];
const targetVal = target[attr.key];
switch (attr.type) {
case "exact":
return {
...attr,
guessValue: formatValue(attr.key, guessVal),
targetValue: formatValue(attr.key, targetVal),
guessValue: formatValue(guessVal),
targetValue: formatValue(targetVal),
result:
String(guessVal).toLowerCase() === String(targetVal).toLowerCase()
String(guessVal ?? "").toLowerCase() === String(targetVal ?? "").toLowerCase()
? "correct"
: "wrong",
};
case "multi":
return {
...attr,
guessValue: formatValue(attr.key, guessVal),
targetValue: formatValue(attr.key, targetVal),
guessValue: formatValue(guessVal),
targetValue: formatValue(targetVal),
result: compareMultiValue(guessVal, targetVal),
};
case "year":
return {
...attr,
guessValue: guessVal || "?",
targetValue: targetVal || "?",
guessValue: parseYear(guessVal) || "?",
targetValue: parseYear(targetVal) || "?",
...compareYear(guessVal, targetVal),
};
default:
@@ -54,9 +59,9 @@ export function compareChampions(guess, target) {
});
}
function compareMultiValue(guessStr, targetStr) {
const guessSet = parseSet(guessStr);
const targetSet = parseSet(targetStr);
function compareMultiValue(guess, target) {
const guessSet = toSet(guess);
const targetSet = toSet(target);
if (guessSet.size === 0 && targetSet.size === 0) return "correct";
if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
@@ -67,56 +72,33 @@ function compareMultiValue(guessStr, targetStr) {
return "wrong";
}
function compareYear(guessYear, targetYear) {
const g = Number(guessYear);
const t = Number(targetYear);
function parseYear(val) {
if (!val) return 0;
const m = String(val).match(/^(\d{4})/);
return m ? Number(m[1]) : 0;
}
function compareYear(guess, target) {
const g = parseYear(guess);
const t = parseYear(target);
if (!g || !t) return { result: "wrong" };
if (g === t) return { result: "correct" };
return { result: "wrong", direction: g < t ? "up" : "down" };
}
function parseSet(str) {
if (!str) return new Set();
return new Set(
String(str)
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
function toSet(val) {
const arr = Array.isArray(val) ? val : String(val ?? "").split(",");
return new Set(arr.map((s) => String(s).trim().toLowerCase()).filter(Boolean));
}
function setsEqual(a, b) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
for (const v of a) if (!b.has(v)) return false;
return true;
}
function formatValue(key, value) {
if (!value) return "—";
const str = String(value);
switch (key) {
case "gender":
return capitalize(str);
case "attackType":
return str === "close" ? "Melee" : "Ranged";
case "region":
return str
.split(",")
.map((s) => s.split("-").map(capitalize).join(" "))
.join(", ");
case "species":
case "lane":
return str
.split(",")
.map((s) => capitalize(s.trim()))
.join(", ");
default:
return str;
}
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
function formatValue(val) {
if (val == null || val === "") return "—";
if (Array.isArray(val)) return val.length === 0 ? "—" : val.join(", ");
return String(val);
}
+10 -10
View File
@@ -71,7 +71,7 @@ async function getOrInitGame(db, subject) {
async function startFreshGame(db, subject) {
const target = pickRandom(champions);
const fresh = {
target: target.id,
target: target.championName,
guesses: [],
solved: false,
giveup: false,
@@ -118,14 +118,14 @@ export async function handleLoldle(ctx, db) {
const guess = findChampion(champions, arg);
if (!guess) return ctx.reply(`Champion not found: "${arg}".`);
if (game.guesses.some((g) => g.champion === guess.name)) {
if (game.guesses.some((g) => g.champion === guess.championName)) {
return ctx.reply(
`🔁 <b>${escapeHtml(guess.name)}</b> was already guessed this round — try another champion.`,
`🔁 <b>${escapeHtml(guess.championName)}</b> was already guessed this round — try another champion.`,
{ parse_mode: "HTML" },
);
}
const target = champions.find((c) => c.id === game.target);
const target = champions.find((c) => c.championName === game.target);
// champions.json can be refreshed between rounds — an active target may disappear.
if (!target) {
await startFreshGame(db, subject);
@@ -134,14 +134,14 @@ export async function handleLoldle(ctx, db) {
);
}
const results = compareChampions(guess, target);
game.guesses.push({ champion: guess.name, results });
const won = guess.id === target.id;
game.guesses.push({ champion: guess.championName, results });
const won = guess.championName === target.championName;
if (won) game.solved = true;
await saveGame(db, subject, game);
const reply = renderGuess(guess.name, results);
const reply = renderGuess(guess.championName, results);
const elapsed = formatDuration(Date.now() - (game.startedAt ?? Date.now()));
const champ = escapeHtml(target.name);
const champ = escapeHtml(target.championName);
if (won) {
const s = await recordResult(db, subject, true);
@@ -178,10 +178,10 @@ export async function handleGiveup(ctx, db) {
game.giveup = true;
await saveGame(db, subject, game);
await recordResult(db, subject, false);
const target = champions.find((c) => c.id === game.target);
const target = champions.find((c) => c.championName === game.target);
await startFreshGame(db, subject);
await trySendSticker(ctx, GIVEUP_STICKERS);
const answer = target ? escapeHtml(target.name) : escapeHtml(game.target);
const answer = target ? escapeHtml(target.championName) : escapeHtml(game.target);
return ctx.reply(`🏳️ Answer was ${answer}.\n${NEW_ROUND_HINT}`, { parse_mode: "HTML" });
}
+4 -6
View File
@@ -1,7 +1,7 @@
/**
* @file Champion name lookup — normalizes user input to a champion record.
* Matches by exact id/name (case/space/punct-insensitive).
* Falls back to prefix match when unique.
* Matches championName case/space/punct-insensitive. Falls back to prefix
* match when unique.
*/
function normalize(s) {
@@ -20,11 +20,9 @@ export function findChampion(champions, input) {
if (!q) return null;
for (const c of champions) {
if (normalize(c.id) === q || normalize(c.name) === q) return c;
if (normalize(c.championName) === q) return c;
}
const prefixMatches = champions.filter(
(c) => normalize(c.id).startsWith(q) || normalize(c.name).startsWith(q),
);
const prefixMatches = champions.filter((c) => normalize(c.championName).startsWith(q));
return prefixMatches.length === 1 ? prefixMatches[0] : null;
}
+1 -1
View File
@@ -28,7 +28,7 @@ function buildRows(championName, results) {
for (const r of results) {
const marker = MARKER[r.result] ?? MARKER.wrong;
let value = String(r.guessValue ?? "");
if (r.key === "releaseDate" && r.result !== "correct" && r.direction) {
if (r.key === "release_date" && r.result !== "correct" && r.direction) {
const arrow = ARROW[r.direction];
if (arrow) value = `${value} ${arrow}`;
}
+29 -33
View File
@@ -2,36 +2,36 @@ import { describe, expect, it } from "vitest";
import { CLASSIC_ATTRIBUTES, compareChampions } from "../../../src/modules/loldle/compare.js";
const aatrox = {
id: "Aatrox",
gender: "male",
species: "darkin",
attackType: "close",
championName: "Aatrox",
gender: "Male",
species: ["Darkin"],
range_type: ["Melee"],
resource: "Manaless",
region: "runeterra,shurima",
lane: "top",
releaseDate: 2013,
regions: ["Runeterra", "Shurima"],
positions: ["Top"],
release_date: "2013-06-13",
};
const ahri = {
id: "Ahri",
gender: "female",
species: "vastayan",
attackType: "range",
championName: "Ahri",
gender: "Female",
species: ["Vastayan"],
range_type: ["Ranged"],
resource: "Mana",
region: "ionia",
lane: "mid",
releaseDate: 2011,
regions: ["Ionia"],
positions: ["Middle"],
release_date: "2011-12-14",
};
const akali = {
id: "Akali",
gender: "female",
species: "human",
attackType: "close",
championName: "Akali",
gender: "Female",
species: ["Human"],
range_type: ["Melee"],
resource: "Energy",
region: "ionia",
lane: "mid,top",
releaseDate: 2010,
regions: ["Ionia"],
positions: ["Middle", "Top"],
release_date: "2010-05-11",
};
function byKey(results, key) {
@@ -47,38 +47,34 @@ describe("compareChampions", () => {
it("exact mismatch is wrong", () => {
const r = compareChampions(aatrox, ahri);
expect(byKey(r, "gender").result).toBe("wrong");
expect(byKey(r, "attackType").result).toBe("wrong");
expect(byKey(r, "resource").result).toBe("wrong");
});
it("multi-value partial overlap is partial", () => {
const r = compareChampions({ ...akali, lane: "mid,top" }, { ...ahri, lane: "mid" });
expect(byKey(r, "lane").result).toBe("partial");
const r2 = compareChampions(
{ ...aatrox, region: "runeterra,shurima" },
{ ...ahri, region: "runeterra,ionia" },
);
expect(byKey(r2, "region").result).toBe("partial");
const r = compareChampions(akali, { ...ahri, positions: ["Middle"] });
expect(byKey(r, "positions").result).toBe("partial");
const r2 = compareChampions(aatrox, { ...ahri, regions: ["Runeterra", "Ionia"] });
expect(byKey(r2, "regions").result).toBe("partial");
});
it("multi-value identical sets are correct even if order/case differ", () => {
const r = compareChampions(
{ ...akali, species: "human,ninja" },
{ ...akali, species: "Ninja,Human" },
{ ...akali, species: ["Human", "Ninja"] },
{ ...akali, species: ["ninja", "HUMAN"] },
);
expect(byKey(r, "species").result).toBe("correct");
});
it("year direction hints up when guess < target", () => {
const r = compareChampions(akali, aatrox); // 2010 vs 2013
const y = byKey(r, "releaseDate");
const y = byKey(r, "release_date");
expect(y.result).toBe("wrong");
expect(y.direction).toBe("up");
});
it("year direction hints down when guess > target", () => {
const r = compareChampions(aatrox, akali); // 2013 vs 2010
const y = byKey(r, "releaseDate");
const y = byKey(r, "release_date");
expect(y.result).toBe("wrong");
expect(y.direction).toBe("down");
});
+13 -12
View File
@@ -2,22 +2,23 @@ import { describe, expect, it } from "vitest";
import { findChampion } from "../../../src/modules/loldle/lookup.js";
const champions = [
{ id: "Aatrox", name: "Aatrox" },
{ id: "Ahri", name: "Ahri" },
{ id: "KhaZix", name: "Kha'Zix" },
{ id: "MissFortune", name: "Miss Fortune" },
{ championName: "Aatrox" },
{ championName: "Ahri" },
{ championName: "Kha'Zix" },
{ championName: "Miss Fortune" },
];
describe("findChampion", () => {
it("matches by exact id (case-insensitive)", () => {
expect(findChampion(champions, "aatrox").id).toBe("Aatrox");
expect(findChampion(champions, "AATROX").id).toBe("Aatrox");
it("matches championName (case-insensitive)", () => {
expect(findChampion(champions, "aatrox").championName).toBe("Aatrox");
expect(findChampion(champions, "AATROX").championName).toBe("Aatrox");
});
it("normalizes punctuation and spaces", () => {
expect(findChampion(champions, "kha'zix").id).toBe("KhaZix");
expect(findChampion(champions, "miss fortune").id).toBe("MissFortune");
expect(findChampion(champions, "MissFortune").id).toBe("MissFortune");
expect(findChampion(champions, "kha'zix").championName).toBe("Kha'Zix");
expect(findChampion(champions, "khazix").championName).toBe("Kha'Zix");
expect(findChampion(champions, "miss fortune").championName).toBe("Miss Fortune");
expect(findChampion(champions, "MissFortune").championName).toBe("Miss Fortune");
});
it("returns null for non-matching input", () => {
@@ -26,11 +27,11 @@ describe("findChampion", () => {
});
it("falls back to unique prefix match", () => {
expect(findChampion(champions, "aat").id).toBe("Aatrox");
expect(findChampion(champions, "aat").championName).toBe("Aatrox");
});
it("prefix match returns null on ambiguity", () => {
const ambig = [...champions, { id: "Aatrox2", name: "Aatrox 2" }];
const ambig = [...champions, { championName: "Aatrox Prime" }];
expect(findChampion(ambig, "aa")).toBeNull();
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ const sampleResults = [
{ key: "region", label: "Region", result: "correct", guessValue: "Runeterra" },
{ key: "lane", label: "Lane", result: "partial", guessValue: "Jungle, Support" },
{
key: "releaseDate",
key: "release_date",
label: "Year",
result: "wrong",
direction: "up",
@@ -48,7 +48,7 @@ describe("renderGuess", () => {
expect(up).toContain("2011 ⬆️");
const correctYear = sampleResults.map((r) =>
r.key === "releaseDate" ? { ...r, result: "correct", direction: undefined } : r,
r.key === "release_date" ? { ...r, result: "correct", direction: undefined } : r,
);
const out = renderGuess("Brand", correctYear);
expect(out).not.toContain("⬆️");