mirror of
https://github.com/tiennm99/loldle.git
synced 2026-09-07 18:17:19 +00:00
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:
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user