Compare commits
No commits in common. "main" and "feat/multiplayer-mode" have entirely different histories.
main
...
feat/multi
170 changed files with 5600434 additions and 13141 deletions
|
|
@ -1,5 +1,4 @@
|
||||||
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
|
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
|
||||||
DATABASE_URL_LOCAL=postgres://postgres:mypassword@localhost:5432/databasename
|
|
||||||
|
|
||||||
POSTGRES_USER=postgres
|
POSTGRES_USER=postgres
|
||||||
POSTGRES_PASSWORD=postgres
|
POSTGRES_PASSWORD=postgres
|
||||||
|
|
@ -11,11 +10,3 @@ GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
GITHUB_CLIENT_SECRET=
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
||||||
VITE_WS_URL=
|
|
||||||
|
|
||||||
UID=1000
|
|
||||||
GID=1000
|
|
||||||
|
|
||||||
RESEND_API_KEY=
|
|
||||||
EMAIL_FROM=mail@example.com
|
|
||||||
|
|
|
||||||
|
|
@ -5,35 +5,9 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
quality:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Install tools
|
|
||||||
run: apt-get update && apt-get install -y nodejs
|
|
||||||
- name: Checkout code
|
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
|
||||||
- name: Install pnpm
|
|
||||||
run: npm install -g pnpm
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
- name: Build shared packages
|
|
||||||
run: pnpm --filter @lila/shared build && pnpm --filter @lila/db build
|
|
||||||
- name: Format check
|
|
||||||
run: pnpm format:check
|
|
||||||
- name: Lint
|
|
||||||
run: pnpm lint
|
|
||||||
- name: Type-check
|
|
||||||
run: pnpm typecheck
|
|
||||||
- name: Test
|
|
||||||
run: pnpm test:run
|
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
needs: quality
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install tools
|
|
||||||
run: apt-get update && apt-get install -y docker.io openssh-client
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -9,9 +9,3 @@ repomix/
|
||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
data-pipeline/archive/
|
|
||||||
data-pipeline/stage-1-extract/output/
|
|
||||||
data-pipeline/stage-2-annotate/output/
|
|
||||||
data-pipeline/stage-3-enrich/output/
|
|
||||||
data-pipeline/stage-4-merge/output/
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pnpm lint-staged
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
pnpm test:run
|
|
||||||
|
|
@ -18,5 +18,3 @@ coverage/
|
||||||
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
routeTree.gen.ts
|
routeTree.gen.ts
|
||||||
|
|
||||||
.pnpm-store/
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
lilastudy.com {
|
lilastudy.com {
|
||||||
header {
|
|
||||||
X-Content-Type-Options "nosniff"
|
|
||||||
X-Frame-Options "DENY"
|
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
|
||||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
||||||
Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.lilastudy.com wss://api.lilastudy.com; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
|
|
||||||
}
|
|
||||||
reverse_proxy web:80
|
reverse_proxy web:80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
169
README.md
169
README.md
|
|
@ -1,170 +1 @@
|
||||||
# lila
|
# lila
|
||||||
|
|
||||||
**Learn words. Beat friends.**
|
|
||||||
|
|
||||||
lila is a vocabulary trainer built around a Duolingo-style quiz loop: a word appears in one language, you pick the correct translation from four choices. It supports singleplayer and real-time multiplayer, and is designed to work across multiple language pairs without schema changes.
|
|
||||||
|
|
||||||
Live at [lilastudy.com](https://lilastudy.com).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Technology |
|
|
||||||
| ------------ | ---------------------------------- |
|
|
||||||
| Monorepo | pnpm workspaces |
|
|
||||||
| Frontend | React 18, Vite, TypeScript |
|
|
||||||
| Routing | TanStack Router |
|
|
||||||
| Server state | TanStack Query |
|
|
||||||
| Styling | Tailwind CSS |
|
|
||||||
| Backend | Node.js, Express, TypeScript |
|
|
||||||
| Database | PostgreSQL + Drizzle ORM |
|
|
||||||
| Validation | Zod (shared schemas) |
|
|
||||||
| Auth | Better Auth (Google + GitHub) |
|
|
||||||
| Realtime | WebSockets (`ws` library) |
|
|
||||||
| Testing | Vitest, supertest |
|
|
||||||
| Deployment | Docker Compose, Caddy, Hetzner VPS |
|
|
||||||
| CI/CD | Forgejo Actions |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Structure
|
|
||||||
|
|
||||||
```tree
|
|
||||||
lila/
|
|
||||||
├── apps/
|
|
||||||
│ ├── api/ — Express backend
|
|
||||||
│ └── web/ — React frontend
|
|
||||||
├── packages/
|
|
||||||
│ ├── shared/ — Zod schemas and types shared between frontend and backend
|
|
||||||
│ └── db/ — Drizzle schema, migrations, models, seeding scripts
|
|
||||||
├── scripts/ — Python scripts for vocabulary data extraction
|
|
||||||
└── documentation/ — Project docs
|
|
||||||
```
|
|
||||||
|
|
||||||
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas and never duplicated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Requests flow through a strict layered architecture:
|
|
||||||
|
|
||||||
```text
|
|
||||||
HTTP Request → Router → Controller → Service → Model → Database
|
|
||||||
```
|
|
||||||
|
|
||||||
Each layer only talks to the layer directly below it. Controllers handle HTTP only. Services contain business logic only. Models contain database queries only. All database code lives in `packages/db` — the API never imports Drizzle directly for queries.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
Words are modelled as language-neutral concepts (`terms`) with per-language `translations`. Adding a new language requires no schema changes — only new rows. CEFR levels (A1–C2) are stored per translation for difficulty filtering.
|
|
||||||
|
|
||||||
Core tables: `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`
|
|
||||||
Auth tables (managed by Better Auth): `user`, `session`, `account`, `verification`
|
|
||||||
|
|
||||||
Vocabulary data is sourced from WordNet and the Open Multilingual Wordnet (OMW).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/v1/game/start — start a quiz session (auth required)
|
|
||||||
POST /api/v1/game/answer — submit an answer (auth required)
|
|
||||||
GET /api/v1/health — health check (public)
|
|
||||||
ALL /api/auth/* — Better Auth handlers (public)
|
|
||||||
```
|
|
||||||
|
|
||||||
The correct answer is never sent to the frontend — all evaluation happens server-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Multiplayer
|
|
||||||
|
|
||||||
Rooms are created via REST, then managed over WebSockets. Messages are typed via a Zod discriminated union. The host starts the game; all players answer simultaneously with a 15-second server-enforced timer. Room state is held in-memory (Valkey deferred).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Infrastructure
|
|
||||||
|
|
||||||
```tree
|
|
||||||
Internet → Caddy (HTTPS)
|
|
||||||
├── lilastudy.com → web (nginx, static files)
|
|
||||||
├── api.lilastudy.com → api (Express)
|
|
||||||
└── git.lilastudy.com → Forgejo (git + registry)
|
|
||||||
```
|
|
||||||
|
|
||||||
Deployed on a Hetzner VPS (Debian 13, ARM64). Images are built cross-compiled for ARM64 and pushed to the Forgejo container registry. CI/CD runs via Forgejo Actions on push to `main`. Daily database backups are synced to the dev laptop via rsync.
|
|
||||||
|
|
||||||
See `documentation/deployment.md` for the full infrastructure setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- pnpm 9+
|
|
||||||
- Docker + Docker Compose
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Create your local env file (used by docker compose + the API)
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Start local services (PostgreSQL, Valkey)
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Build shared packages
|
|
||||||
pnpm --filter @lila/shared build
|
|
||||||
pnpm --filter @lila/db build
|
|
||||||
|
|
||||||
# Run migrations and seed data
|
|
||||||
pnpm --filter @lila/db migrate
|
|
||||||
pnpm --filter @lila/db seed
|
|
||||||
|
|
||||||
# Start dev servers
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The API runs on `http://localhost:3000` and the frontend on `http://localhost:5173`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All tests
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# API only
|
|
||||||
pnpm --filter api test
|
|
||||||
|
|
||||||
# Frontend only
|
|
||||||
pnpm --filter web test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
| Phase | Description | Status |
|
|
||||||
| ----- | ---------------------------------------------------------------------- | ------ |
|
|
||||||
| 0 | Foundation — monorepo, tooling, dev environment | ✅ |
|
|
||||||
| 1 | Vocabulary data pipeline + REST API | ✅ |
|
|
||||||
| 2 | Singleplayer quiz UI | ✅ |
|
|
||||||
| 3 | Auth (Google + GitHub) | ✅ |
|
|
||||||
| 4 | Multiplayer lobby (WebSockets) | ✅ |
|
|
||||||
| 5 | Multiplayer game (real-time, server timer) | ✅ |
|
|
||||||
| 6 | Production deployment + CI/CD | ✅ |
|
|
||||||
| 7 | Hardening (rate limiting, error boundaries, monitoring, accessibility) | 🔄 |
|
|
||||||
|
|
||||||
See `documentation/roadmap.md` for task-level detail.
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,9 @@ CMD ["pnpm", "--filter", "api", "dev"]
|
||||||
# 4. build
|
# 4. build
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY apps/api/package.json ./apps/api/
|
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
|
||||||
COPY packages/db/package.json ./packages/db/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pnpm install
|
||||||
RUN pnpm --filter shared build
|
RUN pnpm --filter shared build
|
||||||
RUN pnpm --filter db build
|
RUN pnpm --filter db build
|
||||||
RUN pnpm --filter api build
|
RUN pnpm --filter api build
|
||||||
|
|
@ -42,7 +39,6 @@ COPY packages/db/package.json ./packages/db/
|
||||||
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
COPY --from=builder /app/apps/api/dist ./apps/api/dist
|
||||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
|
||||||
COPY --from=builder /app/packages/db/dist ./packages/db/dist
|
COPY --from=builder /app/packages/db/dist ./packages/db/dist
|
||||||
COPY --from=builder /app/packages/db/drizzle ./packages/db/drizzle
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
RUN pnpm install --frozen-lockfile --prod
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["sh", "-c", "node packages/db/dist/src/migrate.js && node apps/api/dist/src/server.js"]
|
CMD ["node", "apps/api/dist/src/server.js"]
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@
|
||||||
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
|
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/src/server.js",
|
"start": "node dist/src/server.js",
|
||||||
"test": "vitest",
|
"test": "vitest"
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lila/db": "workspace:*",
|
"@lila/db": "workspace:*",
|
||||||
|
|
@ -16,9 +15,6 @@
|
||||||
"better-auth": "^1.6.2",
|
"better-auth": "^1.6.2",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.4.0",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"resend": "^6.12.2",
|
|
||||||
"ws": "^8.20.0"
|
"ws": "^8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import request from "supertest";
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { createApp } from "./app.js";
|
|
||||||
|
|
||||||
const app = createApp();
|
|
||||||
|
|
||||||
describe("security headers (helmet)", () => {
|
|
||||||
it("sets X-Content-Type-Options to nosniff", async () => {
|
|
||||||
const res = await request(app).get("/api/v1/health");
|
|
||||||
expect(res.headers["x-content-type-options"]).toBe("nosniff");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets X-Frame-Options to SAMEORIGIN", async () => {
|
|
||||||
const res = await request(app).get("/api/v1/health");
|
|
||||||
expect(res.headers["x-frame-options"]).toBe("SAMEORIGIN");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes X-Powered-By header", async () => {
|
|
||||||
const res = await request(app).get("/api/v1/health");
|
|
||||||
expect(res.headers).not.toHaveProperty("x-powered-by");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets Content-Security-Policy", async () => {
|
|
||||||
const res = await request(app).get("/api/v1/health");
|
|
||||||
expect(res.headers).toHaveProperty("content-security-policy");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth rate limiting", () => {
|
|
||||||
it("returns 429 after exceeding the auth limit", async () => {
|
|
||||||
const testApp = createApp();
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(testApp).post("/api/auth/sign-in");
|
|
||||||
}
|
|
||||||
const res = await request(testApp).post("/api/auth/sign-in");
|
|
||||||
expect(res.status).toBe(429);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,33 +1,23 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Express } from "express";
|
import type { Express } from "express";
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
import cors from "cors";
|
|
||||||
import helmet from "helmet";
|
|
||||||
import { auth } from "./lib/auth.js";
|
import { auth } from "./lib/auth.js";
|
||||||
import { createApiRouter } from "./routes/apiRouter.js";
|
import { apiRouter } from "./routes/apiRouter.js";
|
||||||
import { InMemoryGameSessionStore } from "./gameSessionStore/index.js";
|
|
||||||
import { errorHandler } from "./middleware/errorHandler.js";
|
import { errorHandler } from "./middleware/errorHandler.js";
|
||||||
import { authLimiter } from "./middleware/rateLimiters.js";
|
import cors from "cors";
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
app.use(helmet());
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env["CORS_ORIGIN"] || "http://localhost:5173",
|
origin: process.env["CORS_ORIGIN"] || "http://localhost:5173",
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("/api/auth", authLimiter);
|
|
||||||
app.all("/api/auth/*splat", toNodeHandler(auth));
|
app.all("/api/auth/*splat", toNodeHandler(auth));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use("/api/v1", apiRouter);
|
||||||
const store = new InMemoryGameSessionStore();
|
|
||||||
app.use("/api/v1", createApiRouter(store));
|
|
||||||
|
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const validBody = {
|
||||||
target_language: "it",
|
target_language: "it",
|
||||||
pos: "noun",
|
pos: "noun",
|
||||||
difficulty: "easy",
|
difficulty: "easy",
|
||||||
rounds: 3,
|
rounds: "3",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
|
|
@ -110,26 +110,6 @@ describe("POST /api/v1/game/start", () => {
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 422 when no terms are found for the given filters", async () => {
|
|
||||||
mockGetGameTerms.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const res = await request(app).post("/api/v1/game/start").send(validBody);
|
|
||||||
const body = res.body as ErrorResponse;
|
|
||||||
expect(res.status).toBe(422);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a sanitised error message when the body is invalid", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send({ ...validBody, difficulty: "impossible" });
|
|
||||||
const body = res.body as ErrorResponse;
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.error).toBe("Invalid game settings");
|
|
||||||
expect(body.error).not.toContain("Invalid literal value");
|
|
||||||
expect(body.error).not.toContain("Invalid enum value");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/v1/game/answer", () => {
|
describe("POST /api/v1/game/answer", () => {
|
||||||
|
|
@ -178,7 +158,7 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
expect(body.error).toContain("Game session not found");
|
expect(body.error).toContain("Game session not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 409 when the question does not exist in the session", async () => {
|
it("returns 404 when the question does not exist in the session", async () => {
|
||||||
const startRes = await request(app)
|
const startRes = await request(app)
|
||||||
.post("/api/v1/game/start")
|
.post("/api/v1/game/start")
|
||||||
.send(validBody);
|
.send(validBody);
|
||||||
|
|
@ -193,26 +173,8 @@ describe("POST /api/v1/game/answer", () => {
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
});
|
});
|
||||||
const body = res.body as ErrorResponse;
|
const body = res.body as ErrorResponse;
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(404);
|
||||||
expect(body.success).toBe(false);
|
|
||||||
expect(body.error).toContain("Question already answered");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when a field has an invalid value", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send({ ...validBody, difficulty: "impossible" });
|
|
||||||
const body = res.body as ErrorResponse;
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when rounds has an invalid value", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send({ ...validBody, rounds: "invalid" });
|
|
||||||
const body = res.body as ErrorResponse;
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(body.success).toBe(false);
|
expect(body.success).toBe(false);
|
||||||
|
expect(body.error).toContain("Question not found");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,42 @@
|
||||||
import type { Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import type { AuthenticatedRequest } from "../types/express.js";
|
|
||||||
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
|
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
|
||||||
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
|
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
|
||||||
import { ValidationError } from "../errors/AppError.js";
|
import { ValidationError } from "../errors/AppError.js";
|
||||||
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
|
||||||
|
|
||||||
export const createGameController = (store: GameSessionStore) => ({
|
export const createGame = async (
|
||||||
createGame: async (
|
req: Request,
|
||||||
req: AuthenticatedRequest,
|
res: Response,
|
||||||
res: Response,
|
next: NextFunction,
|
||||||
next: NextFunction,
|
) => {
|
||||||
) => {
|
try {
|
||||||
try {
|
const gameSettings = GameRequestSchema.safeParse(req.body);
|
||||||
const gameSettings = GameRequestSchema.safeParse(req.body);
|
|
||||||
if (!gameSettings.success) {
|
|
||||||
throw new ValidationError("Invalid game settings");
|
|
||||||
}
|
|
||||||
const gameQuestions = await createGameSession(
|
|
||||||
gameSettings.data,
|
|
||||||
store,
|
|
||||||
req.session.user.id,
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: gameQuestions });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
submitAnswer: async (
|
if (!gameSettings.success) {
|
||||||
req: AuthenticatedRequest,
|
throw new ValidationError(gameSettings.error.message);
|
||||||
res: Response,
|
|
||||||
next: NextFunction,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const submission = AnswerSubmissionSchema.safeParse(req.body);
|
|
||||||
if (!submission.success) {
|
|
||||||
throw new ValidationError("Invalid answer submission");
|
|
||||||
}
|
|
||||||
const result = await evaluateAnswer(
|
|
||||||
submission.data,
|
|
||||||
store,
|
|
||||||
req.session.user.id,
|
|
||||||
);
|
|
||||||
res.json({ success: true, data: result });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
const gameQuestions = await createGameSession(gameSettings.data);
|
||||||
|
res.json({ success: true, data: gameQuestions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitAnswer = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const submission = AnswerSubmissionSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!submission.success) {
|
||||||
|
throw new ValidationError(submission.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await evaluateAnswer(submission.data);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,3 @@ export class ConflictError extends AppError {
|
||||||
super(message, 409);
|
super(message, 409);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UnprocessableEntityError extends AppError {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message, 422);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
export type GameSessionData = {
|
export type GameSessionData = { answers: Map<string, number> };
|
||||||
answers: Map<string, { correctOptionId: number }>;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface GameSessionStore {
|
export interface GameSessionStore {
|
||||||
create(
|
create(sessionId: string, data: GameSessionData): Promise<void>;
|
||||||
sessionId: string,
|
|
||||||
data: GameSessionData,
|
|
||||||
ttlMs: number,
|
|
||||||
): Promise<void>;
|
|
||||||
get(sessionId: string): Promise<GameSessionData | null>;
|
get(sessionId: string): Promise<GameSessionData | null>;
|
||||||
update(sessionId: string, data: GameSessionData): Promise<void>;
|
|
||||||
delete(sessionId: string): Promise<void>;
|
delete(sessionId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js";
|
|
||||||
|
|
||||||
describe("InMemoryGameSessionStore", () => {
|
|
||||||
let store: InMemoryGameSessionStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new InMemoryGameSessionStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for a non-existent session", async () => {
|
|
||||||
const result = await store.get("00000000-0000-0000-0000-000000000000");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns session data after creation", async () => {
|
|
||||||
const data = {
|
|
||||||
answers: new Map([["q1", { correctOptionId: 2 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
await store.create("session-1", data, 60_000);
|
|
||||||
const result = await store.get("session-1");
|
|
||||||
expect(result).toEqual(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null after the session is deleted", async () => {
|
|
||||||
const data = {
|
|
||||||
answers: new Map([["q1", { correctOptionId: 2 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
await store.create("session-1", data, 60_000);
|
|
||||||
await store.delete("session-1");
|
|
||||||
const result = await store.get("session-1");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null after TTL expires", async () => {
|
|
||||||
const data = {
|
|
||||||
answers: new Map([["q1", { correctOptionId: 2 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
await store.create("session-1", data, 1);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
const result = await store.get("session-1");
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns session data before TTL expires", async () => {
|
|
||||||
const data = {
|
|
||||||
answers: new Map([["q1", { correctOptionId: 2 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
await store.create("session-1", data, 60_000);
|
|
||||||
const result = await store.get("session-1");
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("update persists modified session data", async () => {
|
|
||||||
const data = {
|
|
||||||
answers: new Map([["q1", { correctOptionId: 2 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
|
|
||||||
await store.create("session-1", data, 60_000);
|
|
||||||
|
|
||||||
const updated = {
|
|
||||||
answers: new Map([["q2", { correctOptionId: 1 }]]),
|
|
||||||
userId: "user-1",
|
|
||||||
};
|
|
||||||
await store.update("session-1", updated);
|
|
||||||
|
|
||||||
const result = await store.get("session-1");
|
|
||||||
expect(result).toEqual(updated);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,34 +1,15 @@
|
||||||
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
||||||
|
|
||||||
type SessionEntry = { data: GameSessionData; expiresAt: number };
|
|
||||||
|
|
||||||
export class InMemoryGameSessionStore implements GameSessionStore {
|
export class InMemoryGameSessionStore implements GameSessionStore {
|
||||||
private sessions = new Map<string, SessionEntry>();
|
private sessions = new Map<string, GameSessionData>();
|
||||||
|
|
||||||
create(
|
create(sessionId: string, data: GameSessionData): Promise<void> {
|
||||||
sessionId: string,
|
this.sessions.set(sessionId, data);
|
||||||
data: GameSessionData,
|
|
||||||
ttlMs: number,
|
|
||||||
): Promise<void> {
|
|
||||||
this.sessions.set(sessionId, { data, expiresAt: Date.now() + ttlMs });
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
get(sessionId: string): Promise<GameSessionData | null> {
|
get(sessionId: string): Promise<GameSessionData | null> {
|
||||||
const entry = this.sessions.get(sessionId);
|
return Promise.resolve(this.sessions.get(sessionId) ?? null);
|
||||||
if (!entry) return Promise.resolve(null);
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
this.sessions.delete(sessionId);
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return Promise.resolve(entry.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(sessionId: string, data: GameSessionData): Promise<void> {
|
|
||||||
const entry = this.sessions.get(sessionId);
|
|
||||||
if (!entry) return Promise.resolve();
|
|
||||||
this.sessions.set(sessionId, { data, expiresAt: entry.expiresAt });
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(sessionId: string): Promise<void> {
|
delete(sessionId: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { Resend } from "resend";
|
|
||||||
import { db } from "@lila/db";
|
import { db } from "@lila/db";
|
||||||
import * as schema from "@lila/db/schema";
|
import * as schema from "@lila/db/schema";
|
||||||
|
|
||||||
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
|
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
|
||||||
advanced: {
|
advanced: {
|
||||||
|
|
@ -19,44 +16,6 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
database: drizzleAdapter(db, { provider: "pg", schema }),
|
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
requireEmailVerification: true,
|
|
||||||
sendResetPassword: async ({
|
|
||||||
user,
|
|
||||||
url,
|
|
||||||
}: {
|
|
||||||
user: { email: string };
|
|
||||||
url: string;
|
|
||||||
}) => {
|
|
||||||
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
|
||||||
await resend.emails.send({
|
|
||||||
from: emailFrom,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Reset your lila password",
|
|
||||||
html: `<p>Click <a href="${url}">here</a> to reset your password. This link expires in 1 hour.</p>`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emailVerification: {
|
|
||||||
sendOnSignUp: true,
|
|
||||||
autoSignInAfterVerification: true,
|
|
||||||
sendVerificationEmail: async ({
|
|
||||||
user,
|
|
||||||
url,
|
|
||||||
}: {
|
|
||||||
user: { email: string };
|
|
||||||
url: string;
|
|
||||||
}) => {
|
|
||||||
const resend = new Resend(process.env["RESEND_API_KEY"]);
|
|
||||||
await resend.emails.send({
|
|
||||||
from: emailFrom,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Verify your lila account",
|
|
||||||
html: `<p>Click <a href="${url}">here</a> to verify your email address.</p>`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
|
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
google: {
|
google: {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
export const shuffleArray = <T>(array: T[]): T[] => {
|
|
||||||
const result = [...array];
|
|
||||||
for (let i = result.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
const temp = result[i]!;
|
|
||||||
result[i] = result[j]!;
|
|
||||||
result[j] = temp;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
import express from "express";
|
|
||||||
import request from "supertest";
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { authLimiter, gameLimiter, lobbyLimiter } from "./rateLimiters.js";
|
|
||||||
|
|
||||||
import type { Session, User } from "better-auth";
|
|
||||||
|
|
||||||
function createTestApp() {
|
|
||||||
const app = express();
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
app.use("/api/auth", authLimiter);
|
|
||||||
app.all("/api/auth/*splat", (_req, res) => {
|
|
||||||
res.status(200).json({ success: true });
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("authLimiter", () => {
|
|
||||||
let app: ReturnType<typeof createTestApp>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
app = createTestApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows requests under the limit through on sensitive endpoints", async () => {
|
|
||||||
const res = await request(app).post("/api/auth/sign-in");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 429 after exceeding the limit on sensitive endpoints", async () => {
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(app).post("/api/auth/sign-in");
|
|
||||||
}
|
|
||||||
const res = await request(app).post("/api/auth/sign-in");
|
|
||||||
expect(res.status).toBe(429);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not rate limit /get-session", async () => {
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit + 5; i++) {
|
|
||||||
await request(app).get("/api/auth/get-session");
|
|
||||||
}
|
|
||||||
const res = await request(app).get("/api/auth/get-session");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not rate limit /sign-out", async () => {
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit + 5; i++) {
|
|
||||||
await request(app).post("/api/auth/sign-out");
|
|
||||||
}
|
|
||||||
const res = await request(app).post("/api/auth/sign-out");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not rate limit OAuth callbacks", async () => {
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit + 5; i++) {
|
|
||||||
await request(app).get("/api/auth/callback/google");
|
|
||||||
}
|
|
||||||
const res = await request(app).get("/api/auth/callback/google");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets RateLimit headers on sensitive responses", async () => {
|
|
||||||
const res = await request(app).post("/api/auth/sign-in");
|
|
||||||
expect(res.headers).toHaveProperty("ratelimit");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function fakeAuth(userId: string) {
|
|
||||||
return (
|
|
||||||
req: express.Request,
|
|
||||||
_res: express.Response,
|
|
||||||
next: express.NextFunction,
|
|
||||||
) => {
|
|
||||||
req.session = { session: {} as Session, user: { id: userId } as User };
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createGameTestApp(userId = "user-1") {
|
|
||||||
const app = express();
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
app.use(fakeAuth(userId));
|
|
||||||
app.use(gameLimiter);
|
|
||||||
app.post("/game/start", (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
app.post("/game/answer", (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("gameLimiter", () => {
|
|
||||||
it("allows requests under the limit through", async () => {
|
|
||||||
const app = createGameTestApp();
|
|
||||||
const res = await request(app).post("/game/start");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 429 after exceeding the limit", async () => {
|
|
||||||
const app = createGameTestApp();
|
|
||||||
const limit = 150;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(app).post("/game/answer");
|
|
||||||
}
|
|
||||||
const res = await request(app).post("/game/answer");
|
|
||||||
expect(res.status).toBe(429);
|
|
||||||
expect(res.body).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests, please try again later.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks limits per user, not per IP", async () => {
|
|
||||||
const app = express();
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
|
|
||||||
// Two routes, same limiter, different users
|
|
||||||
app.use("/user1", fakeAuth("user-1"), gameLimiter, (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
app.use("/user2", fakeAuth("user-2"), gameLimiter, (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const limit = 150;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(app).post("/user1");
|
|
||||||
}
|
|
||||||
|
|
||||||
// user-1 is exhausted
|
|
||||||
const blocked = await request(app).post("/user1");
|
|
||||||
expect(blocked.status).toBe(429);
|
|
||||||
|
|
||||||
// user-2 is unaffected
|
|
||||||
const allowed = await request(app).post("/user2");
|
|
||||||
expect(allowed.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createLobbyTestApp(userId = "user-1") {
|
|
||||||
const app = express();
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
app.use(fakeAuth(userId));
|
|
||||||
app.use(lobbyLimiter);
|
|
||||||
app.post("/lobbies", (_req, res) => res.status(200).json({ success: true }));
|
|
||||||
app.post("/lobbies/:code/join", (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("lobbyLimiter", () => {
|
|
||||||
it("allows requests under the limit through", async () => {
|
|
||||||
const app = createLobbyTestApp();
|
|
||||||
const res = await request(app).post("/lobbies");
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 429 after exceeding the limit", async () => {
|
|
||||||
const app = createLobbyTestApp();
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(app).post("/lobbies");
|
|
||||||
}
|
|
||||||
const res = await request(app).post("/lobbies");
|
|
||||||
expect(res.status).toBe(429);
|
|
||||||
expect(res.body).toEqual({
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests, please try again later.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks limits per user, not per IP", async () => {
|
|
||||||
const app = express();
|
|
||||||
app.set("trust proxy", 1);
|
|
||||||
|
|
||||||
app.use("/user1", fakeAuth("user-1"), lobbyLimiter, (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
app.use("/user2", fakeAuth("user-2"), lobbyLimiter, (_req, res) =>
|
|
||||||
res.status(200).json({ success: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const limit = 20;
|
|
||||||
for (let i = 0; i < limit; i++) {
|
|
||||||
await request(app).post("/user1");
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocked = await request(app).post("/user1");
|
|
||||||
expect(blocked.status).toBe(429);
|
|
||||||
|
|
||||||
const allowed = await request(app).post("/user2");
|
|
||||||
expect(allowed.status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import rateLimit from "express-rate-limit";
|
|
||||||
import type { Request } from "express";
|
|
||||||
|
|
||||||
// TODO: When Valkey is wired up, swap the default in-memory store for
|
|
||||||
// rate-limit-redis to persist limits across restarts:
|
|
||||||
//
|
|
||||||
// import { RedisStore } from "rate-limit-redis";
|
|
||||||
// import { valkey } from "../lib/valkey.js";
|
|
||||||
// Then add to each limiter: store: new RedisStore({ sendCommand: (...args) => valkey.call(...args) })
|
|
||||||
|
|
||||||
export const authLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
limit: 20,
|
|
||||||
standardHeaders: "draft-8",
|
|
||||||
legacyHeaders: false,
|
|
||||||
skip: (req) => {
|
|
||||||
const path = req.path;
|
|
||||||
return (
|
|
||||||
path.includes("/get-session") ||
|
|
||||||
path.includes("/sign-out") ||
|
|
||||||
path.startsWith("/callback/") ||
|
|
||||||
path.includes("/callback/")
|
|
||||||
);
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests, please try again later.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const gameLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
limit: 150,
|
|
||||||
standardHeaders: "draft-8",
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req: Request) => req.session!.user.id,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests, please try again later.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const lobbyLimiter = rateLimit({
|
|
||||||
windowMs: 15 * 60 * 1000,
|
|
||||||
limit: 20,
|
|
||||||
standardHeaders: "draft-8",
|
|
||||||
legacyHeaders: false,
|
|
||||||
keyGenerator: (req: Request) => req.session!.user.id,
|
|
||||||
message: {
|
|
||||||
success: false,
|
|
||||||
error: "Too many requests, please try again later.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Router } from "express";
|
import { Router } from "express";
|
||||||
import { healthRouter } from "./healthRouter.js";
|
import { healthRouter } from "./healthRouter.js";
|
||||||
import { createGameRouter } from "./gameRouter.js";
|
import { gameRouter } from "./gameRouter.js";
|
||||||
import { lobbyRouter } from "./lobbyRouter.js";
|
import { lobbyRouter } from "./lobbyRouter.js";
|
||||||
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
|
||||||
|
|
||||||
export const createApiRouter = (store: GameSessionStore): Router => {
|
export const apiRouter: Router = express.Router();
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.use("/health", healthRouter);
|
apiRouter.use("/health", healthRouter);
|
||||||
router.use("/game", createGameRouter(store));
|
apiRouter.use("/game", gameRouter);
|
||||||
router.use("/lobbies", lobbyRouter);
|
apiRouter.use("/lobbies", lobbyRouter);
|
||||||
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,10 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import { createGameController } from "../controllers/gameController.js";
|
import { createGame, submitAnswer } from "../controllers/gameController.js";
|
||||||
import { requireAuth } from "../middleware/authMiddleware.js";
|
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||||
import { gameLimiter } from "../middleware/rateLimiters.js";
|
|
||||||
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
|
||||||
|
|
||||||
export const createGameRouter = (store: GameSessionStore): Router => {
|
export const gameRouter: Router = express.Router();
|
||||||
const router = express.Router();
|
|
||||||
const controller = createGameController(store);
|
|
||||||
|
|
||||||
router.use(requireAuth);
|
gameRouter.use(requireAuth);
|
||||||
router.use(gameLimiter);
|
gameRouter.post("/start", createGame);
|
||||||
|
gameRouter.post("/answer", submitAnswer);
|
||||||
router.post("/start", controller.createGame as express.RequestHandler);
|
|
||||||
router.post("/answer", controller.submitAnswer as express.RequestHandler);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,10 @@ import {
|
||||||
joinLobbyHandler,
|
joinLobbyHandler,
|
||||||
} from "../controllers/lobbyController.js";
|
} from "../controllers/lobbyController.js";
|
||||||
import { requireAuth } from "../middleware/authMiddleware.js";
|
import { requireAuth } from "../middleware/authMiddleware.js";
|
||||||
import { lobbyLimiter } from "../middleware/rateLimiters.js";
|
|
||||||
|
|
||||||
export const lobbyRouter: Router = express.Router();
|
export const lobbyRouter: Router = express.Router();
|
||||||
|
|
||||||
lobbyRouter.use(requireAuth);
|
lobbyRouter.use(requireAuth);
|
||||||
lobbyRouter.use(lobbyLimiter);
|
|
||||||
|
|
||||||
lobbyRouter.post("/", createLobbyHandler);
|
lobbyRouter.post("/", createLobbyHandler);
|
||||||
lobbyRouter.post("/:code/join", joinLobbyHandler);
|
lobbyRouter.post("/:code/join", joinLobbyHandler);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
import { createGameSession, evaluateAnswer } from "./gameService.js";
|
import { createGameSession, evaluateAnswer } from "./gameService.js";
|
||||||
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
|
|
||||||
|
|
||||||
const mockGetGameTerms = vi.mocked(getGameTerms);
|
const mockGetGameTerms = vi.mocked(getGameTerms);
|
||||||
const mockGetDistractors = vi.mocked(getDistractors);
|
const mockGetDistractors = vi.mocked(getDistractors);
|
||||||
|
|
@ -15,7 +14,7 @@ const validRequest: GameRequest = {
|
||||||
target_language: "it",
|
target_language: "it",
|
||||||
pos: "noun",
|
pos: "noun",
|
||||||
difficulty: "easy",
|
difficulty: "easy",
|
||||||
rounds: 3,
|
rounds: "3",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
|
|
@ -32,32 +31,19 @@ const fakeTerms = [
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
||||||
mockGetDistractors.mockResolvedValue([
|
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
||||||
"wrong1",
|
|
||||||
"wrong2",
|
|
||||||
"wrong3",
|
|
||||||
"wrong4",
|
|
||||||
"wrong5",
|
|
||||||
"wrong6",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("createGameSession", () => {
|
describe("createGameSession", () => {
|
||||||
let store: InMemoryGameSessionStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new InMemoryGameSessionStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a session with the correct number of questions", async () => {
|
it("returns a session with the correct number of questions", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
expect(session.sessionId).toBeDefined();
|
expect(session.sessionId).toBeDefined();
|
||||||
expect(session.questions).toHaveLength(3);
|
expect(session.questions).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each question has exactly 4 options", async () => {
|
it("each question has exactly 4 options", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
for (const question of session.questions) {
|
for (const question of session.questions) {
|
||||||
expect(question.options).toHaveLength(4);
|
expect(question.options).toHaveLength(4);
|
||||||
|
|
@ -65,14 +51,14 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("each question has a unique questionId", async () => {
|
it("each question has a unique questionId", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
const ids = session.questions.map((q) => q.questionId);
|
const ids = session.questions.map((q) => q.questionId);
|
||||||
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("options have sequential optionIds 0-3", async () => {
|
it("options have sequential optionIds 0-3", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
for (const question of session.questions) {
|
for (const question of session.questions) {
|
||||||
const optionIds = question.options.map((o) => o.optionId);
|
const optionIds = question.options.map((o) => o.optionId);
|
||||||
|
|
@ -81,7 +67,7 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the correct answer is always among the options", async () => {
|
it("the correct answer is always among the options", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
for (let i = 0; i < session.questions.length; i++) {
|
for (let i = 0; i < session.questions.length; i++) {
|
||||||
const question = session.questions[i]!;
|
const question = session.questions[i]!;
|
||||||
|
|
@ -92,26 +78,24 @@ describe("createGameSession", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("correct answer appears exactly once even if getDistractors returns a duplicate", async () => {
|
it("distractors are never the correct answer", async () => {
|
||||||
mockGetDistractors.mockResolvedValueOnce([
|
const session = await createGameSession(validRequest);
|
||||||
"cane",
|
|
||||||
"wrong2",
|
|
||||||
"wrong3",
|
|
||||||
"wrong4",
|
|
||||||
"wrong5",
|
|
||||||
"wrong6",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
for (let i = 0; i < session.questions.length; i++) {
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[i]!;
|
||||||
const optionTexts = question.options.map((o) => o.text);
|
const correctText = fakeTerms[i]!.targetText;
|
||||||
|
const distractorTexts = question.options
|
||||||
|
.map((o) => o.text)
|
||||||
|
.filter((t) => t !== correctText);
|
||||||
|
|
||||||
expect(optionTexts.filter((t) => t === "cane")).toHaveLength(1);
|
for (const text of distractorTexts) {
|
||||||
expect(question.options).toHaveLength(4);
|
expect(text).not.toBe(correctText);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the prompt from the source text", async () => {
|
it("sets the prompt from the source text", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
expect(session.questions[0]!.prompt).toBe("dog");
|
expect(session.questions[0]!.prompt).toBe("dog");
|
||||||
expect(session.questions[1]!.prompt).toBe("cat");
|
expect(session.questions[1]!.prompt).toBe("cat");
|
||||||
|
|
@ -119,14 +103,14 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes gloss through (null or string)", async () => {
|
it("passes gloss through (null or string)", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
expect(session.questions[0]!.gloss).toBeNull();
|
expect(session.questions[0]!.gloss).toBeNull();
|
||||||
expect(session.questions[2]!.gloss).toBe("a building for living in");
|
expect(session.questions[2]!.gloss).toBe("a building for living in");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls getGameTerms with the correct arguments", async () => {
|
it("calls getGameTerms with the correct arguments", async () => {
|
||||||
await createGameSession(validRequest, store, "user-1");
|
await createGameSession(validRequest);
|
||||||
|
|
||||||
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
||||||
"en",
|
"en",
|
||||||
|
|
@ -138,83 +122,24 @@ describe("createGameSession", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls getDistractors once per question", async () => {
|
it("calls getDistractors once per question", async () => {
|
||||||
await createGameSession(validRequest, store, "user-1");
|
await createGameSession(validRequest);
|
||||||
|
|
||||||
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("propagates unexpected errors from getGameTerms", async () => {
|
|
||||||
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
createGameSession(validRequest, store, "user-1"),
|
|
||||||
).rejects.toThrow("connection refused");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates getDistractors failure", async () => {
|
|
||||||
mockGetDistractors.mockRejectedValue(new Error("db timeout"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
createGameSession(validRequest, store, "user-1"),
|
|
||||||
).rejects.toThrow("db timeout");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when fewer than 3 unique distractors remain after deduplication", async () => {
|
|
||||||
mockGetDistractors.mockResolvedValueOnce([
|
|
||||||
"cane",
|
|
||||||
"cane",
|
|
||||||
"cane",
|
|
||||||
"cane",
|
|
||||||
"cane",
|
|
||||||
"cane",
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
createGameSession(validRequest, store, "user-1"),
|
|
||||||
).rejects.toThrow("Not enough unique distractors");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("duplicate distractors are deduplicated against each other", async () => {
|
|
||||||
mockGetDistractors.mockResolvedValueOnce([
|
|
||||||
"wrong1",
|
|
||||||
"wrong1",
|
|
||||||
"wrong1",
|
|
||||||
"wrong2",
|
|
||||||
"wrong3",
|
|
||||||
"wrong4",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
|
||||||
const question = session.questions[0]!;
|
|
||||||
const optionTexts = question.options.map((o) => o.text);
|
|
||||||
|
|
||||||
expect(new Set(optionTexts).size).toBe(4);
|
|
||||||
expect(question.options).toHaveLength(4);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("evaluateAnswer", () => {
|
describe("evaluateAnswer", () => {
|
||||||
let store: InMemoryGameSessionStore;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = new InMemoryGameSessionStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns isCorrect: true when the correct option is selected", async () => {
|
it("returns isCorrect: true when the correct option is selected", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[0]!;
|
||||||
const correctText = fakeTerms[0]!.targetText;
|
const correctText = fakeTerms[0]!.targetText;
|
||||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||||
|
|
||||||
const result = await evaluateAnswer(
|
const result = await evaluateAnswer({
|
||||||
{
|
sessionId: session.sessionId,
|
||||||
sessionId: session.sessionId,
|
questionId: question.questionId,
|
||||||
questionId: question.questionId,
|
selectedOptionId: correctOption.optionId,
|
||||||
selectedOptionId: correctOption.optionId,
|
});
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isCorrect).toBe(true);
|
expect(result.isCorrect).toBe(true);
|
||||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||||
|
|
@ -222,21 +147,17 @@ describe("evaluateAnswer", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns isCorrect: false when a wrong option is selected", async () => {
|
it("returns isCorrect: false when a wrong option is selected", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
const question = session.questions[0]!;
|
const question = session.questions[0]!;
|
||||||
const correctText = fakeTerms[0]!.targetText;
|
const correctText = fakeTerms[0]!.targetText;
|
||||||
const correctOption = question.options.find((o) => o.text === correctText)!;
|
const correctOption = question.options.find((o) => o.text === correctText)!;
|
||||||
const wrongOption = question.options.find((o) => o.text !== correctText)!;
|
const wrongOption = question.options.find((o) => o.text !== correctText)!;
|
||||||
|
|
||||||
const result = await evaluateAnswer(
|
const result = await evaluateAnswer({
|
||||||
{
|
sessionId: session.sessionId,
|
||||||
sessionId: session.sessionId,
|
questionId: question.questionId,
|
||||||
questionId: question.questionId,
|
selectedOptionId: wrongOption.optionId,
|
||||||
selectedOptionId: wrongOption.optionId,
|
});
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isCorrect).toBe(false);
|
expect(result.isCorrect).toBe(false);
|
||||||
expect(result.correctOptionId).toBe(correctOption.optionId);
|
expect(result.correctOptionId).toBe(correctOption.optionId);
|
||||||
|
|
@ -250,13 +171,13 @@ describe("evaluateAnswer", () => {
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow(
|
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
||||||
"Game session not found",
|
"Game session not found",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ConflictError for a non-existent question", async () => {
|
it("throws NotFoundError for a non-existent question", async () => {
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
const session = await createGameSession(validRequest);
|
||||||
|
|
||||||
const submission: AnswerSubmission = {
|
const submission: AnswerSubmission = {
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
|
|
@ -264,71 +185,8 @@ describe("evaluateAnswer", () => {
|
||||||
selectedOptionId: 0,
|
selectedOptionId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(evaluateAnswer(submission)).rejects.toThrow(
|
||||||
evaluateAnswer(submission, store, "user-1"),
|
"Question not found",
|
||||||
).rejects.toMatchObject({ statusCode: 409 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws ConflictError when the same question is submitted twice", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
|
||||||
const question = session.questions[0]!;
|
|
||||||
|
|
||||||
await evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
|
||||||
evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ statusCode: 409 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deletes the session after the last question is answered", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
|
||||||
|
|
||||||
for (const question of session.questions) {
|
|
||||||
await evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
questionId: question.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
evaluateAnswer(
|
|
||||||
{
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
questionId: session.questions[0]!.questionId,
|
|
||||||
selectedOptionId: 0,
|
|
||||||
},
|
|
||||||
store,
|
|
||||||
"user-1",
|
|
||||||
),
|
|
||||||
).rejects.toThrow("Game session not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws UnprocessableEntityError when getGameTerms returns no terms", async () => {
|
|
||||||
mockGetGameTerms.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
createGameSession(validRequest, store, "user-1"),
|
|
||||||
).rejects.toMatchObject({ statusCode: 422 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,57 +8,38 @@ import type {
|
||||||
AnswerSubmission,
|
AnswerSubmission,
|
||||||
AnswerResult,
|
AnswerResult,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
import type { GameSessionStore } from "../gameSessionStore/index.js";
|
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
|
||||||
import {
|
import { NotFoundError } from "../errors/AppError.js";
|
||||||
NotFoundError,
|
|
||||||
ConflictError,
|
const gameSessionStore = new InMemoryGameSessionStore();
|
||||||
UnprocessableEntityError,
|
|
||||||
} from "../errors/AppError.js";
|
|
||||||
import { shuffleArray } from "../lib/utils.js";
|
|
||||||
|
|
||||||
export const createGameSession = async (
|
export const createGameSession = async (
|
||||||
request: GameRequest,
|
request: GameRequest,
|
||||||
store: GameSessionStore,
|
|
||||||
userId: string,
|
|
||||||
): Promise<GameSession> => {
|
): Promise<GameSession> => {
|
||||||
const terms = await getGameTerms(
|
const correctAnswers = await getGameTerms(
|
||||||
request.source_language,
|
request.source_language,
|
||||||
request.target_language,
|
request.target_language,
|
||||||
request.pos,
|
request.pos,
|
||||||
request.difficulty,
|
request.difficulty,
|
||||||
request.rounds,
|
Number(request.rounds),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (terms.length === 0) {
|
const answerKey = new Map<string, number>();
|
||||||
throw new UnprocessableEntityError("No terms found for the given filters");
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerKey = new Map<string, { correctOptionId: number }>();
|
|
||||||
|
|
||||||
const questions: GameQuestion[] = await Promise.all(
|
const questions: GameQuestion[] = await Promise.all(
|
||||||
terms.map(async (term) => {
|
correctAnswers.map(async (correctAnswer) => {
|
||||||
const distractorTexts = await getDistractors(
|
const distractorTexts = await getDistractors(
|
||||||
term.termId,
|
correctAnswer.termId,
|
||||||
term.targetText,
|
correctAnswer.targetText,
|
||||||
request.target_language,
|
request.target_language,
|
||||||
request.pos,
|
request.pos,
|
||||||
request.difficulty,
|
request.difficulty,
|
||||||
6,
|
3,
|
||||||
);
|
);
|
||||||
|
|
||||||
const uniqueDistractors = [
|
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
|
||||||
...new Set(distractorTexts.filter((t) => t !== term.targetText)),
|
const shuffledTexts = shuffle(optionTexts);
|
||||||
];
|
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
|
||||||
|
|
||||||
if (uniqueDistractors.length < 3) {
|
|
||||||
throw new Error(
|
|
||||||
`Not enough unique distractors for term: ${term.targetText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
|
||||||
const shuffledTexts = shuffleArray(optionTexts);
|
|
||||||
const correctOptionId = shuffledTexts.indexOf(term.targetText);
|
|
||||||
|
|
||||||
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
|
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
|
||||||
optionId: index,
|
optionId: index,
|
||||||
|
|
@ -66,58 +47,53 @@ export const createGameSession = async (
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const questionId = randomUUID();
|
const questionId = randomUUID();
|
||||||
answerKey.set(questionId, { correctOptionId });
|
answerKey.set(questionId, correctOptionId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
questionId,
|
questionId,
|
||||||
prompt: term.sourceText,
|
prompt: correctAnswer.sourceText,
|
||||||
gloss: term.sourceGloss,
|
gloss: correctAnswer.sourceGloss,
|
||||||
options,
|
options,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
|
await gameSessionStore.create(sessionId, { answers: answerKey });
|
||||||
|
|
||||||
return { sessionId, questions };
|
return { sessionId, questions };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shuffle = <T>(array: T[]): T[] => {
|
||||||
|
const result = [...array];
|
||||||
|
for (let i = result.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
const temp = result[i]!;
|
||||||
|
result[i] = result[j]!;
|
||||||
|
result[j] = temp;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export const evaluateAnswer = async (
|
export const evaluateAnswer = async (
|
||||||
submission: AnswerSubmission,
|
submission: AnswerSubmission,
|
||||||
store: GameSessionStore,
|
|
||||||
userId: string,
|
|
||||||
): Promise<AnswerResult> => {
|
): Promise<AnswerResult> => {
|
||||||
const session = await store.get(submission.sessionId);
|
const session = await gameSessionStore.get(submission.sessionId);
|
||||||
|
|
||||||
if (!session || session.userId !== userId) {
|
if (!session) {
|
||||||
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const answer = session.answers.get(submission.questionId);
|
const correctOptionId = session.answers.get(submission.questionId);
|
||||||
|
|
||||||
if (answer === undefined) {
|
if (correctOptionId === undefined) {
|
||||||
throw new ConflictError(
|
throw new NotFoundError(`Question not found: ${submission.questionId}`);
|
||||||
`Question already answered: ${submission.questionId}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAnswers = new Map(session.answers);
|
|
||||||
updatedAnswers.delete(submission.questionId);
|
|
||||||
|
|
||||||
if (updatedAnswers.size === 0) {
|
|
||||||
await store.delete(submission.sessionId);
|
|
||||||
} else {
|
|
||||||
await store.update(submission.sessionId, {
|
|
||||||
answers: updatedAnswers,
|
|
||||||
userId: session.userId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
questionId: submission.questionId,
|
questionId: submission.questionId,
|
||||||
isCorrect: submission.selectedOptionId === answer.correctOptionId,
|
isCorrect: submission.selectedOptionId === correctOptionId,
|
||||||
correctOptionId: answer.correctOptionId,
|
correctOptionId,
|
||||||
selectedOptionId: submission.selectedOptionId,
|
selectedOptionId: submission.selectedOptionId,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -87,14 +87,6 @@ describe("createLobby", () => {
|
||||||
"Could not generate a unique lobby code",
|
"Could not generate a unique lobby code",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("re-throws non-unique-violation errors immediately", async () => {
|
|
||||||
const dbError = new Error("connection refused");
|
|
||||||
mockCreateLobby.mockRejectedValue(dbError);
|
|
||||||
|
|
||||||
await expect(createLobby("user-1")).rejects.toThrow("connection refused");
|
|
||||||
expect(mockCreateLobby).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("joinLobby", () => {
|
describe("joinLobby", () => {
|
||||||
|
|
@ -181,22 +173,4 @@ describe("joinLobby", () => {
|
||||||
"Lobby is full",
|
"Lobby is full",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws ConflictError when addPlayer returns falsy (race condition)", async () => {
|
|
||||||
mockAddPlayer.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
|
|
||||||
"Lobby is no longer available",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws AppError when lobby disappears after addPlayer succeeds", async () => {
|
|
||||||
mockGetLobbyByCodeWithPlayers
|
|
||||||
.mockResolvedValueOnce(fakeLobbyWithPlayers)
|
|
||||||
.mockResolvedValueOnce(undefined);
|
|
||||||
|
|
||||||
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
|
|
||||||
"Lobby disappeared during join",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
|
||||||
import { generateMultiplayerQuestions } from "./multiplayerGameService.js";
|
|
||||||
|
|
||||||
const mockGetGameTerms = vi.mocked(getGameTerms);
|
|
||||||
const mockGetDistractors = vi.mocked(getDistractors);
|
|
||||||
|
|
||||||
const fakeTerms = [
|
|
||||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
|
||||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
|
||||||
{
|
|
||||||
termId: "t3",
|
|
||||||
sourceText: "house",
|
|
||||||
targetText: "casa",
|
|
||||||
sourceGloss: "a building for living in",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockGetGameTerms.mockResolvedValue(fakeTerms);
|
|
||||||
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("generateMultiplayerQuestions", () => {
|
|
||||||
it("returns the correct number of questions", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
expect(questions).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each question has exactly 4 options", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
for (const question of questions) {
|
|
||||||
expect(question.options).toHaveLength(4);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each question has a unique questionId", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
const ids = questions.map((q) => q.questionId);
|
|
||||||
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("options have sequential optionIds 0-3", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
for (const question of questions) {
|
|
||||||
const optionIds = question.options.map((o) => o.optionId);
|
|
||||||
expect(optionIds).toEqual([0, 1, 2, 3]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the correct answer is always among the options", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
|
||||||
const question = questions[i]!;
|
|
||||||
const correctText = fakeTerms[i]!.targetText;
|
|
||||||
const optionTexts = question.options.map((o) => o.text);
|
|
||||||
|
|
||||||
expect(optionTexts).toContain(correctText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("correctOptionId points to the option whose text matches the correct answer", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
|
||||||
const question = questions[i]!;
|
|
||||||
const correctText = fakeTerms[i]!.targetText;
|
|
||||||
const correctOption = question.options.find(
|
|
||||||
(o) => o.optionId === question.correctOptionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(correctOption?.text).toBe(correctText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets the prompt from the source text", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
expect(questions[0]!.prompt).toBe("dog");
|
|
||||||
expect(questions[1]!.prompt).toBe("cat");
|
|
||||||
expect(questions[2]!.prompt).toBe("house");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes gloss through (null or string)", async () => {
|
|
||||||
const questions = await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
expect(questions[0]!.gloss).toBeNull();
|
|
||||||
expect(questions[2]!.gloss).toBe("a building for living in");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls getGameTerms with the multiplayer defaults", async () => {
|
|
||||||
await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
expect(mockGetGameTerms).toHaveBeenCalledWith(
|
|
||||||
"en",
|
|
||||||
"it",
|
|
||||||
"noun",
|
|
||||||
"easy",
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls getDistractors once per question", async () => {
|
|
||||||
await generateMultiplayerQuestions();
|
|
||||||
|
|
||||||
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("propagates unexpected errors from getGameTerms", async () => {
|
|
||||||
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
|
|
||||||
|
|
||||||
await expect(generateMultiplayerQuestions()).rejects.toThrow(
|
|
||||||
"connection refused",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
5
apps/api/src/types/express.d.ts
vendored
5
apps/api/src/types/express.d.ts
vendored
|
|
@ -1,4 +1,3 @@
|
||||||
import type { Request } from "express";
|
|
||||||
import type { Session, User } from "better-auth";
|
import type { Session, User } from "better-auth";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -15,6 +14,4 @@ declare module "ws" {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthenticatedRequest = Request & {
|
export {};
|
||||||
session: { session: Session; user: User };
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,9 @@ CMD ["pnpm", "--filter", "web", "dev", "--host"]
|
||||||
# 4. Build
|
# 4. Build
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY apps/web/package.json ./apps/web/
|
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pnpm install
|
||||||
ARG VITE_API_URL
|
ARG VITE_API_URL
|
||||||
ENV VITE_API_URL=$VITE_API_URL
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
RUN pnpm --filter shared build
|
RUN pnpm --filter shared build
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lila/shared": "workspace:*",
|
"@lila/shared": "workspace:*",
|
||||||
|
|
@ -17,7 +16,6 @@
|
||||||
"better-auth": "^1.6.2",
|
"better-auth": "^1.6.2",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<section className="relative flex flex-col items-center justify-center py-24 text-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
|
||||||
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-rotate-1 mb-4">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
|
||||||
lost in translation
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-8xl font-black tracking-tight text-(--color-text) leading-none">
|
|
||||||
4
|
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
|
||||||
0
|
|
||||||
</span>
|
|
||||||
4
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
|
|
||||||
This page doesn't exist. Maybe it never did - or maybe you{" "}
|
|
||||||
<span className="text-(--color-accent) font-bold">
|
|
||||||
just guessed wrong
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
interface RootErrorProps {
|
|
||||||
error: Error;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootError({ error, reset }: RootErrorProps) {
|
|
||||||
return (
|
|
||||||
<section className="relative flex flex-col items-center justify-center min-h-screen text-center overflow-hidden px-6">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
|
||||||
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-rotate-1 mb-4">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
|
||||||
something went wrong
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-(--color-text) leading-[1.05]">
|
|
||||||
Unexpected{" "}
|
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
|
||||||
error
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
|
|
||||||
Something crashed. This has been noted —{" "}
|
|
||||||
<span className="text-(--color-accent) font-bold">it's not you</span>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{import.meta.env.DEV && (
|
|
||||||
<pre className="mt-4 max-w-xl w-full text-left text-xs bg-(--color-surface) border border-(--color-primary-light) rounded-2xl p-4 overflow-auto text-(--color-text-muted)">
|
|
||||||
{error.message}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3 flex-wrap justify-center">
|
|
||||||
<button
|
|
||||||
onClick={reset}
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
interface RouteErrorProps {
|
|
||||||
error: Error;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RouteError({ error, reset }: RouteErrorProps) {
|
|
||||||
return (
|
|
||||||
<section className="relative flex flex-col items-center justify-center py-24 text-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
|
||||||
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-rotate-1 mb-4">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
|
||||||
something went wrong
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-5xl font-black tracking-tight text-(--color-text) leading-[1.05]">
|
|
||||||
This page{" "}
|
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
|
||||||
crashed
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
|
|
||||||
Something went wrong loading this page.{" "}
|
|
||||||
<span className="text-(--color-accent) font-bold">Try again</span> or
|
|
||||||
head back home.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{import.meta.env.DEV && (
|
|
||||||
<pre className="mt-4 max-w-xl w-full text-left text-xs bg-(--color-surface) border border-(--color-primary-light) rounded-2xl p-4 overflow-auto text-(--color-text-muted)">
|
|
||||||
{error.message}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3 flex-wrap justify-center">
|
|
||||||
<button
|
|
||||||
onClick={reset}
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,249 +0,0 @@
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { authClient } from "../../lib/auth-client";
|
|
||||||
|
|
||||||
type Tab = "login" | "register";
|
|
||||||
|
|
||||||
type AuthModalProps = { onClose: () => void; onSuccess: () => void };
|
|
||||||
|
|
||||||
type LoginFormProps = { onSuccess: () => void };
|
|
||||||
|
|
||||||
const LoginForm = ({ onSuccess }: LoginFormProps) => {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isPending, setIsPending] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setIsPending(true);
|
|
||||||
await authClient.signIn.email(
|
|
||||||
{ email, password },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Welcome back!");
|
|
||||||
onSuccess();
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
||||||
setIsPending(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<div className="text-right">
|
|
||||||
<a
|
|
||||||
href="/forgot-password"
|
|
||||||
className="text-xs text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? "Logging in..." : "Login"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type RegisterFormProps = { onSuccess: () => void };
|
|
||||||
|
|
||||||
const RegisterForm = ({ onSuccess }: RegisterFormProps) => {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isPending, setIsPending] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setIsPending(true);
|
|
||||||
await authClient.signUp.email(
|
|
||||||
{ name, email, password },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Check your email to verify your account.");
|
|
||||||
onSuccess();
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
||||||
setIsPending(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? "Creating account..." : "Register"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type SocialButtonsProps = { onSuccess: () => void };
|
|
||||||
|
|
||||||
const SocialButtons = ({ onSuccess }: SocialButtonsProps) => {
|
|
||||||
const handleSocial = (provider: "google" | "github") => {
|
|
||||||
void authClient.signIn.social(
|
|
||||||
{ provider, callbackURL: window.location.origin },
|
|
||||||
{
|
|
||||||
onSuccess,
|
|
||||||
onError: (ctx) => {
|
|
||||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
|
||||||
<span className="text-xs text-(--color-text-muted) font-medium">
|
|
||||||
or continue with
|
|
||||||
</span>
|
|
||||||
<div className="flex-1 h-px bg-(--color-primary-light)" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSocial("github")}
|
|
||||||
className="w-full rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
|
|
||||||
>
|
|
||||||
Continue with GitHub
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSocial("google")}
|
|
||||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
|
||||||
>
|
|
||||||
Continue with Google
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => {
|
|
||||||
const [tab, setTab] = useState<Tab>("login");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full max-w-sm rounded-3xl border border-(--color-primary-light) bg-white shadow-lg p-8 flex flex-col gap-6"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Close button */}
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="absolute top-4 right-4 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
lila
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex rounded-2xl border border-(--color-primary-light) overflow-hidden">
|
|
||||||
{(["login", "register"] as Tab[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setTab(t)}
|
|
||||||
className={`flex-1 py-2 text-sm font-bold transition-colors capitalize ${
|
|
||||||
tab === t
|
|
||||||
? "bg-(--color-primary) text-white"
|
|
||||||
: "text-(--color-text-muted) hover:text-(--color-primary)"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === "login" ? (
|
|
||||||
<LoginForm onSuccess={onSuccess} />
|
|
||||||
) : (
|
|
||||||
<RegisterForm onSuccess={onClose} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Social */}
|
|
||||||
<SocialButtons onSuccess={onSuccess} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -10,9 +10,6 @@ import type { GameRequest } from "@lila/shared";
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
en: "English",
|
en: "English",
|
||||||
it: "Italian",
|
it: "Italian",
|
||||||
de: "German",
|
|
||||||
fr: "French",
|
|
||||||
es: "Spanish",
|
|
||||||
noun: "Nouns",
|
noun: "Nouns",
|
||||||
verb: "Verbs",
|
verb: "Verbs",
|
||||||
easy: "Easy",
|
easy: "Easy",
|
||||||
|
|
@ -24,35 +21,33 @@ const LABELS: Record<string, string> = {
|
||||||
|
|
||||||
type GameSetupProps = { onStart: (settings: GameRequest) => void };
|
type GameSetupProps = { onStart: (settings: GameRequest) => void };
|
||||||
|
|
||||||
type SettingGroupProps<T extends string | number> = {
|
type SettingGroupProps = {
|
||||||
label: string;
|
label: string;
|
||||||
options: readonly T[];
|
options: readonly string[];
|
||||||
selected: T;
|
selected: string;
|
||||||
onSelect: (value: T) => void;
|
onSelect: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SettingGroup = <T extends string | number>({
|
const SettingGroup = ({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: SettingGroupProps<T>) => (
|
}: SettingGroupProps) => (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
|
<p className="text-sm font-medium text-purple-400 mb-2">{label}</p>
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={option}
|
||||||
onClick={() => onSelect(option)}
|
onClick={() => onSelect(option)}
|
||||||
className={`py-2 px-5 rounded-xl font-semibold text-sm border transition-all duration-200 cursor-pointer ${
|
className={`py-2 px-5 rounded-xl font-semibold text-sm border-b-4 transition-all duration-200 cursor-pointer ${
|
||||||
selected === option
|
selected === option
|
||||||
? "bg-(--color-primary) text-white border-(--color-primary-dark) shadow-sm"
|
? "bg-purple-600 text-white border-purple-800"
|
||||||
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
|
: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{LABELS[String(option)] ?? option}
|
{LABELS[option] ?? option}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,7 +63,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
);
|
);
|
||||||
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
|
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
|
||||||
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
|
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
|
||||||
const [rounds, setRounds] = useState<number>(GAME_ROUNDS[0]);
|
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]);
|
||||||
|
|
||||||
const handleSourceLanguage = (value: string) => {
|
const handleSourceLanguage = (value: string) => {
|
||||||
if (value === targetLanguage) {
|
if (value === targetLanguage) {
|
||||||
|
|
@ -96,18 +91,12 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-8 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
||||||
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h1 className="text-3xl font-bold text-purple-900 mb-1">lila</h1>
|
||||||
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
<p className="text-sm text-gray-400">Set up your quiz</p>
|
||||||
<h1 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-1">
|
|
||||||
lila
|
|
||||||
</h1>
|
|
||||||
<p className="relative text-sm text-(--color-text-muted)">
|
|
||||||
Set up your quiz
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white dark:bg-black/10 shadow-sm p-6 flex flex-col gap-5">
|
<div className="bg-white rounded-3xl shadow-lg p-6 w-full flex flex-col gap-5">
|
||||||
<SettingGroup
|
<SettingGroup
|
||||||
label="I speak"
|
label="I speak"
|
||||||
options={SUPPORTED_LANGUAGE_CODES}
|
options={SUPPORTED_LANGUAGE_CODES}
|
||||||
|
|
@ -142,9 +131,9 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
className="w-full py-4 rounded-2xl text-xl font-black bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-4 rounded-2xl text-xl font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Start
|
Start Quiz
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,26 @@ type OptionButtonProps = {
|
||||||
|
|
||||||
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
|
||||||
const base =
|
const base =
|
||||||
"group relative w-full overflow-hidden py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border cursor-pointer text-left";
|
"w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer";
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
idle: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0",
|
idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2",
|
||||||
selected:
|
selected:
|
||||||
"bg-(--color-surface) text-(--color-primary-dark) border-(--color-primary) ring-2 ring-(--color-primary)",
|
"bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400",
|
||||||
disabled:
|
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
|
||||||
"bg-(--color-surface) text-(--color-primary-light) border-(--color-primary-light) cursor-default",
|
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
|
||||||
correct:
|
wrong: "bg-pink-400 text-white border-pink-600",
|
||||||
"bg-emerald-400/90 text-white border-emerald-600 ring-2 ring-emerald-300 scale-[1.01]",
|
|
||||||
wrong: "bg-pink-500/90 text-white border-pink-700 ring-2 ring-pink-300",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const motion =
|
|
||||||
state === "correct" ? "lila-pop" : state === "wrong" ? "lila-shake" : "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={`${base} ${styles[state]} ${motion}`}
|
className={`${base} ${styles[state]}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
disabled={
|
disabled={
|
||||||
state === "disabled" || state === "correct" || state === "wrong"
|
state === "disabled" || state === "correct" || state === "wrong"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="absolute inset-0 -z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
{text}
|
||||||
<span className="absolute -top-10 -right-12 h-24 w-24 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl" />
|
|
||||||
<span className="absolute -bottom-10 -left-12 h-24 w-24 rounded-full bg-(--color-accent) opacity-[0.10] blur-2xl" />
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center justify-between gap-3">
|
|
||||||
<span className="truncate">{text}</span>
|
|
||||||
{state === "correct" && <span aria-hidden>✓</span>}
|
|
||||||
{state === "wrong" && <span aria-hidden>✕</span>}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,59 +48,45 @@ export const QuestionCard = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
|
||||||
<div className="w-full flex items-center justify-between">
|
<div className="flex items-center gap-2 text-sm font-medium text-purple-400">
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
<span>
|
||||||
Round {questionNumber}/{totalQuestions}
|
{questionNumber} / {totalQuestions}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
{currentResult
|
|
||||||
? "Checked"
|
|
||||||
: selectedOptionId !== null
|
|
||||||
? "Ready"
|
|
||||||
: "Pick one"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-full overflow-hidden rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-8 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
|
||||||
<div className="absolute -top-16 -left-20 h-40 w-40 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h2 className="text-3xl font-bold text-purple-900 mb-2">
|
||||||
<div className="absolute -bottom-20 -right-20 h-44 w-44 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
|
||||||
|
|
||||||
<h2 className="relative text-3xl font-black tracking-tight text-(--color-text) mb-2">
|
|
||||||
{question.prompt}
|
{question.prompt}
|
||||||
</h2>
|
</h2>
|
||||||
{question.gloss && (
|
{question.gloss && (
|
||||||
<p className="relative text-sm text-(--color-text-muted) italic">
|
<p className="text-sm text-gray-400 italic">{question.gloss}</p>
|
||||||
{question.gloss}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full rounded-3xl border border-(--color-primary-light) bg-white/55 dark:bg-black/10 backdrop-blur shadow-sm p-4">
|
<div className="flex flex-col gap-3 w-full">
|
||||||
<div className="flex flex-col gap-3">
|
{question.options.map((option) => (
|
||||||
{question.options.map((option) => (
|
<OptionButton
|
||||||
<OptionButton
|
key={option.optionId}
|
||||||
key={option.optionId}
|
text={option.text}
|
||||||
text={option.text}
|
state={getOptionState(option.optionId)}
|
||||||
state={getOptionState(option.optionId)}
|
onSelect={() => handleSelect(option.optionId)}
|
||||||
onSelect={() => handleSelect(option.optionId)}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!currentResult && selectedOptionId !== null && (
|
{!currentResult && selectedOptionId !== null && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Lock it in
|
Submit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentResult && (
|
{currentResult && (
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="w-full py-3 rounded-2xl text-lg font-bold bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="w-full py-3 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
{questionNumber === totalQuestions ? "See Results" : "Next"}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { AnswerResult } from "@lila/shared";
|
import type { AnswerResult } from "@lila/shared";
|
||||||
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
|
||||||
|
|
||||||
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
|
||||||
|
|
||||||
|
|
@ -18,38 +17,30 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
|
||||||
<div className="relative overflow-hidden w-full rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/10 backdrop-blur shadow-sm p-10 text-center">
|
<div className="bg-white rounded-3xl shadow-lg p-10 w-full text-center">
|
||||||
{percentage === 100 && <ConfettiBurst />}
|
<p className="text-lg font-medium text-purple-400 mb-2">Your Score</p>
|
||||||
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
<h2 className="text-6xl font-bold text-purple-900 mb-1">
|
||||||
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl" />
|
|
||||||
|
|
||||||
<p className="relative inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-3">
|
|
||||||
Results
|
|
||||||
</p>
|
|
||||||
<h2 className="relative text-6xl font-black tracking-tight text-(--color-text) mb-1">
|
|
||||||
{score}/{total}
|
{score}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="relative text-2xl mb-6">{getMessage()}</p>
|
<p className="text-2xl mb-6">{getMessage()}</p>
|
||||||
|
|
||||||
<div className="relative w-full bg-(--color-surface) border border-(--color-primary-light) rounded-full h-4 mb-2 overflow-hidden">
|
<div className="w-full bg-purple-100 rounded-full h-4 mb-2">
|
||||||
<div
|
<div
|
||||||
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
|
||||||
style={{ width: `${percentage}%` }}
|
style={{ width: `${percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="relative text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-400">{percentage}% correct</p>
|
||||||
{percentage}% correct
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 w-full">
|
<div className="flex flex-col gap-2 w-full">
|
||||||
{results.map((result, index) => (
|
{results.map((result, index) => (
|
||||||
<div
|
<div
|
||||||
key={result.questionId}
|
key={result.questionId}
|
||||||
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm border ${
|
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm ${
|
||||||
result.isCorrect
|
result.isCorrect
|
||||||
? "bg-emerald-50/60 text-emerald-700 border-emerald-200"
|
? "bg-emerald-50 text-emerald-700"
|
||||||
: "bg-pink-50/60 text-pink-700 border-pink-200"
|
: "bg-pink-50 text-pink-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-bold">{index + 1}.</span>
|
<span className="font-bold">{index + 1}.</span>
|
||||||
|
|
@ -60,9 +51,9 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onPlayAgain}
|
onClick={onPlayAgain}
|
||||||
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
|
className="py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
|
||||||
>
|
>
|
||||||
Play again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
emoji: "📱",
|
|
||||||
title: "Mobile-first",
|
|
||||||
description: "Designed for your thumb. Play on the go, anytime.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
emoji: "🌍",
|
|
||||||
title: "5 languages",
|
|
||||||
description:
|
|
||||||
"English, Italian, German, French, Spanish — with more on the way.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
emoji: "⚔️",
|
|
||||||
title: "Real-time multiplayer",
|
|
||||||
description: "Create a room, share the code, and race to the best score.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FeatureCards = () => {
|
|
||||||
return (
|
|
||||||
<section className="py-14">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
|
||||||
Tiny rounds · big dopamine
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-black tracking-tight text-(--color-text)">
|
|
||||||
Why lila
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
|
||||||
Built to be fast to start, satisfying to finish, and fun to repeat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{features.map(({ emoji, title, description }) => (
|
|
||||||
<div
|
|
||||||
key={title}
|
|
||||||
className="group relative overflow-hidden rounded-2xl border border-(--color-primary-light) bg-(--color-bg) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center text-2xl">
|
|
||||||
<span aria-hidden>{emoji}</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-(--color-text-muted) leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
|
||||||
Instant feedback
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-primary)" />
|
|
||||||
Type-safe API
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeatureCards;
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { useSession } from "../../lib/auth-client";
|
|
||||||
|
|
||||||
const Hero = () => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="relative pt-10 md:pt-16 pb-10 md:pb-14">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
<div className="absolute -top-24 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
|
|
||||||
<div className="absolute -top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid items-center gap-10 md:grid-cols-2">
|
|
||||||
<div className="text-center md:text-left">
|
|
||||||
<div className="-rotate-1 mb-3">
|
|
||||||
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
|
|
||||||
Duolingo-style drills · real-time multiplayer
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-(--color-text) leading-[1.05]">
|
|
||||||
Learn vocabulary fast,{" "}
|
|
||||||
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
|
|
||||||
together
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="mt-5 text-lg md:text-xl font-medium text-(--color-text-muted) max-w-xl mx-auto md:mx-0">
|
|
||||||
A word appears. You pick the translation. You score points. Then you
|
|
||||||
queue up a room and{" "}
|
|
||||||
<span className="text-(--color-accent) font-bold">
|
|
||||||
beat friends
|
|
||||||
</span>{" "}
|
|
||||||
in real time.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2 flex-wrap justify-center md:justify-start">
|
|
||||||
{["🇬🇧", "🇮🇹", "🇩🇪", "🇫🇷", "🇪🇸"].map((flag) => (
|
|
||||||
<span key={flag} className="text-2xl" aria-hidden>
|
|
||||||
{flag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<span className="sr-only">
|
|
||||||
Supported languages: English, Italian, German, French, Spanish
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex gap-3 flex-wrap justify-center md:justify-start">
|
|
||||||
{session ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/play"
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Play solo
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/multiplayer"
|
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
Play with friends
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
search={{ modal: "auth" }}
|
|
||||||
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
search={{ modal: "auth" }}
|
|
||||||
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<div className="rounded-3xl border border-(--color-primary-light) bg-white/40 dark:bg-black/20 backdrop-blur p-3 shadow-sm">
|
|
||||||
<div className="rounded-2xl bg-(--color-bg) border border-(--color-primary-light) overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 bg-(--color-surface) border-b border-(--color-primary-light)">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-accent)" />
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-primary-light)" />
|
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-(--color-text-muted) opacity-40" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
Live preview
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 md:p-6">
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-(--color-text-muted)">
|
|
||||||
Translate
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-5">
|
|
||||||
<div className="text-3xl font-black text-(--color-text)">
|
|
||||||
finestra
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-(--color-text-muted)">
|
|
||||||
(noun) · A2
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
||||||
{["window", "forest", "river", "kitchen"].map((opt) => (
|
|
||||||
<div
|
|
||||||
key={opt}
|
|
||||||
className="rounded-xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm font-semibold text-(--color-text) hover:bg-(--color-surface)"
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
|
||||||
<div className="text-xs text-(--color-text-muted)">
|
|
||||||
Round 2/10
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-semibold text-(--color-text-muted)">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-(--color-accent)" />
|
|
||||||
Multiplayer room
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Hero;
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
const steps = [
|
|
||||||
{
|
|
||||||
number: "01",
|
|
||||||
title: "See a word",
|
|
||||||
description:
|
|
||||||
"A word appears in your target language, ready to challenge you.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "02",
|
|
||||||
title: "Pick the translation",
|
|
||||||
description:
|
|
||||||
"Choose from four options. Only one is correct — trust your gut.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: "03",
|
|
||||||
title: "Track your score",
|
|
||||||
description: "See how you did and challenge a friend to beat it.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const HowItWorks = () => {
|
|
||||||
return (
|
|
||||||
<section className="py-14">
|
|
||||||
<div className="relative -mx-6 px-6 py-12 rounded-3xl bg-(--color-surface) border border-(--color-primary-light) overflow-hidden">
|
|
||||||
<div className="absolute -top-20 -left-24 h-56 w-56 rounded-full bg-(--color-accent) opacity-[0.12] blur-3xl" />
|
|
||||||
<div className="absolute -bottom-24 -right-20 h-64 w-64 rounded-full bg-(--color-primary) opacity-[0.14] blur-3xl" />
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-(--color-bg) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-accent)">
|
|
||||||
Quick · satisfying · replayable
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-3 text-3xl font-black tracking-tight text-(--color-text)">
|
|
||||||
How it works
|
|
||||||
</h2>
|
|
||||||
<p className="mt-3 text-(--color-text-muted) max-w-2xl mx-auto">
|
|
||||||
Short rounds, instant feedback, and just enough pressure to make the
|
|
||||||
words stick.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="relative mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{steps.map(({ number, title, description }) => (
|
|
||||||
<li
|
|
||||||
key={number}
|
|
||||||
className="group relative overflow-hidden rounded-2xl bg-(--color-bg) border border-(--color-primary-light) p-6 shadow-sm hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="absolute -top-24 -right-24 h-48 w-48 rounded-full bg-(--color-primary) opacity-[0.10] blur-2xl transition-transform duration-300 group-hover:translate-x-2 group-hover:-translate-y-2" />
|
|
||||||
<div className="absolute -bottom-24 -left-24 h-48 w-48 rounded-full bg-(--color-accent) opacity-[0.08] blur-2xl transition-transform duration-300 group-hover:-translate-x-2 group-hover:translate-y-2" />
|
|
||||||
<div className="relative flex items-start gap-4">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<div className="h-12 w-12 rounded-2xl bg-(--color-surface) border border-(--color-primary-light) grid place-items-center">
|
|
||||||
<span className="text-sm font-black tracking-widest text-(--color-primary)">
|
|
||||||
{number}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-(--color-text)">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 text-sm text-(--color-text-muted) leading-relaxed">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-3 py-1 text-xs font-bold text-(--color-primary-dark)">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-(--color-accent)" />
|
|
||||||
Under 30 seconds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HowItWorks;
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import type { LobbyPlayer } from "@lila/shared";
|
import type { LobbyPlayer } from "@lila/shared";
|
||||||
import { ConfettiBurst } from "../ui/ConfettiBurst";
|
|
||||||
|
|
||||||
type MultiplayerScoreScreenProps = {
|
type MultiplayerScoreScreenProps = {
|
||||||
players: LobbyPlayer[];
|
players: LobbyPlayer[];
|
||||||
|
|
@ -27,27 +26,19 @@ export const MultiplayerScoreScreen = ({
|
||||||
.join(" and ");
|
.join(" and ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="absolute inset-0 -z-10 bg-linear-to-b from-purple-100 to-pink-50" />
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
<div className="absolute -top-24 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-primary) opacity-[0.12] blur-3xl -z-10" />
|
|
||||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 h-72 w-[46rem] rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl -z-10" />
|
|
||||||
|
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
|
||||||
{isWinner && !isTie && <ConfettiBurst />}
|
|
||||||
{/* Result header */}
|
{/* Result header */}
|
||||||
<div className="text-center flex flex-col gap-1">
|
<div className="text-center flex flex-col gap-1">
|
||||||
<div className="inline-flex mx-auto items-center gap-2 rounded-full bg-(--color-surface) border border-(--color-primary-light) px-4 py-1 text-xs font-bold tracking-widest uppercase text-(--color-primary)">
|
<h1 className="text-2xl font-bold text-purple-800">
|
||||||
Multiplayer
|
|
||||||
</div>
|
|
||||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
{isTie ? "It's a tie!" : isWinner ? "You win! 🎉" : "Game over"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">
|
||||||
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
{isTie ? `${winnerNames} tied` : `${winnerNames} wins!`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Score list */}
|
{/* Score list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|
@ -57,37 +48,35 @@ export const MultiplayerScoreScreen = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className={`flex items-center justify-between rounded-2xl px-4 py-3 border ${
|
className={`flex items-center justify-between rounded-lg px-4 py-3 ${
|
||||||
isCurrentUser
|
isCurrentUser
|
||||||
? "bg-(--color-surface) border-(--color-primary-light)"
|
? "bg-purple-50 border border-purple-200"
|
||||||
: "bg-white/30 dark:bg-black/10 border-(--color-primary-light)"
|
: "bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-bold text-(--color-text-muted) w-4">
|
<span className="text-sm font-medium text-gray-400 w-4">
|
||||||
{index + 1}.
|
{index + 1}.
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold ${
|
className={`text-sm font-medium ${
|
||||||
isCurrentUser
|
isCurrentUser ? "text-purple-800" : "text-gray-700"
|
||||||
? "text-(--color-text)"
|
|
||||||
: "text-(--color-text)"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<span className="text-xs text-(--color-primary) ml-1">
|
<span className="text-xs text-purple-400 ml-1">
|
||||||
(you)
|
(you)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{isPlayerWinner && (
|
{isPlayerWinner && (
|
||||||
<span className="text-xs font-medium" aria-label="Winner">
|
<span className="text-xs text-yellow-500 font-medium">
|
||||||
👑
|
👑
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-black text-(--color-text)">
|
<span className="text-sm font-bold text-gray-700">
|
||||||
{player.score} pts
|
{player.score} pts
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -95,12 +84,12 @@ export const MultiplayerScoreScreen = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/multiplayer/lobby/$code",
|
to: "/multiplayer/lobby/$code",
|
||||||
|
|
@ -111,7 +100,7 @@ export const MultiplayerScoreScreen = ({
|
||||||
Play Again
|
Play Again
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-white/30 dark:bg-black/10 border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-bold hover:bg-(--color-surface) transition-colors"
|
className="rounded bg-gray-100 px-4 py-2 text-gray-700 hover:bg-gray-200"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigate({ to: "/multiplayer" });
|
void navigate({ to: "/multiplayer" });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useSession, signOut } from "../../lib/auth-client";
|
|
||||||
|
|
||||||
const NavAuth = () => {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSignOut = () => {
|
|
||||||
void signOut()
|
|
||||||
.then(() => void navigate({ to: "/" }))
|
|
||||||
.catch((err) => console.error("Sign out error:", err));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="ml-auto">
|
|
||||||
{session ? (
|
|
||||||
<button
|
|
||||||
onClick={handleSignOut}
|
|
||||||
className="text-sm text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
Sign out{" "}
|
|
||||||
<span className="text-(--color-accent)">{session.user.name}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
search={{ modal: "auth" }}
|
|
||||||
className="text-sm font-medium px-4 py-1.5 rounded-full
|
|
||||||
text-white bg-(--color-primary)
|
|
||||||
hover:bg-(--color-primary-dark)
|
|
||||||
transition-colors duration-200"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavAuth;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import NavAuth from "./NavAuth";
|
|
||||||
import NavLinks from "./NavLinks";
|
|
||||||
|
|
||||||
const Navbar = () => {
|
|
||||||
return (
|
|
||||||
<header className="sticky top-0 z-50 w-full bg-(--color-surface) border-b border-(--color-primary-light)">
|
|
||||||
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center gap-8">
|
|
||||||
<span className="text-sm font-bold tracking-tight text-(--color-primary)">
|
|
||||||
lila
|
|
||||||
</span>
|
|
||||||
<NavLinks />
|
|
||||||
<NavAuth />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Navbar;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
type NavLinkProps = { to: string; children: React.ReactNode };
|
|
||||||
|
|
||||||
const NavLink = ({ to, children }: NavLinkProps) => {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
className="relative text-sm font-medium text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)
|
|
||||||
[&.active]:text-(--color-primary)
|
|
||||||
[&.active]:after:absolute
|
|
||||||
[&.active]:after:-bottom-1
|
|
||||||
[&.active]:after:left-0
|
|
||||||
[&.active]:after:w-full
|
|
||||||
[&.active]:after:h-0.5
|
|
||||||
[&.active]:after:bg-(--color-accent)
|
|
||||||
[&.active]:after:rounded-full
|
|
||||||
[&.active]:after:content-['']"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLink;
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import NavLink from "./NavLink";
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{ to: "/", label: "Home" },
|
|
||||||
{ to: "/play", label: "Play" },
|
|
||||||
{ to: "/multiplayer", label: "Multiplayer" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const NavLinks = () => {
|
|
||||||
return (
|
|
||||||
<nav className="flex items-center gap-6">
|
|
||||||
{links.map(({ to, label }) => (
|
|
||||||
<NavLink key={to} to={to}>
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLinks;
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
|
||||||
import { signOut } from "../../lib/auth-client";
|
|
||||||
|
|
||||||
type NavLogoutProps = { name: string };
|
|
||||||
|
|
||||||
const NavLogout = ({ name }: NavLogoutProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
void signOut()
|
|
||||||
.then(() => void navigate({ to: "/" }))
|
|
||||||
.catch((err) => console.error("logout error:", err));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="text-sm text-(--color-text-muted) transition-colors duration-200
|
|
||||||
hover:text-(--color-primary)"
|
|
||||||
>
|
|
||||||
logout <span className="text-(--color-accent)">{name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NavLogout;
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
import { useEffect, useMemo, useState, useId } from "react";
|
|
||||||
|
|
||||||
type ConfettiBurstProps = {
|
|
||||||
className?: string;
|
|
||||||
colors?: string[];
|
|
||||||
count?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Piece = { id: number; style: React.CSSProperties & ConfettiVars };
|
|
||||||
|
|
||||||
type ConfettiVars = {
|
|
||||||
["--x0"]: string;
|
|
||||||
["--y0"]: string;
|
|
||||||
["--x1"]: string;
|
|
||||||
["--y1"]: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hashStringToUint32 = (value: string) => {
|
|
||||||
// FNV-1a 32-bit
|
|
||||||
let hash = 2166136261;
|
|
||||||
for (let i = 0; i < value.length; i++) {
|
|
||||||
hash ^= value.charCodeAt(i);
|
|
||||||
hash = Math.imul(hash, 16777619);
|
|
||||||
}
|
|
||||||
return hash >>> 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mulberry32 = (seed: number) => {
|
|
||||||
return () => {
|
|
||||||
let t = (seed += 0x6d2b79f5);
|
|
||||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
||||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConfettiBurst = ({
|
|
||||||
className,
|
|
||||||
colors = [
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-accent)",
|
|
||||||
"var(--color-primary-light)",
|
|
||||||
"var(--color-accent-light)",
|
|
||||||
],
|
|
||||||
count = 18,
|
|
||||||
}: ConfettiBurstProps) => {
|
|
||||||
const [visible, setVisible] = useState(true);
|
|
||||||
const instanceId = useId();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = window.setTimeout(() => setVisible(false), 1100);
|
|
||||||
return () => window.clearTimeout(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pieces = useMemo<Piece[]>(() => {
|
|
||||||
const seed = hashStringToUint32(
|
|
||||||
`${instanceId}:${count}:${colors.join(",")}`,
|
|
||||||
);
|
|
||||||
const rand = mulberry32(seed);
|
|
||||||
const rnd = (min: number, max: number) => min + rand() * (max - min);
|
|
||||||
|
|
||||||
return Array.from({ length: count }).map((_, i) => {
|
|
||||||
const x0 = rnd(-6, 6);
|
|
||||||
const y0 = rnd(-6, 6);
|
|
||||||
const x1 = rnd(-160, 160);
|
|
||||||
const y1 = rnd(60, 220);
|
|
||||||
const delay = rnd(0, 120);
|
|
||||||
const rotate = rnd(0, 360);
|
|
||||||
const color = colors[i % colors.length];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: i,
|
|
||||||
style: {
|
|
||||||
left: "50%",
|
|
||||||
top: "0%",
|
|
||||||
backgroundColor: color,
|
|
||||||
transform: `translate(${x0}px, ${y0}px) rotate(${rotate}deg)`,
|
|
||||||
animationDelay: `${delay}ms`,
|
|
||||||
// consumed by keyframes
|
|
||||||
["--x0"]: `${x0}px`,
|
|
||||||
["--y0"]: `${y0}px`,
|
|
||||||
["--x1"]: `${x1}px`,
|
|
||||||
["--y1"]: `${y1}px`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [colors, count, instanceId]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute inset-0 overflow-visible ${className ?? ""}`}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{pieces.map((p) => (
|
|
||||||
<span key={p.id} className="lila-confetti-piece" style={p.style} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,90 +1 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
|
||||||
--color-primary: #7c3aed;
|
|
||||||
--color-primary-light: #a78bfa;
|
|
||||||
--color-primary-dark: #5b21b6;
|
|
||||||
--color-accent: #ec4899;
|
|
||||||
--color-accent-light: #f9a8d4;
|
|
||||||
--color-accent-dark: #be185d;
|
|
||||||
--color-bg: #fafafa;
|
|
||||||
--color-surface: #f5f3ff;
|
|
||||||
--color-text: #1f1f2e;
|
|
||||||
--color-text-muted: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-bg: #0f0e17;
|
|
||||||
--color-surface: #1a1730;
|
|
||||||
--color-text: #fffffe;
|
|
||||||
--color-text-muted: #a7a9be;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes lila-pop {
|
|
||||||
0% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: scale(1.03);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes lila-shake {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
20% {
|
|
||||||
transform: translateX(-3px);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
transform: translateX(3px);
|
|
||||||
}
|
|
||||||
60% {
|
|
||||||
transform: translateX(-2px);
|
|
||||||
}
|
|
||||||
80% {
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes lila-confetti {
|
|
||||||
0% {
|
|
||||||
transform: translate(var(--x0), var(--y0)) rotate(0deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
10% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate(var(--x1), var(--y1)) rotate(540deg);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.lila-pop {
|
|
||||||
animation: lila-pop 220ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lila-shake {
|
|
||||||
animation: lila-shake 260ms ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lila-confetti-piece {
|
|
||||||
position: absolute;
|
|
||||||
width: 8px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
animation: lila-confetti 900ms ease-out forwards;
|
|
||||||
will-change: transform, opacity;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,15 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
|
||||||
import { Route as PlayRouteImport } from './routes/play'
|
import { Route as PlayRouteImport } from './routes/play'
|
||||||
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
|
||||||
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AboutRouteImport } from './routes/about'
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
|
||||||
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
|
||||||
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
|
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
|
||||||
|
|
||||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
|
||||||
id: '/reset-password',
|
|
||||||
path: '/reset-password',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const PlayRoute = PlayRouteImport.update({
|
const PlayRoute = PlayRouteImport.update({
|
||||||
id: '/play',
|
id: '/play',
|
||||||
path: '/play',
|
path: '/play',
|
||||||
|
|
@ -34,9 +28,9 @@ const MultiplayerRoute = MultiplayerRouteImport.update({
|
||||||
path: '/multiplayer',
|
path: '/multiplayer',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
id: '/forgot-password',
|
id: '/login',
|
||||||
path: '/forgot-password',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AboutRoute = AboutRouteImport.update({
|
const AboutRoute = AboutRouteImport.update({
|
||||||
|
|
@ -68,10 +62,9 @@ const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/login': typeof LoginRoute
|
||||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
|
||||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -79,9 +72,8 @@ export interface FileRoutesByFullPath {
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/login': typeof LoginRoute
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
|
||||||
'/multiplayer': typeof MultiplayerIndexRoute
|
'/multiplayer': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -90,10 +82,9 @@ export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/login': typeof LoginRoute
|
||||||
'/multiplayer': typeof MultiplayerRouteWithChildren
|
'/multiplayer': typeof MultiplayerRouteWithChildren
|
||||||
'/play': typeof PlayRoute
|
'/play': typeof PlayRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
|
||||||
'/multiplayer/': typeof MultiplayerIndexRoute
|
'/multiplayer/': typeof MultiplayerIndexRoute
|
||||||
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
|
||||||
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
|
||||||
|
|
@ -103,10 +94,9 @@ export interface FileRouteTypes {
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/forgot-password'
|
| '/login'
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/play'
|
| '/play'
|
||||||
| '/reset-password'
|
|
||||||
| '/multiplayer/'
|
| '/multiplayer/'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -114,9 +104,8 @@ export interface FileRouteTypes {
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/forgot-password'
|
| '/login'
|
||||||
| '/play'
|
| '/play'
|
||||||
| '/reset-password'
|
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -124,10 +113,9 @@ export interface FileRouteTypes {
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/about'
|
| '/about'
|
||||||
| '/forgot-password'
|
| '/login'
|
||||||
| '/multiplayer'
|
| '/multiplayer'
|
||||||
| '/play'
|
| '/play'
|
||||||
| '/reset-password'
|
|
||||||
| '/multiplayer/'
|
| '/multiplayer/'
|
||||||
| '/multiplayer/game/$code'
|
| '/multiplayer/game/$code'
|
||||||
| '/multiplayer/lobby/$code'
|
| '/multiplayer/lobby/$code'
|
||||||
|
|
@ -136,21 +124,13 @@ export interface FileRouteTypes {
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
LoginRoute: typeof LoginRoute
|
||||||
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
MultiplayerRoute: typeof MultiplayerRouteWithChildren
|
||||||
PlayRoute: typeof PlayRoute
|
PlayRoute: typeof PlayRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/reset-password': {
|
|
||||||
id: '/reset-password'
|
|
||||||
path: '/reset-password'
|
|
||||||
fullPath: '/reset-password'
|
|
||||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/play': {
|
'/play': {
|
||||||
id: '/play'
|
id: '/play'
|
||||||
path: '/play'
|
path: '/play'
|
||||||
|
|
@ -165,11 +145,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof MultiplayerRouteImport
|
preLoaderRoute: typeof MultiplayerRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/forgot-password': {
|
'/login': {
|
||||||
id: '/forgot-password'
|
id: '/login'
|
||||||
path: '/forgot-password'
|
path: '/login'
|
||||||
fullPath: '/forgot-password'
|
fullPath: '/login'
|
||||||
preLoaderRoute: typeof ForgotPasswordRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/about': {
|
'/about': {
|
||||||
|
|
@ -229,10 +209,9 @@ const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
LoginRoute: LoginRoute,
|
||||||
MultiplayerRoute: MultiplayerRouteWithChildren,
|
MultiplayerRoute: MultiplayerRouteWithChildren,
|
||||||
PlayRoute: PlayRoute,
|
PlayRoute: PlayRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,59 @@
|
||||||
import {
|
import {
|
||||||
createRootRoute,
|
createRootRoute,
|
||||||
|
Link,
|
||||||
Outlet,
|
Outlet,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
useSearch,
|
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { Toaster } from "sonner";
|
import { useSession, signOut } from "../lib/auth-client";
|
||||||
import Navbar from "../components/navbar/NavBar";
|
|
||||||
import NotFound from "../components/NotFound";
|
|
||||||
import RootError from "../components/RootError";
|
|
||||||
import { AuthModal } from "../components/auth/AuthModal";
|
|
||||||
import { AuthModalSearchSchema } from "@lila/shared";
|
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { modal, redirect } = useSearch({ from: "__root__" });
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
void navigate({ to: "/", search: {} });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuccess = () => {
|
|
||||||
void navigate({ to: (redirect as string) ?? "/", search: {} });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<div className="p-2 flex gap-2 items-center">
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8">
|
<Link to="/" className="[&.active]:font-bold">
|
||||||
<Outlet />
|
Home
|
||||||
</main>
|
</Link>
|
||||||
{modal === "auth" && (
|
<Link to="/play" className="[&.active]:font-bold">
|
||||||
<AuthModal onClose={handleClose} onSuccess={handleSuccess} />
|
Play
|
||||||
)}
|
</Link>
|
||||||
<Toaster richColors position="top-center" />
|
<Link to="/multiplayer" className="[&.active]:font-bold">
|
||||||
|
Multiplayer
|
||||||
|
</Link>
|
||||||
|
<div className="ml-auto">
|
||||||
|
{session ? (
|
||||||
|
<button
|
||||||
|
className="text-sm text-gray-600 hover:text-gray-900"
|
||||||
|
onClick={() => {
|
||||||
|
void signOut()
|
||||||
|
.then(() => {
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Sign out error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out ({session.user.name})
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({ component: RootLayout });
|
||||||
component: RootLayout,
|
|
||||||
notFoundComponent: NotFound,
|
|
||||||
errorComponent: RootError,
|
|
||||||
validateSearch: AuthModalSearchSchema,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import { authClient } from "../lib/auth-client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
function ForgotPasswordPage() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [isPending, setIsPending] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setIsPending(true);
|
|
||||||
await authClient.requestPasswordReset(
|
|
||||||
{ email, redirectTo: `${window.location.origin}/reset-password` },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Check your email for a reset link.");
|
|
||||||
setIsPending(false);
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
||||||
setIsPending(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
|
|
||||||
<div className="w-full text-center">
|
|
||||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
Forgot password
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
|
||||||
Enter your email and we'll send you a reset link.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}}
|
|
||||||
className="w-full flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? "Sending..." : "Send reset link"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
|
|
||||||
>
|
|
||||||
Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/forgot-password")({
|
|
||||||
component: ForgotPasswordPage,
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import Hero from "../components/landing/Hero";
|
|
||||||
import HowItWorks from "../components/landing/HowItWorks";
|
|
||||||
import FeatureCards from "../components/landing/FeatureCards";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({ component: Index });
|
export const Route = createFileRoute("/")({ component: Index });
|
||||||
|
|
||||||
function Index() {
|
function Index() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="p-2 text-3xl text-amber-400">
|
||||||
<Hero />
|
<h3>Welcome Home!</h3>
|
||||||
<HowItWorks />
|
|
||||||
<FeatureCards />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
apps/web/src/routes/login.tsx
Normal file
46
apps/web/src/routes/login.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { signIn, useSession } from "../lib/auth-client";
|
||||||
|
|
||||||
|
const LoginPage = () => {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (isPending) return <div className="p-4">Loading...</div>;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
void navigate({ to: "/" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<h1 className="text-2xl font-bold">sign in to lila</h1>
|
||||||
|
<button
|
||||||
|
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
|
||||||
|
onClick={() => {
|
||||||
|
void signIn
|
||||||
|
.social({ provider: "github", callbackURL: window.location.origin })
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("GitHub sign in error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
|
||||||
|
onClick={() => {
|
||||||
|
void signIn
|
||||||
|
.social({ provider: "google", callbackURL: window.location.origin })
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Google sign in error:", err);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/login")({ component: LoginPage });
|
||||||
|
|
@ -14,10 +14,7 @@ export const Route = createFileRoute("/multiplayer")({
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const { data: session } = await authClient.getSession();
|
const { data: session } = await authClient.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect({
|
throw redirect({ to: "/login" });
|
||||||
to: "/",
|
|
||||||
search: { modal: "auth", redirect: "/multiplayer" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { session };
|
return { session };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -112,9 +112,9 @@ function GamePage() {
|
||||||
// Phase: playing
|
// Phase: playing
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted) text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
Question {currentQuestion.questionNumber} of{" "}
|
Question {currentQuestion.questionNumber} of{" "}
|
||||||
{currentQuestion.totalQuestions}
|
{currentQuestion.totalQuestions}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -150,7 +150,7 @@ function GamePage() {
|
||||||
{/* Round results */}
|
{/* Round results */}
|
||||||
{answerResult && (
|
{answerResult && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="text-sm font-black text-(--color-text)">
|
<h3 className="text-sm font-semibold text-gray-700">
|
||||||
Round results
|
Round results
|
||||||
</h3>
|
</h3>
|
||||||
{answerResult.players.map((player) => {
|
{answerResult.players.map((player) => {
|
||||||
|
|
@ -160,9 +160,9 @@ function GamePage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center justify-between text-sm text-(--color-text)"
|
className="flex items-center justify-between text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-semibold">{player.user.name}</span>
|
<span className="text-gray-700">{player.user.name}</span>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
result?.isCorrect
|
result?.isCorrect
|
||||||
|
|
@ -176,9 +176,7 @@ function GamePage() {
|
||||||
? "✓ Correct"
|
? "✓ Correct"
|
||||||
: "✗ Wrong"}
|
: "✗ Wrong"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-(--color-text-muted)">
|
<span className="text-gray-500">{player.score} pts</span>
|
||||||
{player.score} pts
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
<h1 className="text-2xl font-black tracking-tight text-center text-(--color-text)">
|
<h1 className="text-2xl font-bold text-center text-purple-800">
|
||||||
Multiplayer
|
Multiplayer
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
|
@ -85,14 +85,14 @@ function MultiplayerPage() {
|
||||||
|
|
||||||
{/* Create lobby */}
|
{/* Create lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-bold text-(--color-text)">
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
Create a lobby
|
Create a lobby
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">
|
||||||
Start a new game and invite friends with a code.
|
Start a new game and invite friends with a code.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleCreate().catch((err) => {
|
void handleCreate().catch((err) => {
|
||||||
console.error("Create lobby error:", err);
|
console.error("Create lobby error:", err);
|
||||||
|
|
@ -104,18 +104,16 @@ function MultiplayerPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Join lobby */}
|
{/* Join lobby */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-bold text-(--color-text)">
|
<h2 className="text-lg font-semibold text-gray-700">Join a lobby</h2>
|
||||||
Join a lobby
|
<p className="text-sm text-gray-500">
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
|
||||||
Enter the code shared by your host.
|
Enter the code shared by your host.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
className="rounded-2xl border border-(--color-primary-light) bg-white/30 dark:bg-black/10 px-4 py-3 text-sm uppercase tracking-widest text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
className="rounded border border-gray-300 px-3 py-2 text-sm uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||||
placeholder="Enter code (e.g. WOLF42)"
|
placeholder="Enter code (e.g. WOLF42)"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
onChange={(e) => setJoinCode(e.target.value)}
|
onChange={(e) => setJoinCode(e.target.value)}
|
||||||
|
|
@ -130,7 +128,7 @@ function MultiplayerPage() {
|
||||||
disabled={isCreating || isJoining}
|
disabled={isCreating || isJoining}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-surface) border border-(--color-primary-light) px-4 py-3 text-(--color-text) font-black hover:bg-white/30 dark:hover:bg-black/10 shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700 disabled:opacity-50"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void handleJoin().catch((err) => {
|
void handleJoin().catch((err) => {
|
||||||
console.error("Join lobby error:", err);
|
console.error("Join lobby error:", err);
|
||||||
|
|
|
||||||
|
|
@ -88,14 +88,12 @@ function LobbyPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
||||||
<div className="w-full max-w-md rounded-3xl border border-(--color-primary-light) bg-white/50 dark:bg-black/10 backdrop-blur shadow-sm p-8 flex flex-col gap-6">
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
||||||
{/* Lobby code */}
|
{/* Lobby code */}
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<p className="text-xs font-bold tracking-widest uppercase text-(--color-text-muted)">
|
<p className="text-sm text-gray-500">Lobby code</p>
|
||||||
Lobby code
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
className="text-4xl font-black tracking-widest text-(--color-text) hover:text-(--color-primary) cursor-pointer"
|
className="text-4xl font-bold tracking-widest text-purple-800 hover:text-purple-600 cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(code);
|
void navigator.clipboard.writeText(code);
|
||||||
}}
|
}}
|
||||||
|
|
@ -103,21 +101,21 @@ function LobbyPage() {
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-(--color-text-muted)">Click to copy</p>
|
<p className="text-xs text-gray-400">Click to copy</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-(--color-primary-light) opacity-60" />
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
{/* Player list */}
|
{/* Player list */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-lg font-black text-(--color-text)">
|
<h2 className="text-lg font-semibold text-gray-700">
|
||||||
Players ({lobby.players.length})
|
Players ({lobby.players.length})
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{lobby.players.map((player) => (
|
{lobby.players.map((player) => (
|
||||||
<li
|
<li
|
||||||
key={player.userId}
|
key={player.userId}
|
||||||
className="flex items-center gap-2 text-sm text-(--color-text)"
|
className="flex items-center gap-2 text-sm text-gray-700"
|
||||||
>
|
>
|
||||||
<span className="w-2 h-2 rounded-full bg-green-400" />
|
<span className="w-2 h-2 rounded-full bg-green-400" />
|
||||||
{player.user.name}
|
{player.user.name}
|
||||||
|
|
@ -137,7 +135,7 @@ function LobbyPage() {
|
||||||
{/* Start button — host only */}
|
{/* Start button — host only */}
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<button
|
<button
|
||||||
className="rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-black hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
className="rounded bg-purple-600 px-4 py-2 text-white hover:bg-purple-500 disabled:opacity-50"
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={!canStart}
|
disabled={!canStart}
|
||||||
>
|
>
|
||||||
|
|
@ -151,7 +149,7 @@ function LobbyPage() {
|
||||||
|
|
||||||
{/* Non-host waiting message */}
|
{/* Non-host waiting message */}
|
||||||
{!isHost && (
|
{!isHost && (
|
||||||
<p className="text-sm text-(--color-text-muted) text-center">
|
<p className="text-sm text-gray-500 text-center">
|
||||||
Waiting for host to start the game...
|
Waiting for host to start the game...
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
|
||||||
import { QuestionCard } from "../components/game/QuestionCard";
|
import { QuestionCard } from "../components/game/QuestionCard";
|
||||||
import { ScoreScreen } from "../components/game/ScoreScreen";
|
import { ScoreScreen } from "../components/game/ScoreScreen";
|
||||||
import { GameSetup } from "../components/game/GameSetup";
|
import { GameSetup } from "../components/game/GameSetup";
|
||||||
import RouteError from "../components/RouteError";
|
|
||||||
import { authClient } from "../lib/auth-client";
|
import { authClient } from "../lib/auth-client";
|
||||||
|
|
||||||
type GameStartResponse = { success: true; data: GameSession };
|
type GameStartResponse = { success: true; data: GameSession };
|
||||||
|
|
@ -128,11 +127,10 @@ function Play() {
|
||||||
|
|
||||||
export const Route = createFileRoute("/play")({
|
export const Route = createFileRoute("/play")({
|
||||||
component: Play,
|
component: Play,
|
||||||
errorComponent: RouteError,
|
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
const { data: session } = await authClient.getSession();
|
const { data: session } = await authClient.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
|
throw redirect({ to: "/login" });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { authClient } from "../lib/auth-client";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { ResetPasswordSearchSchema } from "@lila/shared";
|
|
||||||
|
|
||||||
function ResetPasswordPage() {
|
|
||||||
const { token } = Route.useSearch();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isPending, setIsPending] = useState(false);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 p-8 max-w-sm mx-auto text-center">
|
|
||||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
Invalid link
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-(--color-text-muted)">
|
|
||||||
This reset link is invalid or has expired.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/forgot-password"
|
|
||||||
className="text-sm text-(--color-primary) hover:underline"
|
|
||||||
>
|
|
||||||
Request a new one
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
setIsPending(true);
|
|
||||||
await authClient.resetPassword(
|
|
||||||
{ newPassword: password, token },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Password updated. You can now sign in.");
|
|
||||||
void navigate({ to: "/" });
|
|
||||||
},
|
|
||||||
onError: (ctx) => {
|
|
||||||
toast.error(ctx.error.message ?? "Something went wrong.");
|
|
||||||
setIsPending(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
|
|
||||||
<div className="w-full text-center">
|
|
||||||
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
|
|
||||||
Reset password
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-(--color-text-muted)">
|
|
||||||
Enter your new password below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void handleSubmit();
|
|
||||||
}}
|
|
||||||
className="w-full flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="New password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isPending ? "Updating..." : "Update password"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/reset-password")({
|
|
||||||
component: ResetPasswordPage,
|
|
||||||
validateSearch: ResetPasswordSearchSchema,
|
|
||||||
});
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@lila/pipeline",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {},
|
|
||||||
"dependencies": {
|
|
||||||
"@lila/shared": "workspace:*",
|
|
||||||
"better-sqlite3": "^12.9.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/node": "^24.12.0",
|
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
"""
|
|
||||||
data-pipeline/stage-1-extract/scripts/extract.py
|
|
||||||
|
|
||||||
Extract all synsets from the Open Multilingual Wordnet (OMW) for all
|
|
||||||
supported languages and parts of speech.
|
|
||||||
|
|
||||||
Output: one JSON file per language, written to stage-1-extract/output/
|
|
||||||
en.json, it.json, es.json, de.json, fr.json
|
|
||||||
|
|
||||||
Each file is a JSON array of synset records:
|
|
||||||
{
|
|
||||||
"source_id": "ili:i12345",
|
|
||||||
"pos": "noun",
|
|
||||||
"translations": { "en": ["dog", "canine"], "it": ["cane"] },
|
|
||||||
"glosses": { "en": ["a domesticated animal..."] },
|
|
||||||
"examples": { "en": ["the dog barked at the stranger"] }
|
|
||||||
}
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python stage-1-extract/scripts/extract.py
|
|
||||||
python stage-1-extract/scripts/extract.py --sample
|
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
pip install wn
|
|
||||||
python -m wn download omw-en:1.4
|
|
||||||
python -m wn download omw-it:1.4
|
|
||||||
python -m wn download omw-de:1.4
|
|
||||||
python -m wn download omw-es:1.4
|
|
||||||
python -m wn download omw-fr:1.4
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import wn
|
|
||||||
|
|
||||||
SUPPORTED_LANGUAGE_CODES: list[str] = ["en", "it", "es", "de", "fr"]
|
|
||||||
POS_MAP: dict[str, str] = {
|
|
||||||
"n": "noun",
|
|
||||||
"v": "verb",
|
|
||||||
"a": "adjective",
|
|
||||||
"s": "adjective", # adjective satellite — collapsed into adjective
|
|
||||||
"r": "adverb",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_all(
|
|
||||||
output_dir: str = "stage-1-extract/output", sample: bool = False
|
|
||||||
) -> None:
|
|
||||||
out = Path(output_dir)
|
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
sample_size = 100 if sample else None
|
|
||||||
|
|
||||||
# Load one Wordnet object per language up front.
|
|
||||||
print("Loading wordnets...")
|
|
||||||
wordnets: dict[str, wn.Wordnet] = {}
|
|
||||||
for lang in SUPPORTED_LANGUAGE_CODES:
|
|
||||||
try:
|
|
||||||
wordnets[lang] = wn.Wordnet(lang=lang)
|
|
||||||
synset_count = len(wordnets[lang].synsets())
|
|
||||||
print(f" {lang}: {synset_count:,} total synsets")
|
|
||||||
except wn.Error as e:
|
|
||||||
print(f" ERROR loading {lang}: {e}")
|
|
||||||
print(f" Run: python -m wn download omw-{lang}:1.4")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Collect per-ILI data across all languages and POS.
|
|
||||||
print("\nExtracting synsets...")
|
|
||||||
by_ili: dict[str, dict] = {}
|
|
||||||
|
|
||||||
for lang, wnet in wordnets.items():
|
|
||||||
for omw_pos, pos_label in POS_MAP.items():
|
|
||||||
synsets = wnet.synsets(pos=omw_pos)
|
|
||||||
covered = 0
|
|
||||||
for synset in synsets:
|
|
||||||
ili = synset.ili
|
|
||||||
if not ili:
|
|
||||||
continue
|
|
||||||
covered += 1
|
|
||||||
|
|
||||||
lemmas = [str(lemma) for lemma in synset.lemmas()]
|
|
||||||
defns = [d for d in synset.definitions() if d]
|
|
||||||
examples = [e for e in synset.examples() if e]
|
|
||||||
|
|
||||||
if ili not in by_ili:
|
|
||||||
by_ili[ili] = {"pos": pos_label}
|
|
||||||
|
|
||||||
if lang not in by_ili[ili]:
|
|
||||||
by_ili[ili][lang] = {
|
|
||||||
"lemmas": lemmas,
|
|
||||||
"glosses": defns,
|
|
||||||
"examples": examples,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# ILI already exists for this language — merge data.
|
|
||||||
# Happens when 'a' and 's' both map to adjective for the
|
|
||||||
# same ILI. Deduplicate to avoid repeated entries.
|
|
||||||
existing = by_ili[ili][lang]
|
|
||||||
existing["lemmas"] = list(
|
|
||||||
dict.fromkeys(existing["lemmas"] + lemmas)
|
|
||||||
)
|
|
||||||
existing["glosses"] = list(
|
|
||||||
dict.fromkeys(existing["glosses"] + defns)
|
|
||||||
)
|
|
||||||
existing["examples"] = list(
|
|
||||||
dict.fromkeys(existing["examples"] + examples)
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f" {lang} {pos_label}: {covered:,} synsets with ILI")
|
|
||||||
|
|
||||||
# Build records and write single combined output file.
|
|
||||||
print("\nBuilding records...")
|
|
||||||
ilis = sorted(by_ili.keys())
|
|
||||||
if sample_size:
|
|
||||||
ilis = ilis[:sample_size]
|
|
||||||
|
|
||||||
records: list[dict] = []
|
|
||||||
for ili in ilis:
|
|
||||||
data = by_ili[ili]
|
|
||||||
record: dict = {
|
|
||||||
"source_id": f"ili:{ili}",
|
|
||||||
"pos": data["pos"],
|
|
||||||
"translations": {},
|
|
||||||
"glosses": {},
|
|
||||||
"examples": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value in data.items():
|
|
||||||
if key == "pos":
|
|
||||||
continue
|
|
||||||
lang = key
|
|
||||||
if value["lemmas"]:
|
|
||||||
record["translations"][lang] = value["lemmas"]
|
|
||||||
if value["glosses"]:
|
|
||||||
record["glosses"][lang] = value["glosses"]
|
|
||||||
if value["examples"]:
|
|
||||||
record["examples"][lang] = value["examples"]
|
|
||||||
|
|
||||||
records.append(record)
|
|
||||||
|
|
||||||
output_file = out / "omw.json"
|
|
||||||
with open(output_file, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(records, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
print(f"\nWrote {len(records):,} synsets → {output_file}")
|
|
||||||
_print_coverage(records)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_coverage(records: list[dict]) -> None:
|
|
||||||
"""Print per-language translation, gloss, and example counts."""
|
|
||||||
lang_stats: dict[str, dict[str, int]] = {}
|
|
||||||
for lang in SUPPORTED_LANGUAGE_CODES:
|
|
||||||
lang_stats[lang] = {"translations": 0, "glosses": 0, "examples": 0}
|
|
||||||
|
|
||||||
pos_stats: dict[str, int] = {}
|
|
||||||
|
|
||||||
for r in records:
|
|
||||||
pos = r["pos"]
|
|
||||||
pos_stats[pos] = pos_stats.get(pos, 0) + 1
|
|
||||||
|
|
||||||
for lang, lemmas in r["translations"].items():
|
|
||||||
if lang in lang_stats:
|
|
||||||
lang_stats[lang]["translations"] += len(lemmas)
|
|
||||||
for lang, gloss_list in r["glosses"].items():
|
|
||||||
if lang in lang_stats:
|
|
||||||
lang_stats[lang]["glosses"] += len(gloss_list)
|
|
||||||
for lang, example_list in r["examples"].items():
|
|
||||||
if lang in lang_stats:
|
|
||||||
lang_stats[lang]["examples"] += len(example_list)
|
|
||||||
|
|
||||||
print("\nPOS breakdown:")
|
|
||||||
for pos, count in sorted(pos_stats.items()):
|
|
||||||
print(f" {pos}: {count:,}")
|
|
||||||
|
|
||||||
print("\nCoverage per language:")
|
|
||||||
for lang, counts in lang_stats.items():
|
|
||||||
t = counts["translations"]
|
|
||||||
g = counts["glosses"]
|
|
||||||
e = counts["examples"]
|
|
||||||
total = len(records)
|
|
||||||
print(
|
|
||||||
f" {lang}: {t:,} translations, {g:,} glosses, {e:,} examples (avg {(t / total):.1f} translations/synset)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Extract OMW data to JSON")
|
|
||||||
parser.add_argument(
|
|
||||||
"--output-dir",
|
|
||||||
default="stage-1-extract/output",
|
|
||||||
help="Output directory for JSON files",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--sample",
|
|
||||||
action="store_true",
|
|
||||||
help="Extract only 100 synsets per language for inspection",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
extract_all(output_dir=args.output_dir, sample=args.sample)
|
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { SUPPORTED_LANGUAGE_CODES } from "@lila/shared";
|
|
||||||
import type { SupportedLanguageCode, SupportedPos } from "@lila/shared";
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type OmwExample = { text: string; source: "omw" };
|
|
||||||
|
|
||||||
type CefrExample = { text: string; source: "cefr" };
|
|
||||||
|
|
||||||
type Example = OmwExample | CefrExample;
|
|
||||||
|
|
||||||
type OmwRecord = {
|
|
||||||
source_id: string;
|
|
||||||
pos: SupportedPos;
|
|
||||||
translations: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
glosses: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
examples: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AnnotatedRecord = {
|
|
||||||
source_id: string;
|
|
||||||
pos: SupportedPos;
|
|
||||||
translations: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
glosses: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
examples: Partial<Record<SupportedLanguageCode, Example[]>>;
|
|
||||||
votes: Partial<
|
|
||||||
Record<SupportedLanguageCode, Record<string, { cefr_source: string }>>
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CefrSourceEntry = {
|
|
||||||
word: string;
|
|
||||||
pos: string;
|
|
||||||
cefr_level: string;
|
|
||||||
example_sentence_native?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConflictEntry = {
|
|
||||||
word: string;
|
|
||||||
pos: string;
|
|
||||||
language: SupportedLanguageCode;
|
|
||||||
levels: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const POS_NORMALIZE: Record<string, SupportedPos> = {
|
|
||||||
noun: "noun",
|
|
||||||
n: "noun",
|
|
||||||
nom: "noun", // French
|
|
||||||
verb: "verb",
|
|
||||||
verbs: "verb",
|
|
||||||
v: "verb",
|
|
||||||
v1: "verb",
|
|
||||||
adjective: "adjective",
|
|
||||||
adjektiv: "adjective", // German
|
|
||||||
adj: "adjective",
|
|
||||||
adverb: "adverb",
|
|
||||||
adverbs: "adverb",
|
|
||||||
adv: "adverb",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CEFR_LEVELS = new Set(["A1", "A2", "B1", "B2", "C1", "C2"]);
|
|
||||||
|
|
||||||
const PATHS = {
|
|
||||||
omw: "stage-1-extract/output/omw.json",
|
|
||||||
cefrDir: "stage-2-annotate/sources/cefr",
|
|
||||||
outputDir: "stage-2-annotate/output",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── CEFR source loading ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type CefrIndex = Map<string, { level: string; example?: string }>;
|
|
||||||
|
|
||||||
async function loadCefrSource(
|
|
||||||
lang: SupportedLanguageCode,
|
|
||||||
): Promise<{ index: CefrIndex; conflicts: ConflictEntry[] }> {
|
|
||||||
const filepath = path.join(PATHS.cefrDir, `${lang}.json`);
|
|
||||||
const raw = await fs.readFile(filepath, "utf-8");
|
|
||||||
const entries = JSON.parse(raw) as CefrSourceEntry[];
|
|
||||||
|
|
||||||
// First pass — detect conflicts.
|
|
||||||
// Structure: "word|pos" -> Set of CEFR levels seen
|
|
||||||
const seen = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const pos = POS_NORMALIZE[entry.pos.toLowerCase().trim()];
|
|
||||||
if (!pos) continue;
|
|
||||||
if (!CEFR_LEVELS.has(entry.cefr_level)) continue;
|
|
||||||
|
|
||||||
const key = `${entry.word.toLowerCase().trim()}|${pos}`;
|
|
||||||
if (!seen.has(key)) seen.set(key, new Set());
|
|
||||||
seen.get(key)!.add(entry.cefr_level);
|
|
||||||
}
|
|
||||||
|
|
||||||
const conflicts: ConflictEntry[] = [];
|
|
||||||
for (const [key, levels] of seen.entries()) {
|
|
||||||
if (levels.size > 1) {
|
|
||||||
const [word, pos] = key.split("|") as [string, string];
|
|
||||||
conflicts.push({ word, pos, language: lang, levels: [...levels] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass — build index, skip conflicting entries.
|
|
||||||
const conflictKeys = new Set(conflicts.map((c) => `${c.word}|${c.pos}`));
|
|
||||||
|
|
||||||
const index: CefrIndex = new Map();
|
|
||||||
for (const entry of entries) {
|
|
||||||
const pos = POS_NORMALIZE[entry.pos.toLowerCase().trim()];
|
|
||||||
if (!pos) continue;
|
|
||||||
if (!CEFR_LEVELS.has(entry.cefr_level)) continue;
|
|
||||||
|
|
||||||
const key = `${entry.word.toLowerCase().trim()}|${pos}`;
|
|
||||||
if (conflictKeys.has(key)) continue;
|
|
||||||
|
|
||||||
index.set(key, {
|
|
||||||
level: entry.cefr_level,
|
|
||||||
...(entry.example_sentence_native
|
|
||||||
? { example: entry.example_sentence_native }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { index, conflicts };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Annotation ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function annotate(): Promise<void> {
|
|
||||||
// Load OMW records
|
|
||||||
console.log("Reading OMW extract...");
|
|
||||||
const raw = await fs.readFile(PATHS.omw, "utf-8");
|
|
||||||
const omwRecords = JSON.parse(raw) as OmwRecord[];
|
|
||||||
console.log(` Loaded ${omwRecords.length.toLocaleString()} synsets`);
|
|
||||||
|
|
||||||
// Load CEFR sources for all languages
|
|
||||||
console.log("\nLoading CEFR source files...");
|
|
||||||
const cefrIndexes = new Map<SupportedLanguageCode, CefrIndex>();
|
|
||||||
const allConflicts: ConflictEntry[] = [];
|
|
||||||
|
|
||||||
for (const lang of SUPPORTED_LANGUAGE_CODES) {
|
|
||||||
const { index, conflicts } = await loadCefrSource(lang);
|
|
||||||
cefrIndexes.set(lang, index);
|
|
||||||
allConflicts.push(...conflicts);
|
|
||||||
console.log(
|
|
||||||
` ${lang}: ${index.size.toLocaleString()} entries, ${conflicts.length} conflicts`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write conflicts file
|
|
||||||
await fs.mkdir(PATHS.outputDir, { recursive: true });
|
|
||||||
await fs.writeFile(
|
|
||||||
path.join(PATHS.outputDir, "conflicts.json"),
|
|
||||||
JSON.stringify(allConflicts, null, 2),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`\nWrote ${allConflicts.length} conflicts → ${PATHS.outputDir}/conflicts.json`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Annotate and write one file per language
|
|
||||||
console.log("\nAnnotating...");
|
|
||||||
for (const lang of SUPPORTED_LANGUAGE_CODES) {
|
|
||||||
const index = cefrIndexes.get(lang)!;
|
|
||||||
const records: AnnotatedRecord[] = [];
|
|
||||||
let matched = 0;
|
|
||||||
|
|
||||||
for (const record of omwRecords) {
|
|
||||||
const annotated: AnnotatedRecord = {
|
|
||||||
source_id: record.source_id,
|
|
||||||
pos: record.pos,
|
|
||||||
translations: record.translations,
|
|
||||||
glosses: record.glosses,
|
|
||||||
examples: {},
|
|
||||||
votes: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert OMW examples to typed format
|
|
||||||
for (const [l, exList] of Object.entries(record.examples)) {
|
|
||||||
annotated.examples[l as SupportedLanguageCode] = exList.map((text) => ({
|
|
||||||
text,
|
|
||||||
source: "omw" as const,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match translations for this language against CEFR index
|
|
||||||
const langTranslations = record.translations[lang] ?? [];
|
|
||||||
for (const word of langTranslations) {
|
|
||||||
const key = `${word.toLowerCase().trim()}|${record.pos}`;
|
|
||||||
const cefrEntry = index.get(key);
|
|
||||||
if (!cefrEntry) continue;
|
|
||||||
|
|
||||||
matched++;
|
|
||||||
|
|
||||||
// Add CEFR vote
|
|
||||||
if (!annotated.votes[lang]) annotated.votes[lang] = {};
|
|
||||||
annotated.votes[lang]![word] = { cefr_source: cefrEntry.level };
|
|
||||||
|
|
||||||
// Add native example if present
|
|
||||||
if (cefrEntry.example) {
|
|
||||||
if (!annotated.examples[lang]) annotated.examples[lang] = [];
|
|
||||||
annotated.examples[lang]!.push({
|
|
||||||
text: cefrEntry.example,
|
|
||||||
source: "cefr" as const,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
records.push(annotated);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputFile = path.join(PATHS.outputDir, `${lang}.json`);
|
|
||||||
await fs.writeFile(outputFile, JSON.stringify(records, null, 2), "utf-8");
|
|
||||||
console.log(
|
|
||||||
` ${lang}: ${matched.toLocaleString()} matches → ${outputFile}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
annotate().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,205 +0,0 @@
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { SUPPORTED_LANGUAGE_CODES } from "@lila/shared";
|
|
||||||
import type { SupportedLanguageCode, SupportedPos } from "@lila/shared";
|
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Example = { text: string; source: "omw" | "cefr" };
|
|
||||||
|
|
||||||
type AnnotatedRecord = {
|
|
||||||
source_id: string;
|
|
||||||
pos: SupportedPos;
|
|
||||||
translations: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
glosses: Partial<Record<SupportedLanguageCode, string[]>>;
|
|
||||||
examples: Partial<Record<SupportedLanguageCode, Example[]>>;
|
|
||||||
votes: Partial<
|
|
||||||
Record<SupportedLanguageCode, Record<string, { cefr_source: string }>>
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SampleRecord = AnnotatedRecord & { _sample_bucket: string };
|
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PATHS = {
|
|
||||||
annotatedDir: "stage-2-annotate/output",
|
|
||||||
output: "test/output/sample.json",
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUCKET_SIZE = 20;
|
|
||||||
|
|
||||||
// ── Bucket predicates ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Bucket = { name: string; predicate: (record: AnnotatedRecord) => boolean };
|
|
||||||
|
|
||||||
const BUCKETS: Bucket[] = [
|
|
||||||
{
|
|
||||||
name: "has_cefr_vote",
|
|
||||||
predicate: (r) =>
|
|
||||||
Object.values(r.votes).some(
|
|
||||||
(langVotes) => Object.keys(langVotes ?? {}).length > 0,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no_cefr_vote",
|
|
||||||
predicate: (r) =>
|
|
||||||
Object.values(r.votes).every(
|
|
||||||
(langVotes) => Object.keys(langVotes ?? {}).length === 0,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "has_glosses_and_examples",
|
|
||||||
predicate: (r) =>
|
|
||||||
Object.keys(r.glosses).length > 0 && Object.keys(r.examples).length > 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no_glosses_no_examples",
|
|
||||||
predicate: (r) =>
|
|
||||||
!r.glosses["fr"] &&
|
|
||||||
!r.examples["fr"] &&
|
|
||||||
!r.votes["fr"] &&
|
|
||||||
!r.glosses["es"] &&
|
|
||||||
!r.examples["es"] &&
|
|
||||||
!r.votes["es"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "pos_spread",
|
|
||||||
predicate: () => true, // sampled separately to ensure POS coverage
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Sampling ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function sampleBucket(
|
|
||||||
records: AnnotatedRecord[],
|
|
||||||
predicate: (r: AnnotatedRecord) => boolean,
|
|
||||||
size: number,
|
|
||||||
exclude: Set<string>,
|
|
||||||
): AnnotatedRecord[] {
|
|
||||||
const candidates = records.filter(
|
|
||||||
(r) => !exclude.has(r.source_id) && predicate(r),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Shuffle for random sampling
|
|
||||||
for (let i = candidates.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[candidates[i], candidates[j]] = [candidates[j]!, candidates[i]!];
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates.slice(0, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
function samplePosBucket(
|
|
||||||
records: AnnotatedRecord[],
|
|
||||||
exclude: Set<string>,
|
|
||||||
): AnnotatedRecord[] {
|
|
||||||
const posList: SupportedPos[] = ["noun", "verb", "adjective", "adverb"];
|
|
||||||
const perPos = Math.floor(BUCKET_SIZE / posList.length);
|
|
||||||
const result: AnnotatedRecord[] = [];
|
|
||||||
|
|
||||||
for (const pos of posList) {
|
|
||||||
const sampled = sampleBucket(
|
|
||||||
records,
|
|
||||||
(r) => r.pos === pos,
|
|
||||||
perPos,
|
|
||||||
exclude,
|
|
||||||
);
|
|
||||||
result.push(...sampled);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Loading ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function loadAnnotated(): Promise<AnnotatedRecord[]> {
|
|
||||||
// Load all language files and merge votes into a single record set.
|
|
||||||
// Use en.json as the base record structure since it has the most complete
|
|
||||||
// glosses and examples. Votes from all other languages are merged in.
|
|
||||||
const baseRaw = await fs.readFile(
|
|
||||||
path.join(PATHS.annotatedDir, "en.json"),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
const base = JSON.parse(baseRaw) as AnnotatedRecord[];
|
|
||||||
|
|
||||||
// Build a map for fast lookup by source_id
|
|
||||||
const byId = new Map<string, AnnotatedRecord>();
|
|
||||||
for (const record of base) {
|
|
||||||
byId.set(record.source_id, record);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge votes from remaining language files
|
|
||||||
for (const lang of SUPPORTED_LANGUAGE_CODES) {
|
|
||||||
if (lang === "en") continue;
|
|
||||||
const raw = await fs.readFile(
|
|
||||||
path.join(PATHS.annotatedDir, `${lang}.json`),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
const records = JSON.parse(raw) as AnnotatedRecord[];
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
const base = byId.get(record.source_id);
|
|
||||||
if (!base) continue;
|
|
||||||
|
|
||||||
// Merge votes
|
|
||||||
for (const [l, langVotes] of Object.entries(record.votes)) {
|
|
||||||
if (!base.votes[l as SupportedLanguageCode]) {
|
|
||||||
base.votes[l as SupportedLanguageCode] = {};
|
|
||||||
}
|
|
||||||
Object.assign(base.votes[l as SupportedLanguageCode]!, langVotes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge examples from CEFR source files not in base
|
|
||||||
for (const [l, examples] of Object.entries(record.examples)) {
|
|
||||||
const lang = l as SupportedLanguageCode;
|
|
||||||
if (!base.examples[lang]) {
|
|
||||||
base.examples[lang] = examples as Example[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...byId.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
console.log("Loading annotated files...");
|
|
||||||
const records = await loadAnnotated();
|
|
||||||
console.log(` Loaded ${records.length.toLocaleString()} synsets`);
|
|
||||||
|
|
||||||
const sampled: SampleRecord[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
// Sample each bucket except pos_spread
|
|
||||||
for (const bucket of BUCKETS.filter((b) => b.name !== "pos_spread")) {
|
|
||||||
const results = sampleBucket(records, bucket.predicate, BUCKET_SIZE, seen);
|
|
||||||
for (const r of results) {
|
|
||||||
seen.add(r.source_id);
|
|
||||||
sampled.push({ ...r, _sample_bucket: bucket.name });
|
|
||||||
}
|
|
||||||
console.log(` ${bucket.name}: ${results.length} records`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample pos_spread bucket
|
|
||||||
const posResults = samplePosBucket(records, seen);
|
|
||||||
for (const r of posResults) {
|
|
||||||
seen.add(r.source_id);
|
|
||||||
sampled.push({ ...r, _sample_bucket: "pos_spread" });
|
|
||||||
}
|
|
||||||
console.log(` pos_spread: ${posResults.length} records`);
|
|
||||||
|
|
||||||
console.log(`\nTotal sampled: ${sampled.length} records`);
|
|
||||||
|
|
||||||
// Write output
|
|
||||||
await fs.mkdir(path.dirname(PATHS.output), { recursive: true });
|
|
||||||
await fs.writeFile(PATHS.output, JSON.stringify(sampled, null, 2), "utf-8");
|
|
||||||
console.log(`Wrote sample → ${PATHS.output}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": ".",
|
|
||||||
"types": ["node"]
|
|
||||||
},
|
|
||||||
"references": [{ "path": "../packages/shared" }],
|
|
||||||
"include": ["./**/*"]
|
|
||||||
}
|
|
||||||
7800
data-sources/english/cefrj-vocabulary-profile-1.5.csv
Normal file
7800
data-sources/english/cefrj-vocabulary-profile-1.5.csv
Normal file
File diff suppressed because it is too large
Load diff
BIN
data-sources/english/en_m3.xls
Normal file
BIN
data-sources/english/en_m3.xls
Normal file
Binary file not shown.
2137
data-sources/english/octanove-vocabulary-profile-c1c2-1.0.csv
Normal file
2137
data-sources/english/octanove-vocabulary-profile-c1c2-1.0.csv
Normal file
File diff suppressed because it is too large
Load diff
2987
data-sources/italian/it-list_with_glossas.csv
Normal file
2987
data-sources/italian/it-list_with_glossas.csv
Normal file
File diff suppressed because it is too large
Load diff
BIN
data-sources/italian/it_m3.xls
Normal file
BIN
data-sources/italian/it_m3.xls
Normal file
Binary file not shown.
517565
data-sources/italian/subtlex-it.csv
Normal file
517565
data-sources/italian/subtlex-it.csv
Normal file
File diff suppressed because it is too large
Load diff
661563
data-sources/italian/wordlist_of_italian_words_660000_parole_italiane.txt
Normal file
661563
data-sources/italian/wordlist_of_italian_words_660000_parole_italiane.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -31,7 +31,6 @@ services:
|
||||||
|
|
||||||
api:
|
api:
|
||||||
container_name: lila-api
|
container_name: lila-api
|
||||||
user: "${UID}:${GID}"
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/api/Dockerfile
|
dockerfile: ./apps/api/Dockerfile
|
||||||
|
|
@ -60,7 +59,6 @@ services:
|
||||||
|
|
||||||
web:
|
web:
|
||||||
container_name: lila-web
|
container_name: lila-web
|
||||||
user: "${UID}:${GID}"
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./apps/web/Dockerfile
|
dockerfile: ./apps/web/Dockerfile
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
# lila — backlog
|
|
||||||
|
|
||||||
Labels: `[feature]` `[infra]` `[security]` `[ux]` `[debt]`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## now
|
|
||||||
|
|
||||||
Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work.
|
|
||||||
|
|
||||||
- **Hetzner domain migration check** `[infra]`
|
|
||||||
Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## next
|
|
||||||
|
|
||||||
Clearly planned work, not yet started. No hard ordering — sequence based on what unblocks real users first.
|
|
||||||
|
|
||||||
- **Batch distractor queries to eliminate N+1** `[debt]`
|
|
||||||
createGameSession calls getDistractors once per term in parallel — 3 queries for 3 rounds, 10 for 10. Each query does ORDER BY RANDOM() which can't use an index and gets slower as the translations table grows. Fix: add a getDistractorsForTerms(termIds[], ...) function to @lila/db that batches all distractor fetches into a single query and returns results grouped by term. The service distributes the results per question. Prerequisite: none. Blocked by: nothing, but coordinate with any ongoing @lila/db changes.
|
|
||||||
|
|
||||||
- **Atomic session creation** `[debt]`
|
|
||||||
createGameSession reads from Postgres (getGameTerms, getDistractors) then writes to the session store (in-memory/Valkey). A crash between the two leaves the terms consumed with no session created — the user gets an error and retries, no data is corrupted, but the work is wasted. A true transaction boundary isn't achievable across two different systems (Postgres + Valkey have no shared coordinator). Options when revisiting: store sessions in Postgres instead of Valkey (full transactionality, higher latency), or accept the current behaviour and add retry logic on the client. Revisit after Valkey is in production and actual failure rates are observable.
|
|
||||||
|
|
||||||
- **Guest / try-now flow** `[feature]`
|
|
||||||
Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a "Try without account" button on the landing/login page.
|
|
||||||
|
|
||||||
- **Favicon, page titles, Open Graph meta** `[ux]`
|
|
||||||
Add favicon, set proper per-route page titles, add Open Graph meta tags for link previews.
|
|
||||||
|
|
||||||
- **Accessibility pass** `[ux]`
|
|
||||||
Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow.
|
|
||||||
|
|
||||||
- **Monitoring and logging** `[infra]`
|
|
||||||
Uptime monitoring and centralized logging on the VPS. Options: `chkrootkit`/`rkhunter` for security, `logwatch`/`monit` for daily summaries.
|
|
||||||
|
|
||||||
- **Offsite backup storage** `[infra]`
|
|
||||||
Database backups currently live on the same VPS. Add offsite copies to Hetzner Object Storage or an S3-compatible service to protect against VPS failure.
|
|
||||||
|
|
||||||
- **Valkey for game session store** `[infra]`
|
|
||||||
Add Valkey to the production Docker stack. Implement `ValkeyGameSessionStore` against the existing `GameSessionStore` interface. Required before multiplayer scales.
|
|
||||||
NOTE: the rate limiting middleware needs to be adjusted for valkey, see todo comment
|
|
||||||
|
|
||||||
- **User stats endpoint + profile page** `[feature]`
|
|
||||||
`GET /users/me/stats` returning games played, score history, etc. Frontend profile page displaying the stats.
|
|
||||||
|
|
||||||
- **Admin dashboard** `[feature]`
|
|
||||||
User management, overview of words and languages, and per-term stats. Not urgent but has real operational value once real users are present.
|
|
||||||
|
|
||||||
- **Email + password login** `[feature]`
|
|
||||||
Traditional email/password auth as an alternative to social login. Configure via Better Auth.
|
|
||||||
|
|
||||||
- **Apple login** `[feature]`
|
|
||||||
Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration.
|
|
||||||
|
|
||||||
- **Graceful WS reconnect** `[infra]`
|
|
||||||
Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if a game is still in progress.
|
|
||||||
|
|
||||||
- **Configurable game settings in multiplayer lobby** `[feature]`
|
|
||||||
Game settings (mode, round count, timer duration, target score) are currently hardcoded. The host should be able to configure these when creating a lobby. Settings should be stored in the settings jsonb column on the lobbies table and passed through to the game service at start.
|
|
||||||
|
|
||||||
- **Tighten CSP to remove unsafe-inline** `[security]`
|
|
||||||
Current script-src uses 'unsafe-inline' to accommodate framework-injected inline scripts (likely TanStack Router hydration). Tightening this would require nonce-based CSP, which needs server-rendered HTML or a Caddy layer that injects per-request nonces. Not urgent — pragmatic CSP with 'unsafe-inline' is mainstream for SPAs at this scale. Revisit if the app handles more sensitive data or grows a meaningful user base
|
|
||||||
|
|
||||||
- **Publish Google OAuth consent screen** `[infra]`
|
|
||||||
App is currently in testing mode, which caps OAuth sign-ins at 100 users. Before hitting that limit, publish the consent screen in Google Cloud Console. Basic scopes (email, profile, openid) require no Google review — just fill in branding fields (app name, logo, support email, privacy policy URL) and click publish. Trigger: do this before reaching 80 users.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## later
|
|
||||||
|
|
||||||
Directionally right, timing is unclear. Revisit when the next/now work is done.
|
|
||||||
|
|
||||||
- **Game modes** `[feature]`
|
|
||||||
Five modes are designed in `game_modes.md` — TV Quiz Show, Race to the Top, Chain Link, Elimination Round, Cooperative Challenge. The lobby infrastructure is mode-agnostic; each mode adds game logic only. First mode to implement is TBD. This is effectively a new phase.
|
|
||||||
|
|
||||||
- **Single Player Extended** `[feature]`
|
|
||||||
Expanded singleplayer flow. Possible directions: longer sessions with increasing difficulty, streak bonuses, mixed POS/language rounds, progress tracking across sessions, timed challenge mode.
|
|
||||||
|
|
||||||
- **Users in a separate database** `[infra]`
|
|
||||||
Architectural separation of auth/user data from vocabulary and game data. No immediate benefit — revisit after hardening is complete and user growth justifies the complexity.
|
|
||||||
|
|
||||||
- **Modern env management** `[debt]`
|
|
||||||
Replace `.env` files with a more robust approach (e.g. `dotenvx`, `infisical`). Current setup works but is error-prone and not versioned.
|
|
||||||
|
|
||||||
- **Reorganize datafiles and wordlists** `[debt]`
|
|
||||||
The current layout of `data-sources/`, `scripts/datafiles/`, `scripts/data-sources/`, and `packages/db/src/data/` is confusing with overlapping content. Consolidate into a clear structure.
|
|
||||||
|
|
||||||
- **Resolve eslint peer dependency warning** `[debt]`
|
|
||||||
`eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0–^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby.
|
|
||||||
|
|
||||||
- **OpenAPI documentation for REST endpoints** `[feature]`
|
|
||||||
Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors.
|
|
||||||
|
|
||||||
- **Frontend tests** `[debt]`
|
|
||||||
component tests for QuestionCard, OptionButton, ScoreScreen; consider Playwright or Vitest browser mode for e2e
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## changelog
|
|
||||||
|
|
||||||
Shipped milestones, newest first.
|
|
||||||
|
|
||||||
- **04 - 2026 - husky + lint-staged + CI quality gate** - Pre-commit formatting, pre-push tests, and CI lint/typecheck/test gate before every deploy.
|
|
||||||
- **04 - 2026 - t00001 - Docker credential helper**
|
|
||||||
- **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk.
|
|
||||||
- **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app.
|
|
||||||
- **04 - 2026 - 404 and redirect handling** - Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s.
|
|
||||||
- **04 - 2026 - Multiplayer GameService unit tests** - round evaluation, scoring, tie-breaking, timeout handling
|
|
||||||
- **04 - 2026 - Security headers with helmet** - Add helmet middleware to set secure HTTP response headers.
|
|
||||||
- **04 - 2026 - Rate limiting on API endpoints** - At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention)
|
|
||||||
- **04 - 2026 — Migrations in deploy pipeline** — Drizzle migrate runs as a CI/CD step before the API container restarts
|
|
||||||
- **04 - 2026 — Phase 6: Production deployment** — Hetzner VPS, Caddy HTTPS, Forgejo CI/CD, daily DB backups, cross-subdomain auth
|
|
||||||
- **04 - 2026 — Phase 5: Multiplayer game** — real-time simultaneous play, 15s server timer, live scoring, winner screen
|
|
||||||
- **04 - 2026 — Phase 4: Multiplayer lobby** — WebSocket server, lobby create/join, real-time player list
|
|
||||||
- **04 - 2026 — Phase 3: Auth** — Better Auth, Google + GitHub social login, session middleware, auth guard
|
|
||||||
- **04 - 2026 — Phase 2: Singleplayer UI** — full quiz loop in browser, game setup, question card, score screen
|
|
||||||
- **04 - 2026 — Phase 1: Vocabulary data + API** — WordNet/OMW data pipeline, CEFR enrichment, game session endpoints
|
|
||||||
- **04 - 2026 — Phase 0: Foundation** — pnpm monorepo, TypeScript, ESLint, Vitest, Drizzle, Docker Compose
|
|
||||||
|
|
@ -1,468 +0,0 @@
|
||||||
# lila data pipeline
|
|
||||||
|
|
||||||
> **NOTE: BEFORE RUNNING THE PIPELINE, CONSIDER IMPROVING THE CEFR SOURCE
|
|
||||||
> FILES IN `stage-2-annotate/sources/cefr/`. BETTER SOURCE COVERAGE MEANS
|
|
||||||
> FEWER WORDS FOR THE LLM TO ANNOTATE FROM SCRATCH, FASTER OVERNIGHT RUNS,
|
|
||||||
> AND HIGHER CONFIDENCE IN THE FINAL OUTPUT. SEE UNIVERSALCEFR
|
|
||||||
> (huggingface.co/UniversalCEFR) AND CEFR-J
|
|
||||||
> (github.com/openlanguageprofiles/olp-en-cefrj) AS STARTING POINTS.**
|
|
||||||
|
|
||||||
This pipeline extracts vocabulary data from the Open Multilingual Wordnet (OMW), annotates it with CEFR levels from curated source files, verifies and enriches annotations using local LLMs, and produces authoritative JSON files per language. These files are consumed by the seeder in `packages/db` to populate the database with terms, translations, glosses, CEFR levels, difficulty ratings, and LLM-generated descriptions.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
omw[(OMW SQLite DBs)]
|
|
||||||
cefr[(CEFR JSON files)]
|
|
||||||
extract[Extract]
|
|
||||||
annotate[Annotate]
|
|
||||||
enrich[Enrich]
|
|
||||||
merge[Merge]
|
|
||||||
final[(final/lang.json)]
|
|
||||||
flagged[(flagged/lang.json)]
|
|
||||||
seeder[packages/db seeder]
|
|
||||||
db[(Database)]
|
|
||||||
|
|
||||||
omw --> extract
|
|
||||||
cefr --> annotate
|
|
||||||
extract --> annotate
|
|
||||||
annotate --> enrich
|
|
||||||
enrich --> merge
|
|
||||||
merge --> final
|
|
||||||
merge --> flagged
|
|
||||||
final --> seeder
|
|
||||||
seeder --> db
|
|
||||||
```
|
|
||||||
|
|
||||||
Each stage is a standalone script that reads from the previous stage's output and produces one JSON file per language. Stages can be re-run independently without affecting earlier or later stages.
|
|
||||||
|
|
||||||
The enrich stage is the exception — it produces one checkpoint file per model run per language, plus a compiled votes file once all runs are complete. It is designed to run overnight, one model at a time, and is fully resumable if interrupted.
|
|
||||||
|
|
||||||
Only fully annotated output in `stage-4-merge/output/final/` reaches the database. Words where LLMs could not reach a majority vote land in `stage-4-merge/output/flagged/` and wait for manual review before seeding.
|
|
||||||
|
|
||||||
## Data sources
|
|
||||||
|
|
||||||
### OMW / WordNet
|
|
||||||
|
|
||||||
The Open Multilingual Wordnet (OMW) is the base vocabulary source. It provides synsets — groups of synonymous words — with translations and glosses across multiple languages. One SQLite database per language is downloaded and placed in `sources/omw/`. These files are not committed to git.
|
|
||||||
|
|
||||||
All four parts of speech are extracted: noun, verb, adjective, adverb. WordNet's adjective satellites are collapsed into adjective — this is a WordNet-internal distinction that has no relevance for language learning. Alongside translations and glosses, usage examples are extracted where available and stored in the database as term_examples.
|
|
||||||
|
|
||||||
See **Setup** for download instructions.
|
|
||||||
|
|
||||||
### CEFR source files
|
|
||||||
|
|
||||||
Per-language JSON files in `sources/cefr/` provide the initial CEFR level annotations. These files do not cover the full vocabulary extracted from OMW — coverage varies by language. Gaps and disagreements are handled by the enrich stage.
|
|
||||||
|
|
||||||
| Language | File |
|
|
||||||
| -------- | ---------------------- |
|
|
||||||
| English | `sources/cefr/en.json` |
|
|
||||||
| Italian | `sources/cefr/it.json` |
|
|
||||||
| Spanish | `sources/cefr/es.json` |
|
|
||||||
| German | `sources/cefr/de.json` |
|
|
||||||
| French | `sources/cefr/fr.json` |
|
|
||||||
|
|
||||||
These files are committed to git. For per-language coverage detail see `COVERAGE.md`.
|
|
||||||
|
|
||||||
### CEFR annotation and verification
|
|
||||||
|
|
||||||
CEFR levels are determined by a majority vote combining all available sources:
|
|
||||||
|
|
||||||
- The CEFR source file counts as one vote (if it has an entry for the word)
|
|
||||||
- Each LLM model run counts as one vote
|
|
||||||
|
|
||||||
The LLMs verify existing annotations as well as filling gaps — a source file entry does not automatically win. Majority vote across all sources determines the final level.
|
|
||||||
|
|
||||||
If no majority is reached, the word is flagged for manual review and excluded from the database until resolved.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
### OMW databases
|
|
||||||
|
|
||||||
Download the OMW SQLite database for each language using the `wn` Python
|
|
||||||
library:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m wn download omw-en:1.4
|
|
||||||
python -m wn download omw-it:1.4
|
|
||||||
python -m wn download omw-de:1.4
|
|
||||||
python -m wn download omw-es:1.4
|
|
||||||
python -m wn download omw-fr:1.4
|
|
||||||
```
|
|
||||||
|
|
||||||
The data is stored automatically at `~/.wn_data/wn.db` and is not committed
|
|
||||||
to git.
|
|
||||||
|
|
||||||
### LLM setup
|
|
||||||
|
|
||||||
See `LLM-SETUP.md`.
|
|
||||||
|
|
||||||
## Pipeline stages
|
|
||||||
|
|
||||||
The pipeline runs in five stages. Each stage is independent and can be re-run without affecting the others.
|
|
||||||
|
|
||||||
| Stage | What it does |
|
|
||||||
| ----------- | -------------------------------------------------------------------- |
|
|
||||||
| 1. Extract | Reads OMW SQLite database, outputs normalized JSON per language |
|
|
||||||
| 2. Annotate | Merges CEFR source files into extracted data, adds source file votes |
|
|
||||||
| 3. Enrich | Runs local LLMs in two rounds — generation then voting |
|
|
||||||
| 4. Merge | Resolves votes, derives difficulty, splits into final and flagged |
|
|
||||||
| 5. Compare | Generates COVERAGE.md with detailed quality report |
|
|
||||||
|
|
||||||
### 1. Extract
|
|
||||||
|
|
||||||
Reads the OMW SQLite database (`~/.wn_data/wn.db`) and produces a single normalized JSON file containing all synsets with their translations, glosses, and usage examples across all five languages and all parts of speech. Adjective satellites are collapsed into adjective at this stage.
|
|
||||||
|
|
||||||
**Input:** `~/.wn_data/wn.db`
|
|
||||||
**Output:** `stage-1-extract/output/omw.json`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python stage-1-extract/scripts/extract.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `--sample` to extract 100 synsets for inspection before running the full
|
|
||||||
extraction.
|
|
||||||
|
|
||||||
Each record in the output looks like this:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_id": "ili:i1",
|
|
||||||
"pos": "adjective",
|
|
||||||
"translations": {
|
|
||||||
"en": ["able"],
|
|
||||||
"it": ["abile", "intelligente", "valente", "capace"],
|
|
||||||
"es": ["capaz"],
|
|
||||||
"fr": ["comptable"]
|
|
||||||
},
|
|
||||||
"glosses": {
|
|
||||||
"en": [
|
|
||||||
"(usually followed by 'to') having the necessary means or skill or know-how or authority to do something"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"examples": { "en": ["able to swim", "she was able to program her computer"] }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: glosses and examples are not available for all languages. French and Spanish have no glosses or examples in the current OMW database — these will be generated by the LLM in the enrich stage. Coverage detail is in `COVERAGE.md`.
|
|
||||||
|
|
||||||
### 2. Annotate
|
|
||||||
|
|
||||||
Reads the combined OMW extract and merges CEFR source data into it. Each translation in each language is matched against the corresponding CEFR source
|
|
||||||
file by word text and part of speech. Matched translations receive a `cefr_source` vote which carries into the enrich stage. Unmatched translations proceed without a vote.
|
|
||||||
|
|
||||||
This stage also extracts native example sentences from the CEFR source files and adds them to the record alongside OMW examples, with `source: "cefr"` to distinguish them.
|
|
||||||
|
|
||||||
Words appearing in the CEFR source file multiple times with different CEFR levels are written to `conflicts.json` for manual review and excluded from voting until resolved.
|
|
||||||
|
|
||||||
**Input:** `stage-1-extract/output/omw.json` + `stage-2-annotate/sources/cefr/{lang}.json`
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
- `stage-2-annotate/output/{lang}.json` — one per language
|
|
||||||
- `stage-2-annotate/output/conflicts.json` — cross-language conflicts for review
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline annotate
|
|
||||||
```
|
|
||||||
|
|
||||||
Each record in the output extends the OMW record with a `votes` field and any additional examples from the CEFR source file:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_id": "ili:i1",
|
|
||||||
"pos": "adjective",
|
|
||||||
"translations": {
|
|
||||||
"en": ["able"],
|
|
||||||
"it": ["abile", "intelligente", "valente", "capace"],
|
|
||||||
"es": ["capaz"],
|
|
||||||
"fr": ["comptable"]
|
|
||||||
},
|
|
||||||
"glosses": { "en": ["having the necessary means or skill to do something"] },
|
|
||||||
"examples": {
|
|
||||||
"en": [
|
|
||||||
{ "text": "able to swim", "source": "omw" },
|
|
||||||
{ "text": "She was able to finish the task.", "source": "cefr" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"votes": { "en": { "able": { "cefr_source": "B1" } } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Words not present in the CEFR source file will have an empty `votes` object.
|
|
||||||
|
|
||||||
### 3. Enrich
|
|
||||||
|
|
||||||
The enrich stage runs in two rounds, both designed to execute overnight one model at a time. The llama.cpp server must be running locally before starting either round. See `LLM-SETUP.md` for setup instructions.
|
|
||||||
|
|
||||||
**Round 1 — generation**
|
|
||||||
|
|
||||||
Each model processes every word in every language one term at a time and
|
|
||||||
generates:
|
|
||||||
|
|
||||||
- A CEFR level vote for each translation
|
|
||||||
- A description for each language
|
|
||||||
- A translation for each language, only if OMW provides none
|
|
||||||
- A gloss for each language, only if OMW provides none
|
|
||||||
- Usage examples for each language, only if OMW provides none
|
|
||||||
|
|
||||||
OMW data is never duplicated — the script checks what OMW already provides before building the prompt. For translations, glosses and examples, if OMW data exists for that language the LLM skips generation entirely. This significantly reduces compute time for languages with good OMW coverage such as English.
|
|
||||||
|
|
||||||
All model-generated content is stored with an anonymised source (`model_1`, `model_2` etc.) so models cannot be biased by knowing who generated what in round 2.
|
|
||||||
|
|
||||||
**Input:** `stage-2-annotate/output/{lang}.json`
|
|
||||||
**Output:** `stage-3-enrich/output/round1/{lang}_{model}.json` per run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline enrich --round 1 --model {model}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Compiling candidates**
|
|
||||||
|
|
||||||
Once all round 1 runs are complete, compile all generated candidates into a single structured file per language. This is the input to round 2.
|
|
||||||
|
|
||||||
**Input:** `stage-3-enrich/output/round1/{lang}_{model}.json`
|
|
||||||
**Output:** `stage-3-enrich/output/candidates/{lang}_candidates.json`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline enrich --compile-candidates
|
|
||||||
```
|
|
||||||
|
|
||||||
**Round 2 — voting**
|
|
||||||
|
|
||||||
Each model receives the compiled candidate list for every word and votes on:
|
|
||||||
|
|
||||||
- The best gloss candidate (if multiple exist)
|
|
||||||
- The best description candidate (if multiple exist)
|
|
||||||
- The best usage examples candidate (if multiple exist)
|
|
||||||
- A CEFR level vote for each translation
|
|
||||||
|
|
||||||
OMW data is not put to a vote — it automatically wins over any LLM-generated candidate. Round 2 only resolves conflicts between model-generated candidates. The prompt is kept small — one word at a time, a clean numbered candidate list — to fit within a limited context window.
|
|
||||||
|
|
||||||
**Input:** `stage-3-enrich/output/candidates/{lang}_candidates.json`
|
|
||||||
**Output:** `stage-3-enrich/output/round2/{lang}_{model}.json` per run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline enrich --round 2 --model {model}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Compiling votes**
|
|
||||||
|
|
||||||
Once all round 2 runs are complete, compile all votes into a single file per language. This is the input to the merge stage.
|
|
||||||
|
|
||||||
**Input:** `stage-3-enrich/output/round2/{lang}_{model}.json`
|
|
||||||
**Output:** `stage-3-enrich/output/votes/{lang}_votes.json`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline enrich --compile-votes
|
|
||||||
```
|
|
||||||
|
|
||||||
Each record in the votes file looks like this:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_id": "omw-en-12345",
|
|
||||||
"pos": "noun",
|
|
||||||
"translations": {
|
|
||||||
"en": [
|
|
||||||
{
|
|
||||||
"text": "dog",
|
|
||||||
"votes": { "cefr_source": "A1", "model_1": "A1", "model_2": "A1" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text": "canine",
|
|
||||||
"votes": { "cefr_source": "B2", "model_1": "B2", "model_2": "B1" }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"it": [
|
|
||||||
{
|
|
||||||
"text": "cane",
|
|
||||||
"votes": { "cefr_source": "A1", "model_1": "A1", "model_2": "A1" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"glosses": {
|
|
||||||
"en": { "text": "a domesticated carnivorous mammal", "source": "omw" },
|
|
||||||
"fr": {
|
|
||||||
"candidates": [
|
|
||||||
{ "text": "un mammifère carnivore domestiqué", "source": "model_1" },
|
|
||||||
{ "text": "un animal domestique carnivore", "source": "model_2" }
|
|
||||||
],
|
|
||||||
"votes": { "model_1": 1, "model_2": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"examples": {
|
|
||||||
"en": [{ "text": "the dog barked at the stranger", "source": "omw" }],
|
|
||||||
"fr": {
|
|
||||||
"candidates": [
|
|
||||||
{ "text": "le chien a aboyé", "source": "model_1" },
|
|
||||||
{ "text": "le chien gardait la maison", "source": "model_2" }
|
|
||||||
],
|
|
||||||
"votes": { "model_1": 2, "model_2": 1 }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"descriptions": {
|
|
||||||
"en": {
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"text": "a common household pet known for loyalty",
|
|
||||||
"source": "model_1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"text": "a domesticated animal and loyal companion",
|
|
||||||
"source": "model_2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"votes": { "model_1": 2, "model_2": 1 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Merge
|
|
||||||
|
|
||||||
Reads the votes file per language and resolves the final value for every field. Produces two output files per language — fully resolved records ready for seeding, and flagged records that need manual review.
|
|
||||||
|
|
||||||
**Merge rules:**
|
|
||||||
|
|
||||||
- OMW data wins automatically and is never overridden
|
|
||||||
- For CEFR levels: the level with the most votes wins. If no majority is reached, that translation is flagged
|
|
||||||
- For LLM-generated text fields (gloss, examples, descriptions): the candidate with the most votes wins
|
|
||||||
|
|
||||||
<!-- TODO: decide fallback strategy when no majority is reached for text fields -->
|
|
||||||
|
|
||||||
**Difficulty mapping:**
|
|
||||||
|
|
||||||
| CEFR | Difficulty |
|
|
||||||
| ------ | ------------ |
|
|
||||||
| A1, A2 | easy |
|
|
||||||
| B1, B2 | intermediate |
|
|
||||||
| C1, C2 | hard |
|
|
||||||
|
|
||||||
**Input:** `stage-3-enrich/output/votes/{lang}_votes.json`
|
|
||||||
**Output:**
|
|
||||||
|
|
||||||
- `stage-4-merge/output/final/{lang}.json` — fully resolved, ready for seeding
|
|
||||||
- `stage-4-merge/output/flagged/{lang}.json` — CEFR majority not reached, needs manual review before seeding
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline merge
|
|
||||||
```
|
|
||||||
|
|
||||||
Each record in `final/{lang}.json` looks like this:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source_id": "omw-en-12345",
|
|
||||||
"pos": "noun",
|
|
||||||
"translations": {
|
|
||||||
"en": [
|
|
||||||
{ "text": "dog", "cefr_level": "A1", "difficulty": "easy" },
|
|
||||||
{ "text": "canine", "cefr_level": "B2", "difficulty": "intermediate" }
|
|
||||||
],
|
|
||||||
"it": [{ "text": "cane", "cefr_level": "A1", "difficulty": "easy" }]
|
|
||||||
},
|
|
||||||
"glosses": {
|
|
||||||
"en": { "text": "a domesticated carnivorous mammal", "source": "omw" },
|
|
||||||
"fr": { "text": "un mammifère carnivore domestiqué", "source": "model_1" }
|
|
||||||
},
|
|
||||||
"examples": {
|
|
||||||
"en": [{ "text": "the dog barked at the stranger", "source": "omw" }],
|
|
||||||
"fr": [{ "text": "le chien a aboyé", "source": "model_1" }]
|
|
||||||
},
|
|
||||||
"descriptions": {
|
|
||||||
"en": {
|
|
||||||
"text": "a common household pet known for loyalty and companionship",
|
|
||||||
"source": "model_1"
|
|
||||||
},
|
|
||||||
"it": {
|
|
||||||
"text": "un animale domestico comune noto per la sua fedeltà",
|
|
||||||
"source": "model_2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolving flagged words:**
|
|
||||||
|
|
||||||
Open `stage-4-merge/output/flagged/{lang}.json`, manually set the correct `cefr_level` and `difficulty` for each flagged translation, then move the resolved entries into `stage-4-merge/output/final/{lang}.json`. Re-run the seeder after resolving.
|
|
||||||
|
|
||||||
### 5. Compare / QA
|
|
||||||
|
|
||||||
Read-only. Generates `COVERAGE.md` with a full breakdown of the pipeline
|
|
||||||
output quality per language. Run this after merge to verify output before
|
|
||||||
seeding the database.
|
|
||||||
|
|
||||||
**Input:**
|
|
||||||
|
|
||||||
- `stage-4-merge/output/final/{lang}.json`
|
|
||||||
- `stage-4-merge/output/flagged/{lang}.json`
|
|
||||||
|
|
||||||
**Output:** `COVERAGE.md`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @lila/pipeline compare
|
|
||||||
```
|
|
||||||
|
|
||||||
`COVERAGE.md` reports the following per language:
|
|
||||||
|
|
||||||
- Total synsets extracted
|
|
||||||
- Total translations per language
|
|
||||||
- POS breakdown per language — word counts for noun, verb, adjective, adverb
|
|
||||||
- CEFR coverage per language — how many translations have a resolved CEFR level, broken down by level (A1, A2, B1, B2, C1, C2)
|
|
||||||
- Difficulty breakdown per language — word counts for easy, intermediate, hard
|
|
||||||
- Flagged count per language — how many translations are awaiting manual review
|
|
||||||
- Gloss coverage per language — total glosses, broken down by source (omw vs LLM-generated) and which languages have no glosses at all
|
|
||||||
- Example coverage per language — same breakdown as glosses
|
|
||||||
- Description coverage per language — how many translations have a description, broken down by source
|
|
||||||
- CEFR source file coverage per language — how many words from the source file were matched against OMW translations
|
|
||||||
- LLM model contribution — how many CEFR votes and text candidates each anonymised model contributed
|
|
||||||
|
|
||||||
## Adding a new language
|
|
||||||
|
|
||||||
1. Add the language code to `SUPPORTED_LANGUAGE_CODES` in `packages/shared/src/constants.ts`
|
|
||||||
2. Build shared: `pnpm --filter @lila/shared build`
|
|
||||||
3. Generate and run a DB migration: `pnpm --filter @lila/db generate` then `pnpm --filter @lila/db migrate`
|
|
||||||
4. Download the OMW lexicon for the language using the `wn` Python library
|
|
||||||
5. Add a CEFR source file at `stage-2-annotate/sources/cefr/{lang}.json`
|
|
||||||
6. Run the full pipeline
|
|
||||||
|
|
||||||
## Constants and constraints
|
|
||||||
|
|
||||||
These values are defined in `packages/shared/src/constants.ts` and enforced by database check constraints. The pipeline filters out any entries that violate them.
|
|
||||||
|
|
||||||
| Constant | Values |
|
|
||||||
| --------------- | ------------------------------------- |
|
|
||||||
| Languages | `en`, `it`, `de`, `es`, `fr` |
|
|
||||||
| Parts of speech | `noun`, `verb`, `adjective`, `adverb` |
|
|
||||||
| CEFR levels | `A1`, `A2`, `B1`, `B2`, `C1`, `C2` |
|
|
||||||
| Difficulty | `easy`, `intermediate`, `hard` |
|
|
||||||
|
|
||||||
Adding a new value to any of these requires a constants update and a database migration before re-running the pipeline. See **Adding a new language** for the full steps — the same process applies for new parts of speech.
|
|
||||||
|
|
||||||
## Further extensions
|
|
||||||
|
|
||||||
These are not part of the current pipeline but are worth considering as the
|
|
||||||
dataset matures:
|
|
||||||
|
|
||||||
- **Grammatical gender and articles** — Wiktionary dumps contain gender and
|
|
||||||
article data for nouns across all supported languages. Could be extracted
|
|
||||||
and stored as a new `translation_forms` table.
|
|
||||||
- **Conjugations** — Wiktionary also carries verb conjugation tables. Useful
|
|
||||||
for a future grammar-focused quiz mode.
|
|
||||||
- **IPA pronunciations** — Wiktionary and Forvo are potential sources for
|
|
||||||
phonetic transcriptions per language.
|
|
||||||
- **TTS audio files** — Generate pronunciation audio for each translation
|
|
||||||
using a local or cloud TTS engine. Stored as static files, served alongside
|
|
||||||
the quiz UI.
|
|
||||||
- **Images** — Associate an image with each synset to support visual
|
|
||||||
vocabulary learning. Could be sourced from open image datasets like
|
|
||||||
ImageNet or WikiMedia Commons.
|
|
||||||
- **Frequency data** — Word frequency rankings per language from sources like
|
|
||||||
the Google Ngram dataset. Useful for smarter difficulty calibration beyond
|
|
||||||
CEFR levels alone.
|
|
||||||
- **Improved CEFR source files** — See note at the top of this document.
|
|
||||||
UniversalCEFR and CEFR-J are good starting points.
|
|
||||||
- **Additional languages** — The pipeline is language-agnostic. Adding a new
|
|
||||||
language requires an OMW lexicon, a CEFR source file, and a constants
|
|
||||||
update. See **Adding a new language**.
|
|
||||||
|
|
@ -144,7 +144,6 @@ docker system prune -a # aggressive — removes all unused images
|
||||||
### API (`apps/api/Dockerfile`)
|
### API (`apps/api/Dockerfile`)
|
||||||
|
|
||||||
Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration.
|
Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration.
|
||||||
The runner stage copies compiled migration files from the builder (packages/db/drizzle) alongside the application code. The container entrypoint runs migrate.js first, then starts server.js, ensuring schema and code are always in sync on every deploy.
|
|
||||||
|
|
||||||
### Frontend (`apps/web/Dockerfile`)
|
### Frontend (`apps/web/Dockerfile`)
|
||||||
|
|
||||||
|
|
@ -175,7 +174,12 @@ The seeding script (`packages/db/src/seeding-datafiles.ts`) uses `onConflictDoNo
|
||||||
|
|
||||||
### Schema Migrations
|
### Schema Migrations
|
||||||
|
|
||||||
Migrations are run automatically on container startup via the CMD in the API Dockerfile. The entrypoint runs migrate.js before starting the server, so the schema is always up to date before the API begins accepting requests. The correct deploy order is enforced automatically.
|
Schema changes are managed by Drizzle. Deploy order matters:
|
||||||
|
|
||||||
|
1. Run migration first (database gets new structure)
|
||||||
|
2. Deploy new API image (code uses new structure)
|
||||||
|
|
||||||
|
Reversing this order causes the API to crash on missing columns/tables.
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
|
|
@ -221,59 +225,9 @@ Host git.lilastudy.com
|
||||||
|
|
||||||
This allows standard git commands without specifying the port.
|
This allows standard git commands without specifying the port.
|
||||||
|
|
||||||
## CI/CD Pipeline
|
|
||||||
|
|
||||||
Automated build and deploy via Forgejo Actions. On every push to `main`, the pipeline builds ARM64 images natively on the VPS, pushes them to the Forgejo registry, and restarts the app containers.
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
- **Forgejo Actions** — enabled by default, workflow files in `.forgejo/workflows/`
|
|
||||||
- **Forgejo Runner** — runs as a container (`lila-ci-runner`) on the VPS, uses the host's Docker socket to build images natively on ARM64
|
|
||||||
- **Workflow file** — `.forgejo/workflows/deploy.yml`
|
|
||||||
|
|
||||||
### Pipeline Steps
|
|
||||||
|
|
||||||
1. Install Docker CLI and SSH client in the job container
|
|
||||||
2. Checkout the repository
|
|
||||||
3. Login to the Forgejo container registry
|
|
||||||
4. Build API image (target: `runner`)
|
|
||||||
5. Build Web image (target: `production`, with `VITE_API_URL` baked in)
|
|
||||||
6. Push both images to `git.lilastudy.com`
|
|
||||||
7. SSH into the VPS, pull new images, restart `api` and `web` containers, prune old images
|
|
||||||
|
|
||||||
### Secrets (stored in Forgejo repo settings → Actions → Secrets)
|
|
||||||
|
|
||||||
| Secret | Value |
|
|
||||||
| ----------------- | ----------------------------------------- |
|
|
||||||
| REGISTRY_USER | Forgejo username |
|
|
||||||
| REGISTRY_PASSWORD | Forgejo password |
|
|
||||||
| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS |
|
|
||||||
| SSH_HOST | VPS IP address |
|
|
||||||
| SSH_USER | `lila` |
|
|
||||||
|
|
||||||
### Runner Configuration
|
|
||||||
|
|
||||||
The runner config is at `/data/config.yml` inside the `lila-ci-runner` container. Key settings:
|
|
||||||
|
|
||||||
- `docker_host: "automount"` — mounts the host Docker socket into job containers
|
|
||||||
- `valid_volumes: ["/var/run/docker.sock"]` — allows the socket mount
|
|
||||||
- `privileged: true` — required for Docker access from job containers
|
|
||||||
- `options: "--group-add 989"` — adds the host's docker group (GID 989) to job containers
|
|
||||||
|
|
||||||
The runner command must explicitly reference the config file:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
command: '/bin/sh -c "sleep 5; forgejo-runner -c /data/config.yml daemon"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy Cycle
|
|
||||||
|
|
||||||
Push to main → pipeline runs automatically (~2-5 min) → app is updated. No manual steps required.
|
|
||||||
|
|
||||||
To manually trigger a re-run: go to the repo's Actions tab, click on the latest run, and use the re-run button.
|
|
||||||
|
|
||||||
## Known Issues and Future Work
|
## Known Issues and Future Work
|
||||||
|
|
||||||
|
- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation)
|
||||||
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
|
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
|
||||||
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
|
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
|
||||||
- **Monitoring/logging**: No centralized logging or uptime monitoring configured
|
- **Monitoring/logging**: No centralized logging or uptime monitoring configured
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# design
|
|
||||||
|
|
||||||
## notes
|
|
||||||
|
|
||||||
break points
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
# LLM Setup — lila pipeline
|
|
||||||
|
|
||||||
This document covers the LLM infrastructure for stage 3 (enrich) of the lila
|
|
||||||
data pipeline. It documents the hardware constraints, supported providers,
|
|
||||||
model recommendations, and how to configure and swap providers in the test
|
|
||||||
and production scripts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hardware (dev machine)
|
|
||||||
|
|
||||||
| Component | Spec |
|
|
||||||
| --------- | --------------------------------------------------------------- |
|
|
||||||
| CPU | Intel Core i7-6500U (2 cores / 4 threads @ 3.10 GHz) |
|
|
||||||
| RAM | 8 GB |
|
|
||||||
| GPU | NVIDIA GeForce GTX 950M — 4 GB VRAM (Maxwell, CUDA compute 5.0) |
|
|
||||||
| OS | Debian GNU/Linux 13 (trixie) x86_64 |
|
|
||||||
|
|
||||||
**Local inference verdict:** viable for small/quantized models, not for
|
|
||||||
production runs. See the [Local inference](#local-inference-llamacpp) section
|
|
||||||
for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider overview
|
|
||||||
|
|
||||||
The enrich script uses a single, swappable provider config. All providers
|
|
||||||
except Anthropic expose an OpenAI-compatible API, so the same client code
|
|
||||||
works across all of them — only `baseURL`, `apiKey`, and `model` change.
|
|
||||||
|
|
||||||
| Provider | Use case | Cost | Rate limits |
|
|
||||||
| ---------------------- | --------------------------------------------- | ------------------ | ---------------------- |
|
|
||||||
| llama.cpp (local) | Quality testing, overnight dev runs | Free (electricity) | None |
|
|
||||||
| OpenRouter (free tier) | Quality comparison, multi-model evaluation | Free | 50 req/day, 20 req/min |
|
|
||||||
| OpenRouter (paid) | Production runs if local quality insufficient | Pay-per-token | None |
|
|
||||||
| Anthropic API | Quality baseline / reference | Pay-per-token | Standard |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Local inference (llama.cpp)
|
|
||||||
|
|
||||||
### Why local inference is worth testing
|
|
||||||
|
|
||||||
Time is not a constraint — the pipeline scripts are fully resumable. The
|
|
||||||
laptop can run overnight for multiple nights. The only question is output
|
|
||||||
quality, which the test script evaluates empirically.
|
|
||||||
|
|
||||||
### Hardware constraints
|
|
||||||
|
|
||||||
The GTX 950M has 4 GB VRAM and Maxwell architecture (CUDA compute 5.0).
|
|
||||||
llama.cpp supports Maxwell via CUDA backend but newer builds may require
|
|
||||||
the `--cuda-no-kv-offload` flag depending on the version.
|
|
||||||
|
|
||||||
llama.cpp splits model layers between GPU and CPU automatically via
|
|
||||||
`--n-gpu-layers`. You set how many layers go on the GPU; the rest run on
|
|
||||||
CPU/RAM. This means a model larger than VRAM is not a dead end — it runs
|
|
||||||
in hybrid mode, slower than full-GPU but much faster than pure CPU.
|
|
||||||
|
|
||||||
Practical estimates for this hardware (~3.5 GB VRAM usable after drivers):
|
|
||||||
|
|
||||||
| Model size | Q4 VRAM | Mode | Est. speed |
|
|
||||||
| ---------- | ------- | ----------------------------- | ------------ |
|
|
||||||
| 3B | ~2.0 GB | Full GPU | ~15–20 tok/s |
|
|
||||||
| 4B | ~2.5 GB | Full GPU | ~12–18 tok/s |
|
|
||||||
| 7B | ~4.5 GB | Hybrid (~26/32 layers on GPU) | ~8–12 tok/s |
|
|
||||||
| 13B+ | ~8 GB+ | CPU-heavy hybrid | too slow |
|
|
||||||
|
|
||||||
### Recommended local models
|
|
||||||
|
|
||||||
Two candidates worth testing, covering different points on the size/quality
|
|
||||||
tradeoff:
|
|
||||||
|
|
||||||
**Gemma 4 E4B Instruct (Q4 / UD-Q4_K_XL)**
|
|
||||||
|
|
||||||
- GGUF file: `gemma-4-E4B-it-UD-Q4_K_XL.gguf` (~2.5 GB)
|
|
||||||
- Source: https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF
|
|
||||||
- Runs fully on GPU. Brand new (April 2025), built for edge hardware, 140+
|
|
||||||
language support including all five pipeline languages. First candidate
|
|
||||||
to test.
|
|
||||||
|
|
||||||
**Qwen2.5 7B Instruct (Q4_K_M)**
|
|
||||||
|
|
||||||
- GGUF file: `Qwen2.5-7B-Instruct-Q4_K_M.gguf` (~4.5 GB)
|
|
||||||
- Source: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF
|
|
||||||
- Runs in hybrid mode (~26 of 32 layers on GPU, rest on CPU), ~8–12 tok/s.
|
|
||||||
Stronger multilingual generation than any 3–4B model. Second candidate,
|
|
||||||
for comparison against the smaller Gemma 4 E4B.
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install build dependencies
|
|
||||||
sudo apt install build-essential cmake git
|
|
||||||
|
|
||||||
# Clone llama.cpp
|
|
||||||
git clone https://github.com/ggerganov/llama.cpp
|
|
||||||
cd llama.cpp
|
|
||||||
|
|
||||||
# Build with CUDA support (GTX 950M — compute 5.0)
|
|
||||||
cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=50
|
|
||||||
cmake --build build --config Release -j$(nproc)
|
|
||||||
|
|
||||||
# Download model (example — adjust path as needed)
|
|
||||||
mkdir -p models
|
|
||||||
wget -O models/qwen2.5-3b-instruct-q4_k_m.gguf \
|
|
||||||
https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF/resolve/main/qwen2.5-3b-instruct-q4_k_m.gguf
|
|
||||||
```
|
|
||||||
|
|
||||||
### Starting the server
|
|
||||||
|
|
||||||
**Gemma 4 E4B** (full GPU):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build/bin/llama-server \
|
|
||||||
--model models/gemma-4-e4b-it-ud-q4_k_xl.gguf \
|
|
||||||
--port 8080 \
|
|
||||||
--ctx-size 4096 \
|
|
||||||
--n-gpu-layers 999 \
|
|
||||||
--host 127.0.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Qwen2.5 7B** (hybrid — tune `--n-gpu-layers` to fit your VRAM):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build/bin/llama-server \
|
|
||||||
--model models/qwen2.5-7b-instruct-q4_k_m.gguf \
|
|
||||||
--port 8080 \
|
|
||||||
--ctx-size 4096 \
|
|
||||||
--n-gpu-layers 28 \
|
|
||||||
--host 127.0.0.1
|
|
||||||
```
|
|
||||||
|
|
||||||
`--n-gpu-layers 999` means "put everything on GPU" — llama.cpp caps at the
|
|
||||||
actual layer count automatically, so 999 is safe as a "full offload" value.
|
|
||||||
For the 7B hybrid, start with `28` and reduce by 2 if the server reports
|
|
||||||
out-of-memory at startup.
|
|
||||||
|
|
||||||
### Verify the server is running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8080/health
|
|
||||||
# Expected: {"status":"ok"}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenRouter (free tier)
|
|
||||||
|
|
||||||
OpenRouter exposes all models via an OpenAI-compatible API. No code changes
|
|
||||||
are needed to switch from local llama.cpp to OpenRouter — only the config
|
|
||||||
object changes.
|
|
||||||
|
|
||||||
### Rate limits (free tier)
|
|
||||||
|
|
||||||
- **50 requests per day** (account total, not per model)
|
|
||||||
- 20 requests per minute
|
|
||||||
|
|
||||||
> **Implication for testing:** with a 10-record test set you have headroom
|
|
||||||
> to test 4–5 models per day. With a 100-record test set, plan one model per
|
|
||||||
> day.
|
|
||||||
|
|
||||||
> **Implication for production:** the free tier is not viable for 117k
|
|
||||||
> records. If local quality is insufficient, use paid OpenRouter credits or
|
|
||||||
> a dedicated provider.
|
|
||||||
|
|
||||||
### Free models recommended for this pipeline
|
|
||||||
|
|
||||||
Ranked by expected multilingual generation quality for en/it/de/fr/es:
|
|
||||||
|
|
||||||
| Model ID | Params | Notes |
|
|
||||||
| ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| `qwen/qwen3-coder:free` | 480B MoE (35B active) | Best free option. Strong multilingual despite "coder" label. Use as quality ceiling. |
|
|
||||||
| `qwen/qwen3-next-80b-a3b-instruct:free` | 80B MoE (3B active) | Smaller Qwen, useful comparison point. |
|
|
||||||
| `nvidia/nemotron-3-super-120b-a12b:free` | 120B MoE (12B active) | 262K context, supports structured output. |
|
|
||||||
| `google/gemma-4-31b-it:free` | 31B | 140+ language support, good European language coverage. |
|
|
||||||
| `zhipuai/glm-4.5-air:free` | MoE | Multilingual-focused. |
|
|
||||||
|
|
||||||
**Skip for this pipeline:**
|
|
||||||
|
|
||||||
- Llama models — weaker European language generation than Qwen/Gemma
|
|
||||||
- Mistral free tier — requests may be used for model training
|
|
||||||
|
|
||||||
### API endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
https://openrouter.ai/api/v1/chat/completions
|
|
||||||
```
|
|
||||||
|
|
||||||
Set `Authorization: Bearer <OPENROUTER_API_KEY>` in the request headers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provider configuration in the test script
|
|
||||||
|
|
||||||
The enrich test script reads a single config object. To switch providers,
|
|
||||||
change this object and re-run.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// config.ts
|
|
||||||
|
|
||||||
export type ProviderConfig = {
|
|
||||||
name: string; // used for output folder naming
|
|
||||||
baseURL: string;
|
|
||||||
apiKey: string;
|
|
||||||
model: string;
|
|
||||||
maxTokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Local llama.cpp
|
|
||||||
export const LOCAL_QWEN3B: ProviderConfig = {
|
|
||||||
name: "local-qwen2.5-3b",
|
|
||||||
baseURL: "http://127.0.0.1:8080/v1",
|
|
||||||
apiKey: "none", // llama.cpp ignores this
|
|
||||||
model: "qwen2.5-3b", // llama.cpp ignores model name, uses loaded model
|
|
||||||
maxTokens: 512,
|
|
||||||
};
|
|
||||||
|
|
||||||
// OpenRouter — Qwen3 480B (free)
|
|
||||||
export const OR_QWEN3_480B: ProviderConfig = {
|
|
||||||
name: "or-qwen3-480b",
|
|
||||||
baseURL: "https://openrouter.ai/api/v1",
|
|
||||||
apiKey: process.env.OPENROUTER_API_KEY!,
|
|
||||||
model: "qwen/qwen3-coder:free",
|
|
||||||
maxTokens: 512,
|
|
||||||
};
|
|
||||||
|
|
||||||
// OpenRouter — Gemma 4 31B (free)
|
|
||||||
export const OR_GEMMA4_31B: ProviderConfig = {
|
|
||||||
name: "or-gemma4-31b",
|
|
||||||
baseURL: "https://openrouter.ai/api/v1",
|
|
||||||
apiKey: process.env.OPENROUTER_API_KEY!,
|
|
||||||
model: "google/gemma-4-31b-it:free",
|
|
||||||
maxTokens: 512,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Anthropic (reference baseline — different adapter required)
|
|
||||||
export const ANTHROPIC_SONNET: ProviderConfig = {
|
|
||||||
name: "anthropic-sonnet",
|
|
||||||
baseURL: "https://api.anthropic.com/v1", // adapter handles format difference
|
|
||||||
apiKey: process.env.ANTHROPIC_API_KEY!,
|
|
||||||
model: "claude-sonnet-4-6",
|
|
||||||
maxTokens: 512,
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Output from each run lands in:
|
|
||||||
|
|
||||||
```
|
|
||||||
stage-3-enrich/test/output/{provider.name}/results.json
|
|
||||||
stage-3-enrich/test/output/{provider.name}/metrics.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The evaluate script compares all `metrics.json` files side by side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Evaluation metrics
|
|
||||||
|
|
||||||
The test script measures the following per provider run:
|
|
||||||
|
|
||||||
| Metric | What it measures |
|
|
||||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| **JSON parse rate** | % of responses that are valid, schema-compliant JSON. Critical — a failed parse is a wasted call. Target: >97% |
|
|
||||||
| **Field coverage** | % of records where all required fields are present (cefr votes for all translations, descriptions for all languages, glosses/examples for fr/es) |
|
|
||||||
| **CEFR agreement** | For records that have a `cefr_source` vote, % where the model agrees. Measures calibration. |
|
|
||||||
| **Language correctness** | Manual spot-check only — automated detection not reliable enough |
|
|
||||||
| **Tokens/second** | Local only. Indicates overnight run feasibility |
|
|
||||||
|
|
||||||
### Decision thresholds
|
|
||||||
|
|
||||||
| Metric | Threshold | Action if below |
|
|
||||||
| --------------- | --------- | ---------------------------------------------- |
|
|
||||||
| JSON parse rate | < 97% | Do not use this model for production |
|
|
||||||
| Field coverage | < 95% | Prompt needs revision before production |
|
|
||||||
| CEFR agreement | < 70% | Model lacks vocabulary knowledge for this task |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended test sequence
|
|
||||||
|
|
||||||
1. **Start local, minimal dataset (5–10 records)**
|
|
||||||
Install llama.cpp, run Qwen2.5 3B against 5–10 hand-picked records.
|
|
||||||
Verify the server works, the output parses, and the model produces
|
|
||||||
something reasonable. This is purely a smoke test.
|
|
||||||
|
|
||||||
2. **Expand local to full 100-record sample**
|
|
||||||
Once the pipeline is confirmed working, run all 100 records locally.
|
|
||||||
Collect metrics. This is your local quality baseline.
|
|
||||||
|
|
||||||
3. **Run the same 100 records through OpenRouter free models**
|
|
||||||
One model per day (50 req/day limit). Start with `qwen/qwen3-coder:free`
|
|
||||||
as the quality ceiling.
|
|
||||||
|
|
||||||
4. **Compare metrics side by side**
|
|
||||||
If local 3B is within acceptable range of the cloud models on CEFR
|
|
||||||
agreement and field coverage, proceed with local overnight runs for
|
|
||||||
production. If not, use the cloud model that passed.
|
|
||||||
|
|
||||||
5. **Production run**
|
|
||||||
Full 117k records. Resume-safe — the script checkpoints after each
|
|
||||||
record so overnight runs can be stopped and continued.
|
|
||||||
|
|
@ -1,27 +1,9 @@
|
||||||
# notes
|
# notes
|
||||||
|
|
||||||
## prompt
|
|
||||||
|
|
||||||
ive attached the readme of my project. this is my current task:
|
|
||||||
|
|
||||||
task description.
|
|
||||||
|
|
||||||
1. tell me which files you need to see to get the full context of the problem
|
|
||||||
2. walk me text-only through the problem and the solution
|
|
||||||
3. if we need to update multiple files: lets go through them one by one, no matter how many files
|
|
||||||
4. if we go through a file, we'll do it slowly section by section, no matter how many sections
|
|
||||||
5. how to name the current feature branch? also tell me when its time to git commit and provide a commit message
|
|
||||||
6. if we have multiple options to do something, also always provide options that reflect current industry standards and best practices
|
|
||||||
7. never assume anything! always ask for clarification!
|
|
||||||
8. For every completed task, produce a ticket file in documentation/tickets/. Use ADR format (adr-) for decisions between options with long-term consequences. Use feat-/fix-/chore- for routine tasks. Always include a setup guide or summary of what was done. Suggest the filename.
|
|
||||||
|
|
||||||
## tasks
|
## tasks
|
||||||
|
|
||||||
- **IMPORTANT** db migrations have to be part of the deployment pipeline!!!!!!!!!!!!!!!!!!
|
|
||||||
- put users in separate db
|
|
||||||
- pinning dependencies in package.json files
|
- pinning dependencies in package.json files
|
||||||
- rethink organisation of datafiles and wordlists
|
- rethink organisation of datafiles and wordlists
|
||||||
- admin dashboard for user management, also overview of words and languages and all their stats
|
|
||||||
|
|
||||||
## problems+thoughts
|
## problems+thoughts
|
||||||
|
|
||||||
|
|
@ -46,18 +28,6 @@ laptop: verify if docker containers run on startup (they shouldnt)
|
||||||
### vps setup
|
### vps setup
|
||||||
|
|
||||||
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
|
||||||
<<<<<<< HEAD
|
|
||||||
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
|
|
||||||
|
|
||||||
### ~~cd/ci pipeline~~ ✅ RESOLVED
|
|
||||||
|
|
||||||
Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `deployment.md`.
|
|
||||||
|
|
||||||
### ~~postgres backups~~ ✅ RESOLVED
|
|
||||||
|
|
||||||
# Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
|
|
||||||
|
|
||||||
> > > > > > > dev
|
|
||||||
|
|
||||||
### try now option
|
### try now option
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Each phase produces a working increment. Nothing is built speculatively.
|
||||||
- [x] Configure Drizzle ORM + connection to local PostgreSQL
|
- [x] Configure Drizzle ORM + connection to local PostgreSQL
|
||||||
- [x] Write first migration (empty — validates the pipeline works)
|
- [x] Write first migration (empty — validates the pipeline works)
|
||||||
- [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
|
- [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
|
||||||
- [x] Root `.env.example` for local dev (`docker-compose.yml` + API)
|
- [x] `.env.example` files for `apps/api` and `apps/web`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -176,36 +176,37 @@ _Note: Deployment was moved ahead of multiplayer — the app is useful without m
|
||||||
**Goal:** Players can create and join rooms; the host sees all joined players in real time.
|
**Goal:** Players can create and join rooms; the host sees all joined players in real time.
|
||||||
**Done when:** Two browser tabs can join the same room and see each other's display names update live via WebSocket.
|
**Done when:** Two browser tabs can join the same room and see each other's display names update live via WebSocket.
|
||||||
|
|
||||||
- [x] Write Drizzle schema: `lobbies`, `lobby_players`
|
- [ ] Write Drizzle schema: `rooms`, `room_players`
|
||||||
- [x] Write and run migration
|
- [ ] Write and run migration
|
||||||
- [x] `POST /api/v1/lobbies` and `POST /api/v1/lobbies/:code/join` REST endpoints
|
- [ ] `POST /rooms` and `POST /rooms/:code/join` REST endpoints
|
||||||
- [x] `LobbyService`: create lobby with Crockford Base32 code, join lobby, enforce max player limit
|
- [ ] `RoomService`: create room with short code, join room, enforce max player limit
|
||||||
- [x] WebSocket server: attach `ws` upgrade handler to Express HTTP server
|
- [ ] WebSocket server: attach `ws` upgrade handler to Express HTTP server
|
||||||
- [x] WS auth middleware: validate Better Auth session on upgrade
|
- [ ] WS auth middleware: validate JWT on upgrade
|
||||||
- [x] WS message router: dispatch by `type` via Zod discriminated union
|
- [ ] WS message router: dispatch by `type`
|
||||||
- [x] `lobby:join` / `lobby:leave` handlers → broadcast `lobby:state`
|
- [ ] `room:join` / `room:leave` handlers → broadcast `room:state`
|
||||||
- [x] Lobby membership tracked in PostgreSQL (durable), game state in-memory (Valkey deferred)
|
- [ ] Room membership tracked in Valkey (ephemeral) + PostgreSQL (durable)
|
||||||
- [x] Define all WS event Zod schemas in `packages/shared`
|
- [ ] Define all WS event Zod schemas in `packages/shared`
|
||||||
- [x] Frontend: `/multiplayer` — create lobby + join-by-code
|
- [ ] Frontend: `/multiplayer/lobby` — create room + join-by-code
|
||||||
- [x] Frontend: `/multiplayer/lobby/:code` — player list, lobby code, "Start Game" (host only)
|
- [ ] Frontend: `/multiplayer/room/:code` — player list, room code, "Start Game" (host only)
|
||||||
- [x] Frontend: WS client class with typed message handlers
|
- [ ] Frontend: WS client singleton with reconnect
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5 — Multiplayer Game
|
## Phase 5 — Multiplayer Game
|
||||||
|
|
||||||
**Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared.
|
**Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared.
|
||||||
**Done when:** 2–4 players complete a 3-round game with correct live scores and a winner screen.
|
**Done when:** 2–4 players complete a 10-round game with correct live scores and a winner screen.
|
||||||
|
|
||||||
- [x] `MultiplayerGameService`: generate question sequence, enforce 15s server timer
|
- [ ] `GameService`: generate question sequence, enforce 15s server timer
|
||||||
- [x] `lobby:start` WS handler → broadcast first `game:question`
|
- [ ] `room:start` WS handler → broadcast first `game:question`
|
||||||
- [x] `game:answer` WS handler → collect per-player answers
|
- [ ] `game:answer` WS handler → collect per-player answers
|
||||||
- [x] On all-answered or timeout → evaluate, broadcast `game:answer_result`
|
- [ ] On all-answered or timeout → evaluate, broadcast `game:answer_result`
|
||||||
- [x] After N rounds → broadcast `game:finished`, update DB (transactional)
|
- [ ] After N rounds → broadcast `game:finished`, update DB (transactional)
|
||||||
- [x] Frontend: `/multiplayer/game/:code` route
|
- [ ] Frontend: `/multiplayer/game/:code` route
|
||||||
- [x] Frontend: reuse `QuestionCard` + `OptionButton`; round results per player
|
- [ ] Frontend: reuse `QuestionCard` + `OptionButton`; add countdown timer
|
||||||
- [x] Frontend: `MultiplayerScoreScreen` — winner highlight, final scores, play again
|
- [ ] Frontend: `ScoreBoard` component — live per-player scores
|
||||||
- [x] Unit tests for `LobbyService`, WS auth, WS router
|
- [ ] Frontend: `GameFinished` screen — winner highlight, final scores, play again
|
||||||
|
- [ ] Unit tests for `GameService` (round evaluation, tie-breaking, timeout)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -235,7 +236,7 @@ Phase 0 (Foundation) ✅
|
||||||
└── Phase 2 (Singleplayer UI) ✅
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
├── Phase 3 (Auth) ✅
|
├── Phase 3 (Auth) ✅
|
||||||
│ └── Phase 6 (Deployment + CI/CD) ✅
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
└── Phase 4 (Multiplayer Lobby) ✅
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
└── Phase 5 (Multiplayer Game) ✅
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
|
|
||||||
|
|
||||||
> _"It works on my machine" is not a scalability strategy._
|
|
||||||
|
|
||||||
**Project:** lila — Vocabulary Trainer
|
|
||||||
**File Roasted:** `gameService.ts`
|
|
||||||
**Date:** $(date)
|
|
||||||
**Roaster:** Qwen3.6
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Executive Summary
|
|
||||||
|
|
||||||
| Metric | Score | Notes |
|
|
||||||
| ------------- | -------- | ---------------------------------------------------- |
|
|
||||||
| Code Quality | 8/10 | Clean layering, good types, consistent style |
|
|
||||||
| Correctness | 6/10 | Race condition + N+1 query are critical |
|
|
||||||
| Test Coverage | 7/10 | Good happy-path tests, missing concurrency tests |
|
|
||||||
| Scalability | 5/10 | Will choke at ~100 concurrent users without fixes |
|
|
||||||
| **Overall** | **7/10** | Solid foundation, but fix the footguns before launch |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Critical Issues (Fix Before Production)
|
|
||||||
|
|
||||||
### 1. Race Condition: Lost Update in `evaluateAnswer`
|
|
||||||
|
|
||||||
**Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()`
|
|
||||||
|
|
||||||
// Current flow (VULNERABLE):
|
|
||||||
const session = await store.get(submission.sessionId); // READ
|
|
||||||
const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
|
|
||||||
updatedAnswers.delete(submission.questionId);
|
|
||||||
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
|
|
||||||
|
|
||||||
The Attack:
|
|
||||||
|
|
||||||
Client submits answer A and answer B for the same question (network retry, bug, or malice)
|
|
||||||
Both requests read the same session.answers Map (question still present)
|
|
||||||
Both delete the question from their local copy
|
|
||||||
Both write back → second write overwrites first
|
|
||||||
Result: One answer is silently lost, session state desyncs
|
|
||||||
|
|
||||||
Why Tests Missed It: Vitest runs tests synchronously. Race conditions require deliberate concurrency testing.
|
|
||||||
Fix Options:
|
|
||||||
|
|
||||||
// Option A: Add atomic operation to store interface
|
|
||||||
interface GameSessionStore {
|
|
||||||
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option B: Use Valkey Lua script for atomic read-modify-write
|
|
||||||
// Option C: Optimistic locking with version numbers
|
|
||||||
|
|
||||||
Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb
|
|
||||||
Location: gameService.ts:24-26 + termModel.ts:getDistractors()
|
|
||||||
|
|
||||||
// For each of N terms, we call getDistractors():
|
|
||||||
const questions: GameQuestion[] = await Promise.all(
|
|
||||||
terms.map(async (term) => {
|
|
||||||
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
Impact Analysis:
|
|
||||||
Rounds
|
|
||||||
DB Queries
|
|
||||||
At 50 concurrent users
|
|
||||||
3
|
|
||||||
1 + 3 = 4
|
|
||||||
200 queries/min
|
|
||||||
10
|
|
||||||
1 + 10 = 11
|
|
||||||
550 queries/min
|
|
||||||
20
|
|
||||||
1 + 20 = 21
|
|
||||||
1,050 queries/min
|
|
||||||
Each getDistractors() runs:
|
|
||||||
|
|
||||||
SELECT text FROM terms
|
|
||||||
JOIN translations ON ...
|
|
||||||
WHERE pos = $1 AND difficulty = $2 AND term_id != $3 AND text != $4
|
|
||||||
ORDER BY RANDOM() LIMIT 6
|
|
||||||
|
|
||||||
Fix: Batch Fetch Distractors
|
|
||||||
|
|
||||||
// Fetch all distractors in ONE query
|
|
||||||
const allDistractors = await db
|
|
||||||
.select({ termId: terms.id, text: translations.text })
|
|
||||||
.from(terms)
|
|
||||||
.innerJoin(translations, /_ ... _/)
|
|
||||||
.where(and(
|
|
||||||
eq(terms.pos, pos),
|
|
||||||
eq(translations.difficulty, difficulty),
|
|
||||||
inArray(terms.id, termIds), // Batch!
|
|
||||||
))
|
|
||||||
.limit(DISTRACTOR_FETCH_COUNT \* termIds.length);
|
|
||||||
|
|
||||||
// Group by termId in JS, then slice to 3 unique distractors per term
|
|
||||||
const distractorsByTerm = groupByTermId(allDistractors);
|
|
||||||
|
|
||||||
Priority: 🔴 CRITICAL — Performance/scalability issue
|
|
||||||
|
|
||||||
3. Error Handling Inconsistency
|
|
||||||
Location: gameService.ts:33-36
|
|
||||||
|
|
||||||
if (uniqueDistractors.length < 3) {
|
|
||||||
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
|
|
||||||
}
|
|
||||||
|
|
||||||
Problem: Raw Error bypasses your errorHandler middleware:
|
|
||||||
|
|
||||||
No HTTP status mapping (defaults to 500)
|
|
||||||
No structured logging
|
|
||||||
Inconsistent API responses
|
|
||||||
|
|
||||||
Fix:
|
|
||||||
import { UnprocessableEntityError } from "../errors/AppError.js";
|
|
||||||
|
|
||||||
if (uniqueDistractors.length < 3) {
|
|
||||||
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
|
|
||||||
"insufficient_distractors");
|
|
||||||
throw new UnprocessableEntityError(
|
|
||||||
`Not enough unique distractors for term: ${term.targetText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Priority: 🟡 HIGH — Observability & UX issue
|
|
||||||
⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer
|
|
||||||
Compare: gameService.ts vs multiplayerGameService.ts
|
|
||||||
// gameService.ts
|
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
|
||||||
const shuffledTexts = shuffleArray(optionTexts);
|
|
||||||
const correctOptionId = shuffledTexts.indexOf(term.targetText);
|
|
||||||
|
|
||||||
// multiplayerGameService.ts (lines 35-45)
|
|
||||||
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
|
|
||||||
const shuffledTexts = shuffle(optionTexts); // Different function, same logic!
|
|
||||||
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
|
|
||||||
|
|
||||||
Risks:
|
|
||||||
|
|
||||||
Fix shuffle bias in one place, forget the other
|
|
||||||
Add new option type (e.g., etymology hint), update one service only
|
|
||||||
Harder to test core game logic in isolation
|
|
||||||
|
|
||||||
Fix: Extract pure function to @lila/shared or new @lila/game-logic:
|
|
||||||
|
|
||||||
// packages/shared/src/game-logic.ts
|
|
||||||
export const buildQuestionOptions = (
|
|
||||||
correctAnswer: string,
|
|
||||||
distractors: string[],
|
|
||||||
optionCount: number = 4
|
|
||||||
): { options: AnswerOption[]; correctOptionId: number } => {
|
|
||||||
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
|
|
||||||
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
|
|
||||||
const shuffled = shuffleSecure(optionTexts);
|
|
||||||
const correctOptionId = shuffled.indexOf(correctAnswer);
|
|
||||||
|
|
||||||
return {
|
|
||||||
options: shuffled.map((text, idx) => ({ optionId: idx, text })),
|
|
||||||
correctOptionId
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap
|
|
||||||
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
|
|
||||||
|
|
||||||
export const shuffleArray = <T>(array: T[]): T[] => {
|
|
||||||
for (let i = result.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() \* (i + 1)); // 🚩 Modulo bias + non-crypto RNG
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
The Math:
|
|
||||||
|
|
||||||
Math.random() has ~53 bits of entropy (fine for vocab)
|
|
||||||
Math.floor(rand * n) has modulo bias when n isn't a power of 2
|
|
||||||
For n=4: bias is ~0.01% (tiny, but non-zero)
|
|
||||||
|
|
||||||
When It Matters:
|
|
||||||
|
|
||||||
Competitive leaderboards ("option 0 is correct 26% of the time")
|
|
||||||
Achievement systems based on answer patterns
|
|
||||||
Security-sensitive features (not applicable here, but principle matters)
|
|
||||||
|
|
||||||
Fix (if needed):
|
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
|
|
||||||
const shuffleSecure = <T>(array: T[]): T[] => {
|
|
||||||
const result = [...array];
|
|
||||||
for (let i = result.length - 1; i > 0; i--) {
|
|
||||||
// Use crypto.getRandomValues for better randomness
|
|
||||||
const rand = randomBytes(4).readUInt32LE(0);
|
|
||||||
const j = rand % (i + 1);
|
|
||||||
[result[i], result[j]] = [result[j], result[i]];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
Priority: 🟢 LOW — Document tradeoff and move on for now
|
|
||||||
|
|
||||||
6. Test Coverage Gaps
|
|
||||||
File: gameService.test.ts
|
|
||||||
✅ Well Tested:
|
|
||||||
|
|
||||||
Happy path: session creation, answer evaluation
|
|
||||||
Edge cases: duplicate distractors, empty results, invalid inputs
|
|
||||||
Error propagation from DB layer
|
|
||||||
|
|
||||||
❌ Missing Tests:
|
|
||||||
|
|
||||||
// 1. Concurrency test (race condition)
|
|
||||||
it("rejects duplicate answers for same question under concurrent load", async () => {
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
|
||||||
const question = session.questions[0]!;
|
|
||||||
|
|
||||||
// Submit two answers simultaneously
|
|
||||||
const [result1, result2] = await Promise.allSettled([
|
|
||||||
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
|
|
||||||
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Exactly one should succeed, one should throw ConflictError
|
|
||||||
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. TTL expiration test
|
|
||||||
it("deletes session after TTL expires", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const session = await createGameSession(validRequest, store, "user-1");
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(31 _ 60 _ 1000); // 31 minutes
|
|
||||||
|
|
||||||
await expect(store.get(session.sessionId)).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Distractor fallback strategy test
|
|
||||||
it("uses fallback when <3 unique distractors available", async () => {
|
|
||||||
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
|
|
||||||
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
|
|
||||||
});
|
|
||||||
|
|
||||||
Priority: 🟡 HIGH — Prevents regression on critical fixes
|
|
||||||
🧼 Code Quality Nitpicks 7. Magic Numbers
|
|
||||||
|
|
||||||
// gameService.ts:52
|
|
||||||
await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this?
|
|
||||||
|
|
||||||
// termModel.ts:65
|
|
||||||
.limit(count); // count=6, but why?
|
|
||||||
|
|
||||||
// shared/schemas/game.ts:15
|
|
||||||
optionId: z.number().int().min(0).max(3), // Why 4 options?
|
|
||||||
|
|
||||||
Fix: Centralize in @lila/shared/constants.ts:
|
|
||||||
|
|
||||||
export const GAME*SESSION_TTL_MS = 30 * 60 \_ 1000;
|
|
||||||
export const DISTRACTOR_FETCH_COUNT = 6;
|
|
||||||
export const GAME_OPTION_COUNT = 4;
|
|
||||||
export const MIN_UNIQUE_DISTRACTORS = 3;
|
|
||||||
|
|
||||||
8. Mutable Reference Leakage
|
|
||||||
Location: InMemoryGameSessionStore.ts:get()
|
|
||||||
|
|
||||||
get(sessionId: string): Promise<GameSessionData | null> {
|
|
||||||
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
|
|
||||||
}
|
|
||||||
|
|
||||||
Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly.
|
|
||||||
Fix:
|
|
||||||
|
|
||||||
// Option A: Deep clone (simple, works for this data shape)
|
|
||||||
return Promise.resolve(structuredClone(entry.data));
|
|
||||||
|
|
||||||
// Option B: Return readonly view (TypeScript-only protection)
|
|
||||||
return Promise.resolve(entry.data as Readonly<GameSessionData>);
|
|
||||||
|
|
||||||
// Option C: Use immutable data structures (overkill for now)
|
|
||||||
|
|
||||||
9. Zero Observability
|
|
||||||
Problem: No logging, no metrics. You're flying blind in production.
|
|
||||||
Minimal Fix (5 minutes):
|
|
||||||
|
|
||||||
// apps/api/src/lib/logger.ts
|
|
||||||
import pino from "pino";
|
|
||||||
export const logger = pino({
|
|
||||||
level: process.env.LOG_LEVEL || "info",
|
|
||||||
transport: process.env.NODE_ENV === "production"
|
|
||||||
? { target: "pino-pretty" }
|
|
||||||
: undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// In gameService.ts:
|
|
||||||
import { logger } from "../lib/logger.js";
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
{ userId, sourceLang, targetLang, termCount: terms.length },
|
|
||||||
"game_session_created"
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
{ sessionId, questionId, isCorrect, responseTimeMs },
|
|
||||||
"answer_evaluated"
|
|
||||||
);
|
|
||||||
|
|
||||||
Bonus: Export a Prometheus histogram for game_service_duration_seconds.
|
|
||||||
|
|
||||||
10. ORDER BY RANDOM() Time Bomb
|
|
||||||
Location: termModel.ts:getGameTerms() + getDistractors()
|
|
||||||
|
|
||||||
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
|
|
||||||
|
|
||||||
The Comment Admits It:
|
|
||||||
|
|
||||||
// TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set...
|
|
||||||
|
|
||||||
Reality Check: "Post-MVP" never comes without a ticket.
|
|
||||||
Fix Options:
|
|
||||||
|
|
||||||
-- Option A: Pre-computed random_seed column (updated nightly)
|
|
||||||
WHERE ... AND random_seed >= random()
|
|
||||||
ORDER BY random_seed
|
|
||||||
LIMIT $1
|
|
||||||
|
|
||||||
-- Option B: TABLESAMPLE for approximate sampling (Postgres 9.5+)
|
|
||||||
FROM terms TABLESAMPLE SYSTEM(10)
|
|
||||||
WHERE ...
|
|
||||||
LIMIT $1
|
|
||||||
|
|
||||||
-- Option C: Random offset (simple, but still scans)
|
|
||||||
OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...))
|
|
||||||
|
|
||||||
Action: Add a ticket to documentation/tickets/t00009.md now.
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
## 1. Project Overview
|
## 1. Project Overview
|
||||||
|
|
||||||
A vocabulary trainer for English–Italian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The app supports both singleplayer and real-time multiplayer game modes.
|
A vocabulary trainer for English–Italian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The long-term vision is a multiplayer competitive game, but the MVP is a polished singleplayer experience.
|
||||||
|
|
||||||
**The core learning loop:**
|
**The core learning loop:**
|
||||||
Show word → pick answer → see result → next word → final score
|
Show word → pick answer → see result → next word → final score
|
||||||
|
|
@ -29,13 +29,13 @@ The vocabulary data comes from WordNet + the Open Multilingual Wordnet (OMW). A
|
||||||
- Multiplayer mode: create a room, share a code, 2–4 players answer simultaneously in real time, live scores, winner screen
|
- Multiplayer mode: create a room, share a code, 2–4 players answer simultaneously in real time, live scores, winner screen
|
||||||
- 1000+ English–Italian nouns seeded from WordNet
|
- 1000+ English–Italian nouns seeded from WordNet
|
||||||
|
|
||||||
This is the full vision. The current implementation already covers most of it; remaining items are captured in the roadmap and the Post-MVP ladder below.
|
This is the full vision. The MVP deliberately ignores most of it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. MVP Scope
|
## 3. MVP Scope
|
||||||
|
|
||||||
**Goal:** A working, presentable vocabulary trainer that can be shown to real people (singleplayer and multiplayer), with a production deployment.
|
**Goal:** A working, presentable singleplayer quiz that can be shown to real people.
|
||||||
|
|
||||||
### What is IN the MVP
|
### What is IN the MVP
|
||||||
|
|
||||||
|
|
@ -45,15 +45,17 @@ This is the full vision. The current implementation already covers most of it; r
|
||||||
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
|
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
|
||||||
- Global error handler with typed error classes
|
- Global error handler with typed error classes
|
||||||
- Unit + integration tests for the API
|
- Unit + integration tests for the API
|
||||||
- Authentication via Better Auth (Google + GitHub)
|
- Local dev only (no deployment for MVP)
|
||||||
- Multiplayer lobby + game over WebSockets
|
|
||||||
- Production deployment (Docker Compose + Caddy + Hetzner) and CI/CD (Forgejo Actions)
|
|
||||||
|
|
||||||
### What is CUT from the MVP
|
### What is CUT from the MVP
|
||||||
|
|
||||||
| Feature | Why cut |
|
| Feature | Why cut |
|
||||||
| --------------------- | ---------- |
|
| ------------------------------- | -------------------------------------- |
|
||||||
| User stats / profiles | Needs auth |
|
| Authentication (Better Auth) | No user accounts needed for a demo |
|
||||||
|
| Multiplayer (WebSockets, rooms) | Core quiz works without it |
|
||||||
|
| Valkey / Redis cache | Only needed for multiplayer room state |
|
||||||
|
| Deployment to Hetzner | Ship to people locally first |
|
||||||
|
| User stats / profiles | Needs auth |
|
||||||
|
|
||||||
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
|
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
|
||||||
|
|
||||||
|
|
@ -63,30 +65,30 @@ These are not deleted from the plan — they are deferred. The architecture is a
|
||||||
|
|
||||||
The monorepo structure and tooling are already set up. This is the full stack.
|
The monorepo structure and tooling are already set up. This is the full stack.
|
||||||
|
|
||||||
| Layer | Technology | Status |
|
| Layer | Technology | Status |
|
||||||
| ------------ | ------------------------------ | ------------------------------------------------------ |
|
| ------------ | ------------------------------ | ----------- |
|
||||||
| Monorepo | pnpm workspaces | ✅ |
|
| Monorepo | pnpm workspaces | ✅ |
|
||||||
| Frontend | React 18, Vite, TypeScript | ✅ |
|
| Frontend | React 18, Vite, TypeScript | ✅ |
|
||||||
| Routing | TanStack Router | ✅ |
|
| Routing | TanStack Router | ✅ |
|
||||||
| Server state | TanStack Query | ✅ |
|
| Server state | TanStack Query | ✅ |
|
||||||
| Client state | Zustand | ✅ |
|
| Client state | Zustand | ✅ |
|
||||||
| Styling | Tailwind CSS + shadcn/ui | ✅ |
|
| Styling | Tailwind CSS + shadcn/ui | ✅ |
|
||||||
| Backend | Node.js, Express, TypeScript | ✅ |
|
| Backend | Node.js, Express, TypeScript | ✅ |
|
||||||
| Database | PostgreSQL + Drizzle ORM | ✅ |
|
| Database | PostgreSQL + Drizzle ORM | ✅ |
|
||||||
| Validation | Zod (shared schemas) | ✅ |
|
| Validation | Zod (shared schemas) | ✅ |
|
||||||
| Testing | Vitest, supertest | ✅ |
|
| Testing | Vitest, supertest | ✅ |
|
||||||
| Auth | Better Auth (Google + GitHub) | ✅ |
|
| Auth | Better Auth (Google + GitHub) | ✅ |
|
||||||
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
| Deployment | Docker Compose, Caddy, Hetzner | ✅ |
|
||||||
| CI/CD | Forgejo Actions | ✅ |
|
| CI/CD | Forgejo Actions | ✅ |
|
||||||
| Realtime | WebSockets (`ws` library) | ✅ |
|
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
|
||||||
| Cache | Valkey | ⚠️ optional (used locally; production/state hardening) |
|
| Cache | Valkey | ❌ post-MVP |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Repository Structure
|
## 5. Repository Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
lila/
|
vocab-trainer/
|
||||||
├── .forgejo/
|
├── .forgejo/
|
||||||
│ └── workflows/
|
│ └── workflows/
|
||||||
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
│ └── deploy.yml — CI/CD pipeline (build, push, deploy)
|
||||||
|
|
@ -152,6 +154,7 @@ lila/
|
||||||
├── scripts/ — Python extraction/comparison/merge scripts
|
├── scripts/ — Python extraction/comparison/merge scripts
|
||||||
├── documentation/ — project docs
|
├── documentation/ — project docs
|
||||||
├── docker-compose.yml — local dev stack
|
├── docker-compose.yml — local dev stack
|
||||||
|
├── docker-compose.prod.yml — production config reference
|
||||||
├── Caddyfile — reverse proxy routing
|
├── Caddyfile — reverse proxy routing
|
||||||
└── pnpm-workspace.yaml
|
└── pnpm-workspace.yaml
|
||||||
```
|
```
|
||||||
|
|
@ -287,28 +290,15 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
|
|
||||||
## 11. Post-MVP Ladder
|
## 11. Post-MVP Ladder
|
||||||
|
|
||||||
<<<<<<< HEAD
|
| Phase | What it adds | Status |
|
||||||
| Phase | What it adds | Status |
|
|
||||||
| ----------------- | ------------------------------------------------------------------------------- | ------ |
|
|
||||||
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
|
||||||
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
|
||||||
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
|
||||||
| User Stats | Games played, score history, profile page | ❌ |
|
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
|
||||||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
|
||||||
=======
|
|
||||||
| Phase | What it adds | Status |
|
|
||||||
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
| ------------------- | ----------------------------------------------------------------------- | ------ |
|
||||||
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
| Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB | ✅ |
|
||||||
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
| Deployment | Docker Compose, Caddy, Forgejo, CI/CD, Hetzner VPS | ✅ |
|
||||||
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
| Hardening (partial) | CI/CD pipeline, DB backups | ✅ |
|
||||||
| User Stats | Games played, score history, profile page | ❌ |
|
| User Stats | Games played, score history, profile page | ❌ |
|
||||||
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ✅ |
|
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ❌ |
|
||||||
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ✅ |
|
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ❌ |
|
||||||
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
|
||||||
|
|
||||||
> > > > > > > dev
|
|
||||||
|
|
||||||
### Future Data Model Extensions (deferred, additive)
|
### Future Data Model Extensions (deferred, additive)
|
||||||
|
|
||||||
|
|
@ -321,20 +311,12 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
|
||||||
|
|
||||||
All are new tables referencing existing `terms` rows via FK. No existing schema changes required.
|
All are new tables referencing existing `terms` rows via FK. No existing schema changes required.
|
||||||
|
|
||||||
### Multiplayer Architecture (current + deferred)
|
### Multiplayer Architecture (deferred)
|
||||||
|
|
||||||
**Implemented now:**
|
- WebSocket protocol: `ws` library, Zod discriminated union for message types
|
||||||
|
- Room model: human-readable codes (e.g. `WOLF-42`), not matchmaking queue
|
||||||
- WebSocket protocol uses the `ws` library with a Zod discriminated union for message types (defined in `packages/shared`)
|
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
|
||||||
- Room model uses human-readable codes (no matchmaking queue)
|
- Valkey for ephemeral room state, PostgreSQL for durable records
|
||||||
- Lobby flow (create/join/leave) is real-time over WS, backed by PostgreSQL for durable membership/state
|
|
||||||
- Multiplayer game flow is real-time: host starts, all players see the same question, answers are collected simultaneously, with a server-enforced 15s timer and live scoring
|
|
||||||
- WebSocket connections are authenticated (Better Auth session validation on upgrade)
|
|
||||||
|
|
||||||
**Deferred / hardening:**
|
|
||||||
|
|
||||||
- Valkey-backed ephemeral state (room/game/session store) where in-memory state becomes a bottleneck
|
|
||||||
- Graceful reconnect/resume flows and more robust failure handling (tracked in Phase 7)
|
|
||||||
|
|
||||||
### Infrastructure (current)
|
### Infrastructure (current)
|
||||||
|
|
||||||
|
|
@ -349,7 +331,7 @@ See `deployment.md` for full infrastructure documentation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Definition of Done (Current Baseline)
|
## 12. Definition of Done (MVP)
|
||||||
|
|
||||||
- [x] API returns quiz terms with correct distractors
|
- [x] API returns quiz terms with correct distractors
|
||||||
- [x] User can complete a quiz without errors
|
- [x] User can complete a quiz without errors
|
||||||
|
|
@ -358,9 +340,6 @@ See `deployment.md` for full infrastructure documentation.
|
||||||
- [x] No hardcoded data — everything comes from the database
|
- [x] No hardcoded data — everything comes from the database
|
||||||
- [x] Global error handler with typed error classes
|
- [x] Global error handler with typed error classes
|
||||||
- [x] Unit + integration tests for API
|
- [x] Unit + integration tests for API
|
||||||
- [x] Auth works end-to-end (Google + GitHub via Better Auth)
|
|
||||||
- [x] Multiplayer works end-to-end (lobby + real-time game over WebSockets)
|
|
||||||
- [x] Production deployment is live behind HTTPS (Caddy) with CI/CD deploys via Forgejo Actions
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -376,8 +355,8 @@ Phase 0 (Foundation) ✅
|
||||||
└── Phase 2 (Singleplayer UI) ✅
|
└── Phase 2 (Singleplayer UI) ✅
|
||||||
├── Phase 3 (Auth) ✅
|
├── Phase 3 (Auth) ✅
|
||||||
│ └── Phase 6 (Deployment + CI/CD) ✅
|
│ └── Phase 6 (Deployment + CI/CD) ✅
|
||||||
└── Phase 4 (Multiplayer Lobby) ✅
|
└── Phase 4 (Multiplayer Lobby)
|
||||||
└── Phase 5 (Multiplayer Game) ✅
|
└── Phase 5 (Multiplayer Game)
|
||||||
└── Phase 7 (Hardening)
|
└── Phase 7 (Hardening)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
# Ticket Blueprint
|
|
||||||
|
|
||||||
Two formats depending on task type. Choose based on whether a meaningful
|
|
||||||
decision between options was made.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Format A — ADR (architectural/infrastructural decisions)
|
|
||||||
|
|
||||||
Use when: you chose between options with long-term consequences.
|
|
||||||
Prefix: `adr-`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# ADR: <title>
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted | Superseded by | Deprecated
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
YYYY-MM-DD
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
What is the problem? Why does it need to be solved?
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
What was chosen and why in one or two sentences.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — <name> ✅
|
|
||||||
|
|
||||||
Description. Why it was chosen.
|
|
||||||
|
|
||||||
### Option B — <name>
|
|
||||||
|
|
||||||
Description. Why it was rejected.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- What gets better
|
|
||||||
- What gets worse or more complex
|
|
||||||
- Operational implications
|
|
||||||
- What breaks if this needs to be redone
|
|
||||||
|
|
||||||
## Affected files / machines
|
|
||||||
|
|
||||||
- List files, servers, or systems touched
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Links to relevant docs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide / implementation notes
|
|
||||||
|
|
||||||
Step-by-step of what was actually done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Format B — Task (features, fixes, chores)
|
|
||||||
|
|
||||||
Use when: routine task with a clear solution.
|
|
||||||
Prefix: `feat-` / `fix-` / `chore-`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# <prefix>: <title>
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
What was wrong or missing?
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — <name> ✅
|
|
||||||
|
|
||||||
### Option B — <name>
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
What was done and why.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `path/to/file.ts`
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
`<type>: <message>`
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
# ADR: Docker Credential Helper Setup
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-04-26
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Docker credentials for `git.lilastudy.com` and `dhi.io` were stored as base64-encoded strings in `~/.docker/config.json` on both the dev laptop and the VPS. Base64 is not encryption — anyone with read access to the file can decode the credentials instantly.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Use `pass` (GPG-backed password store) as the Docker credential helper on both machines.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — `pass` (GPG-backed) ✅
|
|
||||||
|
|
||||||
Stores credentials encrypted with a GPG key. Works on headless servers and desktops without GNOME. Industry standard for Linux servers.
|
|
||||||
|
|
||||||
### Option B — `secretservice` (GNOME keyring)
|
|
||||||
|
|
||||||
Uses the desktop keyring daemon. Not suitable for a headless VPS, and not suitable for an i3 desktop without running `gnome-keyring-daemon` manually.
|
|
||||||
|
|
||||||
### Option C — `gnome-libsecret`
|
|
||||||
|
|
||||||
Same limitations as Option B.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Credentials are now GPG-encrypted at rest on both machines
|
|
||||||
- Requires GPG passphrase entry when Docker needs to pull credentials
|
|
||||||
in a new session
|
|
||||||
- Must be set up manually on each machine — not reproducible via the repo
|
|
||||||
- VPS setup must be repeated if the server is reprovisioned
|
|
||||||
|
|
||||||
## Affected machines
|
|
||||||
|
|
||||||
- Dev laptop (Debian 13, i3)
|
|
||||||
- VPS (Debian 13, ARM64, headless)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [docker docs](https://docs.docker.com/reference/cli/docker/login/#credential-stores)
|
|
||||||
- [pass docs](https://www.passwordstore.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide
|
|
||||||
|
|
||||||
Repeat these steps on each machine.
|
|
||||||
|
|
||||||
### 1. Install dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get install -y pass gnupg2 golang-docker-credential-helpers
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Generate a GPG key
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --full-generate-key
|
|
||||||
```
|
|
||||||
|
|
||||||
Choose RSA, 4096 bits, no expiry. Set a strong passphrase.
|
|
||||||
|
|
||||||
### 3. Get the key ID
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --list-secret-keys --keyid-format LONG
|
|
||||||
```
|
|
||||||
|
|
||||||
Copy the hex string after the `/` on the `sec` line.
|
|
||||||
|
|
||||||
### 4. Initialise pass
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pass init <your-key-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Update `~/.docker/config.json`
|
|
||||||
|
|
||||||
Replace the entire file contents with:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "credsStore": "pass" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Re-login to registries
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker login git.lilastudy.com
|
|
||||||
# dev laptop only:
|
|
||||||
docker login dhi.io
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat ~/.docker/config.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Should show only `"credsStore": "pass"` with no `auths` block.
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
# ADR: Change GAME_ROUNDS from strings to numbers
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-04-28
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`GAME_ROUNDS` in `packages/shared/src/constants.ts` was typed as `["3", "10"] as const`, making `GameRounds` a string union (`"3" | "10"`). This meant `gameService.ts` had to cast the value with `Number(request.rounds)` deep in business logic — a type conversion happening far from the boundary where data enters the system. The type system was lying: `rounds` was described as a string everywhere but used as a number where it mattered.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Change `GAME_ROUNDS` to `[3, 10] as const` and update the Zod schema to use `z.literal(GAME_ROUNDS)` instead of `z.enum(GAME_ROUNDS)`. The single source of truth remains `constants.ts` — adding a new round count (e.g. `20`) requires only editing that file.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — Numbers everywhere ✅
|
|
||||||
|
|
||||||
Change `GAME_ROUNDS` to `[3, 10] as const`. Use `z.literal(GAME_ROUNDS)` in the schema. Update the frontend component state and `SettingGroup` props. Drop `Number()` cast in the service.
|
|
||||||
|
|
||||||
Chosen because: JSON carries numbers natively, both ends of the wire are owned by this codebase, and type conversions belong at the boundary — not inside business logic.
|
|
||||||
|
|
||||||
### Option B — Keep strings, accept the cast
|
|
||||||
|
|
||||||
Leave `GAME_ROUNDS` as `["3", "10"]`. The `Number()` cast stays in `gameService.ts`.
|
|
||||||
|
|
||||||
Rejected because: it pushes type conversion into business logic and makes the inferred `GameRequest` type misleading. The cast has to live somewhere — the schema boundary is the right place.
|
|
||||||
|
|
||||||
### Option C — Coerce at the schema boundary
|
|
||||||
|
|
||||||
Keep `GAME_ROUNDS` as numbers but use `z.coerce.number().pipe(z.literal(GAME_ROUNDS))` so the frontend can keep sending strings.
|
|
||||||
|
|
||||||
Rejected because: coercion is for untrusted or uncontrolled inputs (form fields, query params, third-party clients). We control both ends of the wire. Coercing a self-inflicted type mismatch is treating a wound we gave ourselves.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- `GameRounds` is now `3 | 10` instead of `"3" | "10"`
|
|
||||||
- `Number(request.rounds)` cast removed from `gameService.ts`
|
|
||||||
- `SettingGroup` in `GameSetup.tsx` now accepts `string | number` options
|
|
||||||
- `useState<string>` for rounds changed to `useState<number>`
|
|
||||||
- Adding a new round count requires only editing `GAME_ROUNDS` in `constants.ts`
|
|
||||||
- `z.enum` cannot be used for number literals — `z.literal` must be used instead (this is a Zod constraint, not a project convention)
|
|
||||||
|
|
||||||
## Affected files
|
|
||||||
|
|
||||||
- `packages/shared/src/constants.ts`
|
|
||||||
- `packages/shared/src/schemas/game.ts`
|
|
||||||
- `apps/api/src/services/gameService.ts`
|
|
||||||
- `apps/api/src/services/gameService.test.ts`
|
|
||||||
- `apps/api/src/controllers/gameController.test.ts`
|
|
||||||
- `apps/web/src/components/game/GameSetup.tsx`
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Zod literals](https://zod.dev/?id=literals)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide / implementation notes
|
|
||||||
|
|
||||||
1. In `packages/shared/src/constants.ts`, change:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const GAME_ROUNDS = ["3", "10"] as const;
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const GAME_ROUNDS = [3, 10] as const;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. In `packages/shared/src/schemas/game.ts`, change:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: z.enum(GAME_ROUNDS),
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: z.literal(GAME_ROUNDS),
|
|
||||||
```
|
|
||||||
|
|
||||||
3. In `apps/api/src/services/gameService.ts`, change:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
Number(request.rounds),
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
request.rounds,
|
|
||||||
```
|
|
||||||
|
|
||||||
4. In `apps/api/src/services/gameService.test.ts`, change:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: "3",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: 3,
|
|
||||||
```
|
|
||||||
|
|
||||||
5. In `apps/api/src/controllers/gameController.test.ts`, change:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: "3",
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
rounds: 3,
|
|
||||||
```
|
|
||||||
|
|
||||||
Also add a pinning test before the refactor:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it("returns 400 when rounds has an invalid value", async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post("/api/v1/game/start")
|
|
||||||
.send({ ...validBody, rounds: "invalid" });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.success).toBe(false);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
6. In `apps/web/src/components/game/GameSetup.tsx`:
|
|
||||||
- Update `SettingGroup` props to accept `string | number`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type SettingGroupProps = {
|
|
||||||
options: readonly (string | number)[];
|
|
||||||
selected: string | number;
|
|
||||||
onSelect: (value: string | number) => void;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- Update `LABELS` lookup to `LABELS[String(option)]`
|
|
||||||
- Change rounds state from `useState<string>` to `useState<number>`
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
# refactor: extract shuffleArray to lib/utils, rename correctAnswers to terms
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Two readability issues in `gameService.ts`:
|
|
||||||
|
|
||||||
1. `shuffle` was defined as a private function at the bottom of `gameService.ts`, after the function that calls it. It is a pure generic utility with no dependency on game domain logic, so it had no business living there.
|
|
||||||
|
|
||||||
2. The variable holding terms fetched from the database was named `correctAnswers`. These are word pairs — they only become "correct answers" once options are built around them. The name was premature and misleading.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — Move `shuffle` up in the same file
|
|
||||||
|
|
||||||
Simple, no new files. Fixes the ordering issue but keeps a generic utility buried in domain code.
|
|
||||||
|
|
||||||
### Option B — Extract to `lib/utils.ts` ✅
|
|
||||||
|
|
||||||
Move `shuffle` (renamed `shuffleArray`) to `apps/api/src/lib/utils.ts` and import it. Cleaner separation: domain logic stays in services, generic utilities live in `lib/`.
|
|
||||||
|
|
||||||
Chosen because `lib/` already exists, the function is reusable, and it gives future utilities a home.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
- Created `apps/api/src/lib/utils.ts` with `shuffleArray`
|
|
||||||
- Renamed `shuffle` → `shuffleArray` for clarity at the call site
|
|
||||||
- Removed the inline `shuffle` from `gameService.ts` and imported from `lib/utils.ts`
|
|
||||||
- Renamed `correctAnswers` → `terms` and `correctAnswer` → `term` throughout `gameService.ts`
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `apps/api/src/lib/utils.ts` — created
|
|
||||||
- `apps/api/src/services/gameService.ts` — removed `shuffle`, updated import, renamed variables
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
`refactor: extract shuffleArray to lib/utils, rename correctAnswers to terms`
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# ADR: Dependency injection for GameSessionStore via composition root
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-04-28
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`gameService.ts` had a module-level singleton:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const gameSessionStore = new InMemoryGameSessionStore();
|
|
||||||
```
|
|
||||||
|
|
||||||
This made the store invisible to anything outside the file. The `GameSessionStore` interface existed to make the store swappable — but the singleton made that impossible without editing the service itself. Tests shared the same instance across every test run, creating the potential for ghost sessions leaking between tests. The controller also briefly owned the singleton during an intermediate step, which violated the principle that controllers should only handle HTTP concerns.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Adopt a composition root pattern. The store is created once in `createApp()` and passed down through factory functions: `createApiRouter(store)` → `createGameRouter(store)` → `createGameController(store)` → service calls. Neither the controller nor the service knows which implementation they're working with — they both see `GameSessionStore`.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — Composition root ✅
|
|
||||||
|
|
||||||
Convert routers and controllers to factory functions. Create the store in `createApp()` and pass it down. The store is created once, at the top, and injected through the call chain.
|
|
||||||
|
|
||||||
Chosen because: clean separation of concerns, no layer below `createApp()` needs to know the concrete implementation, swapping to `ValKeyGameSessionStore` is a one-line change in `app.ts`, and tests get fresh isolated store instances.
|
|
||||||
|
|
||||||
### Option B — Keep singleton in controller
|
|
||||||
|
|
||||||
Leave the store as a module-level singleton in `gameController.ts`. Controllers own the store lifetime.
|
|
||||||
|
|
||||||
Rejected because: controllers should only handle HTTP concerns. Owning infrastructure lifetime is not an HTTP concern.
|
|
||||||
|
|
||||||
### Option C — DI framework (tsyringe, inversify)
|
|
||||||
|
|
||||||
Use a proper dependency injection container.
|
|
||||||
|
|
||||||
Rejected because: overkill for the current scale. The composition root pattern achieves the same result with zero dependencies and no magic.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Swapping `InMemoryGameSessionStore` for `ValKeyGameSessionStore` requires editing one line in `app.ts`
|
|
||||||
- Tests create fresh `InMemoryGameSessionStore` instances per test — no shared state, no ghost sessions
|
|
||||||
- Routers and controllers are now factory functions instead of module-level singletons — slightly more verbose but explicitly testable
|
|
||||||
- `gameController.test.ts` uses `createApp()` which owns the store — controller tests remain integration-style and unaffected
|
|
||||||
- All layers below `createApp()` depend only on the `GameSessionStore` interface, never the concrete implementation
|
|
||||||
|
|
||||||
## Affected files
|
|
||||||
|
|
||||||
- `apps/api/src/app.ts` — creates the store, passes to `createApiRouter`
|
|
||||||
- `apps/api/src/routes/apiRouter.ts` — converted to `createApiRouter(store)` factory
|
|
||||||
- `apps/api/src/routes/gameRouter.ts` — converted to `createGameRouter(store)` factory
|
|
||||||
- `apps/api/src/controllers/gameController.ts` — converted to `createGameController(store)` factory
|
|
||||||
- `apps/api/src/services/gameService.ts` — `store` parameter added to both functions, singleton removed
|
|
||||||
- `apps/api/src/services/gameService.test.ts` — fresh store per describe block via `beforeEach`
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Composition root pattern](https://blog.ploeh.dk/2011/07/28/CompositionRoot/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide / implementation notes
|
|
||||||
|
|
||||||
1. `gameService.ts` — remove module-level singleton, add `store: GameSessionStore` parameter to `createGameSession` and `evaluateAnswer`
|
|
||||||
|
|
||||||
2. `gameController.ts` — convert exported functions to a factory:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createGameController = (store: GameSessionStore) => ({
|
|
||||||
createGame: async (req, res, next) => { ... },
|
|
||||||
submitAnswer: async (req, res, next) => { ... },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `gameRouter.ts` — convert to factory:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createGameRouter = (store: GameSessionStore): Router => {
|
|
||||||
const router = express.Router();
|
|
||||||
const controller = createGameController(store);
|
|
||||||
router.post("/start", controller.createGame);
|
|
||||||
router.post("/answer", controller.submitAnswer);
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
4. `apiRouter.ts` — convert to factory:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createApiRouter = (store: GameSessionStore): Router => {
|
|
||||||
const router = express.Router();
|
|
||||||
router.use("/game", createGameRouter(store));
|
|
||||||
return router;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
5. `app.ts` — create the store at the composition root:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const store = new InMemoryGameSessionStore();
|
|
||||||
app.use("/api/v1", createApiRouter(store));
|
|
||||||
```
|
|
||||||
|
|
||||||
6. `gameService.test.ts` — add `let store: InMemoryGameSessionStore` to each `describe` block, reset in `beforeEach`, pass to every service call
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
# ADR: Session lifecycle — TTL and replay protection
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-04-28
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`InMemoryGameSessionStore` had no TTL and no cleanup mechanism. Every session created stayed in memory until the process restarted. Additionally, `evaluateAnswer` never removed a question from the answer key after evaluating it, meaning the same question could be submitted multiple times and receive a valid result each time — a potential exploit in multiplayer and a correctness bug in singleplayer.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Add a `ttlMs` parameter to `GameSessionStore.create()` so both the in-memory and future Valkey implementations handle expiry consistently. Delete questions from the answer key after evaluation. Delete the session when the last question is answered.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — Delete on last answer only
|
|
||||||
|
|
||||||
Simple. Covers replay protection and normal session completion. Abandoned sessions (player starts game, never finishes) still leak memory.
|
|
||||||
|
|
||||||
### Option B — Delete on last answer + TTL on the interface ✅
|
|
||||||
|
|
||||||
Delete on answer covers normal flow. TTL covers abandoned sessions. TTL on the interface means `ValKeyGameSessionStore` can use Redis-native `EXPIRE` without any interface changes during migration.
|
|
||||||
|
|
||||||
Chosen because it closes the memory leak entirely and makes the Valkey migration a zero-interface-change operation.
|
|
||||||
|
|
||||||
### Option C — TTL hardcoded inside InMemoryGameSessionStore only
|
|
||||||
|
|
||||||
Simpler short-term. But the interface wouldn't carry the TTL parameter, so `ValKeyGameSessionStore` would need a different mechanism — inconsistency between implementations.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Sessions expire after 30 minutes of inactivity regardless of completion state
|
|
||||||
- Submitting the same question twice throws `NotFoundError` on the second attempt
|
|
||||||
- Sessions are deleted automatically when the last question is answered
|
|
||||||
- `GameSessionStore.create()` now requires a `ttlMs` argument — any future implementation must honour it
|
|
||||||
- `ValKeyGameSessionStore` can implement TTL via Redis `EXPIRE` with no interface changes
|
|
||||||
- `InMemoryGameSessionStore` stores `{ data, expiresAt }` entries instead of raw `GameSessionData` — expiry is checked lazily on `get()`
|
|
||||||
|
|
||||||
## Affected files
|
|
||||||
|
|
||||||
- `apps/api/src/gameSessionStore/GameSessionStore.ts` — `ttlMs` added to `create`
|
|
||||||
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts` — TTL implementation
|
|
||||||
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — new test file
|
|
||||||
- `apps/api/src/services/gameService.ts` — passes TTL to `store.create`, deletes question after evaluation, deletes session when empty
|
|
||||||
- `apps/api/src/services/gameService.test.ts` — replay protection and session cleanup tests added
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Redis EXPIRE command](https://redis.io/commands/expire/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide / implementation notes
|
|
||||||
|
|
||||||
1. `GameSessionStore.ts` — add `ttlMs` to `create`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
create(sessionId: string, data: GameSessionData, ttlMs: number): Promise<void>;
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `InMemoryGameSessionStore.ts` — wrap stored data with expiry:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type SessionEntry = { data: GameSessionData; expiresAt: number };
|
|
||||||
```
|
|
||||||
|
|
||||||
Check expiry on `get()`, delete expired entries lazily.
|
|
||||||
|
|
||||||
3. `gameService.ts` — pass TTL when creating session:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);
|
|
||||||
```
|
|
||||||
|
|
||||||
After evaluating an answer:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
session.answers.delete(submission.questionId);
|
|
||||||
if (session.answers.size === 0) {
|
|
||||||
await store.delete(submission.sessionId);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. When implementing `ValKeyGameSessionStore`, pass `ttlMs` to Redis `EXPIRE`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000));
|
|
||||||
```
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
# ADR: Session ownership check and AuthenticatedRequest type
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Date
|
|
||||||
|
|
||||||
2026-04-28
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`evaluateAnswer` accepted any `sessionId` without verifying it belonged to the requesting user. The only protection was the unguessability of a UUID — security through obscurity. If a user intercepted or guessed another user's `sessionId`, they could submit answers on their behalf.
|
|
||||||
|
|
||||||
Additionally, protected controller handlers typed their `req` parameter as `Request`, making `session` optional even though `requireAuth` middleware guarantees it is present. This required non-null assertions (`req.session!`) in business logic — a type assertion that could cause a runtime crash if middleware ordering ever changed.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Store `userId` in `GameSessionData`. Pass `userId` from the controller into both `createGameSession` and `evaluateAnswer`. Assert ownership on evaluation — if the session's `userId` doesn't match the requesting user's ID, throw `NotFoundError`. Introduce `AuthenticatedRequest` to eliminate non-null assertions in protected handlers.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — AuthenticatedRequest type ✅
|
|
||||||
|
|
||||||
Define `AuthenticatedRequest = Request & { session: { session: Session; user: User } }` in `types/express.d.ts`. Use it in protected controller handlers instead of `Request`. Requires a single `as express.RequestHandler` cast at route registration due to Express's type limitations.
|
|
||||||
|
|
||||||
Chosen because: eliminates dangerous non-null assertions in business logic. The cast at route registration is a necessary cast caused by a third-party library limitation, not uncertain logic.
|
|
||||||
|
|
||||||
### Option B — Non-null assertion (`req.session!`)
|
|
||||||
|
|
||||||
Keep `Request` on all handlers. Assert `req.session!` at every usage.
|
|
||||||
|
|
||||||
Rejected because: non-null assertions in business logic are dangerous — if middleware ordering ever changes, the assertion silently passes and crashes at runtime.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Option C — NotFoundError (404) on ownership failure ✅
|
|
||||||
|
|
||||||
When a session exists but belongs to a different user, throw `NotFoundError` with the same message as a missing session.
|
|
||||||
|
|
||||||
Chosen because: session IDs are opaque secrets. Returning 403 would confirm to the caller that the session ID is valid and belongs to someone else — information they shouldn't have. This pattern is used by GitHub, AWS, and most security-conscious APIs.
|
|
||||||
|
|
||||||
### Option D — ForbiddenError (403) on ownership failure
|
|
||||||
|
|
||||||
Explicit error that distinguishes "not found" from "not allowed".
|
|
||||||
|
|
||||||
Rejected because: for user-owned resources identified by opaque IDs, confirming existence to an unauthorised caller is an information leak. 404 is the industry standard for this case.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Alice cannot submit answers for Bob's session — ownership is verified at the service layer
|
|
||||||
- `req.session.user.id` is accessible without non-null assertions in protected handlers
|
|
||||||
- `GameSessionData` now carries `userId` — any future `GameSessionStore` implementation must store and return it
|
|
||||||
- Route registration requires `as express.RequestHandler` cast for protected handlers — one cast per route, in wiring code only
|
|
||||||
- `ValKeyGameSessionStore` must serialise and deserialise `userId` alongside `answers`
|
|
||||||
|
|
||||||
## Affected files
|
|
||||||
|
|
||||||
- `apps/api/src/types/express.d.ts` — `AuthenticatedRequest` type added
|
|
||||||
- `apps/api/src/gameSessionStore/GameSessionStore.ts` — `userId` added to `GameSessionData`
|
|
||||||
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — updated data fixtures
|
|
||||||
- `apps/api/src/services/gameService.ts` — `userId` parameter added to both functions, ownership assertion in `evaluateAnswer`
|
|
||||||
- `apps/api/src/services/gameService.test.ts` — updated all calls, ownership test added
|
|
||||||
- `apps/api/src/controllers/gameController.ts` — extracts `userId` from `req.session.user.id`, passes to service calls
|
|
||||||
- `apps/api/src/routes/gameRouter.ts` — `as express.RequestHandler` cast at route registration
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [OWASP: Insecure Direct Object Reference](https://owasp.org/www-community/attacks/Insecure_Direct_Object_Reference)
|
|
||||||
- [HTTP 403 vs 404 for authorization failures](https://stackoverflow.com/questions/3297048/403-forbidden-vs-401-unauthorized-http-responses)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup guide / implementation notes
|
|
||||||
|
|
||||||
1. `types/express.d.ts` — add:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type AuthenticatedRequest = Request & {
|
|
||||||
session: { session: Session; user: User };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type GameSessionData = {
|
|
||||||
answers: Map<string, number>;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
3. `gameService.ts` — add `userId` to both function signatures:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const createGameSession = async (
|
|
||||||
request: GameRequest,
|
|
||||||
store: GameSessionStore,
|
|
||||||
userId: string,
|
|
||||||
): Promise<GameSession>
|
|
||||||
```
|
|
||||||
|
|
||||||
Store it on create:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await store.create(
|
|
||||||
sessionId,
|
|
||||||
{ answers: answerKey, userId },
|
|
||||||
30 * 60 * 1000,
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Assert on evaluate:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (!session || session.userId !== userId) {
|
|
||||||
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. `gameController.ts` — extract from authenticated request:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
req.session.user.id;
|
|
||||||
```
|
|
||||||
|
|
||||||
5. `gameRouter.ts` — cast at registration:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
router.post("/start", controller.createGame as express.RequestHandler);
|
|
||||||
router.post("/answer", controller.submitAnswer as express.RequestHandler);
|
|
||||||
```
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
# feat: guard against empty terms in createGameSession
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
If `getGameTerms` returned an empty array — no vocabulary data matched the requested language, difficulty, and part of speech combination — `createGameSession` would create a session with zero questions and return it. The frontend would receive an empty `questions` array, attempt to render the first question, find nothing, and crash with no useful error message shown to the user.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — `NotFoundError` (404) ✅
|
|
||||||
|
|
||||||
Throw when `terms.length === 0` before any session is created. The combination of filters yielded no data — that's a "not found" situation.
|
|
||||||
|
|
||||||
Chosen because: the request is technically valid (all filter values are recognised), but the combination has no matching data. 404 is the correct semantic response.
|
|
||||||
|
|
||||||
### Option B — `ValidationError` (400)
|
|
||||||
|
|
||||||
Treat empty results as a bad request.
|
|
||||||
|
|
||||||
Rejected because: the client sent valid input. The problem is missing data, not invalid input. 400 would be misleading.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Added a guard in `createGameSession` immediately after `getGameTerms`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (terms.length === 0) {
|
|
||||||
throw new NotFoundError("No terms found for the given filters");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The error propagates through the controller's `try/catch` to the error handler, which returns a clean 404 response. No session is created.
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `apps/api/src/services/gameService.ts` — empty terms guard added
|
|
||||||
- `apps/api/src/services/gameService.test.ts` — pinning test added
|
|
||||||
- `apps/api/src/controllers/gameController.test.ts` — pinning test added at HTTP layer
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
`feat: guard against empty terms in createGameSession`
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
# fix: deduplicate distractors, replace tautological test
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Two issues in `createGameSession` and its test suite:
|
|
||||||
|
|
||||||
1. If `getDistractors` returned the correct answer as one of the distractors, `createGameSession` would include it in the options array without filtering it out. `indexOf` would then find the first occurrence, which might not be the one intended as the correct answer — producing a question where the correct answer appears twice and the stored `correctOptionId` is wrong.
|
|
||||||
|
|
||||||
2. The test `"distractors are never the correct answer"` was tautological — it filtered the correct answer out of the options array, then asserted the remaining items were not the correct answer. It was testing that `Array.filter()` works. It could never fail.
|
|
||||||
|
|
||||||
## Options considered
|
|
||||||
|
|
||||||
### Option A — Filter duplicates after fetching, request extra distractors as buffer ✅
|
|
||||||
|
|
||||||
Filter out any distractor that matches the correct answer after fetching. Request 6 distractors instead of 3 to ensure enough remain after deduplication. Take the first 3 valid ones with `slice(0, 3)`.
|
|
||||||
|
|
||||||
Chosen because: deduplication at the service layer is the right place — `getDistractors` shouldn't need to know what the correct answer is. Requesting extra provides a buffer against collisions.
|
|
||||||
|
|
||||||
### Option B — Fix `getDistractors` to never return the correct answer
|
|
||||||
|
|
||||||
Add a NOT filter in the database query.
|
|
||||||
|
|
||||||
Not chosen for this ticket — the database query is in `@lila/db` and is a separate concern. The service layer should be defensive regardless of what the model layer returns.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
- Filter distractors against the correct answer before building options:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const uniqueDistractors = distractorTexts.filter(
|
|
||||||
(t) => t !== term.targetText,
|
|
||||||
);
|
|
||||||
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
|
|
||||||
```
|
|
||||||
|
|
||||||
- Request 6 distractors instead of 3 to account for potential duplicates
|
|
||||||
- Replaced tautological test with a test that actually exercises the duplicate case:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it("correct answer appears exactly once even if getDistractors returns a duplicate", ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Added distractor failure propagation test:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it("propagates getDistractors failure", ...)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `apps/api/src/services/gameService.ts` — deduplication logic, distractor count increased to 6
|
|
||||||
- `apps/api/src/services/gameService.test.ts` — tautological test replaced, failure test added
|
|
||||||
|
|
||||||
## Commit
|
|
||||||
|
|
||||||
`fix: deduplicate distractors, replace tautological test, add distractor failure test`
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue