initial commit
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Node.js / Next.js backend
|
||||||
|
backend/node_modules/
|
||||||
|
backend/.next/
|
||||||
|
backend/out/
|
||||||
|
backend/data/
|
||||||
|
backend/.env
|
||||||
|
backend/.env.local
|
||||||
|
backend/.env.production
|
||||||
|
backend/next-env.d.ts
|
||||||
|
|
||||||
|
# Dota 2 custom game compiled files
|
||||||
|
*.vpcf_c
|
||||||
|
*.vxml_c
|
||||||
|
*.vsndevts_c
|
||||||
|
*.vtex_c
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
data
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Stage 1: Install deps
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache python3 make g++ gcc
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Stage 2: Build
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production runner
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV DB_PATH=/app/data/zombie_invasion.db
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
|
adduser --system --uid 1001 --shell /bin/sh nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/docker-entrypoint.sh ./
|
||||||
|
|
||||||
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/sh", "docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: registry.achmad.dev/dota-zombie-invasion:latest
|
||||||
|
pull_policy: always
|
||||||
|
ports:
|
||||||
|
- "6100:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/admin/check"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "6100:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- ADMIN_PASSWORD=admin123
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data
|
||||||
|
chown -R 1001:1001 /app/data
|
||||||
|
exec su -c "exec node server.js" nextjs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
module.exports = nextConfig;
|
||||||
Generated
+2095
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "zombie-invasion-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3000"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"better-sqlite3": "^12.0.0",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"autoprefixer": "^10.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function ArsenalPage() {
|
||||||
|
const [data, setData] = useState<any>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/arsenal').then(r => r.json()).then(setData);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Arsenal</h1>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Inventory ({data.inventory?.length || 0})</h2>
|
||||||
|
<div className="max-h-96 overflow-y-auto text-sm space-y-1">
|
||||||
|
{data.inventory?.map((i: any, idx: number) => (
|
||||||
|
<div key={idx} className="text-gray-300 p-1 border-b border-gray-700 last:border-0">
|
||||||
|
<span className="text-amber-300">{i.item_name}</span>
|
||||||
|
<span className="text-gray-500 ml-2">[{i.quality}]</span>
|
||||||
|
<div className="text-xs text-gray-500">{i.steam_id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.inventory?.length && <p className="text-gray-500 text-sm">None</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Loadouts ({data.loadouts?.length || 0})</h2>
|
||||||
|
<div className="max-h-96 overflow-y-auto text-sm space-y-1">
|
||||||
|
{data.loadouts?.map((l: any, idx: number) => (
|
||||||
|
<div key={idx} className="text-gray-300 p-1 border-b border-gray-700">
|
||||||
|
<span className="font-mono text-xs">{l.steam_id}</span>
|
||||||
|
<span className="text-amber-300 ml-2">{l.hero_name}</span>
|
||||||
|
<div className="text-xs text-gray-500">{l.loadout}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.loadouts?.length && <p className="text-gray-500 text-sm">None</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Active Listings ({data.listings?.length || 0})</h2>
|
||||||
|
<div className="max-h-96 overflow-y-auto text-sm space-y-1">
|
||||||
|
{data.listings?.map((l: any, idx: number) => (
|
||||||
|
<div key={idx} className="text-gray-300 p-1 border-b border-gray-700">
|
||||||
|
<span className="text-amber-300">{l.item_name}</span>
|
||||||
|
<span className="text-gray-500 ml-2">{l.price_free} free</span>
|
||||||
|
<div className="text-xs text-gray-500">{l.steam_id}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.listings?.length && <p className="text-gray-500 text-sm">None</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function BattlePassDetailPage() {
|
||||||
|
const { steamId } = useParams();
|
||||||
|
const [bp, setBp] = useState<any>(null);
|
||||||
|
const [quests, setQuests] = useState<any[]>([]);
|
||||||
|
const [editLevel, setEditLevel] = useState(0);
|
||||||
|
const [editXp, setEditXp] = useState(0);
|
||||||
|
const [editPremium, setEditPremium] = useState(false);
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
fetch(`/api/admin/battlepass/${steamId}`).then(r => r.json()).then(d => {
|
||||||
|
setBp(d.battlePass);
|
||||||
|
setQuests(d.quests || []);
|
||||||
|
setEditLevel(d.battlePass?.level || 0);
|
||||||
|
setEditXp(d.battlePass?.experience || 0);
|
||||||
|
setEditPremium(d.battlePass?.has_premium === 1);
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
useEffect(load, [steamId]);
|
||||||
|
|
||||||
|
const saveBp = async () => {
|
||||||
|
const res = await fetch(`/api/admin/battlepass/${steamId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ level: editLevel, experience: editXp, has_premium: editPremium }),
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
setMsg(d.success ? 'Saved!' : 'Error');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!bp) return <div className="text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<a href="/admin/battlepass" className="text-amber-400 mb-4 block">← Back</a>
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Battle Pass</h1>
|
||||||
|
<p className="font-mono text-sm text-gray-400 mb-4">Steam ID: {steamId}</p>
|
||||||
|
{msg && <p className="mb-4 text-sm text-green-400">{msg}</p>}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Settings</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Level</label>
|
||||||
|
<input type="number" value={editLevel} onChange={e => setEditLevel(Number(e.target.value))}
|
||||||
|
className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">XP</label>
|
||||||
|
<input type="number" value={editXp} onChange={e => setEditXp(Number(e.target.value))}
|
||||||
|
className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Premium</label>
|
||||||
|
<input type="checkbox" checked={editPremium} onChange={e => setEditPremium(e.target.checked)}
|
||||||
|
className="mt-2 block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={saveBp} className="mt-4 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold text-sm">Save BP</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Quests ({quests.length})</h2>
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{quests.map((q: any) => (
|
||||||
|
<div key={q.id} className="bg-gray-700 rounded p-3 text-sm flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-amber-300">{q.name || q.quest_id}</div>
|
||||||
|
<div className="text-gray-400 text-xs">{q.type} — {q.progress}/{q.target}</div>
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className={q.completed ? 'text-green-400' : 'text-yellow-400'}>
|
||||||
|
{q.completed ? 'Completed' : 'In Progress'}
|
||||||
|
</span>
|
||||||
|
{q.claimed ? ' — Claimed' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function BattlePassListPage() {
|
||||||
|
const [bps, setBps] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/battlepass').then(r => r.json()).then(setBps).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Battle Passes</h1>
|
||||||
|
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-sm text-gray-400">
|
||||||
|
<th className="p-3">Steam ID</th>
|
||||||
|
<th className="p-3">Level</th>
|
||||||
|
<th className="p-3">XP</th>
|
||||||
|
<th className="p-3">Premium</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bps.map(bp => (
|
||||||
|
<tr key={bp.steam_id} className="border-b border-gray-700 hover:bg-gray-750">
|
||||||
|
<td className="p-3">
|
||||||
|
<Link href={`/admin/battlepass/${bp.steam_id}`} className="text-amber-400 font-mono text-sm">{bp.steam_id}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{bp.level}</td>
|
||||||
|
<td className="p-3">{bp.experience}</td>
|
||||||
|
<td className="p-3">{bp.has_premium ? 'Yes' : 'No'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function ContractsPage() {
|
||||||
|
const [contracts, setContracts] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/contracts').then(r => r.json()).then(setContracts);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Death Sentence Contracts</h1>
|
||||||
|
<div className="bg-gray-800 rounded-lg">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-gray-400">
|
||||||
|
<th className="p-3">Steam ID</th><th className="p-3">Contract Data</th><th className="p-3">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contracts.map((c: any) => (
|
||||||
|
<tr key={c.steam_id} className="border-b border-gray-700">
|
||||||
|
<td className="p-3 font-mono text-xs">{c.steam_id}</td>
|
||||||
|
<td className="p-3 text-xs max-w-md truncate">{c.contracts}</td>
|
||||||
|
<td className="p-3 text-xs text-gray-500">{c.updated_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ href: '/admin', label: 'Dashboard' },
|
||||||
|
{ href: '/admin/players', label: 'Players' },
|
||||||
|
{ href: '/admin/battlepass', label: 'Battle Pass' },
|
||||||
|
{ href: '/admin/matches', label: 'Matches' },
|
||||||
|
{ href: '/admin/promocodes', label: 'Promo Codes' },
|
||||||
|
{ href: '/admin/store', label: 'Store' },
|
||||||
|
{ href: '/admin/contracts', label: 'Contracts' },
|
||||||
|
{ href: '/admin/arsenal', label: 'Arsenal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [authed, setAuthed] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === '/admin/login') {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch('/api/admin/check')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.authenticated) setAuthed(true);
|
||||||
|
else router.push('/admin/login');
|
||||||
|
})
|
||||||
|
.catch(() => router.push('/admin/login'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [pathname, router]);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-gray-400">Loading...</div>;
|
||||||
|
if (pathname === '/admin/login') return <>{children}</>;
|
||||||
|
if (!authed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 text-gray-100 flex">
|
||||||
|
<nav className="w-56 bg-gray-800 p-4 flex flex-col gap-1 shrink-0">
|
||||||
|
<h1 className="text-lg font-bold mb-4 px-3 text-amber-400">Zombie Admin</h1>
|
||||||
|
{NAV.map(item => (
|
||||||
|
<a
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`px-3 py-2 rounded hover:bg-gray-700 transition-colors ${
|
||||||
|
pathname === item.href || pathname.startsWith(item.href + '/') ? 'bg-gray-700 text-amber-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<div className="mt-auto pt-4">
|
||||||
|
<a href="/api/admin/logout" className="px-3 py-2 text-red-400 hover:text-red-300 block text-sm">Logout</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className="flex-1 p-6 overflow-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
const res = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) router.push('/admin');
|
||||||
|
else setError(data.error || 'Login failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-lg w-80">
|
||||||
|
<h1 className="text-2xl font-bold mb-6 text-amber-400">Admin Login</h1>
|
||||||
|
{error && <p className="text-red-400 mb-4 text-sm">{error}</p>}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
className="w-full px-3 py-2 bg-gray-700 rounded mb-4 text-white"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="submit" className="w-full bg-amber-500 hover:bg-amber-600 text-black font-semibold py-2 rounded">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function MatchesPage() {
|
||||||
|
const [matches, setMatches] = useState<any[]>([]);
|
||||||
|
const [heroFilter, setHeroFilter] = useState('');
|
||||||
|
const [diffFilter, setDiffFilter] = useState('');
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (heroFilter) params.set('hero', heroFilter);
|
||||||
|
if (diffFilter) params.set('difficulty', diffFilter);
|
||||||
|
fetch(`/api/admin/matches?${params}`).then(r => r.json()).then(setMatches).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [heroFilter, diffFilter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Match History</h1>
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<input type="text" placeholder="Filter by hero..." value={heroFilter} onChange={e => setHeroFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-gray-800 rounded text-white text-sm" />
|
||||||
|
<select value={diffFilter} onChange={e => setDiffFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-gray-800 rounded text-white text-sm">
|
||||||
|
{['', 'easy', 'normal', 'hard', 'impossible', 'death_sentence'].map(d =>
|
||||||
|
<option key={d} value={d}>{d || 'All Difficulties'}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-800 rounded-lg overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-gray-400">
|
||||||
|
<th className="p-2">Steam ID</th><th className="p-2">Hero</th><th className="p-2">Result</th>
|
||||||
|
<th className="p-2">Difficulty</th><th className="p-2">K/D</th><th className="p-2">Duration</th>
|
||||||
|
<th className="p-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{matches.map((m: any) => (
|
||||||
|
<tr key={m.id} className="border-b border-gray-700">
|
||||||
|
<td className="p-2 font-mono text-xs">{m.steam_id}</td>
|
||||||
|
<td className="p-2">{m.hero}</td>
|
||||||
|
<td className={`p-2 font-semibold ${m.result === 'win' ? 'text-green-400' : 'text-red-400'}`}>{m.result}</td>
|
||||||
|
<td className="p-2">{m.difficulty}</td>
|
||||||
|
<td className="p-2">{m.kills}/{m.deaths}</td>
|
||||||
|
<td className="p-2">{Math.floor((m.duration || 0) / 60)}m</td>
|
||||||
|
<td className="p-2 text-gray-400 text-xs">{m.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Stats = { players: number; games: number; activeBps: number; questsCompleted: number };
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/stats').then(r => r.json()).then(setStats).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!stats) return <div className="text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: 'Players', value: stats.players, color: 'text-blue-400' },
|
||||||
|
{ label: 'Games Played', value: stats.games, color: 'text-green-400' },
|
||||||
|
{ label: 'Active Battle Passes', value: stats.activeBps, color: 'text-amber-400' },
|
||||||
|
{ label: 'Quests Completed', value: stats.questsCompleted, color: 'text-purple-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{cards.map(c => (
|
||||||
|
<div key={c.label} className="bg-gray-800 rounded-lg p-5">
|
||||||
|
<div className="text-sm text-gray-400 mb-1">{c.label}</div>
|
||||||
|
<div className={`text-3xl font-bold ${c.color}`}>{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function PlayerDetailPage() {
|
||||||
|
const { steamId } = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [form, setForm] = useState<any>({});
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/admin/players/${steamId}`).then(r => r.json()).then(d => {
|
||||||
|
setData(d);
|
||||||
|
setForm(d.player || {});
|
||||||
|
}).catch(() => router.push('/admin/players'));
|
||||||
|
}, [steamId, router]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const res = await fetch(`/api/admin/players/${steamId}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
setMsg(result.success ? 'Saved!' : 'Error: ' + (result.error || ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data) return <div className="text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<a href="/admin/players" className="text-amber-400 mb-4 block">← Back to Players</a>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Player: {data.player?.player_name}</h1>
|
||||||
|
<p className="font-mono text-sm text-gray-400 mb-4">Steam ID: {steamId}</p>
|
||||||
|
{msg && <p className="mb-4 text-sm" style={{ color: msg.startsWith('Saved') ? '#4ade80' : '#f87171' }}>{msg}</p>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Profile</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency'].map(f => (
|
||||||
|
<div key={f}>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">{f}</label>
|
||||||
|
<input type={f === 'player_name' ? 'text' : 'number'}
|
||||||
|
value={(form as any)[f] ?? ''}
|
||||||
|
onChange={e => setForm({ ...form, [f]: e.target.value })}
|
||||||
|
className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={save} className="mt-4 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold">Save</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Battle Pass</h2>
|
||||||
|
{data.battlePass && (
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<p>Level: {data.battlePass.level}</p>
|
||||||
|
<p>XP: {data.battlePass.experience}</p>
|
||||||
|
<p>Premium: {data.battlePass.has_premium ? 'Yes' : 'No'}</p>
|
||||||
|
<a href={`/admin/battlepass/${steamId}`} className="text-amber-400 block mt-2">Edit BP →</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Recent Purchases ({data.purchases?.length})</h2>
|
||||||
|
<div className="max-h-48 overflow-y-auto text-sm space-y-1">
|
||||||
|
{data.purchases?.map((p: any, i: number) => (
|
||||||
|
<div key={i} className="text-gray-300">{p.item_id} <span className="text-gray-500">({p.item_category})</span></div>
|
||||||
|
))}
|
||||||
|
{!data.purchases?.length && <p className="text-gray-500">None</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Recent Matches ({data.matches?.length})</h2>
|
||||||
|
<div className="max-h-48 overflow-y-auto text-sm space-y-1">
|
||||||
|
{data.matches?.map((m: any, i: number) => (
|
||||||
|
<div key={i} className="text-gray-300">
|
||||||
|
{m.hero} — <span className={m.result === 'win' ? 'text-green-400' : 'text-red-400'}>{m.result}</span>
|
||||||
|
<span className="text-gray-500"> ({m.difficulty})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.matches?.length && <p className="text-gray-500">None</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function PlayersListPage() {
|
||||||
|
const [players, setPlayers] = useState<any[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/players').then(r => r.json()).then(setPlayers).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = players.filter(p =>
|
||||||
|
p.steam_id?.includes(search) || p.player_name?.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Players</h1>
|
||||||
|
<input type="text" placeholder="Search by steam_id or name..." value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="w-full max-w-md px-3 py-2 bg-gray-800 rounded mb-4 text-white" />
|
||||||
|
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-sm text-gray-400">
|
||||||
|
<th className="p-3">Steam ID</th>
|
||||||
|
<th className="p-3">Name</th>
|
||||||
|
<th className="p-3">Level</th>
|
||||||
|
<th className="p-3">Free</th>
|
||||||
|
<th className="p-3">Donate</th>
|
||||||
|
<th className="p-3">Dust</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(p => (
|
||||||
|
<tr key={p.steam_id} className="border-b border-gray-700 hover:bg-gray-750 cursor-pointer"
|
||||||
|
onClick={() => window.location.href = `/admin/players/${p.steam_id}`}>
|
||||||
|
<td className="p-3 font-mono text-sm">{p.steam_id}</td>
|
||||||
|
<td className="p-3">{p.player_name}</td>
|
||||||
|
<td className="p-3">{p.profile_level}</td>
|
||||||
|
<td className="p-3">{p.free_currency}</td>
|
||||||
|
<td className="p-3">{p.donate_currency}</td>
|
||||||
|
<td className="p-3">{p.dust_currency}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function PromoCodesPage() {
|
||||||
|
const [codes, setCodes] = useState<any[]>([]);
|
||||||
|
const [form, setForm] = useState({ code: '', free_currency: 0, donate_currency: 0, dust_currency: 0, max_uses: 1 });
|
||||||
|
|
||||||
|
const load = () => { fetch('/api/admin/promocodes').then(r => r.json()).then(setCodes); };
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
await fetch('/api/admin/promocodes', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form),
|
||||||
|
});
|
||||||
|
setForm({ code: '', free_currency: 0, donate_currency: 0, dust_currency: 0, max_uses: 1 });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (code: string) => {
|
||||||
|
await fetch(`/api/admin/promocodes?code=${code}`, { method: 'DELETE' });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Promo Codes</h1>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg p-4 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Create Code</h2>
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
<input type="text" placeholder="CODE" value={form.code} onChange={e => setForm({...form, code: e.target.value})}
|
||||||
|
className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
<input type="number" placeholder="Free" value={form.free_currency} onChange={e => setForm({...form, free_currency: Number(e.target.value)})}
|
||||||
|
className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
<input type="number" placeholder="Donate" value={form.donate_currency} onChange={e => setForm({...form, donate_currency: Number(e.target.value)})}
|
||||||
|
className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
<input type="number" placeholder="Dust" value={form.dust_currency} onChange={e => setForm({...form, dust_currency: Number(e.target.value)})}
|
||||||
|
className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
<input type="number" placeholder="Max uses" value={form.max_uses} onChange={e => setForm({...form, max_uses: Number(e.target.value)})}
|
||||||
|
className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={create} className="mt-3 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold text-sm">Create</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-gray-400">
|
||||||
|
<th className="p-3">Code</th><th className="p-3">Free</th><th className="p-3">Donate</th>
|
||||||
|
<th className="p-3">Dust</th><th className="p-3">Uses</th><th className="p-3">Expires</th>
|
||||||
|
<th className="p-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{codes.map(c => (
|
||||||
|
<tr key={c.code} className="border-b border-gray-700">
|
||||||
|
<td className="p-3 font-mono">{c.code}</td>
|
||||||
|
<td className="p-3">{c.free_currency}</td>
|
||||||
|
<td className="p-3">{c.donate_currency}</td>
|
||||||
|
<td className="p-3">{c.dust_currency}</td>
|
||||||
|
<td className="p-3">{c.current_uses}/{c.max_uses}</td>
|
||||||
|
<td className="p-3 text-xs">{c.expires_at || 'Never'}</td>
|
||||||
|
<td className="p-3"><button onClick={() => del(c.code)} className="text-red-400 hover:text-red-300 text-xs">Delete</button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function StorePage() {
|
||||||
|
const [purchases, setPurchases] = useState<any[]>([]);
|
||||||
|
const [effects, setEffects] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/store').then(r => r.json()).then(d => {
|
||||||
|
setPurchases(d.purchases || []);
|
||||||
|
setEffects(d.effects || []);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Store Purchases</h1>
|
||||||
|
<div className="bg-gray-800 rounded-lg overflow-x-auto max-h-[70vh] overflow-y-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-gray-400">
|
||||||
|
<th className="p-2">Player</th><th className="p-2">Item</th><th className="p-2">Category</th>
|
||||||
|
<th className="p-2">Cost</th><th className="p-2">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{purchases.map((p: any) => (
|
||||||
|
<tr key={p.id} className="border-b border-gray-700">
|
||||||
|
<td className="p-2 font-mono text-xs">{p.player_name || p.steam_id}</td>
|
||||||
|
<td className="p-2">{p.item_id}</td>
|
||||||
|
<td className="p-2 text-gray-400">{p.item_category}</td>
|
||||||
|
<td className="p-2">{p.price_free || p.price_donate || p.price_dust || 0}</td>
|
||||||
|
<td className="p-2 text-xs text-gray-500">{p.created_at}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Active Effects</h1>
|
||||||
|
<div className="bg-gray-800 rounded-lg">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-700 text-gray-400">
|
||||||
|
<th className="p-3">Steam ID</th><th className="p-3">Effects</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{effects.map((e: any) => (
|
||||||
|
<tr key={e.steam_id} className="border-b border-gray-700">
|
||||||
|
<td className="p-3 font-mono text-xs">{e.steam_id}</td>
|
||||||
|
<td className="p-3 text-xs">{e.effects}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { dispatch } from '@/lib/router';
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Import all handlers to register their routes
|
||||||
|
import '@/lib/handlers/player';
|
||||||
|
import '@/lib/handlers/battlepass';
|
||||||
|
import '@/lib/handlers/game';
|
||||||
|
import '@/lib/handlers/payments';
|
||||||
|
import '@/lib/handlers/leaderboard';
|
||||||
|
import '@/lib/handlers/cards';
|
||||||
|
import '@/lib/handlers/equipment';
|
||||||
|
import '@/lib/handlers/arsenal';
|
||||||
|
import '@/lib/handlers/marketplace';
|
||||||
|
import '@/lib/handlers/contracts';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return dispatch(request, params.path, 'GET');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return dispatch(request, params.path, 'POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { path: string[] } }) {
|
||||||
|
return dispatch(request, params.path, 'PUT');
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const inventory = db.prepare('SELECT * FROM arsenal_inventory ORDER BY steam_id').all();
|
||||||
|
const loadouts = db.prepare('SELECT * FROM arsenal_loadouts ORDER BY steam_id').all();
|
||||||
|
const listings = db.prepare("SELECT * FROM arsenal_market_listings WHERE status = 'active' ORDER BY created_at DESC").all();
|
||||||
|
return NextResponse.json({ inventory, loadouts, listings });
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { steamId: string } }) {
|
||||||
|
const db = getDb();
|
||||||
|
const battlePass = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(params.steamId);
|
||||||
|
const quests = db.prepare('SELECT * FROM battle_pass_quests WHERE steam_id = ? ORDER BY id').all(params.steamId);
|
||||||
|
return NextResponse.json({ battlePass, quests });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { steamId: string } }) {
|
||||||
|
const body = await request.json();
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('UPDATE battle_passes SET level = ?, experience = ?, has_premium = ?, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(body.level, body.experience, body.has_premium ? 1 : 0, params.steamId);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const bps = db.prepare('SELECT * FROM battle_passes ORDER BY updated_at DESC').all();
|
||||||
|
return NextResponse.json(bps);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const store = cookies();
|
||||||
|
const authed = store.get('admin_session')?.value === 'authenticated';
|
||||||
|
return NextResponse.json({ authenticated: !!authed });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const contracts = db.prepare('SELECT * FROM death_sentence_contracts').all();
|
||||||
|
return NextResponse.json(contracts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const { password } = await request.json();
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin';
|
||||||
|
console.log('[AdminLogin] attempt', { providedLength: password?.length, expectedLength: ADMIN_PASSWORD.length, match: password === ADMIN_PASSWORD });
|
||||||
|
if (password !== ADMIN_PASSWORD) {
|
||||||
|
console.log('[AdminLogin] failed: password mismatch');
|
||||||
|
return NextResponse.json({ success: false, error: 'Invalid password' }, { status: 401 });
|
||||||
|
}
|
||||||
|
const secure = request.nextUrl.protocol === 'https:' || request.headers.get('x-forwarded-proto') === 'https';
|
||||||
|
console.log('[AdminLogin] success, setting cookie', { secure, protocol: request.nextUrl.protocol, forwardedProto: request.headers.get('x-forwarded-proto') });
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.set('admin_session', 'authenticated', {
|
||||||
|
httpOnly: true, secure, sameSite: 'lax', path: '/', maxAge: 86400,
|
||||||
|
});
|
||||||
|
console.log('[AdminLogin] response cookie header:', response.headers.get('set-cookie'));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.delete('admin_session');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const db = getDb();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const hero = searchParams.get('hero');
|
||||||
|
const difficulty = searchParams.get('difficulty');
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '100');
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM game_history WHERE 1=1';
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (hero) { query += ' AND hero LIKE ?'; params.push(`%${hero}%`); }
|
||||||
|
if (difficulty) { query += ' AND difficulty = ?'; params.push(difficulty); }
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const matches = db.prepare(query).all(...params);
|
||||||
|
return NextResponse.json(matches);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: { steamId: string } }) {
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(params.steamId);
|
||||||
|
if (!player) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(params.steamId);
|
||||||
|
const purchases = db.prepare('SELECT * FROM purchases WHERE steam_id = ? ORDER BY created_at DESC LIMIT 50').all(params.steamId);
|
||||||
|
const matches = db.prepare('SELECT * FROM game_history WHERE steam_id = ? ORDER BY created_at DESC LIMIT 20').all(params.steamId);
|
||||||
|
const effects = db.prepare('SELECT effects FROM active_effects WHERE steam_id = ?').get(params.steamId) as any;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
player, battlePass: bp, purchases, matches,
|
||||||
|
activeEffects: effects ? JSON.parse(effects.effects) : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { steamId: string } }) {
|
||||||
|
const body = await request.json();
|
||||||
|
const db = getDb();
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
for (const key of ['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency']) {
|
||||||
|
if (body[key] !== undefined) {
|
||||||
|
fields.push(`${key} = ?`);
|
||||||
|
values.push(body[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body.sounds_wheel !== undefined) {
|
||||||
|
fields.push('sounds_wheel = ?');
|
||||||
|
values.push(JSON.stringify(body.sounds_wheel));
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
||||||
|
|
||||||
|
fields.push("updated_at = datetime('now')");
|
||||||
|
values.push(params.steamId);
|
||||||
|
db.prepare(`UPDATE players SET ${fields.join(', ')} WHERE steam_id = ?`).run(...values);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const players = db.prepare('SELECT * FROM players ORDER BY updated_at DESC LIMIT 100').all();
|
||||||
|
return NextResponse.json(players);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const codes = db.prepare('SELECT * FROM promo_codes ORDER BY code').all();
|
||||||
|
return NextResponse.json(codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.json();
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('INSERT OR REPLACE INTO promo_codes (code, free_currency, donate_currency, dust_currency, max_uses, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(body.code?.toUpperCase(), body.free_currency || 0, body.donate_currency || 0, body.dust_currency || 0, body.max_uses || 1, body.expires_at || null);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
if (!code) return NextResponse.json({ error: 'code required' }, { status: 400 });
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('DELETE FROM promo_codes WHERE code = ?').run(code.toUpperCase());
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const players = (db.prepare('SELECT COUNT(*) as c FROM players').get() as any).c;
|
||||||
|
const games = (db.prepare('SELECT COUNT(*) as c FROM game_history').get() as any).c;
|
||||||
|
const activeBps = (db.prepare('SELECT COUNT(*) as c FROM battle_passes').get() as any).c;
|
||||||
|
const questsCompleted = (db.prepare('SELECT COUNT(*) as c FROM battle_pass_quests WHERE completed = 1').get() as any).c;
|
||||||
|
|
||||||
|
return NextResponse.json({ players, games, activeBps, questsCompleted });
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
const purchases = db.prepare(`
|
||||||
|
SELECT p.*, pl.player_name FROM purchases p
|
||||||
|
LEFT JOIN players pl ON p.steam_id = pl.steam_id
|
||||||
|
ORDER BY p.created_at DESC LIMIT 200
|
||||||
|
`).all();
|
||||||
|
const effects = db.prepare('SELECT * FROM active_effects').all();
|
||||||
|
return NextResponse.json({ purchases, effects });
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Zombie Invasion Backend',
|
||||||
|
};
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/admin');
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import path from 'path';
|
||||||
|
import { seedDatabase } from './seed';
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), 'data', 'zombie_invasion.db');
|
||||||
|
|
||||||
|
let db: Database.Database;
|
||||||
|
|
||||||
|
export function getDb(): Database.Database {
|
||||||
|
if (!db) {
|
||||||
|
const fs = require('fs');
|
||||||
|
const dir = path.dirname(DB_PATH);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
initSchema(db);
|
||||||
|
seedDatabase();
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSchema(db: Database.Database) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
profile_level INTEGER DEFAULT 1,
|
||||||
|
free_currency INTEGER DEFAULT 0,
|
||||||
|
donate_currency INTEGER DEFAULT 0,
|
||||||
|
dust_currency INTEGER DEFAULT 0,
|
||||||
|
arcade_pack_credits TEXT DEFAULT '{"standard":0,"premium":0}',
|
||||||
|
sounds_wheel TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_sessions (
|
||||||
|
game_id TEXT PRIMARY KEY,
|
||||||
|
match_id INTEGER,
|
||||||
|
session_id TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
steam_id TEXT NOT NULL,
|
||||||
|
game_id TEXT,
|
||||||
|
match_id INTEGER,
|
||||||
|
result TEXT,
|
||||||
|
hero TEXT,
|
||||||
|
hero_level INTEGER,
|
||||||
|
difficulty TEXT,
|
||||||
|
duration INTEGER,
|
||||||
|
kills INTEGER DEFAULT 0,
|
||||||
|
deaths INTEGER DEFAULT 0,
|
||||||
|
score INTEGER DEFAULT 0,
|
||||||
|
outgoing_damage REAL DEFAULT 0,
|
||||||
|
incoming_damage REAL DEFAULT 0,
|
||||||
|
items TEXT,
|
||||||
|
modifiers TEXT,
|
||||||
|
aghanim_scepter INTEGER DEFAULT 0,
|
||||||
|
aghanim_shard INTEGER DEFAULT 0,
|
||||||
|
gold_earned INTEGER DEFAULT 0,
|
||||||
|
session_id TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS battle_passes (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
level INTEGER DEFAULT 0,
|
||||||
|
experience INTEGER DEFAULT 0,
|
||||||
|
has_premium INTEGER DEFAULT 0,
|
||||||
|
claimed_rewards TEXT DEFAULT '[]',
|
||||||
|
claimed_premium_rewards TEXT DEFAULT '[]',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS battle_pass_quests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
steam_id TEXT NOT NULL,
|
||||||
|
quest_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
progress INTEGER DEFAULT 0,
|
||||||
|
target INTEGER DEFAULT 1,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
claimed INTEGER DEFAULT 0,
|
||||||
|
reward_exp INTEGER DEFAULT 0,
|
||||||
|
reward_free_currency INTEGER DEFAULT 0,
|
||||||
|
quality TEXT,
|
||||||
|
npc TEXT,
|
||||||
|
target_item TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
steam_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
item_category TEXT,
|
||||||
|
card_id INTEGER,
|
||||||
|
price_free INTEGER DEFAULT 0,
|
||||||
|
price_donate INTEGER DEFAULT 0,
|
||||||
|
price_dust INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS active_effects (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
effects TEXT DEFAULT '{}',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promo_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
free_currency INTEGER DEFAULT 0,
|
||||||
|
donate_currency INTEGER DEFAULT 0,
|
||||||
|
dust_currency INTEGER DEFAULT 0,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
current_uses INTEGER DEFAULT 0,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS promo_redemptions (
|
||||||
|
steam_id TEXT,
|
||||||
|
code TEXT,
|
||||||
|
redeemed_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (steam_id, code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_levels (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
card_levels TEXT DEFAULT '{}',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS decks (
|
||||||
|
steam_id TEXT,
|
||||||
|
deck_index INTEGER,
|
||||||
|
name TEXT DEFAULT 'My Deck',
|
||||||
|
cards TEXT DEFAULT '[]',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (steam_id, deck_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS equipment (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
equipment TEXT DEFAULT '{}',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arsenal_loadouts (
|
||||||
|
steam_id TEXT,
|
||||||
|
hero_name TEXT,
|
||||||
|
loadout TEXT DEFAULT '{}',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (steam_id, hero_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arsenal_inventory (
|
||||||
|
steam_id TEXT,
|
||||||
|
instance_id TEXT,
|
||||||
|
item_name TEXT,
|
||||||
|
quality TEXT,
|
||||||
|
upgrade_level INTEGER DEFAULT 0,
|
||||||
|
serial INTEGER,
|
||||||
|
global_serial INTEGER,
|
||||||
|
owner_name TEXT,
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
favorite INTEGER DEFAULT 0,
|
||||||
|
stats TEXT DEFAULT '[]',
|
||||||
|
PRIMARY KEY (steam_id, instance_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arsenal_market_listings (
|
||||||
|
listing_id TEXT PRIMARY KEY,
|
||||||
|
steam_id TEXT NOT NULL,
|
||||||
|
instance_id TEXT,
|
||||||
|
item_name TEXT,
|
||||||
|
quality TEXT,
|
||||||
|
upgrade_level INTEGER DEFAULT 0,
|
||||||
|
serial INTEGER,
|
||||||
|
global_serial INTEGER,
|
||||||
|
price_free INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS arsenal_market_sales (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
listing_id TEXT,
|
||||||
|
seller_steam_id TEXT,
|
||||||
|
buyer_steam_id TEXT,
|
||||||
|
item_name TEXT,
|
||||||
|
price_free INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS death_sentence_contracts (
|
||||||
|
steam_id TEXT PRIMARY KEY,
|
||||||
|
contracts TEXT DEFAULT '{}',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_loadouts', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.prepare('SELECT * FROM arsenal_loadouts WHERE steam_id = ?').all(ctx.params.steamId);
|
||||||
|
const loadouts: Record<string, any> = {};
|
||||||
|
for (const r of rows as any[]) {
|
||||||
|
loadouts[r.hero_name] = JSON.parse(r.loadout);
|
||||||
|
}
|
||||||
|
return { arsenal_loadouts: loadouts };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_loadouts', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { arsenal_loadouts } = ctx.body as any;
|
||||||
|
if (!arsenal_loadouts) throw new HttpError(400, 'arsenal_loadouts required');
|
||||||
|
const db = getDb();
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO arsenal_loadouts (steam_id, hero_name, loadout, updated_at) VALUES (?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id, hero_name) DO UPDATE SET loadout = ?, updated_at = datetime('now')
|
||||||
|
`);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const [hero, loadout] of Object.entries(arsenal_loadouts)) {
|
||||||
|
upsert.run(ctx.params.steamId, hero, JSON.stringify(loadout), JSON.stringify(loadout));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_inventory', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const items = db.prepare('SELECT * FROM arsenal_inventory WHERE steam_id = ?').all(ctx.params.steamId);
|
||||||
|
const instances: Record<string, any> = {};
|
||||||
|
for (const item of items as any[]) {
|
||||||
|
instances[item.instance_id] = {
|
||||||
|
instanceId: item.instance_id,
|
||||||
|
itemName: item.item_name,
|
||||||
|
quality: item.quality,
|
||||||
|
upgradeLevel: item.upgrade_level,
|
||||||
|
serial: item.serial,
|
||||||
|
globalSerial: item.global_serial,
|
||||||
|
ownerName: item.owner_name,
|
||||||
|
pinned: !!item.pinned,
|
||||||
|
favorite: !!item.favorite,
|
||||||
|
stats: JSON.parse(item.stats || '[]'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { arsenal_inventory: { instances } };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_inventory', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { arsenal_inventory } = ctx.body as any;
|
||||||
|
if (!arsenal_inventory || !arsenal_inventory.instances) throw new HttpError(400, 'arsenal_inventory.instances required');
|
||||||
|
const db = getDb();
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO arsenal_inventory (steam_id, instance_id, item_name, quality, upgrade_level, serial, global_serial, owner_name, pinned, favorite, stats)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(steam_id, instance_id) DO UPDATE SET
|
||||||
|
item_name = excluded.item_name, quality = excluded.quality, upgrade_level = excluded.upgrade_level,
|
||||||
|
serial = excluded.serial, global_serial = excluded.global_serial, owner_name = excluded.owner_name,
|
||||||
|
pinned = excluded.pinned, favorite = excluded.favorite, stats = excluded.stats
|
||||||
|
`);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const [instId, inst] of Object.entries(arsenal_inventory.instances)) {
|
||||||
|
const i = inst as any;
|
||||||
|
upsert.run(ctx.params.steamId, instId,
|
||||||
|
i.itemName || i.item_name || '',
|
||||||
|
i.quality || 'common',
|
||||||
|
i.upgradeLevel || i.upgrade_level || 0,
|
||||||
|
i.serial || 0,
|
||||||
|
i.globalSerial || i.global_serial || 0,
|
||||||
|
i.ownerName || i.owner_name || '',
|
||||||
|
i.pinned ? 1 : 0,
|
||||||
|
i.favorite ? 1 : 0,
|
||||||
|
JSON.stringify(i.stats || []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
const QUEST_DEFS = [
|
||||||
|
{ quest_id: 'kill_zombies_1', type: 'kill_zombies', name: 'Zombie Slayer I', description: 'Kill 100 zombies', target: 100, reward_exp: 50, reward_free_currency: 100 },
|
||||||
|
{ quest_id: 'kill_zombies_2', type: 'kill_zombies', name: 'Zombie Slayer II', description: 'Kill 500 zombies', target: 500, reward_exp: 100, reward_free_currency: 250 },
|
||||||
|
{ quest_id: 'survive_time_1', type: 'survive_time', name: 'Survivor I', description: 'Survive for 600 seconds', target: 600, reward_exp: 30, reward_free_currency: 50 },
|
||||||
|
{ quest_id: 'survive_waves_1', type: 'survive_waves', name: 'Wave Breaker I', description: 'Survive 10 waves', target: 10, reward_exp: 40, reward_free_currency: 75 },
|
||||||
|
{ quest_id: 'buy_black_shop_1', type: 'buy_black_shop', name: 'Black Shopper I', description: 'Buy 5 items from Black Shop', target: 5, reward_exp: 25, reward_free_currency: 50 },
|
||||||
|
{ quest_id: 'complete_npc_quest_1', type: 'complete_npc_quest', name: 'Helper I', description: 'Complete 3 NPC quests', target: 3, reward_exp: 35, reward_free_currency: 60 },
|
||||||
|
{ quest_id: 'earn_gold_1', type: 'earn_gold', name: 'Gold Rush I', description: 'Earn 5000 gold', target: 5000, reward_exp: 45, reward_free_currency: 100 },
|
||||||
|
{ quest_id: 'hero_level_1', type: 'hero_level', name: 'Stronger I', description: 'Reach level 10', target: 10, reward_exp: 30, reward_free_currency: 50 },
|
||||||
|
{ quest_id: 'cook_grilled_meat_1', type: 'cook_grilled_meat', name: 'Chef I', description: 'Cook grilled meat', target: 1, reward_exp: 20, reward_free_currency: 25 },
|
||||||
|
{ quest_id: 'use_campfire_1', type: 'use_campfire', name: 'Camper I', description: 'Use campfire 5 times', target: 5, reward_exp: 15, reward_free_currency: 25 },
|
||||||
|
{ quest_id: 'tip_teammate_1', type: 'tip_teammate', name: 'Friendly I', description: 'Tip teammates 3 times', target: 3, reward_exp: 20, reward_free_currency: 30 },
|
||||||
|
{ quest_id: 'deal_damage_1', type: 'deal_damage', name: 'Berserker I', description: 'Deal 50000 damage', target: 50000, reward_exp: 60, reward_free_currency: 150 },
|
||||||
|
{ quest_id: 'collect_item_1', type: 'collect_item', name: 'Collector I', description: 'Collect a rare item', target: 1, reward_exp: 40, reward_free_currency: 80, target_item: 'rare' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// POST /battlepass — Create or ensure BP exists for a player, assign default quests
|
||||||
|
route('battlepass', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id required');
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('INSERT OR IGNORE INTO battle_passes (steam_id, level, experience) VALUES (?, 0, 0)').run(steam_id);
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM battle_pass_quests WHERE steam_id = ?').get(steam_id) as any;
|
||||||
|
if (existing.c === 0) {
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO battle_pass_quests (steam_id, quest_id, type, name, description, target, reward_exp, reward_free_currency)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const q of QUEST_DEFS) {
|
||||||
|
insert.run(steam_id, q.quest_id, q.type, q.name, q.description, q.target, q.reward_exp, q.reward_free_currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /battlepass/:steamId — Get BP data
|
||||||
|
route('battlepass/:steamId', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
let bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!bp) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO battle_passes (steam_id) VALUES (?)').run(ctx.params.steamId);
|
||||||
|
bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
level: bp.level,
|
||||||
|
experience: bp.experience,
|
||||||
|
has_premium: bp.has_premium === 1,
|
||||||
|
claimed_rewards: JSON.parse(bp.claimed_rewards || '[]'),
|
||||||
|
claimed_premium_rewards: JSON.parse(bp.claimed_premium_rewards || '[]'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /battlepass/:steamId/quests — Get quests for a player
|
||||||
|
route('battlepass/:steamId/quests', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const quests = db.prepare('SELECT * FROM battle_pass_quests WHERE steam_id = ? ORDER BY id').all(ctx.params.steamId);
|
||||||
|
return { quests };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/quests/progress — Sync quest progress
|
||||||
|
route('battlepass/:steamId/quests/progress', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { quest_id, progress } = ctx.body as any;
|
||||||
|
if (!quest_id) throw new HttpError(400, 'quest_id required');
|
||||||
|
const db = getDb();
|
||||||
|
const quest = db.prepare('SELECT * FROM battle_pass_quests WHERE steam_id = ? AND quest_id = ?').get(ctx.params.steamId, quest_id) as any;
|
||||||
|
if (!quest) {
|
||||||
|
db.prepare(`INSERT INTO battle_pass_quests (steam_id, quest_id, type, name, target, progress) VALUES (?, ?, 'custom', ?, 1, ?)`)
|
||||||
|
.run(ctx.params.steamId, quest_id, quest_id, progress || 0);
|
||||||
|
return { success: true, completed: false, progress: progress || 0 };
|
||||||
|
}
|
||||||
|
const newProgress = Math.min(progress ?? quest.progress, quest.target);
|
||||||
|
const completed = newProgress >= quest.target ? 1 : 0;
|
||||||
|
db.prepare('UPDATE battle_pass_quests SET progress = ?, completed = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
||||||
|
.run(newProgress, completed, quest.id);
|
||||||
|
return { success: true, completed: completed === 1, progress: newProgress };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/quests/claim — Claim a quest reward
|
||||||
|
route('battlepass/:steamId/quests/claim', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { quest_id } = ctx.body as any;
|
||||||
|
if (!quest_id) throw new HttpError(400, 'quest_id required');
|
||||||
|
const db = getDb();
|
||||||
|
const quest = db.prepare('SELECT * FROM battle_pass_quests WHERE steam_id = ? AND quest_id = ?').get(ctx.params.steamId, quest_id) as any;
|
||||||
|
if (!quest) throw new HttpError(404, 'Quest not found');
|
||||||
|
if (!quest.completed) throw new HttpError(400, 'Quest not completed');
|
||||||
|
if (quest.claimed) throw new HttpError(400, 'Already claimed');
|
||||||
|
|
||||||
|
db.prepare('UPDATE battle_pass_quests SET claimed = 1, updated_at = datetime(\'now\') WHERE id = ?').run(quest.id);
|
||||||
|
db.prepare('UPDATE players SET free_currency = free_currency + ?, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(quest.reward_free_currency, ctx.params.steamId);
|
||||||
|
db.prepare('UPDATE battle_passes SET experience = experience + ?, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(quest.reward_exp, ctx.params.steamId);
|
||||||
|
|
||||||
|
const bp = db.prepare('SELECT level, experience FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
reward_exp: quest.reward_exp,
|
||||||
|
reward_free_currency: quest.reward_free_currency,
|
||||||
|
new_level: bp.level,
|
||||||
|
new_experience: bp.experience,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/hero-played — Record a hero being played (fire-and-forget)
|
||||||
|
route('battlepass/:steamId/hero-played', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/claim — Claim a free BP level reward
|
||||||
|
route('battlepass/:steamId/claim', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, level } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!bp) throw new HttpError(404, 'BP not found');
|
||||||
|
let claimed = JSON.parse(bp.claimed_rewards || '[]');
|
||||||
|
if (!claimed.includes(level)) {
|
||||||
|
claimed.push(level);
|
||||||
|
db.prepare("UPDATE battle_passes SET claimed_rewards = ?, updated_at = datetime('now') WHERE steam_id = ?")
|
||||||
|
.run(JSON.stringify(claimed), ctx.params.steamId);
|
||||||
|
}
|
||||||
|
return { success: true, level, currency_granted: { free_currency: level * 250, donate_currency: 0 } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/claim-premium — Claim a premium BP level reward
|
||||||
|
route('battlepass/:steamId/claim-premium', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, level } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!bp) throw new HttpError(404, 'BP not found');
|
||||||
|
let claimed = JSON.parse(bp.claimed_premium_rewards || '[]');
|
||||||
|
if (!claimed.includes(level)) {
|
||||||
|
claimed.push(level);
|
||||||
|
db.prepare("UPDATE battle_passes SET claimed_premium_rewards = ?, updated_at = datetime('now') WHERE steam_id = ?")
|
||||||
|
.run(JSON.stringify(claimed), ctx.params.steamId);
|
||||||
|
}
|
||||||
|
return { success: true, level, currency_granted: { free_currency: level * 250, donate_currency: level * 100 } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/claim-all — Claim all rewards up to current level
|
||||||
|
route('battlepass/:steamId/claim-all', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!bp) throw new HttpError(404, 'BP not found');
|
||||||
|
|
||||||
|
const unclaimedFree: number[] = [];
|
||||||
|
const unclaimedPremium: number[] = [];
|
||||||
|
const claimedFree = JSON.parse(bp.claimed_rewards || '[]') as number[];
|
||||||
|
const claimedPremium = JSON.parse(bp.claimed_premium_rewards || '[]') as number[];
|
||||||
|
|
||||||
|
for (let lvl = 1; lvl <= bp.level; lvl++) {
|
||||||
|
if (!claimedFree.includes(lvl)) unclaimedFree.push(lvl);
|
||||||
|
if (bp.has_premium && !claimedPremium.includes(lvl)) unclaimedPremium.push(lvl);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE battle_passes SET claimed_rewards = ?, claimed_premium_rewards = ?, updated_at = datetime('now') WHERE steam_id = ?")
|
||||||
|
.run(JSON.stringify([...claimedFree, ...unclaimedFree]),
|
||||||
|
JSON.stringify([...claimedPremium, ...unclaimedPremium]),
|
||||||
|
ctx.params.steamId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
free_levels: unclaimedFree,
|
||||||
|
premium_levels: unclaimedPremium,
|
||||||
|
currency_granted: {
|
||||||
|
free_currency: unclaimedFree.length * 250 + unclaimedPremium.length * 250,
|
||||||
|
donate_currency: unclaimedPremium.length * 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/buy-premium — Activate premium BP
|
||||||
|
route('battlepass/:steamId/buy-premium', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("UPDATE battle_passes SET has_premium = 1, updated_at = datetime('now') WHERE steam_id = ?")
|
||||||
|
.run(ctx.params.steamId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /battlepass/:steamId/addexp — Add experience to BP
|
||||||
|
route('battlepass/:steamId/addexp', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { experience } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const bp = db.prepare('SELECT * FROM battle_passes WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!bp) throw new HttpError(404, 'BP not found');
|
||||||
|
|
||||||
|
const newExp = bp.experience + (experience || 0);
|
||||||
|
const levelUp = Math.floor(newExp / 1000);
|
||||||
|
const newLevel = bp.level + levelUp;
|
||||||
|
const remainder = newExp % 1000;
|
||||||
|
|
||||||
|
db.prepare('UPDATE battle_passes SET experience = ?, level = ?, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(remainder, newLevel, ctx.params.steamId);
|
||||||
|
|
||||||
|
return { level: newLevel, experience: remainder, level_up: levelUp > 0 };
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('player/:steamId/card-levels', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT card_levels FROM card_levels WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
return { card_levels: row ? JSON.parse(row.card_levels) : {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/card-levels', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { card_levels } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO card_levels (steam_id, card_levels, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id) DO UPDATE SET card_levels = ?, updated_at = datetime('now')
|
||||||
|
`).run(ctx.params.steamId, JSON.stringify(card_levels || {}), JSON.stringify(card_levels || {}));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/decks', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const decks = db.prepare('SELECT * FROM decks WHERE steam_id = ? ORDER BY deck_index').all(ctx.params.steamId);
|
||||||
|
return decks.map((d: any) => ({ ...d, cards: JSON.parse(d.cards || '[]') }));
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/decks/:index', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const deck = db.prepare('SELECT * FROM decks WHERE steam_id = ? AND deck_index = ?').get(ctx.params.steamId, parseInt(ctx.params.index)) as any;
|
||||||
|
if (!deck) return { name: 'New Deck', cards: [] };
|
||||||
|
return { ...deck, cards: JSON.parse(deck.cards || '[]') };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/decks/:index', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { name, cards } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const idx = parseInt(ctx.params.index);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO decks (steam_id, deck_index, name, cards, updated_at) VALUES (?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id, deck_index) DO UPDATE SET name = ?, cards = ?, updated_at = datetime('now')
|
||||||
|
`).run(ctx.params.steamId, idx, name || 'My Deck', JSON.stringify(cards || []), name || 'My Deck', JSON.stringify(cards || []));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('player/:steamId/death_sentence_contracts', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT contracts FROM death_sentence_contracts WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
return { death_sentence_contracts: row ? JSON.parse(row.contracts) : { roster: [] } };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/death_sentence_contracts', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { death_sentence_contracts } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO death_sentence_contracts (steam_id, contracts, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id) DO UPDATE SET contracts = ?, updated_at = datetime('now')
|
||||||
|
`).run(ctx.params.steamId, JSON.stringify(death_sentence_contracts || {}), JSON.stringify(death_sentence_contracts || {}));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('player/:steamId/equipment', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT equipment FROM equipment WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
return { equipment: row ? JSON.parse(row.equipment) : {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/equipment', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { equipment } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO equipment (steam_id, equipment, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id) DO UPDATE SET equipment = ?, updated_at = datetime('now')
|
||||||
|
`).run(ctx.params.steamId, JSON.stringify(equipment || {}), JSON.stringify(equipment || {}));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/equipment/drop', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('game/start', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, hero, hero_level, difficulty, player_name, match_id, session_id, session_participants } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id required');
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const gameId = `game_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||||
|
const newMatchId = match_id || Math.floor(Math.random() * 100000000);
|
||||||
|
|
||||||
|
db.prepare('INSERT OR REPLACE INTO game_sessions (game_id, match_id, session_id, status) VALUES (?, ?, ?, \'active\')')
|
||||||
|
.run(gameId, newMatchId, session_id || '');
|
||||||
|
|
||||||
|
return { game_id: gameId, match_id: newMatchId };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('game/heartbeat', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('game', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, result, duration, kills, deaths, score, outgoing_damage, incoming_damage,
|
||||||
|
hero, hero_level, items, modifiers, aghanim_scepter, aghanim_shard, gold_earned,
|
||||||
|
difficulty, session_id, game_id } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id required');
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO game_history (steam_id, game_id, result, duration, kills, deaths, score,
|
||||||
|
outgoing_damage, incoming_damage, hero, hero_level, items, modifiers,
|
||||||
|
aghanim_scepter, aghanim_shard, gold_earned, difficulty, session_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(steam_id, game_id || null, result || 'loss', duration || 0, kills || 0, deaths || 0,
|
||||||
|
score || 0, outgoing_damage || 0, incoming_damage || 0, hero || '', hero_level || 1,
|
||||||
|
items || '', modifiers || '', aghanim_scepter ? 1 : 0, aghanim_shard ? 1 : 0,
|
||||||
|
gold_earned || 0, difficulty || 'normal', session_id || '');
|
||||||
|
|
||||||
|
if (game_id) {
|
||||||
|
db.prepare("UPDATE game_sessions SET status = 'completed' WHERE game_id = ?").run(game_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('game/:id/players', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const session = db.prepare('SELECT * FROM game_sessions WHERE game_id = ? OR match_id = ?')
|
||||||
|
.get(ctx.params.id, parseInt(ctx.params.id) || 0) as any;
|
||||||
|
if (!session) return { players: [] };
|
||||||
|
|
||||||
|
const players = db.prepare(
|
||||||
|
'SELECT DISTINCT steam_id, hero, hero_level, result FROM game_history WHERE match_id = ? OR game_id = ?'
|
||||||
|
).all(session.match_id, session.game_id);
|
||||||
|
|
||||||
|
return { party_players: players, players };
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { route, HandlerContext } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('leaderboard', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const limit = parseInt(ctx.searchParams.get('limit') || '20');
|
||||||
|
const offset = parseInt(ctx.searchParams.get('offset') || '0');
|
||||||
|
const board = ctx.searchParams.get('board') || 'rating';
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
let rows: any[];
|
||||||
|
if (board === 'wealth') {
|
||||||
|
rows = db.prepare(
|
||||||
|
'SELECT steam_id, player_name, (free_currency + donate_currency) as score, free_currency, donate_currency FROM players ORDER BY score DESC LIMIT ? OFFSET ?'
|
||||||
|
).all(limit, offset);
|
||||||
|
} else {
|
||||||
|
rows = db.prepare(`
|
||||||
|
SELECT p.steam_id, p.player_name,
|
||||||
|
COUNT(CASE WHEN gh.result = 'win' THEN 1 END) as wins,
|
||||||
|
COUNT(gh.id) as total_games
|
||||||
|
FROM players p
|
||||||
|
LEFT JOIN game_history gh ON p.steam_id = gh.steam_id
|
||||||
|
GROUP BY p.steam_id
|
||||||
|
ORDER BY wins DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard: rows,
|
||||||
|
total: (db.prepare('SELECT COUNT(*) as c FROM players').get() as any).c,
|
||||||
|
board,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
route('arsenal_market/listings', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare("SELECT * FROM arsenal_market_listings WHERE status = 'active' ORDER BY created_at DESC").all();
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/my_listings', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare("SELECT * FROM arsenal_market_listings WHERE steam_id = ? AND status = 'active' ORDER BY created_at DESC").all(ctx.params.steamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/slots', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
return { slots: 5, used: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/sales', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM arsenal_market_sales WHERE seller_steam_id = ? ORDER BY created_at DESC').all(ctx.params.steamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/create', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { instance_id, item_name, quality, upgrade_level, serial, global_serial, price_free } = ctx.body as any;
|
||||||
|
const listingId = `list_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO arsenal_market_listings (listing_id, steam_id, instance_id, item_name, quality, upgrade_level, serial, global_serial, price_free, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')
|
||||||
|
`).run(listingId, ctx.params.steamId, instance_id || '', item_name || 'Unknown', quality || 'common',
|
||||||
|
upgrade_level || 0, serial || 0, global_serial || 0, price_free || 0);
|
||||||
|
return { success: true, listing_id: listingId };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/buy', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { listing_id } = ctx.body as any;
|
||||||
|
if (!listing_id) throw new HttpError(400, 'listing_id required');
|
||||||
|
const db = getDb();
|
||||||
|
const listing = db.prepare('SELECT * FROM arsenal_market_listings WHERE listing_id = ?').get(listing_id) as any;
|
||||||
|
if (!listing) throw new HttpError(404, 'Listing not found');
|
||||||
|
if (listing.status !== 'active') throw new HttpError(400, 'Listing not active');
|
||||||
|
|
||||||
|
db.prepare("UPDATE arsenal_market_listings SET status = 'sold' WHERE listing_id = ?").run(listing_id);
|
||||||
|
db.prepare('INSERT INTO arsenal_market_sales (listing_id, seller_steam_id, buyer_steam_id, item_name, price_free) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.run(listing_id, listing.steam_id, ctx.params.steamId, listing.item_name, listing.price_free);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
route('player/:steamId/arsenal_market/cancel', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { listing_id } = ctx.body as any;
|
||||||
|
if (!listing_id) throw new HttpError(400, 'listing_id required');
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("UPDATE arsenal_market_listings SET status = 'cancelled' WHERE listing_id = ? AND steam_id = ?")
|
||||||
|
.run(listing_id, ctx.params.steamId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
// POST /payments/robokassa/link — Auto-grant purchased currency
|
||||||
|
route('payments/robokassa/link', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, amount_rub } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id required');
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const donateShards = (amount_rub || 100) * 10;
|
||||||
|
db.prepare('UPDATE players SET donate_currency = donate_currency + ?, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(donateShards, steam_id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payment_url: '',
|
||||||
|
donate_shards: donateShards,
|
||||||
|
inv_id: Math.floor(Math.random() * 100000),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /payments/bundles/link — Auto-grant bundle items
|
||||||
|
route('payments/bundles/link', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, bundle_id } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id required');
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
db.prepare('UPDATE players SET free_currency = free_currency + 500, donate_currency = donate_currency + 200, updated_at = datetime(\'now\') WHERE steam_id = ?')
|
||||||
|
.run(steam_id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
payment_url: '',
|
||||||
|
inv_id: Math.floor(Math.random() * 100000),
|
||||||
|
message: 'Bundle granted',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /payments/deals?steam_id= — Return deal catalog
|
||||||
|
route('payments/deals', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bundles: [
|
||||||
|
{ id: 'starter_bundle', name: 'Starter Pack', description: 'Get started with 500 shards', price_free: 0, price_donate: 0, items: [{ item_id: 'starter_pack', name: 'Starter Pack' }] },
|
||||||
|
{ id: 'hero_bundle_1', name: 'Hero Bundle I', description: 'Unlock a random hero', price_free: 1000, price_donate: 0, items: [{ item_id: 'hero_bundle_1', name: 'Hero Bundle' }] },
|
||||||
|
],
|
||||||
|
daily: { available: true, items: [] },
|
||||||
|
weekly: { available: true, items: [] },
|
||||||
|
player_created_at_unix: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { route, HandlerContext, HttpError } from '@/lib/router';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
// POST /player — Create player profile
|
||||||
|
route('player', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { steam_id, player_name } = ctx.body as any;
|
||||||
|
if (!steam_id) throw new HttpError(400, 'steam_id is required');
|
||||||
|
const db = getDb();
|
||||||
|
const existing = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(steam_id) as any;
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
db.prepare('INSERT INTO players (steam_id, player_name) VALUES (?, ?)').run(steam_id, player_name || '');
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO battle_passes (steam_id) VALUES (?)').run(steam_id);
|
||||||
|
} catch {}
|
||||||
|
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(steam_id);
|
||||||
|
return player;
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /player/:steamId — Get player profile
|
||||||
|
// Returns the player row plus recentGames array and stats object
|
||||||
|
route('player/:steamId', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!player) throw new HttpError(404, 'Player not found');
|
||||||
|
return {
|
||||||
|
...player,
|
||||||
|
recentGames: [],
|
||||||
|
stats: {
|
||||||
|
total_games: 0,
|
||||||
|
total_wins: 0,
|
||||||
|
rating: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /player/:steamId/history — Match history with limit/offset
|
||||||
|
route('player/:steamId/history', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const limit = parseInt(ctx.searchParams.get('limit') || '10');
|
||||||
|
const offset = parseInt(ctx.searchParams.get('offset') || '0');
|
||||||
|
const db = getDb();
|
||||||
|
const games = db.prepare(
|
||||||
|
'SELECT * FROM game_history WHERE steam_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
||||||
|
).all(ctx.params.steamId, limit, offset);
|
||||||
|
return games;
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /player/:steamId/currency — Get currency balances
|
||||||
|
route('player/:steamId/currency', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT free_currency, donate_currency, dust_currency FROM players WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!player) throw new HttpError(404, 'Player not found');
|
||||||
|
return player;
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /player/:steamId/currency — Save currency balances
|
||||||
|
route('player/:steamId/currency', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { free_currency, donate_currency, dust_currency } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!player) throw new HttpError(404, 'Player not found');
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE players SET free_currency = ?, donate_currency = ?, dust_currency = ?, updated_at = datetime('now')
|
||||||
|
WHERE steam_id = ?
|
||||||
|
`).run(
|
||||||
|
free_currency ?? player.free_currency,
|
||||||
|
donate_currency ?? player.donate_currency,
|
||||||
|
dust_currency ?? player.dust_currency,
|
||||||
|
ctx.params.steamId
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /player/:steamId/currency/give — Grant currency (used by BP rewards)
|
||||||
|
route('player/:steamId/currency/give', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const body = ctx.body as any;
|
||||||
|
const free_amount = body.free_amount ?? body.freeAmount ?? 0;
|
||||||
|
const donate_amount = body.donate_amount ?? body.donateAmount ?? 0;
|
||||||
|
const dust_amount = body.dust_amount ?? body.dustAmount ?? 0;
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT * FROM players WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!player) throw new HttpError(404, 'Player not found');
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE players SET free_currency = free_currency + ?, donate_currency = donate_currency + ?,
|
||||||
|
dust_currency = dust_currency + ?, updated_at = datetime('now') WHERE steam_id = ?
|
||||||
|
`).run(
|
||||||
|
free_amount,
|
||||||
|
donate_amount,
|
||||||
|
dust_amount,
|
||||||
|
ctx.params.steamId
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /player/:steamId/purchases — Record a store purchase
|
||||||
|
route('player/:steamId/purchases', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { item_id, item_category, card_id, price_free, price_donate, price_dust } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO purchases (steam_id, item_id, item_category, card_id, price_free, price_donate, price_dust)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(ctx.params.steamId, item_id, item_category || 'items', card_id || null, price_free || 0, price_donate || 0, price_dust || 0);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /player/:steamId/promo/redeem — Redeem a promo code
|
||||||
|
route('player/:steamId/promo/redeem', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { code } = ctx.body as any;
|
||||||
|
if (!code) throw new HttpError(400, 'Code is required');
|
||||||
|
const normalizedCode = String(code).toUpperCase();
|
||||||
|
const db = getDb();
|
||||||
|
const promo = db.prepare('SELECT * FROM promo_codes WHERE code = ?').get(normalizedCode) as any;
|
||||||
|
if (!promo) throw new HttpError(404, 'Promo code not found');
|
||||||
|
if (promo.expires_at && new Date(promo.expires_at) < new Date()) throw new HttpError(400, 'Code expired');
|
||||||
|
if (promo.current_uses >= promo.max_uses) throw new HttpError(400, 'Code fully redeemed');
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM promo_redemptions WHERE steam_id = ? AND code = ?').get(ctx.params.steamId, normalizedCode);
|
||||||
|
if (existing) throw new HttpError(400, 'Code already redeemed');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE players SET free_currency = free_currency + ?, donate_currency = donate_currency + ?,
|
||||||
|
dust_currency = dust_currency + ?, updated_at = datetime('now') WHERE steam_id = ?
|
||||||
|
`).run(promo.free_currency, promo.donate_currency, promo.dust_currency, ctx.params.steamId);
|
||||||
|
|
||||||
|
db.prepare('UPDATE promo_codes SET current_uses = current_uses + 1 WHERE code = ?').run(normalizedCode);
|
||||||
|
db.prepare('INSERT INTO promo_redemptions (steam_id, code) VALUES (?, ?)').run(ctx.params.steamId, normalizedCode);
|
||||||
|
|
||||||
|
const player = db.prepare('SELECT free_currency, donate_currency, dust_currency FROM players WHERE steam_id = ?').get(ctx.params.steamId);
|
||||||
|
return { success: true, rewards: { free_currency: promo.free_currency, donate_currency: promo.donate_currency, dust_currency: promo.dust_currency }, currency: player };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /player/:steamId/sounds_wheel — Get sounds wheel
|
||||||
|
route('player/:steamId/sounds_wheel', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const player = db.prepare('SELECT sounds_wheel FROM players WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
if (!player) throw new HttpError(404, 'Player not found');
|
||||||
|
return { sounds_wheel: JSON.parse(player.sounds_wheel || '{}') };
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /player/:steamId/sounds_wheel — Save sounds wheel
|
||||||
|
route('player/:steamId/sounds_wheel', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { sounds_wheel } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("UPDATE players SET sounds_wheel = ?, updated_at = datetime('now') WHERE steam_id = ?")
|
||||||
|
.run(JSON.stringify(sounds_wheel || {}), ctx.params.steamId);
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /player/:steamId/deal-purchase — Buy a deal/offer
|
||||||
|
route('player/:steamId/deal-purchase', ['POST'], (ctx: HandlerContext) => {
|
||||||
|
const { deal_key } = ctx.body as any;
|
||||||
|
return { success: true, ok: true, item_id: 'deal_' + deal_key, item_category: 'items' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /player/:steamId/active_effects — Get active cosmetic effects
|
||||||
|
route('player/:steamId/active_effects', ['GET'], (ctx: HandlerContext) => {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT effects FROM active_effects WHERE steam_id = ?').get(ctx.params.steamId) as any;
|
||||||
|
return { active_effects: row ? JSON.parse(row.effects) : {} };
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /player/:steamId/active_effects — Save active effects
|
||||||
|
route('player/:steamId/active_effects', ['PUT'], (ctx: HandlerContext) => {
|
||||||
|
const { active_effects } = ctx.body as any;
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO active_effects (steam_id, effects, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(steam_id) DO UPDATE SET effects = ?, updated_at = datetime('now')
|
||||||
|
`).run(ctx.params.steamId, JSON.stringify(active_effects || {}), JSON.stringify(active_effects || {}));
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export type HandlerFn = (ctx: HandlerContext) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
export type HandlerContext = {
|
||||||
|
params: Record<string, string>;
|
||||||
|
method: string;
|
||||||
|
body: unknown;
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteEntry = {
|
||||||
|
pattern: string[];
|
||||||
|
methods: string[];
|
||||||
|
handler: HandlerFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes: RouteEntry[] = [];
|
||||||
|
|
||||||
|
export function route(pattern: string, methods: string[], handler: HandlerFn) {
|
||||||
|
const parts = pattern.split('/').filter(Boolean);
|
||||||
|
routes.push({ pattern: parts, methods: methods.map(m => m.toUpperCase()), handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatch(
|
||||||
|
request: Request,
|
||||||
|
pathSegments: string[],
|
||||||
|
method: string
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
for (const entry of routes) {
|
||||||
|
if (!entry.methods.includes(method)) continue;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
let match = true;
|
||||||
|
|
||||||
|
if (entry.pattern.length !== pathSegments.length) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < entry.pattern.length; i++) {
|
||||||
|
const ep = entry.pattern[i];
|
||||||
|
const sp = pathSegments[i];
|
||||||
|
if (ep.startsWith(':')) {
|
||||||
|
params[ep.slice(1)] = sp;
|
||||||
|
} else if (ep !== sp) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
let body: unknown = undefined;
|
||||||
|
const ct = request.headers.get('content-type') || '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
try { body = await request.json(); } catch { body = undefined; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: HandlerContext = {
|
||||||
|
params,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
searchParams: new URL(request.url).searchParams,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await entry.handler(ctx);
|
||||||
|
return NextResponse.json(result, { status: 200 });
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
return NextResponse.json({ error: err.message || 'Internal error' }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { getDb } from './db';
|
||||||
|
|
||||||
|
export function seedDatabase() {
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.prepare('SELECT COUNT(*) as c FROM promo_codes').get() as { c: number };
|
||||||
|
if (count.c > 0) return;
|
||||||
|
|
||||||
|
const insert = db.prepare(`
|
||||||
|
INSERT INTO promo_codes (code, free_currency, donate_currency, dust_currency, max_uses, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const codes = [
|
||||||
|
['WELCOME100', 100, 0, 0, 100, null],
|
||||||
|
['ZOMBIE500', 500, 50, 0, 50, null],
|
||||||
|
['DONATE100', 0, 100, 0, 20, null],
|
||||||
|
['DUST250', 0, 0, 250, 30, null],
|
||||||
|
];
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const c of codes) {
|
||||||
|
insert.run(...c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
|
||||||
|
console.log('Database seeded with promo codes');
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
const config: Config = {
|
||||||
|
content: ['./src/**/*.{ts,tsx}'],
|
||||||
|
theme: { extend: {} },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
|||||||
|
# Zombie Invasion — Backend & Admin Panel Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A lightweight Next.js application that serves as both:
|
||||||
|
- The **REST API backend** that the Zombie Invasion Dota 2 custom game client talks to
|
||||||
|
- An **admin panel** (served under `/admin`) for managing all player data
|
||||||
|
|
||||||
|
Deployed as a single Docker container with SQLite for storage. All payment-related endpoints auto-accept (no real payment integration).
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework:** Next.js 14 (App Router)
|
||||||
|
- **Database:** SQLite via `better-sqlite3`
|
||||||
|
- **Language:** TypeScript
|
||||||
|
- **Container:** Docker (single container, multi-stage build)
|
||||||
|
- **Port:** 3000 (same as the original game server)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
nextjs-app/
|
||||||
|
├── Dockerfile # Multi-stage: build → run
|
||||||
|
├── docker-compose.yml # Single service
|
||||||
|
├── docker-entrypoint.sh # DB init + seed + start
|
||||||
|
├── next.config.js
|
||||||
|
├── package.json
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── layout.tsx # Root layout
|
||||||
|
│ │ ├── page.tsx # Redirects to /admin
|
||||||
|
│ │ ├── admin/
|
||||||
|
│ │ │ ├── layout.tsx # Admin sidebar + auth check
|
||||||
|
│ │ │ ├── page.tsx # Dashboard (counts, quick-stats)
|
||||||
|
│ │ │ ├── login/page.tsx # Simple password login
|
||||||
|
│ │ │ ├── players/[steamId]/page.tsx # Single player editor
|
||||||
|
│ │ │ ├── players/page.tsx # Player list + search
|
||||||
|
│ │ │ ├── battlepass/[steamId]/page.tsx
|
||||||
|
│ │ │ ├── battlepass/page.tsx # BP overview
|
||||||
|
│ │ │ ├── matches/page.tsx # Match history browser
|
||||||
|
│ │ │ ├── promocodes/page.tsx # Manage promo codes
|
||||||
|
│ │ │ ├── store/page.tsx # Purchases, currencies
|
||||||
|
│ │ │ ├── contracts/page.tsx # Death sentence contracts
|
||||||
|
│ │ │ └── arsenal/page.tsx # Arsenal & marketplace
|
||||||
|
│ │ └── api/
|
||||||
|
│ │ └── [...path]/
|
||||||
|
│ │ └── route.ts # Catch-all: dispatches game client requests
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── db.ts # SQLite singleton + schema init
|
||||||
|
│ ├── seed.ts # Initial data (promo codes, sample quests)
|
||||||
|
│ ├── auth.ts # Simple admin auth helpers
|
||||||
|
│ └── handlers/
|
||||||
|
│ ├── player.ts # Profile, currency, history, purchases
|
||||||
|
│ ├── battlepass.ts # BP data, quests, claim rewards
|
||||||
|
│ ├── game.ts # Match tracking, heartbeat
|
||||||
|
│ ├── payments.ts # Auto-accept mock payments
|
||||||
|
│ ├── leaderboard.ts # Leaderboard queries
|
||||||
|
│ ├── cards.ts # Card levels, decks
|
||||||
|
│ ├── equipment.ts # Equipment state
|
||||||
|
│ ├── arsenal.ts # Arsenal loadouts + inventory
|
||||||
|
│ ├── marketplace.ts # Marketplace listings + sales
|
||||||
|
│ └── contracts.ts # Death sentence contracts
|
||||||
|
├── data/ # SQLite DB file (Docker volume mount)
|
||||||
|
└── Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `players`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| player_name | TEXT NOT NULL | |
|
||||||
|
| profile_level | INTEGER | Default 1 |
|
||||||
|
| free_currency | INTEGER | Default 0 |
|
||||||
|
| donate_currency | INTEGER | Default 0 |
|
||||||
|
| dust_currency | INTEGER | Default 0 |
|
||||||
|
| arcade_pack_credits | TEXT | JSON `{standard, premium}` |
|
||||||
|
| sounds_wheel | TEXT | JSON object of `sound_id → true` |
|
||||||
|
| created_at | TEXT | ISO datetime |
|
||||||
|
| updated_at | TEXT | ISO datetime |
|
||||||
|
|
||||||
|
### `game_sessions`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| game_id | TEXT PK | |
|
||||||
|
| match_id | INTEGER | Shared across party |
|
||||||
|
| session_id | TEXT | |
|
||||||
|
| status | TEXT | `active` / `completed` |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
### `game_history`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | INTEGER PK AUTO | |
|
||||||
|
| steam_id | TEXT NOT NULL | |
|
||||||
|
| game_id | TEXT | |
|
||||||
|
| match_id | INTEGER | |
|
||||||
|
| result | TEXT | `win` / `loss` |
|
||||||
|
| hero | TEXT | |
|
||||||
|
| hero_level | INTEGER | |
|
||||||
|
| difficulty | TEXT | |
|
||||||
|
| duration | INTEGER | seconds |
|
||||||
|
| kills | INTEGER | |
|
||||||
|
| deaths | INTEGER | |
|
||||||
|
| score | INTEGER | net worth |
|
||||||
|
| outgoing_damage | REAL | |
|
||||||
|
| incoming_damage | REAL | |
|
||||||
|
| items | TEXT | comma-separated |
|
||||||
|
| modifiers | TEXT | comma-separated |
|
||||||
|
| aghanim_scepter | INTEGER | 0/1 |
|
||||||
|
| aghanim_shard | INTEGER | 0/1 |
|
||||||
|
| gold_earned | INTEGER | |
|
||||||
|
| session_id | TEXT | |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
### `battle_passes`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| level | INTEGER | Default 0 |
|
||||||
|
| experience | INTEGER | Default 0 |
|
||||||
|
| has_premium | INTEGER | 0/1 |
|
||||||
|
| claimed_rewards | TEXT | JSON array of level numbers |
|
||||||
|
| claimed_premium_rewards | TEXT | JSON array of level numbers |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `battle_pass_quests`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | INTEGER PK AUTO | |
|
||||||
|
| steam_id | TEXT NOT NULL | |
|
||||||
|
| quest_id | TEXT NOT NULL | |
|
||||||
|
| type | TEXT | `kill_zombies`, `survive_time`, etc. |
|
||||||
|
| name | TEXT | |
|
||||||
|
| description | TEXT | |
|
||||||
|
| progress | INTEGER | |
|
||||||
|
| target | INTEGER | |
|
||||||
|
| completed | INTEGER | 0/1 |
|
||||||
|
| claimed | INTEGER | 0/1 |
|
||||||
|
| reward_exp | INTEGER | |
|
||||||
|
| reward_free_currency | INTEGER | |
|
||||||
|
| quality | TEXT | nullable |
|
||||||
|
| npc | TEXT | nullable |
|
||||||
|
| target_item | TEXT | nullable |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `purchases`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | INTEGER PK AUTO | |
|
||||||
|
| steam_id | TEXT NOT NULL | |
|
||||||
|
| item_id | TEXT NOT NULL | |
|
||||||
|
| item_category | TEXT | `items`, `cards`, `chat_wheel_sound`, etc. |
|
||||||
|
| card_id | INTEGER | nullable |
|
||||||
|
| price_free | INTEGER | |
|
||||||
|
| price_donate | INTEGER | |
|
||||||
|
| price_dust | INTEGER | |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
### `active_effects`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| effects | TEXT | JSON: `{effect_type: effect_id}` |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `promo_codes`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| code | TEXT PK | |
|
||||||
|
| free_currency | INTEGER | reward amount |
|
||||||
|
| donate_currency | INTEGER | reward amount |
|
||||||
|
| dust_currency | INTEGER | reward amount |
|
||||||
|
| max_uses | INTEGER | default 1 |
|
||||||
|
| current_uses | INTEGER | |
|
||||||
|
| expires_at | TEXT | nullable, ISO datetime |
|
||||||
|
|
||||||
|
### `promo_redemptions`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT | PK (composite) |
|
||||||
|
| code | TEXT | PK (composite) |
|
||||||
|
| redeemed_at | TEXT | |
|
||||||
|
|
||||||
|
### `card_levels`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| card_levels | TEXT | JSON: `{card_id: level}` |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `decks`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT | PK (composite) |
|
||||||
|
| deck_index | INTEGER | PK (composite) |
|
||||||
|
| name | TEXT | |
|
||||||
|
| cards | TEXT | JSON array of card IDs |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `equipment`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| equipment | TEXT | JSON: `{weapon, armor, ...}` |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `arsenal_loadouts`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT | PK (composite) |
|
||||||
|
| hero_name | TEXT | PK (composite) |
|
||||||
|
| loadout | TEXT | JSON: `{weapon, armor}` |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
### `arsenal_inventory`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT | PK (composite) |
|
||||||
|
| instance_id | TEXT | PK (composite) |
|
||||||
|
| item_name | TEXT | |
|
||||||
|
| quality | TEXT | |
|
||||||
|
| upgrade_level | INTEGER | |
|
||||||
|
| serial | INTEGER | |
|
||||||
|
| global_serial | INTEGER | |
|
||||||
|
| owner_name | TEXT | |
|
||||||
|
| pinned | INTEGER | 0/1 |
|
||||||
|
| favorite | INTEGER | 0/1 |
|
||||||
|
| stats | TEXT | JSON array |
|
||||||
|
|
||||||
|
### `arsenal_market_listings`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| listing_id | TEXT PK | |
|
||||||
|
| steam_id | TEXT NOT NULL | |
|
||||||
|
| instance_id | TEXT | |
|
||||||
|
| item_name | TEXT | |
|
||||||
|
| quality | TEXT | |
|
||||||
|
| upgrade_level | INTEGER | |
|
||||||
|
| serial | INTEGER | |
|
||||||
|
| global_serial | INTEGER | |
|
||||||
|
| price_free | INTEGER | |
|
||||||
|
| status | TEXT | `active` / `sold` / `cancelled` |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
### `arsenal_market_sales`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | INTEGER PK AUTO | |
|
||||||
|
| listing_id | TEXT | |
|
||||||
|
| seller_steam_id | TEXT | |
|
||||||
|
| buyer_steam_id | TEXT | |
|
||||||
|
| item_name | TEXT | |
|
||||||
|
| price_free | INTEGER | |
|
||||||
|
| created_at | TEXT | |
|
||||||
|
|
||||||
|
### `death_sentence_contracts`
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| steam_id | TEXT PK | |
|
||||||
|
| contracts | TEXT | JSON roster |
|
||||||
|
| updated_at | TEXT | |
|
||||||
|
|
||||||
|
## API Endpoints (Game Client)
|
||||||
|
|
||||||
|
All under `/api/`. The catch-all route looks at the URL path and HTTP method to dispatch.
|
||||||
|
|
||||||
|
### Player (`/api/player/:steamId`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/player` | Create profile |
|
||||||
|
| GET | `/api/player/:steamId` | Get profile + currencies + stats |
|
||||||
|
| GET | `/api/player/:steamId/history` | Match history (limit, offset) |
|
||||||
|
| GET | `/api/player/:steamId/currency` | Get currency balances |
|
||||||
|
| PUT | `/api/player/:steamId/currency` | Save currency |
|
||||||
|
| POST | `/api/player/:steamId/currency/give` | Grant currency (BP rewards) |
|
||||||
|
| POST | `/api/player/:steamId/purchases` | Record a purchase |
|
||||||
|
| POST | `/api/player/:steamId/promo/redeem` | Redeem promo code |
|
||||||
|
| GET | `/api/player/:steamId/sounds_wheel` | Get chat wheel sounds |
|
||||||
|
| PUT | `/api/player/:steamId/sounds_wheel` | Save chat wheel sounds |
|
||||||
|
| POST | `/api/player/:steamId/deal-purchase` | Buy a deal |
|
||||||
|
| GET | `/api/player/:steamId/active_effects` | Get equipped effects |
|
||||||
|
| PUT | `/api/player/:steamId/active_effects` | Save equipped effects |
|
||||||
|
| GET | `/api/player/:steamId/card-levels` | Get card levels |
|
||||||
|
| PUT | `/api/player/:steamId/card-levels` | Update card levels |
|
||||||
|
| GET | `/api/player/:steamId/decks` | Get all decks |
|
||||||
|
| GET | `/api/player/:steamId/decks/:index` | Get one deck |
|
||||||
|
| PUT | `/api/player/:steamId/decks/:index` | Save one deck |
|
||||||
|
| GET | `/api/player/:steamId/equipment` | Get equipment |
|
||||||
|
| PUT | `/api/player/:steamId/equipment` | Save equipment |
|
||||||
|
| POST | `/api/player/:steamId/equipment/drop` | Equipment drop |
|
||||||
|
| GET | `/api/player/:steamId/arsenal_loadouts` | Get arsenal loadouts |
|
||||||
|
| PUT | `/api/player/:steamId/arsenal_loadouts` | Save arsenal loadouts |
|
||||||
|
| GET | `/api/player/:steamId/arsenal_inventory` | Get arsenal inventory |
|
||||||
|
| PUT | `/api/player/:steamId/arsenal_inventory` | Save arsenal inventory |
|
||||||
|
| GET | `/api/player/:steamId/arsenal_market/my_listings` | My active listings |
|
||||||
|
| GET | `/api/player/:steamId/arsenal_market/slots` | Market slot info |
|
||||||
|
| GET | `/api/player/:steamId/arsenal_market/sales` | My sales history |
|
||||||
|
| POST | `/api/player/:steamId/arsenal_market/create` | Create listing |
|
||||||
|
| POST | `/api/player/:steamId/arsenal_market/buy` | Buy from listing |
|
||||||
|
| POST | `/api/player/:steamId/arsenal_market/cancel` | Cancel listing |
|
||||||
|
| GET | `/api/player/:steamId/death_sentence_contracts` | Get contracts |
|
||||||
|
| PUT | `/api/player/:steamId/death_sentence_contracts` | Save contracts |
|
||||||
|
|
||||||
|
### Battle Pass (`/api/battlepass`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/battlepass` | Create BP |
|
||||||
|
| GET | `/api/battlepass/:steamId` | Get BP data |
|
||||||
|
| POST | `/api/battlepass/:steamId/hero-played` | Record hero played |
|
||||||
|
| GET | `/api/battlepass/:steamId/quests` | Get quests |
|
||||||
|
| POST | `/api/battlepass/:steamId/quests/progress` | Sync quest progress |
|
||||||
|
| POST | `/api/battlepass/:steamId/quests/claim` | Claim quest reward |
|
||||||
|
| POST | `/api/battlepass/:steamId/claim` | Claim BP level reward |
|
||||||
|
| POST | `/api/battlepass/:steamId/claim-premium` | Claim premium level reward |
|
||||||
|
| POST | `/api/battlepass/:steamId/claim-all` | Claim all rewards |
|
||||||
|
| POST | `/api/battlepass/:steamId/buy-premium` | Buy premium BP |
|
||||||
|
| POST | `/api/battlepass/:steamId/addexp` | Add BP XP |
|
||||||
|
|
||||||
|
### Game (`/api/game`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/game/start` | Register game start |
|
||||||
|
| POST | `/api/game/heartbeat` | Match heartbeat |
|
||||||
|
| POST | `/api/game` | Save game result |
|
||||||
|
| GET | `/api/game/:id/players` | Get match participants |
|
||||||
|
|
||||||
|
### Payments (`/api/payments`) — auto-grant (no actual payment)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/api/payments/robokassa/link` | Instantly grants purchased currency to player balance, writes to DB |
|
||||||
|
| POST | `/api/payments/bundles/link` | Instantly grants bundle items/writes purchase to DB |
|
||||||
|
| GET | `/api/payments/deals?steamId=` | Returns deal catalog (deals purchasable with in-game currency) |
|
||||||
|
|
||||||
|
### Leaderboard (`/api/leaderboard`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/leaderboard?limit=&offset=&board=` | Leaderboard by rating/wealth |
|
||||||
|
|
||||||
|
### Marketplace (`/api/arsenal_market`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/arsenal_market/listings` | Public listings (with optional stat filters) |
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
The game client (Lua) expects JSON responses. The catch-all handler wraps each response:
|
||||||
|
- Success (2xx): returns the JSON body directly
|
||||||
|
- 404: returns `{error: "Not found"}`
|
||||||
|
- The game handles both wrapped responses `{ok: true, data: ...}` and unwrapped objects
|
||||||
|
|
||||||
|
Since different game modules expect different response shapes (some expect arrays, some expect objects, some look for specific keys), each handler returns the exact shape the game code expects.
|
||||||
|
|
||||||
|
## Admin Panel
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Single password set via `ADMIN_PASSWORD` env var
|
||||||
|
- Cookie-based session (simple, no JWT library needed)
|
||||||
|
- Login page at `/admin/login`
|
||||||
|
- All `/admin/*` routes check auth middleware
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
| Path | Content |
|
||||||
|
|------|---------|
|
||||||
|
| `/admin` | Dashboard with quick stats (player count, games played, active BPs) |
|
||||||
|
| `/admin/players` | Searchable player list with currency/level/bp overview |
|
||||||
|
| `/admin/players/[steamId]` | Edit all player fields, view purchases, effects |
|
||||||
|
| `/admin/battlepass` | Overview of all BPs with search |
|
||||||
|
| `/admin/battlepass/[steamId]` | Edit BP level/XP/premium, add/manage quests |
|
||||||
|
| `/admin/matches` | Browse match history, filter by player/hero/difficulty |
|
||||||
|
| `/admin/promocodes` | List, create, edit, delete promo codes |
|
||||||
|
| `/admin/store` | View player purchases and active effects |
|
||||||
|
| `/admin/contracts` | View/edit death sentence contracts |
|
||||||
|
| `/admin/arsenal` | View inventory, loadouts, marketplace listings |
|
||||||
|
|
||||||
|
## Docker Setup
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Multi-stage build: node:20-alpine
|
||||||
|
# Stage 1: Install deps + build Next.js
|
||||||
|
# Stage 2: Run with production deps + SQLite data volume
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "6100:3000" # Host:6100 → Container:3000
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # Persist SQLite DB
|
||||||
|
environment:
|
||||||
|
- ADMIN_PASSWORD=admin123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seed Data
|
||||||
|
|
||||||
|
On first run (empty DB), the entrypoint seeds:
|
||||||
|
- A few promo codes (e.g. `WELCOME100`, `ZOMBIE500`)
|
||||||
|
- The DB schema itself (via `db.ts` CREATE TABLE IF NOT EXISTS)
|
||||||
|
- A test player if none exist
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No user registration / multi-tenant support (single personal server)
|
||||||
|
- No real payment processing
|
||||||
|
- No WebSocket / real-time features
|
||||||
|
- No metrics / logging beyond basic requests
|
||||||
|
- No automated test suite (manual testing via game client + admin panel)
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "Zombie Invasion API",
|
||||||
|
"description": "API endpoints for the Zombie Invasion Dota 2 custom game. Base URL: http://localhost:6100/api\n\nAuth: x-custom-key header from GetDedicatedServerKeyV3(\"zombie_invasion\")\nCheat mode fallback key: menya_ebut_negry_tolpoy",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||||
|
"_exporter_id": "manual"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"type": "apikey",
|
||||||
|
"apikey": [
|
||||||
|
{
|
||||||
|
"key": "key",
|
||||||
|
"value": "menya_ebut_negry_tolpoy",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "value",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "in",
|
||||||
|
"value": "header",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Player",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Create Player Profile",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"steam_id\": \"76561198000000001\",\n \"player_name\": \"TestPlayer\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Player Profile",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Player Match History",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/history?limit=10&offset=0",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "history"],
|
||||||
|
"query": [
|
||||||
|
{"key": "limit", "value": "10"},
|
||||||
|
{"key": "offset", "value": "0"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Game Players",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/game/{{game_id}}/players",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["game", "{{game_id}}", "players"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Redeem Promo Code",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"code\": \"PROMO2024\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/promo/redeem",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "promo", "redeem"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Sounds Wheel",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/sounds_wheel",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "sounds_wheel"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Purchase Deal",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"deal_key\": \"starter_pack\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/deal-purchase",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "deal-purchase"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Battle Pass",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Battle Pass Data",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/battlepass/{{steam_id}}",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["battlepass", "{{steam_id}}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Battle Pass Quests",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/battlepass/{{steam_id}}/quests",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["battlepass", "{{steam_id}}", "quests"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sync Quest Progress",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"quest_id\": \"kill_zombies_1\",\n \"progress\": 42\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/battlepass/{{steam_id}}/quests/progress",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["battlepass", "{{steam_id}}", "quests", "progress"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Claim Quest Reward",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"quest_id\": \"kill_zombies_1\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/battlepass/{{steam_id}}/quests/claim",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["battlepass", "{{steam_id}}", "quests", "claim"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Record Hero Played",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"hero_name\": \"npc_dota_hero_axe\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/battlepass/{{steam_id}}/hero-played",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["battlepass", "{{steam_id}}", "hero-played"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Payments",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Create Robokassa Payment Link",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"steam_id\": \"{{steam_id}}\",\n \"amount_rub\": 100\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/payments/robokassa/link",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["payments", "robokassa", "link"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create Bundle Payment Link",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"steam_id\": \"{{steam_id}}\",\n \"bundle_id\": \"starter_bundle\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/payments/bundles/link",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["payments", "bundles", "link"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Deals Catalog",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/payments/deals?steam_id={{steam_id}}",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["payments", "deals"],
|
||||||
|
"query": [
|
||||||
|
{"key": "steam_id", "value": "{{steam_id}}"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cards",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Card Levels",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/card-levels",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "card-levels"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Update Card Levels",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"card_levels\": {\n \"1\": 3,\n \"2\": 1\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/card-levels",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "card-levels"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get All Decks",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/decks",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "decks"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Deck by Index",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/decks/0",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "decks", "0"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Save Deck",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"My Deck\",\n \"cards\": [1, 2, 3, 4, 5]\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/decks/0",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "decks", "0"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Equipment",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Equipment State",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/equipment",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "equipment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Save Equipment State",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"equipment\": {\n \"weapon\": \"sword_t1\",\n \"armor\": \"plate_t2\"\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/equipment",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "equipment"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Post Equipment Drop",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"item\": {\n \"name\": \"helmet_t3\",\n \"rarity\": \"epic\"\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/equipment/drop",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "equipment", "drop"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Arsenal",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Arsenal Loadouts",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_loadouts",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_loadouts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Save Arsenal Loadouts",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"arsenal_loadouts\": {\n \"npc_dota_hero_axe\": {\n \"weapon\": \"ars_abc123\",\n \"armor\": \"ars_def456\"\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_loadouts",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_loadouts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Arsenal Inventory",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_inventory",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_inventory"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Save Arsenal Inventory",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"arsenal_inventory\": {\n \"instances\": {\n \"ars_abc123\": {\n \"instanceId\": \"ars_abc123\",\n \"itemName\": \"sword_of_doom\",\n \"quality\": \"legendary\",\n \"upgradeLevel\": 3,\n \"serial\": 42,\n \"globalSerial\": 999,\n \"ownerName\": \"TestPlayer\",\n \"pinned\": true,\n \"favorite\": false,\n \"stats\": []\n }\n }\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_inventory",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_inventory"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Arsenal Marketplace",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Public Listings",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/arsenal_market/listings?stats=bonus_damage,attack_speed",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["arsenal_market", "listings"],
|
||||||
|
"query": [
|
||||||
|
{"key": "stats", "value": "bonus_damage,attack_speed", "disabled": true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get My Listings",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/my_listings",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "my_listings"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Market Slots",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/slots",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "slots"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get Sales History",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/sales",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "sales"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create Listing",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"instance_id\": \"ars_abc123\",\n \"instanceId\": \"ars_abc123\",\n \"item_instance_id\": \"ars_abc123\",\n \"itemInstanceId\": \"ars_abc123\",\n \"serial\": 42,\n \"global_serial\": 999,\n \"globalSerial\": 999,\n \"item_name\": \"sword_of_doom\",\n \"itemName\": \"sword_of_doom\",\n \"quality\": \"legendary\",\n \"upgrade_level\": 3,\n \"upgradeLevel\": 3,\n \"price_free\": 5000,\n \"priceFree\": 5000,\n \"request_id\": \"market_create_001\",\n \"requestId\": \"market_create_alt_001\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/create",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "create"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Buy Listing",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"listing_id\": \"list_xyz789\",\n \"request_id\": \"market_buy_001\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/buy",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "buy"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cancel Listing",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"listing_id\": \"list_xyz789\",\n \"request_id\": \"market_cancel_001\"\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/arsenal_market/cancel",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "arsenal_market", "cancel"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Death Sentence Contracts",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get Contracts",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/death_sentence_contracts",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "death_sentence_contracts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Save Contracts",
|
||||||
|
"request": {
|
||||||
|
"method": "PUT",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "{{x-custom-key}}",
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"death_sentence_contracts\": {\n \"roster\": [\n {\n \"instanceId\": \"dsc_abc123\",\n \"serial\": 1,\n \"titleIndex\": 0,\n \"rarity\": \"epic\",\n \"rewardMultiplier\": 2.5,\n \"traitId\": \"none\",\n \"complicationIds\": [\"comp_fire\", \"comp_poison\"],\n \"durability\": 3,\n \"durabilityMax\": 3,\n \"pinned\": false,\n \"favorite\": true\n }\n ]\n }\n}"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{base_url}}/player/{{steam_id}}/death_sentence_contracts",
|
||||||
|
"host": ["{{base_url}}"],
|
||||||
|
"path": ["player", "{{steam_id}}", "death_sentence_contracts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "base_url",
|
||||||
|
"value": "http://localhost:6100/api",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "x-custom-key",
|
||||||
|
"value": "localhost",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "steam_id",
|
||||||
|
"value": "76561198000000001",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "game_id",
|
||||||
|
"value": "12345",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user