Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall

Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
This commit is contained in:
Claude Sonnet 5
2026-08-12 19:24:02 +00:00
commit c4e2132717
96 changed files with 16108 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.git
data
*.env
!.env.example
coverage
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
# Generate with: npm run generate-key
CREDENTIALS_ENCRYPTION_KEY=
# Where the SQLite DB lives — set by docker-compose in production, defaults
# to ./data for local dev.
DATA_DIR=./data
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# app data (SQLite db, WAL files)
/data/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-bookworm-slim
# python3/make/g++ let npm fall back to compiling better-sqlite3/argon2 from
# source if no prebuilt binary matches this platform.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 8082
# Migrate the (volume-mounted) SQLite DB on every start — a no-op once
# already applied.
CMD ["sh", "-c", "npm run db:migrate && npm start"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+16
View File
@@ -0,0 +1,16 @@
services:
app:
build: .
ports:
- "8082:8082"
env_file:
- .env
environment:
- PORT=8082
- DATA_DIR=/data
volumes:
- data:/data
restart: unless-stopped
volumes:
data:
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: path.join(dataDir, "db.sqlite"),
},
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+24
View File
@@ -0,0 +1,24 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Not using output: "standalone" — file-tracing only copies argon2's
// native .node binaries, not its JS loader, causing a segfault in
// production. Deploying with the full node_modules via `next start` is
// simple enough for a single-VM deployment.
async headers() {
return [
{
// Every page is personalized (session, wallet balance, owned
// games) — an intermediate proxy (nginx, a CDN) has no business
// caching any of it. Static assets under /_next/static are
// content-hashed by the build and are explicitly excluded here so
// they keep their own long-lived caching.
source: "/((?!_next/static).*)",
headers: [{ key: "Cache-Control", value: "no-store" }],
},
];
},
};
export default nextConfig;
+8356
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "tis",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start -p ${PORT:-8082}",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"generate-key": "tsx scripts/generate-key.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.28.0",
"next": "16.2.12",
"phaser": "^4.2.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4",
"tsx": "^4.23.1",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+7
View File
@@ -0,0 +1,7 @@
/**
* Generates a fresh AES-256-GCM key for CREDENTIALS_ENCRYPTION_KEY.
* Usage: npm run generate-key
*/
import crypto from "node:crypto";
console.log(crypto.randomBytes(32).toString("base64"));
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LogIn } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
return (
<div className="mx-auto flex w-full max-w-sm flex-1 items-center justify-center px-4 py-12">
<form onSubmit={handleSubmit} className="card w-full p-8">
<h1 className="mb-1 font-display text-xl font-semibold">Вход</h1>
<p className="mb-6 text-sm text-text-muted">Войдите, чтобы создавать и публиковать игры.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Входим…" : "Войти"}
</button>
<p className="mt-4 text-center text-sm text-text-muted">
Нет аккаунта?{" "}
<Link href="/register" className="font-medium text-accent hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
export default function RegisterPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось зарегистрироваться");
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
return (
<div className="mx-auto flex w-full max-w-sm flex-1 items-center justify-center px-4 py-12">
<form onSubmit={handleSubmit} className="card w-full p-8">
<h1 className="mb-1 font-display text-xl font-semibold">Регистрация</h1>
<p className="mb-6 text-sm text-text-muted">Создайте аккаунт, чтобы делать свои игры.</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Имя</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<UserPlus size={16} />
{loading ? "Создаём…" : "Зарегистрироваться"}
</button>
<p className="mt-4 text-center text-sm text-text-muted">
Уже есть аккаунт?{" "}
<Link href="/login" className="font-medium text-accent hover:underline">
Войти
</Link>
</p>
</form>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getAiStatus, saveAiSettings, disableAi } from "@/lib/ai/config";
import { testAnthropicKey } from "@/lib/ai/client";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getAiStatus());
}
const configureSchema = z.object({
apiKey: z.string().min(1),
model: z.string().min(1),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
await testAnthropicKey(parsed.data.apiKey, parsed.data.model);
await saveAiSettings(parsed.data);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
}
}
export async function DELETE() {
const { session, response } = await requireSession();
if (!session) return response;
await disableAi();
return NextResponse.json({ ok: true });
}
+55
View File
@@ -0,0 +1,55 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { generateGameDefinition, AiNotConfiguredError } from "@/lib/ai/generate";
import { TEMPLATE_TYPES } from "@/lib/games/types";
// This costs real API spend once a key is configured, so the window/limit
// is tighter than the plain login rate limiter.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 15;
const WINDOW_MS = 60 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
const generateSchema = z.object({
templateType: z.enum(TEMPLATE_TYPES),
prompt: z.string().min(3).max(2000),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
if (isRateLimited(session.user.id)) {
return NextResponse.json({ error: "Слишком много запросов к ИИ — попробуйте позже" }, { status: 429 });
}
const body = await request.json().catch(() => null);
const parsed = generateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
try {
const definition = await generateGameDefinition(parsed.data.templateType, parsed.data.prompt);
return NextResponse.json({ definition });
} catch (err) {
if (err instanceof AiNotConfiguredError) {
return NextResponse.json({ error: "ИИ не настроен — добавьте ключ в Настройки → ИИ" }, { status: 400 });
}
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось сгенерировать игру: ${message}` }, { status: 502 });
}
}
+61
View File
@@ -0,0 +1,61 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const user = await db.query.users.findFirst({ where: (u, { eq }) => eq(u.email, email) });
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ??
"$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name } });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { destroySessionToken, getSessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+44
View File
@@ -0,0 +1,44 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { grantCoins } from "@/lib/wallet/service";
import { SIGNUP_BONUS_COINS } from "@/lib/wallet/economy";
const registerSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
});
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = registerSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const passwordHash = await hashPassword(parsed.data.password);
let user;
try {
[user] = await db
.insert(users)
.values({ name: parsed.data.name, email, passwordHash })
.returning();
} catch {
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
}
await grantCoins(user.id, SIGNUP_BONUS_COINS, "signup_bonus");
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name } });
}
+27
View File
@@ -0,0 +1,27 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { getGameById } from "@/lib/games/service";
import { claimCompletionReward } from "@/lib/prestige/service";
import { crystalsForCompletion, type ClickerDefinition } from "@/lib/games/clicker";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const game = await getGameById(id);
if (!game) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (game.templateType !== "clicker") {
return NextResponse.json({ error: "Completion rewards are only for Clicker dungeons" }, { status: 400 });
}
const definition = game.definition as ClickerDefinition;
const reward = crystalsForCompletion(definition.targetLevel);
const result = await claimCompletionReward(session.user.id, id, reward);
return NextResponse.json(result);
}
+59
View File
@@ -0,0 +1,59 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getGameById } from "@/lib/games/service";
import { getProgress, saveProgress } from "@/lib/games/progress";
import { safeParseProgress } from "@/lib/games/types";
import { spendCoins } from "@/lib/wallet/service";
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const state = await getProgress(id, session.user.id);
return NextResponse.json({ state });
}
const postSchema = z.object({
state: z.unknown(),
manual: z.boolean().optional().default(false),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const game = await getGameById(id);
if (!game) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = postSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const stateResult = safeParseProgress(game.templateType, parsed.data.state);
if (!stateResult.success) {
return NextResponse.json({ error: stateResult.error.issues[0]?.message ?? "Invalid state" }, { status: 400 });
}
if (parsed.data.manual) {
const ok = await spendCoins(session.user.id, MANUAL_SAVE_COST, "manual_save", id);
if (!ok) {
return NextResponse.json(
{ error: `Недостаточно монет — нужно ${MANUAL_SAVE_COST}` },
{ status: 402 },
);
}
}
await saveProgress(id, session.user.id, stateResult.data);
return NextResponse.json({ ok: true });
}
+61
View File
@@ -0,0 +1,61 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { getGameById, updateGame, deleteGame } from "@/lib/games/service";
import { safeParseDefinition } from "@/lib/games/types";
const patchSchema = z.object({
title: z.string().min(1).max(120).optional(),
description: z.string().max(500).nullable().optional(),
definition: z.unknown().optional(),
status: z.enum(["draft", "published"]).optional(),
});
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const existing = await getGameById(id);
if (!existing || existing.ownerId !== session.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
let definition = undefined;
if (parsed.data.definition !== undefined) {
const defResult = safeParseDefinition(existing.templateType, parsed.data.definition);
if (!defResult.success) {
return NextResponse.json({ error: defResult.error.issues[0]?.message ?? "Invalid definition" }, { status: 400 });
}
definition = defResult.data;
}
const game = await updateGame(id, session.user.id, {
title: parsed.data.title,
description: parsed.data.description,
definition,
status: parsed.data.status,
});
return NextResponse.json({ game });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession();
if (!session) return response;
const { id } = await params;
const ok = await deleteGame(id, session.user.id);
if (!ok) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
+40
View File
@@ -0,0 +1,40 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { createGame } from "@/lib/games/service";
import { TEMPLATE_TYPES, safeParseDefinition } from "@/lib/games/types";
const createSchema = z.object({
title: z.string().min(1).max(120),
description: z.string().max(500).optional(),
templateType: z.enum(TEMPLATE_TYPES),
definition: z.unknown(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
const defResult = safeParseDefinition(parsed.data.templateType, parsed.data.definition);
if (!defResult.success) {
return NextResponse.json({ error: defResult.error.issues[0]?.message ?? "Invalid definition" }, { status: 400 });
}
const game = await createGame({
ownerId: session.user.id,
title: parsed.data.title,
description: parsed.data.description,
templateType: parsed.data.templateType,
definition: defResult.data,
});
return NextResponse.json({ game }, { status: 201 });
}
+12
View File
@@ -0,0 +1,12 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { getPrestige } from "@/lib/prestige/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
return NextResponse.json(await getPrestige(session.user.id));
}
+26
View File
@@ -0,0 +1,26 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { buyPrestigeUpgrade, getPrestige } from "@/lib/prestige/service";
const upgradeSchema = z.object({ kind: z.enum(["click", "gold"]) });
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = upgradeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const ok = await buyPrestigeUpgrade(session.user.id, parsed.data.kind);
if (!ok) {
return NextResponse.json({ error: "Недостаточно кристаллов" }, { status: 402 });
}
return NextResponse.json(await getPrestige(session.user.id));
}
+53
View File
@@ -0,0 +1,53 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/auth/require";
import { db } from "@/lib/db/client";
import { coinPurchases } from "@/lib/db/schema";
import { COIN_PACKAGES } from "@/lib/wallet/economy";
import { getPaymentProvider } from "@/lib/payments/provider";
const purchaseSchema = z.object({ packageId: z.string() });
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = purchaseSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const pkg = COIN_PACKAGES.find((p) => p.id === parsed.data.packageId);
if (!pkg) {
return NextResponse.json({ error: "Unknown package" }, { status: 400 });
}
const [purchase] = await db
.insert(coinPurchases)
.values({ userId: session.user.id, coins: pkg.coins, priceRub: pkg.priceRub, status: "pending" })
.returning();
const provider = getPaymentProvider();
if (!provider) {
return NextResponse.json(
{ error: "Оплата пока не подключена — мы работаем над этим, попробуйте позже" },
{ status: 400 },
);
}
try {
const { redirectUrl } = await provider.createPayment({
purchaseId: purchase.id,
userId: session.user.id,
coins: pkg.coins,
priceRub: pkg.priceRub,
});
return NextResponse.json({ redirectUrl });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось создать платёж: ${message}` }, { status: 502 });
}
}
+17
View File
@@ -0,0 +1,17 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require";
import { getBalance, listRecentTransactions } from "@/lib/wallet/service";
export async function GET() {
const { session, response } = await requireSession();
if (!session) return response;
const [balance, transactions] = await Promise.all([
getBalance(session.user.id),
listRecentTransactions(session.user.id),
]);
return NextResponse.json({ balance, transactions });
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect, notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
import { SnakeBuilder } from "@/components/builders/snake-builder";
export default async function ManualCreatePage({ params }: { params: Promise<{ template: string }> }) {
const { template } = await params;
if (!TEMPLATE_TYPES.includes(template as TemplateType)) notFound();
const templateType = template as TemplateType;
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">
Новая игра {TEMPLATE_LABELS[templateType]}
</h1>
<p className="mb-6 text-sm text-text-muted">Сохранится как черновик опубликуете, когда будете готовы.</p>
{templateType === "quiz" && <QuizBuilder mode="create" />}
{templateType === "clicker" && <ClickerBuilder mode="create" />}
{templateType === "maze" && <MazeBuilder mode="create" />}
{templateType === "snake" && <SnakeBuilder mode="create" />}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { AiGenerateForm } from "@/components/ai-generate-form";
export default async function AiCreatePage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Создать с помощью ИИ</h1>
<p className="mb-6 text-sm text-text-muted">
Опишите игру словами ИИ заполнит шаблон, вы проверите и подправите перед сохранением.
</p>
<AiGenerateForm />
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { HelpCircle, MousePointerClick, Map as MapIcon, Worm, Sparkles } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, TEMPLATE_DESCRIPTIONS, type TemplateType } from "@/lib/games/types";
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
quiz: HelpCircle,
clicker: MousePointerClick,
maze: MapIcon,
snake: Worm,
};
export default async function CreatePage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Создать игру</h1>
<p className="mb-8 text-sm text-text-muted">Выберите шаблон вручную или с помощью ИИ по описанию.</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{TEMPLATE_TYPES.map((template) => {
const Icon = TEMPLATE_ICONS[template];
return (
<Link
key={template}
href={`/create/${template}/manual`}
className="card flex flex-col gap-3 p-5 transition-transform hover:-translate-y-0.5"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
<Icon size={20} />
</div>
<h2 className="font-display text-lg font-semibold">{TEMPLATE_LABELS[template]}</h2>
<p className="text-sm text-text-muted">{TEMPLATE_DESCRIPTIONS[template]}</p>
</Link>
);
})}
</div>
<Link
href="/create/ai"
className="card mt-4 flex items-center gap-3 p-5 transition-transform hover:-translate-y-0.5"
>
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
<Sparkles size={20} />
</div>
<div>
<h2 className="font-display text-lg font-semibold">Создать с помощью ИИ</h2>
<p className="text-sm text-text-muted">Опишите игру словами ИИ заполнит шаблон, вы проверите и сохраните.</p>
</div>
</Link>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+171
View File
@@ -0,0 +1,171 @@
@import "tailwindcss";
@theme {
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-display), var(--font-sans), ui-sans-serif, sans-serif;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border);
--color-text: var(--text);
--color-text-muted: var(--text-muted);
--color-text-faint: var(--text-faint);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-soft: var(--accent-soft);
--color-accent-soft-text: var(--accent-soft-text);
--color-danger: var(--danger);
--color-danger-soft: var(--danger-soft);
--color-danger-soft-text: var(--danger-soft-text);
--color-success: var(--success);
--color-success-soft: var(--success-soft);
--color-success-soft-text: var(--success-soft-text);
--radius-sm: var(--radius-sm-val);
--radius-md: var(--radius-md-val);
--radius-lg: var(--radius-lg-val);
}
:root {
--bg: #f7f8fb;
--surface: #ffffff;
--surface-hover: #eef1fa;
--border: #e1e5f0;
--text: #14162b;
--text-muted: #5c6178;
--text-faint: #949ab3;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-soft: #ebeafe;
--accent-soft-text: #4338ca;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--success: #0d9488;
--success-soft: #ecfdf9;
--success-soft-text: #0f766e;
--radius-sm-val: 8px;
--radius-md-val: 12px;
--radius-lg-val: 18px;
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0b14;
--surface: #14162a;
--surface-hover: #1b1e36;
--border: #292d4a;
--text: #eef0fb;
--text-muted: #a3a8c6;
--text-faint: #666b8c;
--accent: #818cf8;
--accent-hover: #6366f1;
--accent-soft: #23264a;
--accent-soft-text: #c7d2fe;
--danger: #f87171;
--danger-soft: #2a1517;
--danger-soft-text: #fca5a5;
--success: #2dd4bf;
--success-soft: #0f2320;
--success-soft-text: #5eead4;
--card-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
* {
border-color: var(--border);
}
html,
body {
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 4px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--card-shadow);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.5;
pointer-events: none;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-ghost:hover {
background: var(--surface-hover);
color: var(--text);
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.15rem 0.55rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-soft-text);
}
+37
View File
@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/header";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin", "cyrillic"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin", "cyrillic"],
});
export const metadata: Metadata = {
title: "tis — топ-игроструктор",
description: "Лаунчер и конструктор браузерных игр",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="ru"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Header />
<main className="flex flex-1 flex-col">{children}</main>
</body>
</html>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { Play } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getGameById } from "@/lib/games/service";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
import { SnakeBuilder } from "@/components/builders/snake-builder";
import type { QuizDefinition } from "@/lib/games/quiz";
import type { ClickerDefinition } from "@/lib/games/clicker";
import type { MazeDefinition } from "@/lib/games/maze";
import type { SnakeDefinition } from "@/lib/games/snake";
export default async function EditGamePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getCurrentSession();
if (!session) redirect("/login");
const game = await getGameById(id);
if (!game || game.ownerId !== session.user.id) notFound();
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<div className="mb-6 flex items-center justify-between gap-4">
<div>
<span className="badge mb-1">{TEMPLATE_LABELS[game.templateType]}</span>
<h1 className="font-display text-2xl font-bold tracking-tight">{game.title}</h1>
</div>
<Link href={`/play/${game.id}`} className="btn btn-ghost">
<Play size={14} />
Играть
</Link>
</div>
{game.templateType === "quiz" && (
<QuizBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as QuizDefinition}
/>
)}
{game.templateType === "clicker" && (
<ClickerBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as ClickerDefinition}
/>
)}
{game.templateType === "maze" && (
<MazeBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as MazeDefinition}
/>
)}
{game.templateType === "snake" && (
<SnakeBuilder
mode="edit"
gameId={game.id}
initialTitle={game.title}
initialDescription={game.description ?? ""}
initialDefinition={game.definition as SnakeDefinition}
/>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listGamesByOwner } from "@/lib/games/service";
import { MyGamesList } from "@/components/my-games-list";
export default async function MyGamesPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const games = await listGamesByOwner(session.user.id);
return (
<div className="mx-auto w-full max-w-3xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Мои игры</h1>
<p className="mb-6 text-sm text-text-muted">Управляйте своими играми редактируйте, публикуйте, удаляйте.</p>
<MyGamesList initialGames={games} />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import Link from "next/link";
import { Gamepad2, Plus } from "lucide-react";
import { listPublishedGames } from "@/lib/games/service";
import { getCurrentSession } from "@/lib/auth/session";
import { GameCard } from "@/components/game-card";
export default async function Home() {
const [games, session] = await Promise.all([listPublishedGames(), getCurrentSession()]);
return (
<div className="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="font-display text-2xl font-bold tracking-tight">Лаунчер</h1>
<p className="mt-1 text-sm text-text-muted">Играйте в игры, которые сделали другие пользователи.</p>
</div>
<Link href={session ? "/create" : "/register"} className="btn btn-primary">
<Plus size={16} />
Создать игру
</Link>
</div>
{games.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-12 text-center">
<Gamepad2 size={32} className="text-text-faint" />
<p className="text-text-muted">Пока нет опубликованных игр будьте первым, кто её создаст.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{games.map((game) => (
<GameCard
key={game.id}
id={game.id}
title={game.title}
description={game.description}
templateType={game.templateType}
playCount={game.playCount}
/>
))}
</div>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { notFound } from "next/navigation";
import { getGameById, incrementPlayCount } from "@/lib/games/service";
import { getProgress } from "@/lib/games/progress";
import { getBalance } from "@/lib/wallet/service";
import { getPrestige } from "@/lib/prestige/service";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import { PlaySession } from "@/components/play-session";
const TEMPLATE_HINTS: Record<string, string> = {
quiz: "Выбирайте вариант ответа мышкой.",
clicker: "Кликайте по кругу и покупайте улучшения.",
maze: "Управление — стрелки на клавиатуре.",
};
export default async function PlayPage({ params }: { params: Promise<{ gameId: string }> }) {
const { gameId } = await params;
const game = await getGameById(gameId);
if (!game) notFound();
const session = await getCurrentSession();
if (game.status === "draft") {
if (!session || session.user.id !== game.ownerId) notFound();
} else {
await incrementPlayCount(gameId);
}
const [initialState, initialBalance] = session
? await Promise.all([getProgress(gameId, session.user.id), getBalance(session.user.id)])
: [null, 0];
const accountBonuses =
session && game.templateType === "clicker" ? await getPrestige(session.user.id) : undefined;
return (
<div className="flex flex-1 flex-col">
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
<div className="mb-1 flex items-center gap-2">
<span className="badge">{TEMPLATE_LABELS[game.templateType]}</span>
{game.status === "draft" && <span className="badge bg-danger-soft text-danger-soft-text">Черновик</span>}
</div>
<h1 className="font-display text-2xl font-bold tracking-tight">{game.title}</h1>
{game.description && <p className="mt-1 text-sm text-text-muted">{game.description}</p>}
<p className="mt-1 text-xs text-text-faint">{TEMPLATE_HINTS[game.templateType]}</p>
</div>
<PlaySession
gameId={game.id}
templateType={game.templateType}
definition={game.definition}
initialState={initialState ?? undefined}
initialBalance={initialBalance}
accountBonuses={accountBonuses}
canSave={Boolean(session)}
/>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getAiStatus } from "@/lib/ai/config";
import { AiSettingsForm } from "@/components/ai-settings-form";
export default async function AiSettingsPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const status = await getAiStatus();
return (
<div className="mx-auto w-full max-w-lg flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Настройки ИИ</h1>
<p className="mb-6 text-sm text-text-muted">
Ключ Anthropic API нужен, чтобы создавать игры по текстовому описанию. Отдельный от того, чем управляется
Claude Code со своей оплатой.
</p>
<AiSettingsForm initialStatus={status} />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getPrestige } from "@/lib/prestige/service";
import { PrestigeShopPanel } from "@/components/prestige-shop-panel";
export default async function ShopPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const prestige = await getPrestige(session.user.id);
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Магазин престижа</h1>
<p className="mb-6 text-sm text-text-muted">
Кристаллы зарабатываются за прохождение кликер-подземелий до конца отдельная валюта от монет в кошельке,
тратится на постоянные улучшения на аккаунте.
</p>
<PrestigeShopPanel initialPrestige={prestige} />
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { Coins } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getBalance, listRecentTransactions } from "@/lib/wallet/service";
import { COIN_PACKAGES } from "@/lib/wallet/economy";
import { WalletPurchasePanel } from "@/components/wallet-purchase-panel";
const REASON_LABELS: Record<string, string> = {
signup_bonus: "Бонус за регистрацию",
manual_save: "Ручное сохранение",
purchase: "Пополнение",
};
export default async function WalletPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const [balance, transactions] = await Promise.all([
getBalance(session.user.id),
listRecentTransactions(session.user.id),
]);
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Монеты</h1>
<p className="mb-6 text-sm text-text-muted">
Монеты нужны для мгновенного ручного сохранения прогресса в играх автосохранение при этом всегда бесплатное.
</p>
<div className="card mb-6 flex items-center gap-3 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
<Coins size={20} />
</div>
<div>
<p className="text-2xl font-bold">{balance}</p>
<p className="text-sm text-text-muted">монет на балансе</p>
</div>
</div>
<WalletPurchasePanel packages={COIN_PACKAGES} />
<div className="card mt-6 p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">История</h2>
{transactions.length === 0 ? (
<p className="text-sm text-text-muted">Пока пусто.</p>
) : (
<div className="flex flex-col divide-y divide-border">
{transactions.map((t) => (
<div key={t.id} className="flex items-center justify-between py-2 text-sm">
<span className="text-text-muted">{REASON_LABELS[t.reason] ?? t.reason}</span>
<span className={t.amount >= 0 ? "text-success" : "text-danger"}>
{t.amount >= 0 ? "+" : ""}
{t.amount}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { TEMPLATE_TYPES, TEMPLATE_LABELS, type TemplateType, type GameDefinition } from "@/lib/games/types";
import type { QuizDefinition } from "@/lib/games/quiz";
import type { ClickerDefinition } from "@/lib/games/clicker";
import type { MazeDefinition } from "@/lib/games/maze";
import type { SnakeDefinition } from "@/lib/games/snake";
import { QuizBuilder } from "@/components/builders/quiz-builder";
import { ClickerBuilder } from "@/components/builders/clicker-builder";
import { MazeBuilder } from "@/components/builders/maze-builder";
import { SnakeBuilder } from "@/components/builders/snake-builder";
export function AiGenerateForm() {
const [templateType, setTemplateType] = useState<TemplateType>("quiz");
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [definition, setDefinition] = useState<GameDefinition | null>(null);
async function handleGenerate(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
setDefinition(null);
const res = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templateType, prompt }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сгенерировать игру");
return;
}
const data = await res.json();
setDefinition(data.definition);
}
if (definition) {
return (
<div className="flex flex-col gap-4">
<p className="rounded-md bg-accent-soft px-3 py-2 text-sm text-accent-soft-text">
Игра сгенерирована проверьте и подправьте перед сохранением.
</p>
{templateType === "quiz" && (
<QuizBuilder mode="create" initialTitle={title} initialDefinition={definition as QuizDefinition} />
)}
{templateType === "clicker" && (
<ClickerBuilder mode="create" initialTitle={title} initialDefinition={definition as ClickerDefinition} />
)}
{templateType === "maze" && (
<MazeBuilder mode="create" initialTitle={title} initialDefinition={definition as MazeDefinition} />
)}
{templateType === "snake" && (
<SnakeBuilder mode="create" initialTitle={title} initialDefinition={definition as SnakeDefinition} />
)}
</div>
);
}
return (
<form onSubmit={handleGenerate} className="card flex flex-col gap-4 p-5">
<div className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Тип игры</span>
<div className="flex gap-2">
{TEMPLATE_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => setTemplateType(t)}
className={`btn ${templateType === t ? "btn-primary" : "btn-ghost"}`}
>
{TEMPLATE_LABELS[t]}
</button>
))}
</div>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название игры</span>
<input
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Опишите игру</span>
<textarea
required
rows={5}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Например: викторина на 5 вопросов про космос для детей"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading || !title || !prompt} className="btn btn-primary self-start">
<Sparkles size={15} />
{loading ? "Генерируем…" : "Сгенерировать"}
</button>
</form>
);
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { CheckCircle2, XCircle } from "lucide-react";
interface Status {
configured: boolean;
enabled: boolean;
model: string;
verifiedAt: number | null;
}
export function AiSettingsForm({ initialStatus }: { initialStatus: Status }) {
const [status, setStatus] = useState(initialStatus);
const [apiKey, setApiKey] = useState("");
const [model, setModel] = useState(initialStatus.model);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/ai/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey, model }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось подключить ИИ");
return;
}
setStatus({ configured: true, enabled: true, model, verifiedAt: Date.now() });
setApiKey("");
}
async function handleDisable() {
setLoading(true);
await fetch("/api/ai/config", { method: "DELETE" });
setLoading(false);
setStatus((s) => ({ ...s, enabled: false }));
}
return (
<div className="card p-5">
<div className="mb-4 flex items-center gap-2 text-sm">
{status.enabled ? (
<>
<CheckCircle2 size={16} className="text-success" />
<span>Подключено: {status.model}</span>
</>
) : (
<>
<XCircle size={16} className="text-text-faint" />
<span className="text-text-muted">ИИ не подключён</span>
</>
)}
</div>
<form onSubmit={handleConnect}>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Anthropic API key</span>
<input
required
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Модель</span>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button type="submit" disabled={loading || !apiKey} className="btn btn-primary">
{status.configured ? "Обновить ключ" : "Подключить"}
</button>
{status.enabled && (
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
Отключить
</button>
)}
</div>
</form>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
import {
clickerDefinitionSchema,
DEFAULT_CLICKER_DEFINITION,
heroRoleLabel,
type ClickerDefinition,
type ClickerHero,
} from "@/lib/games/clicker";
export function ClickerBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: ClickerDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [theme, setTheme] = useState(initialDefinition?.theme ?? DEFAULT_CLICKER_DEFINITION.theme);
const [targetLevel, setTargetLevel] = useState(initialDefinition?.targetLevel ?? DEFAULT_CLICKER_DEFINITION.targetLevel);
const [startingClickDamage, setStartingClickDamage] = useState(
initialDefinition?.startingClickDamage ?? DEFAULT_CLICKER_DEFINITION.startingClickDamage,
);
const [baseMonsterHp, setBaseMonsterHp] = useState(initialDefinition?.baseMonsterHp ?? DEFAULT_CLICKER_DEFINITION.baseMonsterHp);
const [monsterEmojisText, setMonsterEmojisText] = useState(
(initialDefinition?.monsterEmojis ?? DEFAULT_CLICKER_DEFINITION.monsterEmojis).join(", "),
);
const [bossEmoji, setBossEmoji] = useState(initialDefinition?.bossEmoji ?? DEFAULT_CLICKER_DEFINITION.bossEmoji);
const [heroes, setHeroes] = useState<ClickerHero[]>(initialDefinition?.heroes ?? DEFAULT_CLICKER_DEFINITION.heroes);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function updateHero(index: number, patch: Partial<ClickerHero>) {
setHeroes((hs) => hs.map((h, i) => (i === index ? { ...h, ...patch } : h)));
}
function addHero() {
setHeroes((hs) => [...hs, { name: "", baseCost: 10, clickDamageBonus: 0, dpsBonus: 0 }]);
}
function removeHero(index: number) {
setHeroes((hs) => hs.filter((_, i) => i !== index));
}
async function handleSave() {
setError(null);
const monsterEmojis = monsterEmojisText
.split(",")
.map((e) => e.trim())
.filter((e) => e.length > 0);
const definition: ClickerDefinition = {
theme,
targetLevel,
startingClickDamage,
baseMonsterHp,
monsterEmojis,
bossEmoji,
heroes,
};
const parsed = clickerDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте настройки — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "clicker", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Тема</span>
<input
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Уровней до победы (5200)</span>
<input
type="number"
min={5}
max={200}
value={targetLevel}
onChange={(e) => setTargetLevel(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Урон за клик (старт)</span>
<input
type="number"
min={1}
value={startingClickDamage}
onChange={(e) => setStartingClickDamage(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">HP первого монстра</span>
<input
type="number"
min={1}
value={baseMonsterHp}
onChange={(e) => setBaseMonsterHp(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Эмодзи монстров (через запятую, чередуются по уровням)</span>
<input
value={monsterEmojisText}
onChange={(e) => setMonsterEmojisText(e.target.value)}
placeholder="👹, 👺, 💀, 🧟"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="w-32 text-sm">
<span className="mb-1 block font-medium text-text-muted">Эмодзи босса</span>
<input
value={bossEmoji}
onChange={(e) => setBossEmoji(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<p className="text-xs text-text-faint">
Каждый 5-й уровень босс (HP ×4, таймер на удар). Герои покупаются многократно каждый следующий уровень
дороже и сильнее.
</p>
</div>
<div className="card flex flex-col gap-3 p-4">
<span className="text-sm font-semibold text-text-muted">Герои</span>
{heroes.map((hero, index) => (
<div key={index} className="flex flex-col gap-1">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
<input
value={hero.name}
onChange={(e) => updateHero(index, { name: e.target.value })}
placeholder="Название"
className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={1}
value={hero.baseCost}
onChange={(e) => updateHero(index, { baseCost: Number(e.target.value) })}
title="Базовая цена"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={0}
value={hero.clickDamageBonus}
onChange={(e) => updateHero(index, { clickDamageBonus: Number(e.target.value) })}
title="Урон за клик за уровень"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<input
type="number"
min={0}
value={hero.dpsBonus}
onChange={(e) => updateHero(index, { dpsBonus: Number(e.target.value) })}
title="Урон в секунду за уровень"
className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
/>
<button onClick={() => removeHero(index)} className="text-text-faint hover:text-danger">
<Trash2 size={14} />
</button>
</div>
{heroRoleLabel(hero) && <span className="text-xs text-text-faint">{heroRoleLabel(hero)}</span>}
</div>
))}
<div className="flex gap-4 text-xs text-text-faint">
<span>Цена</span>
<span className="ml-auto">Урон/клик</span>
<span>Урон/сек</span>
</div>
<button onClick={addHero} className="btn btn-ghost self-start">
<Plus size={15} />
Добавить героя
</button>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Save } from "lucide-react";
import { mazeDefinitionSchema, DEFAULT_MAZE_DEFINITION, type MazeDefinition } from "@/lib/games/maze";
export function MazeBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: MazeDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [gridText, setGridText] = useState((initialDefinition?.grid ?? DEFAULT_MAZE_DEFINITION.grid).join("\n"));
const [timeLimitSec, setTimeLimitSec] = useState(initialDefinition?.timeLimitSec ?? 0);
const [lives, setLives] = useState(initialDefinition?.lives ?? DEFAULT_MAZE_DEFINITION.lives);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSave() {
setError(null);
const grid = gridText.split("\n").map((row) => row.trimEnd()).filter((row) => row.length > 0);
const definition: MazeDefinition = { grid, timeLimitSec, lives };
const parsed = mazeDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте лабиринт — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "maze", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Лимит времени, сек (0 без лимита)</span>
<input
type="number"
min={0}
max={600}
value={timeLimitSec}
onChange={(e) => setTimeLimitSec(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Жизни</span>
<input
type="number"
min={1}
max={9}
value={lives}
onChange={(e) => setLives(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
</div>
<div className="card flex flex-col gap-2 p-4">
<span className="text-sm font-medium text-text-muted">
Лабиринт в стиле Pac-Man каждая строка ряд. <code className="text-text-faint">#</code> стена,{" "}
<code className="text-text-faint">.</code> пол, <code className="text-text-faint">S</code> старт (один),{" "}
<code className="text-text-faint">E</code> выход, <code className="text-text-faint">*</code> точка,{" "}
<code className="text-text-faint">O</code> усиливающая точка (пугает привидений),{" "}
<code className="text-text-faint">G</code> призрак (до 4). Нужно собрать все точки и дойти до выхода.
</span>
<textarea
value={gridText}
onChange={(e) => setGridText(e.target.value)}
rows={12}
spellCheck={false}
className="w-full resize-y rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
/>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
import {
quizDefinitionSchema,
DEFAULT_QUIZ_DEFINITION,
QUIZ_CATEGORIES,
type QuizDefinition,
type QuizQuestion,
} from "@/lib/games/quiz";
export function QuizBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: QuizDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [questions, setQuestions] = useState<QuizQuestion[]>(
initialDefinition?.questions ?? DEFAULT_QUIZ_DEFINITION.questions,
);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
function updateQuestion(index: number, patch: Partial<QuizQuestion>) {
setQuestions((qs) => qs.map((q, i) => (i === index ? { ...q, ...patch } : q)));
}
function updateOption(qIndex: number, optIndex: number, value: string) {
setQuestions((qs) =>
qs.map((q, i) => (i === qIndex ? { ...q, options: q.options.map((o, j) => (j === optIndex ? value : o)) } : q)),
);
}
function addQuestion() {
setQuestions((qs) => [
...qs,
{ category: QUIZ_CATEGORIES[0], band: 1, question: "", options: ["", "", "", ""], correctIndex: 0 },
]);
}
function removeQuestion(index: number) {
setQuestions((qs) => qs.filter((_, i) => i !== index));
}
async function handleSave() {
setError(null);
const definition: QuizDefinition = { questions };
const parsed = quizDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте вопросы — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "quiz", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<p className="text-xs text-text-faint">
Денежная лестница и несгораемые суммы фиксированы (15 уровней). У каждого вопроса рубрика и уровень
сложности 15; в каждой из 5 сложностей должен быть хотя бы один вопрос.
</p>
</div>
{questions.map((q, qIndex) => (
<div key={qIndex} className="card flex flex-col gap-3 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-text-muted">Вопрос {qIndex + 1}</span>
{questions.length > 1 && (
<button onClick={() => removeQuestion(qIndex)} className="btn btn-ghost text-danger">
<Trash2 size={14} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Рубрика</span>
<select
value={q.category}
onChange={(e) => updateQuestion(qIndex, { category: e.target.value as QuizQuestion["category"] })}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
{QUIZ_CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Сложность (15)</span>
<select
value={q.band}
onChange={(e) => updateQuestion(qIndex, { band: Number(e.target.value) })}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
>
{[1, 2, 3, 4, 5].map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</label>
</div>
<input
value={q.question}
onChange={(e) => updateQuestion(qIndex, { question: e.target.value })}
placeholder="Текст вопроса"
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
{q.options.map((option, optIndex) => (
<div key={optIndex} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${qIndex}`}
checked={q.correctIndex === optIndex}
onChange={() => updateQuestion(qIndex, { correctIndex: optIndex })}
title="Правильный ответ"
/>
<input
value={option}
onChange={(e) => updateOption(qIndex, optIndex, e.target.value)}
placeholder={`Вариант ${optIndex + 1}`}
className="flex-1 rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
/>
</div>
))}
</div>
))}
<button onClick={addQuestion} className="btn btn-ghost self-start">
<Plus size={15} />
Добавить вопрос
</button>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Save } from "lucide-react";
import { snakeDefinitionSchema, DEFAULT_SNAKE_DEFINITION, type SnakeDefinition } from "@/lib/games/snake";
export function SnakeBuilder({
mode,
gameId,
initialTitle,
initialDescription,
initialDefinition,
}: {
mode: "create" | "edit";
gameId?: string;
initialTitle?: string;
initialDescription?: string;
initialDefinition?: SnakeDefinition;
}) {
const router = useRouter();
const [title, setTitle] = useState(initialTitle ?? "");
const [description, setDescription] = useState(initialDescription ?? "");
const [width, setWidth] = useState(initialDefinition?.width ?? DEFAULT_SNAKE_DEFINITION.width);
const [height, setHeight] = useState(initialDefinition?.height ?? DEFAULT_SNAKE_DEFINITION.height);
const [startLength, setStartLength] = useState(initialDefinition?.startLength ?? DEFAULT_SNAKE_DEFINITION.startLength);
const [targetLength, setTargetLength] = useState(
initialDefinition?.targetLength ?? DEFAULT_SNAKE_DEFINITION.targetLength,
);
const [wrapAround, setWrapAround] = useState(initialDefinition?.wrapAround ?? DEFAULT_SNAKE_DEFINITION.wrapAround);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function handleSave() {
setError(null);
const definition: SnakeDefinition = { width, height, startLength, targetLength, wrapAround };
const parsed = snakeDefinitionSchema.safeParse(definition);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? "Проверьте настройки — что-то заполнено неверно");
return;
}
if (!title.trim()) {
setError("Укажите название игры");
return;
}
setSaving(true);
const url = mode === "create" ? "/api/games" : `/api/games/${gameId}`;
const method = mode === "create" ? "POST" : "PATCH";
const body =
mode === "create"
? { title, description: description || undefined, templateType: "snake", definition: parsed.data }
: { title, description: description || null, definition: parsed.data };
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setSaving(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось сохранить");
return;
}
if (mode === "create") {
const { game } = await res.json();
router.push(`/my-games/${game.id}/edit`);
} else {
router.refresh();
}
}
return (
<div className="flex flex-col gap-4">
<div className="card flex flex-col gap-3 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Описание (необязательно)</span>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Ширина поля (8-40)</span>
<input
type="number"
min={8}
max={40}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Высота поля (8-40)</span>
<input
type="number"
min={8}
max={40}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Начальная длина</span>
<input
type="number"
min={1}
max={10}
value={startLength}
onChange={(e) => setStartLength(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Длина для победы</span>
<input
type="number"
min={5}
max={200}
value={targetLength}
onChange={(e) => setTargetLength(Number(e.target.value))}
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
/>
</label>
</div>
<label className="flex items-center gap-2 text-sm text-text-muted">
<input type="checkbox" checked={wrapAround} onChange={(e) => setWrapAround(e.target.checked)} />
Проходить сквозь стены (выход с одного края вход с другого)
</label>
<p className="text-xs text-text-faint">
Скорость растёт по мере роста змейки автоматически. Длина для победы не может превышать ширину × высоту.
</p>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button onClick={handleSave} disabled={saving} className="btn btn-primary self-start">
<Save size={15} />
{saving ? "Сохраняем…" : "Сохранить"}
</button>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import type Phaser from "phaser";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
import type { ClickerAccountBonuses } from "@/lib/games/clicker";
export type TouchDirection = "up" | "down" | "left" | "right";
export type GameCanvasHandle = {
sendDirection: (dir: TouchDirection) => void;
};
export const GameCanvas = forwardRef<
GameCanvasHandle,
{
templateType: TemplateType;
definition: GameDefinition;
initialState?: GameProgress;
accountBonuses?: ClickerAccountBonuses;
onFinish?: (result: GameResult) => void;
onProgress?: (state: GameProgress) => void;
}
>(function GameCanvas({ templateType, definition, initialState, accountBonuses, onFinish, onProgress }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(null);
useImperativeHandle(ref, () => ({
sendDirection: (dir) => {
gameRef.current?.registry.events.emit("touchDirection", dir);
},
}));
useEffect(() => {
let cancelled = false;
(async () => {
const [{ default: PhaserLib }, { sceneClassFor }] = await Promise.all([
import("phaser"),
import("@/lib/games/scenes"),
]);
if (cancelled || !containerRef.current) return;
const SceneClass = sceneClassFor(templateType);
const game = new PhaserLib.Game({
type: PhaserLib.AUTO,
parent: containerRef.current,
backgroundColor: "#0a0b14",
scale: {
mode: PhaserLib.Scale.FIT,
autoCenter: PhaserLib.Scale.CENTER_BOTH,
width: 960,
height: 720,
},
scene: [SceneClass],
});
gameRef.current = game;
game.registry.set("definition", definition);
game.registry.set("initialState", initialState);
game.registry.set("accountBonuses", accountBonuses);
game.registry.set("onFinish", onFinish);
game.registry.set("onProgress", onProgress);
})();
return () => {
cancelled = true;
gameRef.current?.destroy(true);
gameRef.current = null;
};
// Intentionally only re-creating the game when the template changes —
// callers that need a fresh definition/result loaded should force a
// remount via a `key` prop rather than mutating props in place.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateType]);
return <div ref={containerRef} className="aspect-[4/3] w-full overflow-hidden rounded-lg bg-black" />;
});
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { HelpCircle, MousePointerClick, Map as MapIcon, Worm, Play } from "lucide-react";
import { TEMPLATE_LABELS, type TemplateType } from "@/lib/games/types";
const TEMPLATE_ICONS: Record<TemplateType, typeof HelpCircle> = {
quiz: HelpCircle,
clicker: MousePointerClick,
maze: MapIcon,
snake: Worm,
};
export function GameCard({
id,
title,
description,
templateType,
playCount,
}: {
id: string;
title: string;
description: string | null;
templateType: TemplateType;
playCount: number;
}) {
const Icon = TEMPLATE_ICONS[templateType];
return (
<Link href={`/play/${id}`} className="card group flex flex-col gap-3 p-4 transition-transform hover:-translate-y-0.5">
<div className="flex items-center gap-2">
<span className="badge">
<Icon size={13} />
{TEMPLATE_LABELS[templateType]}
</span>
</div>
<h3 className="font-display text-lg font-semibold leading-snug">{title}</h3>
{description && <p className="line-clamp-2 text-sm text-text-muted">{description}</p>}
<div className="mt-auto flex items-center justify-between pt-2 text-xs text-text-faint">
<span>{playCount} игр сыграно</span>
<span className="flex items-center gap-1 font-medium text-accent group-hover:underline">
<Play size={12} />
Играть
</span>
</div>
</Link>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from "next/link";
import { Gamepad2, Coins, Gem } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getBalance } from "@/lib/wallet/service";
import { getPrestige } from "@/lib/prestige/service";
import { LogoutButton } from "./logout-button";
export async function Header() {
const session = await getCurrentSession();
const [balance, prestige] = session
? await Promise.all([getBalance(session.user.id), getPrestige(session.user.id)])
: [null, null];
return (
<header className="border-b border-border bg-surface">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 sm:px-6">
<Link href="/" className="flex items-center gap-2 font-display text-[15px] font-bold tracking-tight">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-accent text-white">
<Gamepad2 size={15} strokeWidth={2.5} />
</div>
tis
</Link>
<div className="ml-auto flex items-center gap-2">
{session ? (
<>
<Link
href="/wallet"
className="flex items-center gap-1.5 rounded-full bg-accent-soft px-2.5 py-1 text-sm font-medium text-accent-soft-text hover:opacity-90"
title="Монеты"
>
<Coins size={13} />
{balance}
</Link>
<Link
href="/shop"
className="flex items-center gap-1.5 rounded-full bg-accent-soft px-2.5 py-1 text-sm font-medium text-accent-soft-text hover:opacity-90"
title="Кристаллы престижа"
>
<Gem size={13} />
{prestige?.crystals ?? 0}
</Link>
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton />
</>
) : (
<>
<Link href="/login" className="btn btn-ghost">
Войти
</Link>
<Link href="/register" className="btn btn-primary">
Регистрация
</Link>
</>
)}
</div>
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:overflow-visible">
<nav className="flex items-center gap-1 text-sm font-medium text-text-muted">
<Link href="/" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Лаунчер
</Link>
{session && (
<>
<Link href="/create" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Создать
</Link>
<Link href="/my-games" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Мои игры
</Link>
<Link
href="/settings/ai"
className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text"
>
Настройки ИИ
</Link>
</>
)}
</nav>
</div>
</div>
</header>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { useRouter } from "next/navigation";
import { LogOut } from "lucide-react";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/");
router.refresh();
}
return (
<button onClick={handleLogout} className="btn btn-ghost" title="Выйти">
<LogOut size={15} />
</button>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Play, Pencil, Trash2, Eye, EyeOff } from "lucide-react";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import type { GameSummaryDTO } from "@/lib/games/service";
export function MyGamesList({ initialGames }: { initialGames: GameSummaryDTO[] }) {
const [games, setGames] = useState(initialGames);
const [error, setError] = useState<string | null>(null);
async function togglePublish(game: GameSummaryDTO) {
setError(null);
const nextStatus = game.status === "published" ? "draft" : "published";
const res = await fetch(`/api/games/${game.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
});
if (!res.ok) {
setError("Не удалось изменить статус публикации");
return;
}
setGames((gs) => gs.map((g) => (g.id === game.id ? { ...g, status: nextStatus } : g)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/games/${id}`, { method: "DELETE" });
if (!res.ok) {
setError("Не удалось удалить игру");
return;
}
setGames((gs) => gs.filter((g) => g.id !== id));
}
if (games.length === 0) {
return <p className="text-sm text-text-muted">Вы ещё не создали ни одной игры.</p>;
}
return (
<div className="flex flex-col gap-3">
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{games.map((game) => (
<div key={game.id} className="card flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="badge">{TEMPLATE_LABELS[game.templateType]}</span>
<span
className={`badge ${
game.status === "published" ? "bg-success-soft text-success-soft-text" : "bg-surface-hover text-text-muted"
}`}
>
{game.status === "published" ? "Опубликовано" : "Черновик"}
</span>
</div>
<p className="truncate font-medium">{game.title}</p>
<p className="text-xs text-text-faint">{game.playCount} игр сыграно</p>
</div>
<Link href={`/play/${game.id}`} className="btn btn-ghost" title="Играть">
<Play size={14} />
</Link>
<Link href={`/my-games/${game.id}/edit`} className="btn btn-ghost" title="Редактировать">
<Pencil size={14} />
</Link>
<button
onClick={() => togglePublish(game)}
className="btn btn-ghost"
title={game.status === "published" ? "Снять с публикации" : "Опубликовать"}
>
{game.status === "published" ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => handleDelete(game.id)} className="btn btn-ghost text-danger" title="Удалить">
<Trash2 size={14} />
</button>
</div>
))}
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { RotateCcw, Trophy, XCircle, Save, Clock, Gem } from "lucide-react";
import { GameCanvas, type GameCanvasHandle } from "./game-canvas";
import { TouchDpad } from "./touch-dpad";
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
import type { ClickerAccountBonuses } from "@/lib/games/clicker";
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
export function PlaySession({
gameId,
templateType,
definition,
initialState,
initialBalance,
accountBonuses,
canSave,
}: {
gameId: string;
templateType: TemplateType;
definition: GameDefinition;
initialState?: GameProgress;
initialBalance: number;
accountBonuses?: ClickerAccountBonuses;
canSave: boolean;
}) {
const [attempt, setAttempt] = useState(0);
const [result, setResult] = useState<GameResult | null>(null);
const [crystalsAwarded, setCrystalsAwarded] = useState<number | null>(null);
const [balance, setBalance] = useState(initialBalance);
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
const [lastSavedAt, setLastSavedAt] = useState<number | null>(() => (initialState ? Date.now() : null));
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const latestStateRef = useRef<GameProgress | null>(initialState ?? null);
const gameCanvasRef = useRef<GameCanvasHandle>(null);
const handleProgress = useCallback((state: GameProgress) => {
latestStateRef.current = state;
setHasProgress(true);
}, []);
const save = useCallback(
async (manual: boolean) => {
const state = latestStateRef.current;
if (!state) return;
if (manual) {
setSaving(true);
setSaveError(null);
}
const res = await fetch(`/api/games/${gameId}/progress`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, manual }),
});
if (manual) setSaving(false);
if (!res.ok) {
if (manual) {
const data = await res.json().catch(() => null);
setSaveError(data?.error ?? "Не удалось сохранить");
}
return;
}
setLastSavedAt(Date.now());
if (manual) setBalance((b) => b - MANUAL_SAVE_COST);
},
[gameId],
);
useEffect(() => {
if (!canSave) return;
const interval = setInterval(() => save(false), AUTOSAVE_INTERVAL_MS);
return () => clearInterval(interval);
}, [canSave, save]);
useEffect(() => {
if (!result?.won || !canSave || templateType !== "clicker") return;
let cancelled = false;
fetch(`/api/games/${gameId}/complete`, { method: "POST" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled && data?.claimed) setCrystalsAwarded(data.crystalsAwarded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [result, gameId, templateType, canSave]);
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-1 py-4 sm:px-4 sm:py-6">
<div className="relative rounded-xl border border-border bg-gradient-to-b from-accent-soft/40 to-surface p-2 shadow-lg sm:p-3">
<GameCanvas
ref={gameCanvasRef}
key={attempt}
templateType={templateType}
definition={definition}
initialState={initialState}
accountBonuses={accountBonuses}
onFinish={setResult}
onProgress={canSave ? handleProgress : undefined}
/>
{result && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/70 backdrop-blur-sm">
<div className="card flex flex-col items-center gap-3 p-8 text-center">
{result.won ? (
<Trophy size={32} className="text-accent" />
) : (
<XCircle size={32} className="text-danger" />
)}
<h2 className="font-display text-xl font-semibold">{result.won ? "Победа!" : "Игра окончена"}</h2>
{result.message && <p className="text-sm text-text-muted">{result.message}</p>}
{crystalsAwarded !== null && (
<p className="flex items-center gap-1.5 text-sm font-medium text-accent-soft-text">
<Gem size={14} />+{crystalsAwarded} кристаллов престижа
</p>
)}
<div className="mt-2 flex gap-2">
<button
onClick={() => {
setResult(null);
setCrystalsAwarded(null);
setAttempt((a) => a + 1);
}}
className="btn btn-primary"
>
<RotateCcw size={15} />
Играть снова
</button>
<Link href="/" className="btn btn-ghost">
В лаунчер
</Link>
</div>
</div>
</div>
)}
</div>
{(templateType === "maze" || templateType === "snake") && (
<TouchDpad onDirection={(dir) => gameCanvasRef.current?.sendDirection(dir)} />
)}
{canSave && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-1.5 text-text-muted">
<Clock size={13} />
<span>
Прогресс сохраняется автоматически раз в 10 минут.
{lastSavedAt &&
` Сохранено в ${new Date(lastSavedAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}.`}
</span>
</div>
<div className="flex items-center gap-2">
{saveError && (
<span className="text-danger">
{saveError}{" "}
<Link href="/wallet" className="underline">
Пополнить
</Link>
</span>
)}
<button
onClick={() => save(true)}
disabled={saving || !hasProgress || balance < MANUAL_SAVE_COST}
title={!hasProgress ? "Пока нечего сохранять" : undefined}
className="btn btn-ghost"
>
<Save size={14} />
Сохранить сейчас {MANUAL_SAVE_COST} монет (баланс: {balance})
</button>
</div>
</div>
)}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useState } from "react";
import { Gem, MousePointerClick, Coins as CoinsIcon } from "lucide-react";
import { PRESTIGE_BONUS_PER_LEVEL, prestigeUpgradeCostAtLevel } from "@/lib/games/clicker";
interface Prestige {
crystals: number;
clickBonusLevel: number;
goldBonusLevel: number;
}
const UPGRADES: {
kind: "click" | "gold";
title: string;
description: string;
icon: typeof MousePointerClick;
}[] = [
{
kind: "click",
title: "Сила клика",
description: "Постоянный бонус к урону за клик во всех кликер-играх на вашем аккаунте.",
icon: MousePointerClick,
},
{
kind: "gold",
title: "Удача старателя",
description: "Постоянный бонус к золоту с побеждённых монстров во всех кликер-играх.",
icon: CoinsIcon,
},
];
export function PrestigeShopPanel({ initialPrestige }: { initialPrestige: Prestige }) {
const [prestige, setPrestige] = useState(initialPrestige);
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleBuy(kind: "click" | "gold") {
setLoading(kind);
setError(null);
const res = await fetch("/api/prestige/upgrade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind }),
});
setLoading(null);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось купить улучшение");
return;
}
setPrestige(await res.json());
}
return (
<div className="flex flex-col gap-4">
<div className="card flex items-center gap-3 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
<Gem size={20} />
</div>
<div>
<p className="text-2xl font-bold">{prestige.crystals}</p>
<p className="text-sm text-text-muted">кристаллов престижа</p>
</div>
</div>
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{UPGRADES.map(({ kind, title, description, icon: Icon }) => {
const level = kind === "click" ? prestige.clickBonusLevel : prestige.goldBonusLevel;
const cost = prestigeUpgradeCostAtLevel(level);
const bonusPct = Math.round(level * PRESTIGE_BONUS_PER_LEVEL * 100);
const affordable = prestige.crystals >= cost;
return (
<div key={kind} className="card flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
<Icon size={18} />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">
{title} <span className="text-text-faint"> уровень {level} (+{bonusPct}%)</span>
</p>
<p className="text-sm text-text-muted">{description}</p>
</div>
<button
onClick={() => handleBuy(kind)}
disabled={loading !== null || !affordable}
className="btn btn-primary shrink-0"
>
<Gem size={14} />
{cost}
</button>
</div>
);
})}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp } from "lucide-react";
import type { TouchDirection } from "./game-canvas";
const BUTTON_CLASS =
"flex h-14 w-14 touch-manipulation items-center justify-center rounded-lg border border-border bg-surface text-text active:bg-surface-hover";
export function TouchDpad({ onDirection }: { onDirection: (dir: TouchDirection) => void }) {
const press = (dir: TouchDirection) => (e: React.PointerEvent) => {
e.preventDefault();
onDirection(dir);
};
return (
<div className="mt-3 grid w-fit grid-cols-3 grid-rows-3 gap-1.5 select-none">
<div />
<button type="button" aria-label="Вверх" onPointerDown={press("up")} className={`${BUTTON_CLASS} col-start-2`}>
<ArrowUp size={22} />
</button>
<div />
<button type="button" aria-label="Влево" onPointerDown={press("left")} className={BUTTON_CLASS}>
<ArrowLeft size={22} />
</button>
<div />
<button type="button" aria-label="Вправо" onPointerDown={press("right")} className={BUTTON_CLASS}>
<ArrowRight size={22} />
</button>
<div />
<button
type="button"
aria-label="Вниз"
onPointerDown={press("down")}
className={`${BUTTON_CLASS} col-start-2`}
>
<ArrowDown size={22} />
</button>
<div />
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import type { CoinPackage } from "@/lib/wallet/economy";
export function WalletPurchasePanel({ packages }: { packages: CoinPackage[] }) {
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleBuy(packageId: string) {
setLoadingId(packageId);
setError(null);
const res = await fetch("/api/wallet/purchase", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId }),
});
setLoadingId(null);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось начать оплату");
return;
}
const data = await res.json();
window.location.assign(data.redirectUrl);
}
return (
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Купить монеты</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{packages.map((pkg) => (
<button
key={pkg.id}
onClick={() => handleBuy(pkg.id)}
disabled={loadingId !== null}
className="btn btn-ghost flex flex-col items-center gap-1 py-4"
>
<span className="text-lg font-bold text-text">{pkg.coins} монет</span>
<span className="text-sm text-text-muted">{pkg.priceRub} </span>
</button>
))}
</div>
{error && <p className="mt-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import Anthropic from "@anthropic-ai/sdk";
/** Cheap validation call — used before persisting a new key/model, same "validate before save" shape as the mailbox/LDAP settings. */
export async function testAnthropicKey(apiKey: string, model: string): Promise<void> {
const client = new Anthropic({ apiKey });
await client.messages.create({
model,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
});
}
+49
View File
@@ -0,0 +1,49 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { aiConfig } from "@/lib/db/schema";
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
export interface AiSettings {
apiKey: string;
model: string;
}
/** Settings for actually calling the API — null if never configured or disabled. */
export async function getAiSettings(): Promise<AiSettings | null> {
const config = await db.query.aiConfig.findFirst();
if (!config || !config.enabled) return null;
return { apiKey: decryptCredential(config.anthropicApiKeyEnc), model: config.model };
}
export async function saveAiSettings(settings: AiSettings): Promise<void> {
const row = {
anthropicApiKeyEnc: encryptCredential(settings.apiKey),
model: settings.model,
enabled: true,
verifiedAt: new Date(),
};
const existing = await db.query.aiConfig.findFirst();
if (existing) {
await db.update(aiConfig).set(row).where(eq(aiConfig.id, existing.id));
} else {
await db.insert(aiConfig).values(row);
}
}
export async function disableAi(): Promise<void> {
const existing = await db.query.aiConfig.findFirst();
if (existing) {
await db.update(aiConfig).set({ enabled: false }).where(eq(aiConfig.id, existing.id));
}
}
export async function getAiStatus() {
const config = await db.query.aiConfig.findFirst();
return {
configured: Boolean(config),
enabled: Boolean(config?.enabled),
model: config?.model ?? "claude-sonnet-5",
verifiedAt: config?.verifiedAt?.getTime() ?? null,
};
}
+52
View File
@@ -0,0 +1,52 @@
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAiSettings } from "./config";
import { templateSchemas, type TemplateType, type GameDefinition } from "@/lib/games/types";
const SYSTEM_PROMPTS: Record<TemplateType, string> = {
quiz: "Ты создаёшь вопросы для викторины в формате «Кто хочет стать миллионером?» — 15-уровневая денежная лестница с нарастающей сложностью. Каждый вопрос должен использовать ТОЛЬКО реальные, проверяемые факты (даты, столицы, авторов, научные факты и т.п.) — ничего не выдумывай и не искажай. У каждого вопроса ровно 4 варианта ответа и один правильный. Поле band (1-5) — сложность: 1 — очень лёгкий вопрос, 5 — очень сложный. Обязательно должен быть хотя бы один вопрос на каждый band от 1 до 5. Категория (category) должна отражать тему вопроса.",
clicker:
"Ты создаёшь RPG-кликер в духе Clicker Heroes для игрового движка: игрок бьёт монстра по клику, монстр имеет HP, каждые 5 уровней — босс с таймером. Придумай тему (theme), название босса через bossEmoji (один эмодзи), пул из 5-10 эмодзи обычных монстров (monsterEmojis, каждый — один эмодзи-символ) и 4-8 героев (heroes) по описанию пользователя — герои покупаются многократно (уровни), у каждого либо бонус к урону за клик (clickDamageBonus), либо к урону в секунду (dpsBonus), либо оба понемногу. Более поздние герои в списке должны быть дороже (baseCost) и сильнее.",
maze: "Ты создаёшь лабиринт в стиле Pac-Man для игрового движка на сетке символов. Придумай интересный лабиринт по описанию пользователя. Символы: # стена, . пол, S старт (ровно один), E выход (хотя бы один), * обычная точка, O усиливающая точка (пугает привидений на время), G призрак-респавн (0-4 штук). Расставь точки * почти на каждой проходимой клетке коридоров (как в настоящем Pac-Man), несколько O в дальних углах. Победа — собрать все * и O, затем дойти до E, поэтому каждая точка, усиливающая точка и выход обязательно должны быть достижимы от старта по коридорам без стен.",
snake:
"Ты настраиваешь классическую змейку по описанию пользователя. Поля: width и height (8-40, размер поля), startLength (1-10, начальная длина змейки), targetLength (5-200, длина для победы — должна помещаться в поле, то есть не больше width×height), wrapAround (проходить сквозь стены — true/false). Никаких текстовых полей с темой или сюжетом не требуется, просто подбери сбалансированные числовые параметры под описание пользователя (например, «маленькое быстрое поле» — меньше width/height, «долгая игра» — больше targetLength).",
};
/** Thrown when no Anthropic key is configured — callers map this to a clean "not configured" response. */
export class AiNotConfiguredError extends Error {
constructor() {
super("AI_NOT_CONFIGURED");
}
}
export async function generateGameDefinition(templateType: TemplateType, prompt: string): Promise<GameDefinition> {
const settings = await getAiSettings();
if (!settings) {
throw new AiNotConfiguredError();
}
const client = new Anthropic({ apiKey: settings.apiKey });
const schema = templateSchemas[templateType];
const outputFormat = zodOutputFormat(schema);
async function attempt(userContent: string): Promise<GameDefinition> {
const message = await client.messages.parse({
model: settings!.model,
max_tokens: 2048,
system: SYSTEM_PROMPTS[templateType],
messages: [{ role: "user", content: userContent }],
output_config: { format: outputFormat },
});
return message.parsed_output as GameDefinition;
}
try {
return await attempt(prompt);
} catch (err) {
// One retry, feeding the validation error back — covers the rare case
// where the model's first draft violates a refine() (e.g. an
// unreachable maze exit) despite the schema-constrained output.
const message = err instanceof Error ? err.message : String(err);
return await attempt(`${prompt}\n\nПредыдущая попытка не прошла проверку: ${message}. Исправь и верни заново.`);
}
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "./session";
/** Guard for API route handlers — returns the session, or a 401 response to return as-is. */
export async function requireSession() {
const session = await getCurrentSession();
if (!session) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
+71
View File
@@ -0,0 +1,71 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
+50
View File
@@ -0,0 +1,50 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
interface EncryptedPayload {
iv: string;
ciphertext: string;
authTag: string;
}
function loadKey(): Buffer {
const raw = process.env.CREDENTIALS_ENCRYPTION_KEY;
if (!raw) {
throw new Error(
"CREDENTIALS_ENCRYPTION_KEY is not set — generate one with `npm run generate-key`",
);
}
const key = Buffer.from(raw, "base64");
if (key.length !== 32) {
throw new Error("CREDENTIALS_ENCRYPTION_KEY must decode to exactly 32 bytes");
}
return key;
}
export function encryptCredential(plaintext: string, key: Buffer = loadKey()): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload: EncryptedPayload = {
iv: iv.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
return JSON.stringify(payload);
}
export function decryptCredential(encoded: string, key: Buffer = loadKey()): string {
const payload: EncryptedPayload = JSON.parse(encoded);
const iv = Buffer.from(payload.iv, "base64");
const ciphertext = Buffer.from(payload.ciphertext, "base64");
const authTag = Buffer.from(payload.authTag, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString("utf8");
}
+21
View File
@@ -0,0 +1,21 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
// Next's build-time page-data collection evaluates route modules across
// several worker processes concurrently — without this, two workers racing
// to open/initialize a fresh db.sqlite can throw SQLITE_BUSY instead of
// just waiting for the other's lock to clear (hit repeatedly in the
// sibling top-tickets project; baking the fix in from day one here).
sqlite.pragma("busy_timeout = 30000");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,40 @@
CREATE TABLE `ai_config` (
`id` text PRIMARY KEY NOT NULL,
`anthropic_api_key_enc` text NOT NULL,
`model` text DEFAULT 'claude-sonnet-5' NOT NULL,
`enabled` integer DEFAULT false NOT NULL,
`verified_at` integer,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `games` (
`id` text PRIMARY KEY NOT NULL,
`owner_id` text NOT NULL,
`title` text NOT NULL,
`description` text,
`template_type` text NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`definition` text NOT NULL,
`play_count` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,41 @@
CREATE TABLE `coin_purchases` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`coins` integer NOT NULL,
`price_rub` integer NOT NULL,
`provider` text,
`status` text DEFAULT 'pending' NOT NULL,
`provider_payment_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`completed_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `coin_transactions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`amount` integer NOT NULL,
`reason` text NOT NULL,
`related_game_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`related_game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `game_progress` (
`id` text PRIMARY KEY NOT NULL,
`game_id` text NOT NULL,
`user_id` text NOT NULL,
`state` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `game_progress_game_user_unique` ON `game_progress` (`game_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `wallets` (
`user_id` text PRIMARY KEY NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,20 @@
CREATE TABLE `prestige` (
`user_id` text PRIMARY KEY NOT NULL,
`crystals` integer DEFAULT 0 NOT NULL,
`click_bonus_level` integer DEFAULT 0 NOT NULL,
`gold_bonus_level` integer DEFAULT 0 NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `prestige_claims` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`game_id` text NOT NULL,
`crystals_awarded` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `prestige_claims_user_game_unique` ON `prestige_claims` (`user_id`,`game_id`);
@@ -0,0 +1,278 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ef845fe8-d266-400e-9a8c-264d90d11b26",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"ai_config": {
"name": "ai_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anthropic_api_key_enc": {
"name": "anthropic_api_key_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claude-sonnet-5'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_type": {
"name": "template_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"games_owner_id_users_id_fk": {
"name": "games_owner_id_users_id_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -0,0 +1,576 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2da73b1a-5598-47e5-beef-ce9e8f1db0c5",
"prevId": "ef845fe8-d266-400e-9a8c-264d90d11b26",
"tables": {
"ai_config": {
"name": "ai_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anthropic_api_key_enc": {
"name": "anthropic_api_key_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claude-sonnet-5'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_purchases": {
"name": "coin_purchases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"coins": {
"name": "coins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"price_rub": {
"name": "price_rub",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"provider_payment_id": {
"name": "provider_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"coin_purchases_user_id_users_id_fk": {
"name": "coin_purchases_user_id_users_id_fk",
"tableFrom": "coin_purchases",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_transactions": {
"name": "coin_transactions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"related_game_id": {
"name": "related_game_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"coin_transactions_user_id_users_id_fk": {
"name": "coin_transactions_user_id_users_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"coin_transactions_related_game_id_games_id_fk": {
"name": "coin_transactions_related_game_id_games_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "games",
"columnsFrom": [
"related_game_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"game_progress": {
"name": "game_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"game_progress_game_user_unique": {
"name": "game_progress_game_user_unique",
"columns": [
"game_id",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"game_progress_game_id_games_id_fk": {
"name": "game_progress_game_id_games_id_fk",
"tableFrom": "game_progress",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"game_progress_user_id_users_id_fk": {
"name": "game_progress_user_id_users_id_fk",
"tableFrom": "game_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_type": {
"name": "template_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"games_owner_id_users_id_fk": {
"name": "games_owner_id_users_id_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wallets": {
"name": "wallets",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"wallets_user_id_users_id_fk": {
"name": "wallets_user_id_users_id_fk",
"tableFrom": "wallets",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
@@ -0,0 +1,721 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5562a6c0-8876-47fe-8d5c-b8d76083c13a",
"prevId": "2da73b1a-5598-47e5-beef-ce9e8f1db0c5",
"tables": {
"ai_config": {
"name": "ai_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anthropic_api_key_enc": {
"name": "anthropic_api_key_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claude-sonnet-5'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_purchases": {
"name": "coin_purchases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"coins": {
"name": "coins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"price_rub": {
"name": "price_rub",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"provider_payment_id": {
"name": "provider_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"coin_purchases_user_id_users_id_fk": {
"name": "coin_purchases_user_id_users_id_fk",
"tableFrom": "coin_purchases",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_transactions": {
"name": "coin_transactions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"related_game_id": {
"name": "related_game_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"coin_transactions_user_id_users_id_fk": {
"name": "coin_transactions_user_id_users_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"coin_transactions_related_game_id_games_id_fk": {
"name": "coin_transactions_related_game_id_games_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "games",
"columnsFrom": [
"related_game_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"game_progress": {
"name": "game_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"game_progress_game_user_unique": {
"name": "game_progress_game_user_unique",
"columns": [
"game_id",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"game_progress_game_id_games_id_fk": {
"name": "game_progress_game_id_games_id_fk",
"tableFrom": "game_progress",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"game_progress_user_id_users_id_fk": {
"name": "game_progress_user_id_users_id_fk",
"tableFrom": "game_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_type": {
"name": "template_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"games_owner_id_users_id_fk": {
"name": "games_owner_id_users_id_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prestige": {
"name": "prestige",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"crystals": {
"name": "crystals",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"click_bonus_level": {
"name": "click_bonus_level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"gold_bonus_level": {
"name": "gold_bonus_level",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"prestige_user_id_users_id_fk": {
"name": "prestige_user_id_users_id_fk",
"tableFrom": "prestige",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prestige_claims": {
"name": "prestige_claims",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"crystals_awarded": {
"name": "crystals_awarded",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"prestige_claims_user_game_unique": {
"name": "prestige_claims_user_game_unique",
"columns": [
"user_id",
"game_id"
],
"isUnique": true
}
},
"foreignKeys": {
"prestige_claims_user_id_users_id_fk": {
"name": "prestige_claims_user_id_users_id_fk",
"tableFrom": "prestige_claims",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"prestige_claims_game_id_games_id_fk": {
"name": "prestige_claims_game_id_games_id_fk",
"tableFrom": "prestige_claims",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wallets": {
"name": "wallets",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"wallets_user_id_users_id_fk": {
"name": "wallets_user_id_users_id_fk",
"tableFrom": "wallets",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785518228143,
"tag": "0000_marvelous_tyrannus",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785534147942,
"tag": "0001_fearless_proudstar",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1785552846668,
"tag": "0002_stale_lucky_pierre",
"breakpoints": true
}
]
}
+149
View File
@@ -0,0 +1,149 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
import crypto from "node:crypto";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
/** Every registered account can both play and create games — no role split. */
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
name: text("name").notNull(),
createdAt: timestamps.createdAt,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: timestamps.createdAt,
});
export const games = sqliteTable("games", {
id: id(),
ownerId: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
templateType: text("template_type", { enum: ["quiz", "clicker", "maze", "snake"] }).notNull(),
status: text("status", { enum: ["draft", "published"] }).notNull().default("draft"),
// JSON string validated against the templateType's zod schema (src/lib/games/*).
definition: text("definition").notNull(),
playCount: integer("play_count").notNull().default(0),
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
});
/** Singleton — same shape as top-tickets' telegramConfig/mailboxConfig. */
export const aiConfig = sqliteTable("ai_config", {
id: id(),
anthropicApiKeyEnc: text("anthropic_api_key_enc").notNull(),
model: text("model").notNull().default("claude-sonnet-5"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
createdAt: timestamps.createdAt,
});
/** One save slot per (game, player) — upserted, JSON shape per templateType (src/lib/games/*). */
export const gameProgress = sqliteTable(
"game_progress",
{
id: id(),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
state: text("state").notNull(),
updatedAt: timestamps.updatedAt,
},
(table) => [uniqueIndex("game_progress_game_user_unique").on(table.gameId, table.userId)],
);
export const wallets = sqliteTable("wallets", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
balance: integer("balance").notNull().default(0),
updatedAt: timestamps.updatedAt,
});
/** Ledger — every balance change, for audit/history. */
export const coinTransactions = sqliteTable("coin_transactions", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// Positive = credit, negative = debit.
amount: integer("amount").notNull(),
reason: text("reason", { enum: ["signup_bonus", "manual_save", "purchase"] }).notNull(),
relatedGameId: text("related_game_id").references(() => games.id, { onDelete: "set null" }),
createdAt: timestamps.createdAt,
});
/** A real-money order for coins — always "pending" until a payment provider is wired in. */
export const coinPurchases = sqliteTable("coin_purchases", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
coins: integer("coins").notNull(),
priceRub: integer("price_rub").notNull(),
provider: text("provider"),
status: text("status", { enum: ["pending", "completed", "failed"] }).notNull().default("pending"),
providerPaymentId: text("provider_payment_id"),
createdAt: timestamps.createdAt,
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
});
/**
* A separate, non-real-money currency earned by clearing Clicker
* dungeons and spent on permanent account-wide upgrades — deliberately
* kept apart from wallets/coinTransactions so clicker play can never be
* used to farm the real-money coin economy.
*/
export const prestige = sqliteTable("prestige", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
crystals: integer("crystals").notNull().default(0),
clickBonusLevel: integer("click_bonus_level").notNull().default(0),
goldBonusLevel: integer("gold_bonus_level").notNull().default(0),
updatedAt: timestamps.updatedAt,
});
/** One row per (user, game) that has ever paid out a completion reward — makes claiming idempotent. */
export const prestigeClaims = sqliteTable(
"prestige_claims",
{
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
crystalsAwarded: integer("crystals_awarded").notNull(),
createdAt: timestamps.createdAt,
},
(table) => [uniqueIndex("prestige_claims_user_game_unique").on(table.userId, table.gameId)],
);
+114
View File
@@ -0,0 +1,114 @@
import { z } from "zod";
/** Per-level monster HP growth. */
export const HP_GROWTH = 1.18;
/** Gold reward per point of monster max HP. */
export const GOLD_PER_HP = 0.5;
/** Every Nth level is a boss level. */
export const BOSS_EVERY = 5;
/** Boss monsters have this many times a normal monster's HP at that level. */
export const BOSS_HP_MULTIPLIER = 4;
/** Boss levels give the player this long to land the killing blow. */
export const BOSS_TIME_LIMIT_MS = 20000;
/** Each hero level costs this much more than the last. */
export const HERO_COST_GROWTH = 1.15;
/** Permanent account-wide prestige upgrades — separate currency from the real coin wallet. */
export const PRESTIGE_BONUS_PER_LEVEL = 0.05;
export const PRESTIGE_UPGRADE_BASE_COST = 3;
export const PRESTIGE_UPGRADE_COST_GROWTH = 1.3;
export function crystalsForCompletion(targetLevel: number): number {
return Math.max(1, Math.round(targetLevel / 5));
}
export function prestigeUpgradeCostAtLevel(currentLevel: number): number {
return Math.round(PRESTIGE_UPGRADE_BASE_COST * Math.pow(PRESTIGE_UPGRADE_COST_GROWTH, currentLevel));
}
export function prestigeMultiplier(level: number): number {
return 1 + level * PRESTIGE_BONUS_PER_LEVEL;
}
export const clickerHeroSchema = z.object({
name: z.string().min(1).max(40),
baseCost: z.number().min(1),
clickDamageBonus: z.number().min(0).default(0),
dpsBonus: z.number().min(0).default(0),
});
export const clickerDefinitionSchema = z.object({
theme: z.string().min(1).max(60).default("Подземелье"),
targetLevel: z.number().int().min(5).max(200).default(30),
startingClickDamage: z.number().min(1).default(5),
baseMonsterHp: z.number().min(1).default(20),
monsterEmojis: z.array(z.string().min(1).max(8)).min(1).max(12).default(["👹"]),
bossEmoji: z.string().min(1).max(8).default("🐉"),
heroes: z.array(clickerHeroSchema).min(1).max(20),
});
export type ClickerHero = z.infer<typeof clickerHeroSchema>;
export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>;
/** Permanent, account-wide bonuses bought in the prestige shop — passed into the scene via the registry. */
export interface ClickerAccountBonuses {
clickBonusLevel: number;
goldBonusLevel: number;
}
export function isBossLevel(level: number): boolean {
return level % BOSS_EVERY === 0;
}
export function monsterHpForLevel(level: number, def: Pick<ClickerDefinition, "baseMonsterHp">): number {
const hp = def.baseMonsterHp * Math.pow(HP_GROWTH, level - 1);
return Math.round(isBossLevel(level) ? hp * BOSS_HP_MULTIPLIER : hp);
}
export function goldForMonster(hp: number): number {
return Math.max(1, Math.round(hp * GOLD_PER_HP));
}
/** Cost to buy the hero's (currentLevel + 1)th level. */
export function heroCostAtLevel(hero: ClickerHero, currentLevel: number): number {
return Math.round(hero.baseCost * Math.pow(HERO_COST_GROWTH, currentLevel));
}
/** Which kind of damage a hero contributes — derived from its bonuses, never hand-authored. */
export function heroRoleLabel(hero: Pick<ClickerHero, "clickDamageBonus" | "dpsBonus">): string {
const hasClick = hero.clickDamageBonus > 0;
const hasDps = hero.dpsBonus > 0;
if (hasClick && hasDps) return "⚔🔄 клик + пассивно";
if (hasClick) return "⚔ улучшает клик";
if (hasDps) return "🔄 бьёт пассивно";
return "";
}
export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
theme: "Тёмное подземелье",
targetLevel: 60,
startingClickDamage: 5,
baseMonsterHp: 20,
monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
bossEmoji: "🐉",
heroes: [
{ name: "Оруженосец", baseCost: 15, clickDamageBonus: 2, dpsBonus: 0 },
{ name: "Лучник", baseCost: 40, clickDamageBonus: 0, dpsBonus: 3 },
{ name: "Кузнец", baseCost: 100, clickDamageBonus: 8, dpsBonus: 0 },
{ name: "Маг", baseCost: 250, clickDamageBonus: 0, dpsBonus: 12 },
{ name: "Паладин", baseCost: 600, clickDamageBonus: 25, dpsBonus: 10 },
{ name: "Дракон", baseCost: 1500, clickDamageBonus: 0, dpsBonus: 60 },
{ name: "Рыцарь", baseCost: 4000, clickDamageBonus: 60, dpsBonus: 0 },
{ name: "Некромант", baseCost: 10000, clickDamageBonus: 0, dpsBonus: 200 },
{ name: "Архангел", baseCost: 25000, clickDamageBonus: 150, dpsBonus: 80 },
{ name: "Титан", baseCost: 60000, clickDamageBonus: 0, dpsBonus: 500 },
],
};
export const clickerProgressSchema = z.object({
level: z.number().int().min(1),
gold: z.number().min(0),
heroLevels: z.array(z.number().int().min(0)).default([]),
});
export type ClickerProgress = z.infer<typeof clickerProgressSchema>;
+127
View File
@@ -0,0 +1,127 @@
import { z } from "zod";
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*", "O", "G"]);
const MAX_GHOSTS = 4;
// Pac-Man-flavored timing/scoring — fixed, not hand-authored (same
// "curve is fixed" precedent as the quiz money ladder / clicker HP curve).
export const PLAYER_STEP_MS = 180;
export const GHOST_STEP_MS = 220; // slightly slower than the player — keeps it beatable.
export const FRIGHTEN_DURATION_MS = 7000;
export const GHOST_RESPAWN_DELAY_MS = 3000;
export const DOT_SCORE = 10;
export const PELLET_SCORE = 50;
export const GHOST_EAT_SCORE = 200;
/** BFS from S — used both to find the exit and to prove every collectible tile is reachable. '#' is the only impassable tile. */
function reachableFrom(grid: string[], start: [number, number]): Set<string> {
const rows = grid.length;
const cols = grid[0]?.length ?? 0;
const visited = new Set<string>([`${start[0]},${start[1]}`]);
const queue: [number, number][] = [start];
while (queue.length > 0) {
const [r, c] = queue.shift()!;
for (const [dr, dc] of [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const key = `${nr},${nc}`;
if (visited.has(key) || grid[nr][nc] === "#") continue;
visited.add(key);
queue.push([nr, nc]);
}
}
return visited;
}
function findStart(grid: string[]): [number, number] | null {
for (let r = 0; r < grid.length; r++) {
const c = grid[r].indexOf("S");
if (c !== -1) return [r, c];
}
return null;
}
/** Every dot, power pellet, and the exit must be reachable — win condition requires collecting them all. */
function allCollectiblesReachable(grid: string[]): boolean {
const start = findStart(grid);
if (!start) return false;
const reachable = reachableFrom(grid, start);
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
const ch = grid[r][c];
if ((ch === "*" || ch === "O" || ch === "E") && !reachable.has(`${r},${c}`)) return false;
}
}
return true;
}
export const mazeDefinitionSchema = z
.object({
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit,
// '*' dot, 'O' power pellet, 'G' ghost spawn (0-4 of them).
grid: z.array(z.string().min(1)).min(3).max(30),
// 0 = no limit.
timeLimitSec: z.number().int().min(0).max(600).default(0),
lives: z.number().int().min(1).max(9).default(3),
})
.refine((def) => def.grid.every((row) => row.length === def.grid[0].length), {
message: "All grid rows must be the same length",
path: ["grid"],
})
.refine((def) => def.grid.every((row) => [...row].every((c) => MAZE_CHARS.has(c))), {
message: "Grid may only contain the characters # . S E * O G",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, {
message: "Grid must contain exactly one S (start)",
path: ["grid"],
})
.refine((def) => def.grid.some((row) => row.includes("E")), {
message: "Grid must contain at least one E (exit)",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "G").length <= MAX_GHOSTS, {
message: `Grid may contain at most ${MAX_GHOSTS} G (ghost spawns)`,
path: ["grid"],
})
.refine((def) => allCollectiblesReachable(def.grid), {
message: "Every dot (*), power pellet (O), and the exit (E) must be reachable from the start (S)",
path: ["grid"],
});
export type MazeDefinition = z.infer<typeof mazeDefinitionSchema>;
export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
grid: [
"#############",
"#O****#****O#",
"#*###*#*###*#",
"#*#*******#*#",
"#*#*#####*#*#",
"#****GGG****#",
"#*#*#####*#*#",
"#*#*******#*#",
"#*###*#*###*#",
"#S****#****E#",
"#############",
],
timeLimitSec: 0,
lives: 3,
};
export const mazeProgressSchema = z.object({
playerRow: z.number().int().min(0),
playerCol: z.number().int().min(0),
collectedKeys: z.array(z.string()).default([]),
remainingMs: z.number().int().min(0),
lives: z.number().int().min(0),
});
export type MazeProgress = z.infer<typeof mazeProgressSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { eq, and } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { gameProgress } from "@/lib/db/schema";
import { safeParseProgress, type TemplateType, type GameProgress } from "./types";
export async function getProgress(gameId: string, userId: string): Promise<GameProgress | null> {
const row = await db.query.gameProgress.findFirst({
where: and(eq(gameProgress.gameId, gameId), eq(gameProgress.userId, userId)),
});
if (!row) return null;
const templateType = await getTemplateTypeForGame(gameId);
if (!templateType) return null;
const parsed = safeParseProgress(templateType, JSON.parse(row.state));
return parsed.success ? parsed.data : null;
}
export async function saveProgress(gameId: string, userId: string, state: GameProgress): Promise<void> {
await db
.insert(gameProgress)
.values({ gameId, userId, state: JSON.stringify(state) })
.onConflictDoUpdate({
target: [gameProgress.gameId, gameProgress.userId],
set: { state: JSON.stringify(state), updatedAt: new Date() },
});
}
async function getTemplateTypeForGame(gameId: string): Promise<TemplateType | null> {
const game = await db.query.games.findFirst({
where: (g, { eq: eqOp }) => eqOp(g.id, gameId),
columns: { templateType: true },
});
return game?.templateType ?? null;
}
+185
View File
@@ -0,0 +1,185 @@
import { z } from "zod";
export const QUIZ_CATEGORIES = [
"История",
"География",
"Наука и техника",
"Литература",
"Кино и музыка",
"Спорт",
"Общие знания",
] as const;
export type QuizCategory = (typeof QUIZ_CATEGORIES)[number];
/** 15-level money ladder, fixed — not part of the editable schema. */
export const MONEY_LADDER = [
100, 200, 300, 500, 1000, 2000, 4000, 8000, 16000, 32000, 64000, 125000, 250000, 500000, 1000000,
];
/** "Несгораемые суммы" — a wrong answer never drops you below the last checkpoint passed. */
export const CHECKPOINT_LEVELS = [5, 10];
/** Maps a ladder level (1-15) to its difficulty band (1-5), 3 levels per band. */
export function bandForLevel(level: number): number {
return Math.min(5, Math.max(1, Math.ceil(level / 3)));
}
export const quizQuestionSchema = z
.object({
category: z.enum(QUIZ_CATEGORIES),
// Difficulty band 1 (easiest) 5 (hardest); band N covers ladder levels (N-1)*3+1..N*3.
band: z.number().int().min(1).max(5),
question: z.string().min(1).max(300),
options: z.array(z.string().min(1).max(120)).length(4),
correctIndex: z.number().int().min(0).max(3),
})
.refine((q) => q.correctIndex < q.options.length, {
message: "correctIndex must be a valid index into options",
path: ["correctIndex"],
});
export const quizDefinitionSchema = z
.object({
questions: z.array(quizQuestionSchema).min(5).max(500),
})
.refine((def) => [1, 2, 3, 4, 5].every((band) => def.questions.some((q) => q.band === band)), {
message: "Every difficulty band 1-5 needs at least one question",
path: ["questions"],
});
export type QuizQuestion = z.infer<typeof quizQuestionSchema>;
export type QuizDefinition = z.infer<typeof quizDefinitionSchema>;
export const quizProgressSchema = z.object({
level: z.number().int().min(1).max(15),
bankedScore: z.number().int().min(0),
usedLifelines: z.array(z.enum(["fiftyFifty", "audience", "phone"])).default([]),
});
export type QuizProgress = z.infer<typeof quizProgressSchema>;
// Real, well-established facts only — 7 categories x 5 difficulty bands x 3
// questions = 105. Deliberately avoids "current record holder"-style facts
// that go stale; sticks to capitals, foundational history, classic
// literature/science, and long-settled records.
export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = {
questions: [
// --- История ---
{ category: "История", band: 1, question: "В каком году началась Вторая мировая война?", options: ["1937", "1939", "1941", "1945"], correctIndex: 1 },
{ category: "История", band: 1, question: "Кто был первым президентом США?", options: ["Джордж Вашингтон", "Авраам Линкольн", "Томас Джефферсон", "Бенджамин Франклин"], correctIndex: 0 },
{ category: "История", band: 1, question: "В каком городе произошло взятие Бастилии, ставшее символом начала Великой французской революции?", options: ["Париж", "Лион", "Марсель", "Версаль"], correctIndex: 0 },
{ category: "История", band: 2, question: "Кто стал первым человеком в мире, побывавшим в космосе?", options: ["Юрий Гагарин", "Герман Титов", "Алексей Леонов", "Валентина Терешкова"], correctIndex: 0 },
{ category: "История", band: 2, question: "В каком году закончилась Вторая мировая война?", options: ["1943", "1944", "1945", "1946"], correctIndex: 2 },
{ category: "История", band: 2, question: "Какая империя построила Колизей в Риме?", options: ["Римская", "Османская", "Персидская", "Британская"], correctIndex: 0 },
{ category: "История", band: 3, question: "В каком году произошла Октябрьская революция в России?", options: ["1905", "1917", "1918", "1922"], correctIndex: 1 },
{ category: "История", band: 3, question: "Кто написал «Манифест Коммунистической партии» вместе с Фридрихом Энгельсом?", options: ["Карл Маркс", "Владимир Ленин", "Лев Троцкий", "Адам Смит"], correctIndex: 0 },
{ category: "История", band: 3, question: "Какая стена разделяла Берлин на Восточный и Западный до 1989 года?", options: ["Берлинская стена", "Великая Китайская стена", "Адрианов вал", "Стена Плача"], correctIndex: 0 },
{ category: "История", band: 4, question: "В каком году пала Западная Римская империя?", options: ["376", "410", "476", "527"], correctIndex: 2 },
{ category: "История", band: 4, question: "Какая царица Египта была связана политическим союзом с Юлием Цезарем и Марком Антонием?", options: ["Клеопатра", "Нефертити", "Хатшепсут", "Изида"], correctIndex: 0 },
{ category: "История", band: 4, question: "Какая война продолжалась с 1618 по 1648 год и затронула большую часть Европы?", options: ["Тридцатилетняя война", "Столетняя война", "Семилетняя война", "Война за испанское наследство"], correctIndex: 0 },
{ category: "История", band: 5, question: "В каком году был подписан Вестфальский мир, завершивший Тридцатилетнюю войну?", options: ["1618", "1648", "1658", "1701"], correctIndex: 1 },
{ category: "История", band: 5, question: "Кто был последним императором России из династии Романовых?", options: ["Николай II", "Александр III", "Николай I", "Александр II"], correctIndex: 0 },
{ category: "История", band: 5, question: "В каком году завершился Венский конгресс, установивший новый порядок в Европе после Наполеоновских войн?", options: ["1812", "1814", "1815", "1820"], correctIndex: 2 },
// --- География ---
{ category: "География", band: 1, question: "Какая река самая длинная в Африке?", options: ["Нил", "Конго", "Нигер", "Замбези"], correctIndex: 0 },
{ category: "География", band: 1, question: "Столица Франции?", options: ["Париж", "Лион", "Марсель", "Ницца"], correctIndex: 0 },
{ category: "География", band: 1, question: "Самый большой океан на Земле?", options: ["Тихий", "Атлантический", "Индийский", "Северный Ледовитый"], correctIndex: 0 },
{ category: "География", band: 2, question: "Столица Японии?", options: ["Токио", "Осака", "Киото", "Йокогама"], correctIndex: 0 },
{ category: "География", band: 2, question: "Самая высокая гора в мире?", options: ["Эверест", "К2", "Килиманджаро", "Эльбрус"], correctIndex: 0 },
{ category: "География", band: 2, question: "Какой континент самый большой по площади?", options: ["Азия", "Африка", "Северная Америка", "Европа"], correctIndex: 0 },
{ category: "География", band: 3, question: "Какая страна занимает наибольшую территорию в мире?", options: ["Россия", "Канада", "Китай", "США"], correctIndex: 0 },
{ category: "География", band: 3, question: "Столица Австралии?", options: ["Канберра", "Сидней", "Мельбурн", "Брисбен"], correctIndex: 0 },
{ category: "География", band: 3, question: "Самое глубокое озеро в мире?", options: ["Байкал", "Танганьика", "Каспийское море", "Верхнее"], correctIndex: 0 },
{ category: "География", band: 4, question: "Какая пустыня самая большая жаркая пустыня в мире?", options: ["Сахара", "Гоби", "Калахари", "Аравийская"], correctIndex: 0 },
{ category: "География", band: 4, question: "Какой пролив разделяет Европу и Африку в самом узком месте?", options: ["Гибралтарский пролив", "Босфор", "Ла-Манш", "Керченский пролив"], correctIndex: 0 },
{ category: "География", band: 4, question: "Какая страна занимает первое место по количеству часовых поясов на своей территории (с учётом заморских территорий)?", options: ["Франция", "Россия", "США", "Китай"], correctIndex: 0 },
{ category: "География", band: 5, question: "Какая река самая полноводная в мире по объёму стока?", options: ["Амазонка", "Нил", "Янцзы", "Конго"], correctIndex: 0 },
{ category: "География", band: 5, question: "Столица Канады?", options: ["Оттава", "Торонто", "Ванкувер", "Монреаль"], correctIndex: 0 },
{ category: "География", band: 5, question: "Какое море считается самым солёным в мире?", options: ["Мёртвое море", "Красное море", "Средиземное море", "Чёрное море"], correctIndex: 0 },
// --- Наука и техника ---
{ category: "Наука и техника", band: 1, question: "Химический символ золота?", options: ["Au", "Ag", "Fe", "Gd"], correctIndex: 0 },
{ category: "Наука и техника", band: 1, question: "Сколько планет в Солнечной системе по современной классификации?", options: ["7", "8", "9", "10"], correctIndex: 1 },
{ category: "Наука и техника", band: 1, question: "Кто разработал теорию относительности?", options: ["Альберт Эйнштейн", "Исаак Ньютон", "Никола Тесла", "Галилео Галилей"], correctIndex: 0 },
{ category: "Наука и техника", band: 2, question: "Какой газ составляет большую часть земной атмосферы?", options: ["Азот", "Кислород", "Углекислый газ", "Аргон"], correctIndex: 0 },
{ category: "Наука и техника", band: 2, question: "Сколько костей в скелете взрослого человека?", options: ["206", "186", "226", "246"], correctIndex: 0 },
{ category: "Наука и техника", band: 2, question: "Кто первым запатентовал телефон?", options: ["Александр Белл", "Томас Эдисон", "Никола Тесла", "Гульельмо Маркони"], correctIndex: 0 },
{ category: "Наука и техника", band: 3, question: "Какая частица имеет отрицательный электрический заряд?", options: ["Электрон", "Протон", "Нейтрон", "Позитрон"], correctIndex: 0 },
{ category: "Наука и техника", band: 3, question: "Сколько хромосом в соматической клетке человека?", options: ["46", "44", "48", "23"], correctIndex: 0 },
{ category: "Наука и техника", band: 3, question: "Кто открыл закон всемирного тяготения?", options: ["Исаак Ньютон", "Галилео Галилей", "Иоганн Кеплер", "Альберт Эйнштейн"], correctIndex: 0 },
{ category: "Наука и техника", band: 4, question: "Химический элемент с атомным номером 1?", options: ["Водород", "Гелий", "Литий", "Кислород"], correctIndex: 0 },
{ category: "Наука и техника", band: 4, question: "Кто считается создателем периодической таблицы химических элементов?", options: ["Дмитрий Менделеев", "Антуан Лавуазье", "Джон Дальтон", "Роберт Бойль"], correctIndex: 0 },
{ category: "Наука и техника", band: 4, question: "Как называется переход вещества из твёрдого состояния сразу в газообразное, минуя жидкое?", options: ["Сублимация", "Конденсация", "Кристаллизация", "Испарение"], correctIndex: 0 },
{ category: "Наука и техника", band: 5, question: "Какая частица, предсказанная теоретически, была открыта на Большом адронном коллайдере в 2012 году?", options: ["Бозон Хиггса", "Кварк", "Позитрон", "Нейтрино"], correctIndex: 0 },
{ category: "Наука и техника", band: 5, question: "Кто сформулировал принцип неопределённости в квантовой механике?", options: ["Вернер Гейзенберг", "Нильс Бор", "Макс Планк", "Эрвин Шрёдингер"], correctIndex: 0 },
{ category: "Наука и техника", band: 5, question: "Как называется единица измерения силы электрического тока в СИ?", options: ["Ампер", "Вольт", "Ом", "Ватт"], correctIndex: 0 },
// --- Литература ---
{ category: "Литература", band: 1, question: "Кто автор романа «Война и мир»?", options: ["Лев Толстой", "Фёдор Достоевский", "Иван Тургенев", "Антон Чехов"], correctIndex: 0 },
{ category: "Литература", band: 1, question: "Кто написал «Ромео и Джульетту»?", options: ["Уильям Шекспир", "Чарльз Диккенс", "Оскар Уайльд", "Джордж Байрон"], correctIndex: 0 },
{ category: "Литература", band: 1, question: "Кто автор сказки «Золотой ключик, или Приключения Буратино»?", options: ["Алексей Толстой", "Лев Толстой", "Корней Чуковский", "Александр Пушкин"], correctIndex: 0 },
{ category: "Литература", band: 2, question: "Кто написал роман «Преступление и наказание»?", options: ["Фёдор Достоевский", "Лев Толстой", "Николай Гоголь", "Иван Гончаров"], correctIndex: 0 },
{ category: "Литература", band: 2, question: "Кто автор серии книг о Гарри Поттере?", options: ["Джоан Роулинг", "Дж. Р. Р. Толкин", "Клайв Льюис", "Урсула Ле Гуин"], correctIndex: 0 },
{ category: "Литература", band: 2, question: "Кто написал поэму «Мёртвые души»?", options: ["Николай Гоголь", "Александр Пушкин", "Иван Тургенев", "Михаил Лермонтов"], correctIndex: 0 },
{ category: "Литература", band: 3, question: "Кто автор романа в стихах «Евгений Онегин»?", options: ["Александр Пушкин", "Михаил Лермонтов", "Николай Некрасов", "Фёдор Тютчев"], correctIndex: 0 },
{ category: "Литература", band: 3, question: "Кто написал роман «Мастер и Маргарита»?", options: ["Михаил Булгаков", "Борис Пастернак", "Александр Солженицын", "Максим Горький"], correctIndex: 0 },
{ category: "Литература", band: 3, question: "Кто автор трагедии «Гамлет»?", options: ["Уильям Шекспир", "Кристофер Марло", "Бен Джонсон", "Джон Мильтон"], correctIndex: 0 },
{ category: "Литература", band: 4, question: "Кто написал роман-антиутопию «1984»?", options: ["Джордж Оруэлл", "Олдос Хаксли", "Рэй Брэдбери", "Герберт Уэллс"], correctIndex: 0 },
{ category: "Литература", band: 4, question: "Кто автор «Божественной комедии»?", options: ["Данте Алигьери", "Франческо Петрарка", "Джованни Боккаччо", "Торквато Тассо"], correctIndex: 0 },
{ category: "Литература", band: 4, question: "Кто написал роман «Анна Каренина»?", options: ["Лев Толстой", "Фёдор Достоевский", "Иван Бунин", "Александр Куприн"], correctIndex: 0 },
{ category: "Литература", band: 5, question: "Кто автор цикла романов «В поисках утраченного времени»?", options: ["Марсель Пруст", "Альбер Камю", "Жан-Поль Сартр", "Виктор Гюго"], correctIndex: 0 },
{ category: "Литература", band: 5, question: "Кому принадлежит цикл романов «Человеческая комедия»?", options: ["Оноре де Бальзак", "Виктор Гюго", "Эмиль Золя", "Гюстав Флобер"], correctIndex: 0 },
{ category: "Литература", band: 5, question: "Кто написал эпическую поэму «Потерянный рай»?", options: ["Джон Мильтон", "Уильям Блейк", "Джон Донн", "Александр Поуп"], correctIndex: 0 },
// --- Кино и музыка ---
{ category: "Кино и музыка", band: 1, question: "Кто сыграл главную мужскую роль в фильме «Титаник»?", options: ["Леонардо Ди Каприо", "Брэд Питт", "Том Круз", "Джонни Депп"], correctIndex: 0 },
{ category: "Кино и музыка", band: 1, question: "Какая студия создала мультфильм «Король Лев» (1994)?", options: ["Disney", "Pixar", "DreamWorks", "Warner Bros"], correctIndex: 0 },
{ category: "Кино и музыка", band: 1, question: "Кто исполнитель песни «Thriller»?", options: ["Майкл Джексон", "Принс", "Элтон Джон", "Стиви Уандер"], correctIndex: 0 },
{ category: "Кино и музыка", band: 2, question: "Кто режиссёр фильма «Список Шиндлера»?", options: ["Стивен Спилберг", "Мартин Скорсезе", "Фрэнсис Форд Коппола", "Квентин Тарантино"], correctIndex: 0 },
{ category: "Кино и музыка", band: 2, question: "Какая группа исполнила песню «Bohemian Rhapsody»?", options: ["Queen", "The Beatles", "Led Zeppelin", "Pink Floyd"], correctIndex: 0 },
{ category: "Кино и музыка", band: 2, question: "В каком фильме прозвучала фраза «Я вернусь» («I'll be back»)?", options: ["Терминатор", "Рэмбо", "Крепкий орешек", "Робокоп"], correctIndex: 0 },
{ category: "Кино и музыка", band: 3, question: "Кто написал музыку к фильму «Крёстный отец»?", options: ["Нино Рота", "Эннио Морриконе", "Джон Уильямс", "Ханс Циммер"], correctIndex: 0 },
{ category: "Кино и музыка", band: 3, question: "Кто режиссёр кинотрилогии «Властелин колец»?", options: ["Питер Джексон", "Джеймс Кэмерон", "Ридли Скотт", "Гильермо дель Торо"], correctIndex: 0 },
{ category: "Кино и музыка", band: 3, question: "Кто написал оперу «Аида»?", options: ["Джузеппе Верди", "Джакомо Пуччини", "Джоаккино Россини", "Винченцо Беллини"], correctIndex: 0 },
{ category: "Кино и музыка", band: 4, question: "Какой фильм получил первый в истории «Оскар» за лучший фильм (церемония 1929 года)?", options: ["«Крылья»", "«Кинг-Конг»", "«Унесённые ветром»", "«Касабланка»"], correctIndex: 0 },
{ category: "Кино и музыка", band: 4, question: "Кто композитор знаменитой Девятой симфонии с «Одой к радости»?", options: ["Людвиг ван Бетховен", "Вольфганг Амадей Моцарт", "Иоганн Себастьян Бах", "Йозеф Гайдн"], correctIndex: 0 },
{ category: "Кино и музыка", band: 4, question: "Кто сыграл Джокера в фильме «Тёмный рыцарь» (2008)?", options: ["Хит Леджер", "Хоакин Феникс", "Джаред Лето", "Джек Николсон"], correctIndex: 0 },
{ category: "Кино и музыка", band: 5, question: "Кто автор оперного цикла «Кольцо нибелунга»?", options: ["Рихард Вагнер", "Рихард Штраус", "Густав Малер", "Антон Брукнер"], correctIndex: 0 },
{ category: "Кино и музыка", band: 5, question: "Какой фильм Альфреда Хичкока известен сценой убийства в душе?", options: ["«Психо»", "«Головокружение»", "«Птицы»", "«Окно во двор»"], correctIndex: 0 },
{ category: "Кино и музыка", band: 5, question: "Кто написал музыку балета «Лебединое озеро»?", options: ["Пётр Чайковский", "Сергей Прокофьев", "Игорь Стравинский", "Александр Глазунов"], correctIndex: 0 },
// --- Спорт ---
{ category: "Спорт", band: 1, question: "Сколько игроков от одной команды находится на поле в футболе одновременно?", options: ["11", "10", "9", "12"], correctIndex: 0 },
{ category: "Спорт", band: 1, question: "В какой стране проходили летние Олимпийские игры 1980 года?", options: ["СССР", "США", "Германия", "Франция"], correctIndex: 0 },
{ category: "Спорт", band: 1, question: "Сколько очков даёт трёхочковый бросок в баскетболе?", options: ["3", "2", "1", "4"], correctIndex: 0 },
{ category: "Спорт", band: 2, question: "Как часто проводится чемпионат мира по футболу?", options: ["Раз в 4 года", "Раз в 2 года", "Ежегодно", "Раз в 3 года"], correctIndex: 0 },
{ category: "Спорт", band: 2, question: "В каком виде спорта победу над соперником называют «нокаутом»?", options: ["Бокс", "Теннис", "Фехтование", "Гольф"], correctIndex: 0 },
{ category: "Спорт", band: 2, question: "Сколько игроков от одной команды на площадке одновременно в баскетболе?", options: ["5", "6", "4", "7"], correctIndex: 0 },
{ category: "Спорт", band: 3, question: "В каком городе прошли первые Олимпийские игры современности (1896)?", options: ["Афины", "Париж", "Лондон", "Рим"], correctIndex: 0 },
{ category: "Спорт", band: 3, question: "Кто считается основателем современного олимпийского движения?", options: ["Пьер де Кубертен", "Деметриус Викелас", "Спиридон Луис", "Теодор Рузвельт"], correctIndex: 0 },
{ category: "Спорт", band: 3, question: "Сколько колец изображено на олимпийском флаге?", options: ["5", "6", "4", "7"], correctIndex: 0 },
{ category: "Спорт", band: 4, question: "Какая сборная выиграла первый в истории чемпионат мира по футболу (1930)?", options: ["Уругвай", "Аргентина", "Бразилия", "Италия"], correctIndex: 0 },
{ category: "Спорт", band: 4, question: "Сколько раз сборная Бразилии выигрывала чемпионат мира по футболу (1958, 1962, 1970, 1994, 2002)?", options: ["5", "4", "6", "3"], correctIndex: 0 },
{ category: "Спорт", band: 4, question: "В каком виде спорта соревнуются в дисциплинах «вольный стиль», «брасс», «баттерфляй» и «на спине»?", options: ["Плавание", "Лёгкая атлетика", "Гребля", "Триатлон"], correctIndex: 0 },
{ category: "Спорт", band: 5, question: "Какой пловец является рекордсменом по числу золотых олимпийских медалей за карьеру (23)?", options: ["Майкл Фелпс", "Марк Спитц", "Ян Торп", "Кейти Ледеки"], correctIndex: 0 },
{ category: "Спорт", band: 5, question: "В каком году женщины впервые приняли участие в Олимпийских играх?", options: ["1900", "1896", "1912", "1920"], correctIndex: 0 },
{ category: "Спорт", band: 5, question: "Какова официальная длина марафонской дистанции?", options: ["42,195 км", "40 км", "45 км", "50 км"], correctIndex: 0 },
// --- Общие знания ---
{ category: "Общие знания", band: 1, question: "Сколько дней в високосном году?", options: ["366", "365", "364", "367"], correctIndex: 0 },
{ category: "Общие знания", band: 1, question: "Сколько цветов принято выделять в радуге?", options: ["7", "6", "5", "8"], correctIndex: 0 },
{ category: "Общие знания", band: 1, question: "Сколько сторон у шестиугольника?", options: ["6", "5", "7", "8"], correctIndex: 0 },
{ category: "Общие знания", band: 2, question: "Сколько минут в сутках?", options: ["1440", "1000", "1200", "1500"], correctIndex: 0 },
{ category: "Общие знания", band: 2, question: "На каком языке говорит большинство населения Бразилии?", options: ["Португальский", "Испанский", "Английский", "Французский"], correctIndex: 0 },
{ category: "Общие знания", band: 2, question: "Сколько букв в современном русском алфавите?", options: ["33", "32", "34", "30"], correctIndex: 0 },
{ category: "Общие знания", band: 3, question: "Как называется денежная единица Японии?", options: ["Иена", "Юань", "Вон", "Донг"], correctIndex: 0 },
{ category: "Общие знания", band: 3, question: "Сколько шейных позвонков у жирафа?", options: ["7", "5", "9", "12"], correctIndex: 0 },
{ category: "Общие знания", band: 3, question: "Какой газ выделяется как побочный продукт фотосинтеза?", options: ["Кислород", "Углекислый газ", "Азот", "Водород"], correctIndex: 0 },
{ category: "Общие знания", band: 4, question: "Как называется самое маленькое по площади государство в мире?", options: ["Ватикан", "Монако", "Сан-Марино", "Лихтенштейн"], correctIndex: 0 },
{ category: "Общие знания", band: 4, question: "Сколько составных частей (стран) входит в Соединённое Королевство Великобритании?", options: ["4", "3", "5", "2"], correctIndex: 0 },
{ category: "Общие знания", band: 4, question: "Какой металл остаётся жидким при комнатной температуре?", options: ["Ртуть", "Свинец", "Олово", "Цинк"], correctIndex: 0 },
{ category: "Общие знания", band: 5, question: "Как называется боязнь замкнутого пространства?", options: ["Клаустрофобия", "Агорафобия", "Акрофобия", "Арахнофобия"], correctIndex: 0 },
{ category: "Общие знания", band: 5, question: "Сколько костей в кисти человека, включая запястье?", options: ["27", "25", "29", "23"], correctIndex: 0 },
{ category: "Общие знания", band: 5, question: "Какое море не имеет сухопутных берегов, а окружено океаническими течениями?", options: ["Саргассово море", "Мёртвое море", "Красное море", "Аравийское море"], correctIndex: 0 },
],
};
+325
View File
@@ -0,0 +1,325 @@
import Phaser from "phaser";
import {
isBossLevel,
monsterHpForLevel,
goldForMonster,
heroCostAtLevel,
heroRoleLabel,
prestigeMultiplier,
BOSS_TIME_LIMIT_MS,
type ClickerAccountBonuses,
} from "../clicker";
import type { ClickerDefinition, ClickerProgress, GameResult } from "../types";
const COLORS = {
bg: 0x14162a,
hpBarBg: 0x292d4a,
hpHigh: 0x0d9488,
hpMid: 0xd97706,
hpLow: 0xdc2626,
bossTimerBg: 0x292d4a,
bossTimerFill: 0xdc2626,
heroRow: 0x23264a,
heroRowUnaffordable: 0x181a30,
coin: 0xfbbf24,
flash: 0xdc2626,
};
const MONSTER_X = 480;
const MONSTER_Y = 155;
const HP_BAR_Y = 258;
const HP_BAR_WIDTH = 500;
const HEROES_START_Y = 308;
const HERO_ROW_GAP = 38;
export class ClickerScene extends Phaser.Scene {
private definition!: ClickerDefinition;
private accountBonuses?: ClickerAccountBonuses;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: ClickerProgress) => void;
private level = 1;
private gold = 0;
private heroLevels: number[] = [];
private clickDamage = 0;
private dps = 0;
private monsterMaxHp = 0;
private monsterHp = 0;
private isBoss = false;
private bossRemainingMs = 0;
private bossTimerEvent?: Phaser.Time.TimerEvent;
private finished = false;
private levelText!: Phaser.GameObjects.Text;
private goldText!: Phaser.GameObjects.Text;
private dpsText!: Phaser.GameObjects.Text;
private monsterEmojiText!: Phaser.GameObjects.Text;
private monsterNameText!: Phaser.GameObjects.Text;
private hpBarBg!: Phaser.GameObjects.Rectangle;
private hpBarFill!: Phaser.GameObjects.Rectangle;
private hpValueText!: Phaser.GameObjects.Text;
private bossBarBg!: Phaser.GameObjects.Rectangle;
private bossBarFill!: Phaser.GameObjects.Rectangle;
private flashCircle!: Phaser.GameObjects.Arc;
private heroRows: { bg: Phaser.GameObjects.Rectangle; label: Phaser.GameObjects.Text }[] = [];
constructor() {
super("ClickerScene");
}
create() {
this.definition = this.registry.get("definition");
this.accountBonuses = this.registry.get("accountBonuses");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
const initialState = this.registry.get("initialState") as ClickerProgress | undefined;
this.level = Math.max(1, initialState?.level ?? 1);
this.gold = initialState?.gold ?? 0;
this.heroLevels = this.definition.heroes.map((_, i) => initialState?.heroLevels[i] ?? 0);
this.finished = false;
this.add
.graphics()
.fillGradientStyle(0x23264a, 0x23264a, COLORS.bg, COLORS.bg, 1)
.fillRect(0, 0, 960, 720)
.setDepth(-10);
this.levelText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#a3a8c6" });
this.goldText = this.add
.text(940, 15, "", { fontFamily: "sans-serif", fontSize: "22px", color: "#fbbf24", fontStyle: "bold" })
.setOrigin(1, 0);
this.dpsText = this.add
.text(940, 38, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" })
.setOrigin(1, 0);
this.flashCircle = this.add.circle(MONSTER_X, MONSTER_Y, 95, COLORS.flash, 0).setDepth(1);
this.monsterEmojiText = this.add
.text(MONSTER_X, MONSTER_Y, "", { fontSize: "130px" })
.setOrigin(0.5)
.setDepth(2)
.setInteractive({ useHandCursor: true });
this.monsterEmojiText.on("pointerdown", (pointer: Phaser.Input.Pointer) => this.handleClick(pointer));
this.monsterNameText = this.add
.text(MONSTER_X, 228, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#818cf8" })
.setOrigin(0.5);
this.hpBarBg = this.add.rectangle(MONSTER_X, HP_BAR_Y, HP_BAR_WIDTH, 18, COLORS.hpBarBg);
this.hpBarFill = this.add.rectangle(MONSTER_X - HP_BAR_WIDTH / 2, HP_BAR_Y, HP_BAR_WIDTH, 18, COLORS.hpHigh).setOrigin(0, 0.5);
this.hpValueText = this.add
.text(MONSTER_X, HP_BAR_Y, "", { fontFamily: "sans-serif", fontSize: "17px", color: "#eef0fb" })
.setOrigin(0.5);
this.bossBarBg = this.add.rectangle(MONSTER_X, 283, HP_BAR_WIDTH, 8, COLORS.bossTimerBg).setVisible(false);
this.bossBarFill = this.add
.rectangle(MONSTER_X - HP_BAR_WIDTH / 2, 283, HP_BAR_WIDTH, 8, COLORS.bossTimerFill)
.setOrigin(0, 0.5)
.setVisible(false);
this.definition.heroes.forEach((_, index) => {
const y = HEROES_START_Y + index * HERO_ROW_GAP;
const bg = this.add.rectangle(480, y, 780, 34, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({
useHandCursor: true,
});
const label = this.add
.text(480, y, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" })
.setOrigin(0.5);
bg.on("pointerdown", () => this.buyHero(index));
this.heroRows.push({ bg, label });
});
this.recomputeStats();
this.refreshHud();
this.refreshHeroPanel();
this.startLevel();
this.time.addEvent({
delay: 1000,
loop: true,
callback: () => {
if (!this.finished && this.dps > 0) this.damage(this.dps, false);
},
});
}
private recomputeStats() {
this.clickDamage = this.definition.startingClickDamage;
this.dps = 0;
this.definition.heroes.forEach((hero, i) => {
const lvl = this.heroLevels[i] ?? 0;
this.clickDamage += lvl * hero.clickDamageBonus;
this.dps += lvl * hero.dpsBonus;
});
// Permanent account-wide prestige bonus — click skill only, dps is
// purely heroes' doing.
this.clickDamage *= prestigeMultiplier(this.accountBonuses?.clickBonusLevel ?? 0);
}
private startLevel() {
this.bossTimerEvent?.remove();
this.isBoss = isBossLevel(this.level);
this.monsterMaxHp = monsterHpForLevel(this.level, this.definition);
this.monsterHp = this.monsterMaxHp;
const emoji = this.isBoss
? this.definition.bossEmoji
: this.definition.monsterEmojis[(this.level - 1) % this.definition.monsterEmojis.length];
this.monsterEmojiText.setText(emoji).setFontSize(this.isBoss ? 160 : 130);
this.monsterNameText.setText(this.isBoss ? `БОСС — уровень ${this.level}` : `Уровень ${this.level}`);
if (this.isBoss) {
this.bossRemainingMs = BOSS_TIME_LIMIT_MS;
this.bossBarBg.setVisible(true);
this.bossBarFill.setVisible(true);
this.updateBossBar();
this.bossTimerEvent = this.time.addEvent({
delay: 100,
loop: true,
callback: () => {
if (this.finished) return;
this.bossRemainingMs -= 100;
if (this.bossRemainingMs <= 0) {
this.monsterHp = this.monsterMaxHp;
this.bossRemainingMs = BOSS_TIME_LIMIT_MS;
this.updateHpBar();
}
this.updateBossBar();
},
});
} else {
this.bossBarBg.setVisible(false);
this.bossBarFill.setVisible(false);
}
this.updateHpBar();
this.refreshHud();
this.reportProgress();
}
private updateHpBar() {
const pct = Math.max(0, this.monsterHp / this.monsterMaxHp);
this.hpBarFill.setScale(pct, 1);
const color = pct > 0.5 ? COLORS.hpHigh : pct > 0.2 ? COLORS.hpMid : COLORS.hpLow;
this.hpBarFill.setFillStyle(color);
this.hpValueText.setText(`${Math.ceil(this.monsterHp)} / ${this.monsterMaxHp}`);
}
private updateBossBar() {
const pct = Math.max(0, this.bossRemainingMs / BOSS_TIME_LIMIT_MS);
this.bossBarFill.setScale(pct, 1);
}
private refreshHud() {
this.levelText.setText(`${this.definition.theme} — уровень ${this.level}/${this.definition.targetLevel}`);
this.goldText.setText(`💰 ${Math.floor(this.gold)}`);
this.dpsText.setText(`${this.clickDamage}/клик • ${this.dps}/сек`);
}
private refreshHeroPanel() {
this.heroRows.forEach(({ bg, label }, index) => {
const hero = this.definition.heroes[index];
const lvl = this.heroLevels[index] ?? 0;
const cost = heroCostAtLevel(hero, lvl);
const affordable = this.gold >= cost;
label.setText(`${hero.name} • ур.${lvl}${cost}💰 • ${heroRoleLabel(hero)}`);
bg.setFillStyle(affordable ? COLORS.heroRow : COLORS.heroRowUnaffordable);
});
}
private handleClick(pointer: Phaser.Input.Pointer) {
if (this.finished) return;
this.damage(this.clickDamage, true, pointer.x, pointer.y);
}
private damage(amount: number, fromClick: boolean, x = MONSTER_X, y = MONSTER_Y) {
if (this.finished || amount <= 0) return;
this.monsterHp = Math.max(0, this.monsterHp - amount);
this.updateHpBar();
if (fromClick) {
this.flashCircle.setAlpha(0.45);
this.tweens.add({ targets: this.flashCircle, alpha: 0, duration: 150 });
this.cameras.main.shake(80, 0.004);
this.tweens.add({ targets: this.monsterEmojiText, scale: 0.85, duration: 70, yoyo: true });
const dmgText = this.add
.text(x, y - 40, `-${Math.round(amount)}`, {
fontFamily: "sans-serif",
fontSize: "24px",
color: "#f87171",
fontStyle: "bold",
})
.setOrigin(0.5)
.setDepth(3);
this.tweens.add({
targets: dmgText,
y: y - 90,
alpha: 0,
duration: 500,
onComplete: () => dmgText.destroy(),
});
}
if (this.monsterHp <= 0) this.onMonsterDefeated();
}
private onMonsterDefeated() {
const goldMultiplier = prestigeMultiplier(this.accountBonuses?.goldBonusLevel ?? 0);
this.gold += Math.round(goldForMonster(this.monsterMaxHp) * goldMultiplier);
this.spawnCoinBurst();
this.refreshHud();
this.refreshHeroPanel();
if (this.level >= this.definition.targetLevel) {
this.finish(true, Math.floor(this.gold), `Подземелье пройдено! Собрано золота: ${Math.floor(this.gold)}.`);
return;
}
this.level += 1;
this.startLevel();
}
private spawnCoinBurst() {
for (let i = 0; i < 6; i++) {
const coin = this.add.circle(MONSTER_X, MONSTER_Y, 6, COLORS.coin).setDepth(3);
const angle = (Math.PI * 2 * i) / 6 + Math.random() * 0.4;
const dist = 60 + Math.random() * 30;
this.tweens.add({
targets: coin,
x: MONSTER_X + Math.cos(angle) * dist,
y: MONSTER_Y + Math.sin(angle) * dist - 20,
alpha: 0,
duration: 500,
onComplete: () => coin.destroy(),
});
}
}
private buyHero(index: number) {
if (this.finished) return;
const hero = this.definition.heroes[index];
const lvl = this.heroLevels[index] ?? 0;
const cost = heroCostAtLevel(hero, lvl);
if (this.gold < cost) return;
this.gold -= cost;
this.heroLevels[index] = lvl + 1;
this.recomputeStats();
this.refreshHud();
this.refreshHeroPanel();
this.reportProgress();
}
private reportProgress() {
this.onProgress?.({ level: this.level, gold: this.gold, heroLevels: [...this.heroLevels] });
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.bossTimerEvent?.remove();
this.onFinish?.({ won, score, message });
}
}
+19
View File
@@ -0,0 +1,19 @@
import type Phaser from "phaser";
import { QuizScene } from "./quiz-scene";
import { ClickerScene } from "./clicker-scene";
import { MazeScene } from "./maze-scene";
import { SnakeScene } from "./snake-scene";
import type { TemplateType } from "../types";
export function sceneClassFor(templateType: TemplateType): typeof Phaser.Scene {
switch (templateType) {
case "quiz":
return QuizScene;
case "clicker":
return ClickerScene;
case "maze":
return MazeScene;
case "snake":
return SnakeScene;
}
}
+481
View File
@@ -0,0 +1,481 @@
import Phaser from "phaser";
import {
PLAYER_STEP_MS,
GHOST_STEP_MS,
FRIGHTEN_DURATION_MS,
GHOST_RESPAWN_DELAY_MS,
DOT_SCORE,
PELLET_SCORE,
GHOST_EAT_SCORE,
} from "../maze";
import type { MazeDefinition, MazeProgress, GameResult } from "../types";
const COLORS = {
bg: 0x0a0b14,
wall: 0x2121de,
floor: 0x0f1030,
exit: 0x0d9488,
dot: 0xfde68a,
pellet: 0xfde68a,
player: 0xfbe200,
ghostFrightened: 0x2b4bde,
ghostColors: [0xff0000, 0xffb8ff, 0x00ffff, 0xffb851],
};
interface Cell {
row: number;
col: number;
}
type Dir = { dr: number; dc: number };
const DIRS: Record<string, Dir> = {
up: { dr: -1, dc: 0 },
down: { dr: 1, dc: 0 },
left: { dr: 0, dc: -1 },
right: { dr: 0, dc: 1 },
};
interface Ghost {
row: number;
col: number;
homeRow: number;
homeCol: number;
color: number;
frightenedUntil?: number;
eatenUntil?: number;
container: Phaser.GameObjects.Container;
body: Phaser.GameObjects.Arc;
}
const GHOST_RADIUS = 12;
const DOT_RADIUS = 3;
const PELLET_RADIUS = 7;
const PLAYER_RADIUS = 13;
export class MazeScene extends Phaser.Scene {
private definition!: MazeDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: MazeProgress) => void;
private grid: string[] = [];
private rows = 0;
private cols = 0;
private cellSize = 32;
private offsetX = 0;
private offsetY = 0;
private startPos: Cell = { row: 0, col: 0 };
private playerPos: Cell = { row: 0, col: 0 };
private desiredDir: Dir | null = null;
private currentDir: Dir | null = null;
private player!: Phaser.GameObjects.Arc;
private dots = new Set<string>();
private pellets = new Set<string>();
private dotSprites = new Map<string, Phaser.GameObjects.Arc>();
private pelletSprites = new Map<string, Phaser.GameObjects.Arc>();
private collectedKeys = new Set<string>();
private totalCollectibles = 0;
private ghosts: Ghost[] = [];
private score = 0;
private lives = 3;
private remainingMs = 0;
private finished = false;
private livesText!: Phaser.GameObjects.Text;
private scoreText!: Phaser.GameObjects.Text;
private dotsText!: Phaser.GameObjects.Text;
private timerText?: Phaser.GameObjects.Text;
private frightenBarBg!: Phaser.GameObjects.Rectangle;
private frightenBarFill!: Phaser.GameObjects.Rectangle;
private frightenEndsAt = 0;
constructor() {
super("MazeScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
this.finished = false;
this.desiredDir = null;
this.currentDir = null;
this.add
.graphics()
.fillGradientStyle(0x14162a, 0x14162a, COLORS.bg, COLORS.bg, 1)
.fillRect(0, 0, 960, 720)
.setDepth(-10);
const initialState = this.registry.get("initialState") as MazeProgress | undefined;
this.collectedKeys = new Set(initialState?.collectedKeys ?? []);
this.lives = initialState?.lives ?? this.definition.lives;
this.score = 0;
this.grid = this.definition.grid;
this.rows = this.grid.length;
this.cols = this.grid[0].length;
this.cellSize = Math.max(10, Math.min(40, Math.floor(900 / this.cols), Math.floor(550 / this.rows)));
this.offsetX = 480 - (this.cols * this.cellSize) / 2;
this.offsetY = 130;
this.dots = new Set();
this.pellets = new Set();
this.dotSprites = new Map();
this.pelletSprites = new Map();
this.ghosts = [];
this.totalCollectibles = 0;
let ghostIndex = 0;
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const ch = this.grid[r][c];
const { x, y } = this.cellToPixel({ row: r, col: c });
const key = `${r},${c}`;
if (ch === "#") {
this.add.rectangle(x, y, this.cellSize, this.cellSize, COLORS.wall);
continue;
}
this.add.rectangle(x, y, this.cellSize, this.cellSize, ch === "E" ? COLORS.exit : COLORS.floor);
if (ch === "S") this.startPos = { row: r, col: c };
if (ch === "*") {
this.totalCollectibles += 1;
if (!this.collectedKeys.has(key)) {
this.dots.add(key);
this.dotSprites.set(key, this.add.circle(x, y, DOT_RADIUS, COLORS.dot));
}
} else if (ch === "O") {
this.totalCollectibles += 1;
if (!this.collectedKeys.has(key)) {
this.pellets.add(key);
const pellet = this.add.circle(x, y, PELLET_RADIUS, COLORS.pellet);
this.tweens.add({ targets: pellet, scale: 1.3, duration: 500, yoyo: true, repeat: -1 });
this.pelletSprites.set(key, pellet);
}
} else if (ch === "G") {
this.ghosts.push(this.createGhost(r, c, ghostIndex));
ghostIndex += 1;
}
}
}
this.playerPos = this.resolvePlayerStart(initialState);
const playerPx = this.cellToPixel(this.playerPos);
this.player = this.add.circle(playerPx.x, playerPx.y, PLAYER_RADIUS, COLORS.player).setDepth(3);
this.tweens.add({ targets: this.player, scale: 0.82, duration: 140, yoyo: true, repeat: -1 });
this.livesText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" });
this.scoreText = this.add.text(20, 38, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" });
this.dotsText = this.add
.text(940, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" })
.setOrigin(1, 0);
this.frightenBarBg = this.add.rectangle(480, 95, 500, 8, 0x292d4a).setVisible(false);
this.frightenBarFill = this.add
.rectangle(480 - 250, 95, 500, 8, 0x60a5fa)
.setOrigin(0, 0.5)
.setVisible(false);
if (this.definition.timeLimitSec > 0) {
this.remainingMs = initialState?.remainingMs ?? this.definition.timeLimitSec * 1000;
this.timerText = this.add
.text(480, 15, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#a3a8c6" })
.setOrigin(0.5, 0);
this.time.addEvent({
delay: 100,
loop: true,
callback: () => {
if (this.finished) return;
this.remainingMs -= 100;
if (this.remainingMs <= 0) {
this.finish(false, this.score, "Время вышло.");
return;
}
this.updateHud();
},
});
}
this.input.keyboard!.on("keydown-LEFT", () => (this.desiredDir = DIRS.left));
this.input.keyboard!.on("keydown-RIGHT", () => (this.desiredDir = DIRS.right));
this.input.keyboard!.on("keydown-UP", () => (this.desiredDir = DIRS.up));
this.input.keyboard!.on("keydown-DOWN", () => (this.desiredDir = DIRS.down));
this.registry.events.on("touchDirection", (dir: "up" | "down" | "left" | "right") => {
this.desiredDir = DIRS[dir];
});
this.time.addEvent({ delay: PLAYER_STEP_MS, loop: true, callback: () => this.playerTick() });
this.time.addEvent({ delay: GHOST_STEP_MS, loop: true, callback: () => this.ghostTick() });
this.updateHud();
this.reportProgress();
}
private createGhost(row: number, col: number, index: number): Ghost {
const color = COLORS.ghostColors[index % COLORS.ghostColors.length];
const { x, y } = this.cellToPixel({ row, col });
const body = this.add.circle(0, 0, GHOST_RADIUS, color);
const eye1 = this.add.circle(-5, -4, 3, 0xffffff);
const eye2 = this.add.circle(5, -4, 3, 0xffffff);
const pupil1 = this.add.circle(-5, -4, 1.3, 0x0a0b14);
const pupil2 = this.add.circle(5, -4, 1.3, 0x0a0b14);
const container = this.add.container(x, y, [body, eye1, pupil1, eye2, pupil2]).setDepth(2);
return { row, col, homeRow: row, homeCol: col, color, container, body };
}
private resolvePlayerStart(initialState?: MazeProgress): Cell {
if (
initialState &&
initialState.playerRow >= 0 &&
initialState.playerRow < this.rows &&
initialState.playerCol >= 0 &&
initialState.playerCol < this.cols &&
this.grid[initialState.playerRow][initialState.playerCol] !== "#"
) {
return { row: initialState.playerRow, col: initialState.playerCol };
}
return this.startPos;
}
private cellToPixel(cell: Cell): { x: number; y: number } {
return {
x: this.offsetX + cell.col * this.cellSize + this.cellSize / 2,
y: this.offsetY + cell.row * this.cellSize + this.cellSize / 2,
};
}
private isWalkable(row: number, col: number): boolean {
if (row < 0 || row >= this.rows || col < 0 || col >= this.cols) return false;
return this.grid[row][col] !== "#";
}
private tryMove(from: Cell, dir: Dir): Cell | null {
const nr = from.row + dir.dr;
const nc = from.col + dir.dc;
return this.isWalkable(nr, nc) ? { row: nr, col: nc } : null;
}
private snapTo(obj: { x: number; y: number }, cell: Cell) {
const { x, y } = this.cellToPixel(cell);
obj.x = x;
obj.y = y;
}
private tweenTo(obj: Phaser.GameObjects.Container | Phaser.GameObjects.Arc, cell: Cell, duration: number) {
const { x, y } = this.cellToPixel(cell);
this.tweens.add({ targets: obj, x, y, duration, ease: "Linear" });
}
private playerTick() {
if (this.finished) return;
let next = this.desiredDir ? this.tryMove(this.playerPos, this.desiredDir) : null;
if (next) {
this.currentDir = this.desiredDir;
} else {
next = this.currentDir ? this.tryMove(this.playerPos, this.currentDir) : null;
}
if (!next) return;
this.playerPos = next;
this.tweenTo(this.player, next, PLAYER_STEP_MS * 0.9);
this.handlePickup(next);
this.checkCollisions();
if (!this.finished) this.reportProgress();
}
private handlePickup(cell: Cell) {
const key = `${cell.row},${cell.col}`;
if (this.dots.has(key)) {
this.dots.delete(key);
this.dotSprites.get(key)?.destroy();
this.dotSprites.delete(key);
this.collectedKeys.add(key);
this.score += DOT_SCORE;
} else if (this.pellets.has(key)) {
this.pellets.delete(key);
this.pelletSprites.get(key)?.destroy();
this.pelletSprites.delete(key);
this.collectedKeys.add(key);
this.score += PELLET_SCORE;
this.frightenAllGhosts();
}
this.updateHud();
if (this.dots.size === 0 && this.pellets.size === 0 && this.grid[cell.row][cell.col] === "E") {
this.finish(true, this.score, `Лабиринт пройден! Очков: ${this.score}`);
}
}
private frightenAllGhosts() {
const until = this.time.now + FRIGHTEN_DURATION_MS;
this.frightenEndsAt = until;
this.ghosts.forEach((g) => {
if (!g.eatenUntil || g.eatenUntil < this.time.now) g.frightenedUntil = until;
});
this.frightenBarBg.setVisible(true);
this.frightenBarFill.setVisible(true);
}
private ghostTick() {
if (this.finished) return;
const now = this.time.now;
this.ghosts.forEach((g) => {
if (g.eatenUntil !== undefined) {
if (g.eatenUntil > now) return;
g.eatenUntil = undefined;
g.row = g.homeRow;
g.col = g.homeCol;
g.body.setFillStyle(g.color);
g.container.setVisible(true);
this.snapTo(g.container, g);
return;
}
const frightened = g.frightenedUntil !== undefined && g.frightenedUntil > now;
if (g.frightenedUntil !== undefined && g.frightenedUntil <= now) g.frightenedUntil = undefined;
g.body.setFillStyle(frightened ? COLORS.ghostFrightened : g.color);
const next = frightened ? this.fleeStep(g) : this.chaseStep(g);
if (next) {
g.row = next.row;
g.col = next.col;
this.tweenTo(g.container, next, GHOST_STEP_MS * 0.9);
}
});
this.updateFrightenBar(now);
this.checkCollisions();
}
private bfsPath(from: Cell, to: Cell): Cell[] | null {
const key = (p: Cell) => `${p.row},${p.col}`;
const visited = new Set<string>([key(from)]);
const queue: Cell[][] = [[from]];
while (queue.length > 0) {
const path = queue.shift()!;
const cur = path[path.length - 1];
if (cur.row === to.row && cur.col === to.col) return path;
for (const d of Object.values(DIRS)) {
const nr = cur.row + d.dr;
const nc = cur.col + d.dc;
if (!this.isWalkable(nr, nc)) continue;
const k = `${nr},${nc}`;
if (visited.has(k)) continue;
visited.add(k);
queue.push([...path, { row: nr, col: nc }]);
}
}
return null;
}
private chaseStep(g: Ghost): Cell | null {
const path = this.bfsPath({ row: g.row, col: g.col }, this.playerPos);
return path && path.length > 1 ? path[1] : null;
}
private fleeStep(g: Ghost): Cell | null {
let best: Cell | null = null;
let bestDist = -1;
for (const d of Object.values(DIRS)) {
const nr = g.row + d.dr;
const nc = g.col + d.dc;
if (!this.isWalkable(nr, nc)) continue;
const dist = Math.abs(nr - this.playerPos.row) + Math.abs(nc - this.playerPos.col);
if (dist > bestDist) {
bestDist = dist;
best = { row: nr, col: nc };
}
}
return best;
}
private checkCollisions() {
if (this.finished) return;
const now = this.time.now;
for (const g of this.ghosts) {
if (g.eatenUntil !== undefined) continue;
if (g.row !== this.playerPos.row || g.col !== this.playerPos.col) continue;
const frightened = g.frightenedUntil !== undefined && g.frightenedUntil > now;
if (frightened) {
this.score += GHOST_EAT_SCORE;
g.frightenedUntil = undefined;
g.eatenUntil = now + GHOST_RESPAWN_DELAY_MS;
g.container.setVisible(false);
this.updateHud();
} else {
this.loseLife();
}
break;
}
}
private loseLife() {
this.lives -= 1;
this.updateHud();
if (this.lives <= 0) {
this.finish(false, this.score, `Вас поймали. Очков: ${this.score}`);
return;
}
this.playerPos = { ...this.startPos };
this.snapTo(this.player, this.playerPos);
this.desiredDir = null;
this.currentDir = null;
this.ghosts.forEach((g) => {
g.row = g.homeRow;
g.col = g.homeCol;
g.frightenedUntil = undefined;
g.eatenUntil = undefined;
g.body.setFillStyle(g.color);
g.container.setVisible(true);
this.snapTo(g.container, g);
});
this.reportProgress();
}
private updateFrightenBar(now: number) {
const active = this.ghosts.some((g) => g.frightenedUntil !== undefined && g.frightenedUntil > now);
if (!active) {
this.frightenBarBg.setVisible(false);
this.frightenBarFill.setVisible(false);
return;
}
const pct = Math.max(0, (this.frightenEndsAt - now) / FRIGHTEN_DURATION_MS);
this.frightenBarFill.setScale(pct, 1);
}
private updateHud() {
this.livesText.setText(`Жизни: ${"❤".repeat(Math.max(0, this.lives))}`);
this.scoreText.setText(`Очки: ${this.score}`);
this.dotsText.setText(`Осталось: ${this.dots.size + this.pellets.size}`);
if (this.timerText) {
this.timerText.setText(`Время: ${Math.max(0, Math.ceil(this.remainingMs / 1000))} сек`);
}
}
private reportProgress() {
this.onProgress?.({
playerRow: this.playerPos.row,
playerCol: this.playerPos.col,
collectedKeys: Array.from(this.collectedKeys),
remainingMs: this.remainingMs,
lives: this.lives,
});
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.onFinish?.({ won, score, message });
}
}
+355
View File
@@ -0,0 +1,355 @@
import Phaser from "phaser";
import { MONEY_LADDER, CHECKPOINT_LEVELS, bandForLevel } from "../quiz";
import type { QuizDefinition, QuizQuestion, QuizProgress, GameResult } from "../types";
const COLORS = {
bg: 0x14162a,
option: 0x23264a,
optionHover: 0x2d3163,
optionHidden: 0x181a30,
correct: 0x0d9488,
wrong: 0xdc2626,
lifeline: 0x23264a,
lifelineUsed: 0x181a30,
overlayBg: 0x1b1e36,
};
type LifelineKind = "fiftyFifty" | "audience" | "phone";
const AUDIENCE_CONFIDENCE: Record<number, number> = { 1: 0.85, 2: 0.75, 3: 0.65, 4: 0.55, 5: 0.45 };
const PHONE_CONFIDENCE: Record<number, number> = { 1: 0.9, 2: 0.82, 3: 0.74, 4: 0.64, 5: 0.55 };
const OPTION_LETTERS = ["A", "B", "C", "D"];
function formatMoney(n: number): string {
return n.toLocaleString("ru-RU");
}
/** Money kept after failing a question, once `completedLevels` were already answered correctly. */
function checkpointScoreAfter(completedLevels: number): number {
const reached = CHECKPOINT_LEVELS.filter((c) => c <= completedLevels);
if (reached.length === 0) return 0;
return MONEY_LADDER[Math.max(...reached) - 1];
}
function shuffle<T>(items: T[]): T[] {
const arr = [...items];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
export class QuizScene extends Phaser.Scene {
private definition!: QuizDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: QuizProgress) => void;
private level = 1;
private bankedScore = 0;
private usedLifelines = new Set<LifelineKind>();
private usedQuestionKeys = new Set<string>();
private currentQuestion!: QuizQuestion;
private shuffledOptions: string[] = [];
private shuffledCorrectIndex = 0;
private hiddenIndices = new Set<number>();
private locked = false;
private finished = false;
private levelText!: Phaser.GameObjects.Text;
private prizeText!: Phaser.GameObjects.Text;
private categoryText!: Phaser.GameObjects.Text;
private questionText!: Phaser.GameObjects.Text;
private optionRects: Phaser.GameObjects.Rectangle[] = [];
private optionLabels: Phaser.GameObjects.Text[] = [];
private lifelineButtons = new Map<LifelineKind, Phaser.GameObjects.Rectangle>();
private walkAwayButton!: Phaser.GameObjects.Rectangle;
private walkAwayLabel!: Phaser.GameObjects.Text;
private overlayObjects: Phaser.GameObjects.GameObject[] = [];
constructor() {
super("QuizScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
const initialState = this.registry.get("initialState") as QuizProgress | undefined;
this.level = initialState ? Math.min(15, Math.max(1, initialState.level)) : 1;
this.bankedScore = initialState?.bankedScore ?? 0;
this.usedLifelines = new Set(initialState?.usedLifelines ?? []);
this.usedQuestionKeys = new Set();
this.finished = false;
this.add
.graphics()
.fillGradientStyle(0x23264a, 0x23264a, COLORS.bg, COLORS.bg, 1)
.fillRect(0, 0, 960, 720)
.setDepth(-10);
this.levelText = this.add.text(30, 18, "", { fontFamily: "sans-serif", fontSize: "22px", color: "#a3a8c6" });
this.prizeText = this.add
.text(930, 18, "", { fontFamily: "sans-serif", fontSize: "24px", color: "#eef0fb", fontStyle: "bold" })
.setOrigin(1, 0);
this.categoryText = this.add
.text(480, 18, "", { fontFamily: "sans-serif", fontSize: "18px", color: "#818cf8" })
.setOrigin(0.5, 0);
this.questionText = this.add
.text(480, 55, "", {
fontFamily: "sans-serif",
fontSize: "28px",
color: "#eef0fb",
align: "center",
wordWrap: { width: 840 },
})
.setOrigin(0.5, 0);
this.walkAwayButton = this.add
.rectangle(880, 60, 140, 30, COLORS.lifeline)
.setStrokeStyle(1, 0x4f46e5)
.setInteractive({ useHandCursor: true });
this.walkAwayLabel = this.add
.text(880, 60, "Забрать", { fontFamily: "sans-serif", fontSize: "18px", color: "#eef0fb" })
.setOrigin(0.5);
this.walkAwayButton.on("pointerdown", () => this.walkAway());
const lifelineDefs: { kind: LifelineKind; x: number; label: string }[] = [
{ kind: "fiftyFifty", x: 300, label: "50:50" },
{ kind: "audience", x: 480, label: "Зал" },
{ kind: "phone", x: 660, label: "Звонок" },
];
lifelineDefs.forEach(({ kind, x, label }) => {
const bg = this.add
.rectangle(x, 520, 150, 46, COLORS.lifeline)
.setStrokeStyle(1, 0x4f46e5)
.setInteractive({ useHandCursor: true });
this.add.text(x, 520, label, { fontFamily: "sans-serif", fontSize: "21px", color: "#eef0fb" }).setOrigin(0.5);
bg.on("pointerdown", () => this.useLifeline(kind));
this.lifelineButtons.set(kind, bg);
});
this.refreshLifelineButtons();
this.startLevel();
}
private startLevel() {
this.locked = false;
this.hiddenIndices = new Set();
this.clearOverlay();
const band = bandForLevel(this.level);
const pool = this.definition.questions.filter(
(q) => q.band === band && !this.usedQuestionKeys.has(`${q.category}|${q.question}`),
);
const fallbackPool = pool.length > 0 ? pool : this.definition.questions.filter((q) => q.band === band);
this.currentQuestion = fallbackPool[Math.floor(Math.random() * fallbackPool.length)];
this.usedQuestionKeys.add(`${this.currentQuestion.category}|${this.currentQuestion.question}`);
const order = shuffle(this.currentQuestion.options.map((_, i) => i));
this.shuffledOptions = order.map((i) => this.currentQuestion.options[i]);
this.shuffledCorrectIndex = order.indexOf(this.currentQuestion.correctIndex);
this.levelText.setText(`Уровень ${this.level}/15`);
this.prizeText.setText(`${formatMoney(MONEY_LADDER[this.level - 1])} очков`);
this.categoryText.setText(this.currentQuestion.category.toUpperCase());
this.questionText.setText(this.currentQuestion.question);
this.walkAwayButton.setVisible(this.level > 1);
this.walkAwayLabel.setVisible(this.level > 1);
this.walkAwayLabel.setText(`Забрать\n${formatMoney(this.bankedScore)}`);
this.renderOptions();
this.refreshLifelineButtons();
this.reportProgress();
}
private clearOptions() {
this.optionRects.forEach((r) => r.destroy());
this.optionLabels.forEach((l) => l.destroy());
this.optionRects = [];
this.optionLabels = [];
}
private renderOptions() {
this.clearOptions();
const startY = 200;
const gap = 76;
this.shuffledOptions.forEach((option, i) => {
const y = startY + i * gap;
const hidden = this.hiddenIndices.has(i);
const bg = this.add.rectangle(480, y, 780, 64, hidden ? COLORS.optionHidden : COLORS.option).setStrokeStyle(
1,
hidden ? 0x292d4a : 0x4f46e5,
);
const label = this.add
.text(140, y, hidden ? "" : `${OPTION_LETTERS[i]}: ${option}`, {
fontFamily: "sans-serif",
fontSize: "24px",
color: "#eef0fb",
})
.setOrigin(0, 0.5);
if (!hidden) {
bg.setInteractive({ useHandCursor: true });
bg.on("pointerover", () => {
if (!this.locked) bg.setFillStyle(COLORS.optionHover);
});
bg.on("pointerout", () => {
if (!this.locked) bg.setFillStyle(COLORS.option);
});
bg.on("pointerdown", () => this.selectOption(i, bg));
}
this.optionRects.push(bg);
this.optionLabels.push(label);
});
}
private selectOption(index: number, bg: Phaser.GameObjects.Rectangle) {
if (this.locked) return;
this.locked = true;
const correct = index === this.shuffledCorrectIndex;
bg.setFillStyle(correct ? COLORS.correct : COLORS.wrong);
if (!correct) {
this.optionRects[this.shuffledCorrectIndex]?.setFillStyle(COLORS.correct);
}
this.time.delayedCall(900, () => {
if (correct) {
this.bankedScore = MONEY_LADDER[this.level - 1];
if (this.level >= 15) {
this.finish(true, this.bankedScore, `Максимальный выигрыш: ${formatMoney(this.bankedScore)} очков!`);
return;
}
this.level += 1;
this.startLevel();
} else {
const finalScore = checkpointScoreAfter(this.level - 1);
this.finish(false, finalScore, `Неверный ответ. Забрано: ${formatMoney(finalScore)} очков.`);
}
});
}
private walkAway() {
if (this.locked || this.finished || this.level <= 1) return;
this.finish(true, this.bankedScore, `Забрано: ${formatMoney(this.bankedScore)} очков.`);
}
private useLifeline(kind: LifelineKind) {
if (this.locked || this.finished || this.usedLifelines.has(kind)) return;
this.usedLifelines.add(kind);
this.refreshLifelineButtons();
this.reportProgress();
if (kind === "fiftyFifty") {
const wrongVisible = this.shuffledOptions
.map((_, i) => i)
.filter((i) => i !== this.shuffledCorrectIndex && !this.hiddenIndices.has(i));
shuffle(wrongVisible)
.slice(0, 2)
.forEach((i) => this.hiddenIndices.add(i));
this.renderOptions();
return;
}
const band = bandForLevel(this.level);
const visible = this.shuffledOptions.map((_, i) => i).filter((i) => !this.hiddenIndices.has(i));
if (kind === "audience") {
const confidence = AUDIENCE_CONFIDENCE[band];
const wrongVisible = visible.filter((i) => i !== this.shuffledCorrectIndex);
const shares = new Map<number, number>();
shares.set(this.shuffledCorrectIndex, confidence);
const remaining = 1 - confidence;
wrongVisible.forEach((i) => shares.set(i, remaining / Math.max(1, wrongVisible.length)));
this.showAudienceOverlay(shares);
return;
}
// phone
const confidence = PHONE_CONFIDENCE[band];
const wrongVisible = visible.filter((i) => i !== this.shuffledCorrectIndex);
const suggestion =
Math.random() < confidence || wrongVisible.length === 0
? this.shuffledCorrectIndex
: wrongVisible[Math.floor(Math.random() * wrongVisible.length)];
this.showPhoneOverlay(suggestion);
}
private clearOverlay() {
this.overlayObjects.forEach((o) => o.destroy());
this.overlayObjects = [];
}
private showAudienceOverlay(shares: Map<number, number>) {
this.clearOverlay();
const bg = this.add.rectangle(480, 615, 780, 90, COLORS.overlayBg).setStrokeStyle(1, 0x292d4a);
this.overlayObjects.push(bg);
const title = this.add
.text(480, 585, "Помощь зала", { fontFamily: "sans-serif", fontSize: "17px", color: "#a3a8c6" })
.setOrigin(0.5);
this.overlayObjects.push(title);
const entries = [...shares.entries()].sort((a, b) => a[0] - b[0]);
const barAreaWidth = 720;
const barWidth = barAreaWidth / entries.length - 16;
entries.forEach(([optIndex, pct], i) => {
const x = 480 - barAreaWidth / 2 + i * (barAreaWidth / entries.length) + barWidth / 2 + 8;
const label = this.add
.text(x, 605, `${OPTION_LETTERS[optIndex]}: ${Math.round(pct * 100)}%`, {
fontFamily: "sans-serif",
fontSize: "18px",
color: "#eef0fb",
})
.setOrigin(0.5);
this.overlayObjects.push(label);
const bar = this.add
.rectangle(x, 635, Math.max(6, barWidth * pct), 10, 0x818cf8)
.setOrigin(0.5);
this.overlayObjects.push(bar);
});
}
private showPhoneOverlay(suggestedIndex: number) {
this.clearOverlay();
const bg = this.add.rectangle(480, 615, 780, 70, COLORS.overlayBg).setStrokeStyle(1, 0x292d4a);
this.overlayObjects.push(bg);
const text = this.add
.text(480, 615, `Друг на телефоне: «Я почти уверен, это ${OPTION_LETTERS[suggestedIndex]}»`, {
fontFamily: "sans-serif",
fontSize: "19px",
color: "#eef0fb",
align: "center",
wordWrap: { width: 720 },
})
.setOrigin(0.5);
this.overlayObjects.push(text);
}
private refreshLifelineButtons() {
this.lifelineButtons.forEach((bg, kind) => {
const used = this.usedLifelines.has(kind);
bg.setFillStyle(used ? COLORS.lifelineUsed : COLORS.lifeline);
bg.disableInteractive();
if (!used) bg.setInteractive({ useHandCursor: true });
});
}
private reportProgress() {
this.onProgress?.({
level: this.level,
bankedScore: this.bankedScore,
usedLifelines: Array.from(this.usedLifelines),
});
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.locked = true;
this.onFinish?.({ won, score, message });
}
}
+236
View File
@@ -0,0 +1,236 @@
import Phaser from "phaser";
import { stepMsForLength, FOOD_SCORE } from "../snake";
import type { SnakeDefinition, SnakeProgress, GameResult } from "../types";
const COLORS = {
bg: 0x0a0b14,
border: 0x292d4a,
head: 0x34d399,
body: 0x10b981,
food: 0xef4444,
};
type Direction = "up" | "down" | "left" | "right";
type Cell = { row: number; col: number };
const DIR_DELTA: Record<Direction, { dr: number; dc: number }> = {
up: { dr: -1, dc: 0 },
down: { dr: 1, dc: 0 },
left: { dr: 0, dc: -1 },
right: { dr: 0, dc: 1 },
};
const OPPOSITE: Record<Direction, Direction> = { up: "down", down: "up", left: "right", right: "left" };
export class SnakeScene extends Phaser.Scene {
private definition!: SnakeDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: SnakeProgress) => void;
private width = 20;
private height = 15;
private cellSize = 20;
private offsetX = 0;
private offsetY = 0;
private body: Cell[] = [];
private direction: Direction = "right";
private desiredDirection: Direction = "right";
private food: Cell = { row: 0, col: 0 };
private score = 0;
private finished = false;
private bodyRects: Phaser.GameObjects.Rectangle[] = [];
private foodShape!: Phaser.GameObjects.Arc;
private scoreText!: Phaser.GameObjects.Text;
private lengthText!: Phaser.GameObjects.Text;
constructor() {
super("SnakeScene");
}
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
this.finished = false;
this.add
.graphics()
.fillGradientStyle(0x14162a, 0x14162a, COLORS.bg, COLORS.bg, 1)
.fillRect(0, 0, 960, 720)
.setDepth(-10);
this.width = this.definition.width;
this.height = this.definition.height;
this.cellSize = Math.max(8, Math.min(36, Math.floor(920 / this.width), Math.floor(580 / this.height)));
this.offsetX = 480 - (this.width * this.cellSize) / 2;
this.offsetY = 110;
this.add
.rectangle(
480,
this.offsetY + (this.height * this.cellSize) / 2,
this.width * this.cellSize + 4,
this.height * this.cellSize + 4,
)
.setStrokeStyle(2, COLORS.border);
const initialState = this.registry.get("initialState") as SnakeProgress | undefined;
if (initialState && this.isValidState(initialState)) {
this.body = initialState.body.map((c) => ({ ...c }));
this.direction = initialState.direction;
this.food = { ...initialState.food };
this.score = initialState.score;
} else {
const startRow = Math.floor(this.height / 2);
const startCol = Math.floor(this.width / 2);
this.body = Array.from({ length: this.definition.startLength }, (_, i) => ({
row: startRow,
col: startCol - i,
}));
this.direction = "right";
this.score = 0;
this.food = this.randomEmptyCell();
}
this.desiredDirection = this.direction;
this.bodyRects = [];
this.redrawBody();
this.foodShape = this.add.circle(0, 0, this.cellSize * 0.4, COLORS.food);
this.updateFoodPosition();
this.scoreText = this.add.text(20, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" });
this.lengthText = this.add
.text(940, 15, "", { fontFamily: "sans-serif", fontSize: "20px", color: "#eef0fb" })
.setOrigin(1, 0);
this.input.keyboard!.on("keydown-LEFT", () => this.setDesiredDirection("left"));
this.input.keyboard!.on("keydown-RIGHT", () => this.setDesiredDirection("right"));
this.input.keyboard!.on("keydown-UP", () => this.setDesiredDirection("up"));
this.input.keyboard!.on("keydown-DOWN", () => this.setDesiredDirection("down"));
this.registry.events.on("touchDirection", (dir: Direction) => this.setDesiredDirection(dir));
this.updateHud();
this.reportProgress();
this.scheduleNextStep();
}
private setDesiredDirection(dir: Direction) {
if (OPPOSITE[dir] === this.direction && this.body.length > 1) return;
this.desiredDirection = dir;
}
private scheduleNextStep() {
if (this.finished) return;
const stepMs = stepMsForLength(this.body.length, this.definition.startLength);
this.time.delayedCall(stepMs, () => {
this.step();
this.scheduleNextStep();
});
}
private step() {
if (this.finished) return;
this.direction = this.desiredDirection;
const delta = DIR_DELTA[this.direction];
const head = this.body[0];
let nr = head.row + delta.dr;
let nc = head.col + delta.dc;
if (this.definition.wrapAround) {
nr = (nr + this.height) % this.height;
nc = (nc + this.width) % this.width;
} else if (nr < 0 || nr >= this.height || nc < 0 || nc >= this.width) {
this.finish(false, this.score, `Врезались в стену. Очков: ${this.score}`);
return;
}
const ateFood = nr === this.food.row && nc === this.food.col;
const bodyToCheck = ateFood ? this.body : this.body.slice(0, -1);
if (bodyToCheck.some((seg) => seg.row === nr && seg.col === nc)) {
this.finish(false, this.score, `Врезались в себя. Очков: ${this.score}`);
return;
}
this.body.unshift({ row: nr, col: nc });
if (ateFood) {
this.score += FOOD_SCORE;
if (this.body.length >= this.definition.targetLength) {
this.redrawBody();
this.finish(true, this.score, `Змейка выросла до ${this.body.length}! Очков: ${this.score}`);
return;
}
this.food = this.randomEmptyCell();
this.updateFoodPosition();
} else {
this.body.pop();
}
this.redrawBody();
this.updateHud();
this.reportProgress();
}
private isValidState(s: SnakeProgress): boolean {
const inBounds = (c: Cell) => c.row >= 0 && c.row < this.height && c.col >= 0 && c.col < this.width;
return s.body.every(inBounds) && inBounds(s.food);
}
private randomEmptyCell(): Cell {
const occupied = new Set(this.body.map((c) => `${c.row},${c.col}`));
const free: Cell[] = [];
for (let r = 0; r < this.height; r++) {
for (let c = 0; c < this.width; c++) {
if (!occupied.has(`${r},${c}`)) free.push({ row: r, col: c });
}
}
return free.length > 0 ? free[Math.floor(Math.random() * free.length)] : this.body[0];
}
private cellToPixel(cell: Cell): { x: number; y: number } {
return {
x: this.offsetX + cell.col * this.cellSize + this.cellSize / 2,
y: this.offsetY + cell.row * this.cellSize + this.cellSize / 2,
};
}
private redrawBody() {
while (this.bodyRects.length < this.body.length) {
this.bodyRects.push(this.add.rectangle(0, 0, this.cellSize - 2, this.cellSize - 2, COLORS.body));
}
while (this.bodyRects.length > this.body.length) {
this.bodyRects.pop()?.destroy();
}
this.body.forEach((seg, i) => {
const { x, y } = this.cellToPixel(seg);
this.bodyRects[i].setPosition(x, y);
this.bodyRects[i].setFillStyle(i === 0 ? COLORS.head : COLORS.body);
});
}
private updateFoodPosition() {
const { x, y } = this.cellToPixel(this.food);
this.foodShape.setPosition(x, y);
}
private updateHud() {
this.scoreText.setText(`Очки: ${this.score}`);
this.lengthText.setText(`Длина: ${this.body.length}/${this.definition.targetLength}`);
}
private reportProgress() {
this.onProgress?.({
body: this.body.map((c) => ({ ...c })),
direction: this.direction,
food: { ...this.food },
score: this.score,
});
}
private finish(won: boolean, score: number, message: string) {
this.finished = true;
this.onFinish?.({ won, score, message });
}
}
+122
View File
@@ -0,0 +1,122 @@
import { eq, and, desc, sql } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { games } from "@/lib/db/schema";
import { parseDefinition, type TemplateType, type GameDefinition } from "./types";
export interface GameSummaryDTO {
id: string;
ownerId: string;
title: string;
description: string | null;
templateType: TemplateType;
status: "draft" | "published";
playCount: number;
createdAt: number;
updatedAt: number;
}
export interface GameWithDefinitionDTO extends GameSummaryDTO {
definition: GameDefinition;
}
function toSummaryDTO(row: typeof games.$inferSelect): GameSummaryDTO {
return {
id: row.id,
ownerId: row.ownerId,
title: row.title,
description: row.description,
templateType: row.templateType,
status: row.status,
playCount: row.playCount,
createdAt: row.createdAt.getTime(),
updatedAt: row.updatedAt.getTime(),
};
}
function toWithDefinitionDTO(row: typeof games.$inferSelect): GameWithDefinitionDTO {
return {
...toSummaryDTO(row),
definition: parseDefinition(row.templateType, JSON.parse(row.definition)),
};
}
export async function listPublishedGames(): Promise<GameSummaryDTO[]> {
const rows = await db.query.games.findMany({
where: eq(games.status, "published"),
orderBy: desc(games.updatedAt),
});
return rows.map(toSummaryDTO);
}
export async function listGamesByOwner(ownerId: string): Promise<GameSummaryDTO[]> {
const rows = await db.query.games.findMany({
where: eq(games.ownerId, ownerId),
orderBy: desc(games.updatedAt),
});
return rows.map(toSummaryDTO);
}
export async function getGameById(id: string): Promise<GameWithDefinitionDTO | null> {
const row = await db.query.games.findFirst({ where: eq(games.id, id) });
return row ? toWithDefinitionDTO(row) : null;
}
export async function incrementPlayCount(id: string): Promise<void> {
await db
.update(games)
.set({ playCount: sql`${games.playCount} + 1` })
.where(eq(games.id, id));
}
export async function createGame(params: {
ownerId: string;
title: string;
description?: string;
templateType: TemplateType;
definition: GameDefinition;
}): Promise<GameSummaryDTO> {
const [row] = await db
.insert(games)
.values({
ownerId: params.ownerId,
title: params.title,
description: params.description ?? null,
templateType: params.templateType,
definition: JSON.stringify(params.definition),
status: "draft",
})
.returning();
return toSummaryDTO(row);
}
export async function updateGame(
id: string,
ownerId: string,
patch: Partial<{
title: string;
description: string | null;
definition: GameDefinition;
status: "draft" | "published";
}>,
): Promise<GameSummaryDTO | null> {
const values: Partial<typeof games.$inferInsert> = { updatedAt: new Date() };
if (patch.title !== undefined) values.title = patch.title;
if (patch.description !== undefined) values.description = patch.description;
if (patch.definition !== undefined) values.definition = JSON.stringify(patch.definition);
if (patch.status !== undefined) values.status = patch.status;
const [row] = await db
.update(games)
.set(values)
.where(and(eq(games.id, id), eq(games.ownerId, ownerId)))
.returning();
return row ? toSummaryDTO(row) : null;
}
export async function deleteGame(id: string, ownerId: string): Promise<boolean> {
const rows = await db
.delete(games)
.where(and(eq(games.id, id), eq(games.ownerId, ownerId)))
.returning({ id: games.id });
return rows.length > 0;
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
// Fixed speed curve — not hand-authored (same "curve is fixed" precedent as
// the quiz money ladder, clicker HP curve, and maze movement timing).
export const BASE_STEP_MS = 160;
export const MIN_STEP_MS = 70;
export const STEP_MS_DECREASE_PER_FOOD = 4;
export const FOOD_SCORE = 10;
export function stepMsForLength(length: number, startLength: number): number {
const eaten = Math.max(0, length - startLength);
return Math.max(MIN_STEP_MS, BASE_STEP_MS - eaten * STEP_MS_DECREASE_PER_FOOD);
}
export const snakeDefinitionSchema = z
.object({
width: z.number().int().min(8).max(40).default(20),
height: z.number().int().min(8).max(40).default(15),
startLength: z.number().int().min(1).max(10).default(3),
targetLength: z.number().int().min(5).max(200).default(20),
wrapAround: z.boolean().default(false),
})
.refine((def) => def.targetLength <= def.width * def.height, {
message: "targetLength must fit within the arena (width × height)",
path: ["targetLength"],
});
export type SnakeDefinition = z.infer<typeof snakeDefinitionSchema>;
export const DEFAULT_SNAKE_DEFINITION: SnakeDefinition = {
width: 20,
height: 15,
startLength: 3,
targetLength: 20,
wrapAround: false,
};
const cellSchema = z.object({ row: z.number().int().min(0), col: z.number().int().min(0) });
export const snakeProgressSchema = z.object({
body: z.array(cellSchema).min(1),
direction: z.enum(["up", "down", "left", "right"]),
food: cellSchema,
score: z.number().int().min(0),
});
export type SnakeProgress = z.infer<typeof snakeProgressSchema>;
+74
View File
@@ -0,0 +1,74 @@
import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
import { mazeDefinitionSchema, mazeProgressSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
import { snakeDefinitionSchema, snakeProgressSchema, DEFAULT_SNAKE_DEFINITION } from "./snake";
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze", "snake"] as const;
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
export const TEMPLATE_LABELS: Record<TemplateType, string> = {
quiz: "Кто хочет стать миллионером?",
clicker: "Кликер",
maze: "Лабиринт",
snake: "Змейка",
};
export const TEMPLATE_DESCRIPTIONS: Record<TemplateType, string> = {
quiz: "15 вопросов по нарастающей, денежная лестница, несгораемые суммы и три подсказки.",
clicker: "Бей монстров, качай героев, проходи уровни и боссов — RPG-кликер.",
maze: "Собери предметы и дойди до выхода по лабиринту.",
snake: "Классическая змейка — расти, не врезайся в стены и в себя.",
};
export const templateSchemas = {
quiz: quizDefinitionSchema,
clicker: clickerDefinitionSchema,
maze: mazeDefinitionSchema,
snake: snakeDefinitionSchema,
};
export const TEMPLATE_DEFAULTS = {
quiz: DEFAULT_QUIZ_DEFINITION,
clicker: DEFAULT_CLICKER_DEFINITION,
maze: DEFAULT_MAZE_DEFINITION,
snake: DEFAULT_SNAKE_DEFINITION,
};
export function parseDefinition(templateType: TemplateType, data: unknown) {
return templateSchemas[templateType].parse(data);
}
export function safeParseDefinition(templateType: TemplateType, data: unknown) {
return templateSchemas[templateType].safeParse(data);
}
export const progressSchemas = {
quiz: quizProgressSchema,
clicker: clickerProgressSchema,
maze: mazeProgressSchema,
snake: snakeProgressSchema,
};
export function safeParseProgress(templateType: TemplateType, data: unknown) {
return progressSchemas[templateType].safeParse(data);
}
/** What a template Scene reports back through the registry callback when the game ends. */
export interface GameResult {
won: boolean;
score: number;
message?: string;
}
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
export type { ClickerDefinition, ClickerHero, ClickerProgress } from "./clicker";
export type { MazeDefinition, MazeProgress } from "./maze";
export type { SnakeDefinition, SnakeProgress } from "./snake";
import type { QuizDefinition, QuizProgress } from "./quiz";
import type { ClickerDefinition, ClickerProgress } from "./clicker";
import type { MazeDefinition, MazeProgress } from "./maze";
import type { SnakeDefinition, SnakeProgress } from "./snake";
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition | SnakeDefinition;
export type GameProgress = QuizProgress | ClickerProgress | MazeProgress | SnakeProgress;
+23
View File
@@ -0,0 +1,23 @@
export interface CoinPurchaseRequest {
purchaseId: string;
userId: string;
coins: number;
priceRub: number;
}
export interface PaymentProvider {
name: string;
createPayment(request: CoinPurchaseRequest): Promise<{ redirectUrl: string }>;
}
/**
* No payment provider has been chosen yet (deliberately — see the plan for
* this feature). Wiring one up later is: implement PaymentProvider in a new
* adapter file, return it from here (probably gated by a config row/env var
* mirroring src/lib/ai/config.ts's shape), and add a webhook route that
* marks the matching coinPurchases row "completed" and calls
* grantCoins(userId, coins, "purchase").
*/
export function getPaymentProvider(): PaymentProvider | null {
return null;
}
+91
View File
@@ -0,0 +1,91 @@
import { eq, and } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { prestige, prestigeClaims } from "@/lib/db/schema";
import { prestigeUpgradeCostAtLevel } from "@/lib/games/clicker";
export type PrestigeUpgradeKind = "click" | "gold";
export interface PrestigeDTO {
userId: string;
crystals: number;
clickBonusLevel: number;
goldBonusLevel: number;
}
export async function getOrCreatePrestige(userId: string): Promise<PrestigeDTO> {
const existing = db.select().from(prestige).where(eq(prestige.userId, userId)).get();
if (existing) return existing;
db.insert(prestige).values({ userId }).onConflictDoNothing().run();
const created = db.select().from(prestige).where(eq(prestige.userId, userId)).get();
return created!;
}
export async function getPrestige(userId: string): Promise<PrestigeDTO> {
return getOrCreatePrestige(userId);
}
/**
* Credits the one-time completion reward for a (user, game) pair,
* atomically. Same "check-then-act inside one transaction" shape as
* spendCoins in src/lib/wallet/service.ts — checking for an existing claim
* and inserting the new one happen inside the same synchronous
* db.transaction() callback, so two concurrent claim attempts for the same
* game can't both succeed. Returns claimed:false (no-op) if this game was
* already claimed by this user.
*/
export async function claimCompletionReward(
userId: string,
gameId: string,
crystalsAwarded: number,
): Promise<{ claimed: boolean; crystalsAwarded: number; crystals: number }> {
await getOrCreatePrestige(userId);
return db.transaction((tx) => {
const existingClaim = tx
.select()
.from(prestigeClaims)
.where(and(eq(prestigeClaims.userId, userId), eq(prestigeClaims.gameId, gameId)))
.get();
const current = tx.select().from(prestige).where(eq(prestige.userId, userId)).get();
const currentCrystals = current?.crystals ?? 0;
if (existingClaim) {
return { claimed: false, crystalsAwarded: 0, crystals: currentCrystals };
}
tx.insert(prestigeClaims).values({ userId, gameId, crystalsAwarded }).run();
tx.update(prestige)
.set({ crystals: currentCrystals + crystalsAwarded, updatedAt: new Date() })
.where(eq(prestige.userId, userId))
.run();
return { claimed: true, crystalsAwarded, crystals: currentCrystals + crystalsAwarded };
});
}
/**
* Buys the next level of a permanent prestige upgrade, atomically —
* returns false without writing anything if crystals are insufficient.
*/
export async function buyPrestigeUpgrade(userId: string, kind: PrestigeUpgradeKind): Promise<boolean> {
await getOrCreatePrestige(userId);
return db.transaction((tx) => {
const current = tx.select().from(prestige).where(eq(prestige.userId, userId)).get();
if (!current) return false;
const currentLevel = kind === "click" ? current.clickBonusLevel : current.goldBonusLevel;
const cost = prestigeUpgradeCostAtLevel(currentLevel);
if (current.crystals < cost) return false;
const patch =
kind === "click"
? { crystals: current.crystals - cost, clickBonusLevel: currentLevel + 1, updatedAt: new Date() }
: { crystals: current.crystals - cost, goldBonusLevel: currentLevel + 1, updatedAt: new Date() };
tx.update(prestige).set(patch).where(eq(prestige.userId, userId)).run();
return true;
});
}
+15
View File
@@ -0,0 +1,15 @@
// Coin economics — one place to tune. 1 RUB = 1 coin, no bonus tiers for now.
export const SIGNUP_BONUS_COINS = 20;
export const MANUAL_SAVE_COST = 10;
export interface CoinPackage {
id: string;
coins: number;
priceRub: number;
}
export const COIN_PACKAGES: CoinPackage[] = [
{ id: "small", coins: 50, priceRub: 50 },
{ id: "medium", coins: 200, priceRub: 200 },
{ id: "large", coins: 500, priceRub: 500 },
];
+105
View File
@@ -0,0 +1,105 @@
import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { wallets, coinTransactions } from "@/lib/db/schema";
export type CoinReason = "signup_bonus" | "manual_save" | "purchase";
export interface WalletDTO {
userId: string;
balance: number;
}
export interface CoinTransactionDTO {
id: string;
amount: number;
reason: CoinReason;
relatedGameId: string | null;
createdAt: number;
}
export async function getOrCreateWallet(userId: string): Promise<WalletDTO> {
const existing = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
if (existing) return existing;
db.insert(wallets).values({ userId, balance: 0 }).onConflictDoNothing().run();
const created = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
return created!;
}
export async function getBalance(userId: string): Promise<number> {
const wallet = await getOrCreateWallet(userId);
return wallet.balance;
}
export async function listRecentTransactions(userId: string, limit = 20): Promise<CoinTransactionDTO[]> {
const rows = await db.query.coinTransactions.findMany({
where: eq(coinTransactions.userId, userId),
orderBy: desc(coinTransactions.createdAt),
limit,
});
return rows.map((row) => ({
id: row.id,
amount: row.amount,
reason: row.reason,
relatedGameId: row.relatedGameId,
createdAt: row.createdAt.getTime(),
}));
}
/**
* Credits coins and logs the ledger entry atomically. The better-sqlite3
* driver's db.transaction() callback must stay fully synchronous — no
* await inside — or the transaction commits before the awaited work
* finishes; every query here uses the sync .get()/.run() methods.
*/
export async function grantCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<void> {
await getOrCreateWallet(userId);
db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
tx.update(wallets)
.set({ balance: balance + amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount, reason, relatedGameId: relatedGameId ?? null })
.run();
});
}
/**
* Debits coins and logs the ledger entry atomically — returns false without
* writing anything if the balance is insufficient. This is the one place
* correctness really matters: the balance check and the debit happen inside
* the same transaction, so two concurrent spends can't both pass the check
* against a stale balance.
*/
export async function spendCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<boolean> {
await getOrCreateWallet(userId);
return db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
if (balance < amount) return false;
tx.update(wallets)
.set({ balance: balance - amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount: -amount, reason, relatedGameId: relatedGameId ?? null })
.run();
return true;
});
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}