diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..9c99e62 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,227 @@ +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 ===== + +const COOKIE = 'auth'; + +function signToken(uid) { + return jwt.sign({ uid }, 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); + } catch { + return null; + } +} + +// ===== HTTP API ===== + +app.post('/api/register', async (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) + return res.status(400).json({ error: 'Missing fields' }); + try { + const user = await Users.create(username, password); + const token = signToken(user.id); + res + .cookie(COOKIE, token, { httpOnly: true, sameSite: 'lax' }) + .json({ ok: true }); + } catch (e) { + console.error(e); + res.status(400).json({ error: 'Username taken' }); + } +}); + +app.post('/api/login', async (req, res) => { + const { username, password } = req.body || {}; + const user = await Users.verify(username, password); + if (!user) return res.status(401).json({ error: 'Invalid login' }); + const token = signToken(user.id); + res + .cookie(COOKIE, token, { httpOnly: true, sameSite: 'lax' }) + .json({ ok: true }); +}); + +app.post('/api/logout', (req, res) => { + res.clearCookie(COOKIE).json({ ok: true }); +}); + +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.getByUserId(auth.uid); + if (!ch) return res.status(404).json({ error: 'No character' }); + 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 => { + // auth handshake + 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); + const ch = await Characters.getByUserId(decoded.uid); + if (!ch) return ack?.({ error: 'no character' }); + + const player = { + x: ch.x, + y: ch.y, + name: ch.name, + uid: decoded.uid, + charId: ch.id + }; + socketsToPlayers.set(socket.id, player); + + socket.join('world'); + 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.name, 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); +});