diff --git a/backend/src/tools/gebietseroberung/routes.js b/backend/src/tools/gebietseroberung/routes.js index d572ba0..6c76e8c 100644 --- a/backend/src/tools/gebietseroberung/routes.js +++ b/backend/src/tools/gebietseroberung/routes.js @@ -12,60 +12,56 @@ const router = express.Router(); id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER NOT NULL, opponent_id INTEGER NOT NULL, - owner_color TEXT NOT NULL DEFAULT '#4ecdc4', - opp_color TEXT NOT NULL DEFAULT '#ff6b9d', grid TEXT NOT NULL, current_turn INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'active', winner_id INTEGER, + move_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now','localtime')) ); `); } + // move_count nachrüsten falls Tabelle schon existiert + if (!cols.includes('move_count')) { + db.exec(`ALTER TABLE geo_games ADD COLUMN move_count INTEGER NOT NULL DEFAULT 0`); + } })(); -const GRID_SIZE = 20; +const GRID = 20; -// Leeres Grid erstellen: 0=neutral, owner_id=Spieler1, opponent_id=Spieler2 function createGrid(ownerId, oppId) { - const grid = Array(GRID_SIZE).fill(null).map(() => Array(GRID_SIZE).fill(0)); - // Startpositionen: Owner oben-links, Opponent unten-rechts + const grid = Array(GRID).fill(null).map(() => Array(GRID).fill(0)); grid[0][0] = ownerId; - grid[GRID_SIZE - 1][GRID_SIZE - 1] = oppId; + grid[GRID - 1][GRID - 1] = oppId; return JSON.stringify(grid); } -// Prüft ob Zelle an Spieler-Gebiet grenzt (oben/unten/links/rechts) +// Diagonal erlaubt: alle 8 Richtungen function isAdjacent(grid, row, col, playerId) { - const dirs = [[-1,0],[1,0],[0,-1],[0,1]]; + const dirs = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]]; for (const [dr, dc] of dirs) { const r = row + dr, c = col + dc; - if (r >= 0 && r < GRID_SIZE && c >= 0 && c < GRID_SIZE && grid[r][c] === playerId) return true; + if (r >= 0 && r < GRID && c >= 0 && c < GRID && grid[r][c] === playerId) return true; } return false; } -// Prüft ob ein Spieler noch Züge machen kann function canMove(grid, playerId) { - for (let r = 0; r < GRID_SIZE; r++) { - for (let c = 0; c < GRID_SIZE; c++) { + for (let r = 0; r < GRID; r++) + for (let c = 0; c < GRID; c++) if (grid[r][c] === 0 && isAdjacent(grid, r, c, playerId)) return true; - } - } return false; } -// Felder zählen function countCells(grid, playerId) { - let count = 0; - for (let r = 0; r < GRID_SIZE; r++) - for (let c = 0; c < GRID_SIZE; c++) - if (grid[r][c] === playerId) count++; - return count; + let n = 0; + for (let r = 0; r < GRID; r++) + for (let c = 0; c < GRID; c++) + if (grid[r][c] === playerId) n++; + return n; } -// Pushover helper 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; @@ -76,24 +72,52 @@ function sendPush(userId, title, message) { }).catch(() => {}); } +// Spiel mit JOIN laden (inkl. owner_name / opp_name) +function getGame(id) { + return 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); +} + const uid = req => req.user.id; // ── Alle Spiele des Users ───────────────────────────────────────────────────── 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 + 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=? + 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) => { + // Nur Spiele die wirklich gespielt wurden (mind. 1 Zug pro Spieler = move_count >= 2) + // und nicht durch Aufgeben ohne Züge beendet wurden + 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); +}); + // ── Alle User (für Einladen) ────────────────────────────────────────────────── router.get('/users', authenticate, (req, res) => { const me = uid(req); @@ -106,16 +130,8 @@ router.get('/users', authenticate, (req, res) => { // ── Einzelnes Spiel ─────────────────────────────────────────────────────────── router.get('/:id', authenticate, (req, res) => { - const me = uid(req); - 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(req.params.id); + const me = uid(req); + const game = getGame(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' }); @@ -132,14 +148,14 @@ router.post('/', authenticate, (req, res) => { 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, opponent_id); + const grid = createGrid(me, opponent_id); const result = db.prepare(` - INSERT INTO geo_games (owner_id, opponent_id, grid, current_turn) - VALUES (?, ?, ?, ?) + INSERT INTO geo_games (owner_id, opponent_id, grid, current_turn, move_count) + VALUES (?, ?, ?, ?, 0) `).run(me, opponent_id, grid, me); const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; - sendPush(opponent_id, '🗺️ Gebietseroberung', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); + sendPush(opponent_id, '⬡ Hex Wars', `${myName} hat dich zu einem Spiel eingeladen! Du bist zuerst dran.`); res.json({ id: result.lastInsertRowid }); }); @@ -158,71 +174,63 @@ router.post('/:id/move', authenticate, (req, res) => { if (game.current_turn !== me) return res.status(400).json({ error: 'Nicht dein Zug' }); const grid = JSON.parse(game.grid); - - if (row < 0 || row >= GRID_SIZE || col < 0 || col >= GRID_SIZE) + 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 bereits belegt' }); if (!isAdjacent(grid, row, col, me)) return res.status(400).json({ error: 'Zelle grenzt nicht an dein Gebiet' }); - // Zug ausführen grid[row][col] = me; + const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; + const newMoves = (game.move_count || 0) + 1; + let nextTurn = opponent; + let status = 'active'; + let winnerId = null; - const opponent = game.owner_id === me ? game.opponent_id : game.owner_id; - let nextTurn = opponent; - let status = 'active'; - let winnerId = null; - - // Prüfen ob Gegner noch ziehen kann if (!canMove(grid, opponent)) { - // Gegner kann nicht — prüfen ob ich auch nicht mehr kann if (!canMove(grid, me)) { - // Spiel vorbei - status = 'finished'; - const myCount = countCells(grid, me); - const oppCount = countCells(grid, opponent); - winnerId = myCount > oppCount ? me : (oppCount > myCount ? opponent : null); // null = Unentschieden + status = 'finished'; + const mc = countCells(grid, me); + const oc = countCells(grid, opponent); + winnerId = mc > oc ? me : (oc > mc ? opponent : null); } else { - // Gegner überspringen, ich mache weiter - nextTurn = me; + nextTurn = me; // Gegner überspringen } } - const newGrid = JSON.stringify(grid); db.prepare(` - UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, + UPDATE geo_games SET grid=?, current_turn=?, status=?, winner_id=?, move_count=?, updated_at=datetime('now','localtime') WHERE id=? - `).run(newGrid, nextTurn, status, winnerId, game.id); + `).run(JSON.stringify(grid), nextTurn, status, winnerId, newMoves, game.id); // Pushover - const myName = db.prepare('SELECT username FROM users WHERE id=?').get(me)?.username || 'Jemand'; + 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'; if (status === 'finished') { - const myCount = countCells(grid, me); - const oppCount = countCells(grid, opponent); + const mc = countCells(grid, me); + const oc = countCells(grid, opponent); if (winnerId === me) { - sendPush(opponent, '🗺️ Spiel beendet', `${myName} hat gewonnen! ${myCount} vs ${oppCount} Felder.`); - sendPush(me, '🗺️ Spiel beendet', `Du hast gewonnen! ${myCount} vs ${oppCount} Felder. 🎉`); + sendPush(opponent, '⬡ Hex Wars beendet', `${myName} hat gewonnen! ${mc} vs ${oc} Felder.`); + sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${mc} vs ${oc} Felder. 🎉`); } else if (winnerId === opponent) { - sendPush(me, '🗺️ Spiel beendet', `${oppName} hat gewonnen. ${myCount} vs ${oppCount} Felder.`); - sendPush(opponent, '🗺️ Spiel beendet', `Du hast gewonnen! ${oppCount} vs ${myCount} Felder. 🎉`); + sendPush(me, '⬡ Hex Wars beendet', `${oppName} hat gewonnen. ${mc} vs ${oc} Felder.`); + sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${oc} vs ${mc} Felder. 🎉`); } else { - sendPush(me, '🗺️ Unentschieden!', `Gleichstand — ${myCount} vs ${oppCount} Felder.`); - sendPush(opponent, '🗺️ Unentschieden!', `Gleichstand — ${oppCount} vs ${myCount} Felder.`); + sendPush(me, '⬡ Hex Wars — Unentschieden!', `${mc} vs ${oc} Felder.`); + sendPush(opponent, '⬡ Hex Wars — Unentschieden!', `${oc} vs ${mc} Felder.`); } } else if (nextTurn === opponent) { - sendPush(opponent, '🗺️ Du bist dran!', `${myName} hat gezogen — jetzt bist du dran!`); + sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!`); } - const updated = db.prepare('SELECT * FROM geo_games WHERE id=?').get(game.id); - res.json(updated); + res.json(getGame(game.id)); }); -// ── Spiel aufgeben ──────────────────────────────────────────────────────────── +// ── Aufgeben ────────────────────────────────────────────────────────────────── router.post('/:id/resign', authenticate, (req, res) => { - const me = uid(req); + 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) @@ -230,10 +238,14 @@ router.post('/:id/resign', authenticate, (req, res) => { 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); + // move_count bleibt wie es ist — wird für Leaderboard/Liste genutzt + 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, '🗺️ Spiel gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`); + sendPush(winner, '⬡ Hex Wars gewonnen!', `${myName} hat aufgegeben. Du gewinnst! 🎉`); res.json({ ok: true }); }); diff --git a/frontend/src/tools/gebietseroberung.jsx b/frontend/src/tools/gebietseroberung.jsx index 0fdb6d7..7f3f3c2 100644 --- a/frontend/src/tools/gebietseroberung.jsx +++ b/frontend/src/tools/gebietseroberung.jsx @@ -2,10 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import { api, S } from '../lib.js'; const GRID = 20; -const MY_COLOR = '#4ecdc4'; -const OPP_COLOR = '#ff6b9d'; +const MY_COLOR = '#4ecdc4'; +const OPP_COLOR = '#ff6b9d'; -// User-ID aus JWT lesen (kein API-Call nötig) function getMyId() { try { const token = localStorage.getItem('sk_token'); @@ -17,43 +16,26 @@ function getMyId() { // ── Spielerklärung ──────────────────────────────────────────────────────────── function HelpModal({ onClose }) { return ( -
+
- - ⬡ Wie funktioniert's? - + ⬡ Wie funktioniert's?
-

- Dein Ziel: Am Ende mehr Felder als dein Gegner besitzen. -

-

- So geht's:
- Jeder Spieler startet in einer Ecke des 20×20 Rasters. Abwechselnd wählst du eine - neutrale (graue) Zelle, - die direkt an dein Gebiet grenzt — oben, unten, links oder rechts. -

-

- Taktik:
- Breite dich aus — aber mauere deinen Gegner - ein, bevor er dich einsperrt! - Blockieren ist genauso wichtig wie Expandieren. -

-

- Ende:
- Wenn niemand mehr expandieren kann, gewinnt wer mehr Felder hat. - Du kriegst eine Pushover-Benachrichtigung wenn du dran bist. -

-
- 💡 Leuchtende Felder sind anklickbar — nur Felder neben deinem Gebiet. +

Dein Ziel: Am Ende mehr Felder als dein Gegner besitzen.

+

So geht's:
+ Jeder Spieler startet in einer Ecke des 20×20 Rasters. Abwechselnd wählst du eine + neutrale (graue) Zelle, + die an dein Gebiet grenzt — auch diagonal.

+

Taktik:
+ Breite dich aus — aber mauere deinen Gegner + ein, bevor er dich einsperrt!

+

Ende:
+ Wenn niemand mehr expandieren kann, gewinnt wer mehr Felder hat. + Du kriegst eine Pushover-Benachrichtigung wenn du dran bist.

+
+ 💡 Leuchtende Felder sind anklickbar — alle 8 Richtungen (inkl. Diagonal).
@@ -67,9 +49,7 @@ function NewGameModal({ onClose, onCreated, toast }) { const [oppId, setOppId] = useState(''); const [loading, setLoading] = useState(false); - useEffect(() => { - api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); - }, []); + useEffect(() => { api('/tools/gebietseroberung/users').then(setUsers).catch(() => {}); }, []); const create = async () => { if (!oppId) return; @@ -84,15 +64,10 @@ function NewGameModal({ onClose, onCreated, toast }) { }; return ( -
+
- - Neues Spiel - + Neues Spiel
@@ -111,8 +86,44 @@ function NewGameModal({ onClose, onCreated, toast }) { ); } +// ── Topliste ────────────────────────────────────────────────────────────────── +function Leaderboard() { + const [rows, setRows] = useState(null); + + useEffect(() => { + api('/tools/gebietseroberung/leaderboard').then(setRows).catch(() => setRows([])); + }, []); + + if (rows === null) return
Lade…
; + + const medals = ['🥇','🥈','🥉']; + + return ( +
+
TOPLISTE — SIEGE
+ {rows.length === 0 + ?
Noch keine abgeschlossenen Spiele.
+ : rows.map((r, i) => ( +
+ {medals[i] || `${i+1}.`} + {r.username} + {r.wins} + Siege +
+ )) + } +
+ ); +} + // ── Spielfeld ───────────────────────────────────────────────────────────────── -function GameBoard({ game, myId, onMove, toast }) { +function GameBoard({ game, myId, onMove }) { const [hover, setHover] = useState(null); const [moving, setMoving] = useState(false); @@ -123,13 +134,13 @@ function GameBoard({ game, myId, onMove, toast }) { const oppColor = game.owner_id === myId ? OPP_COLOR : MY_COLOR; const oppName = game.owner_id === myId ? game.opp_name : game.owner_name; - const isAdj = (r, c) => [[-1,0],[1,0],[0,-1],[0,1]].some(([dr,dc]) => { + // Diagonal erlaubt: alle 8 Richtungen + const isAdj = (r, c) => [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]].some(([dr,dc]) => { const nr = r+dr, nc = c+dc; return nr>=0 && nr=0 && nc - isMyTurn && game.status === 'active' && grid[r][c] === 0 && isAdj(r, c); + const canClick = (r, c) => isMyTurn && game.status === 'active' && grid[r][c] === 0 && isAdj(r, c); const handleClick = async (r, c) => { if (!canClick(r, c) || moving) return; @@ -143,18 +154,16 @@ function GameBoard({ game, myId, onMove, toast }) { let banner = null; if (game.status === 'finished') { - if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; - else if (game.winner_id===myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; - else banner = { text:'Du hast verloren.', color:OPP_COLOR }; + if (!game.winner_id) banner = { text:'Unentschieden! 🤝', color:'#ffe66d' }; + else if (game.winner_id === myId) banner = { text:'Du hast gewonnen! 🎉', color:MY_COLOR }; + else banner = { text:'Du hast verloren.', color:OPP_COLOR }; } - // Zellgröße: passt sich ans Viewport an, nie horizontales Scrollen - const vw = Math.min(window.innerWidth, 600); - const cellSize = Math.floor((vw - 48) / GRID); // 48px = padding + gap + const vw = Math.min(window.innerWidth, 520); + const cellSize = Math.floor((vw - 40) / GRID); return (
- {/* Scoreboard */}
DU
@@ -169,7 +178,6 @@ function GameBoard({ game, myId, onMove, toast }) {
- {/* Banner */} {banner && (
{banner.text}
)} - {/* Zug-Hinweis */} {game.status === 'active' && ( -
- {isMyTurn - ? (moving ? 'Zug wird gespeichert…' : '▶ Dein Zug') - : `⏳ ${oppName} ist dran…`} +
+ {isMyTurn ? (moving ? 'Zug wird gespeichert…' : '▶ Dein Zug') : `⏳ ${oppName} ist dran…`}
)} - {/* Grid — immer passend ohne Scrollen */}
g.status === 'active'); - const finished = games.filter(g => g.status === 'finished'); + // Beendet: nur Spiele mit mind. 2 Zügen (mind. 1 pro Spieler) + const finished = games.filter(g => g.status === 'finished' && (g.move_count || 0) >= 2); const GameRow = ({ g }) => { const isMyTurn = g.status === 'active' && g.current_turn === myId; @@ -244,9 +246,9 @@ function GameList({ games, myId, onSelect, onNew }) { const oppCount = grid.flat().filter(v => v !== myId && v !== 0).length; let result = null; if (g.status === 'finished') { - if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' }; - else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR }; - else result = { label:'Verloren', color:OPP_COLOR }; + if (!g.winner_id) result = { label:'Unentschieden', color:'#ffe66d' }; + else if (g.winner_id === myId) result = { label:'Gewonnen 🎉', color:MY_COLOR }; + else result = { label:'Verloren', color:OPP_COLOR }; } return ( + )} ⬡ HEX WARS @@ -376,7 +372,7 @@ export default function HexWars({ toast }) { {!activeId ? setShowNew(true)} /> : game && myId - ? + ? :
Lade Spiel…
}