import 'dotenv/config'; import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import http from 'http'; import { Server as IOServer } from 'socket.io'; import jwt from 'jsonwebtoken'; import { init as dbInit, Users, Characters, Inventory, query } from './db.js'; const app = express(); app.use(helmet()); app.use(cors({ origin: true, credentials: true })); app.use(express.json()); app.use(cookieParser()); // simple world config const WORLD = { width: 4000, height: 3000, gatherRange: 40 }; // ===== Auth helpers ===== function signToken(cid) { return jwt.sign({ cid }, process.env.JWT_SECRET, { expiresIn: '30d' }); } function authFromReq(req) { try { const token = req.cookies?.[COOKIE]; if (!token) return null; return jwt.verify(token, process.env.JWT_SECRET); // { cid, iat, exp } } catch { return null; } } // ===== HTTP API ===== // Register a new character with username + password app.post('/api/register', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Missing username or password' }); } try { const ch = await Characters.create(username, password); const token = signToken(ch.id); res .cookie(COOKIE, token, { httpOnly: true, sameSite: 'lax' }) .json({ ok: true }); } catch (e) { console.error(e); res.status(400).json({ error: 'Username taken' }); } }); // Login as a specific character app.post('/api/login', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ error: 'Missing username or password' }); } const ch = await Characters.verify(username, password); if (!ch) return res.status(401).json({ error: 'Invalid login' }); const token = signToken(ch.id); res .cookie(COOKIE, token, { httpOnly: true, sameSite: 'lax' }) .json({ ok: true }); }); // Logout: clear cookie app.post('/api/logout', (req, res) => { res.clearCookie(COOKIE).json({ ok: true }); }); // Current character + inventory app.get('/api/me', async (req, res) => { const auth = authFromReq(req); if (!auth) return res.status(401).json({ error: 'Not logged in' }); const ch = await Characters.getById(auth.cid); if (!ch) return res.status(404).json({ error: 'Character not found' }); const inv = await Inventory.all(ch.id); res.json({ character: ch, inventory: inv, world: WORLD }); }); // static client app.use(express.static(new URL('../static', import.meta.url).pathname)); const server = http.createServer(app); const io = new IOServer(server, { cors: { origin: true, credentials: true } }); // ===== In-memory world ===== let nodes = []; function spawnNodes() { nodes = []; for (let i = 0; i < 120; i++) { const types = ['wood', 'stone', 'ore', 'fiber']; const type = types[i % types.length]; nodes.push({ id: i, type, x: Math.floor(Math.random() * WORLD.width), y: Math.floor(Math.random() * WORLD.height), alive: true, respawnAt: 0 }); } } spawnNodes(); // socket player info (very simple) const socketsToPlayers = new Map(); // socket.id -> { x,y,name,uid,charId } io.on('connection', socket => { socket.on('auth:join', async ack => { try { const cookieHeader = socket.handshake.headers.cookie || ''; const match = cookieHeader.match(/auth=([^;]+)/); if (!match) return ack?.({ error: 'no auth cookie' }); const token = match[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET); // { cid } const ch = await Characters.getById(decoded.cid); if (!ch) return ack?.({ error: 'character missing' }); const player = { x: ch.x, y: ch.y, name: ch.username, cid: decoded.cid, charId: ch.id }; socketsToPlayers.set(socket.id, player); socket.join('world'); // Send initial nodes in same handler or from separate listener socket.emit( 'nodes:init', nodes.map(n => ({ id: n.id, type: n.type, x: n.x, y: n.y, alive: n.alive })) ); ack?.({ ok: true, you: { name: ch.username, x: ch.x, y: ch.y }, world: WORLD }); } catch (e) { console.error(e); ack?.({ error: 'auth failed' }); } }); socket.on('move:click', target => { const p = socketsToPlayers.get(socket.id); if (!p) return; // for now, we just trust and update directly (you can add validation later) p.x = Math.max(0, Math.min(WORLD.width, target.x)); p.y = Math.max(0, Math.min(WORLD.height, target.y)); }); socket.on('gather', async ({ nodeId }, ack) => { const p = socketsToPlayers.get(socket.id); if (!p) return ack?.({ error: 'not authed' }); const node = nodes.find(n => n.id === nodeId); if (!node || !node.alive) return ack?.({ error: 'invalid node' }); const dist = Math.hypot(node.x - p.x, node.y - p.y); if (dist > WORLD.gatherRange) return ack?.({ error: 'too far' }); node.alive = false; node.respawnAt = Date.now() + 5000; io.to('world').emit('node:update', { id: node.id, alive: false }); // give +1 item + 10 xp await Inventory.add(p.charId, node.type, 1); await Characters.addXP(p.charId, 10); const { rows } = await query('SELECT xp FROM characters WHERE id=$1', [ p.charId ]); const xp = rows[0].xp; const level = Math.max(1, Math.floor(Math.sqrt(xp / 100)) + 1); ack?.({ ok: true, item: node.type, xp: 10, level }); }); socket.on('chat:send', ({ t }) => { const p = socketsToPlayers.get(socket.id); if (!p) return; const msg = `${p.name}: ${String(t || '').slice(0, 200)}`; io.to('world').emit('chat:push', msg); }); socket.on('disconnect', () => { socketsToPlayers.delete(socket.id); }); }); // game loop: respawn nodes + broadcast simple state setInterval(() => { const now = Date.now(); for (const n of nodes) { if (!n.alive && now >= n.respawnAt) { n.alive = true; io.to('world').emit('node:update', { id: n.id, alive: true }); } } // player snapshot const players = []; for (const [id, p] of socketsToPlayers) { players.push({ id, name: p.name, x: p.x, y: p.y }); } io.to('world').emit('state', { players }); }, 200); const port = process.env.PORT || 8080; server.listen(port, async () => { await dbInit(); console.log('Server listening on :' + port); });