Planetengenerierung
This commit is contained in:
+10
@@ -12,6 +12,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -940,6 +941,15 @@
|
||||
"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": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -26,44 +26,137 @@ export const getSectorsList = async (req, res) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Controller-Methode für den Sektor-Beitritt (Charaktererstellung).
|
||||
* Nimmt die Request an, validiert grob und leitet an den Service weiter.
|
||||
* Verarbeitet den Beitritt eines Spielers zu einem spezifischen Sektor.
|
||||
* 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) => {
|
||||
const accountId = req.user.accountId;
|
||||
const { sectorId, localUsername, gender } = req.body;
|
||||
|
||||
if (!sectorId || !localUsername || !gender) {
|
||||
const lobby_uuid = req.user.lobby_uuid;
|
||||
const { sector_id } = req.body;
|
||||
|
||||
if (!sector_id) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_MISSING_FIELDS'
|
||||
error: 'ERR_MISSING_SECTOR_ID'
|
||||
});
|
||||
}
|
||||
|
||||
if (localUsername.length < 3) {
|
||||
return res.status(400).json({
|
||||
error: 'ERR_USERNAME_TOO_SHORT'
|
||||
});
|
||||
}
|
||||
const client = await pool.connect();
|
||||
|
||||
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({
|
||||
success: true,
|
||||
message: 'MSG_SECTOR_JOINED_SUCCESS'
|
||||
if (userQuery.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'ERR_USER_NOT_FOUND'
|
||||
});
|
||||
}
|
||||
const { username, gender } = userQuery.rows[0];
|
||||
|
||||
// Sektor-Daten laden, um die korrekte interne URL zu erfahren
|
||||
const sectorQuery = await client.query(`
|
||||
SELECT internal_url, registration_open
|
||||
FROM sectors
|
||||
WHERE id = $1
|
||||
`, [sector_id]);
|
||||
|
||||
if (sectorQuery.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'ERR_SECTOR_NOT_FOUND'
|
||||
});
|
||||
}
|
||||
|
||||
const sector = sectorQuery.rows[0];
|
||||
|
||||
// Sicherheits-Check: Ist die Registrierung für diesen Sektor noch offen?
|
||||
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) {
|
||||
if (error.code === 'ERR_ALREADY_IN_SECTOR' || error.code === 'ERR_LOCAL_USERNAME_TAKEN') {
|
||||
return res.status(409).json({
|
||||
error: error.code
|
||||
});
|
||||
}
|
||||
|
||||
// Generischer Fallback für echte Systemabstürze
|
||||
console.error('[SectorController] Fehler beim Sektor-Beitritt:', error);
|
||||
return res.status(500).json({
|
||||
error: 'ERR_INTERNAL_SERVER'
|
||||
});
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ const createTablesQuery = `
|
||||
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
|
||||
-- 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 auth_otps;
|
||||
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()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 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
|
||||
-- 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,
|
||||
status VARCHAR(20) DEFAULT 'offline',
|
||||
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,
|
||||
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(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -144,13 +170,49 @@ async function initDatabase() {
|
||||
const client = await pool.connect();
|
||||
|
||||
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);
|
||||
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) {
|
||||
console.log('🌱 Füge 24 Test-Sektoren ein...');
|
||||
console.log('🌱 Füge Test-Sektoren sequenziell ein...');
|
||||
|
||||
const greekAlphabet = [
|
||||
'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'
|
||||
];
|
||||
|
||||
// Wir generieren für jeden Namen ein dynamisches INSERT
|
||||
const insertPromises = greekAlphabet.map((name, index) => {
|
||||
// Wir variieren die Geschwindigkeiten leicht für optische Vielfalt
|
||||
for (let index = 0; index < greekAlphabet.length; index++) {
|
||||
const name = greekAlphabet[index];
|
||||
const speed = (index % 4) + 1;
|
||||
|
||||
const apiUrl = name === 'Development'
|
||||
? 'http://sector-backend:3000'
|
||||
: `http://sector-api-${name.toLowerCase()}:3000`;
|
||||
|
||||
return client.query(`
|
||||
INSERT INTO sectors (name, status, api_url, speed_fleet, speed_economy)
|
||||
VALUES ($1, 'offline', $2, $3, $3)
|
||||
`, [name, apiUrl, speed]);
|
||||
});
|
||||
|
||||
await Promise.all(insertPromises);
|
||||
console.log('✅ 25 Test-Sektoren erfolgreich eingefügt.');
|
||||
await client.query(`
|
||||
INSERT INTO sectors (name, status, api_url)
|
||||
VALUES ($1, 'offline', $2)
|
||||
`, [name, apiUrl]);
|
||||
}
|
||||
console.log('✅ 25 Sektoren ohne Parallelitäts-Konflikte eingefügt.');
|
||||
}
|
||||
|
||||
console.log('🎉 Lobby-Datenbank-Initialisierung vollständig und sauber abgeschlossen!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
|
||||
console.error('❌ Fehler beim Erstellen oder Befüllen der Lobby-Tabellen:', error);
|
||||
} finally {
|
||||
// Ganz wichtig: Verbindung wieder freigeben
|
||||
// Verbindung freigeben und Prozess sauber beenden
|
||||
client.release();
|
||||
// Den Node-Prozess beenden, damit das Skript nicht in der Konsole hängen bleibt
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import pool from './db.js';
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
import sectorRoutes from './routes/sector.routes.js';
|
||||
import { pingSectors } from './services/heartbeat.service.js';
|
||||
import { initLobbyCronJobs } from './services/cron.service.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -49,11 +50,14 @@ app.get('/api/db-test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Server starten
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Void-Genesis Lobby Backend gestartet auf Port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
initLobbyCronJobs();
|
||||
console.log('💓 Heartbeat-System initialisiert (Intervall: 30s)');
|
||||
pingSectors();
|
||||
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';
|
||||
|
||||
// ==============================================================================
|
||||
@@ -9,49 +7,77 @@ import pool from '../db.js';
|
||||
|
||||
export const pingSectors = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const result = await client.query('SELECT id, name, api_url FROM sectors WHERE api_url IS NOT NULL');
|
||||
const sectors = result.rows;
|
||||
|
||||
for (const sector of sectors) {
|
||||
try {
|
||||
// Wir nutzen einen AbortController, um blockierende Anfragen
|
||||
// nach 3 Sekunden hart abzubrechen (Timeout).
|
||||
const controller = new AbortController();
|
||||
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', {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
// Sektor antwortet korrekt -> Status auf online setzen
|
||||
await client.query(
|
||||
"UPDATE sectors SET status = 'online' WHERE id = $1",
|
||||
[sector.id]
|
||||
`UPDATE sectors SET
|
||||
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
|
||||
]
|
||||
);
|
||||
|
||||
// Log-Ausgabe nur für unseren aktiven Entwicklungs-Sektor,
|
||||
// um die Konsole sauber zu halten.
|
||||
} else {
|
||||
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 {
|
||||
// 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);
|
||||
}
|
||||
} catch (error) {
|
||||
// Sektor gar nicht erreichbar (Container down, Netzwerkfehler, Timeout)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,18 @@ import pool from '../db.js';
|
||||
*/
|
||||
export const getSectorsList = async (accountId) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
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(`
|
||||
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.created_at as joined_at,
|
||||
las.updated_at as last_played
|
||||
@@ -27,31 +34,35 @@ export const getSectorsList = async (accountId) => {
|
||||
|
||||
const newSectorsResult = await client.query(`
|
||||
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
|
||||
WHERE id NOT IN (
|
||||
SELECT sector_id
|
||||
FROM lobby_accounts_sectors
|
||||
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
|
||||
`, [accountId]);
|
||||
|
||||
const mapSectorData = (sector) => ({
|
||||
...sector,
|
||||
online_players: Math.floor(Math.random() * 150) + 10,
|
||||
planets_count: (Math.floor(Math.random() * 150) + 10) * 12
|
||||
});
|
||||
|
||||
// ALTE LOGIK ENTFERNT: Wir brauchen kein mapSectorData mit Math.random() mehr!
|
||||
// Wir geben die echten Datenbank-Ergebnisse direkt ans Frontend weiter.
|
||||
return {
|
||||
mySectors: mySectorsResult.rows.map(mapSectorData),
|
||||
newSectors: newSectorsResult.rows.map(mapSectorData)
|
||||
mySectors: mySectorsResult.rows,
|
||||
newSectors: newSectorsResult.rows
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SectorService] Fehler beim Auslesen der Sektordaten:', error);
|
||||
throw error;
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
@@ -59,8 +70,8 @@ export const getSectorsList = async (accountId) => {
|
||||
|
||||
/**
|
||||
* Trägt einen Spieler in einen neuen Sektor ein.
|
||||
* Führt die eigentliche Datenbankoperation durch und wirft spezifische
|
||||
* Fehler-Objekte, falls Constraints (UNIQUE-Regeln) verletzt werden.
|
||||
* Komuniziert zuest mit dem Ziel-Sektor und speichert den Psielstand in der lobby erst,
|
||||
* wenn des Sektor-Backend den Account erfolgreich akzeptiert hat.
|
||||
*
|
||||
* @param {string} accountId - Die UUID des Nutzers
|
||||
* @param {string} sectorId - Die UUID des Sektors
|
||||
@@ -73,6 +84,61 @@ export const joinSector = async (accountId, sectorId, localUsername, gender) =>
|
||||
const client = await pool.connect();
|
||||
|
||||
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.
|
||||
await client.query(`
|
||||
INSERT INTO lobby_accounts_sectors (lobby_account_id, sector_id, local_username, gender)
|
||||
|
||||
@@ -42,6 +42,9 @@ export const translations = {
|
||||
// --- Sektor-Onboarding ---
|
||||
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_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!",
|
||||
|
||||
// --- Footer ---
|
||||
@@ -83,7 +86,7 @@ export const translations = {
|
||||
btn_reset_password: "CHANGE PASSWORD",
|
||||
link_back: "« back",
|
||||
btn_male: "Male",
|
||||
btn_female: "Femail",
|
||||
btn_female: "Female",
|
||||
btn_cancel: "Cancel",
|
||||
btn_join: "Join Sector",
|
||||
|
||||
@@ -95,7 +98,8 @@ export const translations = {
|
||||
ph_otp: "6-digit code",
|
||||
|
||||
// --- 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 ---
|
||||
txt_otp_instruction: "Please enter the 6-digit code from your e-mail.",
|
||||
@@ -108,6 +112,9 @@ export const translations = {
|
||||
// --- Sektor-Onboarding ---
|
||||
ERR_ALREADY_IN_SECTOR: "You have already joined 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!",
|
||||
|
||||
// --- 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';
|
||||
|
||||
/**
|
||||
* Hilfsfunktion, um die Sprache zu wechseln.
|
||||
*/
|
||||
export function setLanguage(lang) {
|
||||
if (translations[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) {
|
||||
return translations[currentLang][key] || key;
|
||||
}
|
||||
@@ -521,9 +521,52 @@ function renderSectorList(sectors, type) {
|
||||
// HTML-Karte im DOM blitzschnell zu finden, ohne das Grid neu aufzubauen.
|
||||
cardLink.setAttribute('data-sector-id', sector.id);
|
||||
|
||||
// --- Temporäre Platzhalter für das neue UI (ersetzen wir später durch echte DB-Werte) ---
|
||||
const ageDays = sector.age_days || Math.floor(Math.random() * 30) + 1;
|
||||
const totalPlayers = sector.total_players || Math.floor(Math.random() * 500) + 150;
|
||||
// --- ECHTE DATEN STATT PLATZHALTER ---
|
||||
const createdDate = new Date(sector.created_at);
|
||||
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 = '';
|
||||
if (type === 'my' && sector.local_username) {
|
||||
@@ -532,7 +575,7 @@ function renderSectorList(sectors, type) {
|
||||
|
||||
cardLink.innerHTML = `
|
||||
<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}">
|
||||
${sector.name}
|
||||
</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;">
|
||||
${sector.status === 'online' ? 'online' : 'offline'}
|
||||
</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>
|
||||
${bottomRowHtml}
|
||||
</div>
|
||||
@@ -755,12 +799,11 @@ function updateLiveStatus(sectors, type) {
|
||||
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');
|
||||
if (midRowEl) {
|
||||
// Wir behalten deine Logik bei, falls die Gesamtzahl temporär berechnet werden muss
|
||||
const totalPlayers = sector.total_players || Math.floor(Math.random() * 500) + 150;
|
||||
midRowEl.textContent = `Online: ${sector.online_players}/${totalPlayers}`;
|
||||
// Das DOM übernimmt die echten Live-Werte für Online und Gesamt
|
||||
midRowEl.textContent = `Online: ${sector.online_players}/${sector.current_players}`;
|
||||
}
|
||||
|
||||
// 3. HTML-Planetenanzahl über die neue, eindeutige Klasse aktualisieren
|
||||
|
||||
+1126
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "void-genesis-sector-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Void-Genesis Game Sector Backend",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
@@ -1,48 +1,30 @@
|
||||
// Externe Abhängigkeiten importieren
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg'); // Das 'pg'-Modul ermöglicht die Kommunikation mit PostgreSQL
|
||||
import express from 'express';
|
||||
import pool from './db.js';
|
||||
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();
|
||||
// Der interne Port im Container bleibt 3000 (wird via Docker Compose auf 3002 gemappt)
|
||||
const port = 3000;
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
res.send('Void-Genesis Sektor-Backend läuft im Dev-Modus und ist mit der DB verbunden!');
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
app.use('/api', healthRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
app.listen(port, () => {
|
||||
async function startServer() {
|
||||
await initGameConfig();
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user