Files
Dota-Zombie-Invasion-Backend/docs/superpowers/plans/2026-05-29-zombie-invasion-backend.md
T
2026-05-29 20:04:31 +07:00

103 KiB

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

{
  "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
{
  "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
/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
};

module.exports = nextConfig;
  • Step 4: Create tailwind.config.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
module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
  • Step 6: Create globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
  • Step 7: Create root layout.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 (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
  • Step 8: Create root page.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
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
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
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
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.

import { NextResponse } from 'next/server';

export type HandlerFn = (ctx: HandlerContext) => unknown | Promise<unknown>;

export type HandlerContext = {
  params: Record<string, string>;
  method: string;
  body: unknown;
  searchParams: URLSearchParams;
};

type RouteEntry = {
  pattern: string[];
  methods: string[];
  handler: HandlerFn;
};

const routes: RouteEntry[] = [];

export function route(pattern: string, methods: string[], handler: HandlerFn) {
  const parts = pattern.split('/').filter(Boolean);
  routes.push({ pattern: parts, methods: methods.map(m => m.toUpperCase()), handler });
}

export async function dispatch(
  request: Request,
  pathSegments: string[],
  method: string
): Promise<NextResponse> {
  for (const entry of routes) {
    if (!entry.methods.includes(method)) continue;
    const params: Record<string, string> = {};
    let match = true;

    if (entry.pattern.length !== pathSegments.length) continue;

    for (let i = 0; i < entry.pattern.length; i++) {
      const ep = entry.pattern[i];
      const sp = pathSegments[i];
      if (ep.startsWith(':')) {
        params[ep.slice(1)] = sp;
      } else if (ep !== sp) {
        match = false;
        break;
      }
    }

    if (!match) continue;

    let body: unknown = undefined;
    const ct = request.headers.get('content-type') || '';
    if (ct.includes('application/json')) {
      try { body = await request.json(); } catch { body = undefined; }
    }

    const ctx: HandlerContext = {
      params,
      method,
      body,
      searchParams: new URL(request.url).searchParams,
    };

    try {
      const result = await entry.handler(ctx);
      return NextResponse.json(result, { status: 200 });
    } catch (err: any) {
      const status = err.status || 500;
      return NextResponse.json({ error: err.message || 'Internal error' }, { status });
    }
  }

  return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

// 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
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
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
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
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
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
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
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

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
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
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

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
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

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
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

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
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

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<string, any> = {};
  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<string, any> = {};
  for (const item of items as any[]) {
    instances[item.instance_id] = {
      instanceId: item.instance_id,
      itemName: item.item_name,
      quality: item.quality,
      upgradeLevel: item.upgrade_level,
      serial: item.serial,
      globalSerial: item.global_serial,
      ownerName: item.owner_name,
      pinned: !!item.pinned,
      favorite: !!item.favorite,
      stats: JSON.parse(item.stats || '[]'),
    };
  }
  return { arsenal_inventory: { instances } };
});

// 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
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

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
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

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
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)

'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

const NAV = [
  { href: '/admin', label: 'Dashboard' },
  { href: '/admin/players', label: 'Players' },
  { href: '/admin/battlepass', label: 'Battle Pass' },
  { href: '/admin/matches', label: 'Matches' },
  { href: '/admin/promocodes', label: 'Promo Codes' },
  { href: '/admin/store', label: 'Store' },
  { href: '/admin/contracts', label: 'Contracts' },
  { href: '/admin/arsenal', label: 'Arsenal' },
];

export default function AdminLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const router = useRouter();
  const [authed, setAuthed] = useState(false);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (pathname === '/admin/login') {
      setLoading(false);
      return;
    }
    fetch('/api/admin/check')
      .then(r => r.json())
      .then(d => {
        if (d.authenticated) setAuthed(true);
        else router.push('/admin/login');
      })
      .catch(() => router.push('/admin/login'))
      .finally(() => setLoading(false));
  }, [pathname, router]);

  if (loading) return <div className="p-8 text-gray-400">Loading...</div>;
  if (pathname === '/admin/login') return <>{children}</>;
  if (!authed) return null;

  return (
    <div className="min-h-screen bg-gray-900 text-gray-100 flex">
      <nav className="w-56 bg-gray-800 p-4 flex flex-col gap-1 shrink-0">
        <h1 className="text-lg font-bold mb-4 px-3 text-amber-400">Zombie Admin</h1>
        {NAV.map(item => (
          <a
            key={item.href}
            href={item.href}
            className={`px-3 py-2 rounded hover:bg-gray-700 transition-colors ${
              pathname === item.href || pathname.startsWith(item.href + '/') ? 'bg-gray-700 text-amber-300' : ''
            }`}
          >
            {item.label}
          </a>
        ))}
        <div className="mt-auto pt-4">
          <a href="/api/admin/logout" className="px-3 py-2 text-red-400 hover:text-red-300 block text-sm">Logout</a>
        </div>
      </nav>
      <main className="flex-1 p-6 overflow-auto">{children}</main>
    </div>
  );
}
  • Step 2: Create login page
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError('');
    const res = await fetch('/api/admin/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ password }),
    });
    const data = await res.json();
    if (data.success) router.push('/admin');
    else setError(data.error || 'Login failed');
  };

  return (
    <div className="min-h-screen bg-gray-900 flex items-center justify-center">
      <form onSubmit={handleSubmit} className="bg-gray-800 p-8 rounded-lg w-80">
        <h1 className="text-2xl font-bold mb-6 text-amber-400">Admin Login</h1>
        {error && <p className="text-red-400 mb-4 text-sm">{error}</p>}
        <input
          type="password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          placeholder="Password"
          className="w-full px-3 py-2 bg-gray-700 rounded mb-4 text-white"
          autoFocus
        />
        <button type="submit" className="w-full bg-amber-500 hover:bg-amber-600 text-black font-semibold py-2 rounded">
          Login
        </button>
      </form>
    </div>
  );
}
  • Step 3: Add admin API endpoints

Append to backend/src/lib/handlers/player.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:

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:

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:

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
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

'use client';
import { useEffect, useState } from 'react';

type Stats = {
  players: number;
  games: number;
  activeBps: number;
  questsCompleted: number;
};

export default function DashboardPage() {
  const [stats, setStats] = useState<Stats | null>(null);

  useEffect(() => {
    fetch('/api/admin/stats')
      .then(r => r.json())
      .then(setStats)
      .catch(() => {});
  }, []);

  if (!stats) return <div className="text-gray-400">Loading stats...</div>;

  const cards = [
    { label: 'Players', value: stats.players, color: 'text-blue-400' },
    { label: 'Games Played', value: stats.games, color: 'text-green-400' },
    { label: 'Active Battle Passes', value: stats.activeBps, color: 'text-amber-400' },
    { label: 'Quests Completed', value: stats.questsCompleted, color: 'text-purple-400' },
  ];

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
        {cards.map(c => (
          <div key={c.label} className="bg-gray-800 rounded-lg p-5">
            <div className="text-sm text-gray-400 mb-1">{c.label}</div>
            <div className={`text-3xl font-bold ${c.color}`}>{c.value}</div>
          </div>
        ))}
      </div>
    </div>
  );
}
  • Step 2: Create admin stats API route

Create backend/src/app/api/admin/stats/route.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
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

'use client';
import { useEffect, useState } from 'react';

export default function PlayersListPage() {
  const [players, setPlayers] = useState<any[]>([]);
  const [search, setSearch] = useState('');

  useEffect(() => {
    fetch('/api/admin/players')
      .then(r => r.json())
      .then(setPlayers)
      .catch(() => {});
  }, []);

  const filtered = players.filter(p =>
    p.steam_id?.includes(search) || p.player_name?.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Players</h1>
      <input
        type="text"
        placeholder="Search by steam_id or name..."
        value={search}
        onChange={e => setSearch(e.target.value)}
        className="w-full max-w-md px-3 py-2 bg-gray-800 rounded mb-4 text-white"
      />
      <div className="bg-gray-800 rounded-lg overflow-hidden">
        <table className="w-full text-left">
          <thead>
            <tr className="border-b border-gray-700 text-sm text-gray-400">
              <th className="p-3">Steam ID</th>
              <th className="p-3">Name</th>
              <th className="p-3">Level</th>
              <th className="p-3">Free</th>
              <th className="p-3">Donate</th>
              <th className="p-3">Dust</th>
            </tr>
          </thead>
          <tbody>
            {filtered.map(p => (
              <tr key={p.steam_id} className="border-b border-gray-700 hover:bg-gray-750 cursor-pointer" onClick={() => window.location.href = `/admin/players/${p.steam_id}`}>
                <td className="p-3 font-mono text-sm">{p.steam_id}</td>
                <td className="p-3">{p.player_name}</td>
                <td className="p-3">{p.profile_level}</td>
                <td className="p-3">{p.free_currency}</td>
                <td className="p-3">{p.donate_currency}</td>
                <td className="p-3">{p.dust_currency}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 2: Create admin players API route
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:

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:

'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';

export default function PlayerDetailPage() {
  const { steamId } = useParams();
  const router = useRouter();
  const [data, setData] = useState<any>(null);
  const [form, setForm] = useState<any>({});
  const [msg, setMsg] = useState('');

  useEffect(() => {
    fetch(`/api/admin/players/${steamId}`)
      .then(r => r.json())
      .then(d => {
        setData(d);
        setForm(d.player || {});
      })
      .catch(() => router.push('/admin/players'));
  }, [steamId, router]);

  const save = async () => {
    const res = await fetch(`/api/admin/players/${steamId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    const result = await res.json();
    setMsg(result.success ? 'Saved!' : 'Error: ' + (result.error || ''));
  };

  if (!data) return <div className="text-gray-400">Loading...</div>;

  return (
    <div>
      <a href="/admin/players" className="text-amber-400 mb-4 block">&larr; Back to Players</a>
      <h1 className="text-2xl font-bold mb-4">Player: {data.player?.player_name}</h1>
      <p className="font-mono text-sm text-gray-400 mb-4">Steam ID: {steamId}</p>

      {msg && <p className="mb-4 text-sm" style={{ color: msg.startsWith('Saved') ? '#4ade80' : '#f87171' }}>{msg}</p>}

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Profile</h2>
          <div className="grid grid-cols-2 gap-3">
            {['player_name', 'profile_level', 'free_currency', 'donate_currency', 'dust_currency'].map(f => (
              <div key={f}>
                <label className="text-xs text-gray-400 block mb-1">{f}</label>
                <input
                  type={f === 'player_name' ? 'text' : 'number'}
                  value={(form as any)[f] ?? ''}
                  onChange={e => setForm({ ...form, [f]: e.target.value })}
                  className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm"
                />
              </div>
            ))}
          </div>
          <button onClick={save} className="mt-4 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold">Save</button>
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Battle Pass</h2>
          {data.battlePass && (
            <div className="text-sm space-y-1">
              <p>Level: {data.battlePass.level}</p>
              <p>XP: {data.battlePass.experience}</p>
              <p>Premium: {data.battlePass.has_premium ? 'Yes' : 'No'}</p>
              <a href={`/admin/battlepass/${steamId}`} className="text-amber-400 block mt-2">Edit BP &rarr;</a>
            </div>
          )}
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Recent Purchases ({data.purchases?.length})</h2>
          <div className="max-h-48 overflow-y-auto text-sm space-y-1">
            {data.purchases?.map((p: any, i: number) => (
              <div key={i} className="text-gray-300">{p.item_id} <span className="text-gray-500">({p.item_category})</span></div>
            ))}
            {!data.purchases?.length && <p className="text-gray-500">None</p>}
          </div>
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Recent Matches ({data.matches?.length})</h2>
          <div className="max-h-48 overflow-y-auto text-sm space-y-1">
            {data.matches?.map((m: any, i: number) => (
              <div key={i} className="text-gray-300">
                {m.hero}  <span className={m.result === 'win' ? 'text-green-400' : 'text-red-400'}>{m.result}</span>
                <span className="text-gray-500"> ({m.difficulty})</span>
              </div>
            ))}
            {!data.matches?.length && <p className="text-gray-500">None</p>}
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 5: Commit
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

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';

export default function BattlePassListPage() {
  const [bps, setBps] = useState<any[]>([]);

  useEffect(() => {
    fetch('/api/admin/battlepass')
      .then(r => r.json())
      .then(setBps)
      .catch(() => {});
  }, []);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Battle Passes</h1>
      <div className="bg-gray-800 rounded-lg overflow-hidden">
        <table className="w-full text-left">
          <thead>
            <tr className="border-b border-gray-700 text-sm text-gray-400">
              <th className="p-3">Steam ID</th>
              <th className="p-3">Level</th>
              <th className="p-3">XP</th>
              <th className="p-3">Premium</th>
            </tr>
          </thead>
          <tbody>
            {bps.map(bp => (
              <tr key={bp.steam_id} className="border-b border-gray-700 hover:bg-gray-750">
                <td className="p-3">
                  <Link href={`/admin/battlepass/${bp.steam_id}`} className="text-amber-400 font-mono text-sm">{bp.steam_id}</Link>
                </td>
                <td className="p-3">{bp.level}</td>
                <td className="p-3">{bp.experience}</td>
                <td className="p-3">{bp.has_premium ? 'Yes' : 'No'}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 2: Create BP detail/edit page
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';

export default function BattlePassDetailPage() {
  const { steamId } = useParams();
  const [bp, setBp] = useState<any>(null);
  const [quests, setQuests] = useState<any[]>([]);
  const [editLevel, setEditLevel] = useState(0);
  const [editXp, setEditXp] = useState(0);
  const [editPremium, setEditPremium] = useState(false);
  const [msg, setMsg] = useState('');

  const load = () => {
    fetch(`/api/admin/battlepass/${steamId}`)
      .then(r => r.json())
      .then(d => {
        setBp(d.battlePass);
        setQuests(d.quests || []);
        setEditLevel(d.battlePass?.level || 0);
        setEditXp(d.battlePass?.experience || 0);
        setEditPremium(d.battlePass?.has_premium === 1);
      })
      .catch(() => {});
  };

  useEffect(load, [steamId]);

  const saveBp = async () => {
    const res = await fetch(`/api/admin/battlepass/${steamId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ level: editLevel, experience: editXp, has_premium: editPremium }),
    });
    const d = await res.json();
    setMsg(d.success ? 'Saved!' : 'Error');
  };

  const toggleClaim = async (questId: string) => {
    await fetch(`/api/admin/battlepass/${steamId}/quests/${questId}/toggle-claim`, { method: 'POST' });
    load();
  };

  if (!bp) return <div className="text-gray-400">Loading...</div>;

  return (
    <div>
      <a href="/admin/battlepass" className="text-amber-400 mb-4 block">&larr; Back</a>
      <h1 className="text-2xl font-bold mb-2">Battle Pass</h1>
      <p className="font-mono text-sm text-gray-400 mb-4">Steam ID: {steamId}</p>

      {msg && <p className="mb-4 text-sm text-green-400">{msg}</p>}

      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Settings</h2>
          <div className="grid grid-cols-3 gap-3">
            <div>
              <label className="text-xs text-gray-400 block mb-1">Level</label>
              <input type="number" value={editLevel} onChange={e => setEditLevel(Number(e.target.value))}
                className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
            </div>
            <div>
              <label className="text-xs text-gray-400 block mb-1">XP</label>
              <input type="number" value={editXp} onChange={e => setEditXp(Number(e.target.value))}
                className="w-full px-2 py-1 bg-gray-700 rounded text-white text-sm" />
            </div>
            <div>
              <label className="text-xs text-gray-400 block mb-1">Premium</label>
              <input type="checkbox" checked={editPremium} onChange={e => setEditPremium(e.target.checked)}
                className="mt-2 block" />
            </div>
          </div>
          <button onClick={saveBp} className="mt-4 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold text-sm">Save BP</button>
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Quests ({quests.length})</h2>
          <div className="space-y-2 max-h-96 overflow-y-auto">
            {quests.map(q => (
              <div key={q.id} className="bg-gray-700 rounded p-3 text-sm flex items-center justify-between">
                <div>
                  <div className="text-amber-300">{q.name || q.quest_id}</div>
                  <div className="text-gray-400 text-xs">{q.type}  {q.progress}/{q.target}</div>
                  <div className="text-xs">
                    <span className={q.completed ? 'text-green-400' : 'text-yellow-400'}>
                      {q.completed ? 'Completed' : 'In Progress'}
                    </span>
                    {q.claimed ? ' — Claimed' : ''}
                  </div>
                </div>
                {q.completed && !q.claimed && (
                  <button onClick={() => toggleClaim(q.quest_id)}
                    className="bg-blue-500 hover:bg-blue-600 text-white px-2 py-1 rounded text-xs">Claim</button>
                )}
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 3: Create BP admin API routes

Create backend/src/app/api/admin/battlepass/route.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:

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:

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
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

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
'use client';
import { useEffect, useState } from 'react';

export default function MatchesPage() {
  const [matches, setMatches] = useState<any[]>([]);
  const [heroFilter, setHeroFilter] = useState('');
  const [diffFilter, setDiffFilter] = useState('');

  const load = () => {
    const params = new URLSearchParams();
    if (heroFilter) params.set('hero', heroFilter);
    if (diffFilter) params.set('difficulty', diffFilter);
    fetch(`/api/admin/matches?${params}`)
      .then(r => r.json())
      .then(setMatches)
      .catch(() => {});
  };

  useEffect(load, [heroFilter, diffFilter]);

  const difficulties = ['', 'easy', 'normal', 'hard', 'impossible', 'death_sentence'];

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Match History</h1>
      <div className="flex gap-4 mb-4">
        <input type="text" placeholder="Filter by hero..." value={heroFilter} onChange={e => setHeroFilter(e.target.value)}
          className="px-3 py-2 bg-gray-800 rounded text-white text-sm" />
        <select value={diffFilter} onChange={e => setDiffFilter(e.target.value)}
          className="px-3 py-2 bg-gray-800 rounded text-white text-sm">
          {difficulties.map(d => <option key={d} value={d}>{d || 'All Difficulties'}</option>)}
        </select>
      </div>
      <div className="bg-gray-800 rounded-lg overflow-x-auto">
        <table className="w-full text-left text-sm">
          <thead>
            <tr className="border-b border-gray-700 text-gray-400">
              <th className="p-2">Steam ID</th><th className="p-2">Hero</th><th className="p-2">Result</th>
              <th className="p-2">Difficulty</th><th className="p-2">K/D</th><th className="p-2">Duration</th>
              <th className="p-2">Date</th>
            </tr>
          </thead>
          <tbody>
            {matches.map(m => (
              <tr key={m.id} className="border-b border-gray-700">
                <td className="p-2 font-mono text-xs">{m.steam_id}</td>
                <td className="p-2">{m.hero}</td>
                <td className={`p-2 font-semibold ${m.result === 'win' ? 'text-green-400' : 'text-red-400'}`}>{m.result}</td>
                <td className="p-2">{m.difficulty}</td>
                <td className="p-2">{m.kills}/{m.deaths}</td>
                <td className="p-2">{Math.floor((m.duration || 0) / 60)}m</td>
                <td className="p-2 text-gray-400 text-xs">{m.created_at}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 3: Commit
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

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
'use client';
import { useEffect, useState } from 'react';

export default function PromoCodesPage() {
  const [codes, setCodes] = useState<any[]>([]);
  const [form, setForm] = useState({ code: '', free_currency: 0, donate_currency: 0, dust_currency: 0, max_uses: 1 });

  const load = () => { fetch('/api/admin/promocodes').then(r => r.json()).then(setCodes); };
  useEffect(load, []);

  const create = async () => {
    await fetch('/api/admin/promocodes', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    setForm({ code: '', free_currency: 0, donate_currency: 0, dust_currency: 0, max_uses: 1 });
    load();
  };

  const del = async (code: string) => {
    await fetch(`/api/admin/promocodes?code=${code}`, { method: 'DELETE' });
    load();
  };

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Promo Codes</h1>

      <div className="bg-gray-800 rounded-lg p-4 mb-6">
        <h2 className="text-lg font-semibold mb-3">Create Code</h2>
        <div className="grid grid-cols-5 gap-3">
          <input type="text" placeholder="CODE" value={form.code} onChange={e => setForm({...form, code: e.target.value})}
            className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
          <input type="number" placeholder="Free" value={form.free_currency} onChange={e => setForm({...form, free_currency: Number(e.target.value)})}
            className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
          <input type="number" placeholder="Donate" value={form.donate_currency} onChange={e => setForm({...form, donate_currency: Number(e.target.value)})}
            className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
          <input type="number" placeholder="Dust" value={form.dust_currency} onChange={e => setForm({...form, dust_currency: Number(e.target.value)})}
            className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
          <input type="number" placeholder="Max uses" value={form.max_uses} onChange={e => setForm({...form, max_uses: Number(e.target.value)})}
            className="px-2 py-1 bg-gray-700 rounded text-white text-sm" />
        </div>
        <button onClick={create} className="mt-3 bg-amber-500 hover:bg-amber-600 text-black px-4 py-2 rounded font-semibold text-sm">Create</button>
      </div>

      <div className="bg-gray-800 rounded-lg overflow-hidden">
        <table className="w-full text-left text-sm">
          <thead>
            <tr className="border-b border-gray-700 text-gray-400">
              <th className="p-3">Code</th><th className="p-3">Free</th><th className="p-3">Donate</th>
              <th className="p-3">Dust</th><th className="p-3">Uses</th><th className="p-3">Expires</th>
              <th className="p-3"></th>
            </tr>
          </thead>
          <tbody>
            {codes.map(c => (
              <tr key={c.code} className="border-b border-gray-700">
                <td className="p-3 font-mono">{c.code}</td>
                <td className="p-3">{c.free_currency}</td>
                <td className="p-3">{c.donate_currency}</td>
                <td className="p-3">{c.dust_currency}</td>
                <td className="p-3">{c.current_uses}/{c.max_uses}</td>
                <td className="p-3 text-xs">{c.expires_at || 'Never'}</td>
                <td className="p-3"><button onClick={() => del(c.code)} className="text-red-400 hover:text-red-300 text-xs">Delete</button></td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 3: Commit
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

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
'use client';
import { useEffect, useState } from 'react';

export default function StorePage() {
  const [purchases, setPurchases] = useState<any[]>([]);
  const [effects, setEffects] = useState<any[]>([]);

  useEffect(() => {
    fetch('/api/admin/store').then(r => r.json()).then(d => {
      setPurchases(d.purchases || []);
      setEffects(d.effects || []);
    });
  }, []);

  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
      <div>
        <h1 className="text-2xl font-bold mb-4">Store Purchases</h1>
        <div className="bg-gray-800 rounded-lg overflow-x-auto max-h-[70vh] overflow-y-auto">
          <table className="w-full text-left text-sm">
            <thead>
              <tr className="border-b border-gray-700 text-gray-400">
                <th className="p-2">Player</th><th className="p-2">Item</th><th className="p-2">Category</th>
                <th className="p-2">Cost</th><th className="p-2">Date</th>
              </tr>
            </thead>
            <tbody>
              {purchases.map(p => (
                <tr key={p.id} className="border-b border-gray-700">
                  <td className="p-2 font-mono text-xs">{p.player_name || p.steam_id}</td>
                  <td className="p-2">{p.item_id}</td>
                  <td className="p-2 text-gray-400">{p.item_category}</td>
                  <td className="p-2">{p.price_free || p.price_donate || p.price_dust || 0}</td>
                  <td className="p-2 text-xs text-gray-500">{p.created_at}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>

      <div>
        <h1 className="text-2xl font-bold mb-4">Active Effects</h1>
        <div className="bg-gray-800 rounded-lg">
          <table className="w-full text-left text-sm">
            <thead>
              <tr className="border-b border-gray-700 text-gray-400">
                <th className="p-3">Steam ID</th><th className="p-3">Effects</th>
              </tr>
            </thead>
            <tbody>
              {effects.map((e: any) => (
                <tr key={e.steam_id} className="border-b border-gray-700">
                  <td className="p-3 font-mono text-xs">{e.steam_id}</td>
                  <td className="p-3 text-xs">{e.effects}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}
  • Step 3: Commit
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

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
'use client';
import { useEffect, useState } from 'react';

export default function ContractsPage() {
  const [contracts, setContracts] = useState<any[]>([]);

  useEffect(() => {
    fetch('/api/admin/contracts').then(r => r.json()).then(setContracts);
  }, []);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Death Sentence Contracts</h1>
      <div className="bg-gray-800 rounded-lg">
        <table className="w-full text-left text-sm">
          <thead>
            <tr className="border-b border-gray-700 text-gray-400">
              <th className="p-3">Steam ID</th><th className="p-3">Contract Data</th><th className="p-3">Updated</th>
            </tr>
          </thead>
          <tbody>
            {contracts.map((c: any) => (
              <tr key={c.steam_id} className="border-b border-gray-700">
                <td className="p-3 font-mono text-xs">{c.steam_id}</td>
                <td className="p-3 text-xs max-w-md truncate">{c.contracts}</td>
                <td className="p-3 text-xs text-gray-500">{c.updated_at}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}
  • Step 3: Commit
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

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
'use client';
import { useEffect, useState } from 'react';

export default function ArsenalPage() {
  const [data, setData] = useState<any>({});

  useEffect(() => {
    fetch('/api/admin/arsenal').then(r => r.json()).then(setData);
  }, []);

  return (
    <div>
      <h1 className="text-2xl font-bold mb-4">Arsenal</h1>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Inventory ({data.inventory?.length || 0})</h2>
          <div className="max-h-96 overflow-y-auto text-sm space-y-1">
            {data.inventory?.map((i: any, idx: number) => (
              <div key={idx} className="text-gray-300 p-1 border-b border-gray-700 last:border-0">
                <span className="text-amber-300">{i.item_name}</span>
                <span className="text-gray-500 ml-2">[{i.quality}]</span>
                <div className="text-xs text-gray-500">{i.steam_id}</div>
              </div>
            ))}
          </div>
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Loadouts ({data.loadouts?.length || 0})</h2>
          <div className="max-h-96 overflow-y-auto text-sm space-y-1">
            {data.loadouts?.map((l: any, idx: number) => (
              <div key={idx} className="text-gray-300 p-1 border-b border-gray-700">
                <span className="font-mono text-xs">{l.steam_id}</span>
                <span className="text-amber-300 ml-2">{l.hero_name}</span>
                <div className="text-xs text-gray-500">{l.loadout}</div>
              </div>
            ))}
          </div>
        </div>

        <div className="bg-gray-800 rounded-lg p-4">
          <h2 className="text-lg font-semibold mb-3">Active Listings ({data.listings?.length || 0})</h2>
          <div className="max-h-96 overflow-y-auto text-sm space-y-1">
            {data.listings?.map((l: any, idx: number) => (
              <div key={idx} className="text-gray-300 p-1 border-b border-gray-700">
                <span className="text-amber-300">{l.item_name}</span>
                <span className="text-gray-500 ml-2">{l.price_free} free</span>
                <div className="text-xs text-gray-500">{l.steam_id}</div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
  • Step 3: Commit
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
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
#!/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
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
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:

{
  "key": "base_url",
  "value": "http://localhost:6100/api",
  "type": "string"
}
  • Step 2: Commit
git add postman_collection.json
git commit -m "chore: update postman base_url to localhost:6100"