feat: Hex Wars - Server-Scores, Minenexplosion, Dominanzsieg, Regel-Update

This commit is contained in:
2026-06-26 16:50:57 +02:00
parent afd2aa6029
commit df9947303c
2 changed files with 163 additions and 131 deletions

View File

@@ -14,8 +14,6 @@ const router = express.Router();
opponent_id INTEGER NOT NULL,
grid TEXT NOT NULL,
terrain TEXT NOT NULL DEFAULT '[]',
fog_owner TEXT NOT NULL DEFAULT '[]',
fog_opp TEXT NOT NULL DEFAULT '[]',
current_turn INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
winner_id INTEGER,
@@ -26,80 +24,62 @@ const router = express.Router();
);
`);
}
// Spalten nachrüsten falls Tabelle schon existiert
for (const col of ['move_count','terrain','fog_owner','fog_opp','last_event']) {
if (!cols.includes(col)) {
const def = col === 'move_count' ? 'INTEGER NOT NULL DEFAULT 0' : 'TEXT';
db.exec(`ALTER TABLE geo_games ADD COLUMN ${col} ${def}`);
}
for (const [col, def] of [
['move_count', 'INTEGER NOT NULL DEFAULT 0'],
['terrain', 'TEXT'],
['last_event', '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]];
// Terrain-Werte (in separatem 20x20 Array gespeichert, unabhängig von grid)
const T_NORMAL = 0;
const T_GOLD = 1; // +3 Punkte
const T_MINE = 2; // -3 Punkte + Zug verlieren
const T_ROCK = 3; // unbesetzbar
const T_GOLD = 1;
const T_MINE = 2;
const T_ROCK = 3;
// ── Terrain generieren ────────────────────────────────────────────────────────
function generateTerrain(ownerId, oppId) {
function generateTerrain() {
const t = Array(GRID).fill(null).map(() => Array(GRID).fill(T_NORMAL));
// Startecken und ihre direkten Nachbarn freihalten
const safeZone = new Set();
for (const [dr,dc] of [[0,0],[0,1],[1,0],[1,1],[-1,0],[0,-1]]) {
safeZone.add(`0,${Math.max(0,dc)}`);
safeZone.add(`${Math.max(0,dr)},0`);
safeZone.add(`${Math.min(GRID-1,GRID-1+dr)},${Math.min(GRID-1,GRID-1+dc)}`);
}
// Einfacher: 2x2 Startbereich je Ecke freihalten
for (let r=0;r<3;r++) for (let c=0;c<3;c++) { safeZone.add(`${r},${c}`); safeZone.add(`${GRID-1-r},${GRID-1-c}`); }
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 (!safeZone.has(`${r},${c}`)) cells.push([r,c]);
// Zufällig mischen
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;
// 12 Goldfelder
let idx=0;
for (let i=0;i<12&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_GOLD;
// 10 Minen
for (let i=0;i<10&&idx<cells.length;i++,idx++) t[cells[idx][0]][cells[idx][1]] = T_MINE;
// 18 Felsen
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: sichtbare Zellen für einen Spieler berechnen ─────────────────
function computeFog(grid, terrain, playerId) {
// Sichtbar: alle eigenen Zellen + alle 8 Nachbarn davon
// ── 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;
}
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;
}
// ── Grid-Hilfsfunktionen ──────────────────────────────────────────────────────
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
function createGrid(ownerId, oppId) {
const grid = Array(GRID).fill(null).map(() => Array(GRID).fill(0));
grid[0][0] = ownerId;
grid[GRID-1][GRID-1] = oppId;
return grid;
const g = Array(GRID).fill(null).map(() => Array(GRID).fill(0));
g[0][0] = ownerId;
g[GRID-1][GRID-1] = oppId;
return g;
}
function isAdjacent(grid, row, col, playerId) {
@@ -111,36 +91,52 @@ function isAdjacent(grid, row, col, playerId) {
}
function canMove(grid, terrain, playerId) {
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;
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;
}
// Punkte zählen: normale Felder +1, Gold +3, Mine -3
// 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;
}
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' },
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-of-War für anfragenden User anwenden
// 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
@@ -153,21 +149,34 @@ function getGameForUser(id, requesterId) {
const grid = JSON.parse(game.grid);
const terrain = JSON.parse(game.terrain || '[]');
if (!terrain.length) return game; // Migration: altes Spiel ohne Terrain
// Fog berechnen
const visible = computeFog(grid, terrain, requesterId);
// 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;
// Grid maskieren: versteckte Gegner-Felder als 0 zeigen, Terrain nur wo sichtbar
const maskedGrid = grid.map((row,r) => row.map((cell,c) => visible[r][c] ? cell : (cell === requesterId ? cell : 0)));
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));
return { ...game, grid: JSON.stringify(maskedGrid), terrain: JSON.stringify(maskedTerrain), fog: JSON.stringify(visible) };
return {
...game,
grid: JSON.stringify(maskedGrid),
terrain: JSON.stringify(maskedTerrain),
fog: JSON.stringify(visible),
myScore,
oppScore: oppSc,
};
}
const uid = req => req.user.id;
// ── Alle Spiele des Users ─────────────────────────────────────────────────────
// ── Alle Spiele ───────────────────────────────────────────────────────────────
router.get('/', authenticate, (req, res) => {
const me = uid(req);
const games = db.prepare(`
@@ -179,7 +188,6 @@ router.get('/', authenticate, (req, res) => {
AND NOT (g.status='finished' AND g.move_count < 2)
ORDER BY g.updated_at DESC
`).all(me, me);
// Für Listenansicht kein Fog nötig — nur eigene Felder zählen
res.json(games);
});
@@ -195,7 +203,7 @@ router.get('/leaderboard', authenticate, (req, res) => {
res.json(rows);
});
// ── Alle User ─────────────────────────────────────────────────────────────────
// ── User ──────────────────────────────────────────────────────────────────────
router.get('/users', authenticate, (req, res) => {
const me = uid(req);
const isAdmin = req.user?.role === 'admin';
@@ -205,17 +213,16 @@ router.get('/users', authenticate, (req, res) => {
res.json(users);
});
// ── Einzelnes Spiel (mit Fog) ─────────────────────────────────────────────────
// ── 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' });
if (game.owner_id !== me && game.opponent_id !== me) return res.status(403).json({ error: 'Kein Zugriff' });
res.json(game);
});
// ── Neues Spiel erstellen ─────────────────────────────────────────────────────
// ── Neues Spiel ───────────────────────────────────────────────────────────────
router.post('/', authenticate, (req, res) => {
const me = uid(req);
const { opponent_id } = req.body;
@@ -226,7 +233,7 @@ router.post('/', authenticate, (req, res) => {
if (!opp) return res.status(404).json({ error: 'Benutzer nicht gefunden' });
const grid = createGrid(me, Number(opponent_id));
const terrain = generateTerrain(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)
@@ -235,7 +242,6 @@ router.post('/', authenticate, (req, res) => {
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 });
});
@@ -246,7 +252,7 @@ router.post('/:id/move', authenticate, (req, res) => {
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: 'Spiel nicht gefunden' });
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' });
@@ -254,44 +260,65 @@ router.post('/:id/move', authenticate, (req, res) => {
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 (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' });
if (!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 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;
// Mine getroffen?
let mineHit = false;
let lastEvent = null;
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;
lastEvent = JSON.stringify({ type:'mine', player: me, row, col });
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 });
}
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';
// ── 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: Mine = Zug verlieren (Gegner dran), sonst normal
let nextTurn = mineHit ? opponent : opponent;
// Nächster Zug
let nextTurn = mineHit ? opponent : opponent; // Mine = Zug verlieren
let status = 'active';
let winnerId = null;
// Spielende prüfen
const myCanMove = canMove(grid, terrain, me);
const oppCanMove = canMove(grid, terrain, opponent);
if (!oppCanMove && !myCanMove) {
// Spielende: niemand kann mehr ziehen
if (!canMove(grid, terrain, opponent) && !canMove(grid, terrain, me)) {
status = 'finished';
} else if (!canMove(grid, terrain, opponent)) {
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);
} else if (!oppCanMove) {
nextTurn = me; // Gegner überspringen
}
db.prepare(`
@@ -303,20 +330,24 @@ router.post('/:id/move', authenticate, (req, res) => {
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! ${ms} vs ${os} Punkte.`);
sendPush(me, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${ms} vs ${os} Punkte. 🎉`);
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. ${ms} vs ${os} Punkte.`);
sendPush(opponent, '⬡ Hex Wars gewonnen!', `Du hast gewonnen! ${os} vs ${ms} Punkte. 🎉`);
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 {
const mineMsg = mineHit ? ' 💥 MINE! Du verlierst deinen nächsten Zug!' : '';
if (nextTurn === opponent) sendPush(opponent, '⬡ Hex Wars — Du bist dran!', `${myName} hat gezogen!${mineMsg}`);
if (mineHit) sendPush(me, '⬡ Hex Wars — 💥 Mine!', 'Du hast eine Mine getroffen! Dein nächster Zug gehört dem Gegner.');
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));