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:
ogrechkoandClaude Sonnet 5 committed 2026-08-01 03:04:55 +00:00
1 parent f5833a1988
commit be53908471
17 files changed
+1175 -12

No files matched your search

+27
View File
@@ -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);
}
+12
View File
@@ -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));
}
+26
View File
@@ -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));
}
+5
View File
@@ -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 (
<div className="flex flex-1 flex-col">
<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}
initialState={initialState ?? undefined}
initialBalance={initialBalance}
accountBonuses={accountBonuses}
canSave={Boolean(session)}
/>
</div>
+22
View File
@@ -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>
);
}
+11 -2
View File
@@ -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,7 +195,8 @@ export function ClickerBuilder({
<div className="card flex flex-col gap-3 p-4">
<span className="text-sm font-semibold text-text-muted">Герои</span>
{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
value={hero.name}
onChange={(e) => updateHero(index, { name: e.target.value })}
@@ -224,6 +231,8 @@ export function ClickerBuilder({
<Trash2 size={14} />
</button>
</div>
{heroRoleLabel(hero) && <span className="text-xs text-text-faint">{heroRoleLabel(hero)}</span>}
</div>
))}
<div className="flex gap-4 text-xs text-text-faint">
<span>Цена</span>
+4
View File
@@ -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);
})();
+13 -2
View File
@@ -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 (
<header className="border-b border-border bg-surface">
@@ -29,6 +32,14 @@ export async function Header() {
<Coins size={13} />
{balance}
</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>
<LogoutButton />
</>
+28 -1
View File
@@ -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<GameResult | null>(null);
const [crystalsAwarded, setCrystalsAwarded] = useState<number | null>(null);
const [balance, setBalance] = useState(initialBalance);
const [hasProgress, setHasProgress] = useState(Boolean(initialState));
const [lastSavedAt, setLastSavedAt] = useState<number | null>(() => (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 (
<div className="mx-auto w-full max-w-4xl flex-1 px-4 py-6 sm:px-6">
<div className="relative">
@@ -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({
)}
<h2 className="font-display text-xl font-semibold">{result.won ? "Победа!" : "Игра окончена"}</h2>
{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">
<button
onClick={() => {
setResult(null);
setCrystalsAwarded(null);
setAttempt((a) => a + 1);
}}
className="btn btn-primary"
+102
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}
+33
View File
@@ -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)],
);
+38 -1
View File
@@ -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<typeof clickerHeroSchema>;
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 {
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<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 = {
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 },
],
};
+15 -6
View File
@@ -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();
+91
View File
@@ -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;
});
}