422 lines
20 KiB
JavaScript
422 lines
20 KiB
JavaScript
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;r<GRID;r++) for (let c=0;c<GRID;c++) if (!safe.has(`${r},${c}`)) cells.push([r,c]);
|
|
for (let i=cells.length-1;i>0;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<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
|
|
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
|
|
for (let i=0;i<18&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_ROCK;
|
|
return t;
|
|
}
|
|
|
|
// ── Fog of War ────────────────────────────────────────────────────────────────
|
|
function computeFog(grid, playerId) {
|
|
const visible = Array(GRID).fill(null).map(() => Array(GRID).fill(false));
|
|
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
|
if (grid[r][c] === playerId) {
|
|
visible[r][c] = true;
|
|
for (const [dr,dc] of DIRS8) {
|
|
const nr=r+dr, nc=c+dc;
|
|
if (nr>=0&&nr<GRID&&nc>=0&&nc<GRID) visible[nr][nc] = true;
|
|
}
|
|
}
|
|
}
|
|
return visible;
|
|
}
|
|
|
|
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
|
function createGrid(ownerId, oppId) {
|
|
const g = Array(GRID).fill(null).map(() => 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<GRID&&c>=0&&c<GRID&&grid[r][c]===playerId) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function canMove(grid, terrain, playerId, headRow, headCol) {
|
|
// Schlangen-Regel: nur Felder die an den Kopf grenzen sind erreichbar
|
|
if (headRow !== undefined && headCol !== undefined) {
|
|
for (const [dr,dc] of DIRS8) {
|
|
const r=headRow+dr, c=headCol+dc;
|
|
if (r>=0&&r<GRID&&c>=0&&c<GRID&&grid[r][c]===0&&terrain[r][c]!==T_ROCK) return true;
|
|
}
|
|
return false;
|
|
}
|
|
// Fallback (alte Spiele ohne head)
|
|
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++)
|
|
if (grid[r][c]===0 && terrain[r][c]!==T_ROCK && isAdjacent(grid,r,c,playerId)) return true;
|
|
return false;
|
|
}
|
|
|
|
// Score auf dem vollständigen (ungemaskten) Grid berechnen
|
|
function calcScore(grid, terrain, playerId) {
|
|
let score = 0;
|
|
for (let r=0;r<GRID;r++) for (let c=0;c<GRID;c++) {
|
|
if (grid[r][c] !== playerId) continue;
|
|
if (terrain[r][c] === T_GOLD) score += 3;
|
|
else if (terrain[r][c] === T_MINE) score -= 3;
|
|
else score += 1;
|
|
}
|
|
return Math.max(0, score);
|
|
}
|
|
|
|
// ── Minenexplosion: alle 8 Nachbarn der Mine werden zerstört ─────────────────
|
|
// Gibt zurück: { grid, destroyedByOwner, destroyedByOpp, blastCells }
|
|
function explodeMine(grid, terrain, row, col, ownerId, oppId) {
|
|
let destroyedByOwner = 0;
|
|
let destroyedByOpp = 0;
|
|
const blastCells = [];
|
|
|
|
for (const [dr,dc] of DIRS8) {
|
|
const r=row+dr, c=col+dc;
|
|
if (r<0||r>=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;
|