Planetengenerierung

This commit is contained in:
2026-06-15 21:35:25 +02:00
parent 2623da3d39
commit 7a39007712
27 changed files with 2465 additions and 141 deletions
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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,9 +9,10 @@
},
"dependencies": {
"express": "^4.19.2",
"node-cron": "^4.2.1",
"pg": "^8.11.5"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
}
@@ -0,0 +1,8 @@
{
"ACHIEV_INTRO_SKIPPED": {
"id": 1001,
"reward_title_id": 1,
"reward_avatar_id": null,
"hidden": true
}
}
@@ -0,0 +1,62 @@
{
"universe_dimensions": {
"max_galaxies": 5,
"max_systems_per_galaxy": 500,
"max_positions_per_system": 15
},
"starter_zone": {
"min_position": 6,
"max_position": 10,
"fixed_start_positions": [6, 10],
"max_players_per_system": 2,
"leap_min_systems": 3,
"leap_max_systems": 7
},
"game_speeds": {
"economy_speed": 1,
"research_speed": 1,
"fleet_speed_hostile": 1,
"fleet_speed_peaceful": 1,
"fleet_speed_friendly": 1,
"fuel_consumption_factor": 1.0
},
"debris_fields": {
"fleet_to_debris_percentage": 30.0,
"defense_to_debris_percentage": 0.0,
"fuel_included_in_debris": false
},
"account_registration_bonus": {
"initial_res_premium": 800
},
"planet_generation": {
"bonus_fields": 0,
"dimensions": {
"min_diameter": 10000,
"max_diameter": 20000,
"fields_per_km_diameter": 0.015
},
"home_planet": {
"fixed_diameter": 13800,
"pop_idle": 500.0000,
"pop_injured": 500.0000,
"res_food_base": 1000.0000
},
"temperature_zones": {
"1": { "temp_min": 150, "temp_max": 250 },
"2": { "temp_min": 120, "temp_max": 200 },
"3": { "temp_min": 90, "temp_max": 160 },
"4": { "temp_min": 60, "temp_max": 110 },
"5": { "temp_min": 30, "temp_max": 70 },
"6": { "temp_min": 15, "temp_max": 45 },
"7": { "temp_min": 10, "temp_max": 35 },
"8": { "temp_min": 0, "temp_max": 25 },
"9": { "temp_min": -15, "temp_max": 15 },
"10": { "temp_min": -30, "temp_max": 5 },
"11": { "temp_min": -55, "temp_max": -15 },
"12": { "temp_min": -80, "temp_max": -40 },
"13": { "temp_min": -110, "temp_max": -70 },
"14": { "temp_min": -150, "temp_max": -100 },
"15": { "temp_min": -200, "temp_max": -140 }
}
}
}
@@ -0,0 +1,17 @@
{
"1": {
"system_key": "INTRO_SKIPPER",
"languages": {
"de": {
"male": "Der Ungeduldige",
"female": "Die Ungeduldige",
"description": "Hat das Intro übersprungen."
},
"en": {
"male": "The Impatient One",
"female": "The Impatient One",
"description": "Skipped the intro."
}
}
}
}
@@ -0,0 +1,52 @@
import pool from '../db.js';
import { getSectorSettings } from '../services/config.service.js';
/**
* Gibt den Status, die Live-Statistiken und die Konfiguration des Sektor-Backends zurück.
* Wird alle 30 Sekunden vom Heartbeat-Service der Lobby aufgerufen.
*
* @param {import('express').Request} req - Das Express Request-Objekt
* @param {import('express').Response} res - Das Express Response-Objekt
*/
export const getHealthStatus = async (req, res) => {
try {
const settings = getSectorSettings();
const onlineResult = await pool.query(`
SELECT COUNT(*) FROM local_accounts
WHERE updated_at > NOW() - INTERVAL '60 minutes'
`);
const totalPlayersResult = await pool.query('SELECT COUNT(*) FROM local_accounts');
const planetsResult = await pool.query('SELECT COUNT(*) FROM planets');
return res.status(200).json({
status: 'ok',
timestamp: new Date().toISOString(),
stats: {
online_players: parseInt(onlineResult.rows[0].count, 10),
total_players: parseInt(totalPlayersResult.rows[0].count, 10),
planets_count: parseInt(planetsResult.rows[0].count, 10)
},
config: {
max_galaxies: settings.universe_dimensions.max_galaxies,
speed_economy: settings.game_speeds.economy_speed,
research_speed: settings.game_speeds.research_speed,
fleet_speed_hostile: settings.game_speeds.fleet_speed_hostile,
fleet_speed_peaceful: settings.game_speeds.fleet_speed_peaceful,
fleet_speed_friendly: settings.game_speeds.fleet_speed_friendly,
fuel_consumption_factor: settings.game_speeds.fuel_consumption_factor,
fleet_to_debris_percentage: settings.debris_fields.fleet_to_debris_percentage,
defense_to_debris_percentage: settings.debris_fields.defense_to_debris_percentage,
fuel_included_in_debris: settings.debris_fields.fuel_included_in_debris,
initial_res_premium: settings.account_registration_bonus.initial_res_premium,
bonus_fields: settings.planet_generation.bonus_fields
}
});
} catch (error) {
console.error('❌ Fehler im Health-Controller:', error);
return res.status(500).json({ status: 'error', message: 'Internal Server Error' });
}
};
@@ -0,0 +1,59 @@
import { createLocalAccount } from '../services/account.service.js';
import { getFreeStarterSlotsCount } from '../services/planet.service.js';
export const registerInternalAccount = async (req, res) => {
const { lobby_uuid, username, gender } = req.body;
if (!lobby_uuid || !username || !gender) {
return res.status(400).json({
error: 'ERR_MISSING_REQUIRED_FIELDS'
});
}
try {
const { local_uuid, triggerSectorClose } = await createLocalAccount(lobby_uuid, username, gender);
return res.status(201).json({
message: 'MSG_ACCOUNT_CREATED_SUCCESSFULLY',
account: { local_uuid },
triggerSectorClose: triggerSectorClose
});
} catch (error) {
console.error('❌ Fehler bei interner Account-Registrierung:', error);
if (error.code === 'ERR_SECTOR_FULL') {
return res.status(503).json({
error: 'ERR_SECTOR_FULL'
});
}
if (error.code === '23505') {
return res.status(409).json({
error: 'ERR_ACCOUNT_ALREADY_EXISTS'
});
}
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER_ERROR'
});
}
};
/**
* Liefert die Anzahl der freien Startplätze für die Lobby zurück.
*/
export const getSectorSlotStatus = async (req, res) => {
try {
const freeSlots = await getFreeStarterSlotsCount();
return res.status(200).json({
freeSlots
});
} catch (error) {
console.error('❌ Fehler beim Abrufen der Sektor-Slots:', error);
return res.status(500).json({
error: 'ERR_INTERNAL_SERVER_ERROR'
});
}
};
+28
View File
@@ -0,0 +1,28 @@
// ==============================================================================
// Void-Genesis - Datenbank-Konfiguration
// ==============================================================================
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
// Wir testen beim Start einmalig, ob die Verbindung klappt.
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
pool.connect((err, client, release) => {
if (err) {
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
} else {
console.log(`✅ Erfolgreich mit der PostgreSQL-Datenbank (${process.env.DB_NAME}) verbunden!`);
release();
}
});
export default pool;
@@ -0,0 +1,8 @@
import { Router } from 'express';
import { getHealthStatus } from '../controllers/health.controller.js';
const router = Router();
router.get('/health', getHealthStatus);
export default router;
@@ -0,0 +1,14 @@
/**
* Definition der internen Schnittstellen-Routen (Server-zu-Server).
* Diese Routen sind für die Kommunikation mit dem Lobby-Backend gedacht.
*/
import { Router } from 'express';
import { registerInternalAccount, getSectorSlotStatus } from '../controllers/internal.controller.js';
const router = Router();
router.post('/accounts', registerInternalAccount);
router.get('/slots', getSectorSlotStatus);
export default router;
@@ -0,0 +1,142 @@
import pool from '../db.js';
// ==============================================================================
// Void-Genesis - Datenbank Initialisierungs-Skript
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
// ==============================================================================
const createTablesQuery = `
-- ------------------------------------------------------------------------------
-- DROP TABLES
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
-- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden.
-- ------------------------------------------------------------------------------
DROP TABLE IF EXISTS planets;
DROP TABLE IF EXISTS local_accounts;
-- ------------------------------------------------------------------------------
-- TABLE: lobby_accounts
-- Basis-Nutzerdaten für das gesamte Void-Genesis Universum.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS local_accounts (
local_uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lobby_uuid UUID NOT NULL UNIQUE,
username VARCHAR(50) NOT NULL UNIQUE,
gender VARCHAR(10) NOT NULL,
vacation BOOLEAN NOT NULL DEFAULT FALSE,
blocked BOOLEAN NOT NULL DEFAULT FALSE,
res_premium INT NOT NULL DEFAULT 0,
avatar_id INT DEFAULT NULL,
title_id INT DEFAULT NULL,
-- Zeitstempel für Logs und Analysen
last_login TIMESTAMP WITH TIME ZONE DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: planets
-- Verwaltet alle Planeten des Universums, deren Ressourcen, Bevölkerung,
-- Geografie und die Zuordnung zum jeweiligen Commander (local_uuid).
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS planets (
planet_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_planet_id UUID REFERENCES planets(planet_id) ON DELETE CASCADE DEFAULT NULL,
local_uuid UUID REFERENCES local_accounts(local_uuid) ON DELETE SET NULL,
planet_name VARCHAR(100) NOT NULL,
galaxy INT NOT NULL,
system INT NOT NULL,
position INT NOT NULL,
temp_min INT NOT NULL,
temp_max INT NOT NULL,
diameter INT NOT NULL,
fields_built INT NOT NULL,
fields_explored INT NOT NULL,
fields_max INT NOT NULL,
res_wood NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_stone NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_food_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_food_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_idle NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_working NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
pop_injured NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- ------------------------------------------------------------------------------
-- TABLE: debris_fields
-- Speichert Trümmerfelder (aus Flottenkämpfen), wie in sectorSettings.json definiert.
-- Da ein Trümmerfeld keinem Account gehört, ist es eine separate Entität auf einer Koordinate.
-- ------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS debris_fields (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
galaxy INT NOT NULL,
system INT NOT NULL,
position INT NOT NULL,
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
UNIQUE(galaxy, system, position)
);
-- ------------------------------------------------------------------------------
-- TRIGGER: Auto-update 'updated_at' timestamp
-- Diese Funktion setzt das 'updated_at' Feld bei jeder Änderung auf die aktuelle Zeit.
-- ------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Wir wenden den Trigger nur an, wenn er noch nicht existiert.
DO $$
BEGIN
-- Trigger für local_accounts
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_local_accounts_modtime') THEN
CREATE TRIGGER update_local_accounts_modtime
BEFORE UPDATE ON local_accounts
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_debris_fields_modtime') THEN
CREATE TRIGGER update_debris_fields_modtime
BEFORE UPDATE ON debris_fields
FOR EACH ROW
EXECUTE FUNCTION update_modified_column();
END IF;
END $$;
`;
// ==============================================================================
// Ausführungs-Logik
// ==============================================================================
async function initDatabase() {
console.log('⏳ Starte Sektor-Datenbank-Initialisierung...');
const client = await pool.connect();
try {
await client.query(createTablesQuery);
console.log('✅ Sektor-Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
} catch (error) {
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
} finally {
client.release();
process.exit();
}
}
initDatabase();
+21 -39
View File
@@ -1,48 +1,30 @@
// Externe Abhängigkeiten importieren
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, () => {
console.log(`🚀 Sektor-Backend gestartet auf Port ${port}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});
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
};
};