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 { users } from "@/lib/db/schema";
|
||||||
import { hashPassword } from "@/lib/auth/password";
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
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({
|
const registerSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -33,6 +35,8 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
|
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await grantCoins(user.id, SIGNUP_BONUS_COINS, "signup_bonus");
|
||||||
|
|
||||||
const token = await createSession(user.id);
|
const token = await createSession(user.id);
|
||||||
await setSessionCookie(token);
|
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 { notFound } from "next/navigation";
|
||||||
import { getGameById, incrementPlayCount } from "@/lib/games/service";
|
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 { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { TEMPLATE_LABELS } from "@/lib/games/types";
|
import { TEMPLATE_LABELS } from "@/lib/games/types";
|
||||||
import { PlaySession } from "@/components/play-session";
|
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);
|
const game = await getGameById(gameId);
|
||||||
if (!game) notFound();
|
if (!game) notFound();
|
||||||
|
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
|
||||||
if (game.status === "draft") {
|
if (game.status === "draft") {
|
||||||
const session = await getCurrentSession();
|
|
||||||
if (!session || session.user.id !== game.ownerId) notFound();
|
if (!session || session.user.id !== game.ownerId) notFound();
|
||||||
} else {
|
} else {
|
||||||
await incrementPlayCount(gameId);
|
await incrementPlayCount(gameId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [initialState, initialBalance] = session
|
||||||
|
? await Promise.all([getProgress(gameId, session.user.id), getBalance(session.user.id)])
|
||||||
|
: [null, 0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<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="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>}
|
{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>
|
<p className="mt-1 text-xs text-text-faint">{TEMPLATE_HINTS[game.templateType]}</p>
|
||||||
</div>
|
</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>
|
</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 { useEffect, useRef } from "react";
|
||||||
import type Phaser from "phaser";
|
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({
|
export function GameCanvas({
|
||||||
templateType,
|
templateType,
|
||||||
definition,
|
definition,
|
||||||
|
initialState,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
onProgress,
|
||||||
}: {
|
}: {
|
||||||
templateType: TemplateType;
|
templateType: TemplateType;
|
||||||
definition: GameDefinition;
|
definition: GameDefinition;
|
||||||
|
initialState?: GameProgress;
|
||||||
onFinish?: (result: GameResult) => void;
|
onFinish?: (result: GameResult) => void;
|
||||||
|
onProgress?: (state: GameProgress) => void;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -42,7 +46,9 @@ export function GameCanvas({
|
|||||||
});
|
});
|
||||||
|
|
||||||
game.registry.set("definition", definition);
|
game.registry.set("definition", definition);
|
||||||
|
game.registry.set("initialState", initialState);
|
||||||
game.registry.set("onFinish", onFinish);
|
game.registry.set("onFinish", onFinish);
|
||||||
|
game.registry.set("onProgress", onProgress);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
+35
-20
@@ -1,10 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Gamepad2 } from "lucide-react";
|
import { Gamepad2, Coins } from "lucide-react";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getBalance } from "@/lib/wallet/service";
|
||||||
import { LogoutButton } from "./logout-button";
|
import { LogoutButton } from "./logout-button";
|
||||||
|
|
||||||
export async function Header() {
|
export async function Header() {
|
||||||
const session = await getCurrentSession();
|
const session = await getCurrentSession();
|
||||||
|
const balance = session ? await getBalance(session.user.id) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-border bg-surface">
|
<header className="border-b border-border bg-surface">
|
||||||
@@ -16,28 +18,17 @@ export async function Header() {
|
|||||||
tis
|
tis
|
||||||
</Link>
|
</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">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{session ? (
|
{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>
|
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</>
|
</>
|
||||||
@@ -52,6 +43,30 @@ export async function Header() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
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 { 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({
|
export function PlaySession({
|
||||||
|
gameId,
|
||||||
templateType,
|
templateType,
|
||||||
definition,
|
definition,
|
||||||
|
initialState,
|
||||||
|
initialBalance,
|
||||||
|
canSave,
|
||||||
}: {
|
}: {
|
||||||
|
gameId: string;
|
||||||
templateType: TemplateType;
|
templateType: TemplateType;
|
||||||
definition: GameDefinition;
|
definition: GameDefinition;
|
||||||
|
initialState?: GameProgress;
|
||||||
|
initialBalance: number;
|
||||||
|
canSave: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [result, setResult] = useState<GameResult | null>(null);
|
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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
|
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
|
||||||
<div className="relative">
|
<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 && (
|
{result && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/70 backdrop-blur-sm">
|
<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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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,
|
"when": 1785518228143,
|
||||||
"tag": "0000_marvelous_tyrannus",
|
"tag": "0000_marvelous_tyrannus",
|
||||||
"breakpoints": true
|
"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 { 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";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const id = () =>
|
const id = () =>
|
||||||
@@ -61,3 +61,56 @@ export const aiConfig = sqliteTable("ai_config", {
|
|||||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||||
createdAt: timestamps.createdAt,
|
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 },
|
{ 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#", "#######"],
|
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
|
||||||
timeLimitSec: 0,
|
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 }],
|
questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }],
|
||||||
timePerQuestionSec: 0,
|
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 Phaser from "phaser";
|
||||||
import type { ClickerDefinition, ClickerUpgrade, GameResult } from "../types";
|
import type { ClickerDefinition, ClickerUpgrade, ClickerProgress, GameResult } from "../types";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
bg: 0x14162a,
|
bg: 0x14162a,
|
||||||
@@ -19,6 +19,7 @@ interface UpgradeRow {
|
|||||||
export class ClickerScene extends Phaser.Scene {
|
export class ClickerScene extends Phaser.Scene {
|
||||||
private definition!: ClickerDefinition;
|
private definition!: ClickerDefinition;
|
||||||
private onFinish?: (result: GameResult) => void;
|
private onFinish?: (result: GameResult) => void;
|
||||||
|
private onProgress?: (state: ClickerProgress) => void;
|
||||||
private score = 0;
|
private score = 0;
|
||||||
private pointsPerClick = 1;
|
private pointsPerClick = 1;
|
||||||
private pointsPerSecond = 0;
|
private pointsPerSecond = 0;
|
||||||
@@ -34,10 +35,16 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
create() {
|
create() {
|
||||||
this.definition = this.registry.get("definition");
|
this.definition = this.registry.get("definition");
|
||||||
this.onFinish = this.registry.get("onFinish");
|
this.onFinish = this.registry.get("onFinish");
|
||||||
this.score = 0;
|
this.onProgress = this.registry.get("onProgress");
|
||||||
this.pointsPerClick = this.definition.startingPointsPerClick;
|
|
||||||
this.pointsPerSecond = 0;
|
const initialState = this.registry.get("initialState") as ClickerProgress | undefined;
|
||||||
this.ownedUpgrades = new Set();
|
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.upgradeRows = [];
|
||||||
this.finished = false;
|
this.finished = false;
|
||||||
|
|
||||||
@@ -97,6 +104,13 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private refresh() {
|
private refresh() {
|
||||||
|
this.onProgress?.({
|
||||||
|
score: this.score,
|
||||||
|
pointsPerClick: this.pointsPerClick,
|
||||||
|
pointsPerSecond: this.pointsPerSecond,
|
||||||
|
ownedUpgradeIndices: Array.from(this.ownedUpgrades),
|
||||||
|
});
|
||||||
|
|
||||||
this.scoreText.setText(
|
this.scoreText.setText(
|
||||||
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`,
|
`${Math.floor(this.score)} / ${this.definition.targetScore}\n+${this.pointsPerClick}/клик +${this.pointsPerSecond}/сек`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import type { MazeDefinition, GameResult } from "../types";
|
import type { MazeDefinition, MazeProgress, GameResult } from "../types";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
bg: 0x0a0b14,
|
bg: 0x0a0b14,
|
||||||
@@ -13,6 +13,7 @@ const COLORS = {
|
|||||||
export class MazeScene extends Phaser.Scene {
|
export class MazeScene extends Phaser.Scene {
|
||||||
private definition!: MazeDefinition;
|
private definition!: MazeDefinition;
|
||||||
private onFinish?: (result: GameResult) => void;
|
private onFinish?: (result: GameResult) => void;
|
||||||
|
private onProgress?: (state: MazeProgress) => void;
|
||||||
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
|
||||||
private playerPos = { row: 0, col: 0 };
|
private playerPos = { row: 0, col: 0 };
|
||||||
private items = new Set<string>();
|
private items = new Set<string>();
|
||||||
@@ -22,6 +23,7 @@ export class MazeScene extends Phaser.Scene {
|
|||||||
private offsetX = 0;
|
private offsetX = 0;
|
||||||
private offsetY = 0;
|
private offsetY = 0;
|
||||||
private totalItems = 0;
|
private totalItems = 0;
|
||||||
|
private collectedItemKeys: string[] = [];
|
||||||
private scoreText!: Phaser.GameObjects.Text;
|
private scoreText!: Phaser.GameObjects.Text;
|
||||||
private timerText?: Phaser.GameObjects.Text;
|
private timerText?: Phaser.GameObjects.Text;
|
||||||
private remainingMs = 0;
|
private remainingMs = 0;
|
||||||
@@ -34,9 +36,14 @@ export class MazeScene extends Phaser.Scene {
|
|||||||
create() {
|
create() {
|
||||||
this.definition = this.registry.get("definition");
|
this.definition = this.registry.get("definition");
|
||||||
this.onFinish = this.registry.get("onFinish");
|
this.onFinish = this.registry.get("onFinish");
|
||||||
|
this.onProgress = this.registry.get("onProgress");
|
||||||
this.finished = false;
|
this.finished = false;
|
||||||
this.cameras.main.setBackgroundColor(COLORS.bg);
|
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 grid = this.definition.grid;
|
||||||
const rows = grid.length;
|
const rows = grid.length;
|
||||||
const cols = grid[0].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);
|
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 === "S") this.playerPos = { row: r, col: c };
|
||||||
if (ch === "*") {
|
if (ch === "*") {
|
||||||
const key = `${r},${c}`;
|
|
||||||
this.items.add(key);
|
|
||||||
this.totalItems += 1;
|
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);
|
const item = this.add.rectangle(x, y, this.cellSize * 0.4, this.cellSize * 0.4, COLORS.item);
|
||||||
this.itemSprites.set(key, 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 px = this.offsetX + this.playerPos.col * this.cellSize + this.cellSize / 2;
|
||||||
const py = this.offsetY + this.playerPos.row * 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);
|
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);
|
.setOrigin(0.5, 0);
|
||||||
|
|
||||||
if (this.definition.timeLimitSec > 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
|
this.timerText = this.add
|
||||||
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
|
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
|
||||||
.setOrigin(0.5, 0);
|
.setOrigin(0.5, 0);
|
||||||
@@ -136,6 +161,7 @@ export class MazeScene extends Phaser.Scene {
|
|||||||
this.items.delete(key);
|
this.items.delete(key);
|
||||||
this.itemSprites.get(key)?.destroy();
|
this.itemSprites.get(key)?.destroy();
|
||||||
this.itemSprites.delete(key);
|
this.itemSprites.delete(key);
|
||||||
|
this.collectedItemKeys.push(key);
|
||||||
this.updateHud();
|
this.updateHud();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +178,15 @@ export class MazeScene extends Phaser.Scene {
|
|||||||
if (this.timerText) {
|
if (this.timerText) {
|
||||||
this.timerText.setText(`Осталось: ${Math.ceil(this.remainingMs / 1000)} сек`);
|
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) {
|
private finish(won: boolean, message?: string) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import type { QuizDefinition, GameResult } from "../types";
|
import type { QuizDefinition, QuizProgress, GameResult } from "../types";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
bg: 0x14162a,
|
bg: 0x14162a,
|
||||||
@@ -14,6 +14,7 @@ const COLORS = {
|
|||||||
export class QuizScene extends Phaser.Scene {
|
export class QuizScene extends Phaser.Scene {
|
||||||
private definition!: QuizDefinition;
|
private definition!: QuizDefinition;
|
||||||
private onFinish?: (result: GameResult) => void;
|
private onFinish?: (result: GameResult) => void;
|
||||||
|
private onProgress?: (state: QuizProgress) => void;
|
||||||
private questionIndex = 0;
|
private questionIndex = 0;
|
||||||
private score = 0;
|
private score = 0;
|
||||||
private locked = false;
|
private locked = false;
|
||||||
@@ -31,8 +32,13 @@ export class QuizScene extends Phaser.Scene {
|
|||||||
create() {
|
create() {
|
||||||
this.definition = this.registry.get("definition");
|
this.definition = this.registry.get("definition");
|
||||||
this.onFinish = this.registry.get("onFinish");
|
this.onFinish = this.registry.get("onFinish");
|
||||||
this.questionIndex = 0;
|
this.onProgress = this.registry.get("onProgress");
|
||||||
this.score = 0;
|
|
||||||
|
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.cameras.main.setBackgroundColor(COLORS.bg);
|
||||||
this.questionText = this.add
|
this.questionText = this.add
|
||||||
@@ -126,6 +132,7 @@ export class QuizScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.time.delayedCall(700, () => {
|
this.time.delayedCall(700, () => {
|
||||||
this.questionIndex += 1;
|
this.questionIndex += 1;
|
||||||
|
this.onProgress?.({ questionIndex: this.questionIndex, score: this.score });
|
||||||
if (this.questionIndex >= this.definition.questions.length) {
|
if (this.questionIndex >= this.definition.questions.length) {
|
||||||
const total = this.definition.questions.length;
|
const total = this.definition.questions.length;
|
||||||
this.onFinish?.({
|
this.onFinish?.({
|
||||||
|
|||||||
+20
-9
@@ -1,6 +1,6 @@
|
|||||||
import { quizDefinitionSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
import { quizDefinitionSchema, quizProgressSchema, DEFAULT_QUIZ_DEFINITION } from "./quiz";
|
||||||
import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
import { clickerDefinitionSchema, clickerProgressSchema, DEFAULT_CLICKER_DEFINITION } from "./clicker";
|
||||||
import { mazeDefinitionSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
import { mazeDefinitionSchema, mazeProgressSchema, DEFAULT_MAZE_DEFINITION } from "./maze";
|
||||||
|
|
||||||
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
|
export const TEMPLATE_TYPES = ["quiz", "clicker", "maze"] as const;
|
||||||
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
export type TemplateType = (typeof TEMPLATE_TYPES)[number];
|
||||||
@@ -37,6 +37,16 @@ export function safeParseDefinition(templateType: TemplateType, data: unknown) {
|
|||||||
return templateSchemas[templateType].safeParse(data);
|
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. */
|
/** What a template Scene reports back through the registry callback when the game ends. */
|
||||||
export interface GameResult {
|
export interface GameResult {
|
||||||
won: boolean;
|
won: boolean;
|
||||||
@@ -44,12 +54,13 @@ export interface GameResult {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { QuizDefinition, QuizQuestion } from "./quiz";
|
export type { QuizDefinition, QuizQuestion, QuizProgress } from "./quiz";
|
||||||
export type { ClickerDefinition, ClickerUpgrade } from "./clicker";
|
export type { ClickerDefinition, ClickerUpgrade, ClickerProgress } from "./clicker";
|
||||||
export type { MazeDefinition } from "./maze";
|
export type { MazeDefinition, MazeProgress } from "./maze";
|
||||||
|
|
||||||
import type { QuizDefinition } from "./quiz";
|
import type { QuizDefinition, QuizProgress } from "./quiz";
|
||||||
import type { ClickerDefinition } from "./clicker";
|
import type { ClickerDefinition, ClickerProgress } from "./clicker";
|
||||||
import type { MazeDefinition } from "./maze";
|
import type { MazeDefinition, MazeProgress } from "./maze";
|
||||||
|
|
||||||
export type GameDefinition = QuizDefinition | ClickerDefinition | MazeDefinition;
|
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