diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts
index 13cb25e..c432e26 100644
--- a/src/app/api/auth/register/route.ts
+++ b/src/app/api/auth/register/route.ts
@@ -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);
diff --git a/src/app/api/games/[id]/progress/route.ts b/src/app/api/games/[id]/progress/route.ts
new file mode 100644
index 0000000..130166c
--- /dev/null
+++ b/src/app/api/games/[id]/progress/route.ts
@@ -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 });
+}
diff --git a/src/app/api/wallet/purchase/route.ts b/src/app/api/wallet/purchase/route.ts
new file mode 100644
index 0000000..401f8df
--- /dev/null
+++ b/src/app/api/wallet/purchase/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/wallet/route.ts b/src/app/api/wallet/route.ts
new file mode 100644
index 0000000..4d0d5b2
--- /dev/null
+++ b/src/app/api/wallet/route.ts
@@ -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 });
+}
diff --git a/src/app/play/[gameId]/page.tsx b/src/app/play/[gameId]/page.tsx
index fc178e8..4b19f44 100644
--- a/src/app/play/[gameId]/page.tsx
+++ b/src/app/play/[gameId]/page.tsx
@@ -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();
+ const session = await getCurrentSession();
+
if (game.status === "draft") {
- const session = await getCurrentSession();
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 (
@@ -33,7 +40,14 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
{game.description &&
{game.description}
}
{TEMPLATE_HINTS[game.templateType]}
-
+
);
}
diff --git a/src/app/wallet/page.tsx b/src/app/wallet/page.tsx
new file mode 100644
index 0000000..e09f923
--- /dev/null
+++ b/src/app/wallet/page.tsx
@@ -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 = {
+ 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 (
+
+
Монеты
+
+ Монеты нужны для мгновенного ручного сохранения прогресса в играх — автосохранение при этом всегда бесплатное.
+
+
+
+
+
+
+
+
{balance}
+
монет на балансе
+
+
+
+
+
+
+
История
+ {transactions.length === 0 ? (
+
Пока пусто.
+ ) : (
+
+ {transactions.map((t) => (
+
+ {REASON_LABELS[t.reason] ?? t.reason}
+ = 0 ? "text-success" : "text-danger"}>
+ {t.amount >= 0 ? "+" : ""}
+ {t.amount}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/game-canvas.tsx b/src/components/game-canvas.tsx
index 1fa1541..d30e31e 100644
--- a/src/components/game-canvas.tsx
+++ b/src/components/game-canvas.tsx
@@ -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(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 () => {
diff --git a/src/components/header.tsx b/src/components/header.tsx
index 8b48be3..f424ec0 100644
--- a/src/components/header.tsx
+++ b/src/components/header.tsx
@@ -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 (
@@ -16,28 +18,17 @@ export async function Header() {
tis
-
-
{session ? (
<>
+
+
+ {balance}
+
{session.user.name}
>
@@ -52,6 +43,30 @@ export async function Header() {
>
)}
+
+
+
+
);
diff --git a/src/components/play-session.tsx b/src/components/play-session.tsx
index df45fe0..2757c83 100644
--- a/src/components/play-session.tsx
+++ b/src/components/play-session.tsx
@@ -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(null);
+ const [balance, setBalance] = useState(initialBalance);
+ const [hasProgress, setHasProgress] = useState(Boolean(initialState));
+ const [lastSavedAt, setLastSavedAt] = useState(() => (initialState ? Date.now() : null));
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const latestStateRef = useRef(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 (
-
+
{result && (
@@ -50,6 +118,38 @@ export function PlaySession({
)}
+
+ {canSave && (
+
+
+
+
+ Прогресс сохраняется автоматически раз в 10 минут.
+ {lastSavedAt &&
+ ` Сохранено в ${new Date(lastSavedAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}.`}
+
+
+
+ {saveError && (
+
+ {saveError}{" "}
+
+ Пополнить
+
+
+ )}
+
+
+
+ )}
);
}
diff --git a/src/components/wallet-purchase-panel.tsx b/src/components/wallet-purchase-panel.tsx
new file mode 100644
index 0000000..19e080c
--- /dev/null
+++ b/src/components/wallet-purchase-panel.tsx
@@ -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(null);
+ const [error, setError] = useState(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 (
+
+
Купить монеты
+
+ {packages.map((pkg) => (
+
+ ))}
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/src/lib/db/migrations/0001_fearless_proudstar.sql b/src/lib/db/migrations/0001_fearless_proudstar.sql
new file mode 100644
index 0000000..be9e3dc
--- /dev/null
+++ b/src/lib/db/migrations/0001_fearless_proudstar.sql
@@ -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
+);
diff --git a/src/lib/db/migrations/meta/0001_snapshot.json b/src/lib/db/migrations/meta/0001_snapshot.json
new file mode 100644
index 0000000..c2d9a7f
--- /dev/null
+++ b/src/lib/db/migrations/meta/0001_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json
index 485098b..0d032a0 100644
--- a/src/lib/db/migrations/meta/_journal.json
+++ b/src/lib/db/migrations/meta/_journal.json
@@ -8,6 +8,13 @@
"when": 1785518228143,
"tag": "0000_marvelous_tyrannus",
"breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "6",
+ "when": 1785534147942,
+ "tag": "0001_fearless_proudstar",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index af35d8a..3d12cba 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -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" }),
+});
diff --git a/src/lib/games/clicker.ts b/src/lib/games/clicker.ts
index 4294d85..2b8e2eb 100644
--- a/src/lib/games/clicker.ts
+++ b/src/lib/games/clicker.ts
@@ -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;
diff --git a/src/lib/games/maze.ts b/src/lib/games/maze.ts
index d7cfc2f..6b57b03 100644
--- a/src/lib/games/maze.ts
+++ b/src/lib/games/maze.ts
@@ -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;
diff --git a/src/lib/games/progress.ts b/src/lib/games/progress.ts
new file mode 100644
index 0000000..f596b74
--- /dev/null
+++ b/src/lib/games/progress.ts
@@ -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 {
+ 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 {
+ 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 {
+ const game = await db.query.games.findFirst({
+ where: (g, { eq: eqOp }) => eqOp(g.id, gameId),
+ columns: { templateType: true },
+ });
+ return game?.templateType ?? null;
+}
diff --git a/src/lib/games/quiz.ts b/src/lib/games/quiz.ts
index 393ebc2..f8da776 100644
--- a/src/lib/games/quiz.ts
+++ b/src/lib/games/quiz.ts
@@ -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;
diff --git a/src/lib/games/scenes/clicker-scene.ts b/src/lib/games/scenes/clicker-scene.ts
index 56bbbbd..bf9050f 100644
--- a/src/lib/games/scenes/clicker-scene.ts
+++ b/src/lib/games/scenes/clicker-scene.ts
@@ -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}/сек`,
);
diff --git a/src/lib/games/scenes/maze-scene.ts b/src/lib/games/scenes/maze-scene.ts
index f533d6c..3e3741a 100644
--- a/src/lib/games/scenes/maze-scene.ts
+++ b/src/lib/games/scenes/maze-scene.ts
@@ -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();
@@ -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) {
diff --git a/src/lib/games/scenes/quiz-scene.ts b/src/lib/games/scenes/quiz-scene.ts
index 721aebf..d3d37bc 100644
--- a/src/lib/games/scenes/quiz-scene.ts
+++ b/src/lib/games/scenes/quiz-scene.ts
@@ -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?.({
diff --git a/src/lib/games/types.ts b/src/lib/games/types.ts
index 15a85ef..59808d5 100644
--- a/src/lib/games/types.ts
+++ b/src/lib/games/types.ts
@@ -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;
diff --git a/src/lib/payments/provider.ts b/src/lib/payments/provider.ts
new file mode 100644
index 0000000..52e1b32
--- /dev/null
+++ b/src/lib/payments/provider.ts
@@ -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;
+}
diff --git a/src/lib/wallet/economy.ts b/src/lib/wallet/economy.ts
new file mode 100644
index 0000000..8f5a4d7
--- /dev/null
+++ b/src/lib/wallet/economy.ts
@@ -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 },
+];
diff --git a/src/lib/wallet/service.ts b/src/lib/wallet/service.ts
new file mode 100644
index 0000000..726f50a
--- /dev/null
+++ b/src/lib/wallet/service.ts
@@ -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 {
+ 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 {
+ const wallet = await getOrCreateWallet(userId);
+ return wallet.balance;
+}
+
+export async function listRecentTransactions(userId: string, limit = 20): Promise {
+ 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 {
+ 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 {
+ 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;
+ });
+}