Planetengenerierung
This commit is contained in:
+10
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+1126
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user