Fix mobile header layout, add resumable progress + coin wallet
Header nav was splitting into three lines on mobile (logo / nav / logout floating right with a big gap) — applied the same order-last wrap pattern already proven in top-tickets/project-claude, so nav drops to its own scrollable row instead. Bigger feature: player progress now survives closing the tab, for all three templates, not just Clicker. Each Phaser scene reports its state through the same registry-callback pattern already used for onFinish (onProgress), and reads an initialState to resume instead of always starting fresh. Autosave fires every 10 minutes for free; an instant manual save costs coins. New coin economy (src/lib/wallet/): a wallet + transaction ledger per user, atomic grant/spend via db.transaction() so two concurrent spends against a low balance can't both succeed (verified with a real race test — exactly one wins, balance never goes negative). New accounts get a 20-coin signup bonus so manual save is testable before any real payment method exists. Real-money purchases are scaffolded but deliberately not wired to a provider yet — user hasn't picked one. /api/wallet/purchase creates a pending order and returns a clean "not configured" error, same shape as the AI/LDAP not-configured responses elsewhere in this project. The payment abstraction (src/lib/payments/provider.ts) is the one seam a real adapter needs to fill in later. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
3d5497fae9
commit
e682c7df13
25 files changed
+1375
-49
No files matched your search
@@ -6,6 +6,8 @@ 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),
|
||||
@@ -33,6 +35,8 @@ export async function POST(request: Request) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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 { getCurrentSession } from "@/lib/auth/session";
|
||||
import { TEMPLATE_LABELS } from "@/lib/games/types";
|
||||
import { PlaySession } from "@/components/play-session";
|
||||
@@ -15,13 +17,18 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
|
||||
const game = await getGameById(gameId);
|
||||
if (!game) notFound();
|
||||
|
||||
if (game.status === "draft") {
|
||||
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];
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
|
||||
@@ -33,7 +40,14 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
|
||||
{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 templateType={game.templateType} definition={game.definition} />
|
||||
<PlaySession
|
||||
gameId={game.id}
|
||||
templateType={game.templateType}
|
||||
definition={game.definition}
|
||||
initialState={initialState ?? undefined}
|
||||
initialBalance={initialBalance}
|
||||
canSave={Boolean(session)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,20 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type Phaser from "phaser";
|
||||
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
|
||||
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
||||
|
||||
export function GameCanvas({
|
||||
templateType,
|
||||
definition,
|
||||
initialState,
|
||||
onFinish,
|
||||
onProgress,
|
||||
}: {
|
||||
templateType: TemplateType;
|
||||
definition: GameDefinition;
|
||||
initialState?: GameProgress;
|
||||
onFinish?: (result: GameResult) => void;
|
||||
onProgress?: (state: GameProgress) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -42,7 +46,9 @@ export function GameCanvas({
|
||||
});
|
||||
|
||||
game.registry.set("definition", definition);
|
||||
game.registry.set("initialState", initialState);
|
||||
game.registry.set("onFinish", onFinish);
|
||||
game.registry.set("onProgress", onProgress);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
|
||||
+35
-20
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Gamepad2 } from "lucide-react";
|
||||
import { Gamepad2, Coins } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { getBalance } from "@/lib/wallet/service";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
|
||||
export async function Header() {
|
||||
const session = await getCurrentSession();
|
||||
const balance = session ? await getBalance(session.user.id) : null;
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-surface">
|
||||
@@ -16,28 +18,17 @@ export async function Header() {
|
||||
tis
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 text-sm font-medium text-text-muted">
|
||||
<Link href="/" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
|
||||
Лаунчер
|
||||
</Link>
|
||||
{session && (
|
||||
<>
|
||||
<Link href="/create" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
|
||||
Создать
|
||||
</Link>
|
||||
<Link href="/my-games" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
|
||||
Мои игры
|
||||
</Link>
|
||||
<Link href="/settings/ai" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
|
||||
Настройки ИИ
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
@@ -52,6 +43,30 @@ export async function Header() {
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { RotateCcw, Trophy, XCircle } from "lucide-react";
|
||||
import { RotateCcw, Trophy, XCircle, Save, Clock } from "lucide-react";
|
||||
import { GameCanvas } from "./game-canvas";
|
||||
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
|
||||
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
|
||||
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
||||
|
||||
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
export function PlaySession({
|
||||
gameId,
|
||||
templateType,
|
||||
definition,
|
||||
initialState,
|
||||
initialBalance,
|
||||
canSave,
|
||||
}: {
|
||||
gameId: string;
|
||||
templateType: TemplateType;
|
||||
definition: GameDefinition;
|
||||
initialState?: GameProgress;
|
||||
initialBalance: number;
|
||||
canSave: boolean;
|
||||
}) {
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [result, setResult] = useState<GameResult | 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 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]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
|
||||
<div className="relative">
|
||||
<GameCanvas key={attempt} templateType={templateType} definition={definition} onFinish={setResult} />
|
||||
<GameCanvas
|
||||
key={attempt}
|
||||
templateType={templateType}
|
||||
definition={definition}
|
||||
initialState={initialState}
|
||||
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">
|
||||
@@ -50,6 +118,38 @@ export function PlaySession({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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,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,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,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": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1785518228143,
|
||||
"tag": "0000_marvelous_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1785534147942,
|
||||
"tag": "0001_fearless_proudstar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+54
-1
@@ -1,5 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const id = () =>
|
||||
@@ -61,3 +61,56 @@ export const aiConfig = sqliteTable("ai_config", {
|
||||
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" }),
|
||||
});
|
||||
@@ -26,3 +26,12 @@ export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
|
||||
{ name: "Автокликер", cost: 50, pointsPerClickBonus: 0, pointsPerSecondBonus: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
export const clickerProgressSchema = z.object({
|
||||
score: z.number().min(0),
|
||||
pointsPerClick: z.number().min(0),
|
||||
pointsPerSecond: z.number().min(0),
|
||||
ownedUpgradeIndices: z.array(z.number().int().min(0)).default([]),
|
||||
});
|
||||
|
||||
export type ClickerProgress = z.infer<typeof clickerProgressSchema>;
|
||||
@@ -71,3 +71,12 @@ export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
|
||||
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
|
||||
timeLimitSec: 0,
|
||||
};
|
||||
|
||||
export const mazeProgressSchema = z.object({
|
||||
playerRow: z.number().int().min(0),
|
||||
playerCol: z.number().int().min(0),
|
||||
collectedItemKeys: z.array(z.string()).default([]),
|
||||
remainingMs: 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;
|
||||
}
|
||||
@@ -24,3 +24,10 @@ export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = {
|
||||
questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }],
|
||||
timePerQuestionSec: 0,
|
||||
};
|
||||
|
||||
export const quizProgressSchema = z.object({
|
||||
questionIndex: z.number().int().min(0),
|
||||
score: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export type QuizProgress = z.infer<typeof quizProgressSchema>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from "phaser";
|
||||
import type { ClickerDefinition, ClickerUpgrade, GameResult } from "../types";
|
||||
import type { ClickerDefinition, ClickerUpgrade, ClickerProgress, GameResult } from "../types";
|
||||
|
||||
const COLORS = {
|
||||
bg: 0x14162a,
|
||||
@@ -19,6 +19,7 @@ interface UpgradeRow {
|
||||
export class ClickerScene extends Phaser.Scene {
|
||||
private definition!: ClickerDefinition;
|
||||
private onFinish?: (result: GameResult) => void;
|
||||
private onProgress?: (state: ClickerProgress) => void;
|
||||
private score = 0;
|
||||
private pointsPerClick = 1;
|
||||
private pointsPerSecond = 0;
|
||||
@@ -34,10 +35,16 @@ export class ClickerScene extends Phaser.Scene {
|
||||
create() {
|
||||
this.definition = this.registry.get("definition");
|
||||
this.onFinish = this.registry.get("onFinish");
|
||||
this.score = 0;
|
||||
this.pointsPerClick = this.definition.startingPointsPerClick;
|
||||
this.pointsPerSecond = 0;
|
||||
this.ownedUpgrades = new Set();
|
||||
this.onProgress = this.registry.get("onProgress");
|
||||
|
||||
const initialState = this.registry.get("initialState") as ClickerProgress | undefined;
|
||||
const validUpgradeIndices = (initialState?.ownedUpgradeIndices ?? []).filter(
|
||||
(i) => i >= 0 && i < this.definition.upgrades.length,
|
||||
);
|
||||
this.score = initialState?.score ?? 0;
|
||||
this.pointsPerClick = initialState?.pointsPerClick ?? this.definition.startingPointsPerClick;
|
||||
this.pointsPerSecond = initialState?.pointsPerSecond ?? 0;
|
||||
this.ownedUpgrades = new Set(validUpgradeIndices);
|
||||
this.upgradeRows = [];
|
||||
this.finished = false;
|
||||
|
||||
@@ -97,6 +104,13 @@ export class ClickerScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
this.onProgress?.({
|
||||
score: this.score,
|
||||
pointsPerClick: this.pointsPerClick,
|
||||
pointsPerSecond: this.pointsPerSecond,
|
||||
ownedUpgradeIndices: Array.from(this.ownedUpgrades),
|
||||
});
|
||||
|
||||
this.scoreText.setText(
|
||||
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from "phaser";
|
||||
import type { MazeDefinition, GameResult } from "../types";
|
||||
import type { MazeDefinition, MazeProgress, GameResult } from "../types";
|
||||
|
||||
const COLORS = {
|
||||
bg: 0x0a0b14,
|
||||
@@ -13,6 +13,7 @@ const COLORS = {
|
||||
export class MazeScene extends Phaser.Scene {
|
||||
private definition!: MazeDefinition;
|
||||
private onFinish?: (result: GameResult) => void;
|
||||
private onProgress?: (state: MazeProgress) => void;
|
||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||
private playerPos = { row: 0, col: 0 };
|
||||
private items = new Set<string>();
|
||||
@@ -22,6 +23,7 @@ export class MazeScene extends Phaser.Scene {
|
||||
private offsetX = 0;
|
||||
private offsetY = 0;
|
||||
private totalItems = 0;
|
||||
private collectedItemKeys: string[] = [];
|
||||
private scoreText!: Phaser.GameObjects.Text;
|
||||
private timerText?: Phaser.GameObjects.Text;
|
||||
private remainingMs = 0;
|
||||
@@ -34,9 +36,14 @@ export class MazeScene extends Phaser.Scene {
|
||||
create() {
|
||||
this.definition = this.registry.get("definition");
|
||||
this.onFinish = this.registry.get("onFinish");
|
||||
this.onProgress = this.registry.get("onProgress");
|
||||
this.finished = false;
|
||||
this.cameras.main.setBackgroundColor(COLORS.bg);
|
||||
|
||||
const initialState = this.registry.get("initialState") as MazeProgress | undefined;
|
||||
const alreadyCollected = new Set(initialState?.collectedItemKeys ?? []);
|
||||
this.collectedItemKeys = [...alreadyCollected];
|
||||
|
||||
const grid = this.definition.grid;
|
||||
const rows = grid.length;
|
||||
const cols = grid[0].length;
|
||||
@@ -62,15 +69,29 @@ export class MazeScene extends Phaser.Scene {
|
||||
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, ch === "E" ? COLORS.exit : COLORS.floor);
|
||||
if (ch === "S") this.playerPos = { row: r, col: c };
|
||||
if (ch === "*") {
|
||||
const key = `${r},${c}`;
|
||||
this.items.add(key);
|
||||
this.totalItems += 1;
|
||||
const key = `${r},${c}`;
|
||||
if (alreadyCollected.has(key)) continue;
|
||||
this.items.add(key);
|
||||
const item = this.add.rectangle(x, y, this.cellSize * 0.4, this.cellSize * 0.4, COLORS.item);
|
||||
this.itemSprites.set(key, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume position only if it's still a valid, non-wall cell — the
|
||||
// definition may have changed since this progress was saved.
|
||||
if (
|
||||
initialState &&
|
||||
initialState.playerRow >= 0 &&
|
||||
initialState.playerRow < rows &&
|
||||
initialState.playerCol >= 0 &&
|
||||
initialState.playerCol < cols &&
|
||||
grid[initialState.playerRow][initialState.playerCol] !== "#"
|
||||
) {
|
||||
this.playerPos = { row: initialState.playerRow, col: initialState.playerCol };
|
||||
}
|
||||
|
||||
const px = this.offsetX + this.playerPos.col * this.cellSize + this.cellSize / 2;
|
||||
const py = this.offsetY + this.playerPos.row * this.cellSize + this.cellSize / 2;
|
||||
this.player = this.add.circle(px, py, this.cellSize * 0.35, COLORS.player);
|
||||
@@ -80,7 +101,11 @@ export class MazeScene extends Phaser.Scene {
|
||||
.setOrigin(0.5, 0);
|
||||
|
||||
if (this.definition.timeLimitSec > 0) {
|
||||
this.remainingMs = this.definition.timeLimitSec * 1000;
|
||||
const savedRemaining = initialState?.remainingMs;
|
||||
this.remainingMs =
|
||||
savedRemaining !== undefined
|
||||
? Math.min(Math.max(savedRemaining, 0), this.definition.timeLimitSec * 1000)
|
||||
: this.definition.timeLimitSec * 1000;
|
||||
this.timerText = this.add
|
||||
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
|
||||
.setOrigin(0.5, 0);
|
||||
@@ -136,6 +161,7 @@ export class MazeScene extends Phaser.Scene {
|
||||
this.items.delete(key);
|
||||
this.itemSprites.get(key)?.destroy();
|
||||
this.itemSprites.delete(key);
|
||||
this.collectedItemKeys.push(key);
|
||||
this.updateHud();
|
||||
}
|
||||
|
||||
@@ -152,6 +178,15 @@ export class MazeScene extends Phaser.Scene {
|
||||
if (this.timerText) {
|
||||
this.timerText.setText(`Осталось: ${Math.ceil(this.remainingMs / 1000)} сек`);
|
||||
}
|
||||
|
||||
if (!this.finished) {
|
||||
this.onProgress?.({
|
||||
playerRow: this.playerPos.row,
|
||||
playerCol: this.playerPos.col,
|
||||
collectedItemKeys: this.collectedItemKeys,
|
||||
remainingMs: this.remainingMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private finish(won: boolean, message?: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Phaser from "phaser";
|
||||
import type { QuizDefinition, GameResult } from "../types";
|
||||
import type { QuizDefinition, QuizProgress, GameResult } from "../types";
|
||||
|
||||
const COLORS = {
|
||||
bg: 0x14162a,
|
||||
@@ -14,6 +14,7 @@ const COLORS = {
|
||||
export class QuizScene extends Phaser.Scene {
|
||||
private definition!: QuizDefinition;
|
||||
private onFinish?: (result: GameResult) => void;
|
||||
private onProgress?: (state: QuizProgress) => void;
|
||||
private questionIndex = 0;
|
||||
private score = 0;
|
||||
private locked = false;
|
||||
@@ -31,8 +32,13 @@ export class QuizScene extends Phaser.Scene {
|
||||
create() {
|
||||
this.definition = this.registry.get("definition");
|
||||
this.onFinish = this.registry.get("onFinish");
|
||||
this.questionIndex = 0;
|
||||
this.score = 0;
|
||||
this.onProgress = this.registry.get("onProgress");
|
||||
|
||||
const initialState = this.registry.get("initialState") as QuizProgress | undefined;
|
||||
const totalQuestions = this.definition.questions.length;
|
||||
this.questionIndex =
|
||||
initialState && initialState.questionIndex < totalQuestions ? initialState.questionIndex : 0;
|
||||
this.score = initialState?.score ?? 0;
|
||||
|
||||
this.cameras.main.setBackgroundColor(COLORS.bg);
|
||||
this.questionText = this.add
|
||||
@@ -126,6 +132,7 @@ export class QuizScene extends Phaser.Scene {
|
||||
|
||||
this.time.delayedCall(700, () => {
|
||||
this.questionIndex += 1;
|
||||
this.onProgress?.({ questionIndex: this.questionIndex, score: this.score });
|
||||
if (this.questionIndex >= this.definition.questions.length) {
|
||||
const total = this.definition.questions.length;
|
||||
this.onFinish?.({
|
||||
|
||||
+20
-9
@@ -1,6 +1,6 @@
|
||||
import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
||||
import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
||||
import { mazeDefinitionSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
||||
import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
||||
import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
||||
import { mazeDefinitionSchema, mazeProgressSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
||||
|
||||
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
|
||||
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
||||
@@ -37,6 +37,16 @@ export function safeParseDefinition(templateType: TemplateType, data: unknown) {
|
||||
return templateSchemas[templateType].safeParse(data);
|
||||
}
|
||||
|
||||
export const progressSchemas = {
|
||||
quiz: quizProgressSchema,
|
||||
clicker: clickerProgressSchema,
|
||||
maze: mazeProgressSchema,
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -44,12 +54,13 @@ export interface GameResult {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type { QuizDefinition, QuizQuestion } from "./quiz";
|
||||
export type { ClickerDefinition, ClickerUpgrade } from "./clicker";
|
||||
export type { MazeDefinition } from "./maze";
|
||||
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
|
||||
export type { ClickerDefinition, ClickerUpgrade, ClickerProgress } from "./clicker";
|
||||
export type { MazeDefinition, MazeProgress } from "./maze";
|
||||
|
||||
import type { QuizDefinition } from "./quiz";
|
||||
import type { ClickerDefinition } from "./clicker";
|
||||
import type { MazeDefinition } from "./maze";
|
||||
import type { QuizDefinition, QuizProgress } from "./quiz";
|
||||
import type { ClickerDefinition, ClickerProgress } from "./clicker";
|
||||
import type { MazeDefinition, MazeProgress } from "./maze";
|
||||
|
||||
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition;
|
||||
export type GameProgress = QuizProgress | ClickerProgress | MazeProgress;
|
||||
@@ -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,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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user