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" - /> - +
+
+ 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" + /> + +
+ {heroRoleLabel(hero) && {heroRoleLabel(hero)}}
))}
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} кристаллов престижа +

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