Clicker: more content, hero role labels, prestige shop
More levels (30->60) and heroes (6->10) in the flagship dungeon so the
curve stays climbable at the new range. Each hero row now shows
whether it improves click damage, hits passively, or both — derived
from its existing bonus fields (heroRoleLabel), never hand-authored,
so it can't drift out of sync with what the hero actually does.
New permanent account-wide upgrade shop, funded by a currency
("crystals") earned by clearing clicker dungeons — kept deliberately
separate from the real coin wallet (wallets/coinTransactions, spent on
real-money purchases and manual save), so clicker play can never be
used to farm real-money-equivalent value. Same atomic-transaction
shape as spendCoins in src/lib/wallet/service.ts throughout:
- claimCompletionReward is idempotent per (user, game) via a unique
index — replaying and re-winning the same dungeon can't farm
crystals twice. Verified through the real HTTP API, not just the
service layer: claim, claim again (no-op), confirm balance
unchanged.
- buyPrestigeUpgrade does the same read-check-debit-increment
atomicity check as spendCoins — verified an insufficient-crystal
purchase leaves no partial debit.
- The two upgrades (click damage %, gold gain %) apply automatically
in every clicker game via a new accountBonuses prop threaded through
GameCanvas/PlaySession (same optional-passthrough shape already used
for initialState/onProgress) down into the registry the scene reads.
- /api/games/[id]/complete correctly rejects non-clicker games (400) —
the reward is clicker-specific.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
f5833a1988
commit
be53908471
17 files changed
+1175
-12
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { getGameById, incrementPlayCount } from "@/lib/games/service";
|
import { getGameById, incrementPlayCount } from "@/lib/games/service";
|
||||||
import { getProgress } from "@/lib/games/progress";
|
import { getProgress } from "@/lib/games/progress";
|
||||||
import { getBalance } from "@/lib/wallet/service";
|
import { getBalance } from "@/lib/wallet/service";
|
||||||
|
import { getPrestige } from "@/lib/prestige/service";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { TEMPLATE_LABELS } from "@/lib/games/types";
|
import { TEMPLATE_LABELS } from "@/lib/games/types";
|
||||||
import { PlaySession } from "@/components/play-session";
|
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)])
|
? await Promise.all([getProgress(gameId, session.user.id), getBalance(session.user.id)])
|
||||||
: [null, 0];
|
: [null, 0];
|
||||||
|
|
||||||
|
const accountBonuses =
|
||||||
|
session && game.templateType === "clicker" ? await getPrestige(session.user.id) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
|
<div className="mx-auto w-full max-w-4xl px-4 pt-6 sm:px-6">
|
||||||
@@ -46,6 +50,7 @@ export default async function PlayPage({ params }: { params: Promise<{ gameId: s
|
|||||||
definition={game.definition}
|
definition={game.definition}
|
||||||
initialState={initialState ?? undefined}
|
initialState={initialState ?? undefined}
|
||||||
initialBalance={initialBalance}
|
initialBalance={initialBalance}
|
||||||
|
accountBonuses={accountBonuses}
|
||||||
canSave={Boolean(session)}
|
canSave={Boolean(session)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
<PrestigeShopPanel initialPrestige={prestige} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Trash2, Save } from "lucide-react";
|
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({
|
export function ClickerBuilder({
|
||||||
mode,
|
mode,
|
||||||
@@ -189,7 +195,8 @@ export function ClickerBuilder({
|
|||||||
<div className="card flex flex-col gap-3 p-4">
|
<div className="card flex flex-col gap-3 p-4">
|
||||||
<span className="text-sm font-semibold text-text-muted">Герои</span>
|
<span className="text-sm font-semibold text-text-muted">Герои</span>
|
||||||
{heroes.map((hero, index) => (
|
{heroes.map((hero, index) => (
|
||||||
<div key={index} className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
|
<div key={index} className="flex flex-col gap-1">
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={hero.name}
|
value={hero.name}
|
||||||
onChange={(e) => updateHero(index, { name: e.target.value })}
|
onChange={(e) => updateHero(index, { name: e.target.value })}
|
||||||
@@ -224,6 +231,8 @@ export function ClickerBuilder({
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{heroRoleLabel(hero) && <span className="text-xs text-text-faint">{heroRoleLabel(hero)}</span>}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex gap-4 text-xs text-text-faint">
|
<div className="flex gap-4 text-xs text-text-faint">
|
||||||
<span>Цена</span>
|
<span>Цена</span>
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type Phaser from "phaser";
|
import type Phaser from "phaser";
|
||||||
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
||||||
|
import type { ClickerAccountBonuses } from "@/lib/games/clicker";
|
||||||
|
|
||||||
export function GameCanvas({
|
export function GameCanvas({
|
||||||
templateType,
|
templateType,
|
||||||
definition,
|
definition,
|
||||||
initialState,
|
initialState,
|
||||||
|
accountBonuses,
|
||||||
onFinish,
|
onFinish,
|
||||||
onProgress,
|
onProgress,
|
||||||
}: {
|
}: {
|
||||||
templateType: TemplateType;
|
templateType: TemplateType;
|
||||||
definition: GameDefinition;
|
definition: GameDefinition;
|
||||||
initialState?: GameProgress;
|
initialState?: GameProgress;
|
||||||
|
accountBonuses?: ClickerAccountBonuses;
|
||||||
onFinish?: (result: GameResult) => void;
|
onFinish?: (result: GameResult) => void;
|
||||||
onProgress?: (state: GameProgress) => void;
|
onProgress?: (state: GameProgress) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -47,6 +50,7 @@ export function GameCanvas({
|
|||||||
|
|
||||||
game.registry.set("definition", definition);
|
game.registry.set("definition", definition);
|
||||||
game.registry.set("initialState", initialState);
|
game.registry.set("initialState", initialState);
|
||||||
|
game.registry.set("accountBonuses", accountBonuses);
|
||||||
game.registry.set("onFinish", onFinish);
|
game.registry.set("onFinish", onFinish);
|
||||||
game.registry.set("onProgress", onProgress);
|
game.registry.set("onProgress", onProgress);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import Link from "next/link";
|
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 { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { getBalance } from "@/lib/wallet/service";
|
import { getBalance } from "@/lib/wallet/service";
|
||||||
|
import { getPrestige } from "@/lib/prestige/service";
|
||||||
import { LogoutButton } from "./logout-button";
|
import { LogoutButton } from "./logout-button";
|
||||||
|
|
||||||
export async function Header() {
|
export async function Header() {
|
||||||
const session = await getCurrentSession();
|
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 (
|
return (
|
||||||
<header className="border-b border-border bg-surface">
|
<header className="border-b border-border bg-surface">
|
||||||
@@ -29,6 +32,14 @@ export async function Header() {
|
|||||||
<Coins size={13} />
|
<Coins size={13} />
|
||||||
{balance}
|
{balance}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/shop"
|
||||||
|
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="Кристаллы престижа"
|
||||||
|
>
|
||||||
|
<Gem size={13} />
|
||||||
|
{prestige?.crystals ?? 0}
|
||||||
|
</Link>
|
||||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import Link from "next/link";
|
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 { GameCanvas } from "./game-canvas";
|
||||||
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
|
import { MANUAL_SAVE_COST } from "@/lib/wallet/economy";
|
||||||
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
import type { TemplateType, GameDefinition, GameProgress, GameResult } from "@/lib/games/types";
|
||||||
|
import type { ClickerAccountBonuses } from "@/lib/games/clicker";
|
||||||
|
|
||||||
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
|
const AUTOSAVE_INTERVAL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export function PlaySession({
|
|||||||
definition,
|
definition,
|
||||||
initialState,
|
initialState,
|
||||||
initialBalance,
|
initialBalance,
|
||||||
|
accountBonuses,
|
||||||
canSave,
|
canSave,
|
||||||
}: {
|
}: {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@@ -22,10 +24,12 @@ export function PlaySession({
|
|||||||
definition: GameDefinition;
|
definition: GameDefinition;
|
||||||
initialState?: GameProgress;
|
initialState?: GameProgress;
|
||||||
initialBalance: number;
|
initialBalance: number;
|
||||||
|
accountBonuses?: ClickerAccountBonuses;
|
||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
const [result, setResult] = useState<GameResult | null>(null);
|
const [result, setResult] = useState<GameResult | null>(null);
|
||||||
|
const [crystalsAwarded, setCrystalsAwarded] = useState<number | null>(null);
|
||||||
const [balance, setBalance] = useState(initialBalance);
|
const [balance, setBalance] = useState(initialBalance);
|
||||||
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
|
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
|
||||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(() => (initialState ? Date.now() : null));
|
const [lastSavedAt, setLastSavedAt] = useState<number | null>(() => (initialState ? Date.now() : null));
|
||||||
@@ -77,6 +81,22 @@ export function PlaySession({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [canSave, save]);
|
}, [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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
|
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -85,6 +105,7 @@ export function PlaySession({
|
|||||||
templateType={templateType}
|
templateType={templateType}
|
||||||
definition={definition}
|
definition={definition}
|
||||||
initialState={initialState}
|
initialState={initialState}
|
||||||
|
accountBonuses={accountBonuses}
|
||||||
onFinish={setResult}
|
onFinish={setResult}
|
||||||
onProgress={canSave ? handleProgress : undefined}
|
onProgress={canSave ? handleProgress : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -99,10 +120,16 @@ export function PlaySession({
|
|||||||
)}
|
)}
|
||||||
<h2 className="font-display text-xl font-semibold">{result.won ? "Победа!" : "Игра окончена"}</h2>
|
<h2 className="font-display text-xl font-semibold">{result.won ? "Победа!" : "Игра окончена"}</h2>
|
||||||
{result.message && <p className="text-sm text-text-muted">{result.message}</p>}
|
{result.message && <p className="text-sm text-text-muted">{result.message}</p>}
|
||||||
|
{crystalsAwarded !== null && (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium text-accent-soft-text">
|
||||||
|
<Gem size={14} />+{crystalsAwarded} кристаллов престижа
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setCrystalsAwarded(null);
|
||||||
setAttempt((a) => a + 1);
|
setAttempt((a) => a + 1);
|
||||||
}}
|
}}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
|
|||||||
@@ -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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="card 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">
|
||||||
|
<Gem size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold">{prestige.crystals}</p>
|
||||||
|
<p className="text-sm text-text-muted">кристаллов престижа</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div key={kind} className="card flex items-center gap-4 p-4">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent-soft-text">
|
||||||
|
<Icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{title} <span className="text-text-faint">— уровень {level} (+{bonusPct}%)</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBuy(kind)}
|
||||||
|
disabled={loading !== null || !affordable}
|
||||||
|
className="btn btn-primary shrink-0"
|
||||||
|
>
|
||||||
|
<Gem size={14} />
|
||||||
|
{cost}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1785534147942,
|
"when": 1785534147942,
|
||||||
"tag": "0001_fearless_proudstar",
|
"tag": "0001_fearless_proudstar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785552846668,
|
||||||
|
"tag": "0002_stale_lucky_pierre",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -114,3 +114,36 @@ export const coinPurchases = sqliteTable("coin_purchases", {
|
|||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
|
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)],
|
||||||
|
);
|
||||||
@@ -13,6 +13,23 @@ export const BOSS_TIME_LIMIT_MS = 20000;
|
|||||||
/** Each hero level costs this much more than the last. */
|
/** Each hero level costs this much more than the last. */
|
||||||
export const HERO_COST_GROWTH = 1.15;
|
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({
|
export const clickerHeroSchema = z.object({
|
||||||
name: z.string().min(1).max(40),
|
name: z.string().min(1).max(40),
|
||||||
baseCost: z.number().min(1),
|
baseCost: z.number().min(1),
|
||||||
@@ -33,6 +50,12 @@ export const clickerDefinitionSchema = z.object({
|
|||||||
export type ClickerHero = z.infer<typeof clickerHeroSchema>;
|
export type ClickerHero = z.infer<typeof clickerHeroSchema>;
|
||||||
export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>;
|
export type ClickerDefinition = z.infer<typeof clickerDefinitionSchema>;
|
||||||
|
|
||||||
|
/** 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 {
|
export function isBossLevel(level: number): boolean {
|
||||||
return level % BOSS_EVERY === 0;
|
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));
|
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<ClickerHero, "clickDamageBonus" | "dpsBonus">): 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 = {
|
export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
|
||||||
theme: "Тёмное подземелье",
|
theme: "Тёмное подземелье",
|
||||||
targetLevel: 30,
|
targetLevel: 60,
|
||||||
startingClickDamage: 5,
|
startingClickDamage: 5,
|
||||||
baseMonsterHp: 20,
|
baseMonsterHp: 20,
|
||||||
monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
|
monsterEmojis: ["👹", "👺", "💀", "🧟", "🐗", "🦂", "🐍", "🦇", "👻", "🕷️"],
|
||||||
@@ -65,6 +98,10 @@ export const DEFAULT_CLICKER_DEFINITION: ClickerDefinition = {
|
|||||||
{ name: "Маг", baseCost: 250, clickDamageBonus: 0, dpsBonus: 12 },
|
{ name: "Маг", baseCost: 250, clickDamageBonus: 0, dpsBonus: 12 },
|
||||||
{ name: "Паладин", baseCost: 600, clickDamageBonus: 25, dpsBonus: 10 },
|
{ name: "Паладин", baseCost: 600, clickDamageBonus: 25, dpsBonus: 10 },
|
||||||
{ name: "Дракон", baseCost: 1500, clickDamageBonus: 0, dpsBonus: 60 },
|
{ 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 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {
|
|||||||
monsterHpForLevel,
|
monsterHpForLevel,
|
||||||
goldForMonster,
|
goldForMonster,
|
||||||
heroCostAtLevel,
|
heroCostAtLevel,
|
||||||
|
heroRoleLabel,
|
||||||
|
prestigeMultiplier,
|
||||||
BOSS_TIME_LIMIT_MS,
|
BOSS_TIME_LIMIT_MS,
|
||||||
|
type ClickerAccountBonuses,
|
||||||
} from "../clicker";
|
} from "../clicker";
|
||||||
import type { ClickerDefinition, ClickerProgress, GameResult } from "../types";
|
import type { ClickerDefinition, ClickerProgress, GameResult } from "../types";
|
||||||
|
|
||||||
@@ -26,11 +29,12 @@ const MONSTER_X = 480;
|
|||||||
const MONSTER_Y = 155;
|
const MONSTER_Y = 155;
|
||||||
const HP_BAR_Y = 258;
|
const HP_BAR_Y = 258;
|
||||||
const HP_BAR_WIDTH = 500;
|
const HP_BAR_WIDTH = 500;
|
||||||
const HEROES_START_Y = 330;
|
const HEROES_START_Y = 308;
|
||||||
const HERO_ROW_GAP = 42;
|
const HERO_ROW_GAP = 28;
|
||||||
|
|
||||||
export class ClickerScene extends Phaser.Scene {
|
export class ClickerScene extends Phaser.Scene {
|
||||||
private definition!: ClickerDefinition;
|
private definition!: ClickerDefinition;
|
||||||
|
private accountBonuses?: ClickerAccountBonuses;
|
||||||
private onFinish?: (result: GameResult) => void;
|
private onFinish?: (result: GameResult) => void;
|
||||||
private onProgress?: (state: ClickerProgress) => void;
|
private onProgress?: (state: ClickerProgress) => void;
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.definition = this.registry.get("definition");
|
this.definition = this.registry.get("definition");
|
||||||
|
this.accountBonuses = this.registry.get("accountBonuses");
|
||||||
this.onFinish = this.registry.get("onFinish");
|
this.onFinish = this.registry.get("onFinish");
|
||||||
this.onProgress = this.registry.get("onProgress");
|
this.onProgress = this.registry.get("onProgress");
|
||||||
|
|
||||||
@@ -112,11 +117,11 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
|
|
||||||
this.definition.heroes.forEach((_, index) => {
|
this.definition.heroes.forEach((_, index) => {
|
||||||
const y = HEROES_START_Y + index * HERO_ROW_GAP;
|
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,
|
useHandCursor: true,
|
||||||
});
|
});
|
||||||
const label = this.add
|
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);
|
.setOrigin(0.5);
|
||||||
bg.on("pointerdown", () => this.buyHero(index));
|
bg.on("pointerdown", () => this.buyHero(index));
|
||||||
this.heroRows.push({ bg, label });
|
this.heroRows.push({ bg, label });
|
||||||
@@ -144,6 +149,9 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
this.clickDamage += lvl * hero.clickDamageBonus;
|
this.clickDamage += lvl * hero.clickDamageBonus;
|
||||||
this.dps += lvl * hero.dpsBonus;
|
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() {
|
private startLevel() {
|
||||||
@@ -212,7 +220,7 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
const lvl = this.heroLevels[index] ?? 0;
|
const lvl = this.heroLevels[index] ?? 0;
|
||||||
const cost = heroCostAtLevel(hero, lvl);
|
const cost = heroCostAtLevel(hero, lvl);
|
||||||
const affordable = this.gold >= cost;
|
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);
|
bg.setFillStyle(affordable ? COLORS.heroRow : COLORS.heroRowUnaffordable);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,7 +263,8 @@ export class ClickerScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private onMonsterDefeated() {
|
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.spawnCoinBurst();
|
||||||
this.refreshHud();
|
this.refreshHud();
|
||||||
this.refreshHeroPanel();
|
this.refreshHeroPanel();
|
||||||
|
|||||||
@@ -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<PrestigeDTO> {
|
||||||
|
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<PrestigeDTO> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user