Planetengenerierung
This commit is contained in:
+1126
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "void-genesis-sector-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Void-Genesis Game Sector Backend",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
@@ -8,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'
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank-Konfiguration
|
||||
// ==============================================================================
|
||||
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
// Wir testen beim Start einmalig, ob die Verbindung klappt.
|
||||
// So sehen wir im Docker-Log sofort, falls z.B. das Passwort falsch ist.
|
||||
pool.connect((err, client, release) => {
|
||||
if (err) {
|
||||
console.error('❌ Fehler beim Verbinden mit der PostgreSQL-Datenbank:', err.stack);
|
||||
} else {
|
||||
console.log(`✅ Erfolgreich mit der PostgreSQL-Datenbank (${process.env.DB_NAME}) verbunden!`);
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { getHealthStatus } from '../controllers/health.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/health', getHealthStatus);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Definition der internen Schnittstellen-Routen (Server-zu-Server).
|
||||
* Diese Routen sind für die Kommunikation mit dem Lobby-Backend gedacht.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { registerInternalAccount, getSectorSlotStatus } from '../controllers/internal.controller.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/accounts', registerInternalAccount);
|
||||
router.get('/slots', getSectorSlotStatus);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,142 @@
|
||||
import pool from '../db.js';
|
||||
|
||||
// ==============================================================================
|
||||
// Void-Genesis - Datenbank Initialisierungs-Skript
|
||||
// Führt alle nötigen CREATE TABLE Befehle aus, falls die Tabellen nicht existieren.
|
||||
// ==============================================================================
|
||||
|
||||
const createTablesQuery = `
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- DROP TABLES
|
||||
-- WICHTIG: Die Reihenfolge beim Löschen ist entscheidend!
|
||||
-- Tabellen mit Fremdschlüsseln (Foreign Keys) müssen zuerst gelöscht werden.
|
||||
-- ------------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS planets;
|
||||
DROP TABLE IF EXISTS local_accounts;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: lobby_accounts
|
||||
-- Basis-Nutzerdaten für das gesamte Void-Genesis Universum.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS local_accounts (
|
||||
local_uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lobby_uuid UUID NOT NULL UNIQUE,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
gender VARCHAR(10) NOT NULL,
|
||||
vacation BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
blocked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
res_premium INT NOT NULL DEFAULT 0,
|
||||
avatar_id INT DEFAULT NULL,
|
||||
title_id INT DEFAULT NULL,
|
||||
|
||||
-- Zeitstempel für Logs und Analysen
|
||||
last_login TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: planets
|
||||
-- Verwaltet alle Planeten des Universums, deren Ressourcen, Bevölkerung,
|
||||
-- Geografie und die Zuordnung zum jeweiligen Commander (local_uuid).
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS planets (
|
||||
planet_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_planet_id UUID REFERENCES planets(planet_id) ON DELETE CASCADE DEFAULT NULL,
|
||||
local_uuid UUID REFERENCES local_accounts(local_uuid) ON DELETE SET NULL,
|
||||
planet_name VARCHAR(100) NOT NULL,
|
||||
galaxy INT NOT NULL,
|
||||
system INT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
temp_min INT NOT NULL,
|
||||
temp_max INT NOT NULL,
|
||||
diameter INT NOT NULL,
|
||||
fields_built INT NOT NULL,
|
||||
fields_explored INT NOT NULL,
|
||||
fields_max INT NOT NULL,
|
||||
res_wood NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_stone NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_food_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_food_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
pop_idle NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
pop_working NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
pop_injured NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TABLE: debris_fields
|
||||
-- Speichert Trümmerfelder (aus Flottenkämpfen), wie in sectorSettings.json definiert.
|
||||
-- Da ein Trümmerfeld keinem Account gehört, ist es eine separate Entität auf einer Koordinate.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS debris_fields (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
galaxy INT NOT NULL,
|
||||
system INT NOT NULL,
|
||||
position INT NOT NULL,
|
||||
res_primary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_secondary NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_fuel_base NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
res_fuel_adv NUMERIC(20,4) NOT NULL DEFAULT 0.0000,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(galaxy, system, position)
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- TRIGGER: Auto-update 'updated_at' timestamp
|
||||
-- Diese Funktion setzt das 'updated_at' Feld bei jeder Änderung auf die aktuelle Zeit.
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION update_modified_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Wir wenden den Trigger nur an, wenn er noch nicht existiert.
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Trigger für local_accounts
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_local_accounts_modtime') THEN
|
||||
CREATE TRIGGER update_local_accounts_modtime
|
||||
BEFORE UPDATE ON local_accounts
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'update_debris_fields_modtime') THEN
|
||||
CREATE TRIGGER update_debris_fields_modtime
|
||||
BEFORE UPDATE ON debris_fields
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_modified_column();
|
||||
END IF;
|
||||
END $$;
|
||||
`;
|
||||
|
||||
// ==============================================================================
|
||||
// Ausführungs-Logik
|
||||
// ==============================================================================
|
||||
async function initDatabase() {
|
||||
console.log('⏳ Starte Sektor-Datenbank-Initialisierung...');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query(createTablesQuery);
|
||||
console.log('✅ Sektor-Tabellen wurden erfolgreich erstellt (oder existierten bereits).');
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Erstellen der Tabellen:', error);
|
||||
} finally {
|
||||
client.release();
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
initDatabase();
|
||||
@@ -1,48 +1,30 @@
|
||||
// Externe Abhängigkeiten importieren
|
||||
const express = require('express');
|
||||
const { Pool } = require('pg'); // Das 'pg'-Modul ermöglicht die Kommunikation mit PostgreSQL
|
||||
import express from 'express';
|
||||
import pool from './db.js';
|
||||
import healthRoutes from './routes/health.routes.js';
|
||||
import internalRoutes from './routes/internal.routes.js';
|
||||
import { initGameConfig } from './services/config.service.js';
|
||||
import { initCronJobs } from './services/cron.service.js';
|
||||
|
||||
const app = express();
|
||||
// Der interne Port im Container bleibt 3000 (wird via Docker Compose auf 3002 gemappt)
|
||||
const port = 3000;
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// PostgreSQL Verbindungs-Pool konfigurieren
|
||||
// ----------------------------------------------------------------------------
|
||||
// Wir ziehen uns die Zugangsdaten direkt aus den Umgebungsvariablen (process.env),
|
||||
// die Docker Compose aus der .env-Datei in den Container injiziert hat.
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST, // Entspricht im Docker-Netzwerk dem Service 'sector-db'
|
||||
port: process.env.DB_PORT, // Interner Port 5432
|
||||
user: process.env.DB_USER, // sector_admin
|
||||
password: process.env.DB_PASS, // Dein neues Sektor-Passwort
|
||||
database: process.env.DB_NAME, // void_sector
|
||||
});
|
||||
app.use(express.json());
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Datenbank-Verbindungstest beim Serverstart
|
||||
// ----------------------------------------------------------------------------
|
||||
// Wir setzen eine Test-Query ab (SELECT NOW()), um zu prüfen, ob die DB bereit ist.
|
||||
pool.query('SELECT NOW()', (err, res) => {
|
||||
if (err) {
|
||||
console.error('❌ Fehler bei der Verbindung zur Sektor-Datenbank:', err.stack);
|
||||
} else {
|
||||
console.log(`✅ Erfolgreich mit der Sektor-PostgreSQL-Datenbank (${process.env.DB_NAME}) verbunden!`);
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Express Routen & Server-Start
|
||||
// ----------------------------------------------------------------------------
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Void-Genesis Sektor-Backend läuft im Dev-Modus und ist mit der DB verbunden!');
|
||||
});
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
app.use('/api', healthRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
app.listen(port, () => {
|
||||
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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user