mirror of
https://github.com/tiennm99/loldle.git
synced 2026-08-30 14:22:33 +00:00
chore: remove Next.js and repoint CI at the SvelteKit layout
sync-data.yml now writes static/champions.json. This is the edit that had to land with the deletion of public/: the workflow reports success regardless of which path it writes, so a miss would have left the weekly sync green while the live game 404'd on data load. deploy.yml publishes build/ and passes BASE_PATH. Adds ci.yml, which runs test, lint, and build on pull_request and pushes to main -- the repo had no PR-triggered workflow before, so 'green before merge' was previously unenforceable. Deletes app/, components/, lib/, public/, next.config.mjs and postcss.config.mjs, drops the Next and React dependencies, and swaps ESLint onto eslint-plugin-svelte. sharp and unrs-resolver left pnpm's allowBuilds because both arrived with Next and pnpm why now reports no dependents.
This commit is contained in:
Vendored
+36
@@ -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
|
||||
Vendored
+2
-2
@@ -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
|
||||
|
||||
Vendored
+4
-4
@@ -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"
|
||||
|
||||
@@ -18,10 +18,6 @@ chrome-dev-tools
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# sveltekit
|
||||
/.svelte-kit/
|
||||
|
||||
|
||||
@@ -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,122 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--color-bg: #1a1a2e;
|
||||
--color-surface: #16213e;
|
||||
--color-surface-hover: #1a2745;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-muted: #8a8a9a;
|
||||
--color-correct: #4ecb71;
|
||||
--color-partial: #f4b35e;
|
||||
--color-wrong: #d85b5b;
|
||||
--color-header: #2a2a4a;
|
||||
--color-input-bg: #0f3460;
|
||||
--color-input-border: #533483;
|
||||
--color-accent: #533483;
|
||||
--cell-size: 90px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Guess grid layout */
|
||||
.guess-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px repeat(7, var(--cell-size));
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.guess-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 8px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
min-height: 56px;
|
||||
word-break: break-word;
|
||||
animation: cellReveal 0.3s ease forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
background: var(--color-header);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.champion-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
padding-left: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.cell-correct {
|
||||
background: var(--color-correct);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-partial {
|
||||
background: var(--color-partial);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.cell-wrong {
|
||||
background: var(--color-wrong);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes cellReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--cell-size: 72px;
|
||||
}
|
||||
.guess-row {
|
||||
grid-template-columns: 100px repeat(7, var(--cell-size));
|
||||
}
|
||||
.guess-cell {
|
||||
font-size: 0.7rem;
|
||||
min-height: 48px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root {
|
||||
--cell-size: 60px;
|
||||
}
|
||||
.guess-row {
|
||||
grid-template-columns: 80px repeat(7, var(--cell-size));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-5
@@ -4,10 +4,6 @@
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"$app/types": ["./.svelte-kit/types/index.d.ts"],
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
// Data layer: load, search, and select champions
|
||||
|
||||
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 basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
|
||||
const response = await fetch(`${basePath}/champions.json`);
|
||||
if (!response.ok) {
|
||||
loadPromise = null;
|
||||
throw new Error(`Failed to load champions: ${response.status}`);
|
||||
}
|
||||
champions = await response.json();
|
||||
return champions;
|
||||
})();
|
||||
|
||||
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 [];
|
||||
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`;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// 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 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 {
|
||||
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,10 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
basePath: process.env.NEXT_PUBLIC_BASE_PATH || "",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+5
-11
@@ -8,25 +8,19 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"next:dev": "next dev",
|
||||
"next:build": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.10",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.70.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"eslint-plugin-svelte": "^3.22.0",
|
||||
"globals": "^17.7.0",
|
||||
"svelte": "^5.56.7",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.1.5",
|
||||
|
||||
Generated
+148
-2956
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 diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user