mirror of
https://github.com/tiennm99/loldle.git
synced 2026-10-16 22:14:47 +00:00
Merge pull request #24 from tiennm99/feat/sveltekit
Migrate from Next.js to SvelteKit
This commit is contained in:
44 files changed
+2172
-3274
No files matched your search
@@ -0,0 +1,36 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
# No BASE_PATH here: this checks that the app compiles. The base-path
|
||||
# behaviour is verified against a real preview before release.
|
||||
- run: pnpm build
|
||||
@@ -31,11 +31,11 @@ jobs:
|
||||
|
||||
- run: pnpm build
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_PATH: /${{ github.event.repository.name }}
|
||||
BASE_PATH: /${{ github.event.repository.name }}
|
||||
|
||||
- uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: out
|
||||
path: build
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
||||
@@ -29,20 +29,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
sha=$(gh api "repos/$SOURCE_REPO/commits/main" --jq .sha)
|
||||
gh api "repos/$SOURCE_REPO/contents/champions.json?ref=$sha" \
|
||||
-H "Accept: application/vnd.github.raw" > public/champions.json
|
||||
-H "Accept: application/vnd.github.raw" > static/champions.json
|
||||
echo "SOURCE_SHA=${sha:0:7}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Commit if changed
|
||||
id: commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- public/champions.json; then
|
||||
echo "public/champions.json already matches loldle-data@$SOURCE_SHA"
|
||||
if git diff --quiet -- static/champions.json; then
|
||||
echo "static/champions.json already matches loldle-data@$SOURCE_SHA"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add public/champions.json
|
||||
git add static/champions.json
|
||||
git commit -m "chore(data): sync champions from loldle-data@$SOURCE_SHA"
|
||||
git push
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+2
-3
@@ -18,9 +18,8 @@ chrome-dev-tools
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
# sveltekit
|
||||
/.svelte-kit/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -4,6 +4,8 @@ LoLdle-style League of Legends daily champion guessing game — data auto-update
|
||||
|
||||
Live: https://tiennm99.github.io/loldle/
|
||||
|
||||
Built with SvelteKit and Svelte 5, styled with Tailwind CSS, and prerendered to a static site deployed on GitHub Pages.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -11,6 +13,18 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # dev server
|
||||
pnpm build # static build into build/
|
||||
pnpm preview # serve the production build
|
||||
pnpm test # unit tests
|
||||
pnpm lint # eslint
|
||||
```
|
||||
|
||||
Champion data lives in `static/champions.json` and is refreshed weekly by the sync workflow.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](LICENSE).
|
||||
@@ -1,14 +0,0 @@
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "LoLdle",
|
||||
description: "Guess the League of Legends champion",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import GameBoard from "@/components/game-board";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex flex-col items-center min-h-screen">
|
||||
<header className="text-center pt-6 pb-2 w-full max-w-[900px]">
|
||||
<h1 className="text-3xl font-bold tracking-wider">
|
||||
Lo<span className="text-[var(--color-accent)]">L</span>dle
|
||||
</h1>
|
||||
</header>
|
||||
<GameBoard />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import Image from "next/image";
|
||||
import { searchChampions, getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Autocomplete search input for champion selection */
|
||||
export default function ChampionSearch({ excludeNames, onSelect, disabled }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// Derive matches from query
|
||||
const matches = useMemo(() => {
|
||||
if (!query.trim()) return [];
|
||||
return searchChampions(query, excludeNames);
|
||||
}, [query, excludeNames]);
|
||||
|
||||
// Derive dropdown open state from matches (no effect needed)
|
||||
const isVisible = isOpen && matches.length > 0;
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
const selectChampion = useCallback(
|
||||
(champion) => {
|
||||
setQuery("");
|
||||
setIsOpen(false);
|
||||
onSelect(champion);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!isVisible || !matches.length) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, matches.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < matches.length) {
|
||||
selectChampion(matches[activeIndex]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[400px] mx-auto px-4">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); setIsOpen(true); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Game over" : "Type a champion name..."}
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-3 rounded-lg bg-[var(--color-input-bg)] border-2 border-[var(--color-input-border)] text-[var(--color-text)] text-base outline-none transition-colors focus:border-[var(--color-accent)] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-4 right-4 bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-b-lg max-h-80 overflow-y-auto z-50"
|
||||
>
|
||||
{matches.map((champion, i) => (
|
||||
<div
|
||||
key={champion.id}
|
||||
onClick={() => selectChampion(champion)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
|
||||
i === activeIndex ? "bg-[var(--color-surface-hover)]" : "hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={36}
|
||||
height={36}
|
||||
className="rounded object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-sm">{champion.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
loadChampions,
|
||||
getRandomChampion,
|
||||
getTodaySeed,
|
||||
} from "@/lib/champion-data";
|
||||
import {
|
||||
createGame,
|
||||
submitGuess,
|
||||
getGuessedNames,
|
||||
getOrCreateUnlimitedSeed,
|
||||
clearUnlimitedState,
|
||||
saveUnlimitedStats,
|
||||
loadUnlimitedStats,
|
||||
clearExpiredCache,
|
||||
} from "@/lib/game-engine";
|
||||
import { compareChampions } from "@/lib/classic-mode";
|
||||
import ChampionSearch from "./champion-search";
|
||||
import GuessGrid from "./guess-grid";
|
||||
import GameOver from "./game-over";
|
||||
import StatsDisplay from "./stats-display";
|
||||
|
||||
export default function GameBoard() {
|
||||
const [mode, setMode] = useState("daily");
|
||||
const [game, setGame] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
const gameRef = useRef(null);
|
||||
|
||||
const initGame = useCallback((gameMode) => {
|
||||
const seed = gameMode === "daily" ? getTodaySeed() : getOrCreateUnlimitedSeed();
|
||||
const target = getRandomChampion(seed);
|
||||
const maxGuesses = gameMode === "daily" ? 6 : 0;
|
||||
|
||||
const newGame = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode: gameMode,
|
||||
seed,
|
||||
});
|
||||
|
||||
gameRef.current = newGame;
|
||||
setGame({ ...newGame });
|
||||
setStats(gameMode === "unlimited" ? loadUnlimitedStats() : null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadChampions()
|
||||
.then(() => {
|
||||
clearExpiredCache(getTodaySeed());
|
||||
initGame("daily");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [initGame]);
|
||||
|
||||
function switchMode(newMode) {
|
||||
setMode(newMode);
|
||||
initGame(newMode);
|
||||
}
|
||||
|
||||
const handleGuess = useCallback((champion) => {
|
||||
const currentGame = gameRef.current;
|
||||
if (!currentGame) return;
|
||||
|
||||
const updated = submitGuess(currentGame, champion);
|
||||
if (!updated) return;
|
||||
|
||||
gameRef.current = updated;
|
||||
setGame(updated);
|
||||
|
||||
if (updated.isOver && updated.mode === "unlimited") {
|
||||
saveUnlimitedStats(updated);
|
||||
setStats(loadUnlimitedStats());
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleNewGame() {
|
||||
clearUnlimitedState();
|
||||
initGame("unlimited");
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-center text-[var(--color-text-muted)] py-8">Loading champions...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-center text-[var(--color-wrong)] py-8">Failed to load champion data. Please refresh.</p>;
|
||||
}
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
const excludeNames = game.guesses.map((g) => g.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex justify-center gap-1 my-3 bg-[var(--color-surface)] rounded-lg p-1 w-fit mx-auto">
|
||||
<button
|
||||
onClick={() => switchMode("daily")}
|
||||
className={`px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all ${
|
||||
mode === "daily"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-text)]"
|
||||
: "bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchMode("unlimited")}
|
||||
className={`px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all ${
|
||||
mode === "unlimited"
|
||||
? "bg-[var(--color-accent)] text-[var(--color-text)]"
|
||||
: "bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]"
|
||||
}`}
|
||||
>
|
||||
Unlimited
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && <StatsDisplay stats={stats} />}
|
||||
|
||||
<ChampionSearch
|
||||
excludeNames={excludeNames}
|
||||
onSelect={handleGuess}
|
||||
disabled={game.isOver}
|
||||
/>
|
||||
|
||||
<p className="text-center text-[var(--color-text-muted)] text-sm my-3">
|
||||
{game.maxGuesses > 0
|
||||
? `${game.guesses.length} / ${game.maxGuesses} guesses`
|
||||
: `${game.guesses.length} guess${game.guesses.length !== 1 ? "es" : ""}`}
|
||||
</p>
|
||||
|
||||
<GuessGrid guesses={game.guesses} results={game.results} />
|
||||
|
||||
{game.isOver && (
|
||||
<GameOver
|
||||
isWon={game.isWon}
|
||||
target={game.target}
|
||||
guessCount={game.guesses.length}
|
||||
onNewGame={mode === "unlimited" ? handleNewGame : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Win/loss message with optional new game button */
|
||||
export default function GameOver({ isWon, target, guessCount, onNewGame }) {
|
||||
return (
|
||||
<div className={`text-center py-6 px-4 mx-auto max-w-[400px]`}>
|
||||
<h2
|
||||
className={`text-2xl font-bold mb-2 ${
|
||||
isWon ? "text-[var(--color-correct)]" : "text-[var(--color-wrong)]"
|
||||
}`}
|
||||
>
|
||||
{isWon ? "You got it!" : "Game Over"}
|
||||
</h2>
|
||||
|
||||
{isWon ? (
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
You found <strong className="text-[var(--color-text)]">{target.name}</strong> in{" "}
|
||||
<strong className="text-[var(--color-text)]">{guessCount}</strong>{" "}
|
||||
guess{guessCount > 1 ? "es" : ""}!
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[var(--color-text-muted)] mb-3">
|
||||
The champion was <strong className="text-[var(--color-text)]">{target.name}</strong>
|
||||
</p>
|
||||
<Image
|
||||
src={getChampionImageUrl(target.id)}
|
||||
alt={target.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="rounded-lg object-cover mx-auto"
|
||||
unoptimized
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onNewGame && (
|
||||
<button
|
||||
onClick={onNewGame}
|
||||
className="mt-4 px-6 py-2.5 rounded-lg bg-[var(--color-accent)] text-[var(--color-text)] font-semibold cursor-pointer transition-opacity hover:opacity-85"
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CLASSIC_ATTRIBUTES } from "@/lib/classic-mode";
|
||||
import GuessRow from "./guess-row";
|
||||
|
||||
/** Grid with header row + guess rows */
|
||||
export default function GuessGrid({ guesses, results }) {
|
||||
return (
|
||||
<div className="w-full max-w-[900px] px-4 overflow-x-auto">
|
||||
{/* Header row */}
|
||||
<div className="guess-row header-row">
|
||||
<div className="guess-cell header-cell">Champion</div>
|
||||
{CLASSIC_ATTRIBUTES.map((attr) => (
|
||||
<div key={attr.key} className="guess-cell header-cell">
|
||||
{attr.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Guess rows — newest first */}
|
||||
{guesses
|
||||
.map((champion, i) => (
|
||||
<GuessRow key={champion.id} champion={champion} results={results[i]} />
|
||||
))
|
||||
.reverse()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { getChampionImageUrl } from "@/lib/champion-data";
|
||||
|
||||
/** Single guess row with colored attribute cells */
|
||||
export default function GuessRow({ champion, results, animationDelay = 0 }) {
|
||||
return (
|
||||
<div className="guess-row">
|
||||
{/* Champion name + image */}
|
||||
<div
|
||||
className="guess-cell champion-cell"
|
||||
style={{ animationDelay: `${animationDelay}s` }}
|
||||
>
|
||||
<Image
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded object-cover shrink-0"
|
||||
unoptimized
|
||||
/>
|
||||
<span className="text-xs font-semibold">{champion.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Attribute cells */}
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className={`guess-cell cell-${r.result}`}
|
||||
style={{ animationDelay: `${animationDelay + (i + 1) * 0.08}s` }}
|
||||
>
|
||||
<span>{r.guessValue}</span>
|
||||
{r.direction && (
|
||||
<span className="font-bold text-base">
|
||||
{r.direction === "up" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/** Unlimited mode stats display */
|
||||
export default function StatsDisplay({ stats }) {
|
||||
if (!stats || stats.gamesPlayed === 0) return null;
|
||||
|
||||
const winRate = Math.round((stats.gamesWon / stats.gamesPlayed) * 100);
|
||||
|
||||
const items = [
|
||||
{ value: stats.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats.gamesWon, label: "Won" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-6 py-3 mb-4">
|
||||
{items.map(({ value, label }) => (
|
||||
<div key={label} className="flex flex-col items-center gap-0.5">
|
||||
<span className="text-xl font-bold text-[var(--color-text)]">{value}</span>
|
||||
<span className="text-[0.7rem] text-[var(--color-text-muted)] uppercase">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+24
-6
@@ -1,9 +1,27 @@
|
||||
import js from "@eslint/js";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import globals from "globals";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||
export default defineConfig([
|
||||
globalIgnores([".svelte-kit/**", "build/**"]),
|
||||
|
||||
js.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
|
||||
// App code runs in the browser.
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser },
|
||||
},
|
||||
},
|
||||
|
||||
// Config files and tests run in Node. Tests keep the browser globals too,
|
||||
// since the localStorage stub is installed onto globalThis.
|
||||
{
|
||||
files: ["*.config.js", "*.config.mjs", "test/**/*.js"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
+5
-3
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+18
-12
@@ -2,22 +2,28 @@
|
||||
"name": "loldle",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"tailwindcss": "^4.3.3"
|
||||
"eslint-plugin-svelte": "^3.22.0",
|
||||
"globals": "^17.7.0",
|
||||
"svelte": "^5.56.7",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
Generated
+758
-2769
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,3 @@
|
||||
allowBuilds:
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
overrides:
|
||||
'@babel/core': '^7.29.7'
|
||||
js-yaml: '^4.3.0'
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
File renamed without changes.
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,7 @@
|
||||
// Data layer: load, search, and select champions
|
||||
|
||||
import { asset } from "$app/paths";
|
||||
|
||||
let champions = [];
|
||||
let loadPromise = null;
|
||||
|
||||
@@ -9,8 +11,7 @@ export async function loadChampions() {
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = (async () => {
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
|
||||
const response = await fetch(`${basePath}/champions.json`);
|
||||
const response = await fetch(asset("/champions.json"));
|
||||
if (!response.ok) {
|
||||
loadPromise = null;
|
||||
throw new Error(`Failed to load champions: ${response.status}`);
|
||||
@@ -22,17 +23,6 @@ export async function loadChampions() {
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/** Get all loaded champions */
|
||||
export function getAllChampions() {
|
||||
return champions;
|
||||
}
|
||||
|
||||
/** Case-insensitive lookup by name */
|
||||
export function getChampionByName(name) {
|
||||
const lower = name.toLowerCase();
|
||||
return champions.find((c) => c.name.toLowerCase() === lower) || null;
|
||||
}
|
||||
|
||||
/** Filter champions for autocomplete (prefix-first, then substring) */
|
||||
export function searchChampions(query, excludeNames = []) {
|
||||
if (!query) return [];
|
||||
File renamed without changes.
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
import { getChampionImageUrl, searchChampions } from "$lib/champion-data";
|
||||
|
||||
/** Autocomplete search input for champion selection */
|
||||
let { excludeNames, onSelect, disabled } = $props();
|
||||
|
||||
let query = $state("");
|
||||
let activeIndex = $state(-1);
|
||||
let isOpen = $state(false);
|
||||
// $state because the dropdown is conditionally mounted, so bind:this
|
||||
// reassigns these after the initial render.
|
||||
let inputEl = $state();
|
||||
let dropdownEl = $state();
|
||||
|
||||
let matches = $derived.by(() =>
|
||||
query.trim() ? searchChampions(query, excludeNames) : [],
|
||||
);
|
||||
|
||||
// Derived, never assigned from an effect — writing state that the same effect
|
||||
// reads is the classic runes loop.
|
||||
let isVisible = $derived(isOpen && matches.length > 0);
|
||||
|
||||
// Close dropdown on outside click. The element refs are read inside the
|
||||
// handler, not during setup, so this registers once like the React effect did.
|
||||
$effect(() => {
|
||||
function handleClick(e) {
|
||||
if (
|
||||
inputEl &&
|
||||
!inputEl.contains(e.target) &&
|
||||
dropdownEl &&
|
||||
!dropdownEl.contains(e.target)
|
||||
) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
});
|
||||
|
||||
function selectChampion(champion) {
|
||||
query = "";
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
onSelect(champion);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!isVisible || !matches.length) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.min(activeIndex + 1, matches.length - 1);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
activeIndex = Math.max(activeIndex - 1, 0);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && activeIndex < matches.length) {
|
||||
selectChampion(matches[activeIndex]);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full max-w-[400px] mx-auto px-4">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={isVisible}
|
||||
aria-controls="champion-listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeIndex >= 0
|
||||
? `champion-option-${activeIndex}`
|
||||
: undefined}
|
||||
bind:value={query}
|
||||
oninput={() => {
|
||||
activeIndex = -1;
|
||||
isOpen = true;
|
||||
}}
|
||||
onkeydown={handleKeyDown}
|
||||
{disabled}
|
||||
placeholder={disabled ? "Game over" : "Type a champion name..."}
|
||||
autocomplete="off"
|
||||
class="w-full px-4 py-3 rounded-lg bg-[var(--color-input-bg)] border-2 border-[var(--color-input-border)] text-[var(--color-text)] text-base outline-none transition-colors focus:border-[var(--color-accent)] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
|
||||
{#if isVisible}
|
||||
<ul
|
||||
bind:this={dropdownEl}
|
||||
id="champion-listbox"
|
||||
role="listbox"
|
||||
class="absolute top-full left-4 right-4 list-none m-0 p-0 bg-[var(--color-surface)] border border-[var(--color-input-border)] rounded-b-lg max-h-80 overflow-y-auto z-50"
|
||||
>
|
||||
{#each matches as champion, i (champion.id)}
|
||||
<li id="champion-option-{i}" role="option" aria-selected={i === activeIndex}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectChampion(champion)}
|
||||
class="w-full text-left flex items-center gap-2.5 px-3 py-2 cursor-pointer transition-colors {i ===
|
||||
activeIndex
|
||||
? 'bg-[var(--color-surface-hover)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
<img
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width="36"
|
||||
height="36"
|
||||
class="rounded object-cover"
|
||||
/>
|
||||
<span class="text-sm">{champion.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
getRandomChampion,
|
||||
getTodaySeed,
|
||||
loadChampions,
|
||||
} from "$lib/champion-data";
|
||||
import { compareChampions } from "$lib/classic-mode";
|
||||
import {
|
||||
clearExpiredCache,
|
||||
clearUnlimitedState,
|
||||
createGame,
|
||||
getOrCreateUnlimitedSeed,
|
||||
loadUnlimitedStats,
|
||||
saveUnlimitedStats,
|
||||
submitGuess,
|
||||
} from "$lib/game-engine";
|
||||
import ChampionSearch from "./champion-search.svelte";
|
||||
import GameOver from "./game-over.svelte";
|
||||
import GuessGrid from "./guess-grid.svelte";
|
||||
import StatsDisplay from "./stats-display.svelte";
|
||||
|
||||
let mode = $state("daily");
|
||||
let game = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(null);
|
||||
let stats = $state(null);
|
||||
|
||||
let excludeNames = $derived(game ? game.guesses.map((g) => g.name) : []);
|
||||
|
||||
function initGame(gameMode) {
|
||||
const seed =
|
||||
gameMode === "daily" ? getTodaySeed() : getOrCreateUnlimitedSeed();
|
||||
const target = getRandomChampion(seed);
|
||||
const maxGuesses = gameMode === "daily" ? 6 : 0;
|
||||
|
||||
game = createGame({
|
||||
target,
|
||||
compareFn: compareChampions,
|
||||
maxGuesses,
|
||||
mode: gameMode,
|
||||
seed,
|
||||
});
|
||||
stats = gameMode === "unlimited" ? loadUnlimitedStats() : null;
|
||||
}
|
||||
|
||||
// Browser-only and once-only by construction: the prerender pass has no
|
||||
// localStorage and no champions.json to fetch.
|
||||
onMount(async () => {
|
||||
try {
|
||||
await loadChampions();
|
||||
clearExpiredCache(getTodaySeed());
|
||||
initGame("daily");
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
function switchMode(newMode) {
|
||||
mode = newMode;
|
||||
initGame(newMode);
|
||||
}
|
||||
|
||||
function handleGuess(champion) {
|
||||
if (!game) return;
|
||||
|
||||
const updated = submitGuess(game, champion);
|
||||
if (!updated) return;
|
||||
|
||||
// Always reassign — submitGuess returns new objects, so mutation would
|
||||
// silently lose reactivity.
|
||||
game = updated;
|
||||
|
||||
if (updated.isOver && updated.mode === "unlimited") {
|
||||
saveUnlimitedStats(updated);
|
||||
stats = loadUnlimitedStats();
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewGame() {
|
||||
clearUnlimitedState();
|
||||
initGame("unlimited");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-center text-[var(--color-text-muted)] py-8">
|
||||
Loading champions...
|
||||
</p>
|
||||
{:else if error}
|
||||
<p class="text-center text-[var(--color-wrong)] py-8">
|
||||
Failed to load champion data. Please refresh.
|
||||
</p>
|
||||
{:else if game}
|
||||
<!-- Mode toggle -->
|
||||
<div
|
||||
class="flex justify-center gap-1 my-3 bg-[var(--color-surface)] rounded-lg p-1 w-fit mx-auto"
|
||||
>
|
||||
<button
|
||||
onclick={() => switchMode("daily")}
|
||||
class="px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all {mode ===
|
||||
'daily'
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-text)]'
|
||||
: 'bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
onclick={() => switchMode("unlimited")}
|
||||
class="px-5 py-2 rounded-md border-none text-sm font-medium cursor-pointer transition-all {mode ===
|
||||
'unlimited'
|
||||
? 'bg-[var(--color-accent)] text-[var(--color-text)]'
|
||||
: 'bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface-hover)]'}"
|
||||
>
|
||||
Unlimited
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if stats}
|
||||
<StatsDisplay {stats} />
|
||||
{/if}
|
||||
|
||||
<ChampionSearch
|
||||
{excludeNames}
|
||||
onSelect={handleGuess}
|
||||
disabled={game.isOver}
|
||||
/>
|
||||
|
||||
<p class="text-center text-[var(--color-text-muted)] text-sm my-3">
|
||||
{game.maxGuesses > 0
|
||||
? `${game.guesses.length} / ${game.maxGuesses} guesses`
|
||||
: `${game.guesses.length} guess${game.guesses.length !== 1 ? "es" : ""}`}
|
||||
</p>
|
||||
|
||||
<GuessGrid guesses={game.guesses} results={game.results} />
|
||||
|
||||
{#if game.isOver}
|
||||
<GameOver
|
||||
isWon={game.isWon}
|
||||
target={game.target}
|
||||
guessCount={game.guesses.length}
|
||||
onNewGame={mode === "unlimited" ? handleNewGame : undefined}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script>
|
||||
import { getChampionImageUrl } from "$lib/champion-data";
|
||||
|
||||
/** Win/loss message with optional new game button */
|
||||
let { isWon, target, guessCount, onNewGame } = $props();
|
||||
</script>
|
||||
|
||||
<div class="text-center py-6 px-4 mx-auto max-w-[400px]">
|
||||
<h2
|
||||
class="text-2xl font-bold mb-2 {isWon
|
||||
? 'text-[var(--color-correct)]'
|
||||
: 'text-[var(--color-wrong)]'}"
|
||||
>
|
||||
{isWon ? "You got it!" : "Game Over"}
|
||||
</h2>
|
||||
|
||||
{#if isWon}
|
||||
<p class="text-[var(--color-text-muted)] mb-3">
|
||||
You found <strong class="text-[var(--color-text)]">{target.name}</strong> in
|
||||
<strong class="text-[var(--color-text)]">{guessCount}</strong>
|
||||
guess{guessCount > 1 ? "es" : ""}!
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-[var(--color-text-muted)] mb-3">
|
||||
The champion was <strong class="text-[var(--color-text)]"
|
||||
>{target.name}</strong
|
||||
>
|
||||
</p>
|
||||
<img
|
||||
src={getChampionImageUrl(target.id)}
|
||||
alt={target.name}
|
||||
width="80"
|
||||
height="80"
|
||||
class="rounded-lg object-cover mx-auto"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if onNewGame}
|
||||
<button
|
||||
onclick={onNewGame}
|
||||
class="mt-4 px-6 py-2.5 rounded-lg bg-[var(--color-accent)] text-[var(--color-text)] font-semibold cursor-pointer transition-opacity hover:opacity-85"
|
||||
>
|
||||
New Game
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { CLASSIC_ATTRIBUTES } from "$lib/classic-mode";
|
||||
import GuessRow from "./guess-row.svelte";
|
||||
|
||||
/** Grid with header row + guess rows */
|
||||
let { guesses, results } = $props();
|
||||
|
||||
// Newest guess first. .reverse() acts on the fresh array from .map(),
|
||||
// so `guesses` itself is never mutated.
|
||||
let rows = $derived(
|
||||
guesses.map((champion, i) => ({ champion, results: results[i] })).reverse(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="w-full max-w-[900px] px-4 overflow-x-auto">
|
||||
<!-- Header row -->
|
||||
<div class="guess-row header-row">
|
||||
<div class="guess-cell header-cell">Champion</div>
|
||||
{#each CLASSIC_ATTRIBUTES as attr (attr.key)}
|
||||
<div class="guess-cell header-cell">{attr.label}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Guess rows — newest first -->
|
||||
{#each rows as row (row.champion.id)}
|
||||
<GuessRow champion={row.champion} results={row.results} />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script>
|
||||
import { getChampionImageUrl } from "$lib/champion-data";
|
||||
|
||||
/** Single guess row with colored attribute cells */
|
||||
let { champion, results } = $props();
|
||||
</script>
|
||||
|
||||
<div class="guess-row">
|
||||
<!-- Champion name + image -->
|
||||
<div class="guess-cell champion-cell" style="animation-delay: 0s">
|
||||
<img
|
||||
src={getChampionImageUrl(champion.id)}
|
||||
alt={champion.name}
|
||||
width="40"
|
||||
height="40"
|
||||
class="rounded object-cover shrink-0"
|
||||
/>
|
||||
<span class="text-xs font-semibold">{champion.name}</span>
|
||||
</div>
|
||||
|
||||
<!-- Attribute cells: the within-row reveal stagger -->
|
||||
{#each results as r, i (r.key)}
|
||||
<div
|
||||
class="guess-cell cell-{r.result}"
|
||||
style="animation-delay: {(i + 1) * 0.08}s"
|
||||
>
|
||||
<span>{r.guessValue}</span>
|
||||
{#if r.direction}
|
||||
<span class="font-bold text-base">
|
||||
{r.direction === "up" ? " ↑" : " ↓"}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
/** Unlimited mode stats display */
|
||||
let { stats } = $props();
|
||||
|
||||
let winRate = $derived(
|
||||
stats && stats.gamesPlayed
|
||||
? Math.round((stats.gamesWon / stats.gamesPlayed) * 100)
|
||||
: 0,
|
||||
);
|
||||
|
||||
let items = $derived([
|
||||
{ value: stats?.gamesPlayed, label: "Played" },
|
||||
{ value: `${winRate}%`, label: "Win Rate" },
|
||||
{ value: stats?.gamesWon, label: "Won" },
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if stats && stats.gamesPlayed > 0}
|
||||
<div class="flex justify-center gap-6 py-3 mb-4">
|
||||
{#each items as { value, label } (label)}
|
||||
<div class="flex flex-col items-center gap-0.5">
|
||||
<span class="text-xl font-bold text-[var(--color-text)]">{value}</span>
|
||||
<span class="text-[0.7rem] text-[var(--color-text-muted)] uppercase"
|
||||
>{label}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -68,11 +68,6 @@ export function submitGuess(game, champion) {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Get names of already-guessed champions */
|
||||
export function getGuessedNames(game) {
|
||||
return game.guesses.map((g) => g.name);
|
||||
}
|
||||
|
||||
/** Get or create a persistent seed for unlimited mode */
|
||||
export function getOrCreateUnlimitedSeed() {
|
||||
try {
|
||||
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>LoLdle</title>
|
||||
<meta name="description" content="Guess the League of Legends champion" />
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import GameBoard from "$lib/components/game-board.svelte";
|
||||
</script>
|
||||
|
||||
<main class="flex flex-col items-center min-h-screen">
|
||||
<header class="text-center pt-6 pb-2 w-full max-w-[900px]">
|
||||
<h1 class="text-3xl font-bold tracking-wider">
|
||||
Lo<span class="text-[var(--color-accent)]">L</span>dle
|
||||
</h1>
|
||||
</header>
|
||||
<GameBoard />
|
||||
</main>
|
||||
Whitespace-only changes.
File renamed without changes.
@@ -0,0 +1,15 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// No options: output goes to build/. No fallback either — every route is
|
||||
// prerendered, so a fallback page would mask a real prerender failure.
|
||||
adapter: adapter(),
|
||||
paths: {
|
||||
base: process.env.BASE_PATH || "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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.
|
||||
*/
|
||||
async function freshModule({ champions = CHAMPIONS, ok = true, status = 200 } = {}) {
|
||||
vi.resetModules();
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok,
|
||||
status,
|
||||
json: async () => champions,
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const mod = await import("$lib/champion-data");
|
||||
return { mod, fetchMock };
|
||||
}
|
||||
|
||||
/** Load a module instance with its champion list already populated. */
|
||||
async function loadedModule(champions = CHAMPIONS) {
|
||||
const { mod, fetchMock } = await freshModule({ champions });
|
||||
await mod.loadChampions();
|
||||
return { mod, fetchMock };
|
||||
}
|
||||
|
||||
function named(names) {
|
||||
return names.map((name) => ({ ...AATROX, id: name, name }));
|
||||
}
|
||||
|
||||
describe("loadChampions", () => {
|
||||
it("fetches the static champions file and returns the parsed list", async () => {
|
||||
const { mod, fetchMock } = await freshModule();
|
||||
await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/champions.json");
|
||||
});
|
||||
|
||||
it("serves later calls from the cache without refetching", async () => {
|
||||
const { mod, fetchMock } = await loadedModule();
|
||||
await mod.loadChampions();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is single-flight — concurrent callers share one fetch", async () => {
|
||||
const { mod, fetchMock } = await freshModule();
|
||||
const [first, second] = await Promise.all([
|
||||
mod.loadChampions(),
|
||||
mod.loadChampions(),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("rejects with the status code when the response is not ok", async () => {
|
||||
const { mod } = await freshModule({ ok: false, status: 404 });
|
||||
await expect(mod.loadChampions()).rejects.toThrow(
|
||||
"Failed to load champions: 404",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the in-flight promise on failure so a retry can succeed", async () => {
|
||||
const { mod, fetchMock } = await freshModule({ ok: false, status: 500 });
|
||||
await expect(mod.loadChampions()).rejects.toThrow();
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => CHAMPIONS,
|
||||
});
|
||||
|
||||
await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchChampions", () => {
|
||||
it("returns nothing for an empty query", async () => {
|
||||
const { mod } = await loadedModule();
|
||||
expect(mod.searchChampions("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches case-insensitively on a substring", async () => {
|
||||
const { mod } = await loadedModule();
|
||||
expect(mod.searchChampions("AATR").map((c) => c.name)).toEqual(["Aatrox"]);
|
||||
});
|
||||
|
||||
it("sorts prefix matches ahead of substring matches", async () => {
|
||||
const { mod } = await loadedModule(named(["Akali", "Kayle", "Kayn"]));
|
||||
// "Kayle"/"Kayn" start with "ka"; "Akali" only contains it
|
||||
expect(mod.searchChampions("ka").map((c) => c.name)).toEqual([
|
||||
"Kayle",
|
||||
"Kayn",
|
||||
"Akali",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts alphabetically within each tier", async () => {
|
||||
const { mod } = await loadedModule(named(["Azir", "Ahri", "Aatrox"]));
|
||||
expect(mod.searchChampions("a").map((c) => c.name)).toEqual([
|
||||
"Aatrox",
|
||||
"Ahri",
|
||||
"Azir",
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the result at 8", async () => {
|
||||
const many = named([
|
||||
"Aatrox", "Ahri", "Akali", "Alistar", "Amumu",
|
||||
"Anivia", "Annie", "Aphelios", "Ashe", "Azir",
|
||||
]);
|
||||
const { mod } = await loadedModule(many);
|
||||
expect(mod.searchChampions("a")).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("filters excluded names case-insensitively", async () => {
|
||||
const { mod } = await loadedModule(named(["Kayle", "Kayn"]));
|
||||
expect(mod.searchChampions("ka", ["kAyN"]).map((c) => c.name)).toEqual([
|
||||
"Kayle",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing matches", async () => {
|
||||
const { mod } = await loadedModule();
|
||||
expect(mod.searchChampions("zzzz")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRandomChampion", () => {
|
||||
it("is deterministic for a given seed", async () => {
|
||||
const { mod } = await loadedModule();
|
||||
expect(mod.getRandomChampion("2026-07-25")).toBe(
|
||||
mod.getRandomChampion("2026-07-25"),
|
||||
);
|
||||
});
|
||||
|
||||
it("always returns a champion from the loaded list", async () => {
|
||||
const { mod } = await loadedModule();
|
||||
for (const seed of ["a", "b", "2026-07-25", "unlimited_1_0.5", ""]) {
|
||||
expect(CHAMPIONS).toContain(mod.getRandomChampion(seed));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null before champions are loaded", async () => {
|
||||
const { mod } = await freshModule();
|
||||
expect(mod.getRandomChampion("2026-07-25")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodaySeed", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats as YYYY-MM-DD", () => {
|
||||
vi.setSystemTime(new Date("2026-07-25T09:00:00Z"));
|
||||
expect(getTodaySeed()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("reflects the local date, not the UTC date", () => {
|
||||
// 18:00 UTC is already the next day in UTC+7 and still the same day in UTC,
|
||||
// so this instant fails any implementation that reaches for UTC getters.
|
||||
vi.setSystemTime(new Date("2026-07-25T18:00:00Z"));
|
||||
// en-CA renders as YYYY-MM-DD — an independent derivation of the same date.
|
||||
expect(getTodaySeed()).toBe(new Date().toLocaleDateString("en-CA"));
|
||||
});
|
||||
|
||||
it("zero-pads single-digit months and days", () => {
|
||||
vi.setSystemTime(new Date(2026, 0, 5, 12, 0, 0));
|
||||
expect(getTodaySeed()).toBe("2026-01-05");
|
||||
});
|
||||
|
||||
it("advances by one day across a local midnight", () => {
|
||||
vi.setSystemTime(new Date(2026, 6, 25, 23, 59, 0));
|
||||
const before = getTodaySeed();
|
||||
vi.setSystemTime(new Date(2026, 6, 26, 0, 1, 0));
|
||||
expect(getTodaySeed()).not.toBe(before);
|
||||
expect(getTodaySeed()).toBe("2026-07-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChampionImageUrl", () => {
|
||||
it("returns the absolute Data Dragon tile URL", () => {
|
||||
expect(getChampionImageUrl("Aatrox")).toBe(
|
||||
"https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/Aatrox_0.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays absolute — it must never be rewritten through a base path", () => {
|
||||
expect(getChampionImageUrl("Belveth")).toMatch(/^https:\/\//);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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. */
|
||||
function cell(guess, target, key) {
|
||||
return compareChampions(guess, target).find((r) => r.key === key);
|
||||
}
|
||||
|
||||
describe("CLASSIC_ATTRIBUTES", () => {
|
||||
it("exposes 7 attributes with gender first and releaseDate last", () => {
|
||||
expect(CLASSIC_ATTRIBUTES).toHaveLength(7);
|
||||
expect(CLASSIC_ATTRIBUTES[0].key).toBe("gender");
|
||||
expect(CLASSIC_ATTRIBUTES[6].key).toBe("releaseDate");
|
||||
});
|
||||
|
||||
it("keeps the display order the grid header depends on", () => {
|
||||
expect(CLASSIC_ATTRIBUTES.map((a) => a.key)).toEqual([
|
||||
"gender",
|
||||
"genre",
|
||||
"attackType",
|
||||
"resource",
|
||||
"region",
|
||||
"lane",
|
||||
"releaseDate",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChampions — result shape", () => {
|
||||
it("returns one entry per attribute, carrying label and type through", () => {
|
||||
const results = compareChampions(AHRI, AATROX);
|
||||
expect(results).toHaveLength(7);
|
||||
expect(results[0]).toMatchObject({
|
||||
key: "gender",
|
||||
label: "Gender",
|
||||
type: "exact",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChampions — exact", () => {
|
||||
it("marks an identical value correct", () => {
|
||||
expect(cell(AKALI, AHRI, "gender").result).toBe("correct");
|
||||
});
|
||||
|
||||
it("marks a differing value wrong", () => {
|
||||
expect(cell(AHRI, AATROX, "gender").result).toBe("wrong");
|
||||
});
|
||||
|
||||
it("compares case-insensitively", () => {
|
||||
const shouty = { ...AATROX, gender: "MALE" };
|
||||
expect(cell(shouty, AATROX, "gender").result).toBe("correct");
|
||||
});
|
||||
|
||||
it("treats a missing value as not matching a present one", () => {
|
||||
expect(cell(BELVETH, AATROX, "resource").result).toBe("wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChampions — multi", () => {
|
||||
it("marks identical sets correct", () => {
|
||||
expect(cell(BELVETH, AATROX, "genre").result).toBe("correct");
|
||||
});
|
||||
|
||||
it("marks intersecting sets partial", () => {
|
||||
// AKALI lane "top,mid" overlaps AATROX lane "top"
|
||||
expect(cell(AKALI, AATROX, "lane").result).toBe("partial");
|
||||
});
|
||||
|
||||
it("marks disjoint sets wrong", () => {
|
||||
expect(cell(AHRI, AATROX, "lane").result).toBe("wrong");
|
||||
});
|
||||
|
||||
it("marks two empty sets correct", () => {
|
||||
const a = { ...AATROX, lane: "" };
|
||||
const b = { ...AATROX, lane: "" };
|
||||
expect(cell(a, b, "lane").result).toBe("correct");
|
||||
});
|
||||
|
||||
it("marks one empty set against a populated one wrong", () => {
|
||||
const empty = { ...AATROX, lane: "" };
|
||||
expect(cell(empty, AATROX, "lane").result).toBe("wrong");
|
||||
});
|
||||
|
||||
it("ignores order and whitespace when comparing sets", () => {
|
||||
const spaced = { ...AKALI, lane: " mid , top " };
|
||||
expect(cell(spaced, AKALI, "lane").result).toBe("correct");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChampions — year", () => {
|
||||
it("marks the same year correct with no direction hint", () => {
|
||||
const twin = { ...AHRI, releaseDate: 2013 };
|
||||
const result = cell(twin, AATROX, "releaseDate");
|
||||
expect(result.result).toBe("correct");
|
||||
expect(result.direction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("points up when the guess is older than the target", () => {
|
||||
expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({
|
||||
result: "wrong",
|
||||
direction: "up",
|
||||
});
|
||||
});
|
||||
|
||||
it("points down when the guess is newer than the target", () => {
|
||||
expect(cell(BELVETH, AATROX, "releaseDate")).toMatchObject({
|
||||
result: "wrong",
|
||||
direction: "down",
|
||||
});
|
||||
});
|
||||
|
||||
it("gives no direction when either year is unparseable", () => {
|
||||
const undated = { ...AHRI, releaseDate: "" };
|
||||
const result = cell(undated, AATROX, "releaseDate");
|
||||
expect(result.result).toBe("wrong");
|
||||
expect(result.direction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders a missing year as ?", () => {
|
||||
const undated = { ...AHRI, releaseDate: "" };
|
||||
expect(cell(undated, AATROX, "releaseDate").guessValue).toBe("?");
|
||||
});
|
||||
|
||||
it("passes the year through unformatted", () => {
|
||||
expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({
|
||||
guessValue: 2011,
|
||||
targetValue: 2013,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareChampions — value formatting", () => {
|
||||
it("capitalizes gender", () => {
|
||||
expect(cell(AATROX, AATROX, "gender").guessValue).toBe("Male");
|
||||
});
|
||||
|
||||
it("renders attackType close as Melee and range as Ranged", () => {
|
||||
expect(cell(AATROX, AATROX, "attackType").guessValue).toBe("Melee");
|
||||
expect(cell(AHRI, AHRI, "attackType").guessValue).toBe("Ranged");
|
||||
});
|
||||
|
||||
it("splits hyphenated regions into capitalized words", () => {
|
||||
expect(cell(AKALI, AKALI, "region").guessValue).toBe("Shadow Isles");
|
||||
});
|
||||
|
||||
it("title-cases comma lists and rejoins them with a space", () => {
|
||||
expect(cell(AHRI, AHRI, "genre").guessValue).toBe("Mage, Assassin");
|
||||
expect(cell(AKALI, AKALI, "lane").guessValue).toBe("Top, Mid");
|
||||
});
|
||||
|
||||
it("renders an empty value as an em dash", () => {
|
||||
expect(cell(BELVETH, BELVETH, "resource").guessValue).toBe("—");
|
||||
});
|
||||
|
||||
it("formats the target value the same way as the guess value", () => {
|
||||
expect(cell(AHRI, AKALI, "region")).toMatchObject({
|
||||
guessValue: "Ionia",
|
||||
targetValue: "Shadow Isles",
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Hand-written champion fixtures, deliberately small so the weekly champions.json
|
||||
* sync can never break the suite. Shapes mirror the real data exactly:
|
||||
* `releaseDate` is a number, `attackType` is "close" | "range".
|
||||
*
|
||||
* Chosen to exercise every compareChampions branch against AATROX as target:
|
||||
* AHRI — disjoint genre, disjoint lane, older year (direction "up")
|
||||
* AKALI — disjoint genre, overlapping lane (partial), hyphenated region
|
||||
* BELVETH — identical genre (correct), disjoint lane, empty resource ("—"),
|
||||
* newer year (direction "down")
|
||||
*/
|
||||
|
||||
export const AATROX = {
|
||||
id: "Aatrox",
|
||||
name: "Aatrox",
|
||||
title: "the Darkin Blade",
|
||||
resource: "Blood Well",
|
||||
genre: "Fighter",
|
||||
gender: "male",
|
||||
attackType: "close",
|
||||
releaseDate: 2013,
|
||||
region: "runeterra",
|
||||
lane: "top",
|
||||
};
|
||||
|
||||
export const AHRI = {
|
||||
id: "Ahri",
|
||||
name: "Ahri",
|
||||
title: "the Nine-Tailed Fox",
|
||||
resource: "Mana",
|
||||
genre: "Mage,Assassin",
|
||||
gender: "female",
|
||||
attackType: "range",
|
||||
releaseDate: 2011,
|
||||
region: "ionia",
|
||||
lane: "mid",
|
||||
};
|
||||
|
||||
export const AKALI = {
|
||||
id: "Akali",
|
||||
name: "Akali",
|
||||
title: "the Rogue Assassin",
|
||||
resource: "Energy",
|
||||
genre: "Assassin",
|
||||
gender: "female",
|
||||
attackType: "close",
|
||||
releaseDate: 2010,
|
||||
region: "shadow-isles",
|
||||
lane: "top,mid",
|
||||
};
|
||||
|
||||
export const BELVETH = {
|
||||
id: "Belveth",
|
||||
name: "Bel'Veth",
|
||||
title: "the Empress of the Void",
|
||||
resource: "",
|
||||
genre: "Fighter",
|
||||
gender: "female",
|
||||
attackType: "close",
|
||||
releaseDate: 2022,
|
||||
region: "void",
|
||||
lane: "jungle",
|
||||
};
|
||||
|
||||
/** Load order matters for getRandomChampion index assertions. */
|
||||
export const CHAMPIONS = [AATROX, AHRI, AKALI, BELVETH];
|
||||
@@ -0,0 +1,357 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearExpiredCache,
|
||||
clearUnlimitedState,
|
||||
createGame,
|
||||
getOrCreateUnlimitedSeed,
|
||||
loadUnlimitedStats,
|
||||
saveUnlimitedStats,
|
||||
submitGuess,
|
||||
} from "$lib/game-engine";
|
||||
import { AATROX, AHRI } from "./fixtures/champions";
|
||||
|
||||
/** Minimal stand-in for compareChampions — the engine only stores what this returns. */
|
||||
const compareFn = (guess, target) => [
|
||||
{ key: "name", result: guess.name === target.name ? "correct" : "wrong" },
|
||||
];
|
||||
|
||||
/** Synthesize a distinct non-target champion. */
|
||||
function other(name) {
|
||||
return { ...AHRI, id: name, name };
|
||||
}
|
||||
|
||||
function daily(overrides = {}) {
|
||||
return createGame({
|
||||
target: AATROX,
|
||||
compareFn,
|
||||
maxGuesses: 6,
|
||||
mode: "daily",
|
||||
seed: "2026-07-25",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function unlimited(overrides = {}) {
|
||||
return createGame({
|
||||
target: AATROX,
|
||||
compareFn,
|
||||
maxGuesses: 0,
|
||||
mode: "unlimited",
|
||||
seed: "unlimited_123",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createGame", () => {
|
||||
it("starts empty and open", () => {
|
||||
const game = daily();
|
||||
expect(game.guesses).toEqual([]);
|
||||
expect(game.results).toEqual([]);
|
||||
expect(game.isOver).toBe(false);
|
||||
expect(game.isWon).toBe(false);
|
||||
});
|
||||
|
||||
it("carries config through onto the game object", () => {
|
||||
expect(daily()).toMatchObject({
|
||||
target: AATROX,
|
||||
maxGuesses: 6,
|
||||
mode: "daily",
|
||||
seed: "2026-07-25",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to 6 guesses in daily mode when maxGuesses is omitted", () => {
|
||||
const game = createGame({ target: AATROX, compareFn });
|
||||
expect(game.maxGuesses).toBe(6);
|
||||
expect(game.mode).toBe("daily");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitGuess", () => {
|
||||
it("appends the guess and its comparison result", () => {
|
||||
const game = submitGuess(daily(), AHRI);
|
||||
expect(game.guesses).toEqual([AHRI]);
|
||||
expect(game.results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not mutate the game it was given", () => {
|
||||
const before = daily();
|
||||
submitGuess(before, AHRI);
|
||||
expect(before.guesses).toHaveLength(0);
|
||||
expect(before.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns fresh array identities so reassignment is observable", () => {
|
||||
const before = daily();
|
||||
const after = submitGuess(before, AHRI);
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.guesses).not.toBe(before.guesses);
|
||||
expect(after.results).not.toBe(before.results);
|
||||
});
|
||||
|
||||
it("rejects a duplicate guess with null", () => {
|
||||
const game = submitGuess(daily(), AHRI);
|
||||
expect(submitGuess(game, AHRI)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects any guess once the game is over", () => {
|
||||
const game = submitGuess(daily(), AATROX);
|
||||
expect(game.isOver).toBe(true);
|
||||
expect(submitGuess(game, AHRI)).toBeNull();
|
||||
});
|
||||
|
||||
it("wins on the correct champion", () => {
|
||||
expect(submitGuess(daily(), AATROX)).toMatchObject({
|
||||
isWon: true,
|
||||
isOver: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still counts a win made with the final allowed guess", () => {
|
||||
let game = daily();
|
||||
for (const name of ["a", "b", "c", "d", "e"]) {
|
||||
game = submitGuess(game, other(name));
|
||||
}
|
||||
expect(game.isOver).toBe(false);
|
||||
|
||||
game = submitGuess(game, AATROX);
|
||||
expect(game).toMatchObject({ isWon: true, isOver: true });
|
||||
expect(game.guesses).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("ends the game as a loss at maxGuesses wrong guesses", () => {
|
||||
let game = daily();
|
||||
for (const name of ["a", "b", "c", "d", "e", "f"]) {
|
||||
game = submitGuess(game, other(name));
|
||||
}
|
||||
expect(game).toMatchObject({ isOver: true, isWon: false });
|
||||
expect(game.guesses).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("never ends on guess count when maxGuesses is 0", () => {
|
||||
let game = unlimited();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
game = submitGuess(game, other(`c${i}`));
|
||||
}
|
||||
expect(game.isOver).toBe(false);
|
||||
expect(game.guesses).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence — storage key contract", () => {
|
||||
it("writes daily state to exactly loldle_daily_<seed>", () => {
|
||||
submitGuess(daily(), AHRI);
|
||||
expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("writes unlimited state to exactly loldle_unlimited_current", () => {
|
||||
submitGuess(unlimited(), AHRI);
|
||||
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("ignores the seed when keying unlimited state", () => {
|
||||
submitGuess(unlimited({ seed: "unlimited_999" }), AHRI);
|
||||
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
|
||||
expect(localStorage.getItem("loldle_unlimited_unlimited_999")).toBeNull();
|
||||
});
|
||||
|
||||
it("stores the target name, guesses, results and flags", () => {
|
||||
submitGuess(daily(), AHRI);
|
||||
const saved = JSON.parse(localStorage.getItem("loldle_daily_2026-07-25"));
|
||||
expect(saved).toMatchObject({
|
||||
targetName: "Aatrox",
|
||||
guesses: [AHRI],
|
||||
isOver: false,
|
||||
isWon: false,
|
||||
});
|
||||
expect(saved.results).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persistence — restore on createGame", () => {
|
||||
it("restores an in-progress game for the same target", () => {
|
||||
submitGuess(daily(), AHRI);
|
||||
const restored = daily();
|
||||
expect(restored.guesses).toEqual([AHRI]);
|
||||
expect(restored.results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("restores the over and won flags", () => {
|
||||
submitGuess(daily(), AATROX);
|
||||
expect(daily()).toMatchObject({ isOver: true, isWon: true });
|
||||
});
|
||||
|
||||
it("discards saved state when the target changed — the date-rollover case", () => {
|
||||
submitGuess(daily(), AHRI);
|
||||
const fresh = createGame({
|
||||
target: { ...AHRI, name: "Ahri" },
|
||||
compareFn,
|
||||
maxGuesses: 6,
|
||||
mode: "daily",
|
||||
seed: "2026-07-25",
|
||||
});
|
||||
expect(fresh.guesses).toEqual([]);
|
||||
expect(fresh.isOver).toBe(false);
|
||||
});
|
||||
|
||||
it("starts fresh when storage holds malformed JSON", () => {
|
||||
localStorage.setItem("loldle_daily_2026-07-25", "{not json");
|
||||
expect(daily().guesses).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearExpiredCache", () => {
|
||||
it("removes yesterday's daily entry but keeps today's", () => {
|
||||
localStorage.setItem("loldle_daily_2026-07-24", "{}");
|
||||
localStorage.setItem("loldle_daily_2026-07-25", "{}");
|
||||
|
||||
clearExpiredCache("2026-07-25");
|
||||
|
||||
expect(localStorage.getItem("loldle_daily_2026-07-24")).toBeNull();
|
||||
expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("leaves unlimited keys untouched", () => {
|
||||
localStorage.setItem("loldle_unlimited_current", "{}");
|
||||
localStorage.setItem("loldle_unlimited_stats", "{}");
|
||||
localStorage.setItem("loldle_unlimited_seed", "s");
|
||||
|
||||
clearExpiredCache("2026-07-25");
|
||||
|
||||
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
|
||||
expect(localStorage.getItem("loldle_unlimited_stats")).not.toBeNull();
|
||||
expect(localStorage.getItem("loldle_unlimited_seed")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("removes every stale daily entry, not just the first", () => {
|
||||
localStorage.setItem("loldle_daily_2026-07-20", "{}");
|
||||
localStorage.setItem("loldle_daily_2026-07-21", "{}");
|
||||
localStorage.setItem("loldle_daily_2026-07-22", "{}");
|
||||
|
||||
clearExpiredCache("2026-07-25");
|
||||
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores unrelated keys", () => {
|
||||
localStorage.setItem("unrelated", "keep");
|
||||
clearExpiredCache("2026-07-25");
|
||||
expect(localStorage.getItem("unrelated")).toBe("keep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlimited seed", () => {
|
||||
it("creates and persists a seed on first call", () => {
|
||||
const seed = getOrCreateUnlimitedSeed();
|
||||
expect(seed).toMatch(/^unlimited_/);
|
||||
expect(localStorage.getItem("loldle_unlimited_seed")).toBe(seed);
|
||||
});
|
||||
|
||||
it("returns the same seed on subsequent calls", () => {
|
||||
expect(getOrCreateUnlimitedSeed()).toBe(getOrCreateUnlimitedSeed());
|
||||
});
|
||||
|
||||
it("clearUnlimitedState drops both the seed and the current game", () => {
|
||||
submitGuess(unlimited(), AHRI);
|
||||
getOrCreateUnlimitedSeed();
|
||||
|
||||
clearUnlimitedState();
|
||||
|
||||
expect(localStorage.getItem("loldle_unlimited_current")).toBeNull();
|
||||
expect(localStorage.getItem("loldle_unlimited_seed")).toBeNull();
|
||||
});
|
||||
|
||||
it("clearUnlimitedState preserves accumulated stats", () => {
|
||||
localStorage.setItem("loldle_unlimited_stats", '{"gamesPlayed":3}');
|
||||
clearUnlimitedState();
|
||||
expect(loadUnlimitedStats().gamesPlayed).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveUnlimitedStats", () => {
|
||||
/**
|
||||
* Play an unlimited game to completion; `won` picks the ending.
|
||||
* Clears saved state first, mirroring handleNewGame — otherwise createGame
|
||||
* restores the previous finished game and submitGuess refuses to run.
|
||||
*/
|
||||
function finish(won, wrongGuesses = 0) {
|
||||
clearUnlimitedState();
|
||||
let game = unlimited();
|
||||
for (let i = 0; i < wrongGuesses; i++) {
|
||||
game = submitGuess(game, other(`c${i}`));
|
||||
}
|
||||
if (won) game = submitGuess(game, AATROX);
|
||||
else game = { ...game, isOver: true };
|
||||
return game;
|
||||
}
|
||||
|
||||
it("counts a played game", () => {
|
||||
saveUnlimitedStats(finish(false));
|
||||
expect(loadUnlimitedStats().gamesPlayed).toBe(1);
|
||||
});
|
||||
|
||||
it("counts a win and records its guess count", () => {
|
||||
saveUnlimitedStats(finish(true, 2));
|
||||
expect(loadUnlimitedStats()).toMatchObject({
|
||||
gamesPlayed: 1,
|
||||
gamesWon: 1,
|
||||
guessDistribution: { 3: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count a win when the game was lost", () => {
|
||||
saveUnlimitedStats(finish(false));
|
||||
expect(loadUnlimitedStats()).toMatchObject({
|
||||
gamesPlayed: 1,
|
||||
gamesWon: 0,
|
||||
guessDistribution: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("accumulates across games", () => {
|
||||
saveUnlimitedStats(finish(true, 1));
|
||||
saveUnlimitedStats(finish(true, 1));
|
||||
saveUnlimitedStats(finish(false));
|
||||
|
||||
expect(loadUnlimitedStats()).toMatchObject({
|
||||
gamesPlayed: 3,
|
||||
gamesWon: 2,
|
||||
guessDistribution: { 2: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps lastPlayed", () => {
|
||||
saveUnlimitedStats(finish(true, 0));
|
||||
expect(typeof loadUnlimitedStats().lastPlayed).toBe("number");
|
||||
});
|
||||
|
||||
it("is a no-op for daily mode", () => {
|
||||
const game = submitGuess(daily(), AATROX);
|
||||
saveUnlimitedStats(game);
|
||||
expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op for an unfinished game", () => {
|
||||
saveUnlimitedStats(submitGuess(unlimited(), AHRI));
|
||||
expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadUnlimitedStats", () => {
|
||||
it("returns zeroed stats when nothing is stored", () => {
|
||||
expect(loadUnlimitedStats()).toEqual({
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
guessDistribution: {},
|
||||
lastPlayed: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns zeroed stats when storage holds malformed JSON", () => {
|
||||
localStorage.setItem("loldle_unlimited_stats", "not json at all");
|
||||
expect(loadUnlimitedStats()).toMatchObject({
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Minimal spec-shaped localStorage stub.
|
||||
* `key(i)` and `length` are required: clearExpiredCache iterates by index.
|
||||
*/
|
||||
class LocalStorageStub {
|
||||
#store = new Map();
|
||||
|
||||
get length() {
|
||||
return this.#store.size;
|
||||
}
|
||||
|
||||
key(i) {
|
||||
return [...this.#store.keys()][i] ?? null;
|
||||
}
|
||||
|
||||
getItem(k) {
|
||||
return this.#store.has(k) ? this.#store.get(k) : null;
|
||||
}
|
||||
|
||||
setItem(k, v) {
|
||||
this.#store.set(k, String(v));
|
||||
}
|
||||
|
||||
removeItem(k) {
|
||||
this.#store.delete(k);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", new LocalStorageStub());
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
// tailwindcss() must precede sveltekit(), per the Tailwind v4 SvelteKit guide.
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
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"],
|
||||
include: ["test/**/*.test.js"],
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user