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; + }); +}