Fix mobile header layout, add resumable progress + coin wallet

Header nav was splitting into three lines on mobile (logo / nav /
logout floating right with a big gap) — applied the same order-last
wrap pattern already proven in top-tickets/project-claude, so nav
drops to its own scrollable row instead.

Bigger feature: player progress now survives closing the tab, for all
three templates, not just Clicker. Each Phaser scene reports its state
through the same registry-callback pattern already used for onFinish
(onProgress), and reads an initialState to resume instead of always
starting fresh. Autosave fires every 10 minutes for free; an instant
manual save costs coins.

New coin economy (src/lib/wallet/): a wallet + transaction ledger per
user, atomic grant/spend via db.transaction() so two concurrent spends
against a low balance can't both succeed (verified with a real race
test — exactly one wins, balance never goes negative). New accounts
get a 20-coin signup bonus so manual save is testable before any real
payment method exists.

Real-money purchases are scaffolded but deliberately not wired to a
provider yet — user hasn't picked one. /api/wallet/purchase creates a
pending order and returns a clean "not configured" error, same shape
as the AI/LDAP not-configured responses elsewhere in this project. The
payment abstraction (src/lib/payments/provider.ts) is the one seam a
real adapter needs to fill in later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-31 21:56:49 +00:00
1 parent 3d5497fae9
commit e682c7df13
25 files changed
+1375 -49

No files matched your search

+4
View File
@@ -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);
+59
View File
@@ -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 });
}
+53
View File
@@ -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 });
}
}
+17
View File
@@ -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 });
}
+16 -2
View File
@@ -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 (
<div className="flex flex-1 flex-col">
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
@@ -33,7 +40,14 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
{game.description && <p className="mt-1 text-sm text-text-muted">{game.description}</p>}
<p className="mt-1 text-xs text-text-faint">{TEMPLATE_HINTS[game.templateType]}</p>
</div>
<PlaySession templateType={game.templateType} definition={game.definition} />
<PlaySession
gameId={game.id}
templateType={game.templateType}
definition={game.definition}
initialState={initialState ?? undefined}
initialBalance={initialBalance}
canSave={Boolean(session)}
/>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { Coins } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getBalance, listRecentTransactions } from "@/lib/wallet/service";
import { COIN_PACKAGES } from "@/lib/wallet/economy";
import { WalletPurchasePanel } from "@/components/wallet-purchase-panel";
const REASON_LABELS: Record<string, string> = {
signup_bonus: "Бонус за регистрацию",
manual_save: "Ручное сохранение",
purchase: "Пополнение",
};
export default async function WalletPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const [balance, transactions] = await Promise.all([
getBalance(session.user.id),
listRecentTransactions(session.user.id),
]);
return (
<div className="mx-auto w-full max-w-2xl flex-1 px-4 py-8 sm:px-6">
<h1 className="mb-1 font-display text-2xl font-bold tracking-tight">Монеты</h1>
<p className="mb-6 text-sm text-text-muted">
Монеты нужны для мгновенного ручного сохранения прогресса в играх — автосохранение при этом всегда бесплатное.
</p>
<div className="card mb-6 flex items-center gap-3 p-5">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-accent-soft text-accent-soft-text">
<Coins size={20} />
</div>
<div>
<p className="text-2xl font-bold">{balance}</p>
<p className="text-sm text-text-muted">монет на балансе</p>
</div>
</div>
<WalletPurchasePanel packages={COIN_PACKAGES} />
<div className="card mt-6 p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">История</h2>
{transactions.length === 0 ? (
<p className="text-sm text-text-muted">Пока пусто.</p>
) : (
<div className="flex flex-col divide-y divide-border">
{transactions.map((t) => (
<div key={t.id} className="flex items-center justify-between py-2 text-sm">
<span className="text-text-muted">{REASON_LABELS[t.reason] ?? t.reason}</span>
<span className={t.amount >= 0 ? "text-success" : "text-danger"}>
{t.amount >= 0 ? "+" : ""}
{t.amount}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
}
+7 -1
View File
@@ -2,16 +2,20 @@
import { useEffect, useRef } from "react";
import type Phaser from "phaser";
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
export function GameCanvas({
templateType,
definition,
initialState,
onFinish,
onProgress,
}: {
templateType: TemplateType;
definition: GameDefinition;
initialState?: GameProgress;
onFinish?: (result: GameResult) => void;
onProgress?: (state: GameProgress) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -42,7 +46,9 @@ export function GameCanvas({
});
game.registry.set("definition", definition);
game.registry.set("initialState", initialState);
game.registry.set("onFinish", onFinish);
game.registry.set("onProgress", onProgress);
})();
return () => {
+35 -20
View File
@@ -1,10 +1,12 @@
import Link from "next/link";
import { Gamepad2 } from "lucide-react";
import { Gamepad2, Coins } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getBalance } from "@/lib/wallet/service";
import { LogoutButton } from "./logout-button";
export async function Header() {
const session = await getCurrentSession();
const balance = session ? await getBalance(session.user.id) : null;
return (
<header className="border-b border-border bg-surface">
@@ -16,28 +18,17 @@ export async function Header() {
tis
</Link>
<nav className="flex items-center gap-1 text-sm font-medium text-text-muted">
<Link href="/" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Лаунчер
</Link>
{session && (
<>
<Link href="/create" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Создать
</Link>
<Link href="/my-games" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Мои игры
</Link>
<Link href="/settings/ai" className="rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Настройки ИИ
</Link>
</>
)}
</nav>
<div className="ml-auto flex items-center gap-2">
{session ? (
<>
<Link
href="/wallet"
className="flex items-center gap-1.5 rounded-full bg-accent-soft px-2.5 py-1 text-sm font-medium text-accent-soft-text hover:opacity-90"
title="Монеты"
>
<Coins size={13} />
{balance}
</Link>
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
<LogoutButton />
</>
@@ -52,6 +43,30 @@ export async function Header() {
</>
)}
</div>
<div className="order-last w-full overflow-x-auto sm:order-none sm:w-auto sm:overflow-visible">
<nav className="flex items-center gap-1 text-sm font-medium text-text-muted">
<Link href="/" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Лаунчер
</Link>
{session && (
<>
<Link href="/create" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Создать
</Link>
<Link href="/my-games" className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text">
Мои игры
</Link>
<Link
href="/settings/ai"
className="shrink-0 rounded-md px-3 py-1.5 hover:bg-surface-hover hover:text-text"
>
Настройки ИИ
</Link>
</>
)}
</nav>
</div>
</div>
</header>
);
+104 -4
View File
@@ -1,25 +1,93 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
import { RotateCcw, Trophy, XCircle } from "lucide-react";
import { RotateCcw, Trophy, XCircle, Save, Clock } from "lucide-react";
import { GameCanvas } from "./game-canvas";
import type { TemplateType, GameDefinition, GameResult } from "@/lib/games/types";
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
export function PlaySession({
gameId,
templateType,
definition,
initialState,
initialBalance,
canSave,
}: {
gameId: string;
templateType: TemplateType;
definition: GameDefinition;
initialState?: GameProgress;
initialBalance: number;
canSave: boolean;
}) {
const [attempt, setAttempt] = useState(0);
const [result, setResult] = useState<GameResult | null>(null);
const [balance, setBalance] = useState(initialBalance);
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
const [lastSavedAt, setLastSavedAt] = useState<number | null>(() => (initialState ? Date.now() : null));
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const latestStateRef = useRef<GameProgress | null>(initialState ?? null);
const handleProgress = useCallback((state: GameProgress) => {
latestStateRef.current = state;
setHasProgress(true);
}, []);
const save = useCallback(
async (manual: boolean) => {
const state = latestStateRef.current;
if (!state) return;
if (manual) {
setSaving(true);
setSaveError(null);
}
const res = await fetch(`/api/games/${gameId}/progress`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, manual }),
});
if (manual) setSaving(false);
if (!res.ok) {
if (manual) {
const data = await res.json().catch(() => null);
setSaveError(data?.error ?? "Не удалось сохранить");
}
return;
}
setLastSavedAt(Date.now());
if (manual) setBalance((b) => b - MANUAL_SAVE_COST);
},
[gameId],
);
useEffect(() => {
if (!canSave) return;
const interval = setInterval(() => save(false), AUTOSAVE_INTERVAL_MS);
return () => clearInterval(interval);
}, [canSave, save]);
return (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
<div className="relative">
<GameCanvas key={attempt} templateType={templateType} definition={definition} onFinish={setResult} />
<GameCanvas
key={attempt}
templateType={templateType}
definition={definition}
initialState={initialState}
onFinish={setResult}
onProgress={canSave ? handleProgress : undefined}
/>
{result && (
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black/70 backdrop-blur-sm">
@@ -50,6 +118,38 @@ export function PlaySession({
</div>
)}
</div>
{canSave && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="flex items-center gap-1.5 text-text-muted">
<Clock size={13} />
<span>
Прогресс сохраняется автоматически раз в 10 минут.
{lastSavedAt &&
` Сохранено в ${new Date(lastSavedAt).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}.`}
</span>
</div>
<div className="flex items-center gap-2">
{saveError && (
<span className="text-danger">
{saveError}{" "}
<Link href="/wallet" className="underline">
Пополнить
</Link>
</span>
)}
<button
onClick={() => save(true)}
disabled={saving || !hasProgress || balance < MANUAL_SAVE_COST}
title={!hasProgress ? "Пока нечего сохранять" : undefined}
className="btn btn-ghost"
>
<Save size={14} />
Сохранить сейчас — {MANUAL_SAVE_COST} монет (баланс: {balance})
</button>
</div>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import type { CoinPackage } from "@/lib/wallet/economy";
export function WalletPurchasePanel({ packages }: { packages: CoinPackage[] }) {
const [loadingId, setLoadingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleBuy(packageId: string) {
setLoadingId(packageId);
setError(null);
const res = await fetch("/api/wallet/purchase", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packageId }),
});
setLoadingId(null);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось начать оплату");
return;
}
const data = await res.json();
window.location.assign(data.redirectUrl);
}
return (
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Купить монеты</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{packages.map((pkg) => (
<button
key={pkg.id}
onClick={() => handleBuy(pkg.id)}
disabled={loadingId !== null}
className="btn btn-ghost flex flex-col items-center gap-1 py-4"
>
<span className="text-lg font-bold text-text">{pkg.coins} монет</span>
<span className="text-sm text-text-muted">{pkg.priceRub} ₽</span>
</button>
))}
</div>
{error && <p className="mt-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
</div>
);
}
@@ -0,0 +1,41 @@
CREATE TABLE `coin_purchases` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`coins` integer NOT NULL,
`price_rub` integer NOT NULL,
`provider` text,
`status` text DEFAULT 'pending' NOT NULL,
`provider_payment_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`completed_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `coin_transactions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`amount` integer NOT NULL,
`reason` text NOT NULL,
`related_game_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`related_game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `game_progress` (
`id` text PRIMARY KEY NOT NULL,
`game_id` text NOT NULL,
`user_id` text NOT NULL,
`state` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `game_progress_game_user_unique` ON `game_progress` (`game_id`,`user_id`);--> statement-breakpoint
CREATE TABLE `wallets` (
`user_id` text PRIMARY KEY NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,576 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2da73b1a-5598-47e5-beef-ce9e8f1db0c5",
"prevId": "ef845fe8-d266-400e-9a8c-264d90d11b26",
"tables": {
"ai_config": {
"name": "ai_config",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"anthropic_api_key_enc": {
"name": "anthropic_api_key_enc",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'claude-sonnet-5'"
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"verified_at": {
"name": "verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_purchases": {
"name": "coin_purchases",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"coins": {
"name": "coins",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"price_rub": {
"name": "price_rub",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"provider_payment_id": {
"name": "provider_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"coin_purchases_user_id_users_id_fk": {
"name": "coin_purchases_user_id_users_id_fk",
"tableFrom": "coin_purchases",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"coin_transactions": {
"name": "coin_transactions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount": {
"name": "amount",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"related_game_id": {
"name": "related_game_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"coin_transactions_user_id_users_id_fk": {
"name": "coin_transactions_user_id_users_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"coin_transactions_related_game_id_games_id_fk": {
"name": "coin_transactions_related_game_id_games_id_fk",
"tableFrom": "coin_transactions",
"tableTo": "games",
"columnsFrom": [
"related_game_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"game_progress": {
"name": "game_progress",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"game_id": {
"name": "game_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"game_progress_game_user_unique": {
"name": "game_progress_game_user_unique",
"columns": [
"game_id",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"game_progress_game_id_games_id_fk": {
"name": "game_progress_game_id_games_id_fk",
"tableFrom": "game_progress",
"tableTo": "games",
"columnsFrom": [
"game_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"game_progress_user_id_users_id_fk": {
"name": "game_progress_user_id_users_id_fk",
"tableFrom": "game_progress",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"games": {
"name": "games",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"template_type": {
"name": "template_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'draft'"
},
"definition": {
"name": "definition",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"play_count": {
"name": "play_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"games_owner_id_users_id_fk": {
"name": "games_owner_id_users_id_fk",
"tableFrom": "games",
"tableTo": "users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"wallets": {
"name": "wallets",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"wallets_user_id_users_id_fk": {
"name": "wallets_user_id_users_id_fk",
"tableFrom": "wallets",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1785518228143,
"tag": "0000_marvelous_tyrannus",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1785534147942,
"tag": "0001_fearless_proudstar",
"breakpoints": true
}
]
}
+54 -1
View File
@@ -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" }),
});
+9
View File
@@ -26,3 +26,12 @@ export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
{ name: "Автокликер", cost: 50, pointsPerClickBonus: 0, pointsPerSecondBonus: 1 },
],
};
export const clickerProgressSchema = z.object({
score: z.number().min(0),
pointsPerClick: z.number().min(0),
pointsPerSecond: z.number().min(0),
ownedUpgradeIndices: z.array(z.number().int().min(0)).default([]),
});
export type ClickerProgress = z.infer<typeof clickerProgressSchema>;
+9
View File
@@ -71,3 +71,12 @@ export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
timeLimitSec: 0,
};
export const mazeProgressSchema = z.object({
playerRow: z.number().int().min(0),
playerCol: z.number().int().min(0),
collectedItemKeys: z.array(z.string()).default([]),
remainingMs: z.number().int().min(0),
});
export type MazeProgress = z.infer<typeof mazeProgressSchema>;
+34
View File
@@ -0,0 +1,34 @@
import { eq, and } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { gameProgress } from "@/lib/db/schema";
import { safeParseProgress, type TemplateType, type GameProgress } from "./types";
export async function getProgress(gameId: string, userId: string): Promise<GameProgress | null> {
const row = await db.query.gameProgress.findFirst({
where: and(eq(gameProgress.gameId, gameId), eq(gameProgress.userId, userId)),
});
if (!row) return null;
const templateType = await getTemplateTypeForGame(gameId);
if (!templateType) return null;
const parsed = safeParseProgress(templateType, JSON.parse(row.state));
return parsed.success ? parsed.data : null;
}
export async function saveProgress(gameId: string, userId: string, state: GameProgress): Promise<void> {
await db
.insert(gameProgress)
.values({ gameId, userId, state: JSON.stringify(state) })
.onConflictDoUpdate({
target: [gameProgress.gameId, gameProgress.userId],
set: { state: JSON.stringify(state), updatedAt: new Date() },
});
}
async function getTemplateTypeForGame(gameId: string): Promise<TemplateType | null> {
const game = await db.query.games.findFirst({
where: (g, { eq: eqOp }) => eqOp(g.id, gameId),
columns: { templateType: true },
});
return game?.templateType ?? null;
}
+7
View File
@@ -24,3 +24,10 @@ export const DEFAULT_QUIZ_DEFINITION: QuizDefinition = {
questions: [{ question: "Сколько будет 2 + 2?", options: ["3", "4", "5"], correctIndex: 1 }],
timePerQuestionSec: 0,
};
export const quizProgressSchema = z.object({
questionIndex: z.number().int().min(0),
score: z.number().int().min(0),
});
export type QuizProgress = z.infer<typeof quizProgressSchema>;
+19 -5
View File
@@ -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}/сек`,
);
+39 -4
View File
@@ -1,5 +1,5 @@
import Phaser from "phaser";
import type { MazeDefinition, GameResult } from "../types";
import type { MazeDefinition, MazeProgress, GameResult } from "../types";
const COLORS = {
bg: 0x0a0b14,
@@ -13,6 +13,7 @@ const COLORS = {
export class MazeScene extends Phaser.Scene {
private definition!: MazeDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: MazeProgress) => void;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private playerPos = { row: 0, col: 0 };
private items = new Set<string>();
@@ -22,6 +23,7 @@ export class MazeScene extends Phaser.Scene {
private offsetX = 0;
private offsetY = 0;
private totalItems = 0;
private collectedItemKeys: string[] = [];
private scoreText!: Phaser.GameObjects.Text;
private timerText?: Phaser.GameObjects.Text;
private remainingMs = 0;
@@ -34,9 +36,14 @@ export class MazeScene extends Phaser.Scene {
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
this.finished = false;
this.cameras.main.setBackgroundColor(COLORS.bg);
const initialState = this.registry.get("initialState") as MazeProgress | undefined;
const alreadyCollected = new Set(initialState?.collectedItemKeys ?? []);
this.collectedItemKeys = [...alreadyCollected];
const grid = this.definition.grid;
const rows = grid.length;
const cols = grid[0].length;
@@ -62,15 +69,29 @@ export class MazeScene extends Phaser.Scene {
this.add.rectangle(x, y, this.cellSize - 1, this.cellSize - 1, ch === "E" ? COLORS.exit : COLORS.floor);
if (ch === "S") this.playerPos = { row: r, col: c };
if (ch === "*") {
const key = `${r},${c}`;
this.items.add(key);
this.totalItems += 1;
const key = `${r},${c}`;
if (alreadyCollected.has(key)) continue;
this.items.add(key);
const item = this.add.rectangle(x, y, this.cellSize * 0.4, this.cellSize * 0.4, COLORS.item);
this.itemSprites.set(key, item);
}
}
}
// Resume position only if it's still a valid, non-wall cell — the
// definition may have changed since this progress was saved.
if (
initialState &&
initialState.playerRow >= 0 &&
initialState.playerRow < rows &&
initialState.playerCol >= 0 &&
initialState.playerCol < cols &&
grid[initialState.playerRow][initialState.playerCol] !== "#"
) {
this.playerPos = { row: initialState.playerRow, col: initialState.playerCol };
}
const px = this.offsetX + this.playerPos.col * this.cellSize + this.cellSize / 2;
const py = this.offsetY + this.playerPos.row * this.cellSize + this.cellSize / 2;
this.player = this.add.circle(px, py, this.cellSize * 0.35, COLORS.player);
@@ -80,7 +101,11 @@ export class MazeScene extends Phaser.Scene {
.setOrigin(0.5, 0);
if (this.definition.timeLimitSec > 0) {
this.remainingMs = this.definition.timeLimitSec * 1000;
const savedRemaining = initialState?.remainingMs;
this.remainingMs =
savedRemaining !== undefined
? Math.min(Math.max(savedRemaining, 0), this.definition.timeLimitSec * 1000)
: this.definition.timeLimitSec * 1000;
this.timerText = this.add
.text(480, 66, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#a3a8c6" })
.setOrigin(0.5, 0);
@@ -136,6 +161,7 @@ export class MazeScene extends Phaser.Scene {
this.items.delete(key);
this.itemSprites.get(key)?.destroy();
this.itemSprites.delete(key);
this.collectedItemKeys.push(key);
this.updateHud();
}
@@ -152,6 +178,15 @@ export class MazeScene extends Phaser.Scene {
if (this.timerText) {
this.timerText.setText(`Осталось: ${Math.ceil(this.remainingMs / 1000)} сек`);
}
if (!this.finished) {
this.onProgress?.({
playerRow: this.playerPos.row,
playerCol: this.playerPos.col,
collectedItemKeys: this.collectedItemKeys,
remainingMs: this.remainingMs,
});
}
}
private finish(won: boolean, message?: string) {
+10 -3
View File
@@ -1,5 +1,5 @@
import Phaser from "phaser";
import type { QuizDefinition, GameResult } from "../types";
import type { QuizDefinition, QuizProgress, GameResult } from "../types";
const COLORS = {
bg: 0x14162a,
@@ -14,6 +14,7 @@ const COLORS = {
export class QuizScene extends Phaser.Scene {
private definition!: QuizDefinition;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: QuizProgress) => void;
private questionIndex = 0;
private score = 0;
private locked = false;
@@ -31,8 +32,13 @@ export class QuizScene extends Phaser.Scene {
create() {
this.definition = this.registry.get("definition");
this.onFinish = this.registry.get("onFinish");
this.questionIndex = 0;
this.score = 0;
this.onProgress = this.registry.get("onProgress");
const initialState = this.registry.get("initialState") as QuizProgress | undefined;
const totalQuestions = this.definition.questions.length;
this.questionIndex =
initialState && initialState.questionIndex < totalQuestions ? initialState.questionIndex : 0;
this.score = initialState?.score ?? 0;
this.cameras.main.setBackgroundColor(COLORS.bg);
this.questionText = this.add
@@ -126,6 +132,7 @@ export class QuizScene extends Phaser.Scene {
this.time.delayedCall(700, () => {
this.questionIndex += 1;
this.onProgress?.({ questionIndex: this.questionIndex, score: this.score });
if (this.questionIndex >= this.definition.questions.length) {
const total = this.definition.questions.length;
this.onFinish?.({
+20 -9
View File
@@ -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;
+23
View File
@@ -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;
}
+15
View File
@@ -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 },
];
+105
View File
@@ -0,0 +1,105 @@
import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { wallets, coinTransactions } from "@/lib/db/schema";
export type CoinReason = "signup_bonus" | "manual_save" | "purchase";
export interface WalletDTO {
userId: string;
balance: number;
}
export interface CoinTransactionDTO {
id: string;
amount: number;
reason: CoinReason;
relatedGameId: string | null;
createdAt: number;
}
export async function getOrCreateWallet(userId: string): Promise<WalletDTO> {
const existing = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
if (existing) return existing;
db.insert(wallets).values({ userId, balance: 0 }).onConflictDoNothing().run();
const created = db.select().from(wallets).where(eq(wallets.userId, userId)).get();
return created!;
}
export async function getBalance(userId: string): Promise<number> {
const wallet = await getOrCreateWallet(userId);
return wallet.balance;
}
export async function listRecentTransactions(userId: string, limit = 20): Promise<CoinTransactionDTO[]> {
const rows = await db.query.coinTransactions.findMany({
where: eq(coinTransactions.userId, userId),
orderBy: desc(coinTransactions.createdAt),
limit,
});
return rows.map((row) => ({
id: row.id,
amount: row.amount,
reason: row.reason,
relatedGameId: row.relatedGameId,
createdAt: row.createdAt.getTime(),
}));
}
/**
* Credits coins and logs the ledger entry atomically. The better-sqlite3
* driver's db.transaction() callback must stay fully synchronous — no
* await inside — or the transaction commits before the awaited work
* finishes; every query here uses the sync .get()/.run() methods.
*/
export async function grantCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<void> {
await getOrCreateWallet(userId);
db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
tx.update(wallets)
.set({ balance: balance + amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount, reason, relatedGameId: relatedGameId ?? null })
.run();
});
}
/**
* Debits coins and logs the ledger entry atomically — returns false without
* writing anything if the balance is insufficient. This is the one place
* correctness really matters: the balance check and the debit happen inside
* the same transaction, so two concurrent spends can't both pass the check
* against a stale balance.
*/
export async function spendCoins(
userId: string,
amount: number,
reason: CoinReason,
relatedGameId?: string,
): Promise<boolean> {
await getOrCreateWallet(userId);
return db.transaction((tx) => {
const wallet = tx.select().from(wallets).where(eq(wallets.userId, userId)).get();
const balance = wallet?.balance ?? 0;
if (balance < amount) return false;
tx.update(wallets)
.set({ balance: balance - amount, updatedAt: new Date() })
.where(eq(wallets.userId, userId))
.run();
tx.insert(coinTransactions)
.values({ userId, amount: -amount, reason, relatedGameId: relatedGameId ?? null })
.run();
return true;
});
}