diff --git a/src/app/api/games/[id]/complete/route.ts b/src/app/api/games/[id]/complete/route.ts
new file mode 100644
index 0000000..fbcf9de
--- /dev/null
+++ b/src/app/api/games/[id]/complete/route.ts
@@ -0,0 +1,27 @@
+export const runtime = "nodejs";
+
+import { NextResponse } from "next/server";
+import { requireSession } from "@/lib/auth/require";
+import { getGameById } from "@/lib/games/service";
+import { claimCompletionReward } from "@/lib/prestige/service";
+import { crystalsForCompletion, type ClickerDefinition } from "@/lib/games/clicker";
+
+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 });
+ }
+ if (game.templateType !== "clicker") {
+ return NextResponse.json({ error: "Completion rewards are only for Clicker dungeons" }, { status: 400 });
+ }
+
+ const definition = game.definition as ClickerDefinition;
+ const reward = crystalsForCompletion(definition.targetLevel);
+ const result = await claimCompletionReward(session.user.id, id, reward);
+
+ return NextResponse.json(result);
+}
diff --git a/src/app/api/prestige/route.ts b/src/app/api/prestige/route.ts
new file mode 100644
index 0000000..f8a6d55
--- /dev/null
+++ b/src/app/api/prestige/route.ts
@@ -0,0 +1,12 @@
+export const runtime = "nodejs";
+
+import { NextResponse } from "next/server";
+import { requireSession } from "@/lib/auth/require";
+import { getPrestige } from "@/lib/prestige/service";
+
+export async function GET() {
+ const { session, response } = await requireSession();
+ if (!session) return response;
+
+ return NextResponse.json(await getPrestige(session.user.id));
+}
diff --git a/src/app/api/prestige/upgrade/route.ts b/src/app/api/prestige/upgrade/route.ts
new file mode 100644
index 0000000..788d577
--- /dev/null
+++ b/src/app/api/prestige/upgrade/route.ts
@@ -0,0 +1,26 @@
+export const runtime = "nodejs";
+
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { requireSession } from "@/lib/auth/require";
+import { buyPrestigeUpgrade, getPrestige } from "@/lib/prestige/service";
+
+const upgradeSchema = z.object({ kind: z.enum(["click", "gold"]) });
+
+export async function POST(request: Request) {
+ const { session, response } = await requireSession();
+ if (!session) return response;
+
+ const body = await request.json().catch(() => null);
+ const parsed = upgradeSchema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Invalid input" }, { status: 400 });
+ }
+
+ const ok = await buyPrestigeUpgrade(session.user.id, parsed.data.kind);
+ if (!ok) {
+ return NextResponse.json({ error: "Недостаточно кристаллов" }, { status: 402 });
+ }
+
+ return NextResponse.json(await getPrestige(session.user.id));
+}
diff --git a/src/app/play/[gameId]/page.tsx b/src/app/play/[gameId]/page.tsx
index 4b19f44..4d031a1 100644
--- a/src/app/play/[gameId]/page.tsx
+++ b/src/app/play/[gameId]/page.tsx
@@ -2,6 +2,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 { getPrestige } from "@/lib/prestige/service";
import { getCurrentSession } from "@/lib/auth/session";
import { TEMPLATE_LABELS } from "@/lib/games/types";
import { PlaySession } from "@/components/play-session";
@@ -29,6 +30,9 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
? await Promise.all([getProgress(gameId, session.user.id), getBalance(session.user.id)])
: [null, 0];
+ const accountBonuses =
+ session && game.templateType === "clicker" ? await getPrestige(session.user.id) : undefined;
+
return (
@@ -46,6 +50,7 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
definition={game.definition}
initialState={initialState ?? undefined}
initialBalance={initialBalance}
+ accountBonuses={accountBonuses}
canSave={Boolean(session)}
/>
diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx
new file mode 100644
index 0000000..ca01275
--- /dev/null
+++ b/src/app/shop/page.tsx
@@ -0,0 +1,22 @@
+import { redirect } from "next/navigation";
+import { getCurrentSession } from "@/lib/auth/session";
+import { getPrestige } from "@/lib/prestige/service";
+import { PrestigeShopPanel } from "@/components/prestige-shop-panel";
+
+export default async function ShopPage() {
+ const session = await getCurrentSession();
+ if (!session) redirect("/login");
+
+ const prestige = await getPrestige(session.user.id);
+
+ return (
+
+
Магазин престижа
+
+ Кристаллы зарабатываются за прохождение кликер-подземелий до конца — отдельная валюта от монет в кошельке,
+ тратится на постоянные улучшения на аккаунте.
+
+
+
+ );
+}
diff --git a/src/components/builders/clicker-builder.tsx b/src/components/builders/clicker-builder.tsx
index b86e9a8..4e42863 100644
--- a/src/components/builders/clicker-builder.tsx
+++ b/src/components/builders/clicker-builder.tsx
@@ -3,7 +3,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, Save } from "lucide-react";
-import { clickerDefinitionSchema, DEFAULT_CLICKER_DEFINITION, type ClickerDefinition, type ClickerHero } from "@/lib/games/clicker";
+import {
+ clickerDefinitionSchema,
+ DEFAULT_CLICKER_DEFINITION,
+ heroRoleLabel,
+ type ClickerDefinition,
+ type ClickerHero,
+} from "@/lib/games/clicker";
export function ClickerBuilder({
mode,
@@ -189,40 +195,43 @@ export function ClickerBuilder({
Герои
{heroes.map((hero, index) => (
-
-
updateHero(index, { name: e.target.value })}
- placeholder="Название"
- className="rounded-md border border-border bg-surface px-3 py-1.5 text-sm outline-none focus:border-accent"
- />
-
updateHero(index, { baseCost: Number(e.target.value) })}
- title="Базовая цена"
- className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
- />
-
updateHero(index, { clickDamageBonus: Number(e.target.value) })}
- title="Урон за клик за уровень"
- className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
- />
-
updateHero(index, { dpsBonus: Number(e.target.value) })}
- title="Урон в секунду за уровень"
- className="w-20 rounded-md border border-border bg-surface px-2 py-1.5 text-sm outline-none focus:border-accent"
- />
-
removeHero(index)} className="text-text-faint hover:text-danger">
-
-
+
))}
diff --git a/src/components/game-canvas.tsx b/src/components/game-canvas.tsx
index d30e31e..495b715 100644
--- a/src/components/game-canvas.tsx
+++ b/src/components/game-canvas.tsx
@@ -3,17 +3,20 @@
import { useEffect, useRef } from "react";
import type Phaser from "phaser";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
+import type { ClickerAccountBonuses } from "@/lib/games/clicker";
export function GameCanvas({
templateType,
definition,
initialState,
+ accountBonuses,
onFinish,
onProgress,
}: {
templateType: TemplateType;
definition: GameDefinition;
initialState?: GameProgress;
+ accountBonuses?: ClickerAccountBonuses;
onFinish?: (result: GameResult) => void;
onProgress?: (state: GameProgress) => void;
}) {
@@ -47,6 +50,7 @@ export function GameCanvas({
game.registry.set("definition", definition);
game.registry.set("initialState", initialState);
+ game.registry.set("accountBonuses", accountBonuses);
game.registry.set("onFinish", onFinish);
game.registry.set("onProgress", onProgress);
})();
diff --git a/src/components/header.tsx b/src/components/header.tsx
index f424ec0..5ce883e 100644
--- a/src/components/header.tsx
+++ b/src/components/header.tsx
@@ -1,12 +1,15 @@
import Link from "next/link";
-import { Gamepad2, Coins } from "lucide-react";
+import { Gamepad2, Coins, Gem } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getBalance } from "@/lib/wallet/service";
+import { getPrestige } from "@/lib/prestige/service";
import { LogoutButton } from "./logout-button";
export async function Header() {
const session = await getCurrentSession();
- const balance = session ? await getBalance(session.user.id) : null;
+ const [balance, prestige] = session
+ ? await Promise.all([getBalance(session.user.id), getPrestige(session.user.id)])
+ : [null, null];
return (
@@ -29,6 +32,14 @@ export async function Header() {
{balance}
+
+
+ {prestige?.crystals ?? 0}
+
{session.user.name}
>
diff --git a/src/components/play-session.tsx b/src/components/play-session.tsx
index 2757c83..3698268 100644
--- a/src/components/play-session.tsx
+++ b/src/components/play-session.tsx
@@ -2,10 +2,11 @@
import { useEffect, useRef, useState, useCallback } from "react";
import Link from "next/link";
-import { RotateCcw, Trophy, XCircle, Save, Clock } from "lucide-react";
+import { RotateCcw, Trophy, XCircle, Save, Clock, Gem } from "lucide-react";
import { GameCanvas } from "./game-canvas";
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
+import type { ClickerAccountBonuses } from "@/lib/games/clicker";
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
@@ -15,6 +16,7 @@ export function PlaySession({
definition,
initialState,
initialBalance,
+ accountBonuses,
canSave,
}: {
gameId: string;
@@ -22,10 +24,12 @@ export function PlaySession({
definition: GameDefinition;
initialState?: GameProgress;
initialBalance: number;
+ accountBonuses?: ClickerAccountBonuses;
canSave: boolean;
}) {
const [attempt, setAttempt] = useState(0);
const [result, setResult] = useState(null);
+ const [crystalsAwarded, setCrystalsAwarded] = useState(null);
const [balance, setBalance] = useState(initialBalance);
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
const [lastSavedAt, setLastSavedAt] = useState(() => (initialState ? Date.now() : null));
@@ -77,6 +81,22 @@ export function PlaySession({
return () => clearInterval(interval);
}, [canSave, save]);
+ useEffect(() => {
+ if (!result?.won || !canSave || templateType !== "clicker") return;
+ let cancelled = false;
+
+ fetch(`/api/games/${gameId}/complete`, { method: "POST" })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data) => {
+ if (!cancelled && data?.claimed) setCrystalsAwarded(data.crystalsAwarded);
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ }, [result, gameId, templateType, canSave]);
+
return (
@@ -85,6 +105,7 @@ export function PlaySession({
templateType={templateType}
definition={definition}
initialState={initialState}
+ accountBonuses={accountBonuses}
onFinish={setResult}
onProgress={canSave ? handleProgress : undefined}
/>
@@ -99,10 +120,16 @@ export function PlaySession({
)}
{result.won ? "Победа!" : "Игра окончена"}
{result.message &&
{result.message}
}
+ {crystalsAwarded !== null && (
+
+ +{crystalsAwarded} кристаллов престижа
+
+ )}
{
setResult(null);
+ setCrystalsAwarded(null);
setAttempt((a) => a + 1);
}}
className="btn btn-primary"
diff --git a/src/components/prestige-shop-panel.tsx b/src/components/prestige-shop-panel.tsx
new file mode 100644
index 0000000..e8075c0
--- /dev/null
+++ b/src/components/prestige-shop-panel.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { useState } from "react";
+import { Gem, MousePointerClick, Coins as CoinsIcon } from "lucide-react";
+import { PRESTIGE_BONUS_PER_LEVEL, prestigeUpgradeCostAtLevel } from "@/lib/games/clicker";
+
+interface Prestige {
+ crystals: number;
+ clickBonusLevel: number;
+ goldBonusLevel: number;
+}
+
+const UPGRADES: {
+ kind: "click" | "gold";
+ title: string;
+ description: string;
+ icon: typeof MousePointerClick;
+}[] = [
+ {
+ kind: "click",
+ title: "Сила клика",
+ description: "Постоянный бонус к урону за клик во всех кликер-играх на вашем аккаунте.",
+ icon: MousePointerClick,
+ },
+ {
+ kind: "gold",
+ title: "Удача старателя",
+ description: "Постоянный бонус к золоту с побеждённых монстров во всех кликер-играх.",
+ icon: CoinsIcon,
+ },
+];
+
+export function PrestigeShopPanel({ initialPrestige }: { initialPrestige: Prestige }) {
+ const [prestige, setPrestige] = useState(initialPrestige);
+ const [loading, setLoading] = useState(null);
+ const [error, setError] = useState(null);
+
+ async function handleBuy(kind: "click" | "gold") {
+ setLoading(kind);
+ setError(null);
+
+ const res = await fetch("/api/prestige/upgrade", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ kind }),
+ });
+
+ setLoading(null);
+ if (!res.ok) {
+ const data = await res.json().catch(() => null);
+ setError(data?.error ?? "Не удалось купить улучшение");
+ return;
+ }
+
+ setPrestige(await res.json());
+ }
+
+ return (
+
+
+
+
+
+
+
{prestige.crystals}
+
кристаллов престижа
+
+
+
+ {error &&
{error}
}
+
+ {UPGRADES.map(({ kind, title, description, icon: Icon }) => {
+ const level = kind === "click" ? prestige.clickBonusLevel : prestige.goldBonusLevel;
+ const cost = prestigeUpgradeCostAtLevel(level);
+ const bonusPct = Math.round(level * PRESTIGE_BONUS_PER_LEVEL * 100);
+ const affordable = prestige.crystals >= cost;
+
+ return (
+
+
+
+
+
+
+ {title} — уровень {level} (+{bonusPct}%)
+
+
{description}
+
+
handleBuy(kind)}
+ disabled={loading !== null || !affordable}
+ className="btn btn-primary shrink-0"
+ >
+
+ {cost}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/lib/db/migrations/0002_stale_lucky_pierre.sql b/src/lib/db/migrations/0002_stale_lucky_pierre.sql
new file mode 100644
index 0000000..42d0bf8
--- /dev/null
+++ b/src/lib/db/migrations/0002_stale_lucky_pierre.sql
@@ -0,0 +1,20 @@
+CREATE TABLE `prestige` (
+ `user_id` text PRIMARY KEY NOT NULL,
+ `crystals` integer DEFAULT 0 NOT NULL,
+ `click_bonus_level` integer DEFAULT 0 NOT NULL,
+ `gold_bonus_level` 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
+);
+--> statement-breakpoint
+CREATE TABLE `prestige_claims` (
+ `id` text PRIMARY KEY NOT NULL,
+ `user_id` text NOT NULL,
+ `game_id` text NOT NULL,
+ `crystals_awarded` integer NOT NULL,
+ `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 (`game_id`) REFERENCES `games`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `prestige_claims_user_game_unique` ON `prestige_claims` (`user_id`,`game_id`);
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/0002_snapshot.json b/src/lib/db/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..aebd731
--- /dev/null
+++ b/src/lib/db/migrations/meta/0002_snapshot.json
@@ -0,0 +1,721 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "5562a6c0-8876-47fe-8d5c-b8d76083c13a",
+ "prevId": "2da73b1a-5598-47e5-beef-ce9e8f1db0c5",
+ "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": {}
+ },
+ "prestige": {
+ "name": "prestige",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "crystals": {
+ "name": "crystals",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "click_bonus_level": {
+ "name": "click_bonus_level",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "gold_bonus_level": {
+ "name": "gold_bonus_level",
+ "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": {
+ "prestige_user_id_users_id_fk": {
+ "name": "prestige_user_id_users_id_fk",
+ "tableFrom": "prestige",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "prestige_claims": {
+ "name": "prestige_claims",
+ "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
+ },
+ "game_id": {
+ "name": "game_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "crystals_awarded": {
+ "name": "crystals_awarded",
+ "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": {
+ "prestige_claims_user_game_unique": {
+ "name": "prestige_claims_user_game_unique",
+ "columns": [
+ "user_id",
+ "game_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "prestige_claims_user_id_users_id_fk": {
+ "name": "prestige_claims_user_id_users_id_fk",
+ "tableFrom": "prestige_claims",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "prestige_claims_game_id_games_id_fk": {
+ "name": "prestige_claims_game_id_games_id_fk",
+ "tableFrom": "prestige_claims",
+ "tableTo": "games",
+ "columnsFrom": [
+ "game_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 0d032a0..a03c01a 100644
--- a/src/lib/db/migrations/meta/_journal.json
+++ b/src/lib/db/migrations/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1785534147942,
"tag": "0001_fearless_proudstar",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1785552846668,
+ "tag": "0002_stale_lucky_pierre",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 3d12cba..89b3122 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -114,3 +114,36 @@ export const coinPurchases = sqliteTable("coin_purchases", {
createdAt: timestamps.createdAt,
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
});
+
+/**
+ * A separate, non-real-money currency earned by clearing Clicker
+ * dungeons and spent on permanent account-wide upgrades — deliberately
+ * kept apart from wallets/coinTransactions so clicker play can never be
+ * used to farm the real-money coin economy.
+ */
+export const prestige = sqliteTable("prestige", {
+ userId: text("user_id")
+ .primaryKey()
+ .references(() => users.id, { onDelete: "cascade" }),
+ crystals: integer("crystals").notNull().default(0),
+ clickBonusLevel: integer("click_bonus_level").notNull().default(0),
+ goldBonusLevel: integer("gold_bonus_level").notNull().default(0),
+ updatedAt: timestamps.updatedAt,
+});
+
+/** One row per (user, game) that has ever paid out a completion reward — makes claiming idempotent. */
+export const prestigeClaims = sqliteTable(
+ "prestige_claims",
+ {
+ id: id(),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ gameId: text("game_id")
+ .notNull()
+ .references(() => games.id, { onDelete: "cascade" }),
+ crystalsAwarded: integer("crystals_awarded").notNull(),
+ createdAt: timestamps.createdAt,
+ },
+ (table) => [uniqueIndex("prestige_claims_user_game_unique").on(table.userId, table.gameId)],
+);
diff --git a/src/lib/games/clicker.ts b/src/lib/games/clicker.ts
index 3d2194c..1b0004a 100644
--- a/src/lib/games/clicker.ts
+++ b/src/lib/games/clicker.ts
@@ -13,6 +13,23 @@ export const BOSS_TIME_LIMIT_MS = 20000;
/** Each hero level costs this much more than the last. */
export const HERO_COST_GROWTH = 1.15;
+/** Permanent account-wide prestige upgrades — separate currency from the real coin wallet. */
+export const PRESTIGE_BONUS_PER_LEVEL = 0.05;
+export const PRESTIGE_UPGRADE_BASE_COST = 3;
+export const PRESTIGE_UPGRADE_COST_GROWTH = 1.3;
+
+export function crystalsForCompletion(targetLevel: number): number {
+ return Math.max(1, Math.round(targetLevel / 5));
+}
+
+export function prestigeUpgradeCostAtLevel(currentLevel: number): number {
+ return Math.round(PRESTIGE_UPGRADE_BASE_COST * Math.pow(PRESTIGE_UPGRADE_COST_GROWTH, currentLevel));
+}
+
+export function prestigeMultiplier(level: number): number {
+ return 1 + level * PRESTIGE_BONUS_PER_LEVEL;
+}
+
export const clickerHeroSchema = z.object({
name: z.string().min(1).max(40),
baseCost: z.number().min(1),
@@ -33,6 +50,12 @@ export const clickerDefinitionSchema = z.object({
export type ClickerHero = z.infer;
export type ClickerDefinition = z.infer;
+/** Permanent, account-wide bonuses bought in the prestige shop — passed into the scene via the registry. */
+export interface ClickerAccountBonuses {
+ clickBonusLevel: number;
+ goldBonusLevel: number;
+}
+
export function isBossLevel(level: number): boolean {
return level % BOSS_EVERY === 0;
}
@@ -51,9 +74,19 @@ export function heroCostAtLevel(hero: ClickerHero, currentLevel: number): number
return Math.round(hero.baseCost * Math.pow(HERO_COST_GROWTH, currentLevel));
}
+/** Which kind of damage a hero contributes — derived from its bonuses, never hand-authored. */
+export function heroRoleLabel(hero: Pick): string {
+ const hasClick = hero.clickDamageBonus > 0;
+ const hasDps = hero.dpsBonus > 0;
+ if (hasClick && hasDps) return "⚔🔄 клик + пассивно";
+ if (hasClick) return "⚔ улучшает клик";
+ if (hasDps) return "🔄 бьёт пассивно";
+ return "";
+}
+
export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
theme: "Тёмное подземелье",
- targetLevel: 30,
+ targetLevel: 60,
startingClickDamage: 5,
baseMonsterHp: 20,
monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
@@ -65,6 +98,10 @@ export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
{ name: "Маг", baseCost: 250, clickDamageBonus: 0, dpsBonus: 12 },
{ name: "Паладин", baseCost: 600, clickDamageBonus: 25, dpsBonus: 10 },
{ name: "Дракон", baseCost: 1500, clickDamageBonus: 0, dpsBonus: 60 },
+ { name: "Рыцарь", baseCost: 4000, clickDamageBonus: 60, dpsBonus: 0 },
+ { name: "Некромант", baseCost: 10000, clickDamageBonus: 0, dpsBonus: 200 },
+ { name: "Архангел", baseCost: 25000, clickDamageBonus: 150, dpsBonus: 80 },
+ { name: "Титан", baseCost: 60000, clickDamageBonus: 0, dpsBonus: 500 },
],
};
diff --git a/src/lib/games/scenes/clicker-scene.ts b/src/lib/games/scenes/clicker-scene.ts
index 2eb8dd0..e25b60d 100644
--- a/src/lib/games/scenes/clicker-scene.ts
+++ b/src/lib/games/scenes/clicker-scene.ts
@@ -4,7 +4,10 @@ import {
monsterHpForLevel,
goldForMonster,
heroCostAtLevel,
+ heroRoleLabel,
+ prestigeMultiplier,
BOSS_TIME_LIMIT_MS,
+ type ClickerAccountBonuses,
} from "../clicker";
import type { ClickerDefinition, ClickerProgress, GameResult } from "../types";
@@ -26,11 +29,12 @@ const MONSTER_X = 480;
const MONSTER_Y = 155;
const HP_BAR_Y = 258;
const HP_BAR_WIDTH = 500;
-const HEROES_START_Y = 330;
-const HERO_ROW_GAP = 42;
+const HEROES_START_Y = 308;
+const HERO_ROW_GAP = 28;
export class ClickerScene extends Phaser.Scene {
private definition!: ClickerDefinition;
+ private accountBonuses?: ClickerAccountBonuses;
private onFinish?: (result: GameResult) => void;
private onProgress?: (state: ClickerProgress) => void;
@@ -66,6 +70,7 @@ export class ClickerScene extends Phaser.Scene {
create() {
this.definition = this.registry.get("definition");
+ this.accountBonuses = this.registry.get("accountBonuses");
this.onFinish = this.registry.get("onFinish");
this.onProgress = this.registry.get("onProgress");
@@ -112,11 +117,11 @@ export class ClickerScene extends Phaser.Scene {
this.definition.heroes.forEach((_, index) => {
const y = HEROES_START_Y + index * HERO_ROW_GAP;
- const bg = this.add.rectangle(480, y, 780, 34, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({
+ const bg = this.add.rectangle(480, y, 780, 24, COLORS.heroRow).setStrokeStyle(1, 0x4f46e5).setInteractive({
useHandCursor: true,
});
const label = this.add
- .text(480, y, "", { fontFamily: "sans-serif", fontSize: "14px", color: "#eef0fb" })
+ .text(480, y, "", { fontFamily: "sans-serif", fontSize: "12px", color: "#eef0fb" })
.setOrigin(0.5);
bg.on("pointerdown", () => this.buyHero(index));
this.heroRows.push({ bg, label });
@@ -144,6 +149,9 @@ export class ClickerScene extends Phaser.Scene {
this.clickDamage += lvl * hero.clickDamageBonus;
this.dps += lvl * hero.dpsBonus;
});
+ // Permanent account-wide prestige bonus — click skill only, dps is
+ // purely heroes' doing.
+ this.clickDamage *= prestigeMultiplier(this.accountBonuses?.clickBonusLevel ?? 0);
}
private startLevel() {
@@ -212,7 +220,7 @@ export class ClickerScene extends Phaser.Scene {
const lvl = this.heroLevels[index] ?? 0;
const cost = heroCostAtLevel(hero, lvl);
const affordable = this.gold >= cost;
- label.setText(`${hero.name} • ур.${lvl} → ${cost}💰`);
+ label.setText(`${hero.name} • ур.${lvl} → ${cost}💰 • ${heroRoleLabel(hero)}`);
bg.setFillStyle(affordable ? COLORS.heroRow : COLORS.heroRowUnaffordable);
});
}
@@ -255,7 +263,8 @@ export class ClickerScene extends Phaser.Scene {
}
private onMonsterDefeated() {
- this.gold += goldForMonster(this.monsterMaxHp);
+ const goldMultiplier = prestigeMultiplier(this.accountBonuses?.goldBonusLevel ?? 0);
+ this.gold += Math.round(goldForMonster(this.monsterMaxHp) * goldMultiplier);
this.spawnCoinBurst();
this.refreshHud();
this.refreshHeroPanel();
diff --git a/src/lib/prestige/service.ts b/src/lib/prestige/service.ts
new file mode 100644
index 0000000..a820875
--- /dev/null
+++ b/src/lib/prestige/service.ts
@@ -0,0 +1,91 @@
+import { eq, and } from "drizzle-orm";
+import { db } from "@/lib/db/client";
+import { prestige, prestigeClaims } from "@/lib/db/schema";
+import { prestigeUpgradeCostAtLevel } from "@/lib/games/clicker";
+
+export type PrestigeUpgradeKind = "click" | "gold";
+
+export interface PrestigeDTO {
+ userId: string;
+ crystals: number;
+ clickBonusLevel: number;
+ goldBonusLevel: number;
+}
+
+export async function getOrCreatePrestige(userId: string): Promise {
+ const existing = db.select().from(prestige).where(eq(prestige.userId, userId)).get();
+ if (existing) return existing;
+
+ db.insert(prestige).values({ userId }).onConflictDoNothing().run();
+ const created = db.select().from(prestige).where(eq(prestige.userId, userId)).get();
+ return created!;
+}
+
+export async function getPrestige(userId: string): Promise {
+ return getOrCreatePrestige(userId);
+}
+
+/**
+ * Credits the one-time completion reward for a (user, game) pair,
+ * atomically. Same "check-then-act inside one transaction" shape as
+ * spendCoins in src/lib/wallet/service.ts — checking for an existing claim
+ * and inserting the new one happen inside the same synchronous
+ * db.transaction() callback, so two concurrent claim attempts for the same
+ * game can't both succeed. Returns claimed:false (no-op) if this game was
+ * already claimed by this user.
+ */
+export async function claimCompletionReward(
+ userId: string,
+ gameId: string,
+ crystalsAwarded: number,
+): Promise<{ claimed: boolean; crystalsAwarded: number; crystals: number }> {
+ await getOrCreatePrestige(userId);
+
+ return db.transaction((tx) => {
+ const existingClaim = tx
+ .select()
+ .from(prestigeClaims)
+ .where(and(eq(prestigeClaims.userId, userId), eq(prestigeClaims.gameId, gameId)))
+ .get();
+
+ const current = tx.select().from(prestige).where(eq(prestige.userId, userId)).get();
+ const currentCrystals = current?.crystals ?? 0;
+
+ if (existingClaim) {
+ return { claimed: false, crystalsAwarded: 0, crystals: currentCrystals };
+ }
+
+ tx.insert(prestigeClaims).values({ userId, gameId, crystalsAwarded }).run();
+ tx.update(prestige)
+ .set({ crystals: currentCrystals + crystalsAwarded, updatedAt: new Date() })
+ .where(eq(prestige.userId, userId))
+ .run();
+
+ return { claimed: true, crystalsAwarded, crystals: currentCrystals + crystalsAwarded };
+ });
+}
+
+/**
+ * Buys the next level of a permanent prestige upgrade, atomically —
+ * returns false without writing anything if crystals are insufficient.
+ */
+export async function buyPrestigeUpgrade(userId: string, kind: PrestigeUpgradeKind): Promise {
+ await getOrCreatePrestige(userId);
+
+ return db.transaction((tx) => {
+ const current = tx.select().from(prestige).where(eq(prestige.userId, userId)).get();
+ if (!current) return false;
+
+ const currentLevel = kind === "click" ? current.clickBonusLevel : current.goldBonusLevel;
+ const cost = prestigeUpgradeCostAtLevel(currentLevel);
+ if (current.crystals < cost) return false;
+
+ const patch =
+ kind === "click"
+ ? { crystals: current.crystals - cost, clickBonusLevel: currentLevel + 1, updatedAt: new Date() }
+ : { crystals: current.crystals - cost, goldBonusLevel: currentLevel + 1, updatedAt: new Date() };
+
+ tx.update(prestige).set(patch).where(eq(prestige.userId, userId)).run();
+ return true;
+ });
+}