diff --git a/web/src/lib/champion-data.js b/web/src/lib/champion-data.js new file mode 100644 index 0000000..1c32e44 --- /dev/null +++ b/web/src/lib/champion-data.js @@ -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`; +} diff --git a/web/src/lib/classic-mode.js b/web/src/lib/classic-mode.js new file mode 100644 index 0000000..2522ee2 --- /dev/null +++ b/web/src/lib/classic-mode.js @@ -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); +} diff --git a/web/src/lib/game-engine.js b/web/src/lib/game-engine.js new file mode 100644 index 0000000..7a5a4ed --- /dev/null +++ b/web/src/lib/game-engine.js @@ -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`; +} diff --git a/web/test/champion-data.test.js b/web/test/champion-data.test.js index b4d01f6..d477766 100644 --- a/web/test/champion-data.test.js +++ b/web/test/champion-data.test.js @@ -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 }; } diff --git a/web/test/classic-mode.test.js b/web/test/classic-mode.test.js index b3df281..05ddaba 100644 --- a/web/test/classic-mode.test.js +++ b/web/test/classic-mode.test.js @@ -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. */ diff --git a/web/test/game-engine.test.js b/web/test/game-engine.test.js index 0d658d2..10d2d50 100644 --- a/web/test/game-engine.test.js +++ b/web/test/game-engine.test.js @@ -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. */ diff --git a/web/vitest.config.js b/web/vitest.config.js index b4c77f4..ae15df0 100644 --- a/web/vitest.config.js +++ b/web/vitest.config.js @@ -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"],