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:
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
*.env
|
||||
!.env.example
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
@@ -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
@@ -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
|
||||
@@ -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 -->
|
||||
+22
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+8356
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">Уровней до победы (5–200)</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 уровней). У каждого вопроса — рубрика и уровень
|
||||
сложности 1–5; в каждой из 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">Сложность (1–5)</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}. Исправь и верни заново.`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)],
|
||||
);
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user