const express = require('express'); const db = require('../../db'); const { authenticate } = require('../../middleware/auth'); const router = express.Router(); // ── DB-Migration ────────────────────────────────────────────────────────────── (function migrate() { const cols = db.pragma('table_info(geo_games)').map(c => c.name); if (!cols.length) { db.exec(` CREATE TABLE IF NOT EXISTS geo_games ( id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL, opponent_id INTEGER NOT NULL, grid TEXT NOT NULL, terrain TEXT NOT NULL DEFAULT '[]', current_turn INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'active', winner_id INTEGER, move_count INTEGER NOT NULL DEFAULT 0, last_event TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); `); } for (const [col, def] of [ ['move_count', 'INTEGER NOT NULL DEFAULT 0'], ['terrain', 'TEXT'], ['last_event', 'TEXT'], ['owner_head', 'TEXT'], ['opp_head', 'TEXT'], ]) { if (!cols.includes(col)) db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`); } // fog_owner/fog_opp waren alte Spalten, ignorieren })(); // ── Konstanten ──────────────────────────────────────────────────────────────── const GRID = 20; const DIRS8 = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; const T_NORMAL = 0; const T_GOLD = 1; const T_MINE = 2; const T_ROCK = 3; // ── Terrain generieren ──────────────────────────────────────────────────────── function generateTerrain() { const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL)); const safe = new Set(); for (let r=0;r<3;r++) for (let c=0;c<3;c++) { safe.add(`${r},${c}`); safe.add(`${GRID-1-r},${GRID-1-c}`); } const cells = []; for (let r=0;r0;i--) { const j=Math.floor(Math.random()*(i+1)); [cells[i],cells[j]]=[cells[j],cells[i]]; } let idx=0; for (let i=0;i<12&&idx Array(GRID).fill(false)); for (let r=0;r=0&&nr=0&&nc Array(GRID).fill(0)); g[0][0] = ownerId; g[GRID-1][GRID-1] = oppId; return g; } // Prüft ob [row,col] an den Kopf [hr,hc] angrenzt (8 Richtungen) function isAdjacentToHead(row, col, hr, hc) { return Math.abs(row - hr) <= 1 && Math.abs(col - hc) <= 1 && !(row === hr && col === hc); } // Fallback für canMove: prüft ob Spieler noch irgendwo vom Kopf aus ziehen kann function isAdjacent(grid, row, col, playerId) { for (const [dr,dc] of DIRS8) { const r=row+dr, c=col+dc; if (r>=0&&r=0&&c=0&&r=0&&c=GRID||c<0||c>=GRID) continue; const cell = grid[r][c]; if (cell === ownerId) { destroyedByOwner++; grid[r][c] = 0; blastCells.push([r,c]); } else if (cell === oppId) { destroyedByOpp++; grid[r][c] = 0; blastCells.push([r,c]); } else if (cell === 0 && terrain[r][c] !== T_ROCK) { blastCells.push([r,c]); } } // Mine selbst wird neutral (bereits besetzt durch Spieler, bleibt als besetztes Mine-Feld) return { destroyedByOwner, destroyedByOpp, blastCells }; } function sendPush(userId, title, message) { const cfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); if (!cfg?.app_token || !cfg?.user_key) return; fetch('https://api.pushover.net/1/messages.json', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), }).catch(() => {}); } // Spiel laden + Fog anwenden + echte Scores beifügen function getGameForUser(id, requesterId) { const game = db.prepare(` SELECT g.*, u1.username as owner_name, u2.username as opp_name FROM geo_games g JOIN users u1 ON u1.id = g.owner_id JOIN users u2 ON u2.id = g.opponent_id WHERE g.id = ? `).get(id); if (!game) return null; const grid = JSON.parse(game.grid); const terrain = JSON.parse(game.terrain || '[]'); // Echte Scores auf ungemasktem Grid berechnen const ownerScore = terrain.length ? calcScore(grid, terrain, game.owner_id) : 0; const oppScore = terrain.length ? calcScore(grid, terrain, game.opponent_id) : 0; // Dem Requester mitteilen welcher Score seiner ist const myScore = requesterId === game.owner_id ? ownerScore : oppScore; const oppSc = requesterId === game.owner_id ? oppScore : ownerScore; if (!terrain.length) return { ...game, myScore:0, oppScore:0 }; // Fog anwenden const visible = computeFog(grid, requesterId); const maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell===requesterId ? cell : 0))); const maskedTerrain = terrain.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : -1)); // Kopf des anfragenden Spielers mitliefern (für Frontend-Validierung) const myHead = requesterId === game.owner_id ? game.owner_head : game.opp_head; const oppHead = requesterId === game.owner_id ? game.opp_head : game.owner_head; return { ...game, grid: JSON.stringify(maskedGrid), terrain: JSON.stringify(maskedTerrain), fog: JSON.stringify(visible), myScore, oppScore: oppSc, myHead, oppHead, }; } const uid = req => req.user.id; // ── Alle Spiele ─────────────────────────────────────────────────────────────── router.get('/', authenticate, (req, res) => { const me = uid(req); const games = db.prepare(` SELECT g.*, u1.username as owner_name, u2.username as opp_name FROM geo_games g JOIN users u1 ON u1.id = g.owner_id JOIN users u2 ON u2.id = g.opponent_id WHERE (g.owner_id=? OR g.opponent_id=?) AND NOT (g.status='finished' AND g.move_count < 2) ORDER BY g.updated_at DESC `).all(me, me); res.json(games); }); // ── Topliste ────────────────────────────────────────────────────────────────── router.get('/leaderboard', authenticate, (req, res) => { const rows = db.prepare(` SELECT u.username, COUNT(*) as wins FROM geo_games g JOIN users u ON u.id = g.winner_id WHERE g.status='finished' AND g.winner_id IS NOT NULL AND g.move_count >= 2 GROUP BY g.winner_id ORDER BY wins DESC LIMIT 20 `).all(); res.json(rows); }); // ── User ────────────────────────────────────────────────────────────────────── router.get('/users', authenticate, (req, res) => { const me = uid(req); const isAdmin = req.user?.role === 'admin'; const users = isAdmin ? db.prepare('SELECT id, username FROM users WHERE id != ? ORDER BY username').all(me) : db.prepare('SELECT id, username FROM users WHERE id != ? AND hidden != 1 ORDER BY username').all(me); res.json(users); }); // ── Einzelnes Spiel ─────────────────────────────────────────────────────────── router.get('/:id', authenticate, (req, res) => { const me = uid(req); const game = getGameForUser(req.params.id, me); if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); res.json(game); }); // ── Neues Spiel ─────────────────────────────────────────────────────────────── router.post('/', authenticate, (req, res) => { const me = uid(req); const { opponent_id } = req.body; if (!opponent_id) return res.status(400).json({ error: 'Gegner fehlt' }); if (Number(opponent_id) === me) return res.status(400).json({ error: 'Du kannst nicht gegen dich spielen' }); const opp = db.prepare('SELECT id, username FROM users WHERE id=?').get(opponent_id); if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); const grid = createGrid(me, Number(opponent_id)); const terrain = generateTerrain(); const result = db.prepare(` INSERT INTO geo_games (owner_id, opponent_id, grid, terrain, current_turn, move_count, owner_head, opp_head) VALUES (?, ?, ?, ?, ?, 0, ?, ?) `).run(me, Number(opponent_id), JSON.stringify(grid), JSON.stringify(terrain), me, '0,0', `${GRID-1},${GRID-1}`); const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; sendPush(Number(opponent_id), '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); res.json({ id: result.lastInsertRowid }); }); // ── Zug machen ──────────────────────────────────────────────────────────────── router.post('/:id/move', authenticate, (req, res) => { const me = uid(req); const { row, col } = req.body; if (row === undefined || col === undefined) return res.status(400).json({ error: 'row/col fehlt' }); const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); if (game.status !== 'active') return res.status(400).json({ error: 'Spiel beendet' }); if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' }); const grid = JSON.parse(game.grid); const terrain = JSON.parse(game.terrain || '[]'); if (row<0||row>=GRID||col<0||col>=GRID) return res.status(400).json({ error: 'Ungültige Position' }); if (grid[row][col] !== 0) return res.status(400).json({ error: 'Zelle belegt' }); if (terrain.length && terrain[row][col] === T_ROCK) return res.status(400).json({ error: 'Felsen — nicht betretbar' }); // Schlangen-Regel: Zug muss an den eigenen Kopf angrenzen const myHeadStr = game.owner_id === me ? game.owner_head : game.opp_head; const [myHR, myHC] = myHeadStr ? myHeadStr.split(',').map(Number) : [null, null]; if (myHR !== null && !isAdjacentToHead(row, col, myHR, myHC)) return res.status(400).json({ error: 'Nur an deinen Kopf anbauen!' }); if (myHR === null && !isAdjacent(grid, row, col, me)) return res.status(400).json({ error: 'Nicht angrenzend' }); const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; const newMoves = (game.move_count || 0) + 1; const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; const oppName = db.prepare('SELECT username FROM users WHERE id=?').get(opponent)?.username || 'Jemand'; // Zug ausführen grid[row][col] = me; // Neuen Kopf setzen let newOwnerHead = game.owner_head; let newOppHead = game.opp_head; if (game.owner_id === me) newOwnerHead = `${row},${col}`; else newOppHead = `${row},${col}`; let lastEvent = null; let mineHit = false; let blastCells = []; let destroyedOwn = 0; let destroyedOpp = 0; // ── Mine betreten: Explosion der 8 Nachbarn ────────────────────────────── if (terrain.length && terrain[row][col] === T_MINE) { mineHit = true; const explosion = explodeMine(grid, terrain, row, col, me, opponent); blastCells = explosion.blastCells; destroyedOwn = explosion.destroyedByOwner; destroyedOpp = explosion.destroyedByOpp; lastEvent = JSON.stringify({ type:'mine', player:me, row, col, blastCells, destroyedOwn, destroyedOpp }); } // ── Mine ins Sichtfeld gekommen: Explosion prüfen ──────────────────────── // Alle Minen die jetzt im Sichtfeld von BEIDEN Spielern liegen und noch nicht explodiert sind // werden gezündet sobald ein Spieler sie sieht (d.h. sie grenzen an sein Gebiet) // Dieses Feature: Mine explodiert wenn sie ins Sichtfeld kommt (nicht wenn betreten) // → wird beim Betreten gehandhabt (oben), kein extra Trigger nötig // Nächster Zug let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren let status = 'active'; let winnerId = null; // Köpfe nach Zug const oppHeadStr = game.owner_id === me ? game.opp_head : game.owner_head; const [oppHR, oppHC] = oppHeadStr ? oppHeadStr.split(',').map(Number) : [null, null]; const myNewHR = row, myNewHC = col; // Spielende: niemand kann mehr ziehen if (!canMove(grid, terrain, opponent, oppHR, oppHC) && !canMove(grid, terrain, me, myNewHR, myNewHC)) { status = 'finished'; } else if (!canMove(grid, terrain, opponent, oppHR, oppHC)) { nextTurn = me; } // Schnellsieg: jemand hat doppelt so viele Punkte wie der Gegner (mind. 10 Züge gespielt) if (status === 'active' && newMoves >= 10) { const ms = calcScore(grid, terrain, me); const os = calcScore(grid, terrain, opponent); if (ms >= os * 2 && os > 0) { status = 'finished'; } else if (os >= ms * 2 && ms > 0) { status = 'finished'; } } if (status === 'finished') { const ms = calcScore(grid, terrain, me); const os = calcScore(grid, terrain, opponent); winnerId = ms > os ? me : (os > ms ? opponent : null); } db.prepare(` UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, last_event=?, owner_head=?, opp_head=?, updated_at=datetime('now','localtime') WHERE id=? `).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, lastEvent, newOwnerHead, newOppHead, game.id); // Pushover if (status === 'finished') { const ms = calcScore(grid, terrain, me); const os = calcScore(grid, terrain, opponent); const reason = newMoves >= 10 && (ms >= os*2 || os >= ms*2) ? ' (Dominanzsieg!)' : ''; if (winnerId === me) { sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen!${reason} ${ms} vs ${os} Punkte.`); sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${ms} vs ${os} Punkte. 🎉`); } else if (winnerId === opponent) { sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen.${reason} ${ms} vs ${os} Punkte.`); sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen!${reason} ${os} vs ${ms} Punkte. 🎉`); } else { sendPush(me, '⬡ Hex Wars — Unentschieden!', `${ms} vs ${os} Punkte.`); sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${os} vs ${ms} Punkte.`); } } else { if (mineHit) { sendPush(me, '⬡ Hex Wars — 💣 Mine!', `Du hast eine Mine getroffen! Explosion zerstörte ${destroyedOwn + destroyedOpp} Felder. Du verlierst deinen nächsten Zug.`); sendPush(opponent, '⬡ Hex Wars — 💥 Explosion!', `${myName} hat eine Mine getroffen! ${destroyedOpp} deiner Felder wurden zerstört. Du bist dran!`); } else if (nextTurn === opponent) { sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`); } } res.json(getGameForUser(game.id, me)); }); // ── Aufgeben ────────────────────────────────────────────────────────────────── router.post('/:id/resign', authenticate, (req, res) => { const me = uid(req); const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' }); if (game.status !== 'active') return res.status(400).json({ error: 'Spiel bereits beendet' }); const winner = game.owner_id === me ? game.opponent_id : game.owner_id; db.prepare(`UPDATE geo_games SET status='finished', winner_id=?, updated_at=datetime('now','localtime') WHERE id=?`).run(winner, game.id); const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`); res.json({ ok: true }); }); // ── Löschen (Admin) ─────────────────────────────────────────────────────────── router.delete('/:id', authenticate, (req, res) => { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); const game = db.prepare('SELECT * FROM geo_games WHERE id=?').get(req.params.id); if (!game) return res.status(404).json({ error: 'Nicht gefunden' }); if (game.status !== 'finished') return res.status(400).json({ error: 'Nur beendete Spiele können gelöscht werden' }); db.prepare('DELETE FROM geo_games WHERE id=?').run(req.params.id); res.json({ ok: true }); }); module.exports = router;