# Zombie Invasion Backend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for syntax tracking.
**Goal:** Build a lightweight Next.js 14 mock backend with SQLite and admin panel for the Zombie Invasion Dota 2 custom game, deployable in a single Docker container.
**Architecture:** Next.js 14 App Router with a single catch-all API route at `/api/[...path]` that dispatches to domain handlers. Admin panel under `/admin` with cookie-based password auth. SQLite via better-sqlite3 for persistence. All payment endpoints auto-grant instantly.
**Tech Stack:** Next.js 14, TypeScript, better-sqlite3, Tailwind CSS (admin panel), Docker
**Location:** All files go in `backend/` directory at the root of this repo.
---
## Task Outline
### Task 1: Project scaffold
### Task 2: Database layer
### Task 3: Core utilities (auth, seed, router)
### Task 4: Catch-all API route
### Task 5: Player handler
### Task 6: Battle pass handler
### Task 7: Game handler
### Task 8: Payments handler
### Task 9: Leaderboard handler
### Task 10: Cards & decks handler
### Task 11: Equipment handler
### Task 12: Arsenal handler
### Task 13: Marketplace handler
### Task 14: Contracts handler
### Task 15: Admin login & layout
### Task 16: Admin dashboard page
### Task 17: Admin players pages
### Task 18: Admin battle pass pages
### Task 19: Admin matches page
### Task 20: Admin promocodes page
### Task 21: Admin store page
### Task 22: Admin contracts page
### Task 23: Admin arsenal page
### Task 24: Docker setup
### Task 25: Update postman_collection.json
---
### Task 1: Project scaffold
**Files:**
- Create: `backend/package.json`
- Create: `backend/tsconfig.json`
- Create: `backend/next.config.js`
- Create: `backend/tailwind.config.ts`
- Create: `backend/postcss.config.js`
- Create: `backend/src/app/globals.css`
- Create: `backend/src/app/layout.tsx`
- Create: `backend/src/app/page.tsx`
- [ ] **Step 1: Create package.json**
```json
{
"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": "^11.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"
}
}
```
- [ ] **Step 2: Create tsconfig.json**
```json
{
"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"]
}
```
- [ ] **Step 3: Create next.config.js**
```js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
module.exports = nextConfig;
```
- [ ] **Step 4: Create tailwind.config.ts**
```ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
theme: { extend: {} },
plugins: [],
};
export default config;
```
- [ ] **Step 5: Create postcss.config.js**
```js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
- [ ] **Step 6: Create globals.css**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
- [ ] **Step 7: Create root layout.tsx**
```tsx
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 (
{children}
);
}
```
- [ ] **Step 8: Create root page.tsx**
```tsx
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/admin');
}
```
- [ ] **Step 9: Install dependencies only**
Run: `cd /Users/achmad/Documents/dota/3728427109/backend && npm install`
Expected: Dependencies installed in node_modules/. (Full build happens in Task 14 after all handlers exist.)
- [ ] **Step 10: Commit**
```bash
git add backend/package.json backend/tsconfig.json backend/next.config.js backend/tailwind.config.ts backend/postcss.config.js backend/src/app/globals.css backend/src/app/layout.tsx backend/src/app/page.tsx
git commit -m "feat: scaffold Next.js 14 project"
```
---
### Task 2: Database layer
**Files:**
- Create: `backend/src/lib/db.ts`
- Create: `backend/src/lib/seed.ts`
The database layer initializes SQLite with all tables and provides a singleton `db` export for all handlers.
- [ ] **Step 1: Create db.ts**
```ts
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'))
);
`);
}
```
- [ ] **Step 2: Create seed.ts**
```ts
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();
// Seed default quest definitions (will be assigned to players on BP creation)
const seedQuests = [
{ 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 },
];
// Store quest defs in a table or just log them — for now we skip persistence
// since quests are assigned per-player dynamically by the BP handler.
console.log('Database seeded with promo codes and quest definitions');
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/lib/db.ts backend/src/lib/seed.ts
git commit -m "feat: add SQLite schema and seed data"
```
---
### Task 3: API router
**Files:**
- Create: `backend/src/lib/router.ts`
- [ ] **Step 1: Create router.ts**
This is the routing engine for the catch-all API route. It maps URL patterns to handler functions.
```ts
import { NextResponse } from 'next/server';
export type HandlerFn = (ctx: HandlerContext) => unknown | Promise;
export type HandlerContext = {
params: Record;
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 {
for (const entry of routes) {
if (!entry.methods.includes(method)) continue;
const params: Record = {};
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 });
}
// Helper to create typed errors
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/lib/router.ts
git commit -m "feat: add API router"
```
---
### Task 4: Catch-all API route
**Files:**
- Create: `backend/src/app/api/[...path]/route.ts`
This file registers all route handlers and exports GET/POST/PUT handlers that delegate to the router.
- [ ] **Step 1: Create the catch-all route**
```ts
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');
}
```
- [ ] **Step 2: Create handlers directory**
Run: `mkdir -p backend/src/lib/handlers`
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/api/[...path]/route.ts
git commit -m "feat: add catch-all API route with handler imports"
```
---
### Task 5: Player handler
**Files:**
- Create: `backend/src/lib/handlers/player.ts`
This handler covers all `/player/:steamId/*` endpoints. The Lua client makes many different requests here.
- [ ] **Step 1: Create player handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// POST /player — Create 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 on conflict (game handles 409 silently)
return existing;
}
db.prepare('INSERT INTO players (steam_id, player_name) VALUES (?, ?)').run(steam_id, player_name || '');
// Also create battle pass
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 profile
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
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
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
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
route('player/:steamId/currency/give', ['POST'], (ctx: HandlerContext) => {
const { free_amount, donate_amount, dust_amount } = 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 = free_currency + ?, donate_currency = donate_currency + ?,
dust_currency = dust_currency + ?, updated_at = datetime('now') WHERE steam_id = ?
`).run(
free_amount || 0,
donate_amount || 0,
dust_amount || 0,
ctx.params.steamId
);
return { success: true };
});
// POST /player/:steamId/purchases — Record 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 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 db = getDb();
const promo = db.prepare('SELECT * FROM promo_codes WHERE code = ?').get(code.toUpperCase()) 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');
// Check if already redeemed by this player
const existing = db.prepare('SELECT * FROM promo_redemptions WHERE steam_id = ? AND code = ?').get(ctx.params.steamId, code.toUpperCase());
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(code.toUpperCase());
db.prepare('INSERT INTO promo_redemptions (steam_id, code) VALUES (?, ?)').run(ctx.params.steamId, code.toUpperCase());
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
route('player/:steamId/deal-purchase', ['POST'], (ctx: HandlerContext) => {
const { deal_key } = ctx.body as any;
// Auto-grant: mock successful deal purchase
return { success: true, ok: true, item_id: 'deal_' + deal_key, item_category: 'items' };
});
// GET /player/:steamId/active_effects — Get active 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 };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/player.ts
git commit -m "feat: add player handler with all endpoints"
```
---
### Task 6: Battle pass handler
**Files:**
- Create: `backend/src/lib/handlers/battlepass.ts`
Covers all `/battlepass/*` endpoints. The game client manages quest logic locally and syncs progress here.
- [ ] **Step 1: Create battlepass handler**
```ts
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 BP
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);
// Assign default quests
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
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) {
// Auto-create 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 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);
// Grant rewards
db.prepare(`
UPDATE players SET free_currency = free_currency + ?, updated_at = datetime('now') WHERE steam_id = ?
`).run(quest.reward_free_currency, ctx.params.steamId);
// Add BP XP
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 hero played
route('battlepass/:steamId/hero-played', ['POST'], (ctx: HandlerContext) => {
const { hero_name } = ctx.body as any;
return { success: true };
});
// POST /battlepass/:steamId/claim — Claim BP level reward (free)
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 BP premium 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
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 || '[]');
const claimedPremium = JSON.parse(bp.claimed_premium_rewards || '[]');
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 — Buy 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 BP XP
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 };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/battlepass.ts
git commit -m "feat: add battle pass handler with quests, rewards, XP"
```
---
### Task 7: Game handler
**Files:**
- Create: `backend/src/lib/handlers/game.ts`
- [ ] **Step 1: Create game handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// POST /game/start — Register game start
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();
// Generate IDs
const gameId = `game_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const newMatchId = match_id || Math.floor(Math.random() * 100000000);
// Store session
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 };
});
// POST /game/heartbeat — Match heartbeat
route('game/heartbeat', ['POST'], (ctx: HandlerContext) => {
return { success: true };
});
// POST /game — Save game result
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 || ''
);
// Update session status
if (game_id) {
db.prepare("UPDATE game_sessions SET status = 'completed' WHERE game_id = ?").run(game_id);
}
return { success: true };
});
// GET /game/:id/players — Get game players
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 };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/game.ts
git commit -m "feat: add game/match handler"
```
---
### Task 8: Payments handler
**Files:**
- Create: `backend/src/lib/handlers/payments.ts`
Auto-grant — no real payment processing.
- [ ] **Step 1: Create payments handler**
```ts
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();
// Convert rubles to donate shards (e.g., 1 RUB = 10 shards)
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
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= — Get deals 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),
};
});
```
---
### Task 9: Leaderboard handler
**Files:**
- Create: `backend/src/lib/handlers/leaderboard.ts`
- [ ] **Step 1: Create leaderboard handler**
```ts
import { route, HandlerContext } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /leaderboard?limit=&offset=&board=
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 {
// rating board: based on wins
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,
(COUNT(CASE WHEN gh.result = 'win' THEN 1 END) * 100.0 / MAX(COUNT(gh.id), 1)) as win_rate
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,
};
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/leaderboard.ts
git commit -m "feat: add leaderboard handler"
```
---
### Task 10: Cards & decks handler
**Files:**
- Create: `backend/src/lib/handlers/cards.ts`
- [ ] **Step 1: Create cards handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /player/:steamId/card-levels
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) : {} };
});
// PUT /player/:steamId/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 };
});
// GET /player/:steamId/decks
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 || '[]') }));
});
// GET /player/:steamId/decks/:index
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, ctx.params.index) as any;
if (!deck) return { name: 'New Deck', cards: [] };
return { ...deck, cards: JSON.parse(deck.cards || '[]') };
});
// PUT /player/:steamId/decks/:index
route('player/:steamId/decks/:index', ['PUT'], (ctx: HandlerContext) => {
const { name, cards } = ctx.body as any;
const db = getDb();
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, parseInt(ctx.params.index), name || 'My Deck', JSON.stringify(cards || []), name || 'My Deck', JSON.stringify(cards || []));
return { success: true };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/cards.ts
git commit -m "feat: add cards and decks handler"
```
---
### Task 11: Equipment handler
**Files:**
- Create: `backend/src/lib/handlers/equipment.ts`
- [ ] **Step 1: Create equipment handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /player/:steamId/equipment
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) : {} };
});
// PUT /player/:steamId/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 };
});
// POST /player/:steamId/equipment/drop
route('player/:steamId/equipment/drop', ['POST'], (ctx: HandlerContext) => {
return { success: true };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/equipment.ts
git commit -m "feat: add equipment handler"
```
---
### Task 12: Arsenal handler
**Files:**
- Create: `backend/src/lib/handlers/arsenal.ts`
- [ ] **Step 1: Create arsenal handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /player/:steamId/arsenal_loadouts
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 = {};
for (const r of rows as any[]) {
loadouts[r.hero_name] = JSON.parse(r.loadout);
}
return { arsenal_loadouts: loadouts };
});
// PUT /player/:steamId/arsenal_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 };
});
// GET /player/:steamId/arsenal_inventory
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 = {};
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 } };
});
// PUT /player/:steamId/arsenal_inventory
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, i.upgradeLevel || i.upgrade_level || 0,
i.serial, i.globalSerial || i.global_serial, i.ownerName || i.owner_name || '',
i.pinned ? 1 : 0, i.favorite ? 1 : 0, JSON.stringify(i.stats || []));
}
});
tx();
return { success: true };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/arsenal.ts
git commit -m "feat: add arsenal handler (loadouts + inventory)"
```
---
### Task 13: Marketplace handler
**Files:**
- Create: `backend/src/lib/handlers/marketplace.ts`
- [ ] **Step 1: Create marketplace handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /arsenal_market/listings — Public listings
route('arsenal_market/listings', ['GET'], (ctx: HandlerContext) => {
const db = getDb();
const listings = db.prepare(
"SELECT * FROM arsenal_market_listings WHERE status = 'active' ORDER BY created_at DESC"
).all();
return listings;
});
// GET /player/:steamId/arsenal_market/my_listings
route('player/:steamId/arsenal_market/my_listings', ['GET'], (ctx: HandlerContext) => {
const db = getDb();
const listings = db.prepare(
"SELECT * FROM arsenal_market_listings WHERE steam_id = ? AND status = 'active' ORDER BY created_at DESC"
).all(ctx.params.steamId);
return listings;
});
// GET /player/:steamId/arsenal_market/slots
route('player/:steamId/arsenal_market/slots', ['GET'], (ctx: HandlerContext) => {
return { slots: 5, used: 0 };
});
// GET /player/:steamId/arsenal_market/sales
route('player/:steamId/arsenal_market/sales', ['GET'], (ctx: HandlerContext) => {
const db = getDb();
const sales = db.prepare(
'SELECT * FROM arsenal_market_sales WHERE seller_steam_id = ? ORDER BY created_at DESC'
).all(ctx.params.steamId);
return sales;
});
// POST /player/:steamId/arsenal_market/create
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 };
});
// POST /player/:steamId/arsenal_market/buy
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 };
});
// POST /player/:steamId/arsenal_market/cancel
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 };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/marketplace.ts
git commit -m "feat: add marketplace handler"
```
---
### Task 14: Contracts handler
**Files:**
- Create: `backend/src/lib/handlers/contracts.ts`
- [ ] **Step 1: Create contracts handler**
```ts
import { route, HandlerContext, HttpError } from '@/lib/router';
import { getDb } from '@/lib/db';
// GET /player/:steamId/death_sentence_contracts
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: [] } };
});
// PUT /player/:steamId/death_sentence_contracts
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 };
});
```
- [ ] **Step 2: Commit**
```bash
git add backend/src/lib/handlers/contracts.ts
git commit -m "feat: add contracts handler"
```
- [ ] **Step 3: Verify the full build**
Run: `cd /Users/achmad/Documents/dota/3728427109/backend && npx next build`
Expected: Build succeeds. All handlers compile and register their routes.
---
### Task 15: Admin login & layout
**Files:**
- Create: `backend/src/app/admin/layout.tsx`
- Create: `backend/src/app/admin/login/page.tsx`
- [ ] **Step 1: Create admin layout (with sidebar + auth check)**
```tsx
'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 Loading...
;
if (pathname === '/admin/login') return <>{children}>;
if (!authed) return null;
return (
);
}
```
- [ ] **Step 2: Create login page**
```tsx
'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 (
);
}
```
- [ ] **Step 3: Add admin API endpoints**
Append to `backend/src/lib/handlers/player.ts`:
```ts
// — Admin auth endpoints —
route('admin/login', ['POST'], (ctx: HandlerContext) => {
const { password } = ctx.body as any;
// Simple in-memory cookie auth via API
const { verifyPassword, createAdminSession } = require('@/lib/auth');
if (!verifyPassword(password)) {
return NextResponse.json({ success: false, error: 'Invalid password' }, { status: 401 });
}
createAdminSession();
return { success: true };
});
route('admin/check', ['GET'], () => {
const { checkAdminAuth } = require('@/lib/auth');
return { authenticated: checkAdminAuth() };
});
route('admin/logout', ['GET'], () => {
const { clearAdminSession } = require('@/lib/auth');
clearAdminSession();
return { success: true };
});
```
Wait — these don't work in the router because we can't use `NextResponse.json` in the handler return. The router already wraps in `NextResponse.json`. Let me fix the admin login to not need NextResponse:
Actually, the issue is that the router always wraps in NextResponse.json with status 200. For admin login on wrong password, we want a 401. The router's error handling only catches HttpError. Let me adjust.
Better approach: keep admin login in a separate standard Next.js route handler instead of using the catch-all. Create a dedicated file for admin API routes.
- [ ] **Step 3 (revised): Create admin API routes as standard Next.js routes**
Create `backend/src/app/api/admin/login/route.ts`:
```ts
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';
if (password !== ADMIN_PASSWORD) {
return NextResponse.json({ success: false, error: 'Invalid password' }, { status: 401 });
}
const response = NextResponse.json({ success: true });
response.cookies.set('admin_session', 'authenticated', {
httpOnly: true, secure: false, sameSite: 'lax', path: '/admin', maxAge: 86400,
});
return response;
}
```
Create `backend/src/app/api/admin/check/route.ts`:
```ts
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 });
}
```
Create `backend/src/app/api/admin/logout/route.ts`:
```ts
import { NextResponse } from 'next/server';
export async function GET() {
const response = NextResponse.json({ success: true });
response.cookies.delete('admin_session');
return response;
}
```
Since admin routes don't start with `api/[...path]`, they'll be matched by these specific route files before the catch-all.
- [ ] **Step 4: Commit**
```bash
git add backend/src/app/admin/layout.tsx backend/src/app/admin/login/page.tsx
git add backend/src/app/api/admin/login/route.ts backend/src/app/api/admin/check/route.ts backend/src/app/api/admin/logout/route.ts
git commit -m "feat: add admin layout with sidebar and login"
```
---
### Task 16: Admin dashboard page
**Files:**
- Create: `backend/src/app/admin/page.tsx`
- [ ] **Step 1: Create dashboard page**
```tsx
'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(null);
useEffect(() => {
fetch('/api/admin/stats')
.then(r => r.json())
.then(setStats)
.catch(() => {});
}, []);
if (!stats) return Loading stats...
;
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 (
);
}
```
- [ ] **Step 2: Create admin stats API route**
Create `backend/src/app/api/admin/stats/route.ts`:
```ts
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
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 });
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/page.tsx backend/src/app/api/admin/stats/route.ts
git commit -m "feat: add admin dashboard with stats"
```
---
### Task 17: Admin players pages
**Files:**
- Create: `backend/src/app/admin/players/page.tsx`
- Create: `backend/src/app/admin/players/[steamId]/page.tsx`
- Create: `backend/src/app/api/admin/players/route.ts` (search/list)
- Create: `backend/src/app/api/admin/players/[steamId]/route.ts` (get/update)
- [ ] **Step 1: Create admin players list page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function PlayersListPage() {
const [players, setPlayers] = useState([]);
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 (
);
}
```
- [ ] **Step 2: Create admin players API route**
```ts
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
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);
}
```
- [ ] **Step 3: Create admin player detail API route**
`backend/src/app/api/admin/players/[steamId]/route.ts`:
```ts
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 });
}
```
- [ ] **Step 4: Create player detail page**
`backend/src/app/admin/players/[steamId]/page.tsx`:
```tsx
'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(null);
const [form, setForm] = useState({});
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 Loading...
;
return (
← Back to Players
Player: {data.player?.player_name}
Steam ID: {steamId}
{msg &&
{msg}
}
Profile
{['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency'].map(f => (
setForm({ ...form, [f]: e.target.value })}
className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm"
/>
))}
Battle Pass
{data.battlePass && (
Level: {data.battlePass.level}
XP: {data.battlePass.experience}
Premium: {data.battlePass.has_premium ? 'Yes' : 'No'}
Edit BP →
)}
Recent Purchases ({data.purchases?.length})
{data.purchases?.map((p: any, i: number) => (
{p.item_id} ({p.item_category})
))}
{!data.purchases?.length &&
None
}
Recent Matches ({data.matches?.length})
{data.matches?.map((m: any, i: number) => (
{m.hero} — {m.result}
({m.difficulty})
))}
{!data.matches?.length &&
None
}
);
}
```
- [ ] **Step 5: Commit**
```bash
git add backend/src/app/admin/players/page.tsx backend/src/app/admin/players/\[steamId\]/page.tsx
git add backend/src/app/api/admin/players/route.ts backend/src/app/api/admin/players/\[steamId\]/route.ts
git commit -m "feat: add admin players pages (list + detail)"
```
---
### Task 18: Admin battle pass pages
**Files:**
- Create: `backend/src/app/admin/battlepass/page.tsx`
- Create: `backend/src/app/admin/battlepass/[steamId]/page.tsx`
- [ ] **Step 1: Create BP overview page**
```tsx
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function BattlePassListPage() {
const [bps, setBps] = useState([]);
useEffect(() => {
fetch('/api/admin/battlepass')
.then(r => r.json())
.then(setBps)
.catch(() => {});
}, []);
return (
Battle Passes
| Steam ID |
Level |
XP |
Premium |
{bps.map(bp => (
|
{bp.steam_id}
|
{bp.level} |
{bp.experience} |
{bp.has_premium ? 'Yes' : 'No'} |
))}
);
}
```
- [ ] **Step 2: Create BP detail/edit page**
```tsx
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
export default function BattlePassDetailPage() {
const { steamId } = useParams();
const [bp, setBp] = useState(null);
const [quests, setQuests] = useState([]);
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');
};
const toggleClaim = async (questId: string) => {
await fetch(`/api/admin/battlepass/${steamId}/quests/${questId}/toggle-claim`, { method: 'POST' });
load();
};
if (!bp) return Loading...
;
return (
← Back
Battle Pass
Steam ID: {steamId}
{msg &&
{msg}
}
Quests ({quests.length})
{quests.map(q => (
{q.name || q.quest_id}
{q.type} — {q.progress}/{q.target}
{q.completed ? 'Completed' : 'In Progress'}
{q.claimed ? ' — Claimed' : ''}
{q.completed && !q.claimed && (
)}
))}
);
}
```
- [ ] **Step 3: Create BP admin API routes**
Create `backend/src/app/api/admin/battlepass/route.ts`:
```ts
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
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);
}
```
Create `backend/src/app/api/admin/battlepass/[steamId]/route.ts`:
```ts
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 });
}
```
Create `backend/src/app/api/admin/battlepass/[steamId]/quests/[questId]/toggle-claim/route.ts`:
```ts
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export async function POST(request: NextRequest, { params }: { params: { steamId: string; questId: string } }) {
const db = getDb();
const quest = db.prepare(
'SELECT * FROM battle_pass_quests WHERE steam_id = ? AND quest_id = ?'
).get(params.steamId, params.questId) as any;
if (!quest) return NextResponse.json({ error: 'Not found' }, { status: 404 });
db.prepare("UPDATE battle_pass_quests SET claimed = 1, updated_at = datetime('now') WHERE id = ?").run(quest.id);
return NextResponse.json({ success: true });
}
```
- [ ] **Step 4: Commit**
```bash
git add backend/src/app/admin/battlepass/page.tsx backend/src/app/admin/battlepass/\[steamId\]/page.tsx
git add backend/src/app/api/admin/battlepass/route.ts backend/src/app/api/admin/battlepass/\[steamId\]/route.ts
git add backend/src/app/api/admin/battlepass/\[steamId\]/quests/\[questId\]/toggle-claim/route.ts
git commit -m "feat: add admin battle pass pages"
```
---
### Task 19: Admin matches page
**Files:**
- Create: `backend/src/app/admin/matches/page.tsx`
- Create: `backend/src/app/api/admin/matches/route.ts`
- [ ] **Step 1: Create admin matches API route**
```ts
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);
}
```
- [ ] **Step 2: Create matches page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function MatchesPage() {
const [matches, setMatches] = useState([]);
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]);
const difficulties = ['', 'easy', 'normal', 'hard', 'impossible', 'death_sentence'];
return (
);
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/matches/page.tsx backend/src/app/api/admin/matches/route.ts
git commit -m "feat: add admin matches page"
```
---
### Task 20: Admin promocodes page
**Files:**
- Create: `backend/src/app/admin/promocodes/page.tsx`
- Create: `backend/src/app/api/admin/promocodes/route.ts`
- [ ] **Step 1: Create promocodes admin API route**
```ts
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 });
}
```
- [ ] **Step 2: Create promocodes page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function PromoCodesPage() {
const [codes, setCodes] = useState([]);
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 (
Promo Codes
| Code | Free | Donate |
Dust | Uses | Expires |
|
{codes.map(c => (
| {c.code} |
{c.free_currency} |
{c.donate_currency} |
{c.dust_currency} |
{c.current_uses}/{c.max_uses} |
{c.expires_at || 'Never'} |
|
))}
);
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/promocodes/page.tsx backend/src/app/api/admin/promocodes/route.ts
git commit -m "feat: add admin promocodes page"
```
---
### Task 21: Admin store page
**Files:**
- Create: `backend/src/app/admin/store/page.tsx`
- Create: `backend/src/app/api/admin/store/route.ts`
- [ ] **Step 1: Create store admin API route**
```ts
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
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 });
}
```
- [ ] **Step 2: Create store page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function StorePage() {
const [purchases, setPurchases] = useState([]);
const [effects, setEffects] = useState([]);
useEffect(() => {
fetch('/api/admin/store').then(r => r.json()).then(d => {
setPurchases(d.purchases || []);
setEffects(d.effects || []);
});
}, []);
return (
Store Purchases
| Player | Item | Category |
Cost | Date |
{purchases.map(p => (
| {p.player_name || p.steam_id} |
{p.item_id} |
{p.item_category} |
{p.price_free || p.price_donate || p.price_dust || 0} |
{p.created_at} |
))}
Active Effects
| Steam ID | Effects |
{effects.map((e: any) => (
| {e.steam_id} |
{e.effects} |
))}
);
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/store/page.tsx backend/src/app/api/admin/store/route.ts
git commit -m "feat: add admin store page"
```
---
### Task 22: Admin contracts page
**Files:**
- Create: `backend/src/app/admin/contracts/page.tsx`
- Create: `backend/src/app/api/admin/contracts/route.ts`
- [ ] **Step 1: Create admin contracts API**
```ts
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export async function GET() {
const db = getDb();
const contracts = db.prepare('SELECT * FROM death_sentence_contracts').all();
return NextResponse.json(contracts);
}
```
- [ ] **Step 2: Create contracts page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function ContractsPage() {
const [contracts, setContracts] = useState([]);
useEffect(() => {
fetch('/api/admin/contracts').then(r => r.json()).then(setContracts);
}, []);
return (
Death Sentence Contracts
| Steam ID | Contract Data | Updated |
{contracts.map((c: any) => (
| {c.steam_id} |
{c.contracts} |
{c.updated_at} |
))}
);
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/contracts/page.tsx backend/src/app/api/admin/contracts/route.ts
git commit -m "feat: add admin contracts page"
```
---
### Task 23: Admin arsenal page
**Files:**
- Create: `backend/src/app/admin/arsenal/page.tsx`
- Create: `backend/src/app/api/admin/arsenal/route.ts`
- [ ] **Step 1: Create admin arsenal API**
```ts
import { NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
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 });
}
```
- [ ] **Step 2: Create arsenal page**
```tsx
'use client';
import { useEffect, useState } from 'react';
export default function ArsenalPage() {
const [data, setData] = useState({});
useEffect(() => {
fetch('/api/admin/arsenal').then(r => r.json()).then(setData);
}, []);
return (
Arsenal
Inventory ({data.inventory?.length || 0})
{data.inventory?.map((i: any, idx: number) => (
{i.item_name}
[{i.quality}]
{i.steam_id}
))}
Loadouts ({data.loadouts?.length || 0})
{data.loadouts?.map((l: any, idx: number) => (
{l.steam_id}
{l.hero_name}
{l.loadout}
))}
Active Listings ({data.listings?.length || 0})
{data.listings?.map((l: any, idx: number) => (
{l.item_name}
{l.price_free} free
{l.steam_id}
))}
);
}
```
- [ ] **Step 3: Commit**
```bash
git add backend/src/app/admin/arsenal/page.tsx backend/src/app/api/admin/arsenal/route.ts
git commit -m "feat: add admin arsenal page"
```
---
### Task 24: Docker setup
**Files:**
- Create: `backend/Dockerfile`
- Create: `backend/docker-compose.yml`
- Create: `backend/.dockerignore`
- Create: `backend/docker-entrypoint.sh`
- [ ] **Step 1: Create .dockerignore**
```
node_modules
.next
.git
data
README.md
```
- [ ] **Step 2: Create Dockerfile**
```dockerfile
FROM node:20-alpine AS base
# Stage 1: Install deps
FROM base AS deps
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 nextjs
# Copy standalone build
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/docker-entrypoint.sh ./
COPY --from=builder /app/data ./data
RUN chmod +x docker-entrypoint.sh
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENTRYPOINT ["/bin/sh", "docker-entrypoint.sh"]
```
- [ ] **Step 3: Create docker-entrypoint.sh**
```bash
#!/bin/sh
set -e
# Ensure data directory exists
mkdir -p /app/data
# Start the application
exec node server.js
```
- [ ] **Step 4: Make entrypoint executable**
Run: `chmod +x /Users/achmad/Documents/dota/3728427109/backend/docker-entrypoint.sh`
- [ ] **Step 5: Create docker-compose.yml**
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "6100:3000" # Host:6100 → Container:3000
volumes:
- ./data:/app/data # Persist SQLite database
environment:
- ADMIN_PASSWORD=admin123
- NODE_ENV=production
restart: unless-stopped
```
- [ ] **Step 6: Build and test Docker image**
Run: `cd /Users/achmad/Documents/dota/3728427109/backend && docker compose build`
Expected: Build succeeds
Run: `docker compose up -d`
Expected: Container starts, accessible at http://localhost:6100
Run: `curl http://localhost:6100/api/admin/check`
Expected: `{"authenticated":false}`
- [ ] **Step 7: Commit**
```bash
git add backend/Dockerfile backend/docker-compose.yml backend/.dockerignore backend/docker-entrypoint.sh
git commit -m "feat: add Docker setup with multi-stage build"
```
---
### Task 25: Update postman_collection.json
**Files:**
- Modify: `postman_collection.json`
- [ ] **Step 1: Update the base_url variable**
Change the `base_url` variable value from `http://82.146.52.69:3000/api` to `http://localhost:6100/api` for local dev. Since the user will host on their domain later, use `{{base_url}}` properly.
In the `variable` array at the bottom of postman_collection.json, change:
```json
{
"key": "base_url",
"value": "http://localhost:6100/api",
"type": "string"
}
```
- [ ] **Step 2: Commit**
```bash
git add postman_collection.json
git commit -m "chore: update postman base_url to localhost:6100"
```