initial commit

This commit is contained in:
achmad
2026-05-29 20:04:31 +07:00
commit 78d0b17354
57 changed files with 8643 additions and 0 deletions
+29
View File
@@ -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*
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
data
README.md
+39
View File
@@ -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"]
+18
View File
@@ -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
+13
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
+2095
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+57
View File
@@ -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">&larr; 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} &mdash; {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 ? ' &mdash; Claimed' : ''}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+41
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+35
View File
@@ -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">&larr; 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 &rarr;</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>
);
}
+51
View File
@@ -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>
);
}
+71
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+28
View File
@@ -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);
}
+8
View File
@@ -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);
}
+19
View File
@@ -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 });
}
+14
View File
@@ -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 });
}
+15
View File
@@ -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 });
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+12
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/admin');
}
+212
View File
@@ -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'))
);
`);
}
+81
View File
@@ -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 };
});
+200
View File
@@ -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 };
});
+42
View File
@@ -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 };
});
+18
View File
@@ -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 };
});
+22
View File
@@ -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 };
});
+57
View File
@@ -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 };
});
+33
View File
@@ -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,
};
});
+56
View File
@@ -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 };
});
+51
View File
@@ -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),
};
});
+172
View File
@@ -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 };
});
+81
View File
@@ -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;
}
}
+28
View File
@@ -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');
}
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
};
export default config;
+21
View File
@@ -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)
+925
View File
@@ -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"
}
]
}