Build MVP: launcher, constructor, and AI game generation

A self-hosted, Roblox-flavored game platform: browse/play published
browser games, build them by hand or by describing them to an LLM.
Fully independent from top-tickets — separate repo, DB, port (8082).

- Auth: email+password accounts (argon2, httpOnly session cookies),
  no admin/agent split — every account can create and play.
- Three game templates (quiz, clicker, maze), each a zod schema paired
  with a Phaser 4 Scene that renders purely from a validated instance
  of that schema — manual and AI-generated games hit the same
  renderer, so a saved game is always guaranteed to load and run. The
  maze schema BFS-validates that the exit is reachable from the start.
- Manual constructor: per-template builder forms, draft/publish/edit/
  delete, a launcher grid of published games, a full-bleed play page.
- AI generation: Anthropic API key configured under Settings → AI
  (encrypted at rest, validated with a live call before saving, same
  shape as top-tickets' mailbox/LDAP settings). Generation uses the
  SDK's zodOutputFormat/messages.parse to force schema-shaped JSON
  output, with one retry feeding back the validation error. Output
  always prefills the manual builder for review — never auto-published
  blind. Clean, distinct error message when no key is configured yet
  (no silent fallback exists here, so it doesn't pretend one does).
- Docker Compose on port 8082 (bakes in the busy_timeout=30000 SQLite
  pragma from day one — top-tickets hit this build-time race twice).

Deferred: more templates/genres, drag-and-drop level editor, likes/
leaderboards, AI-generated sprite assets, multiplayer, moderation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-31 17:40:30 +00:00
1 parent df16c90832
commit c9a6c27a7a
60 files changed
+5601 -93

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION, 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 [timePerQuestionSec, setTimePerQuestionSec] = useState(initialDefinition?.timePerQuestionSec ?? 0);
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, { question: "", options: ["", ""], correctIndex: 0 }]);
}
function removeQuestion(index: number) {
setQuestions((qs) => qs.filter((_, i) => i !== index));
}
function addOption(qIndex: number) {
setQuestions((qs) => qs.map((q, i) => (i === qIndex && q.options.length < 6 ? { ...q, options: [...q.options, ""] } : q)));
}
function removeOption(qIndex: number, optIndex: number) {
setQuestions((qs) =>
qs.map((q, i) => {
if (i !== qIndex || q.options.length <= 2) return q;
const options = q.options.filter((_, j) => j !== optIndex);
const correctIndex = q.correctIndex >= options.length ? 0 : q.correctIndex;
return { ...q, options, correctIndex };
}),
);
}
async function handleSave() {
setError(null);
const definition: QuizDefinition = { questions, timePerQuestionSec };
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>
<label className="w-48 text-sm">
<span className="mb-1 block font-medium text-text-muted">Время на вопрос, сек (0 — без таймера)</span>
<input
type="number"
min={0}
max={120}
value={timePerQuestionSec}
onChange={(e) => setTimePerQuestionSec(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>
{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>
<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"
/>
{q.options.length > 2 && (
<button onClick={() => removeOption(qIndex, optIndex)} className="text-text-faint hover:text-danger">
<Trash2 size={13} />
</button>
)}
</div>
))}
{q.options.length < 6 && (
<button onClick={() => addOption(qIndex)} className="btn btn-ghost self-start text-xs">
<Plus size={12} />
Вариант
</button>
)}
</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>
);
}