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

+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Play, Pencil, Trash2, Eye, EyeOff } from "lucide-react";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import type { GameSummaryDTO } from "@/lib/games/service";
export function MyGamesList({ initialGames }: { initialGames: GameSummaryDTO[] }) {
const [games, setGames] = useState(initialGames);
const [error, setError] = useState<string | null>(null);
async function togglePublish(game: GameSummaryDTO) {
setError(null);
const nextStatus = game.status === "published" ? "draft" : "published";
const res = await fetch(`/api/games/${game.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: nextStatus }),
});
if (!res.ok) {
setError("Не удалось изменить статус публикации");
return;
}
setGames((gs) => gs.map((g) => (g.id === game.id ? { ...g, status: nextStatus } : g)));
}
async function handleDelete(id: string) {
setError(null);
const res = await fetch(`/api/games/${id}`, { method: "DELETE" });
if (!res.ok) {
setError("Не удалось удалить игру");
return;
}
setGames((gs) => gs.filter((g) => g.id !== id));
}
if (games.length === 0) {
return <p className="text-sm text-text-muted">Вы ещё не создали ни одной игры.</p>;
}
return (
<div className="flex flex-col gap-3">
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
{games.map((game) => (
<div key={game.id} className="card flex flex-wrap items-center gap-3 p-4">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="badge">{TEMPLATE_LABELS[game.templateType]}</span>
<span
className={`badge ${
game.status === "published" ? "bg-success-soft text-success-soft-text" : "bg-surface-hover text-text-muted"
}`}
>
{game.status === "published" ? "Опубликовано" : "Черновик"}
</span>
</div>
<p className="truncate font-medium">{game.title}</p>
<p className="text-xs text-text-faint">{game.playCount} игр сыграно</p>
</div>
<Link href={`/play/${game.id}`} className="btn btn-ghost" title="Играть">
<Play size={14} />
</Link>
<Link href={`/my-games/${game.id}/edit`} className="btn btn-ghost" title="Редактировать">
<Pencil size={14} />
</Link>
<button
onClick={() => togglePublish(game)}
className="btn btn-ghost"
title={game.status === "published" ? "Снять с публикации" : "Опубликовать"}
>
{game.status === "published" ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => handleDelete(game.id)} className="btn btn-ghost text-danger" title="Удалить">
<Trash2 size={14} />
</button>
</div>
))}
</div>
);
}