refactor: port lib layer to src/lib

classic-mode.js moves byte-for-byte. champion-data.js swaps the Next.js
env-var fetch prefix for asset() from $app/paths, which resolves to
/champions.json in dev and /loldle/champions.json in the deployed build.
getChampionImageUrl keeps returning an absolute Data Dragon URL, unwrapped.

Drops three exports with no callers: getAllChampions, getChampionByName,
getGuessedNames.

The characterization suite passes unchanged against the new location -- only
import specifiers moved, no assertions touched.
This commit is contained in:
2026-07-25 11:01:40 +07:00
parent e2ae34a8ab
commit 078d80c962
7 changed files with 391 additions and 4 deletions
+75
View File
@@ -0,0 +1,75 @@
// Data layer: load, search, and select champions
import { asset } from "$app/paths";
let champions = [];
let loadPromise = null;
/** Fetch and parse champions data (deduplicated) */
export async function loadChampions() {
if (champions.length > 0) return champions;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
const response = await fetch(asset("/champions.json"));
if (!response.ok) {
loadPromise = null;
throw new Error(`Failed to load champions: ${response.status}`);
}
champions = await response.json();
return champions;
})();
return loadPromise;
}
/** Filter champions for autocomplete (prefix-first, then substring) */
export function searchChampions(query, excludeNames = []) {
if (!query) return [];
const lower = query.toLowerCase();
const excluded = new Set(excludeNames.map((n) => n.toLowerCase()));
return champions
.filter(
(c) =>
c.name.toLowerCase().includes(lower) &&
!excluded.has(c.name.toLowerCase()),
)
.sort((a, b) => {
const aStarts = a.name.toLowerCase().startsWith(lower) ? 0 : 1;
const bStarts = b.name.toLowerCase().startsWith(lower) ? 0 : 1;
return aStarts - bStarts || a.name.localeCompare(b.name);
})
.slice(0, 8);
}
/** Seeded random champion selection (deterministic for same seed string) */
export function getRandomChampion(seed) {
if (!champions.length) return null;
const hash = hashString(seed);
const index = hash % champions.length;
return champions[index];
}
/** Simple string hash (djb2) */
function hashString(str) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = (hash * 33) ^ str.charCodeAt(i);
}
return hash >>> 0;
}
/** Get today's local date string for daily seed */
export function getTodaySeed() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Get champion image URL from Data Dragon CDN */
export function getChampionImageUrl(championId) {
return `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/${championId}_0.jpg`;
}
+109
View File
@@ -0,0 +1,109 @@
// Classic mode: compare two champions across 7 attributes
/** Attributes displayed in order */
export const CLASSIC_ATTRIBUTES = [
{ key: "gender", label: "Gender", type: "exact" },
{ key: "genre", label: "Genre", type: "multi" },
{ key: "attackType", label: "Range", type: "exact" },
{ key: "resource", label: "Resource", type: "exact" },
{ key: "region", label: "Region", type: "exact" },
{ key: "lane", label: "Lane", type: "multi" },
{ key: "releaseDate", label: "Year", type: "year" },
];
/** Compare guess champion against target champion */
export function compareChampions(guess, target) {
return CLASSIC_ATTRIBUTES.map((attr) => {
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),
result: guessVal.toLowerCase() === targetVal.toLowerCase() ? "correct" : "wrong",
};
case "multi":
return {
...attr,
guessValue: formatValue(attr.key, guessVal),
targetValue: formatValue(attr.key, targetVal),
result: compareMultiValue(guessVal, targetVal),
};
case "year":
return {
...attr,
guessValue: guessVal || "?",
targetValue: targetVal || "?",
...compareYear(guessVal, targetVal),
};
default:
return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" };
}
});
}
function compareMultiValue(guessStr, targetStr) {
const guessSet = parseSet(guessStr);
const targetSet = parseSet(targetStr);
if (guessSet.size === 0 && targetSet.size === 0) return "correct";
if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
if (setsEqual(guessSet, targetSet)) return "correct";
for (const val of guessSet) {
if (targetSet.has(val)) return "partial";
}
return "wrong";
}
function compareYear(guessYear, targetYear) {
const g = Number(guessYear);
const t = Number(targetYear);
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(
str.split(",").map((s) => 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;
}
return true;
}
function formatValue(key, value) {
if (!value) return "—";
switch (key) {
case "gender":
return capitalize(value);
case "attackType":
return value === "close" ? "Melee" : "Ranged";
case "region":
return value.split("-").map(capitalize).join(" ");
case "genre":
case "lane":
return value.split(",").map((s) => capitalize(s.trim())).join(", ");
default:
return value;
}
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
+191
View File
@@ -0,0 +1,191 @@
// Mode-agnostic game state machine with localStorage persistence
const STORAGE_KEY_PREFIX = "loldle_";
const UNLIMITED_SEED_KEY = `${STORAGE_KEY_PREFIX}unlimited_seed`;
/**
* Create a new game instance
* @param {Object} config
* @param {Object} config.target - Target champion to guess
* @param {Function} config.compareFn - (guess, target) => comparison results
* @param {number} config.maxGuesses - Max allowed guesses (0 = unlimited)
* @param {string} config.mode - "daily" or "unlimited"
* @param {string} config.seed - Seed string for mode
*/
export function createGame(config) {
const { target, compareFn, maxGuesses = 6, mode = "daily", seed = "" } = config;
// Try to restore saved state
const saved = loadState(mode, seed);
if (saved && saved.targetName === target.name) {
return {
target,
compareFn,
maxGuesses,
mode,
seed,
guesses: saved.guesses,
results: saved.results,
isOver: saved.isOver,
isWon: saved.isWon,
};
}
return {
target,
compareFn,
maxGuesses,
mode,
seed,
guesses: [],
results: [],
isOver: false,
isWon: false,
};
}
/** Submit a guess and return updated game state (immutable) */
export function submitGuess(game, champion) {
if (game.isOver) return null;
if (game.guesses.some((g) => g.name === champion.name)) return null;
const result = game.compareFn(champion, game.target);
const guesses = [...game.guesses, champion];
const results = [...game.results, result];
let isWon = false;
let isOver = false;
if (champion.name === game.target.name) {
isWon = true;
isOver = true;
} else if (game.maxGuesses > 0 && guesses.length >= game.maxGuesses) {
isOver = true;
}
const updated = { ...game, guesses, results, isWon, isOver };
saveState(updated);
return updated;
}
/** Get or create a persistent seed for unlimited mode */
export function getOrCreateUnlimitedSeed() {
try {
const saved = localStorage.getItem(UNLIMITED_SEED_KEY);
if (saved) return saved;
} catch {
// Ignore
}
return createNewUnlimitedSeed();
}
/** Create and persist a new unlimited seed */
function createNewUnlimitedSeed() {
const seed = `unlimited_${Date.now()}_${Math.random()}`;
try {
localStorage.setItem(UNLIMITED_SEED_KEY, seed);
} catch {
// Ignore
}
return seed;
}
/** Clear unlimited mode saved state (for new game) */
export function clearUnlimitedState() {
try {
localStorage.removeItem(`${STORAGE_KEY_PREFIX}unlimited_current`);
localStorage.removeItem(UNLIMITED_SEED_KEY);
} catch {
// Ignore
}
}
/** Save unlimited mode stats */
export function saveUnlimitedStats(game) {
if (game.mode !== "unlimited" || !game.isOver) return;
const key = `${STORAGE_KEY_PREFIX}unlimited_stats`;
let stats;
try {
stats = JSON.parse(localStorage.getItem(key)) || createEmptyStats();
} catch {
stats = createEmptyStats();
}
stats.gamesPlayed++;
if (game.isWon) {
stats.gamesWon++;
const guessCount = game.guesses.length;
stats.guessDistribution[guessCount] = (stats.guessDistribution[guessCount] || 0) + 1;
}
stats.lastPlayed = Date.now();
try {
localStorage.setItem(key, JSON.stringify(stats));
} catch {
// Ignore
}
}
/** Load unlimited stats */
export function loadUnlimitedStats() {
try {
return JSON.parse(localStorage.getItem(`${STORAGE_KEY_PREFIX}unlimited_stats`)) || createEmptyStats();
} catch {
return createEmptyStats();
}
}
function createEmptyStats() {
return { gamesPlayed: 0, gamesWon: 0, guessDistribution: {}, lastPlayed: null };
}
function saveState(game) {
const key = getStorageKey(game.mode, game.seed);
const data = {
targetName: game.target.name,
guesses: game.guesses,
results: game.results,
isOver: game.isOver,
isWon: game.isWon,
timestamp: Date.now(),
};
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
// Ignore
}
}
function loadState(mode, seed) {
const key = getStorageKey(mode, seed);
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** Remove stale daily entries from localStorage (keeps only today's) */
export function clearExpiredCache(todaySeed) {
try {
const todayKey = `${STORAGE_KEY_PREFIX}daily_${todaySeed}`;
const keysToRemove = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(`${STORAGE_KEY_PREFIX}daily_`) && key !== todayKey) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => localStorage.removeItem(key));
} catch {
// Ignore
}
}
function getStorageKey(mode, seed) {
if (mode === "daily") return `${STORAGE_KEY_PREFIX}daily_${seed}`;
return `${STORAGE_KEY_PREFIX}unlimited_current`;
}
+7 -2
View File
@@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getChampionImageUrl, getTodaySeed } from "../lib/champion-data";
import { getChampionImageUrl, getTodaySeed } from "$lib/champion-data";
import { AATROX, CHAMPIONS } from "./fixtures/champions";
// $app/paths is a SvelteKit build-time construct with no meaning to a bare
// Vitest run. Base-path behaviour is verified by the production preview check,
// not here, so the identity stub is the honest unit-test substitute.
vi.mock("$app/paths", () => ({ asset: (p) => p }));
/**
* `champions` is module-private with no reset export, so each group gets a fresh
* module instance instead of adding production code for testability.
@@ -14,7 +19,7 @@ async function freshModule({ champions = CHAMPIONS, ok = true, status = 200 } =
json: async () => champions,
}));
vi.stubGlobal("fetch", fetchMock);
const mod = await import("../lib/champion-data");
const mod = await import("$lib/champion-data");
return { mod, fetchMock };
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { CLASSIC_ATTRIBUTES, compareChampions } from "../lib/classic-mode";
import { CLASSIC_ATTRIBUTES, compareChampions } from "$lib/classic-mode";
import { AATROX, AHRI, AKALI, BELVETH } from "./fixtures/champions";
/** Pull one attribute's comparison out of the 7-entry result array. */
+1 -1
View File
@@ -7,7 +7,7 @@ import {
loadUnlimitedStats,
saveUnlimitedStats,
submitGuess,
} from "../lib/game-engine";
} from "$lib/game-engine";
import { AATROX, AHRI } from "./fixtures/champions";
/** Minimal stand-in for compareChampions — the engine only stores what this returns. */
+7
View File
@@ -1,6 +1,13 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
// Vitest does not read svelte.config.js, so $lib must be declared here too.
alias: {
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
},
},
test: {
environment: "node",
setupFiles: ["./test/setup-local-storage.js"],