Planetengenerierung

This commit is contained in:
2026-06-15 21:35:25 +02:00
parent 2623da3d39
commit 7a39007712
27 changed files with 2465 additions and 141 deletions
+10
View File
@@ -12,6 +12,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-cron": "^4.2.1",
"pg": "^8.11.5" "pg": "^8.11.5"
}, },
"devDependencies": { "devDependencies": {
@@ -940,6 +941,15 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-gyp-build": { "node_modules/node-gyp-build": {
"version": "4.8.4", "version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+1
View File
@@ -13,6 +13,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"node-cron": "^4.2.1",
"pg": "^8.11.5" "pg": "^8.11.5"
}, },
"devDependencies": { "devDependencies": {
@@ -26,44 +26,137 @@ export const getSectorsList = async (req, res) => {
}; };
/** /**
* Controller-Methode für den Sektor-Beitritt (Charaktererstellung). * Verarbeitet den Beitritt eines Spielers zu einem spezifischen Sektor.
* Nimmt die Request an, validiert grob und leitet an den Service weiter. * Sendet einen internen Request an das Sektor-Backend, um dort den Account
* und den Heimatplaneten generieren zu lassen.
*
* @param {import('express').Request} req - Das Express Request-Objekt
* @param {import('express').Response} res - Das Express Response-Objekt
*/ */
export const joinSector = async (req, res) => { export const joinSector = async (req, res) => {
const accountId = req.user.accountId;
const { sectorId, localUsername, gender } = req.body;
if (!sectorId || !localUsername || !gender) { const lobby_uuid = req.user.lobby_uuid;
return res.status(400).json({ const { sector_id } = req.body;
error: 'ERR_MISSING_FIELDS'
if (!sector_id) {
return res.status(400).json({
error: 'ERR_MISSING_SECTOR_ID'
}); });
} }
if (localUsername.length < 3) { const client = await pool.connect();
return res.status(400).json({
error: 'ERR_USERNAME_TOO_SHORT'
});
}
try { try {
await sectorService.joinSector(accountId, sectorId, localUsername, gender); // Spieler-Daten aus der Lobby-Datenbank laden
const userQuery = await client.query(`
SELECT username, gender
FROM lobby_accounts
WHERE lobby_uuid = $1
`, [lobby_uuid]);
return res.status(201).json({ if (userQuery.rows.length === 0) {
success: true, return res.status(404).json({
message: 'MSG_SECTOR_JOINED_SUCCESS' error: 'ERR_USER_NOT_FOUND'
}); });
}
const { username, gender } = userQuery.rows[0];
} catch (error) { // Sektor-Daten laden, um die korrekte interne URL zu erfahren
if (error.code === 'ERR_ALREADY_IN_SECTOR' || error.code === 'ERR_LOCAL_USERNAME_TAKEN') { const sectorQuery = await client.query(`
return res.status(409).json({ SELECT internal_url, registration_open
error: error.code FROM sectors
WHERE id = $1
`, [sector_id]);
if (sectorQuery.rows.length === 0) {
return res.status(404).json({
error: 'ERR_SECTOR_NOT_FOUND'
}); });
} }
// Generischer Fallback für echte Systemabstürze const sector = sectorQuery.rows[0];
console.error('[SectorController] Fehler beim Sektor-Beitritt:', error);
return res.status(500).json({ // Sicherheits-Check: Ist die Registrierung für diesen Sektor noch offen?
error: 'ERR_INTERNAL_SERVER' if (!sector.registration_open) {
return res.status(403).json({
error: 'ERR_SECTOR_REGISTRATION_CLOSED'
});
}
// Den internen POST-Request an das Sektor-Backend feuern
const sectorApiUrl = `${sector.internal_url}/api/internal/accounts`;
const response = await fetch(sectorApiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lobby_uuid,
username,
gender
})
}); });
// Fehlerbehandlung bei negativen HTTP-Statuscodes
// fetch springt bei HTTP-Fehlern nicht in den catch-Block, daher prüfen wir response.ok
if (!response.ok) {
const errorData = await response.json();
const sectorError = errorData.error;
// Wenn der Sektor exakt in diesem Moment voll geworden ist
if (sectorError === 'ERR_SECTOR_FULL') {
// Sektor sicherheitshalber in der Lobby sofort schließen
await client.query(`
UPDATE sectors SET registration_open = false WHERE id = $1
`, [sector_id]);
return res.status(503).json({
error: 'ERR_SECTOR_FULL'
});
}
if (sectorError === 'ERR_ACCOUNT_ALREADY_EXISTS') {
return res.status(409).json({
error: 'ERR_ACCOUNT_ALREADY_EXISTS'
});
}
throw new Error(`Sektor-Backend meldet unerwarteten Fehler: ${sectorError}`);
}
const responseData = await response.json();
const { triggerSectorClose } = responseData;
await client.query('BEGIN');
await client.query(`
INSERT INTO account_sectors (lobby_uuid, sector_id)
VALUES ($1, $2)
`, [lobby_uuid, sector_id]);
// Auswertung der Notfall-Schließung
// Wenn der Sektor uns meldet, dass das Puffer-Limit erreicht ist,
// machen wir ihn dicht, damit er im Frontend verschwindet.
if (triggerSectorClose) {
console.warn(`[Lobby] Sektor ${sector_id} meldet Voll-Auslastung. Schließe Registrierung...`);
await client.query(`
UPDATE sectors
SET registration_open = false
WHERE id = $1
`, [sector_id]);
}
await client.query('COMMIT');
return res.status(200).json({
message: 'MSG_SECTOR_JOINED_SUCCESSFULLY'
});
} catch (error) {
await client.query('ROLLBACK');
console.error('[Lobby] Fehler beim Beitritt zum Sektor:', error.message);
return res.status(500).json({ error: 'ERR_INTERNAL_SERVER_ERROR' });
} finally {
client.release();
} }
}; };
+78 -19
View File
@@ -12,6 +12,7 @@ const createTablesQuery = `
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend! -- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
-- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden. -- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden.
-- ------------------------------------------------------------------------------ -- ------------------------------------------------------------------------------
DROP TABLE IF EXISTS lobby_accounts_titles;
DROP TABLE IF EXISTS refresh_tokens; DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS auth_otps; DROP TABLE IF EXISTS auth_otps;
DROP TABLE IF EXISTS lobby_accounts_sectors; -- Zuerst die Brückentabelle löschen DROP TABLE IF EXISTS lobby_accounts_sectors; -- Zuerst die Brückentabelle löschen
@@ -34,6 +35,17 @@ CREATE TABLE IF NOT EXISTS lobby_accounts (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
-- ------------------------------------------------------------------------------
-- TABLE: lobby_accounts_titles (GLOBAL FREIGESCHALTETE TITEL)
-- Speichert, welche Titel ein Spieler global für sein gesamtes Konto freigeschaltet hat.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS lobby_accounts_titles (
lobby_account_id UUID REFERENCES lobby_accounts(id) ON DELETE CASCADE,
title_id INT NOT NULL,
unlocked_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (lobby_account_id, title_id)
);
-- ------------------------------------------------------------------------------ -- ------------------------------------------------------------------------------
-- TABLE: sectors -- TABLE: sectors
-- Beinhaltet die globalen Metadaten zu allen verfügbaren Spiel-Sektoren. -- Beinhaltet die globalen Metadaten zu allen verfügbaren Spiel-Sektoren.
@@ -44,8 +56,22 @@ CREATE TABLE IF NOT EXISTS sectors (
name VARCHAR(50) UNIQUE NOT NULL, name VARCHAR(50) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'offline', status VARCHAR(20) DEFAULT 'offline',
api_url VARCHAR(255), api_url VARCHAR(255),
speed_fleet INT DEFAULT 1, is_registration_open BOOLEAN DEFAULT true,
current_players INT DEFAULT 0,
online_players INT DEFAULT 0,
planets_count INT DEFAULT 0,
max_galaxies INT DEFAULT 5,
speed_economy INT DEFAULT 1, speed_economy INT DEFAULT 1,
research_speed INT DEFAULT 1,
fleet_speed_hostile INT DEFAULT 1,
fleet_speed_peaceful INT DEFAULT 1,
fleet_speed_friendly INT DEFAULT 1,
fuel_consumption_factor NUMERIC(5,2) DEFAULT 1.00,
fleet_to_debris_percentage INT DEFAULT 30,
defense_to_debris_percentage INT DEFAULT 0,
fuel_included_in_debris BOOLEAN DEFAULT false,
initial_res_premium INT DEFAULT 0,
bonus_fields INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
@@ -144,13 +170,49 @@ async function initDatabase() {
const client = await pool.connect(); const client = await pool.connect();
try { try {
// Wir senden den kompletten SQL-String an die Datenbank // Wir senden den kompletten SQL-String für die Tabellenstruktur an die Datenbank
await client.query(createTablesQuery); await client.query(createTablesQuery);
console.log('✅ Tabellen wurden erfolgreich erstellt (oder existierten bereits).'); console.log('✅ Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
const sectorCheck = await client.query('SELECT count(*) FROM sectors'); // --------------------------------------------------------------------------
// SEEDING: Admin- & Test-Accounts einfegen
// --------------------------------------------------------------------------
console.log('🌱 Starte das Seeding der Core-Lobby-Accounts...');
// FÜGE HIER DEINEN GEMERKTEN HASH EIN:
const sharedPasswordHash = '$argon2id$v=19$m=65536,t=3,p=4$Iy65NQOkCUkJOJZL2HPyEQ$XygOJe9egEtfg0B2o3QRnlxQKbT1MwIy4vKOQntDIkY';
// Wir nutzen ON CONFLICT (username) DO NOTHING, um doppelte Inserts zu verhindern
await client.query(`
INSERT INTO lobby_accounts (username, email, password_hash, is_email_verified, is_admin, res_premium)
VALUES ($1, $2, $3, true, true, 50000000)
ON CONFLICT (username) DO NOTHING
`, ['Novacell', 'info@void-genesis.de', sharedPasswordHash]);
console.log('👑 Admin-Account "Novacell" wurde verifiziert und geladen (Premium: 50.000.000).');
const testAccountPromises = [];
for (let i = 1; i <= 10; i++) {
const username = `Test${i}`;
const email = `info+${i}@void-genesis.de`;
await client.query(`
INSERT INTO lobby_accounts (username, email, password_hash, is_email_verified, is_admin, res_premium)
VALUES ($1, $2, $3, true, false, 10000)
ON CONFLICT (username) DO NOTHING
`, [username, email, sharedPasswordHash]);
}
// Alle Test-Accounts parallel in die DB jagen
await Promise.all(testAccountPromises);
console.log('👥 10 Test-Accounts (TestPlayer1 bis TestPlayer10) erfolgreich angelegt.');
// --------------------------------------------------------------------------
// SEEDING: Sektoren (unverändert aus deinem Original-Skript)
// --------------------------------------------------------------------------
const sectorCheck = await client.query('SELECT count(*) FROM sectors');
if (parseInt(sectorCheck.rows[0].count) === 0) { if (parseInt(sectorCheck.rows[0].count) === 0) {
console.log('🌱 Füge 24 Test-Sektoren ein...'); console.log('🌱 Füge Test-Sektoren sequenziell ein...');
const greekAlphabet = [ const greekAlphabet = [
'Development', 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Development', 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta',
@@ -159,31 +221,28 @@ const sectorCheck = await client.query('SELECT count(*) FROM sectors');
'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega' 'Tau', 'Upsilon', 'Phi', 'Chi', 'Psi', 'Omega'
]; ];
// Wir generieren für jeden Namen ein dynamisches INSERT for (let index = 0; index < greekAlphabet.length; index++) {
const insertPromises = greekAlphabet.map((name, index) => { const name = greekAlphabet[index];
// Wir variieren die Geschwindigkeiten leicht für optische Vielfalt
const speed = (index % 4) + 1; const speed = (index % 4) + 1;
const apiUrl = name === 'Development' const apiUrl = name === 'Development'
? 'http://sector-backend:3000' ? 'http://sector-backend:3000'
: `http://sector-api-${name.toLowerCase()}:3000`; : `http://sector-api-${name.toLowerCase()}:3000`;
return client.query(` await client.query(`
INSERT INTO sectors (name, status, api_url, speed_fleet, speed_economy) INSERT INTO sectors (name, status, api_url)
VALUES ($1, 'offline', $2, $3, $3) VALUES ($1, 'offline', $2)
`, [name, apiUrl, speed]); `, [name, apiUrl]);
}); }
console.log('✅ 25 Sektoren ohne Parallelitäts-Konflikte eingefügt.');
await Promise.all(insertPromises);
console.log('✅ 25 Test-Sektoren erfolgreich eingefügt.');
} }
console.log('🎉 Lobby-Datenbank-Initialisierung vollständig und sauber abgeschlossen!');
} catch (error) { } catch (error) {
console.error('❌ Fehler beim Erstellen der Tabellen:', error); console.error('❌ Fehler beim Erstellen oder Befüllen der Lobby-Tabellen:', error);
} finally { } finally {
// Ganz wichtig: Verbindung wieder freigeben // Verbindung freigeben und Prozess sauber beenden
client.release(); client.release();
// Den Node-Prozess beenden, damit das Skript nicht in der Konsole hängen bleibt
process.exit(); process.exit();
} }
} }
+5 -1
View File
@@ -9,6 +9,7 @@ import pool from './db.js';
import authRoutes from './routes/auth.routes.js'; import authRoutes from './routes/auth.routes.js';
import sectorRoutes from './routes/sector.routes.js'; import sectorRoutes from './routes/sector.routes.js';
import { pingSectors } from './services/heartbeat.service.js'; import { pingSectors } from './services/heartbeat.service.js';
import { initLobbyCronJobs } from './services/cron.service.js';
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -49,11 +50,14 @@ app.get('/api/db-test', async (req, res) => {
} }
}); });
// Server starten // Server starten
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`); console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`); console.log(`Environment: ${process.env.NODE_ENV}`);
initLobbyCronJobs();
console.log('💓 Heartbeat-System initialisiert (Intervall: 30s)'); console.log('💓 Heartbeat-System initialisiert (Intervall: 30s)');
pingSectors(); pingSectors();
setInterval(pingSectors, 30000); setInterval(pingSectors, 30000);
}); });
@@ -0,0 +1,74 @@
import cron from 'node-cron';
import pool from '../db.js';
/**
* Initialisiert alle zeitgesteuerten Hintergrund-Aufgaben (Cronjobs) für die Lobby.
* Wird einmalig beim Serverstart aufgerufen.
*/
export const initLobbyCronJobs = () => {
console.log('⏰ Initialisiere Lobby Cronjobs...');
// --------------------------------------------------------------------------
// CRON: Prüfung geschlossener Sektoren auf freie Kapazitäten
// Format: 'Minute Stunde Tag Monat Wochentag'
// '0 4 * * *' bedeutet: Jeden Tag exakt um 04:00 Uhr nachts.
// --------------------------------------------------------------------------
cron.schedule('0 4 * * *', async () => {
console.log('🔍 [CRON] Prüfe geschlossene Sektoren auf freie Startplätze...');
const client = await pool.connect();
try {
// Alle Sektoren finden, deren Registrierung aktuell geschlossen ist
const closedSectorsQuery = await client.query(`
SELECT id, internal_url, name
FROM sectors
WHERE registration_open = false
`);
const closedSectors = closedSectorsQuery.rows;
if (closedSectors.length === 0) {
console.log('✅ [CRON] Keine geschlossenen Sektoren gefunden. Nichts zu tun.');
return;
}
// Jeden geschlossenen Sektor einzeln über das Netzwerk abfragen
for (const sector of closedSectors) {
try {
const response = await fetch(`${sector.internal_url}/api/internal/slots`);
if (!response.ok) {
console.warn(`⚠️ [CRON] Sektor ${sector.name} hat einen Fehler gemeldet (Status: ${response.status}). Überspringe.`);
continue;
}
const data = await response.json();
const freeSlots = data.freeSlots;
// Auswertung der freien Plätze
// Wenn der Sektor wieder mindestens 150 freie Plätze hat, machen wir ihn auf.
if (freeSlots >= 150) {
console.log(`🔓 [CRON] Sektor ${sector.name} hat ${freeSlots} freie Plätze. Öffne Sektor für Neuregistrierungen!`);
await client.query(`
UPDATE sectors
SET registration_open = true
WHERE id = $1
`, [sector.id]);
} else {
console.log(`🔒 [CRON] Sektor ${sector.name} hat nur ${freeSlots} freie Plätze (unter 150). Bleibt geschlossen.`);
}
} catch (fetchError) {
console.warn(`⚠️ [CRON] Netzwerkfehler bei Abfrage von Sektor ${sector.name}:`, fetchError.message);
}
}
} catch (error) {
console.error('❌ [CRON] Kritischer Datenbank-Fehler beim Prüfen der Sektoren:', error);
} finally {
client.release();
}
});
};
@@ -1,5 +1,3 @@
// === File: lobby-backend/src/services/heartbeat.service.js ===
import pool from '../db.js'; import pool from '../db.js';
// ============================================================================== // ==============================================================================
@@ -9,49 +7,77 @@ import pool from '../db.js';
export const pingSectors = async () => { export const pingSectors = async () => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
const result = await client.query('SELECT id, name, api_url FROM sectors WHERE api_url IS NOT NULL'); const result = await client.query('SELECT id, name, api_url FROM sectors WHERE api_url IS NOT NULL');
const sectors = result.rows; const sectors = result.rows;
for (const sector of sectors) { for (const sector of sectors) {
try { try {
// Wir nutzen einen AbortController, um blockierende Anfragen
// nach 3 Sekunden hart abzubrechen (Timeout).
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); const timeoutId = setTimeout(() => controller.abort(), 3000);
// Nativer, sauberer fetch-Aufruf (Dank NODE_OPTIONS in Docker nun bugfrei)
const response = await fetch(sector.api_url + '/api/health', { const response = await fetch(sector.api_url + '/api/health', {
signal: controller.signal signal: controller.signal
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.status === 'ok') { if (data.status === 'ok') {
// Sektor antwortet korrekt -> Status auf online setzen
await client.query( await client.query(
"UPDATE sectors SET status = 'online' WHERE id = $1", `UPDATE sectors SET
[sector.id] status = 'online',
online_players = $1,
current_players = $2,
planets_count = $3,
max_galaxies = $4,
speed_economy = $5,
research_speed = $6,
fleet_speed_hostile = $7,
fleet_speed_peaceful = $8,
fleet_speed_friendly = $9,
fuel_consumption_factor = $10,
fleet_to_debris_percentage = $11,
defense_to_debris_percentage = $12,
fuel_included_in_debris = $13,
initial_res_premium = $14,
bonus_fields = $15
WHERE id = $16`,
[
data.stats.online_players,
data.stats.total_players,
data.stats.planets_count,
data.config.max_galaxies,
data.config.speed_economy,
data.config.research_speed,
data.config.fleet_speed_hostile,
data.config.fleet_speed_peaceful,
data.config.fleet_speed_friendly,
data.config.fuel_consumption_factor,
data.config.fleet_to_debris_percentage,
data.config.defense_to_debris_percentage,
data.config.fuel_included_in_debris,
data.config.initial_res_premium,
data.config.bonus_fields,
sector.id
]
); );
} else {
// Log-Ausgabe nur für unseren aktiven Entwicklungs-Sektor,
// um die Konsole sauber zu halten.
if (sector.name === 'Development') { if (sector.name === 'Development') {
//console.log(`[Heartbeat] ✅ Sektor '${sector.name}' ist online!`); console.warn(`[Heartbeat-Trace] ⚠️ Fehler: JSON-Status war nicht 'ok', sondern:`, data.status);
} }
await setSectorOffline(client, sector.id);
} }
} else { } else {
// Server erreichbar, liefert aber keinen 2xx Status if (sector.name === 'Development') {
console.warn(`[Heartbeat-Trace] ⚠️ HTTP-Response nicht OK! Status-Code:`, response.status);
}
await setSectorOffline(client, sector.id); await setSectorOffline(client, sector.id);
} }
} catch (error) { } catch (error) {
// Sektor gar nicht erreichbar (Container down, Netzwerkfehler, Timeout)
if (sector.name === 'Development') { if (sector.name === 'Development') {
console.error(`[Heartbeat] ❌ Sektor '${sector.name}' nicht erreichbar:`, error.message); console.error(`[Heartbeat-Trace] ❌ Crash bei Sektor 'Development'. Grund:`, error.message);
} }
await setSectorOffline(client, sector.id); await setSectorOffline(client, sector.id);
} }
@@ -11,11 +11,18 @@ import pool from '../db.js';
*/ */
export const getSectorsList = async (accountId) => { export const getSectorsList = async (accountId) => {
const client = await pool.connect(); const client = await pool.connect();
try { try {
// WURDE ERWEITERT: Wir selektieren nun die echten Cache-Werte (online_players, planets_count)
// sowie Konfigurationswerte (initial_res_premium, fleet_speed_hostile) und das Erstelldatum (created_at).
const mySectorsResult = await client.query(` const mySectorsResult = await client.query(`
SELECT SELECT
s.id, s.name, s.status, s.speed_fleet, s.speed_economy, s.id, s.name, s.status, s.is_registration_open,
s.online_players, s.current_players, s.planets_count, s.created_at,
s.speed_economy, s.research_speed, s.max_galaxies,
s.fleet_speed_hostile, s.fleet_speed_peaceful, s.fleet_speed_friendly,
s.fuel_consumption_factor, s.fleet_to_debris_percentage,
s.defense_to_debris_percentage, s.fuel_included_in_debris,
s.initial_res_premium, s.bonus_fields,
las.local_username, las.gender, las.local_username, las.gender,
las.created_at as joined_at, las.created_at as joined_at,
las.updated_at as last_played las.updated_at as last_played
@@ -27,31 +34,35 @@ export const getSectorsList = async (accountId) => {
const newSectorsResult = await client.query(` const newSectorsResult = await client.query(`
SELECT SELECT
id, name, status, speed_fleet, speed_economy id, name, status, is_registration_open,
online_players, current_players, planets_count, created_at,
speed_economy, research_speed, max_galaxies,
fleet_speed_hostile, fleet_speed_peaceful, fleet_speed_friendly,
fuel_consumption_factor, fleet_to_debris_percentage,
defense_to_debris_percentage, fuel_included_in_debris,
initial_res_premium, bonus_fields
FROM sectors FROM sectors
WHERE id NOT IN ( WHERE id NOT IN (
SELECT sector_id SELECT sector_id
FROM lobby_accounts_sectors FROM lobby_accounts_sectors
WHERE lobby_account_id = $1 WHERE lobby_account_id = $1
) )
-- Wir zeigen bei den "Neuen Sektoren" nur die an,
-- bei denen die Registrierung noch nicht serverseitig geschlossen wurde.
AND is_registration_open = true
ORDER BY created_at ASC ORDER BY created_at ASC
`, [accountId]); `, [accountId]);
const mapSectorData = (sector) => ({ // ALTE LOGIK ENTFERNT: Wir brauchen kein mapSectorData mit Math.random() mehr!
...sector, // Wir geben die echten Datenbank-Ergebnisse direkt ans Frontend weiter.
online_players: Math.floor(Math.random() * 150) + 10,
planets_count: (Math.floor(Math.random() * 150) + 10) * 12
});
return { return {
mySectors: mySectorsResult.rows.map(mapSectorData), mySectors: mySectorsResult.rows,
newSectors: newSectorsResult.rows.map(mapSectorData) newSectors: newSectorsResult.rows
}; };
} catch (error) { } catch (error) {
console.error('[SectorService] Fehler beim Auslesen der Sektordaten:', error); console.error('[SectorService] Fehler beim Auslesen der Sektordaten:', error);
throw error; throw error;
} finally { } finally {
client.release(); client.release();
} }
@@ -59,8 +70,8 @@ export const getSectorsList = async (accountId) => {
/** /**
* Trägt einen Spieler in einen neuen Sektor ein. * Trägt einen Spieler in einen neuen Sektor ein.
* Führt die eigentliche Datenbankoperation durch und wirft spezifische * Komuniziert zuest mit dem Ziel-Sektor und speichert den Psielstand in der lobby erst,
* Fehler-Objekte, falls Constraints (UNIQUE-Regeln) verletzt werden. * wenn des Sektor-Backend den Account erfolgreich akzeptiert hat.
* *
* @param {string} accountId - Die UUID des Nutzers * @param {string} accountId - Die UUID des Nutzers
* @param {string} sectorId - Die UUID des Sektors * @param {string} sectorId - Die UUID des Sektors
@@ -73,6 +84,61 @@ export const joinSector = async (accountId, sectorId, localUsername, gender) =>
const client = await pool.connect(); const client = await pool.connect();
try { try {
const sectorQuery = await client.query(
'SELECT name, api_url, status FROM sectors WHERE id = $1',
[sectorId]
);
if (sectorQuery.rows.length === 0) {
const customErr = new Error('Sector not found');
customErr.code = 'ERR_SECTOR_NOT_FOUND';
throw customErr;
}
const sector = sectorQuery.rows[0];
if (sector.status !== 'online') {
const customErr = new Error('Sector is offline or under maintenance');
customErr.code = 'ERR_SECTOR_OFFLINE';
throw customErr;
}
// Ziel-Endpunkt des Sektors: POST /api/internal/accounts
const sectorInternalUrl = `${sector.api_url}/api/internal/accounts`;
console.log(`[Lobby-to-Server] Sende Registierung für '${localUsername}' an Sektor '${sector.name}' (${sectorInternalUrl})...`);
const response = await fetch(sectorInternalUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lobby_uuid: accountId,
username: localUsername,
gender: gender
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error(`[Lobby-to-Server] Sektor meldet Fehler (Status ${response.status}):`, errorData);
if (response.status == 409) {
const customErr = new Error(errorData.error || 'Conflict in Sector');
customErr.code = errorData.error || 'ERR_LOCAL_USERNAME_TAKEN';
throw customErr;
}
const customErr = new Error('Sector communication failed');
customErr.code = 'ERR_SECTOR_CONNECTION_FAILED';
throw customErr;
}
const sectorResultData = await response.json();
console.log(`[Lobby-to-Server] ✅ Sektor-Account erfolgreich angelegt! Local-UUID: ${sectorResultData.account?.local_uuid}`);
// Wir versuchen, den Datensatz direkt einzufügen. // Wir versuchen, den Datensatz direkt einzufügen.
await client.query(` await client.query(`
INSERT INTO lobby_accounts_sectors (lobby_account_id, sector_id, local_username, gender) INSERT INTO lobby_accounts_sectors (lobby_account_id, sector_id, local_username, gender)
+9 -15
View File
@@ -42,6 +42,9 @@ export const translations = {
// --- Sektor-Onboarding --- // --- Sektor-Onboarding ---
ERR_ALREADY_IN_SECTOR: "Du bist diesem Sektor bereits beigetreten.", ERR_ALREADY_IN_SECTOR: "Du bist diesem Sektor bereits beigetreten.",
ERR_LOCAL_USERNAME_TAKEN: "Dieser Commander-Name ist in diesem Sektor leider schon vergeben.", ERR_LOCAL_USERNAME_TAKEN: "Dieser Commander-Name ist in diesem Sektor leider schon vergeben.",
ERR_ACCOUNT_ALREADY_EXISTS: "Dieser Commander-Name oder deine Lobby-ID ist in diesem Sektor bereits registriert.",
ERR_SECTOR_OFFLINE: "Dieser Sektor ist derzeit offline oder wird gewartet. Beitritt nicht möglich.",
ERR_SECTOR_CONNECTION_FAILED: "Verbindung zum Sektor fehlgeschlagen. Bitte versuche es später noch einmal.",
MSG_SECTOR_JOINED_SUCCESS: "Sektor erfolgreich beigetreten!", MSG_SECTOR_JOINED_SUCCESS: "Sektor erfolgreich beigetreten!",
// --- Footer --- // --- Footer ---
@@ -83,7 +86,7 @@ export const translations = {
btn_reset_password: "CHANGE PASSWORD", btn_reset_password: "CHANGE PASSWORD",
link_back: "« back", link_back: "« back",
btn_male: "Male", btn_male: "Male",
btn_female: "Femail", btn_female: "Female",
btn_cancel: "Cancel", btn_cancel: "Cancel",
btn_join: "Join Sector", btn_join: "Join Sector",
@@ -95,7 +98,8 @@ export const translations = {
ph_otp: "6-digit code", ph_otp: "6-digit code",
// --- Labels --- // --- Labels ---
lbl_agb: "I accept the <a href='agb.html' target='_blank'>Terms of Service</a>, <a href='regeln.html' target='_blank'>Rules</a> & <a href='datenschutz.html' target='_blank'>Privacy Policy</a>", lbl_remember: "Keep me logged in", lbl_agb: "I accept the <a href='agb.html' target='_blank'>Terms of Service</a>, <a href='regeln.html' target='_blank'>Rules</a> & <a href='datenschutz.html' target='_blank'>Privacy Policy</a>",
lbl_remember: "Keep me logged in",
// --- OTP Modus --- // --- OTP Modus ---
txt_otp_instruction: "Please enter the 6-digit code from your e-mail.", txt_otp_instruction: "Please enter the 6-digit code from your e-mail.",
@@ -108,6 +112,9 @@ export const translations = {
// --- Sektor-Onboarding --- // --- Sektor-Onboarding ---
ERR_ALREADY_IN_SECTOR: "You have already joined this sector.", ERR_ALREADY_IN_SECTOR: "You have already joined this sector.",
ERR_LOCAL_USERNAME_TAKEN: "This Commander name is already taken in this sector.", ERR_LOCAL_USERNAME_TAKEN: "This Commander name is already taken in this sector.",
ERR_ACCOUNT_ALREADY_EXISTS: "This Commander name or your Lobby ID is already registered in this sector.",
ERR_SECTOR_OFFLINE: "This sector is currently offline or under maintenance. Cannot join.",
ERR_SECTOR_CONNECTION_FAILED: "Connection to sector failed. Please try again later.",
MSG_SECTOR_JOINED_SUCCESS: "Successfully joined the sector!", MSG_SECTOR_JOINED_SUCCESS: "Successfully joined the sector!",
// --- Footer --- // --- Footer ---
@@ -139,16 +146,8 @@ export const translations = {
} }
}; };
/**
* Die aktuell gewählte Sprache.
* Später können wir das auch im LocalStorage des Browsers speichern,
* damit sich das Spiel die Sprache des Nutzers merkt.
*/
export let currentLang = localStorage.getItem('void_language') || 'de'; export let currentLang = localStorage.getItem('void_language') || 'de';
/**
* Hilfsfunktion, um die Sprache zu wechseln.
*/
export function setLanguage(lang) { export function setLanguage(lang) {
if (translations[lang]) { if (translations[lang]) {
currentLang = lang; currentLang = lang;
@@ -156,11 +155,6 @@ export function setLanguage(lang) {
} }
} }
/**
* Holt den passenden Text für den übergebenen Schlüssel (Key).
* Fällt auf den Key selbst zurück, falls keine Übersetzung gefunden wird
* (hilft beim Debuggen, um fehlende Übersetzungen zu erkennen).
*/
export function t(key) { export function t(key) {
return translations[currentLang][key] || key; return translations[currentLang][key] || key;
} }
+52 -9
View File
@@ -521,9 +521,52 @@ function renderSectorList(sectors, type) {
// HTML-Karte im DOM blitzschnell zu finden, ohne das Grid neu aufzubauen. // HTML-Karte im DOM blitzschnell zu finden, ohne das Grid neu aufzubauen.
cardLink.setAttribute('data-sector-id', sector.id); cardLink.setAttribute('data-sector-id', sector.id);
// --- Temporäre Platzhalter für das neue UI (ersetzen wir später durch echte DB-Werte) --- // --- ECHTE DATEN STATT PLATZHALTER ---
const ageDays = sector.age_days || Math.floor(Math.random() * 30) + 1; const createdDate = new Date(sector.created_at);
const totalPlayers = sector.total_players || Math.floor(Math.random() * 500) + 150; const ageDays = Math.max(1, Math.ceil((new Date() - createdDate) / (1000 * 60 * 60 * 24)));
// Tooltip mit echten Konfigurationswerten aus der Sektor-DB aufbauen
const tooltipLines = [
`Anzahl der Galaxien: ${sector.max_galaxies}`,
`Ökonomie-Tempo: ${sector.speed_economy}x`,
`Forschungs-Tempo: ${sector.research_speed}x`,
`Flotten Geschwindigkeiten:`,
`Feindliche Flotte: ${sector.fleet_speed_hostile}x`,
`Friedliche Flotte: ${sector.fleet_speed_peaceful}x`,
`Freundliche Flotte: ${sector.fleet_speed_friendly}x`
];
// BEDINGUNG: Treibstoff-Verbrauchsfaktor nur anzeigen, wenn er ungleich 1 ist
if (sector.fuel_consumption_factor !== undefined && parseInt(sector.fuel_consumption_factor, 10) !== 1) {
tooltipLines.push(`Treibstoff Verbrauch: ${sector.fuel_consumption_factor}x`);
}
// Standardmäßig immer das Flotten-Trümmerfeld-Prozent anzeigen
tooltipLines.push(`${sector.fleet_to_debris_percentage}% der Flotten ins Trümmerfeld`);
// BEDINGUNG: Verteidigungs-Trümmerfeld-Prozent nur anzeigen, wenn es größer als 0 ist
if (sector.defense_to_debris_percentage !== undefined && parseInt(sector.defense_to_debris_percentage, 10) > 0) {
tooltipLines.push(`${sector.defense_to_debris_percentage}% der Verteidigungsanlagen ins Trümmerfeld`);
}
// BEDINGUNG: Treibstoff im Trümmerfeld anzeigen, wenn das Flag true (oder ungleich 0 / false) ist
if (sector.fuel_included_in_debris === true || sector.fuel_included_in_debris === 1 || sector.fuel_included_in_debris === 'true') {
tooltipLines.push(`Treibstoff im Trümmerfeld: Ja`);
}
// BEDINGUNG: Start-Premium-Währung nur auflisten, wenn sie ungleich 0 ist
if (sector.initial_res_premium !== undefined && parseInt(sector.initial_res_premium, 10) !== 0) {
tooltipLines.push(`Void Kristalle beim Start: ${sector.initial_res_premium}`);
}
// BEDINGUNG: Bonus-Felder für Planeten nur anzeigen, wenn sie ungleich 0 sind
if (sector.bonus_fields !== undefined && parseInt(sector.bonus_fields, 10) !== 0) {
tooltipLines.push(`Bonus auf Planetenfelder: ${sector.bonus_fields}`);
}
// 3. Alle gesammelten Zeilen mit einem echten Zeilenumbruch (\n) zu einem einzelnen String verknüpfen
const tooltipText = tooltipLines.join('\n');
let bottomRowHtml = ''; let bottomRowHtml = '';
if (type === 'my' && sector.local_username) { if (type === 'my' && sector.local_username) {
@@ -532,7 +575,7 @@ function renderSectorList(sectors, type) {
cardLink.innerHTML = ` cardLink.innerHTML = `
<div class="sector-card-content" style="position: relative; width: 100%; height: 100%; padding: 0;"> <div class="sector-card-content" style="position: relative; width: 100%; height: 100%; padding: 0;">
<span style="position: absolute; left: 10px; top: 15px; width: 25px; height: 27px; cursor: help;" title="Sektor-Details (Flotte: ${sector.speed_fleet}x, Öko: ${sector.speed_economy}x)"></span> <span style="position: absolute; left: 10px; top: 15px; width: 25px; height: 27px; cursor: help;" title="${tooltipText}"></span>
<span style="position: absolute; left: 40px; top: 15px; width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 18px; font-weight: bold;" title="${sector.name}"> <span style="position: absolute; left: 40px; top: 15px; width: 140px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 18px; font-weight: bold;" title="${sector.name}">
${sector.name} ${sector.name}
</span> </span>
@@ -540,7 +583,8 @@ function renderSectorList(sectors, type) {
<span class="sector-status ${sector.status === 'online' ? 'status-online' : 'status-maintenance'}" style="position: absolute; right: 20px; top: 17px;"> <span class="sector-status ${sector.status === 'online' ? 'status-online' : 'status-maintenance'}" style="position: absolute; right: 20px; top: 17px;">
${sector.status === 'online' ? 'online' : 'offline'} ${sector.status === 'online' ? 'online' : 'offline'}
</span> </span>
<span class="sector-online-count" style="position: absolute; left: 40px; top: 54px; font-size: 13px; color: var(--color-text-placeholder);">Online: ${sector.online_players}/${totalPlayers}</span>
<span class="sector-online-count" style="position: absolute; left: 40px; top: 54px; font-size: 13px; color: var(--color-text-placeholder);">Online: ${sector.online_players}/${sector.current_players}</span>
<span class="sector-planets-count" style="position: absolute; left: 170px; top: 54px; font-size: 13px; color: var(--color-text-placeholder);">Planeten: ${sector.planets_count}</span> <span class="sector-planets-count" style="position: absolute; left: 170px; top: 54px; font-size: 13px; color: var(--color-text-placeholder);">Planeten: ${sector.planets_count}</span>
${bottomRowHtml} ${bottomRowHtml}
</div> </div>
@@ -755,12 +799,11 @@ function updateLiveStatus(sectors, type) {
statusEl.className = `sector-status ${sector.status === 'online' ? 'status-online' : 'status-maintenance'}`; statusEl.className = `sector-status ${sector.status === 'online' ? 'status-online' : 'status-maintenance'}`;
} }
// 2. HTML-Spielerzahlen über die neue, eindeutige Klasse aktualisieren /// 2. HTML-Spielerzahlen über die eindeutige Klasse aktualisieren
const midRowEl = cardLink.querySelector('.sector-online-count'); const midRowEl = cardLink.querySelector('.sector-online-count');
if (midRowEl) { if (midRowEl) {
// Wir behalten deine Logik bei, falls die Gesamtzahl temporär berechnet werden muss // Das DOM übernimmt die echten Live-Werte für Online und Gesamt
const totalPlayers = sector.total_players || Math.floor(Math.random() * 500) + 150; midRowEl.textContent = `Online: ${sector.online_players}/${sector.current_players}`;
midRowEl.textContent = `Online: ${sector.online_players}/${totalPlayers}`;
} }
// 3. HTML-Planetenanzahl über die neue, eindeutige Klasse aktualisieren // 3. HTML-Planetenanzahl über die neue, eindeutige Klasse aktualisieren
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,7 @@
{ {
"name": "void-genesis-sector-backend", "name": "void-genesis-sector-backend",
"version": "1.0.0", "version": "1.0.0",
"type": "module",
"description": "Void-Genesis Game Sector Backend", "description": "Void-Genesis Game Sector Backend",
"main": "src/server.js", "main": "src/server.js",
"scripts": { "scripts": {
@@ -8,9 +9,10 @@
}, },
"dependencies": { "dependencies": {
"express": "^4.19.2", "express": "^4.19.2",
"node-cron": "^4.2.1",
"pg": "^8.11.5" "pg": "^8.11.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.0" "nodemon": "^3.1.0"
} }
} }
@@ -0,0 +1,8 @@
{
"ACHIEV_INTRO_SKIPPED": {
"id": 1001,
"reward_title_id": 1,
"reward_avatar_id": null,
"hidden": true
}
}
@@ -0,0 +1,62 @@
{
"universe_dimensions": {
"max_galaxies": 5,
"max_systems_per_galaxy": 500,
"max_positions_per_system": 15
},
"starter_zone": {
"min_position": 6,
"max_position": 10,
"fixed_start_positions": [6, 10],
"max_players_per_system": 2,
"leap_min_systems": 3,
"leap_max_systems": 7
},
"game_speeds": {
"economy_speed": 1,
"research_speed": 1,
"fleet_speed_hostile": 1,
"fleet_speed_peaceful": 1,
"fleet_speed_friendly": 1,
"fuel_consumption_factor": 1.0
},
"debris_fields": {
"fleet_to_debris_percentage": 30.0,
"defense_to_debris_percentage": 0.0,
"fuel_included_in_debris": false
},
"account_registration_bonus": {
"initial_res_premium": 800
},
"planet_generation": {
"bonus_fields": 0,
"dimensions": {
"min_diameter": 10000,
"max_diameter": 20000,
"fields_per_km_diameter": 0.015
},
"home_planet": {
"fixed_diameter": 13800,
"pop_idle": 500.0000,
"pop_injured": 500.0000,
"res_food_base": 1000.0000
},
"temperature_zones": {
"1": { "temp_min": 150, "temp_max": 250 },
"2": { "temp_min": 120, "temp_max": 200 },
"3": { "temp_min": 90, "temp_max": 160 },
"4": { "temp_min": 60, "temp_max": 110 },
"5": { "temp_min": 30, "temp_max": 70 },
"6": { "temp_min": 15, "temp_max": 45 },
"7": { "temp_min": 10, "temp_max": 35 },
"8": { "temp_min": 0, "temp_max": 25 },
"9": { "temp_min": -15, "temp_max": 15 },
"10": { "temp_min": -30, "temp_max": 5 },
"11": { "temp_min": -55, "temp_max": -15 },
"12": { "temp_min": -80, "temp_max": -40 },
"13": { "temp_min": -110, "temp_max": -70 },
"14": { "temp_min": -150, "temp_max": -100 },
"15": { "temp_min": -200, "temp_max": -140 }
}
}
}
@@ -0,0 +1,17 @@
{
"1": {
"system_key": "INTRO_SKIPPER",
"languages": {
"de": {
"male": "Der Ungeduldige",
"female": "Die Ungeduldige",
"description": "Hat das Intro übersprungen."
},
"en": {
"male": "The Impatient One",
"female": "The Impatient One",
"description": "Skipped the intro."
}
}
}
}
@@ -0,0 +1,52 @@
import pool from '../db.js';
import { getSectorSettings } from '../services/config.service.js';
/**
* Gibt den Status, die Live-Statistiken und die Konfiguration des Sektor-Backends zurück.
* Wird alle 30 Sekunden vom Heartbeat-Service der Lobby aufgerufen.
*
* @param {import('express').Request} req - Das Express Request-Objekt
* @param {import('express').Response} res - Das Express Response-Objekt
*/
export const getHealthStatus = async (req, res) => {
try {
const settings = getSectorSettings();
const onlineResult = await pool.query(`
SELECT COUNT(*) FROM local_accounts
WHERE updated_at > NOW() - INTERVAL '60 minutes'
`);
const totalPlayersResult = await pool.query('SELECT COUNT(*) FROM local_accounts');
const planetsResult = await pool.query('SELECT COUNT(*) FROM planets');
return res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
stats: {
online_players: parseInt(onlineResult.rows[0].count, 10),
total_players: parseInt(totalPlayersResult.rows[0].count, 10),
planets_count: parseInt(planetsResult.rows[0].count, 10)
},
config: {
max_galaxies: settings.universe_dimensions.max_galaxies,
speed_economy: settings.game_speeds.economy_speed,
research_speed: settings.game_speeds.research_speed,
fleet_speed_hostile: settings.game_speeds.fleet_speed_hostile,
fleet_speed_peaceful: settings.game_speeds.fleet_speed_peaceful,
fleet_speed_friendly: settings.game_speeds.fleet_speed_friendly,
fuel_consumption_factor: settings.game_speeds.fuel_consumption_factor,
fleet_to_debris_percentage: settings.debris_fields.fleet_to_debris_percentage,
defense_to_debris_percentage: settings.debris_fields.defense_to_debris_percentage,
fuel_included_in_debris: settings.debris_fields.fuel_included_in_debris,
initial_res_premium: settings.account_registration_bonus.initial_res_premium,
bonus_fields: settings.planet_generation.bonus_fields
}
});
} catch (error) {
console.error('❌ Fehler im Health-Controller:', error);
return res.status(500).json({ status: 'error', message: 'Internal Server Error' });
}
};
@@ -0,0 +1,59 @@
import { createLocalAccount } from '../services/account.service.js';
import { getFreeStarterSlotsCount } from '../services/planet.service.js';
export const registerInternalAccount = async (req, res) => {
const { lobby_uuid, username, gender } = req.body;
if (!lobby_uuid || !username || !gender) {
return res.status(400).json({
error: 'ERR_MISSING_REQUIRED_FIELDS'
});
}
try {
const { local_uuid, triggerSectorClose } = await createLocalAccount(lobby_uuid, username, gender);
return res.status(201).json({
message: 'MSG_ACCOUNT_CREATED_SUCCESSFULLY',
account: { local_uuid },
triggerSectorClose: triggerSectorClose
});
} catch (error) {
console.error('❌ Fehler bei interner Account-Registrierung:', error);
if (error.code === 'ERR_SECTOR_FULL') {
return res.status(503).json({
error: 'ERR_SECTOR_FULL'
});
}
if (error.code === '23505') {
return res.status(409).json({
error: 'ERR_ACCOUNT_ALREADY_EXISTS'
});
}
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER_ERROR'
});
}
};
/**
* Liefert die Anzahl der freien Startplätze für die Lobby zurück.
*/
export const getSectorSlotStatus = async (req, res) => {
try {
const freeSlots = await getFreeStarterSlotsCount();
return res.status(200).json({
freeSlots
});
} catch (error) {
console.error('❌ Fehler beim Abrufen der Sektor-Slots:', error);
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER_ERROR'
});
}
};
+28
View File
@@ -0,0 +1,28 @@
// ==============================================================================
// Void-Genesis - Datenbank-Konfiguration
// ==============================================================================
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
// Wir testen beim Start einmalig, ob die Verbindung klappt.
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
pool.connect((err, client, release) => {
if (err) {
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
} else {
console.log(`✅ Erfolgreich mit der PostgreSQL-Datenbank (${process.env.DB_NAME}) verbunden!`);
release();
}
});
export default pool;
@@ -0,0 +1,8 @@
import { Router } from 'express';
import { getHealthStatus } from '../controllers/health.controller.js';
const router = Router();
router.get('/health', getHealthStatus);
export default router;
@@ -0,0 +1,14 @@
/**
* Definition der internen Schnittstellen-Routen (Server-zu-Server).
* Diese Routen sind für die Kommunikation mit dem Lobby-Backend gedacht.
*/
import { Router } from 'express';
import { registerInternalAccount, getSectorSlotStatus } from '../controllers/internal.controller.js';
const router = Router();
router.post('/accounts', registerInternalAccount);
router.get('/slots', getSectorSlotStatus);
export default router;
@@ -0,0 +1,142 @@
import pool from '../db.js';
// ==============================================================================
// Void-Genesis - Datenbank Initialisierungs-Skript
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
// ==============================================================================
const createTablesQuery = `
-- ------------------------------------------------------------------------------
-- DROP TABLES
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
-- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden.
-- ------------------------------------------------------------------------------
DROP TABLE IF EXISTS planets;
DROP TABLE IF EXISTS local_accounts;
-- ------------------------------------------------------------------------------
-- TABLE: lobby_accounts
-- Basis-Nutzerdaten für das gesamte Void-Genesis Universum.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS local_accounts (
local_uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lobby_uuid UUID NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL UNIQUE,
gender VARCHAR(10) NOT NULL,
vacation BOOLEAN NOT NULL DEFAULT FALSE,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
res_premium INT NOT NULL DEFAULT 0,
avatar_id INT DEFAULT NULL,
title_id INT DEFAULT NULL,
-- Zeitstempel für Logs und Analysen
last_login TIMESTAMP WITH TIME ZONE DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: planets
-- Verwaltet alle Planeten des Universums, deren Ressourcen, Bevölkerung,
-- Geografie und die Zuordnung zum jeweiligen Commander (local_uuid).
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS planets (
planet_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_planet_id UUID REFERENCES planets(planet_id) ON DELETE CASCADE DEFAULT NULL,
local_uuid UUID REFERENCES local_accounts(local_uuid) ON DELETE SET NULL,
planet_name VARCHAR(100) NOT NULL,
galaxy INT NOT NULL,
system INT NOT NULL,
position INT NOT NULL,
temp_min INT NOT NULL,
temp_max INT NOT NULL,
diameter INT NOT NULL,
fields_built INT NOT NULL,
fields_explored INT NOT NULL,
fields_max INT NOT NULL,
res_wood NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_stone NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_food_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_food_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_idle NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_working NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_injured NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: debris_fields
-- Speichert Trümmerfelder (aus Flottenkämpfen), wie in sectorSettings.json definiert.
-- Da ein Trümmerfeld keinem Account gehört, ist es eine separate Entität auf einer Koordinate.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS debris_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
galaxy INT NOT NULL,
system INT NOT NULL,
position INT NOT NULL,
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(galaxy, system, position)
);
-- ------------------------------------------------------------------------------
-- TRIGGER: Auto-update 'updated_at' timestamp
-- Diese Funktion setzt das 'updated_at' Feld bei jeder Änderung auf die aktuelle Zeit.
-- ------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Wir wenden den Trigger nur an, wenn er noch nicht existiert.
DO $$
BEGIN
-- Trigger für local_accounts
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_local_accounts_modtime') THEN
CREATE TRIGGER update_local_accounts_modtime
BEFORE UPDATE ON local_accounts
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_debris_fields_modtime') THEN
CREATE TRIGGER update_debris_fields_modtime
BEFORE UPDATE ON debris_fields
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
END IF;
END $$;
`;
// ==============================================================================
// Ausführungs-Logik
// ==============================================================================
async function initDatabase() {
console.log('⏳ Starte Sektor-Datenbank-Initialisierung...');
const client = await pool.connect();
try {
await client.query(createTablesQuery);
console.log('✅ Sektor-Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
} catch (error) {
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
} finally {
client.release();
process.exit();
}
}
initDatabase();
+21 -39
View File
@@ -1,48 +1,30 @@
// Externe Abhängigkeiten importieren import express from 'express';
const express = require('express'); import pool from './db.js';
const { Pool } = require('pg'); // Das 'pg'-Modul ermöglicht die Kommunikation mit PostgreSQL import healthRoutes from './routes/health.routes.js';
import internalRoutes from './routes/internal.routes.js';
import { initGameConfig } from './services/config.service.js';
import { initCronJobs } from './services/cron.service.js';
const app = express(); const app = express();
// Der interne Port im Container bleibt 3000 (wird via Docker Compose auf 3002 gemappt) const port = process.env.PORT || 3000;
const port = 3000;
// ---------------------------------------------------------------------------- app.use(express.json());
// PostgreSQL Verbindungs-Pool konfigurieren
// ----------------------------------------------------------------------------
// Wir ziehen uns die Zugangsdaten direkt aus den Umgebungsvariablen (process.env),
// die Docker Compose aus der .env-Datei in den Container injiziert hat.
const pool = new Pool({
host: process.env.DB_HOST, // Entspricht im Docker-Netzwerk dem Service 'sector-db'
port: process.env.DB_PORT, // Interner Port 5432
user: process.env.DB_USER, // sector_admin
password: process.env.DB_PASS, // Dein neues Sektor-Passwort
database: process.env.DB_NAME, // void_sector
});
// ----------------------------------------------------------------------------
// Datenbank-Verbindungstest beim Serverstart
// ----------------------------------------------------------------------------
// Wir setzen eine Test-Query ab (SELECT NOW()), um zu prüfen, ob die DB bereit ist.
pool.query('SELECT NOW()', (err, res) => {
if (err) {
console.error('❌ Fehler bei der Verbindung zur Sektor-Datenbank:', err.stack);
} else {
console.log(`✅ Erfolgreich mit der Sektor-PostgreSQL-Datenbank (${process.env.DB_NAME}) verbunden!`);
}
});
// ----------------------------------------------------------------------------
// Express Routen & Server-Start
// ----------------------------------------------------------------------------
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.send('Void-Genesis Sektor-Backend läuft im Dev-Modus und ist mit der DB verbunden!'); res.send('Void-Genesis Sektor-Backend läuft im Dev-Modus und ist mit der DB verbunden!');
}); });
app.get('/api/health', (req, res) => { app.use('/api', healthRoutes);
res.json({ status: 'ok' }); app.use('/api/internal', internalRoutes);
});
app.listen(port, () => { async function startServer() {
console.log(`🚀 Sektor-Backend gestartet auf Port ${port}`); await initGameConfig();
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); initCronJobs();
});
app.listen(port, () => {
console.log(`🚀 Sektor-Backend gestartet auf Port ${port}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
}
startServer();
@@ -0,0 +1,79 @@
import pool from '../db.js';
import { getSectorSettings } from './config.service.js';
import { findNextAvailableStarterCoordinates } from './planet.service.js';
import { generateStarterPlanetStats } from '../utils/planet.util.js';
/**
* Erstellt einen neuen Sektor-Account samt Heimatplanet in einer sicheren Datenbank-Transaktion.
*
* @param {string} lobby_uuid - Die UUID des Nutzers aus der Lobby.
* @param {string} username - Der Anzeigename.
* @param {string} gender - Das Geschlecht des Commanders.
*
* @returns {Promise<{local_uuid: string, triggerSectorClose: boolean}>}
*/
export const createLocalAccount = async (lobby_uuid, username, gender) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { galaxy, system, position, triggerSectorClose } = await findNextAvailableStarterCoordinates();
const planetStats = generateStarterPlanetStats(position);
const settings = getSectorSettings();
const initialPremium = settings.account_registration_bonus.initial_res_premium;
const accountInsertQuery = `
INSERT INTO local_accounts (lobby_uuid, username, gender, res_premium)
VALUES ($1, $2, $3, $4)
RETURNING local_uuid
`;
const accountResult = await client.query(accountInsertQuery, [
lobby_uuid,
username,
gender,
initialPremium
]);
const local_uuid = accountResult.rows[0].local_uuid;
const planetName = "Unbekannter Planet";
const planetInsertQuery = `
INSERT INTO planets (
local_uuid, planet_name, galaxy, system, position,
temp_min, temp_max, diameter, fields_built, fields_explored, fields_max,
pop_idle, pop_injured, res_food_base
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, 0, 10, $9,
$10, $11, $12
)
`;
await client.query(planetInsertQuery, [
local_uuid,
planetName,
galaxy,
system,
position,
planetStats.temp_min,
planetStats.temp_max,
planetStats.diameter,
planetStats.fields_max,
planetStats.pop_idle,
planetStats.pop_injured,
planetStats.res_food_base
]);
await client.query('COMMIT');
return { local_uuid, triggerSectorClose };
} catch (error) {
await client.query('ROLLBACK');
console.error('[AccountService] Fehler bei der Account-Erstellung. Transaktion abgebrochen:', error);
throw error;
} finally {
client.release();
}
};
@@ -0,0 +1,91 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG_DIR = path.join(__dirname, '../config/game');
let loadedConfig = {
sectorSettings: null,
achievements: null,
titles: null
};
/**
* Lädt eine spezifische JSON-Datei und parst ihren Inhalt.
* SAFETYS: Friert das Objekt ein, um versehentliche Mutationen im Spielcode zu verhindern.
*
* @param {string} fileName - Name der Datei (z.B. 'titles.json')
*
* @returns {Promise<Object>} Das parste und eingefrorene Konfigurations-Objekt
*/
async function loadConfigFile(fileName) {
try {
const fullPath = path.join(CONFIG_DIR, fileName);
const rawData = await fs.readFile(fullPath, 'utf-8');
const parsedData = JSON.parse(rawData);
return Object.freeze(parsedData);
} catch (error) {
console.error(`❌ Kritischer Fehler beim Laden der Konfigurationsdatei [${fileName}]:`, error);
throw error;
}
}
/**
* Initialisiert den ConfigService und lädt alle Module sequenziell in den Speicher.
* Sollte direkt beim Serverstart in der server.js aufgerufen werden.
*/
export const initGameConfig = async () => {
console.log('⏳ Lade modulare Game-Konfigurationen in den Speicher...');
try {
loadedConfig.sectorSettings = await loadConfigFile('sectorSettings.json');
loadedConfig.achievements = await loadConfigFile('achievements.json');
loadedConfig.titles = await loadConfigFile('titles.json');
console.log('✅ Alle Game-Konfigurationen erfolgreich geladen und im RAM gesichert.');
} catch (error) {
console.error('❌ Initialisierung der Game-Konfiguration fehlgeschlagen!');
process.exit(1);
}
};
/**
* Gibt die vollständigen Sektor-Einstellungen zurück.
*
* @returns {Object}
*/
export const getSectorSettings = () => loadedConfig.sectorSettings;
/**
* Gibt den gesamten Erfolgs-Katalog zurück.
*
* @returns {Object}
*/
export const getAchievements = () => loadedConfig.achievements;
/**
* Löst eine Title-ID basierend auf Sprache und Gender in den konkreten Text auf.
*
* @param {number|string} titleId - Die ID des Titels aus der DB
* @param {string} lang - Der Sprachschlüssel (z.B. 'de', 'en')
* @param {string} gender - Das Geschlecht des Commanders ('male', 'female')
*
* @returns {Object|null} Ein Objekt mit { name: "Der...", description: "..." } oder null
*/
export const resolveTitle = (titleId, lang = 'de', gender = 'male') => {
const titleEntry = loadedConfig.titles[String(titleId)];
if (!titleEntry) return null;
const langPack = titleEntry.languages[lang] || titleEntry.languages['de'];
if (!langPack) return null;
return {
system_key: titleEntry.system_key,
name: langPack[gender] || langPack['male'],
description: langPack.description
};
};
@@ -0,0 +1,37 @@
import cron from 'node-cron';
import pool from '../db.js';
/**
* Initialisiert alle zeitgesteuerten Hintergrund-Aufgaben (Cronjobs) für den Sektor.
* Wird einmalig beim Serverstart aufgerufen.
*/
export const initCronJobs = () => {
console.log('⏰ Initialisiere Sektor Cronjobs...');
// --------------------------------------------------------------------------
// CRON: Trümmerfeld-Bereinigung
// Format: 'Minute Stunde Tag Monat Wochentag'
// '15 2 * * 3' bedeutet: Jeden Mittwoch (3) um 02:15 Uhr nachts.
// --------------------------------------------------------------------------
cron.schedule('15 2 * * 3', async () => {
console.log('🧹 [CRON] Starte wöchentliche Trümmerfeld-Bereinigung...');
const client = await pool.connect();
try {
// Wir löschen alle Trümmerfelder, bei denen die Summe aller
// herumfliegenden Ressourcen streng kleiner als 290 ist.
const deleteQuery = `
DELETE FROM debris_fields
WHERE (res_primary + res_secondary + res_fuel_base + res_fuel_adv) < 290
`;
const result = await client.query(deleteQuery);
console.log(`✅ [CRON] Bereinigung abgeschlossen. ${result.rowCount} kleine Trümmerfelder wurden entfernt.`);
} catch (error) {
console.error('❌ [CRON] Fehler bei der Trümmerfeld-Bereinigung:', error);
} finally {
client.release();
}
});
};
@@ -0,0 +1,193 @@
import pool from '../db.js';
import { getSectorSettings } from './config.service.js';
/**
* Hilfsfunktion: Generiert eine zufällige Ganzzahl zwischen min und max (inklusive).
*
* @param {number} min - Minimum
* @param {number} max - Maximum
*
* @returns {number} Zufällige Ganzzahl
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Ermittelt die nächsten freien Koordinaten (Galaxie, System, Position) für einen neuen Spieler.
* Jeder Suchlauf startet bei 0 und springt zufällig vorwärts. Fällt die Suche aus dem
* Sektor, greift ein Notfall-Drop, der die restlichen Lücken füllt und bei < 100 Plätzen
* die Schließung des Sektors anordnet.
*
* @returns {Promise<{galaxy: number, system: number, position: number, triggerSectorClose: boolean}>}
*/
export const findNextAvailableStarterCoordinates = async () => {
const settings = getSectorSettings();
const starterConfig = settings.starter_zone;
const universeConfig = settings.universe_dimensions;
const startPositions = starterConfig.fixed_start_positions; // z.B. [6, 10]
const client = await pool.connect();
try {
let currentGalaxy = 1;
let currentSystem = 0; // Start bei 0. Der erste Leap bringt uns direkt ins erste gültige System.
let foundCoordinates = null;
let triggerSectorClose = false;
// REGULÄRE SCHLEIFE: Wir starten bei 0 und springen iterativ vorwärts
while (!foundCoordinates) {
const leap = getRandomInt(starterConfig.leap_min_systems, starterConfig.leap_max_systems);
currentSystem += leap;
if (currentSystem > universeConfig.max_systems_per_galaxy) {
currentGalaxy += 1;
currentSystem = currentSystem - universeConfig.max_systems_per_galaxy;
}
if (currentGalaxy > universeConfig.max_galaxies) {
break;
}
const systemCheckQuery = await client.query(`
SELECT position
FROM planets
WHERE galaxy = $1 AND system = $2 AND position = ANY($3)
`, [currentGalaxy, currentSystem, startPositions]);
const occupiedPositions = systemCheckQuery.rows.map(row => row.position);
const freePosition = startPositions.find(pos => !occupiedPositions.includes(pos));
if (freePosition) {
foundCoordinates = {
galaxy: currentGalaxy,
system: currentSystem,
position: freePosition,
triggerSectorClose: false // Im regulären Betrieb bleibt der Sektor offen
};
}
}
// NOTFALL-DROP: Die reguläre Suche ist am Ende des Universums angekommen
if (!foundCoordinates) {
console.log('[PlanetService] Suche aus Sektor gefallen. Starte Notfall-Drop Scan...');
// Wir zählen alle belegten Startplätze im GESAMTEN Sektor
const occupiedCountQuery = await client.query(`
SELECT COUNT(*) as count
FROM planets
WHERE position = ANY($1)
`, [startPositions]);
const occupiedCount = parseInt(occupiedCountQuery.rows[0].count, 10);
// Mathematisch maximale Anzahl an Startplätzen im Sektor berechnen
const totalStarterSlots = universeConfig.max_galaxies * universeConfig.max_systems_per_galaxy * startPositions.length;
const freeSlots = totalStarterSlots - occupiedCount;
// Absolute Notbremse: Sollte theoretisch durch unseren 100er-Puffer nie erreicht werden,
// aber schützt die Datenbank vor Endlosschleifen oder Null-Referenzen.
if (freeSlots <= 0) {
console.error('[PlanetService] Absolute Notbremse: Sektor ist komplett voll!');
const error = new Error("Sector is completely full");
error.code = 'ERR_SECTOR_FULL';
throw error;
}
// UX-SCHUTZ: Wenn wir die Grenze von 100 freien Plätzen erreichen,
// ordnen wir die "weiche" Schließung an. Der aktuelle Spieler bekommt seinen Platz!
if (freeSlots <= 100) {
console.warn(`[PlanetService] Sektor-Limit erreicht (${freeSlots} Plätze übrig). Trigger Sektor-Schließung!`);
triggerSectorClose = true;
}
// LÜCKEN FÜLLEN: Wir suchen das allererste System (von 1:1 an aufwärts),
// in dem noch nicht alle Start-Slots belegt sind.
const firstFreeSystemQuery = await client.query(`
SELECT galaxy, system
FROM (
SELECT g.galaxy, s.system
FROM generate_series(1, $1) g(galaxy)
CROSS JOIN generate_series(1, $2) s(system)
) all_systems
LEFT JOIN (
SELECT galaxy, system, COUNT(*) as occupied_slots
FROM planets
WHERE position = ANY($3)
GROUP BY galaxy, system
) occupied ON all_systems.galaxy = occupied.galaxy AND all_systems.system = occupied.system
WHERE COALESCE(occupied.occupied_slots, 0) < $4
ORDER BY all_systems.galaxy ASC, all_systems.system ASC
LIMIT 1
`, [
universeConfig.max_galaxies,
universeConfig.max_systems_per_galaxy,
startPositions,
startPositions.length
]);
const fallbackGalaxy = firstFreeSystemQuery.rows[0].galaxy;
const fallbackSystem = firstFreeSystemQuery.rows[0].system;
// Im gefundenen Fallback-System prüfen wir, welche der Positionen (z.B. 6 oder 10) konkret frei ist
const checkFallbackPos = await client.query(`
SELECT position
FROM planets
WHERE galaxy = $1 AND system = $2 AND position = ANY($3)
`, [fallbackGalaxy, fallbackSystem, startPositions]);
const fbOccupied = checkFallbackPos.rows.map(row => row.position);
const fbFreePos = startPositions.find(pos => !fbOccupied.includes(pos));
foundCoordinates = {
galaxy: fallbackGalaxy,
system: fallbackSystem,
position: fbFreePos,
triggerSectorClose: triggerSectorClose // Gibt die Warnung an den Controller weiter
};
}
return foundCoordinates;
} catch (error) {
console.error('[PlanetService] Fehler bei der Koordinaten-Ermittlung:', error);
throw error;
} finally {
client.release();
}
};
/**
* Berechnet die exakte Anzahl der noch freien Startplätze im gesamten Sektor.
* Wird von der Lobby für den nächtlichen Cronjob zur Sektor-Öffnung abgerufen.
*
* @returns {Promise<number>} Anzahl der freien Plätze
*/
export const getFreeStarterSlotsCount = async () => {
const settings = getSectorSettings();
const starterConfig = settings.starter_zone;
const universeConfig = settings.universe_dimensions;
const startPositions = starterConfig.fixed_start_positions;
const client = await pool.connect();
try {
const occupiedCountQuery = await client.query(`
SELECT COUNT(*) as count
FROM planets
WHERE position = ANY($1)
`, [startPositions]);
const occupiedCount = parseInt(occupiedCountQuery.rows[0].count, 10);
const totalStarterSlots = universeConfig.max_galaxies * universeConfig.max_systems_per_galaxy * startPositions.length;
return totalStarterSlots - occupiedCount;
} catch (error) {
console.error('[PlanetService] Fehler bei der Berechnung der freien Startplätze:', error);
throw error;
} finally {
client.release();
}
};
@@ -0,0 +1,54 @@
import { getSectorSettings } from '../services/config.service.js';
/**
* Hilfsfunktion: Generiert eine zufällige Ganzzahl zwischen min und max (inklusive).
* Wird benötigt, um die planetaren Schwankungen organisch zu berechnen.
*
* @param {number} min - Der kleinstmögliche Wert
* @param {number} max - Der größtmögliche Wert
*
* @returns {number} Eine zufällige Ganzzahl
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Berechnet die physischen Eigenschaften und Startressourcen für einen neuen Heimatplaneten.
* Greift synchron auf die im RAM liegenden Sektor-Einstellungen zu.
*
* @param {number} position - Die exakte Position des Planeten im Sonnensystem.
*
* @returns {Object} Ein Objekt mit allen berechneten Startwerten für die Datenbank.
*/
export const generateStarterPlanetStats = (position) => {
const settings = getSectorSettings();
const planetConfig = settings.planet_generation;
const diameter = planetConfig.home_planet.fixed_diameter;
const rawFields = Math.floor(diameter * planetConfig.dimensions.fields_per_km_diameter);
const maxFields = rawFields + planetConfig.bonus_fields;
const zoneString = String(position);
const tempZone = planetConfig.temperature_zones[zoneString] || planetConfig.temperature_zones["8"];
const tempMidpoint = Math.floor((tempZone.temp_min + tempZone.temp_max) / 2);
const planetTempMin = getRandomInt(tempZone.temp_min, tempMidpoint);
let planetTempMax = getRandomInt(tempMidpoint, tempZone.temp_max);
if (planetTempMax - planetTempMin < 20) {
planetTempMax = planetTempMin + 20;
planetTempMax = Math.min(planetTempMax, tempZone.temp_max);
}
const popIdle = planetConfig.home_planet.pop_idle;
const popInjured = planetConfig.home_planet.pop_injured;
const resFoodBase = planetConfig.home_planet.res_food_base;
return {
diameter: diameter,
fields_max: maxFields,
temp_min: planetTempMin,
temp_max: planetTempMax,
pop_idle: popIdle,
pop_injured: popInjured,
res_food_base: resFoodBase
};
};