# 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 (
{children}
); } ``` - [ ] **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 (

Admin Login

{error &&

{error}

} setPassword(e.target.value)} placeholder="Password" className="w-full px-3 py-2 bg-gray-700 rounded mb-4 text-white" autoFocus />
); } ``` - [ ] **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 (

Dashboard

{cards.map(c => (
{c.label}
{c.value}
))}
); } ``` - [ ] **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 (

Players

setSearch(e.target.value)} className="w-full max-w-md px-3 py-2 bg-gray-800 rounded mb-4 text-white" />
{filtered.map(p => ( window.location.href = `/admin/players/${p.steam_id}`}> ))}
Steam ID Name Level Free Donate Dust
{p.steam_id} {p.player_name} {p.profile_level} {p.free_currency} {p.donate_currency} {p.dust_currency}
); } ``` - [ ] **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

{bps.map(bp => ( ))}
Steam ID Level XP Premium
{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}

}

Settings

setEditLevel(Number(e.target.value))} className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
setEditXp(Number(e.target.value))} className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
setEditPremium(e.target.checked)} className="mt-2 block" />

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 (

Match History

setHeroFilter(e.target.value)} className="px-3 py-2 bg-gray-800 rounded text-white text-sm" />
{matches.map(m => ( ))}
Steam IDHeroResult DifficultyK/DDuration Date
{m.steam_id} {m.hero} {m.result} {m.difficulty} {m.kills}/{m.deaths} {Math.floor((m.duration || 0) / 60)}m {m.created_at}
); } ``` - [ ] **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

Create Code

setForm({...form, code: e.target.value})} className="px-2 py-1 bg-gray-700 rounded text-white text-sm" /> setForm({...form, free_currency: Number(e.target.value)})} className="px-2 py-1 bg-gray-700 rounded text-white text-sm" /> setForm({...form, donate_currency: Number(e.target.value)})} className="px-2 py-1 bg-gray-700 rounded text-white text-sm" /> setForm({...form, dust_currency: Number(e.target.value)})} className="px-2 py-1 bg-gray-700 rounded text-white text-sm" /> setForm({...form, max_uses: Number(e.target.value)})} className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
{codes.map(c => ( ))}
CodeFreeDonate DustUsesExpires
{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

{purchases.map(p => ( ))}
PlayerItemCategory CostDate
{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

{effects.map((e: any) => ( ))}
Steam IDEffects
{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

{contracts.map((c: any) => ( ))}
Steam IDContract DataUpdated
{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" ```