Compare commits

..

9 Commits

Author SHA1 Message Date
Aaron Kimbrell
9e006a0e35 Merge remote-tracking branch 'origin/main' into web-dashboard-simplier
# Conflicts:
#	dMasterServer/MasterServer.cpp
2026-06-28 01:57:53 -05:00
David Markowitz
35337291fa feat: debugger additions (#2013)
* feat: debugger additions

Add type field for links in flash
Add warning level for dangerous buttons
fix uninitialzied memory with jetpack variable
remove a bunch of duplicated position push code

tested that the ui is still functional and components with multiple physics components have all their details visible.
tested that jetpack is initialized now

* remove amf3 header

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fixes

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 17:18:16 -05:00
Aaron Kimbrell
5d523a1e7b fix: handling of the same skill on multiple items (#1990)
* refactor: update behavior slot determination to use equipLocation instead of itemType

* fix: improve skill management in InventoryComponent to ensure correct client updates
2026-06-22 17:16:57 -07:00
David Markowitz
5745742c91 feat: dragon instance script (#2012)
* feat: dragon instance script

* Update FvDragonInstanceServer.h

* feat: implement ronin script

* Update CountdownDestroyAI.cpp

* default initialize

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* remove unused handlers

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* use float

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fixes

* Update ScriptComponent.h

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-22 17:16:21 -07:00
Aaron Kimbrell
62f58f5307 chore: cleanup possession handling (#1984)
* feat: enhance possession mechanics with skill set integration and improved message handling

* fix: restore SetPossessor in Mount() and scope IsRacing to vehicles

SetPossessor was missing from Mount(), breaking direct possessions via
PossessableComponent::OnUse which bypasses HandlePossession. IsRacing
now only set/cleared when the mount has HavokVehiclePhysicsComponent,
preventing non-vehicle possessions from incorrectly affecting the
distance-driven statistic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:39:51 -07:00
David Markowitz
83707e2210 feat: add the lightning orb script* (#2011)
* feat: add the lightning orb script*

Doesn't do anything because MPC don't work...

* Update LightningOrbServer.cpp
2026-06-21 00:30:44 -05:00
Aaron Kimbrell
f658da19a3 WIP 2026-03-26 09:56:29 -05:00
Aaron Kimbrell
8372202d8f WIP working state 2026-02-26 09:56:11 -06:00
Aaron Kimbrell
f1847d1f20 WIP: basic server, no features 2026-01-25 22:33:51 -06:00
127 changed files with 11155 additions and 184 deletions

View File

@@ -115,6 +115,8 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
find_package(MariaDB)
find_package(OpenSSL REQUIRED)
# Create a /resServer directory
make_directory(${CMAKE_BINARY_DIR}/resServer)
@@ -131,7 +133,7 @@ endif()
message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}")
# Copy resource files on first build
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "dashboardconfig.ini" "blocklist.dcf")
message(STATUS "Checking resource file integrity")
include(Utils)
@@ -327,6 +329,7 @@ endif()
add_subdirectory(dWorldServer)
add_subdirectory(dAuthServer)
add_subdirectory(dChatServer)
add_subdirectory(dDashboardServer)
add_subdirectory(dMasterServer) # Add MasterServer last so it can rely on the other binaries
target_precompile_headers(

View File

@@ -19,23 +19,24 @@
#include "eGameMasterLevel.h"
#include "dChatFilter.h"
#include "TeamContainer.h"
#include "HTTPContext.h"
using json = nlohmann::json;
void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
void HandleHTTPPlayersRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = Game::playerContainer;
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
}
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
void HandleHTTPTeamsRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = TeamContainer::GetTeamContainer();
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
}
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
auto data = GeneralUtils::TryParse<json>(body);
void HandleHTTPAnnounceRequest(HTTPReply& reply, const HTTPContext& context) {
auto data = GeneralUtils::TryParse<json>(context.body);
if (!data) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
@@ -96,18 +97,21 @@ namespace ChatWeb {
Game::web.RegisterHTTPRoute({
.path = v1_route + "players",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPPlayersRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "teams",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPTeamsRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "announce",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = HandleHTTPAnnounceRequest
});

View File

@@ -4,7 +4,6 @@
#include "dCommonVars.h"
#include "Logger.h"
#include "Game.h"
#include <type_traits>
#include <unordered_map>
#include <vector>
@@ -368,9 +367,37 @@ public:
}
template<typename AmfType = AMFArrayValue>
AmfType& PushDebug(const std::string_view name) {
AmfType& PushDebug(const std::string_view name, const std::string& objectType = "", const uint32_t warningLevel = 0) {
size_t i = 0;
for (; i < m_Dense.size(); i++) {
const auto& cast = dynamic_cast<AMFArrayValue*>(m_Dense[i].get());
if (!cast) continue;
const auto& nameValue = cast->Get<std::string>("name");
if (!nameValue || nameValue->GetValue() != name) continue;
if (!objectType.empty()) {
cast->Insert<std::string>("type", objectType);
}
if (warningLevel != 0) {
cast->Insert<double>("warningLevel", warningLevel);
}
// found a duplicate, return this instead
auto valueCast = dynamic_cast<AmfType*>(cast->Get("value"));
if (valueCast) return *valueCast;
}
auto* value = PushArray();
value->Insert("name", name.data());
value->Insert<std::string>("name", name.data());
if (!objectType.empty()) {
value->Insert<std::string>("type", objectType);
}
if (warningLevel != 0) {
value->Insert<double>("warningLevel", warningLevel);
}
return value->Insert<AmfType>("value", std::make_unique<AmfType>());
}

View File

@@ -27,6 +27,8 @@ namespace MessageType {
AFFIRM_TRANSFER_REQUEST,
AFFIRM_TRANSFER_RESPONSE,
NEW_SESSION_ALERT
NEW_SESSION_ALERT,
REQUEST_SERVER_LIST
};
}

View File

@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
COMMON = 0,
AUTH,
CHAT,
WORLD = 4,
DASHBOARD,
WORLD,
CLIENT,
MASTER,
UNKNOWN

View File

@@ -0,0 +1,58 @@
set(DDASHBOARDSERVER_SOURCES
"DashboardServer.cpp"
)
add_subdirectory(routes)
add_subdirectory(auth)
add_executable(DashboardServer ${DDASHBOARDSERVER_SOURCES})
target_include_directories(DashboardServer PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto DashboardRoutes DashboardAuth)
# Copy static files and templates to build directory (always copy)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Removing old static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/static
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Copying DashboardServer static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Removing old templates"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/templates
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Copying DashboardServer templates"
)

View File

@@ -0,0 +1,211 @@
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <thread>
#include <csignal>
#include <memory>
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "Database.h"
#include "dConfig.h"
#include "Logger.h"
#include "dServer.h"
#include "AssetManager.h"
#include "BinaryPathFinder.h"
#include "ServiceType.h"
#include "MessageType/Master.h"
#include "Game.h"
#include "BitStreamUtils.h"
#include "Diagnostics.h"
#include "Web.h"
#include "Server.h"
#include "ServerState.h"
#include "APIRoutes.h"
#include "StaticRoutes.h"
#include "DashboardRoutes.h"
#include "WSRoutes.h"
#include "AuthRoutes.h"
#include "AuthMiddleware.h"
#include "DashboardAuthService.h"
#include "AuthTokenHandler.h"
namespace Game {
Logger* logger = nullptr;
dServer* server = nullptr;
dConfig* config = nullptr;
Game::signal_t lastSignal = 0;
std::mt19937 randomEngine;
}
// Define global server state
namespace ServerState {
ServerStatus g_AuthStatus{};
ServerStatus g_ChatStatus{};
std::vector<WorldInstanceInfo> g_WorldInstances{};
std::mutex g_StatusMutex{};
}
namespace {
dServer* g_Server = nullptr;
bool g_RequestedServerList = false;
}
int main(int argc, char** argv) {
Diagnostics::SetProduceMemoryDump(true);
std::signal(SIGINT, Game::OnSignal);
std::signal(SIGTERM, Game::OnSignal);
uint32_t maxClients = 999;
uint32_t ourPort = 2006;
std::string ourIP = "127.0.0.1";
// Read config
Game::config = new dConfig("dashboardconfig.ini");
// Setup logger
Server::SetupLogger("DashboardServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
LOG("Starting Dashboard Server");
// Load settings
if (Game::config->GetValue("max_clients") != "")
maxClients = std::stoi(Game::config->GetValue("max_clients"));
if (Game::config->GetValue("port") != "")
ourPort = std::atoi(Game::config->GetValue("port").c_str());
if (Game::config->GetValue("listen_ip") != "")
ourIP = Game::config->GetValue("listen_ip");
// Connect to CDClient database
try {
const std::string cdclientPath = BinaryPathFinder::GetBinaryDir() / "resServer/CDServer.sqlite";
CDClientDatabase::Connect(cdclientPath);
} catch (std::exception& ex) {
LOG("Failed to connect to CDClient database: %s", ex.what());
return EXIT_FAILURE;
}
// Connect to the database
try {
Database::Connect();
} catch (std::exception& ex) {
LOG("Failed to connect to the database: %s", ex.what());
return EXIT_FAILURE;
}
// Get master info from database
std::string masterIP = "localhost";
uint32_t masterPort = 1000;
std::string masterPassword;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
masterPassword = masterInfo->password;
}
// Setup network server for communicating with Master
g_Server = new dServer(
masterIP,
ourPort,
0,
maxClients,
false,
false,
Game::logger,
masterIP,
masterPort,
ServiceType::DASHBOARD, // Connect as dashboard to master
Game::config,
&Game::lastSignal,
masterPassword
);
// Initialize web server
if (!Game::web.Startup(ourIP, ourPort)) {
LOG("Failed to start web server on %s:%d", ourIP.c_str(), ourPort);
return EXIT_FAILURE;
}
// Set up WebSocket authentication callback using consolidated handler
Game::web.SetWSAuthCallback([](const std::string& token) -> bool {
auto result = AuthTokenHandler::ValidateToken(token);
return result.isValid;
});
// Register global middleware
Game::web.AddGlobalMiddleware(std::make_shared<AuthMiddleware>());
// Register routes in order: API, Static, Auth, WebSocket, Dashboard (dashboard MUST be last)
RegisterAPIRoutes();
RegisterStaticRoutes();
RegisterAuthRoutes();
RegisterWSRoutes();
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
LOG("Dashboard Server started successfully on %s:%d", ourIP.c_str(), ourPort);
LOG("Connected to Master Server at %s:%d", masterIP.c_str(), masterPort);
// Main loop
auto lastTime = std::chrono::high_resolution_clock::now();
auto lastBroadcast = lastTime;
auto currentTime = lastTime;
constexpr float deltaTime = 1.0f / 60.0f; // 60 FPS
constexpr float broadcastInterval = 2000.0f; // Broadcast every 2 seconds
while (!Game::ShouldShutdown()) {
currentTime = std::chrono::high_resolution_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastTime).count();
const auto elapsedSinceBroadcast = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastBroadcast).count();
if (elapsed >= 1000.0f / 60.0f) {
// // Handle master server packets
// Packet* packet = g_Server->ReceiveFromMaster();
// if (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocateMasterPacket(packet);
// }
// // Handle RakNet protocol packets from connected servers
// packet = g_Server->Receive();
// while (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocatePacket(packet);
// packet = g_Server->Receive();
// }
// Handle web requests
Game::web.ReceiveRequests();
// Broadcast dashboard updates periodically
if (elapsedSinceBroadcast >= broadcastInterval) {
BroadcastDashboardUpdate();
lastBroadcast = currentTime;
}
lastTime = currentTime;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Cleanup
Database::Destroy("DashboardServer");
delete g_Server;
g_Server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,6 @@
#include "AuthMiddleware.h"
#include "AuthTokenHandler.h"
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
return AuthTokenHandler::ProcessHTTPContext(context, reply);
}

View File

@@ -0,0 +1,28 @@
#ifndef __AUTHMIDDLEWARE_H__
#define __AUTHMIDDLEWARE_H__
#include <string>
#include <memory>
#include "IHTTPMiddleware.h"
/**
* AuthMiddleware: Extracts and verifies authentication tokens
*
* Token extraction sources (in priority order):
* 1. Query parameter: ?token=eyJhbGc...
* 2. Cookie: dashboardToken=...
* 3. Authorization header: Bearer <token> or Token <token>
*
* Sets HTTPContext.isAuthenticated, HTTPContext.authenticatedUser,
* and HTTPContext.gmLevel if token is valid.
*/
class AuthMiddleware final : public IHTTPMiddleware {
public:
AuthMiddleware() = default;
~AuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "AuthMiddleware"; }
};
#endif // !__AUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,186 @@
#include "AuthTokenHandler.h"
#include "DashboardAuthService.h"
#include "Game.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "Web.h"
// Helper to extract cookie value from header
static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) {
std::string searchStr = cookieName + "=";
size_t pos = cookieHeader.find(searchStr);
if (pos == std::string::npos) {
return "";
}
size_t valueStart = pos + searchStr.length();
size_t valueEnd = cookieHeader.find(";", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = cookieHeader.length();
}
std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart);
// URL decode the value
std::string decoded;
for (size_t i = 0; i < value.length(); ++i) {
if (value[i] == '%' && i + 2 < value.length()) {
std::string hex = value.substr(i + 1, 2);
char* endptr;
int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
if (endptr - hex.c_str() == 2) {
decoded += static_cast<char>(charCode);
i += 2;
continue;
}
}
decoded += value[i];
}
return decoded;
}
std::string AuthTokenHandler::ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) {
return "";
}
// Parse query string to find token parameter
// Expected format: "?token=eyJhbGc..."
std::string tokenPrefix = "token=";
size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
// Extract token value (from "token=" to next "&" or end of string)
size_t valueStart = tokenPos + tokenPrefix.length();
size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = queryString.length();
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
std::string AuthTokenHandler::ExtractTokenFromCookieHeader(const std::string& cookieHeader) {
if (cookieHeader.empty()) {
return "";
}
// Extract dashboardToken cookie value
return ExtractCookieValue(cookieHeader, "dashboardToken");
}
std::string AuthTokenHandler::ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) {
return "";
}
// Check for "Bearer <token>" format
if (authHeader.length() >= 7 && authHeader.substr(0, 7) == "Bearer ") {
return authHeader.substr(7);
}
// Check for "Token <token>" format
if (authHeader.length() >= 6 && authHeader.substr(0, 6) == "Token ") {
return authHeader.substr(6);
}
// If no prefix, assume raw token
return authHeader;
}
std::string AuthTokenHandler::ExtractToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
) {
// Try in priority order: query string, cookie, auth header
std::string token = ExtractTokenFromQueryString(queryString);
if (!token.empty()) {
return token;
}
token = ExtractTokenFromCookieHeader(cookieHeader);
if (!token.empty()) {
return token;
}
token = ExtractTokenFromAuthHeader(authHeader);
return token;
}
AuthTokenHandler::TokenValidationResult AuthTokenHandler::ValidateToken(const std::string& token) {
TokenValidationResult result;
if (token.empty()) {
result.isValid = false;
result.errorMessage = "No token provided";
return result;
}
// Verify JWT token
std::string username;
uint8_t gmLevel = 0;
if (!DashboardAuthService::VerifyToken(token, username, gmLevel)) {
result.isValid = false;
result.errorMessage = "Invalid or expired token";
LOG_DEBUG("Token validation failed");
return result;
}
result.isValid = true;
result.username = username;
result.gmLevel = gmLevel;
LOG_DEBUG("Token validated successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return result;
}
AuthTokenHandler::TokenValidationResult AuthTokenHandler::ExtractAndValidateToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
) {
TokenValidationResult result;
// Extract token from any source
std::string token = ExtractToken(queryString, cookieHeader, authHeader);
if (token.empty()) {
result.isValid = false;
result.errorMessage = "No authentication token found";
return result;
}
// Validate the token
return ValidateToken(token);
}
bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply) {
// Extract and validate token from all available sources
const std::string& queryString = context.queryString;
const std::string& cookieHeader = context.GetHeader("Cookie");
const std::string& authHeader = context.GetHeader("Authorization");
auto result = ExtractAndValidateToken(queryString, cookieHeader, authHeader);
if (result.isValid) {
context.isAuthenticated = true;
context.authenticatedUser = result.username;
context.gmLevel = result.gmLevel;
LOG_DEBUG("User %s authenticated via API token (GM level %d)", result.username.c_str(), result.gmLevel);
return true;
} else {
LOG_DEBUG("Authentication token validation failed: %s", result.errorMessage.c_str());
return true; // Continue - let routes decide if auth is required
}
}

View File

@@ -0,0 +1,90 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Centralized authentication token handler
* Consolidates token extraction from multiple sources and validation
* Used by both HTTP API routes and WebSocket connections
*/
class AuthTokenHandler {
public:
/**
* Result of token extraction and validation
*/
struct TokenValidationResult {
bool isValid{false};
std::string username{};
uint8_t gmLevel{0};
std::string errorMessage{};
};
/**
* Extract token from query string
* Expected format: "?token=eyJhbGc..." or "?token=xyz&other=abc"
* @param queryString The query string from the request
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromQueryString(const std::string& queryString);
/**
* Extract token from Cookie header
* Looks for "dashboardToken=<value>" in the cookie string
* @param cookieHeader The Cookie header value
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromCookieHeader(const std::string& cookieHeader);
/**
* Extract token from Authorization header
* Supports "Bearer <token>", "Token <token>", or raw token
* @param authHeader The Authorization header value
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
/**
* Extract token from any available source
* Tries in priority order: query string, cookie, auth header
* @param queryString The query string
* @param cookieHeader The Cookie header
* @param authHeader The Authorization header
* @return The first token found, or empty string
*/
static std::string ExtractToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
);
/**
* Validate a token and extract user information
* Checks JWT signature, expiration, and user permissions
* @param token The JWT token
* @return TokenValidationResult with validity status and user info
*/
static TokenValidationResult ValidateToken(const std::string& token);
/**
* Convenience method: Extract and validate token in one call
* @param queryString Query string from request
* @param cookieHeader Cookie header from request
* @param authHeader Authorization header from request
* @return TokenValidationResult with validity status and user info
*/
static TokenValidationResult ExtractAndValidateToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
);
/**
* Process authentication for HTTP middleware use
* Extracts and validates token from request, sets HTTPContext properties
* @param context HTTP request context (modified to include auth info)
* @param reply HTTP reply (not modified unless validation fails silently)
* @return true to continue middleware chain, false to stop
*/
static bool ProcessHTTPContext(class HTTPContext& context, class HTTPReply& reply);
};

View File

@@ -0,0 +1,29 @@
set(DASHBOARDAUTH_SOURCES
"JWTUtils.cpp"
"DashboardAuthService.cpp"
"AuthMiddleware.cpp"
"AuthTokenHandler.cpp"
"RequireAuthMiddleware.cpp"
)
add_library(DashboardAuth STATIC ${DASHBOARDAUTH_SOURCES})
target_include_directories(DashboardAuth PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
)
target_link_libraries(DashboardAuth PRIVATE ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)

View File

@@ -0,0 +1,144 @@
#include "DashboardAuthService.h"
#include "JWTUtils.h"
#include "Database.h"
#include "Logger.h"
#include "Game.h"
#include "dConfig.h"
#include "GeneralUtils.h"
#include <bcrypt/bcrypt.h>
#include <ctime>
namespace {
constexpr int64_t LOCKOUT_DURATION = 15 * 60; // 15 minutes in seconds
}
DashboardAuthService::LoginResult DashboardAuthService::Login(
const std::string& username,
const std::string& password,
bool rememberMe) {
LoginResult result;
if (username.empty() || password.empty()) {
result.message = "Username and password are required";
return result;
}
if (password.length() > 40) {
result.message = "Password exceeds maximum length (40 characters)";
return result;
}
try {
// Get account info
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo) {
result.message = "Invalid username or password";
LOG_DEBUG("Login attempt for non-existent user: %s", username.c_str());
return result;
}
uint32_t accountId = accountInfo->id;
// Check if account is locked
bool isLockedOut = Database::Get()->IsLockedOut(accountId);
if (isLockedOut) {
// Record failed attempt even without checking password
Database::Get()->RecordFailedAttempt(accountId);
uint8_t failedAttempts = Database::Get()->GetFailedAttempts(accountId);
result.message = "Account is locked due to too many failed attempts";
result.accountLocked = true;
LOG("Login attempt on locked account: %s (failed attempts: %d)", username.c_str(), failedAttempts);
return result;
}
// Check password
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
// Record failed attempt
Database::Get()->RecordFailedAttempt(accountId);
uint8_t newFailedAttempts = Database::Get()->GetFailedAttempts(accountId);
// Lock account after 3 failed attempts
if (newFailedAttempts >= 3) {
int64_t lockoutUntil = std::time(nullptr) + LOCKOUT_DURATION;
Database::Get()->SetLockout(accountId, lockoutUntil);
result.message = "Account locked due to too many failed attempts";
result.accountLocked = true;
LOG("Account locked after failed attempts: %s", username.c_str());
} else {
result.message = "Invalid username or password";
LOG_DEBUG("Failed login attempt for user: %s (attempt %d/3)",
username.c_str(), newFailedAttempts);
}
return result;
}
// Check GM level
if (!HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
result.message = "Access denied: insufficient permissions";
LOG("Access denied for non-admin user: %s", username.c_str());
return result;
}
// Successful login
Database::Get()->ClearFailedAttempts(accountId);
result.success = true;
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
result.token = JWTUtils::GenerateToken(username, result.gmLevel, rememberMe);
result.message = "Login successful";
LOG("Successful login: %s (GM Level: %d)", username.c_str(), result.gmLevel);
return result;
} catch (const std::exception& ex) {
result.message = "An error occurred during login";
LOG("Error during login process: %s", ex.what());
return result;
}
}
bool DashboardAuthService::VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel) {
JWTUtils::JWTPayload payload;
if (!JWTUtils::ValidateToken(token, payload)) {
LOG_DEBUG("Token validation failed: invalid or expired JWT");
return false;
}
username = payload.username;
gmLevel = payload.gmLevel;
// Optionally verify user still exists and has access
try {
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
LOG_DEBUG("Token verification failed: user no longer has access");
return false;
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error verifying user during token validation: %s", ex.what());
return false;
}
LOG_DEBUG("Token verified successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return true;
}
bool DashboardAuthService::HasDashboardAccess(uint8_t gmLevel) {
// Get minimum GM level from config (default 0 = any user)
uint8_t minGmLevel = 0;
if (Game::config) {
const std::string& minGmLevelStr = Game::config->GetValue("min_dashboard_gm_level");
if (!minGmLevelStr.empty()) {
const auto parsed = GeneralUtils::TryParse<uint8_t>(minGmLevelStr);
if (parsed) {
minGmLevel = parsed.value();
}
}
}
return gmLevel >= minGmLevel;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Dashboard authentication service
* Handles user login, password verification, and account lockout
*/
class DashboardAuthService {
public:
/**
* Login result structure
*/
struct LoginResult {
bool success{false};
std::string message{};
std::string token{}; // JWT token if successful
uint8_t gmLevel{0}; // GM level if successful
bool accountLocked{false}; // Account is locked out
};
/**
* Attempt to log in with username and password
* @param username The username
* @param password The plaintext password (max 40 characters)
* @param rememberMe If true, extends token expiration to 30 days
* @return LoginResult with success status and JWT token if successful
*/
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe = false);
/**
* Verify that a token is valid and get the username
* @param token The JWT token
* @param username Output parameter for the username
* @param gmLevel Output parameter for the GM level
* @return true if token is valid
*/
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
/**
* Check if user has required GM level for dashboard access
* @param gmLevel The user's GM level
* @return true if user can access dashboard (GM level > 0)
*/
static bool HasDashboardAccess(uint8_t gmLevel);
};

View File

@@ -0,0 +1,186 @@
#include "JWTUtils.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "json.hpp"
#include <ctime>
#include <cstring>
#include <openssl/hmac.h>
#include <openssl/sha.h>
namespace {
std::string g_Secret = "default-secret-change-me";
// Simple base64 encoding
std::string Base64Encode(const std::string& input) {
static const char* base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string ret;
int i = 0;
unsigned char char_array_3[3];
unsigned char char_array_4[4];
for (size_t n = 0; n < input.length(); n++) {
char_array_3[i++] = input[n];
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]];
i = 0;
}
}
if (i) {
for (int j = i; j < 3; j++) char_array_3[j] = '\0';
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
for (int j = 0; j <= i; j++) ret += base64_chars[char_array_4[j]];
while (i++ < 3) ret += '=';
}
return ret;
}
// Simple base64 decoding
std::string Base64Decode(const std::string& encoded_string) {
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int in_len = encoded_string.size();
int i = 0, j = 0, in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') &&
(isalnum(encoded_string[in_]) || encoded_string[in_] == '+' || encoded_string[in_] == '/')) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; i < 3; i++) ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++) char_array_4[j] = 0;
for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
for (j = 0; j < i - 1; j++) ret += char_array_3[j];
}
return ret;
}
// HMAC-SHA256
std::string HmacSha256(const std::string& key, const std::string& message) {
unsigned char* digest = HMAC(EVP_sha256(),
reinterpret_cast<const unsigned char*>(key.c_str()), key.length(),
reinterpret_cast<const unsigned char*>(message.c_str()), message.length(),
nullptr, nullptr);
std::string result(reinterpret_cast<char*>(digest), SHA256_DIGEST_LENGTH);
return result;
}
// Create signature for JWT
std::string CreateSignature(const std::string& header, const std::string& payload, const std::string& secret) {
std::string message = header + "." + payload;
std::string signature = HmacSha256(secret, message);
return Base64Encode(signature);
}
// Verify JWT signature
bool VerifySignature(const std::string& header, const std::string& payload, const std::string& signature, const std::string& secret) {
std::string expected = CreateSignature(header, payload, secret);
return signature == expected;
}
}
namespace JWTUtils {
void SetSecretKey(const std::string& secret) {
if (secret.empty()) {
LOG("Warning: JWT secret key is empty, using default");
return;
}
g_Secret = secret;
}
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
// Header
std::string header = R"({"alg":"HS256","typ":"JWT"})";
std::string encodedHeader = Base64Encode(header);
// Payload
int64_t now = std::time(nullptr);
int64_t expiresAt = now + (rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60); // 30 days or 24 hours
std::string payload = R"({"username":")" + username + R"(","gmLevel":)" + std::to_string(gmLevel) +
R"(,"rememberMe":)" + (rememberMe ? "true" : "false") +
R"(,"iat":)" + std::to_string(now) +
R"(,"exp":)" + std::to_string(expiresAt) + "}";
std::string encodedPayload = Base64Encode(payload);
// Signature
std::string signature = CreateSignature(encodedHeader, encodedPayload, g_Secret);
return encodedHeader + "." + encodedPayload + "." + signature;
}
bool ValidateToken(const std::string& token, JWTPayload& payload) {
// Split token into parts
size_t firstDot = token.find('.');
size_t secondDot = token.find('.', firstDot + 1);
if (firstDot == std::string::npos || secondDot == std::string::npos) {
LOG_DEBUG("Invalid JWT format");
return false;
}
std::string header = token.substr(0, firstDot);
std::string encodedPayload = token.substr(firstDot + 1, secondDot - firstDot - 1);
std::string signature = token.substr(secondDot + 1);
// Verify signature
if (!VerifySignature(header, encodedPayload, signature, g_Secret)) {
LOG_DEBUG("Invalid JWT signature");
return false;
}
// Decode and parse payload
std::string decodedPayload = Base64Decode(encodedPayload);
try {
auto json = nlohmann::json::parse(decodedPayload);
payload.username = json.value("username", "");
payload.gmLevel = json.value("gmLevel", 0);
payload.rememberMe = json.value("rememberMe", false);
payload.issuedAt = json.value("iat", 0);
payload.expiresAt = json.value("exp", 0);
if (payload.username.empty()) {
LOG_DEBUG("JWT missing username");
return false;
}
// Check expiration
if (IsTokenExpired(payload.expiresAt)) {
LOG_DEBUG("JWT token expired");
return false;
}
return true;
} catch (const std::exception& ex) {
LOG_DEBUG("Error parsing JWT payload: %s", ex.what());
return false;
}
}
bool IsTokenExpired(int64_t expiresAt) {
return std::time(nullptr) > expiresAt;
}
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <string>
#include <ctime>
#include "json_fwd.hpp"
/**
* JWT Token utilities for dashboard authentication
* Provides secure token generation, validation, and parsing
*/
namespace JWTUtils {
/**
* JWT payload structure
*/
struct JWTPayload {
std::string username{};
uint8_t gmLevel{0};
bool rememberMe{false};
int64_t issuedAt{0};
int64_t expiresAt{0};
};
/**
* Generate a new JWT token
* @param username The username to encode in the token
* @param gmLevel The GM level of the user
* @param rememberMe If true, extends token expiration to 30 days; otherwise 24 hours
* @return Signed JWT token string
*/
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe = false);
/**
* Validate and decode a JWT token
* @param token The JWT token to validate
* @param payload Output parameter for the decoded payload
* @return true if token is valid and not expired, false otherwise
*/
bool ValidateToken(const std::string& token, JWTPayload& payload);
/**
* Check if a token is expired
* @param expiresAt Expiration timestamp
* @return true if token is expired
*/
bool IsTokenExpired(int64_t expiresAt);
/**
* Set the JWT secret key (must be called once at startup)
* @param secret The secret key for signing tokens
*/
void SetSecretKey(const std::string& secret);
}

View File

@@ -0,0 +1,35 @@
#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : minGmLevel(minGmLevel) {}
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Check if user is authenticated
if (!context.isAuthenticated) {
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/login";
reply.contentType = eContentType::TEXT_HTML;
return false; // Stop middleware chain and send reply
}
// Check if user has required GM level
if (context.gmLevel < minGmLevel) {
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop middleware chain and send reply
}
// Authentication passed
LOG_DEBUG("User %s authenticated with GM level %d accessing %s",
context.authenticatedUser.c_str(), context.gmLevel, context.path.c_str());
return true; // Continue to next middleware or route handler
}

View File

@@ -0,0 +1,30 @@
#ifndef __REQUIREAUTHMIDDLEWARE_H__
#define __REQUIREAUTHMIDDLEWARE_H__
#include <memory>
#include <cstdint>
#include "IHTTPMiddleware.h"
/**
* RequireAuthMiddleware: Enforces authentication on protected routes
*
* Returns 401 Unauthorized if user is not authenticated
* Returns 403 Forbidden if user's GM level is below minimum required
*/
class RequireAuthMiddleware final : public IHTTPMiddleware {
public:
/**
* @param minGmLevel Minimum GM level required to access this route
* 0 = any authenticated user, higher numbers = GM-only
*/
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0);
~RequireAuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "RequireAuthMiddleware"; }
private:
uint8_t minGmLevel;
};
#endif // !__REQUIREAUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,443 @@
#include "APIRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "RequireAuthMiddleware.h"
#include <memory>
void RegisterAPIRoutes() {
// GET /api/status - Get overall server status
Game::web.RegisterHTTPRoute({
.path = "/api/status",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = ServerState::GetServerStateJson();
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/players - Get list of online players
Game::web.RegisterHTTPRoute({
.path = "/api/players",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = {
{"players", nlohmann::json::array()},
{"count", 0}
};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/accounts/count - Get total account count
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetAccountCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/characters/count - Get total character count
Game::web.RegisterHTTPRoute({
.path = "/api/characters/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetCharacterCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/characters/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/accounts - Get accounts table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/accounts",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
// Extract DataTables parameters
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
// Extract search - it can be a string or an object with a "value" property
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
// Extract order parameters
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
// Get the accounts table data
nlohmann::json response = Database::Get()->GetAccountsTable(start, length, search, orderColumn, orderAsc);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/accounts: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/accounts: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/characters - Get characters table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/characters",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetCharactersTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/characters: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/characters: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/play_keys - Get play keys table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/play_keys",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPlayKeysTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/play_keys: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/play_keys: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/properties - Get properties table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/properties",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPropertiesTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/properties: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/properties: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/bug_reports - Get bug reports table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/bug_reports",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetBugReportsTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/bug_reports: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/bug_reports: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/accounts/:id - Get single account by ID
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - You do not have permission to view this account\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
}
// Get account data
nlohmann::json response = Database::Get()->GetAccountById(accountId);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/accounts/:id: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/:id: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterAPIRoutes();

View File

@@ -0,0 +1,102 @@
#include "AuthRoutes.h"
#include "DashboardAuthService.h"
#include "json.hpp"
#include "Logger.h"
#include "GeneralUtils.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "HTTPContext.h"
void RegisterAuthRoutes() {
// POST /api/auth/login
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
// Response: { "success": boolean, "message": "string", "token": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string username = json.value("username", "");
std::string password = json.value("password", "");
bool rememberMe = json.value("rememberMe", false);
// Validate input
if (username.empty() || password.empty()) {
reply.message = R"({"success":false,"message":"Username and password are required"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
if (password.length() > 40) {
reply.message = R"({"success":false,"message":"Password exceeds maximum length"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
// Attempt login
auto result = DashboardAuthService::Login(username, password, rememberMe);
nlohmann::json response;
response["success"] = result.success;
response["message"] = result.message;
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
}
reply.message = response.dump();
reply.status = result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing login request: %s", ex.what());
reply.message = R"({"success":false,"message":"Internal server error"})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/auth/verify
// Request body: { "token": "string" }
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/verify",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string token = json.value("token", "");
if (token.empty()) {
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string username;
uint8_t gmLevel{};
bool valid = DashboardAuthService::VerifyToken(token, username, gmLevel);
nlohmann::json response;
response["valid"] = valid;
if (valid) {
response["username"] = username;
response["gmLevel"] = gmLevel;
}
reply.message = response.dump();
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing verify request: %s", ex.what());
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Web.h"
/**
* Register authentication routes
* /api/auth/login - POST login endpoint
* /api/auth/verify - POST verify token endpoint
*/
void RegisterAuthRoutes();

View File

@@ -0,0 +1,30 @@
set(DASHBOARDROUTES_SOURCES
"APIRoutes.cpp"
"StaticRoutes.cpp"
"DashboardRoutes.cpp"
"WSRoutes.cpp"
"AuthRoutes.cpp"
)
add_library(DashboardRoutes STATIC ${DASHBOARDROUTES_SOURCES})
target_include_directories(DashboardRoutes PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer)

View File

@@ -0,0 +1,291 @@
#include "DashboardRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "inja.hpp"
#include "AuthMiddleware.h"
#include "RequireAuthMiddleware.h"
void RegisterDashboardRoutes() {
// GET / - Main dashboard page (requires authentication)
Game::web.RegisterHTTPRoute({
.path = "/",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Server status - merge with server state
nlohmann::json serverState = ServerState::GetServerStateJson();
data.merge_patch(serverState);
// Statistics
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
// Render template
const std::string html = env.render_file("index.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render template\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /login - Login page (no authentication required)
Game::web.RegisterHTTPRoute({
.path = "/login",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Render template with empty user data (not authenticated)
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("login.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering login template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render login page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /accounts/:id - View single account
Game::web.RegisterHTTPRoute({
.path = "/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Invalid account ID</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
LOG("Regular user '%s' (GM level 0) is trying to access account ID %u", context.authenticatedUser.c_str(), accountId);
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
LOG("Permission denied: user '%s' cannot access account ID %u", context.authenticatedUser.c_str(), accountId);
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "<h1>403 - Forbidden</h1><p>You do not have permission to view this account.</p>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
}
// Get account data from API
nlohmann::json account = Database::Get()->GetAccountById(accountId);
// Check if account was found
if (account.contains("error")) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
data["account"] = account;
// Render template
const std::string html = env.render_file("account-view.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering account view template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "<h1>500 - Server Error</h1>";
reply.contentType = eContentType::TEXT_HTML;
}
}
});
// GET /accounts - Accounts management page
Game::web.RegisterHTTPRoute({
.path = "/accounts",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Render template
const std::string html = env.render_file("accounts.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering accounts template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render accounts page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /characters - Characters management page
Game::web.RegisterHTTPRoute({
.path = "/characters",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("characters.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering characters template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render characters page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /play_keys - Play keys management page
Game::web.RegisterHTTPRoute({
.path = "/play_keys",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("play_keys.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering play_keys template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render play_keys page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /properties - Properties management page
Game::web.RegisterHTTPRoute({
.path = "/properties",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("properties.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering properties template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render properties page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /bug_reports - Bug reports management page
Game::web.RegisterHTTPRoute({
.path = "/bug_reports",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("bug_reports.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering bug_reports template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render bug_reports page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "json.hpp"
class HTTPContext;
void RegisterDashboardRoutes();

View File

@@ -0,0 +1,52 @@
#pragma once
#include <chrono>
#include <vector>
#include <string>
#include <cstdint>
#include "json.hpp"
struct ServerStatus {
bool online{false};
uint32_t players{0};
std::string version{};
std::chrono::steady_clock::time_point lastSeen{};
};
struct WorldInstanceInfo {
uint32_t mapID{0};
uint32_t instanceID{0};
uint32_t cloneID{0};
uint32_t players{0};
std::string ip{};
uint32_t port{0};
bool isPrivate{false};
};
namespace ServerState {
extern ServerStatus g_AuthStatus;
extern ServerStatus g_ChatStatus;
extern std::vector<WorldInstanceInfo> g_WorldInstances;
// Helper function to get all server state as JSON
inline nlohmann::json GetServerStateJson() {
nlohmann::json data;
data["auth"]["online"] = g_AuthStatus.online;
data["auth"]["players"] = g_AuthStatus.players;
data["chat"]["online"] = g_ChatStatus.online;
data["chat"]["players"] = g_ChatStatus.players;
data["worlds"] = nlohmann::json::array();
for (const auto& world : g_WorldInstances) {
data["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
return data;
}
}

View File

@@ -0,0 +1,68 @@
#include "StaticRoutes.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "Game.h"
#include "Logger.h"
#include <fstream>
#include <sstream>
namespace {
std::string ReadFileToString(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
LOG("Failed to open file: %s", filePath.c_str());
return "";
}
std::stringstream buffer{};
buffer << file.rdbuf();
return buffer.str();
}
eContentType GetContentType(const std::string& filePath) {
if (filePath.ends_with(".css")) {
return eContentType::TEXT_CSS;
} else if (filePath.ends_with(".js")) {
return eContentType::TEXT_JAVASCRIPT;
} else if (filePath.ends_with(".html")) {
return eContentType::TEXT_HTML;
} else if (filePath.ends_with(".png")) {
return eContentType::IMAGE_PNG;
} else if (filePath.ends_with(".jpg") || filePath.ends_with(".jpeg")) {
return eContentType::IMAGE_JPEG;
} else if (filePath.ends_with(".json")) {
return eContentType::APPLICATION_JSON;
}
return eContentType::TEXT_PLAIN;
}
void ServeStaticFile(const std::string& urlPath, const std::string& filePath) {
Game::web.RegisterHTTPRoute({
.path = urlPath,
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [filePath](HTTPReply& reply, const HTTPContext& context) {
const std::string content = ReadFileToString(filePath);
if (content.empty()) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "{\"error\":\"File not found\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::OK;
reply.message = content;
reply.contentType = GetContentType(filePath);
}
}
});
}
}
void RegisterStaticRoutes() {
// Serve CSS files
ServeStaticFile("/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
ServeStaticFile("/css/login.css", "dDashboardServer/static/css/login.css");
// Serve JavaScript files
ServeStaticFile("/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
ServeStaticFile("/js/login.js", "dDashboardServer/static/js/login.js");
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterStaticRoutes();

View File

@@ -0,0 +1,232 @@
#include "WSRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
void RegisterWSRoutes() {
// Register WebSocket subscriptions for real-time updates
Game::web.RegisterWSSubscription("dashboard_update");
Game::web.RegisterWSSubscription("server_status");
Game::web.RegisterWSSubscription("player_joined");
Game::web.RegisterWSSubscription("player_left");
// Account subscriptions
Game::web.RegisterWSSubscription("account_update");
Game::web.RegisterWSSubscription("accounts_table_update");
Game::web.RegisterWSSubscription("account_list_changed");
// Character subscriptions
Game::web.RegisterWSSubscription("character_update");
Game::web.RegisterWSSubscription("characters_table_update");
Game::web.RegisterWSSubscription("character_list_changed");
// Property subscriptions
Game::web.RegisterWSSubscription("property_update");
Game::web.RegisterWSSubscription("properties_table_update");
Game::web.RegisterWSSubscription("property_list_changed");
// Play Key subscriptions
Game::web.RegisterWSSubscription("play_key_update");
Game::web.RegisterWSSubscription("play_keys_table_update");
Game::web.RegisterWSSubscription("play_key_list_changed");
// Bug Report subscriptions
Game::web.RegisterWSSubscription("bug_report_update");
Game::web.RegisterWSSubscription("bug_reports_table_update");
}
void BroadcastDashboardUpdate() {
// Get server state data (auth, chat, worlds) - mutex is acquired internally
nlohmann::json data = ServerState::GetServerStateJson();
// Add statistics
try {
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
} catch (const std::exception& ex) {
LOG_DEBUG("Error getting stats: %s", ex.what());
}
// Broadcast to all connected WebSocket clients subscribed to "dashboard_update"
Game::web.SendWSMessage("dashboard_update", data);
}
void BroadcastAccountUpdate(uint32_t accountId) {
try {
// Get updated account data
nlohmann::json accountData = Database::Get()->GetAccountById(accountId);
// Only broadcast if account was found
if (!accountData.contains("error")) {
accountData["event"] = "account_update";
accountData["accountId"] = accountId;
Game::web.SendWSMessage("account_update", accountData);
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting account update: %s", ex.what());
}
}
void BroadcastAccountsTableUpdate() {
try {
// Broadcast that the accounts table has been modified
// Clients should reload the current page of data
nlohmann::json message = {
{"event", "accounts_table_update"},
{"message", "Accounts table has been updated"}
};
Game::web.SendWSMessage("accounts_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting accounts table update: %s", ex.what());
}
}
void BroadcastAccountListChanged() {
try {
// Broadcast that accounts list changed (new account created or deleted)
nlohmann::json message = {
{"event", "account_list_changed"},
{"totalAccounts", Database::Get()->GetAccountCount()}
};
Game::web.SendWSMessage("account_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting account list change: %s", ex.what());
}
}
void BroadcastCharacterUpdate(uint32_t characterId) {
try {
// Get updated character data and broadcast
nlohmann::json message = {
{"event", "character_update"},
{"characterId", characterId}
};
Game::web.SendWSMessage("character_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting character update: %s", ex.what());
}
}
void BroadcastCharactersTableUpdate() {
try {
nlohmann::json message = {
{"event", "characters_table_update"},
{"message", "Characters table has been updated"}
};
Game::web.SendWSMessage("characters_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting characters table update: %s", ex.what());
}
}
void BroadcastCharacterListChanged() {
try {
nlohmann::json message = {
{"event", "character_list_changed"},
{"totalCharacters", Database::Get()->GetCharacterCount()}
};
Game::web.SendWSMessage("character_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting character list change: %s", ex.what());
}
}
void BroadcastPropertyUpdate(uint32_t propertyId) {
try {
nlohmann::json message = {
{"event", "property_update"},
{"propertyId", propertyId}
};
Game::web.SendWSMessage("property_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting property update: %s", ex.what());
}
}
void BroadcastPropertiesTableUpdate() {
try {
nlohmann::json message = {
{"event", "properties_table_update"},
{"message", "Properties table has been updated"}
};
Game::web.SendWSMessage("properties_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting properties table update: %s", ex.what());
}
}
void BroadcastPropertyListChanged() {
try {
nlohmann::json message = {
{"event", "property_list_changed"},
{"totalProperties", 0} // TODO: Get from database
};
Game::web.SendWSMessage("property_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting property list change: %s", ex.what());
}
}
void BroadcastPlayKeyUpdate(uint32_t playKeyId) {
try {
nlohmann::json message = {
{"event", "play_key_update"},
{"playKeyId", playKeyId}
};
Game::web.SendWSMessage("play_key_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play key update: %s", ex.what());
}
}
void BroadcastPlayKeysTableUpdate() {
try {
nlohmann::json message = {
{"event", "play_keys_table_update"},
{"message", "Play Keys table has been updated"}
};
Game::web.SendWSMessage("play_keys_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play keys table update: %s", ex.what());
}
}
void BroadcastPlayKeyListChanged() {
try {
nlohmann::json message = {
{"event", "play_key_list_changed"},
{"totalPlayKeys", 0} // TODO: Get from database
};
Game::web.SendWSMessage("play_key_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play key list change: %s", ex.what());
}
}
void BroadcastBugReportUpdate(uint32_t bugReportId) {
try {
nlohmann::json message = {
{"event", "bug_report_update"},
{"bugReportId", bugReportId}
};
Game::web.SendWSMessage("bug_report_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting bug report update: %s", ex.what());
}
}
void BroadcastBugReportsTableUpdate() {
try {
nlohmann::json message = {
{"event", "bug_reports_table_update"},
{"message", "Bug Reports table has been updated"}
};
Game::web.SendWSMessage("bug_reports_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting bug reports table update: %s", ex.what());
}
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include "json.hpp"
#include <cstdint>
void RegisterWSRoutes();
void BroadcastDashboardUpdate();
// Account broadcasts
void BroadcastAccountUpdate(uint32_t accountId);
void BroadcastAccountsTableUpdate();
void BroadcastAccountListChanged();
// Character broadcasts
void BroadcastCharacterUpdate(uint32_t characterId);
void BroadcastCharactersTableUpdate();
void BroadcastCharacterListChanged();
// Property broadcasts
void BroadcastPropertyUpdate(uint32_t propertyId);
void BroadcastPropertiesTableUpdate();
void BroadcastPropertyListChanged();
// Play Key broadcasts
void BroadcastPlayKeyUpdate(uint32_t playKeyId);
void BroadcastPlayKeysTableUpdate();
void BroadcastPlayKeyListChanged();
// Bug Report broadcasts
void BroadcastBugReportUpdate(uint32_t bugReportId);
void BroadcastBugReportsTableUpdate();

View File

@@ -0,0 +1,285 @@
/* DarkflameServer Dashboard - Minimal Branding */
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--dark-bg: #0f0f1e;
--card-bg: rgba(26, 26, 46, 0.8);
}
body {
background: linear-gradient(135deg, var(--dark-bg) 0%, #1a1a2e 100%);
display: grid;
grid-template-columns: 280px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
/* Grid Layout */
.navbar.flex-column {
grid-row: 1 / -1;
grid-column: 1;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
box-shadow: 2px 0 15px rgba(0, 0, 0, 0.3);
overflow-y: auto;
}
main {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
}
.navbar:not(.flex-column) {
grid-column: 1 / -1;
grid-row: 1;
background: rgba(26, 26, 46, 0.95);
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
}
footer {
grid-column: 1 / -1;
grid-row: 3;
background: rgba(26, 26, 46, 0.95);
border-top: 1px solid rgba(102, 126, 234, 0.2);
}
/* Sidebar Navigation */
.navbar.flex-column .nav-link {
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.navbar.flex-column .nav-link:hover {
background-color: rgba(102, 126, 234, 0.15);
border-left-color: var(--primary-color);
}
.navbar.flex-column .nav-link.active {
background-color: rgba(102, 126, 234, 0.25);
border-left-color: var(--primary-color);
color: var(--primary-color);
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid rgba(102, 126, 234, 0.2);
transition: all 0.3s ease;
}
.card:hover {
border-color: rgba(102, 126, 234, 0.4);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15);
}
.card-header {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-bottom: 1px solid rgba(102, 126, 234, 0.3);
}
/* Tables */
.table-dark {
border-color: rgba(102, 126, 234, 0.2);
}
.table-dark thead th {
background: rgba(102, 126, 234, 0.15);
border-bottom: 2px solid rgba(102, 126, 234, 0.3);
}
.table-dark tbody tr:hover {
background-color: rgba(102, 126, 234, 0.1);
}
/* DataTables */
.dataTables_wrapper .dataTables_filter input,
.dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: rgba(42, 42, 58, 0.8);
border: 1px solid rgba(102, 126, 234, 0.3);
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border: none;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border: none;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.logout-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
/* Custom Badge Styles */
.badge-banned {
background-color: #dc3545;
}
.badge-locked {
background-color: #fd7e14;
}
.badge-gm {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
.badge-active {
background-color: #28a745;
}
.badge-inactive {
background-color: #6c757d;
}
.badge-approved {
background-color: #28a745;
}
.badge-pending {
background-color: #ffc107;
color: #000;
}
.badge-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
/* Status Indicators */
.status.online::before,
.status.offline::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.status.online::before {
background: #4caf50;
}
.status.offline::before {
background: #f44336;
}
/* Detail Grid */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.detail-item {
border-left: 3px solid var(--primary-color);
padding-left: 12px;
}
.detail-label {
font-size: 0.875rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.detail-value {
font-size: 1rem;
color: #fff;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.back-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
transition: all 0.3s;
display: inline-block;
margin-bottom: 20px;
}
.back-link:hover {
color: var(--secondary-color);
}
/* Container Wrappers - Use Bootstrap spacing */
.accounts-container,
.characters-container,
.bug-reports-container,
.properties-container,
.play-keys-container,
.account-view-container {
width: 100%;
}
/* Responsive */
@media (max-width: 991.98px) {
body {
grid-template-columns: 1fr;
}
.navbar.flex-column {
grid-column: 1 / -1;
grid-row: 1;
flex-direction: row;
flex-wrap: wrap;
overflow-y: visible;
}
.navbar.flex-column .nav-link {
border-left: none;
border-bottom: 2px solid transparent;
}
.navbar.flex-column .nav-link:hover,
.navbar.flex-column .nav-link.active {
border-bottom-color: var(--primary-color);
}
main {
grid-column: 1;
grid-row: 2;
}
footer {
grid-column: 1;
grid-row: 3;
}
.detail-grid {
grid-template-columns: 1fr;
}
.table-dark {
font-size: 12px;
min-width: 100%;
}
.table-dark th,
.table-dark td {
white-space: nowrap;
padding: 8px 6px;
}
}
@media (max-width: 575.98px) {
.navbar.flex-column .nav-link {
font-size: 11px;
}
}

View File

@@ -0,0 +1,296 @@
/* Modern Login Page Styling */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body.d-flex.bg-dark.text-white {
background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 50%, #16213e 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
min-height: 100vh;
}
/* Hide elements not needed on login */
body .navbar,
body > footer {
display: none !important;
}
body .container-fluid {
padding: 0 !important;
}
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
max-width: 1100px;
min-height: 600px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* Left side - Branding */
.login-left {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: white;
}
.login-branding {
text-align: center;
}
.login-branding .logo {
font-size: 64px;
margin-bottom: 20px;
}
.login-branding h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-branding p {
font-size: 14px;
opacity: 0.9;
}
.login-info {
text-align: center;
}
.login-info h3 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
}
.login-info p {
font-size: 14px;
opacity: 0.85;
line-height: 1.6;
}
/* Right side - Form */
.login-right {
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.login-form-wrapper {
width: 100%;
}
.login-form-wrapper h2 {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 30px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
position: relative;
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input {
padding: 12px 16px;
font-size: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: all 0.3s ease;
font-family: inherit;
color: #1a1a2e;
}
.form-group input::placeholder {
color: #999;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-focus {
height: 2px;
background: linear-gradient(90deg, transparent, #667eea, transparent);
border-radius: 2px;
margin-top: -2px;
opacity: 0;
transition: opacity 0.3s ease;
}
.form-group input:focus ~ .form-focus {
opacity: 1;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 20px 0;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #667eea;
}
.form-checkbox label {
font-size: 13px;
color: #666;
cursor: pointer;
margin: 0;
text-transform: none;
letter-spacing: normal;
}
.btn-login {
padding: 12px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
.btn-login:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.alert-box {
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 8px;
font-size: 13px;
display: none;
}
.alert-box.alert-danger {
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
}
.alert-box.alert-success {
background-color: #efe;
color: #3c3;
border: 1px solid #cfc;
}
.login-footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.login-footer p {
font-size: 12px;
color: #999;
}
/* Responsive Design */
@media (max-width: 768px) {
.login-wrapper {
grid-template-columns: 1fr;
min-height: auto;
}
.login-left {
padding: 40px 30px;
justify-content: center;
min-height: 250px;
}
.login-right {
padding: 40px 30px;
}
.login-branding .logo {
font-size: 48px;
}
.login-branding h1 {
font-size: 24px;
}
.login-form-wrapper h2 {
font-size: 24px;
}
.form-group input {
font-size: 16px;
}
}

View File

@@ -0,0 +1,254 @@
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
// Helper function to get cookie value
function getCookie(name) {
const nameEQ = name + '=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
}
// Helper function to delete cookie
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
// Check authentication on page load
function checkAuthentication() {
// Check localStorage first (most secure)
let token = localStorage.getItem('dashboardToken');
// Fallback to cookie if localStorage empty
if (!token) {
token = getCookie('dashboardToken');
}
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return false;
}
// Verify token is valid (asynchronous)
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => {
if (!res.ok) {
console.error('Verify endpoint returned:', res.status);
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
console.log('Token verification response:', data);
if (!data.valid) {
// Token is invalid/expired, delete cookies and redirect to login
console.log('Token verification failed, redirecting to login');
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
} else {
// Update UI with username
console.log('Token verified, user:', data.username);
const usernameElement = document.querySelector('.username');
if (usernameElement) {
usernameElement.textContent = data.username || 'User';
} else {
console.warn('Username element not found in DOM');
}
// Now that verification is complete, connect to WebSocket
setTimeout(() => {
console.log('Starting WebSocket connection');
connectWebSocket();
}, 100);
}
})
.catch(err => {
console.error('Token verification error:', err);
// Network error - log but don't redirect immediately
// This prevents redirect loops on network issues
});
return true;
}
// Get token from localStorage or cookie
function getAuthToken() {
let token = localStorage.getItem('dashboardToken');
if (!token) {
token = getCookie('dashboardToken');
}
console.log('getAuthToken called, token available:', !!token);
return token;
}
// Logout function
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getAuthToken();
if (!token) {
console.error('No token available for WebSocket connection');
window.location.href = '/login';
return;
}
console.log(`WebSocket connection attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}`);
// Connect to WebSocket without token in URL (token is in cookies)
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
// Subscribe to dashboard updates
ws.send(JSON.stringify({
event: 'subscribe',
subscription: 'dashboard_update'
}));
// Mark connection as ready for other handlers
wsConnectionReady = true;
// Initialize real-time table manager
if (typeof realtimeManager !== 'undefined') {
realtimeManager.Initialize();
}
document.getElementById('connection-status')?.remove();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle subscription confirmation
if (data.subscribed) {
console.log('Subscribed to:', data.subscribed);
return;
}
// Handle dashboard updates
if (data.event === 'dashboard_update') {
updateDashboard(data);
}
// Route to generic realtime handler for all table updates
if (typeof realtimeManager !== 'undefined') {
realtimeManager.HandleMessage(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
ws = null;
// Show connection status
showConnectionStatus('Disconnected - Attempting to reconnect...');
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const backoffDelay = reconnectDelay * Math.pow(2, reconnectAttempts - 1);
console.log(`Reconnecting in ${backoffDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(connectWebSocket, backoffDelay);
} else {
console.error('Max reconnection attempts reached');
showConnectionStatus('Connection lost - Reload page to reconnect');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
showConnectionStatus('Failed to connect - Reload page to retry');
}
}
function showConnectionStatus(message) {
let statusEl = document.getElementById('connection-status');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'connection-status';
statusEl.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #f44336; color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000;';
document.body.appendChild(statusEl);
}
statusEl.textContent = message;
}
function updateDashboard(data) {
// Update server status
if (data.auth) {
document.getElementById('auth-status').textContent = data.auth.online ? 'Online' : 'Offline';
document.getElementById('auth-status').className = 'status ' + (data.auth.online ? 'online' : 'offline');
}
if (data.chat) {
document.getElementById('chat-status').textContent = data.chat.online ? 'Online' : 'Offline';
document.getElementById('chat-status').className = 'status ' + (data.chat.online ? 'online' : 'offline');
}
// Update world instances
if (data.worlds) {
document.getElementById('world-count').textContent = data.worlds.length;
const worldList = document.getElementById('world-list');
if (data.worlds.length === 0) {
worldList.innerHTML = '<div class="loading">No active world instances</div>';
} else {
worldList.innerHTML = data.worlds.map(world => `
<div class="world-item">
<h3>Zone ${world.mapID} - Instance ${world.instanceID}</h3>
<div class="world-detail">Clone ID: ${world.cloneID}</div>
<div class="world-detail">Players: ${world.players}</div>
<div class="world-detail">Type: ${world.isPrivate ? 'Private' : 'Public'}</div>
</div>
`).join('');
}
}
// Update statistics
if (data.stats) {
if (data.stats.onlinePlayers !== undefined) {
document.getElementById('online-players').textContent = data.stats.onlinePlayers;
}
if (data.stats.totalAccounts !== undefined) {
document.getElementById('total-accounts').textContent = data.stats.totalAccounts;
}
if (data.stats.totalCharacters !== undefined) {
document.getElementById('total-characters').textContent = data.stats.totalCharacters;
}
}
}
// Connect on page load
connectWebSocket();

View File

@@ -0,0 +1,99 @@
// Check if user is already logged in
function checkExistingToken() {
const token = localStorage.getItem('dashboardToken');
if (token) {
verifyTokenAndRedirect(token);
}
}
function verifyTokenAndRedirect(token) {
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => res.json())
.then(data => {
if (data.valid) {
window.location.href = '/';
}
})
.catch(err => console.error('Token verification failed:', err));
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = 'alert';
if (type === 'error') {
alert.classList.add('alert-danger');
} else if (type === 'success') {
alert.classList.add('alert-success');
}
alert.style.display = 'block';
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('loginForm');
if (!loginForm) {
console.error('Login form not found');
return;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// Validate input
if (!username || !password) {
showAlert('Username and password are required', 'error');
return;
}
if (password.length > 40) {
showAlert('Password exceeds maximum length (40 characters)', 'error');
return;
}
// Show loading state
document.getElementById('loading').style.display = 'inline-block';
document.getElementById('loginBtn').disabled = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
// Store token in localStorage (also set as cookie for API calls)
localStorage.setItem('dashboardToken', data.token);
document.cookie = `dashboardToken=${data.token}; path=/; SameSite=Strict`;
showAlert('Login successful! Redirecting...', 'success');
// Redirect after a short delay (no token in URL)
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showAlert(data.message || 'Login failed', 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
} catch (error) {
showAlert('Network error: ' + error.message, 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
});
// Check existing token on page load
checkExistingToken();
});

View File

@@ -0,0 +1,339 @@
/**
* Generic Real-time WebSocket Updates for Dashboard Tables
* Provides reactive, non-blocking updates for all admin tables
*/
const realtimeManager = {
tables: {},
currentEntityId: null,
wsReady: false,
/**
* Initialize real-time updates for a DataTable
* @param {string} entityType - Type of entity (account, character, property, etc.)
* @param {jQuery} tableElement - jQuery DataTable element
*/
RegisterTable(entityType, tableElement) {
if (!tableElement || !tableElement.DataTable) {
console.warn(`Invalid table element for entity type: ${entityType}`);
return;
}
this.tables[entityType] = {
element: tableElement,
table: tableElement.DataTable(),
lastUpdate: Date.now()
};
console.log(`Registered real-time table for entity type: ${entityType}`);
},
/**
* Initialize WebSocket listeners for all entity types
*/
Initialize() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not ready for realtime initialization');
return;
}
this.wsReady = true;
console.log('Initialized real-time WebSocket listeners');
this.SubscribeToAll();
},
/**
* Subscribe to all registered entity types
*/
SubscribeToAll() {
const subscriptions = [
'account_update',
'accounts_table_update',
'account_list_changed',
'character_update',
'characters_table_update',
'character_list_changed',
'property_update',
'properties_table_update',
'property_list_changed',
'play_key_update',
'play_keys_table_update',
'play_key_list_changed',
'bug_report_update',
'bug_reports_table_update'
];
for (const subscription of subscriptions) {
if (!ws || ws.readyState !== WebSocket.OPEN) break;
ws.send(JSON.stringify({
event: 'subscribe',
subscription: subscription
}));
}
console.log(`Subscribed to ${subscriptions.length} real-time events`);
},
/**
* Handle incoming WebSocket messages
*/
HandleMessage(data) {
if (!data.event) return;
// Route to appropriate handler based on event type
if (data.event.includes('account')) {
this.HandleAccountEvent(data);
} else if (data.event.includes('character')) {
this.HandleCharacterEvent(data);
} else if (data.event.includes('property')) {
this.HandlePropertyEvent(data);
} else if (data.event.includes('play_key')) {
this.HandlePlayKeyEvent(data);
} else if (data.event.includes('bug_report')) {
this.HandleBugReportEvent(data);
}
},
/**
* Handle account-related events
*/
HandleAccountEvent(data) {
if (data.event === 'account_update') {
this.UpdateRow('account', data.accountId || data.id, data);
// Also update detail panel if viewing this account
this.UpdateDetailPanel('account', data);
} else if (data.event === 'accounts_table_update') {
this.ReloadTable('account');
} else if (data.event === 'account_list_changed') {
this.UpdateListCount('account', data.totalAccounts);
this.ReloadTable('account', true);
}
},
/**
* Handle character-related events
*/
HandleCharacterEvent(data) {
if (data.event === 'character_update') {
this.UpdateRow('character', data.characterId || data.id, data);
} else if (data.event === 'characters_table_update') {
this.ReloadTable('character');
} else if (data.event === 'character_list_changed') {
this.UpdateListCount('character', data.totalCharacters);
this.ReloadTable('character', true);
}
},
/**
* Handle property-related events
*/
HandlePropertyEvent(data) {
if (data.event === 'property_update') {
this.UpdateRow('property', data.propertyId || data.id, data);
} else if (data.event === 'properties_table_update') {
this.ReloadTable('property');
} else if (data.event === 'property_list_changed') {
this.UpdateListCount('property', data.totalProperties);
this.ReloadTable('property', true);
}
},
/**
* Handle play key-related events
*/
HandlePlayKeyEvent(data) {
if (data.event === 'play_key_update') {
this.UpdateRow('play_key', data.playKeyId || data.id, data);
} else if (data.event === 'play_keys_table_update') {
this.ReloadTable('play_key');
} else if (data.event === 'play_key_list_changed') {
this.UpdateListCount('play_key', data.totalPlayKeys);
this.ReloadTable('play_key', true);
}
},
/**
* Handle bug report-related events
*/
HandleBugReportEvent(data) {
if (data.event === 'bug_report_update') {
this.UpdateRow('bug_report', data.bugReportId || data.id, data);
} else if (data.event === 'bug_reports_table_update') {
this.ReloadTable('bug_report');
}
},
/**
* Update a single row in a DataTable
*/
UpdateRow(entityType, entityId, data) {
if (!this.tables[entityType]) {
console.debug(`No table registered for entity type: ${entityType}`);
return;
}
try {
const table = this.tables[entityType].table;
const rows = table.rows().nodes();
// Find and invalidate the matching row
let found = false;
for (let row of rows) {
const rowData = table.row(row).data();
if (rowData && (rowData.id === entityId || rowData.accountId === entityId ||
rowData.characterId === entityId || rowData.propertyId === entityId ||
rowData.playKeyId === entityId)) {
table.row(row).invalidate().draw(false);
found = true;
break;
}
}
if (found) {
this.ShowToast('Updated', `${entityType} data has been refreshed`, 'info');
}
} catch (e) {
console.debug(`Error updating row for ${entityType}:`, e);
}
},
/**
* Reload a table without page change
*/
ReloadTable(entityType, resetPage = false) {
if (!this.tables[entityType]) {
console.debug(`No table registered for entity type: ${entityType}`);
return;
}
try {
const table = this.tables[entityType].table;
if (resetPage) {
table.page(0).ajax.reload();
} else {
table.ajax.reload(null, false);
}
this.ShowToast('Updated', `${entityType} list has been refreshed`, 'info');
} catch (e) {
console.error(`Error reloading table for ${entityType}:`, e);
}
},
/**
* Update list count display
*/
UpdateListCount(entityType, count) {
try {
const selector = `.${entityType}-count`;
const countEl = document.querySelector(selector);
if (countEl) {
countEl.textContent = count;
}
} catch (e) {
console.debug(`Error updating count for ${entityType}:`, e);
}
},
/**
* Set current entity being viewed for detail updates
*/
SetCurrentEntity(entityType, entityId) {
this.currentEntity = { type: entityType, id: entityId };
console.log(`Viewing ${entityType} ID: ${entityId}`);
},
/**
* Clear current entity
*/
ClearCurrentEntity() {
this.currentEntity = null;
},
/**
* Update detail panel for current entity
*/
UpdateDetailPanel(entityType, data) {
if (!this.currentEntity || this.currentEntity.type !== entityType ||
this.currentEntity.id !== (data.id || data.accountId)) {
return;
}
try {
// Update all elements with data-field attributes
for (const [key, value] of Object.entries(data)) {
const selectors = [
`.detail-${key}`,
`[data-field="${key}"]`,
`.${entityType}-${key}`
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
this.UpdateElement(el, key, value);
}
}
}
this.ShowToast('Updated', `${entityType} data refreshed`, 'info');
} catch (e) {
console.error(`Error updating detail panel:`, e);
}
},
/**
* Update an individual element with new value
*/
UpdateElement(element, fieldName, value) {
if (!element) return;
try {
// Handle different field types
if (fieldName.includes('banned') || fieldName.includes('banned') || fieldName.includes('locked')) {
element.textContent = value ? 'Yes' : 'No';
element.className = `badge ${value ? 'bg-danger' : 'bg-success'}`;
} else if (fieldName.includes('count') || fieldName.includes('level')) {
element.textContent = value || '-';
} else if (fieldName.includes('date') || fieldName.includes('created') || fieldName.includes('updated')) {
if (value) {
element.textContent = new Date(value * 1000).toLocaleString();
}
} else {
element.textContent = value || '-';
}
} catch (e) {
console.debug(`Error updating element for field ${fieldName}:`, e);
}
},
/**
* Show toast notification
*/
ShowToast(title, message, type = 'info') {
try {
const toastEl = document.createElement('div');
toastEl.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show`;
toastEl.setAttribute('role', 'alert');
toastEl.style.cssText = 'position: fixed; bottom: 20px; right: 20px; z-index: 9999; min-width: 300px;';
toastEl.innerHTML = `
<strong>${title}</strong><br>${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.body.appendChild(toastEl);
setTimeout(() => {
toastEl.remove();
}, 4000);
} catch (e) {
console.log(`[${type}] ${title}: ${message}`);
}
}
};
// Export to global scope
window.realtimeManager = realtimeManager;

View File

@@ -0,0 +1,154 @@
{% extends "base.jinja2" %}
{% block title %}Account - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="account-view-container">
<div class="container-fluid">
<a href="/accounts" class="back-link">← Back to Accounts</a>
<div class="card">
<div class="card-header">
<h2>Account #{{ account.id }} - <span class="account-name">{{ account.name }}</span></h2>
<p class="text-muted">View account details and manage settings</p>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Account ID</div>
<div class="detail-value">{{ account.id }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Username</div>
<div class="detail-value"><span class="account-name">{{ account.name }}</span></div>
</div>
<div class="detail-item">
<div class="detail-label">Created</div>
<div class="detail-value">{{ account.created_at }}</div>
</div>
<div class="detail-item">
<div class="detail-label">GM Level</div>
<div class="detail-value">
{% if account.gm_level > 0 %}
<span class="account-gm-level badge badge-gm">GM {{ account.gm_level }}</span>
{% else %}
<span class="account-gm-level badge badge-inactive">User</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Ban Status</div>
<div class="detail-value">
{% if account.banned %}
<span class="account-banned badge badge-banned">BANNED</span>
{% else %}
<span class="account-banned badge badge-active">Active</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Lock Status</div>
<div class="detail-value">
{% if account.locked %}
<span class="account-locked badge badge-locked">LOCKED</span>
{% else %}
<span class="account-locked badge badge-active">Unlocked</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Mute Expires</div>
<div class="detail-value">
<span class="account-mute-expire">
{% if account.mute_expire > 0 %}
Muted until {{ account.mute_expire }}
{% else %}
Not muted
{% endif %}
</span>
</div>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="EditAccount()">Edit Account</button>
{% if not account.banned %}
<button class="btn btn-danger" onclick="BanAccount()">Ban Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnbanAccount()">Unban Account</button>
{% endif %}
{% if not account.locked %}
<button class="btn btn-danger" onclick="LockAccount()">Lock Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnlockAccount()">Unlock Account</button>
{% endif %}
</div>
</div>
</div>
<!-- TODO: Add modals for edit, ban, lock operations -->
<!-- TODO: Add character list for this account -->
<!-- TODO: Add login history -->
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
// Set current account for real-time detail panel updates
document.addEventListener('DOMContentLoaded', () => {
realtimeManager.SetCurrentEntity('account', {{ account.id }});
// Clean up when leaving the page
window.addEventListener('beforeunload', () => {
realtimeManager.ClearCurrentEntity();
});
});
function EditAccount() {
alert("Edit functionality coming soon");
// TODO: Open edit modal
}
function BanAccount() {
if (confirm("Are you sure you want to ban this account?")) {
alert("Ban functionality coming soon");
// TODO: Call ban API endpoint
// After successful ban, call: BroadcastAccountUpdate({{ account.id }})
}
}
function UnbanAccount() {
if (confirm("Are you sure you want to unban this account?")) {
alert("Unban functionality coming soon");
// TODO: Call unban API endpoint
// After successful unban, call: BroadcastAccountUpdate({{ account.id }})
}
}
function LockAccount() {
if (confirm("Are you sure you want to lock this account?")) {
alert("Lock functionality coming soon");
// TODO: Call lock API endpoint
// After successful lock, call: BroadcastAccountUpdate({{ account.id }})
}
}
function UnlockAccount() {
if (confirm("Are you sure you want to unlock this account?")) {
alert("Unlock functionality coming soon");
// TODO: Call unlock API endpoint
// After successful unlock, call: BroadcastAccountUpdate({{ account.id }})
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.jinja2" %}
{% block title %}Accounts - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="accounts-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Accounts</h2>
<p class="text-muted">View and manage user accounts - <span class="account-count">0</span> total</p>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="accountsTable" class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Banned</th>
<th>Locked</th>
<th>GM Level</th>
<th>Mute Expires</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#accountsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/accounts',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
},
error: function(xhr, error, thrown) {
console.error('Error loading accounts:', error);
if (xhr.status === 401) {
window.location.href = '/login';
}
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{
data: 'banned',
render: function(data) {
return data ? '<span class="badge badge-banned">Banned</span>' : '<span class="badge bg-success">Active</span>';
}
},
{
data: 'locked',
render: function(data) {
return data ? '<span class="badge badge-locked">Locked</span>' : '<span class="badge bg-secondary">Unlocked</span>';
}
},
{
data: 'gm_level',
render: function(data) {
if (data === 0) return '-';
return '<span class="badge badge-gm">Level ' + data + '</span>';
}
},
{
data: 'mute_expire',
render: function(data) {
if (data === 0) return 'Not Muted';
const now = Math.floor(Date.now() / 1000);
const isMuted = data > now;
const date = new Date(data * 1000);
const dateStr = date.toLocaleString();
if (isMuted) {
return '<span class="badge bg-danger">Muted until ' + dateStr + '</span>';
} else {
return '<span class="badge bg-success">Expired ' + dateStr + '</span>';
}
}
},
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewAccount(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editAccount(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('account', table);
// Update account count when DataTable loads
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.account-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewAccount(id) {
window.location.href = '/accounts/' + id;
}
function editAccount(id) {
alert('Edit account: ' + id);
// TODO: Implement account edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DarkflameServer{% endblock %}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link href="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.css" rel="stylesheet" integrity="sha384-XMNDGLb5fN9IqhIrVXOAtGKcz4KCr+JSHXGZ1TDXQPDukbEAfmLPjHdCXhgK93fv" crossorigin="anonymous">
<link rel="stylesheet" href="/css/dashboard.css">
{% block css %}{% endblock %}
</head>
<body class="d-flex bg-dark text-white">
{% if username and username != "" %}
{% include "header.jinja2" %}
{% endif %}
<div class="container-fluid py-3">
{% block content_before %}{% endblock %}
{% block content %}{% endblock %}
{% block content_after %}{% endblock %}
</div>
<footer class="mt-5 pt-5 border-top border-secondary text-center pb-3">
{% block footer %}
<p class="text-muted small">DarkflameServer Dashboard &copy; 2024</p>
{% endblock %}
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.js" integrity="sha384-BPUXtS4tH3onFfu5m+dPbFfpLOXQwSWGwrsNWxOAAwqqJx6tJHhFkGF6uitrmEui" crossorigin="anonymous"></script>
<script>
// Global logout function (available on all pages)
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
// Setup logout button
document.addEventListener('DOMContentLoaded', () => {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
}
});
</script>
<!-- Load dashboard.js only on dashboard pages (not on login) -->
<script>
const isLoginPage = document.querySelector('.login-container') !== null;
if (!isLoginPage) {
const script = document.createElement('script');
script.src = '/js/dashboard.js';
document.head.appendChild(script);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,119 @@
{% extends "base.jinja2" %}
{% block title %}Bug Reports - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="bug-reports-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Bug Reports <span class="bug_report-count">0</span></h2>
<p class="text-muted">View and manage bug reports from players</p>
</div>
<div class="card-body">
<table id="bugReportsTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Player</th>
<th>Client Version</th>
<th>Submitted</th>
<th>Report Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#bugReportsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/bug_reports',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'other_player_id' },
{ data: 'client_version' },
{
data: 'submitted',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'body',
render: function(data) {
return '<span class="report-preview" title="' + (data || '') + '">' + (data || '-') + '</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewReport(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteReport(' + data + ')" title="Delete">🗑️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'desc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('bug_report', table);
// Update bug report count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.bug_report-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewReport(id) {
alert('View report: ' + id);
// TODO: Implement report view modal
}
function deleteReport(id) {
if (confirm('Are you sure you want to delete this report?')) {
alert('Delete report: ' + id);
// TODO: Implement report deletion
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends "base.jinja2" %}
{% block title %}Characters - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="characters-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Characters</h2>
<p class="text-muted">View and manage player characters - <span class="character-count">0</span> total</p>
</div>
<div class="card-body">
<table id="charactersTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Account</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#charactersTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/characters',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'account_name' },
{
data: 'last_login',
render: function(data) {
if (data === 0) return 'Never';
const date = new Date(data * 1000);
return date.toLocaleString();
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewCharacter(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editCharacter(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('character', table);
// Update character count when DataTable loads
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.character-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize WebSocket realtime updates
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewCharacter(id) {
alert('View character: ' + id);
// TODO: Implement character view modal
}
function editCharacter(id) {
alert('Edit character: ' + id);
// TODO: Implement character edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{# Navigation #}
<nav class="navbar navbar-dark bg-dark flex-column">
<div class="p-3">
<a class="navbar-brand fw-bold" href="/">🎮 DarkflameServer</a>
</div>
<ul class="navbar-nav flex-column w-100 flex-grow-1 p-3">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/characters">Characters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/play_keys">Play Keys</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/properties">Properties</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/bug_reports">Bug Reports</a>
</li>
<li class="nav-item mt-auto">
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,35 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard - DarkflameServer{% endblock %}
{% block content %}
<div class="container-fluid py-5 px-4">
<div class="dashboard-header mb-4">
<div>
<h1>Welcome to DarkflameServer Dashboard</h1>
<p class="text-muted">Real-time server monitoring and administration</p>
</div>
</div>
<div class="row g-3">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
</div>
{% include "world_instances.jinja2" %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Initialize dashboard when dashboard.js is loaded
document.addEventListener('DOMContentLoaded', () => {
// Wait a bit for dashboard.js to load
setTimeout(() => {
if (typeof checkAuthentication === 'function') {
checkAuthentication();
}
}, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard Login - DarkflameServer{% endblock %}
{% block css %}
<link rel="stylesheet" href="/css/login.css">
{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-wrapper">
<div class="login-left">
<div class="login-branding">
<div class="logo">⚙️</div>
<h1>DarkflameServer</h1>
<p>Administration Dashboard</p>
</div>
<div class="login-info">
<h3>Welcome Back</h3>
<p>Manage your DarkflameServer infrastructure with ease. Secure access to server administration tools.</p>
</div>
</div>
<div class="login-right">
<div class="login-form-wrapper">
<h2>Sign In</h2>
<div id="alert" class="alert-box" role="alert" style="display: none;"></div>
<form id="loginForm" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
<div class="form-focus"></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required maxlength="40" placeholder="Enter your password">
<div class="form-focus"></div>
</div>
<div class="form-checkbox">
<input type="checkbox" id="rememberMe" name="rememberMe">
<label for="rememberMe">Remember me for 30 days</label>
</div>
<button type="submit" class="btn-login" id="loginBtn">
<span id="loading" class="spinner" style="display: none;"></span>
<span class="btn-text">Sign In</span>
</button>
</form>
<div class="login-footer">
<p>Contact your administrator if you don't have access</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/login.js"></script>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends "base.jinja2" %}
{% block title %}Play Keys - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="play-keys-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Play Keys <span class="play_key-count">0</span></h2>
<p class="text-muted">View and manage play keys</p>
</div>
<div class="card-body">
<table id="playKeysTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Key String</th>
<th>Uses Remaining</th>
<th>Created</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#playKeysTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/play_keys',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'key_string' },
{ data: 'key_uses' },
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'active',
render: function(data) {
return data ? '<span class="badge badge-active">Active</span>' : '<span class="badge badge-inactive">Inactive</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewKey(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editKey(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('play_key', table);
// Update play key count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.play_key-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewKey(id) {
alert('View key: ' + id);
// TODO: Implement key view modal
}
function editKey(id) {
alert('Edit key: ' + id);
// TODO: Implement key edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends "base.jinja2" %}
{% block title %}Properties - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="properties-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Properties <span class="property-count">0</span></h2>
<p class="text-muted">View and manage player properties</p>
</div>
<div class="card-body">
<table id="propertiesTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Owner ID</th>
<th>Moderation Status</th>
<th>Reputation</th>
<th>Zone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#propertiesTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/properties',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'owner_id' },
{
data: 'mod_approved',
render: function(data) {
return data ? '<span class="badge badge-approved">Approved</span>' : '<span class="badge badge-pending">Pending</span>';
}
},
{ data: 'reputation' },
{ data: 'zone_id' },
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewProperty(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editProperty(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('property', table);
// Update property count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.property-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewProperty(id) {
alert('View property: ' + id);
// TODO: Implement property view modal
}
function editProperty(id) {
alert('Edit property: ' + id);
// TODO: Implement property edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Server Status</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Auth Server</span>
{% if auth.online %}
<span class="badge badge-active" id="auth-status">Online</span>
{% else %}
<span class="badge badge-banned" id="auth-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Chat Server</span>
{% if chat.online %}
<span class="badge badge-active" id="chat-status">Online</span>
{% else %}
<span class="badge badge-banned" id="chat-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Active Worlds</span>
<span class="badge badge-primary" id="world-count">{{ length(worlds) }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Online Players</span>
<span class="badge badge-primary" id="online-players">{{ stats.onlinePlayers }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Total Accounts</span>
<span class="badge badge-primary" id="total-accounts">{{ stats.totalAccounts }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Total Characters</span>
<span class="badge badge-primary" id="total-characters">{{ stats.totalCharacters }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div class="card mt-4">
<div class="card-header">
<h2>Active World Instances</h2>
</div>
<div class="card-body">
<div id="world-list">
{% if length(worlds) == 0 %}
<p class="text-muted text-center mb-0">No active world instances</p>
{% else %}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Zone</th>
<th>Instance</th>
<th>Clone</th>
<th>Players</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for world in worlds %}
<tr>
<td>{{ world.mapID }}</td>
<td>{{ world.instanceID }}</td>
<td>{{ world.cloneID }}</td>
<td><span class="badge badge-primary">{{ world.players }}</span></td>
<td>{% if world.isPrivate %}<span class="badge badge-warning">Private</span>{% else %}<span class="badge badge-success">Public</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@
#include <optional>
#include <string>
#include <string_view>
#include "json.hpp"
enum class eGameMasterLevel : uint8_t;
@@ -39,6 +40,30 @@ public:
virtual void UpdateAccountGmLevel(const uint32_t accountId, const eGameMasterLevel gmLevel) = 0;
virtual uint32_t GetAccountCount() = 0;
// Login attempt tracking methods
// Record a failed login attempt
virtual void RecordFailedAttempt(const uint32_t accountId) = 0;
// Clear failed login attempts and update last login time
virtual void ClearFailedAttempts(const uint32_t accountId) = 0;
// Set account lockout
virtual void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) = 0;
// Check if account is locked out
virtual bool IsLockedOut(const uint32_t accountId) = 0;
// Get failed attempt count
virtual uint8_t GetFailedAttempts(const uint32_t accountId) = 0;
// Get paginated list of accounts with optional search/filtering for DataTables
// Returns a JSON object with the account data and metadata
virtual nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
// Get a single account by ID
// Returns a JSON object with the account details
virtual nlohmann::json GetAccountById(uint32_t accountId) = 0;
};
#endif //!__IACCOUNTS__H__

View File

@@ -2,6 +2,7 @@
#define __IBUGREPORTS__H__
#include <cstdint>
#include <string>
#include <string_view>
class IBugReports {
@@ -16,5 +17,9 @@ public:
// Add a new bug report to the database.
virtual void InsertNewBugReport(const Info& info) = 0;
// Get paginated list of bug reports with optional search/filtering for DataTables
// Returns a JSON-formatted string with the bug report data and metadata
virtual std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IBUGREPORTS__H__

View File

@@ -33,6 +33,9 @@ public:
// Get the character ids for the given account.
virtual std::vector<LWOOBJID> GetAccountCharacterIds(const LWOOBJID accountId) = 0;
// Get the total number of characters in the database.
virtual uint32_t GetCharacterCount() = 0;
// Insert a new character into the database.
virtual void InsertNewCharacter(const ICharInfo::Info info) = 0;

View File

@@ -15,6 +15,10 @@ public:
// Insert the character xml for the given character id.
virtual void InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) = 0;
// Get paginated list of characters with optional search/filtering for DataTables
// Returns a JSON-formatted string with the character data and metadata
virtual std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__ICHARXML__H__

View File

@@ -3,6 +3,8 @@
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
class IPlayKeys {
public:
@@ -10,6 +12,10 @@ public:
// Optional of bool may seem pointless, however the optional indicates if the playkey exists
// and the bool indicates if the playkey is active.
virtual std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) = 0;
// Get paginated list of play keys with optional search/filtering for DataTables
// Returns a JSON-formatted string with the play key data and metadata
virtual std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IPLAYKEYS__H__

View File

@@ -65,5 +65,9 @@ public:
// Insert a new property into the database.
virtual void InsertNewProperty(const IProperty::Info& info, const uint32_t templateId, const LWOZONEID& zoneId) = 0;
// Get paginated list of properties with optional search/filtering for DataTables
// Returns a JSON-formatted string with the property data and metadata
virtual std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) = 0;
};
#endif //!__IPROPERTY__H__

View File

@@ -74,6 +74,7 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;
void SetCharacterName(const LWOOBJID characterId, const std::string_view name) override;
@@ -93,6 +94,7 @@ public:
void RemoveModel(const LWOOBJID& modelId) override;
void UpdatePerformanceCost(const LWOZONEID& zoneId, const float performanceCost) override;
void InsertNewBugReport(const IBugReports::Info& info) override;
std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
void InsertCheatDetection(const IPlayerCheatDetections::Info& info) override;
void InsertNewMail(const MailInfo& mail) override;
void InsertNewUgcModel(
@@ -117,6 +119,7 @@ public:
void InsertDefaultPersistentId() override;
std::optional<uint32_t> GetDonationTotal(const uint32_t activityId) override;
std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) override;
std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<IUgc::Model> GetUgcModels(const LWOOBJID& propertyId) override;
void AddIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
void RemoveIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
@@ -140,10 +143,19 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
uint32_t GetCharacterCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
nlohmann::json GetAccountById(uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
sql::PreparedStatement* CreatePreppedStmt(const std::string& query);
private:

View File

@@ -1,6 +1,7 @@
#include "MySQLDatabase.h"
#include "eGameMasterLevel.h"
#include "json.hpp"
std::optional<IAccounts::Info> MySQLDatabase::GetAccountInfo(const std::string_view username) {
auto result = ExecuteSelect("SELECT id, password, banned, locked, play_key_id, gm_level, mute_expire FROM accounts WHERE name = ? LIMIT 1;", username);
@@ -45,3 +46,142 @@ uint32_t MySQLDatabase::GetAccountCount() {
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM accounts;");
return res->next() ? res->getUInt("count") : 0;
}
void MySQLDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void MySQLDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = NOW() WHERE id = ?;", accountId);
}
void MySQLDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = FROM_UNIXTIME(?) WHERE id = ?;", lockoutUntil, accountId);
}
bool MySQLDatabase::IsLockedOut(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return false;
}
// If lockout_time is set and in the future, account is locked
const char* lockoutTime = result->getString("lockout_time").c_str();
if (lockoutTime == nullptr || strlen(lockoutTime) == 0) {
return false;
}
// Simplified check - if lockout_time exists and is not null, it's locked
return true;
}
uint8_t MySQLDatabase::GetFailedAttempts(const uint32_t accountId) {
auto result = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (!result->next()) {
return 0;
}
return result->getUInt("failed_attempts");
}
nlohmann::json MySQLDatabase::GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "banned"; break;
case 3: orderColumnName = "locked"; break;
case 4: orderColumnName = "gm_level"; break;
case 5: orderColumnName = "mute_expire"; break;
case 6: orderColumnName = "created_at"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM accounts;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM accounts WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json accountsArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
accountsArray.push_back(account);
}
nlohmann::json response = {
{"draw", 1},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", accountsArray}
};
return response;
}
nlohmann::json MySQLDatabase::GetAccountById(uint32_t accountId) {
try {
const std::string query = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts WHERE id = ?;";
auto result = ExecuteSelect(query, accountId);
if (!result->next()) {
return nlohmann::json{{"error", "Account not found"}};
}
nlohmann::json account = {
{"id", result->getUInt("id")},
{"name", result->getString("name")},
{"banned", result->getBoolean("banned")},
{"locked", result->getBoolean("locked")},
{"gm_level", result->getInt("gm_level")},
{"mute_expire", result->getUInt64("mute_expire")},
{"created_at", result->getString("created_at")}
};
return account;
} catch (const sql::SQLException& e) {
LOG_DEBUG("SQL Error: %s", e.what());
return nlohmann::json{{"error", "Database error"}};
}
}

View File

@@ -4,3 +4,77 @@ void MySQLDatabase::InsertNewBugReport(const IBugReports::Info& info) {
ExecuteInsert("INSERT INTO `bug_reports`(body, client_version, other_player_id, selection, reporter_id) VALUES (?, ?, ?, ?, ?)",
info.body, info.clientVersion, info.otherPlayer, info.selection, info.characterId);
}
#include "json.hpp"
std::string MySQLDatabase::GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, body, client_version, other_player_id, selection, submitted FROM bug_reports";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE body LIKE CONCAT('%', ?, '%') OR other_player_id LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "other_player_id"; break;
case 2: orderColumnName = "client_version"; break;
case 3: orderColumnName = "submitted"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM bug_reports;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM bug_reports WHERE body LIKE CONCAT('%', ?, '%') OR other_player_id LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json reportsArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json report = {
{"id", result->getUInt("id")},
{"other_player_id", result->getString("other_player_id")},
{"client_version", result->getString("client_version")},
{"selection", result->getString("selection")},
{"submitted", result->getString("submitted")},
{"body", result->getString("body")}
};
reportsArray.push_back(report);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", reportsArray}
};
return response.dump();
}

View File

@@ -52,6 +52,11 @@ std::vector<LWOOBJID> MySQLDatabase::GetAccountCharacterIds(const LWOOBJID accou
return toReturn;
}
uint32_t MySQLDatabase::GetCharacterCount() {
auto res = ExecuteSelect("SELECT COUNT(*) as count FROM charinfo;");
return res->next() ? res->getUInt("count") : 0;
}
void MySQLDatabase::InsertNewCharacter(const ICharInfo::Info info) {
ExecuteInsert(
"INSERT INTO `charinfo`(`id`, `account_id`, `name`, `pending_name`, `needs_rename`, `last_login`) VALUES (?,?,?,?,?,?)",

View File

@@ -17,3 +17,75 @@ void MySQLDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string_
void MySQLDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {
ExecuteInsert("INSERT INTO `charxml` (`id`, `xml_data`) VALUES (?,?)", characterId, lxfml);
}
#include "json.hpp"
std::string MySQLDatabase::GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT c.id, c.name, c.account_id, c.last_login, a.name as account_name FROM charinfo c JOIN accounts a ON c.account_id = a.id";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE c.name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "c.id";
switch (orderColumn) {
case 0: orderColumnName = "c.id"; break;
case 1: orderColumnName = "c.name"; break;
case 2: orderColumnName = "a.name"; break;
case 3: orderColumnName = "c.last_login"; break;
default: orderColumnName = "c.id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM charinfo;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM charinfo WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json charactersArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json character = {
{"id", result->getUInt64("id")},
{"name", result->getString("name")},
{"account_name", result->getString("account_name")},
{"last_login", result->getUInt64("last_login")}
};
charactersArray.push_back(character);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", charactersArray}
};
return response.dump();
}

View File

@@ -9,3 +9,77 @@ std::optional<bool> MySQLDatabase::IsPlaykeyActive(const int32_t playkeyId) {
return keyCheckRes->getBoolean("active");
}
#include "json.hpp"
std::string MySQLDatabase::GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, key_string, key_uses, created_at, active FROM play_keys";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE key_string LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "key_string"; break;
case 2: orderColumnName = "key_uses"; break;
case 3: orderColumnName = "created_at"; break;
case 4: orderColumnName = "active"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM play_keys;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM play_keys WHERE key_string LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json keysArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json key = {
{"id", result->getUInt("id")},
{"key_string", result->getString("key_string")},
{"key_uses", result->getUInt("key_uses")},
{"created_at", result->getString("created_at")},
{"active", result->getBoolean("active")}
};
keysArray.push_back(key);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", keysArray}
};
return response.dump();
}

View File

@@ -195,3 +195,79 @@ std::optional<IProperty::Info> MySQLDatabase::GetPropertyInfo(const LWOOBJID id)
return ReadPropertyInfo(propertyEntry);
}
#include "json.hpp"
std::string MySQLDatabase::GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, owner_id, name, mod_approved, reputation, zone_id FROM properties";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE CONCAT('%', ?, '%')";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "owner_id"; break;
case 3: orderColumnName = "mod_approved"; break;
case 4: orderColumnName = "reputation"; break;
case 5: orderColumnName = "zone_id"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ?, ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM properties;";
auto totalCountResult = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult->next() ? totalCountResult->getUInt("count") : 0;
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM properties WHERE name LIKE CONCAT('%', ?, '%');";
auto filteredCountResult = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult->next() ? filteredCountResult->getUInt("count") : 0;
}
// Execute main query
std::unique_ptr<sql::ResultSet> result;
if (!search.empty()) {
result = ExecuteSelect(mainQuery, search, start, length);
} else {
result = ExecuteSelect(mainQuery, start, length);
}
// Build response JSON
nlohmann::json propertiesArray = nlohmann::json::array();
while (result->next()) {
nlohmann::json property = {
{"id", result->getUInt64("id")},
{"owner_id", result->getUInt64("owner_id")},
{"name", result->getString("name")},
{"mod_approved", result->getBoolean("mod_approved")},
{"reputation", result->getUInt64("reputation")},
{"zone_id", result->getUInt("zone_id")}
};
propertiesArray.push_back(property);
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", propertiesArray}
};
return response.dump();
}

View File

@@ -58,6 +58,7 @@ public:
std::optional<IAccounts::Info> GetAccountInfo(const std::string_view username) override;
void InsertNewCharacter(const ICharInfo::Info info) override;
void InsertCharacterXml(const LWOOBJID accountId, const std::string_view lxfml) override;
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<LWOOBJID> GetAccountCharacterIds(LWOOBJID accountId) override;
void DeleteCharacter(const LWOOBJID characterId) override;
void SetCharacterName(const LWOOBJID characterId, const std::string_view name) override;
@@ -77,6 +78,7 @@ public:
void RemoveModel(const LWOOBJID& modelId) override;
void UpdatePerformanceCost(const LWOZONEID& zoneId, const float performanceCost) override;
void InsertNewBugReport(const IBugReports::Info& info) override;
std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
void InsertCheatDetection(const IPlayerCheatDetections::Info& info) override;
void InsertNewMail(const MailInfo& mail) override;
void InsertNewUgcModel(
@@ -101,6 +103,7 @@ public:
void InsertDefaultPersistentId() override;
std::optional<uint32_t> GetDonationTotal(const uint32_t activityId) override;
std::optional<bool> IsPlaykeyActive(const int32_t playkeyId) override;
std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
std::vector<IUgc::Model> GetUgcModels(const LWOOBJID& propertyId) override;
void AddIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
void RemoveIgnore(const LWOOBJID playerId, const LWOOBJID ignoredPlayerId) override;
@@ -124,10 +127,19 @@ public:
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override;
void DeleteUgcBuild(const LWOOBJID bigId) override;
uint32_t GetAccountCount() override;
uint32_t GetCharacterCount() override;
void RecordFailedAttempt(const uint32_t accountId) override;
void ClearFailedAttempts(const uint32_t accountId) override;
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override;
bool IsLockedOut(const uint32_t accountId) override;
uint8_t GetFailedAttempts(const uint32_t accountId) override;
nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
nlohmann::json GetAccountById(uint32_t accountId) override;
bool IsNameInUse(const std::string_view name) override;
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override;
std::optional<IUgc::Model> GetUgcModel(const LWOOBJID ugcId) override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override;
private:
CppSQLite3Statement CreatePreppedStmt(const std::string& query);

View File

@@ -2,6 +2,7 @@
#include "eGameMasterLevel.h"
#include "Database.h"
#include "json.hpp"
std::optional<IAccounts::Info> SQLiteDatabase::GetAccountInfo(const std::string_view username) {
auto [_, result] = ExecuteSelect("SELECT * FROM accounts WHERE name = ? LIMIT 1", username);
@@ -48,3 +49,137 @@ uint32_t SQLiteDatabase::GetAccountCount() {
return res.getIntField("count");
}
void SQLiteDatabase::RecordFailedAttempt(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = failed_attempts + 1 WHERE id = ?;", accountId);
}
void SQLiteDatabase::ClearFailedAttempts(const uint32_t accountId) {
ExecuteUpdate("UPDATE accounts SET failed_attempts = 0, lockout_time = NULL, last_login = CURRENT_TIMESTAMP WHERE id = ?;", accountId);
}
void SQLiteDatabase::SetLockout(const uint32_t accountId, const int64_t lockoutUntil) {
ExecuteUpdate("UPDATE accounts SET lockout_time = datetime(?, 'unixepoch') WHERE id = ?;", lockoutUntil, accountId);
}
bool SQLiteDatabase::IsLockedOut(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT lockout_time FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return false;
}
const char* lockoutTime = result.getStringField("lockout_time");
if (lockoutTime == nullptr || strlen(lockoutTime) == 0 || strcmp(lockoutTime, "0") == 0) {
return false;
}
// If lockout_time is set and in the future, account is locked
// For now, simplified check - if lockout_time exists, it's locked
return true;
}
uint8_t SQLiteDatabase::GetFailedAttempts(const uint32_t accountId) {
auto [_, result] = ExecuteSelect("SELECT failed_attempts FROM accounts WHERE id = ?;", accountId);
if (result.eof()) {
return 0;
}
return result.getIntField("failed_attempts");
}
nlohmann::json SQLiteDatabase::GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, name, banned, locked, gm_level, mute_expire, created_at FROM accounts";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "banned"; break;
case 3: orderColumnName = "locked"; break;
case 4: orderColumnName = "gm_level"; break;
case 5: orderColumnName = "mute_expire"; break;
case 6: orderColumnName = "created_at"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM accounts;";
auto [_, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM accounts WHERE name LIKE '%' || ? || '%';";
auto [__, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json accountsArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json account = {
{"id", result.getIntField("id")},
{"name", result.getStringField("name")},
{"banned", result.getIntField("banned")},
{"locked", result.getIntField("locked")},
{"gm_level", result.getIntField("gm_level")},
{"mute_expire", result.getInt64Field("mute_expire")},
{"created_at", result.getStringField("created_at")}
};
accountsArray.push_back(account);
result.nextRow();
}
nlohmann::json response = {
{"draw", 1},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", accountsArray}
};
return response;
}
nlohmann::json SQLiteDatabase::GetAccountById(uint32_t accountId) {
try {
auto [_, result] = ExecuteSelect("SELECT * FROM accounts WHERE id = ? LIMIT 1;", accountId);
if (result.eof()) {
return nlohmann::json{{"error", "Account not found"}};
}
nlohmann::json account = {
{"id", result.getIntField("id")},
{"name", result.getStringField("name")},
{"banned", result.getIntField("banned")},
{"locked", result.getIntField("locked")},
{"gm_level", result.getIntField("gm_level")},
{"mute_expire", result.getInt64Field("mute_expire")},
{"created_at", result.getStringField("created_at")}
};
return account;
} catch (const CppSQLite3Exception& e) {
LOG_DEBUG("SQLite Error: %s", e.errorMessage());
return nlohmann::json{{"error", "Database error"}};
}
}

View File

@@ -4,3 +4,75 @@ void SQLiteDatabase::InsertNewBugReport(const IBugReports::Info& info) {
ExecuteInsert("INSERT INTO `bug_reports`(body, client_version, other_player_id, selection, reporter_id) VALUES (?, ?, ?, ?, ?)",
info.body, info.clientVersion, info.otherPlayer, info.selection, info.characterId);
}
#include "json.hpp"
std::string SQLiteDatabase::GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, body, client_version, other_player_id, selection, submitted FROM bug_reports";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE body LIKE '%' || ? || '%' OR other_player_id LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "other_player_id"; break;
case 2: orderColumnName = "client_version"; break;
case 3: orderColumnName = "submitted"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM bug_reports;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM bug_reports WHERE body LIKE '%' || ? || '%' OR other_player_id LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json reportsArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json report = {
{"id", result.getIntField("id")},
{"other_player_id", result.getStringField("other_player_id")},
{"client_version", result.getStringField("client_version")},
{"selection", result.getStringField("selection")},
{"submitted", result.getStringField("submitted")},
{"body", result.getStringField("body")}
};
reportsArray.push_back(report);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", reportsArray}
};
return response.dump();
}

View File

@@ -55,6 +55,13 @@ std::vector<LWOOBJID> SQLiteDatabase::GetAccountCharacterIds(const LWOOBJID acco
return toReturn;
}
uint32_t SQLiteDatabase::GetCharacterCount() {
auto [_, res] = ExecuteSelect("SELECT COUNT(*) as count FROM charinfo;");
if (res.eof()) return 0;
return res.getIntField("count");
}
void SQLiteDatabase::InsertNewCharacter(const ICharInfo::Info info) {
ExecuteInsert(
"INSERT INTO `charinfo`(`id`, `account_id`, `name`, `pending_name`, `needs_rename`, `last_login`, `prop_clone_id`) VALUES (?,?,?,?,?,?,(SELECT IFNULL(MAX(`prop_clone_id`), 0) + 1 FROM `charinfo`))",

View File

@@ -17,3 +17,73 @@ void SQLiteDatabase::UpdateCharacterXml(const LWOOBJID charId, const std::string
void SQLiteDatabase::InsertCharacterXml(const LWOOBJID characterId, const std::string_view lxfml) {
ExecuteInsert("INSERT INTO `charxml` (`id`, `xml_data`) VALUES (?,?)", characterId, lxfml);
}
#include "json.hpp"
std::string SQLiteDatabase::GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT c.id, c.name, c.account_id, c.last_login, a.name as account_name FROM charinfo c JOIN accounts a ON c.account_id = a.id";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE c.name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "c.id";
switch (orderColumn) {
case 0: orderColumnName = "c.id"; break;
case 1: orderColumnName = "c.name"; break;
case 2: orderColumnName = "a.name"; break;
case 3: orderColumnName = "c.last_login"; break;
default: orderColumnName = "c.id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM charinfo;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM charinfo WHERE name LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json charactersArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json character = {
{"id", result.getInt64Field("id")},
{"name", result.getStringField("name")},
{"account_name", result.getStringField("account_name")},
{"last_login", result.getInt64Field("last_login")}
};
charactersArray.push_back(character);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", charactersArray}
};
return response.dump();
}

View File

@@ -9,3 +9,75 @@ std::optional<bool> SQLiteDatabase::IsPlaykeyActive(const int32_t playkeyId) {
return keyCheckRes.getIntField("active");
}
#include "json.hpp"
std::string SQLiteDatabase::GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, key_string, key_uses, created_at, active FROM play_keys";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE key_string LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "key_string"; break;
case 2: orderColumnName = "key_uses"; break;
case 3: orderColumnName = "created_at"; break;
case 4: orderColumnName = "active"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM play_keys;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM play_keys WHERE key_string LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json keysArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json key = {
{"id", result.getIntField("id")},
{"key_string", result.getStringField("key_string")},
{"key_uses", result.getIntField("key_uses")},
{"created_at", result.getStringField("created_at")},
{"active", result.getIntField("active")}
};
keysArray.push_back(key);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", keysArray}
};
return response.dump();
}

View File

@@ -197,3 +197,77 @@ std::optional<IProperty::Info> SQLiteDatabase::GetPropertyInfo(const LWOOBJID id
return ReadPropertyInfo(propertyEntry);
}
#include "json.hpp"
std::string SQLiteDatabase::GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search, uint32_t orderColumn, bool orderAsc) {
// Build base query
std::string baseQuery = "SELECT id, owner_id, name, mod_approved, reputation, zone_id FROM properties";
std::string whereClause;
std::string orderClause;
// Add search filter if provided
if (!search.empty()) {
whereClause = " WHERE name LIKE '%' || ? || '%'";
}
// Map column indices to database columns
std::string orderColumnName = "id";
switch (orderColumn) {
case 0: orderColumnName = "id"; break;
case 1: orderColumnName = "name"; break;
case 2: orderColumnName = "owner_id"; break;
case 3: orderColumnName = "mod_approved"; break;
case 4: orderColumnName = "reputation"; break;
case 5: orderColumnName = "zone_id"; break;
default: orderColumnName = "id";
}
orderClause = " ORDER BY " + orderColumnName + (orderAsc ? " ASC" : " DESC");
// Build the main query
std::string mainQuery = baseQuery + whereClause + orderClause + " LIMIT ? OFFSET ?;";
// Get total count
std::string totalCountQuery = "SELECT COUNT(*) as count FROM properties;";
auto [__, totalCountResult] = ExecuteSelect(totalCountQuery);
uint32_t totalRecords = totalCountResult.eof() ? 0 : totalCountResult.getIntField("count");
// Get filtered count
uint32_t filteredRecords = totalRecords;
if (!search.empty()) {
std::string filteredCountQuery = "SELECT COUNT(*) as count FROM properties WHERE name LIKE '%' || ? || '%';";
auto [___, filteredCountResult] = ExecuteSelect(filteredCountQuery, search);
filteredRecords = filteredCountResult.eof() ? 0 : filteredCountResult.getIntField("count");
}
// Execute main query
auto [stmt, result] = !search.empty() ?
ExecuteSelect(mainQuery, search, length, start) :
ExecuteSelect(mainQuery, length, start);
// Build response JSON
nlohmann::json propertiesArray = nlohmann::json::array();
while (!result.eof()) {
nlohmann::json property = {
{"id", result.getInt64Field("id")},
{"owner_id", result.getInt64Field("owner_id")},
{"name", result.getStringField("name")},
{"mod_approved", result.getIntField("mod_approved")},
{"reputation", result.getInt64Field("reputation")},
{"zone_id", result.getIntField("zone_id")}
};
propertiesArray.push_back(property);
result.nextRow();
}
nlohmann::json response = {
{"draw", 0},
{"recordsTotal", totalRecords},
{"recordsFiltered", filteredRecords},
{"data", propertiesArray}
};
return response.dump();
}

View File

@@ -103,6 +103,18 @@ class TestSQLDatabase : public GameDatabase {
void InsertUgcBuild(const std::string& modules, const LWOOBJID bigId, const std::optional<LWOOBJID> characterId) override {};
void DeleteUgcBuild(const LWOOBJID bigId) override {};
uint32_t GetAccountCount() override { return 0; };
uint32_t GetCharacterCount() override { return 0; };
void RecordFailedAttempt(const uint32_t accountId) override {};
void ClearFailedAttempts(const uint32_t accountId) override {};
void SetLockout(const uint32_t accountId, const int64_t lockoutUntil) override {};
bool IsLockedOut(const uint32_t accountId) override { return false; };
uint8_t GetFailedAttempts(const uint32_t accountId) override { return 0; };
nlohmann::json GetAccountsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return nlohmann::json::object(); };
nlohmann::json GetAccountById(uint32_t accountId) override { return nlohmann::json::object(); };
std::string GetCharactersTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return "{}"; };
std::string GetPlayKeysTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return "{}"; };
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return "{}"; };
std::string GetBugReportsTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true) override { return "{}"; };
bool IsNameInUse(const std::string_view name) override { return false; };
std::optional<IPropertyContents::Model> GetModel(const LWOOBJID modelID) override { return {}; }

View File

@@ -1019,6 +1019,7 @@ void Entity::WriteBaseReplicaData(RakNet::BitStream& outBitStream, eReplicaPacke
const bool hasParent = m_ParentEntity != nullptr || m_SpawnerID != 0;
outBitStream.Write(hasParent);
if (hasParent) {
// 触るな!
if (m_ParentEntity != nullptr) outBitStream.Write(GeneralUtils::SetBit(m_ParentEntity->GetObjectID(), static_cast<uint32_t>(eObjectBits::CLIENT)));
else if (m_Spawner != nullptr && m_Spawner->m_Info.isNetwork) outBitStream.Write(m_SpawnerID);
else outBitStream.Write(GeneralUtils::SetBit(m_SpawnerID, static_cast<uint32_t>(eObjectBits::CLIENT)));
@@ -2235,11 +2236,16 @@ bool Entity::MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfo& r
const auto& objTableInfo = table->GetByID(GetLOT());
objectInfo.PushDebug<AMFStringValue>("Name") = objTableInfo.name;
objectInfo.PushDebug<AMFIntValue>("Template ID(LOT)") = GetLOT();
objectInfo.PushDebug<AMFStringValue>("Object ID") = std::to_string(GetObjectID());
objectInfo.PushDebug<AMFStringValue>("Spawner's Object ID") = std::to_string(GetSpawnerID());
objectInfo.PushDebug<AMFStringValue>("Owner override") = std::to_string(m_OwnerOverride);
objectInfo.PushDebug<AMFStringValue>("Name", "name") = objTableInfo.name;
objectInfo.PushDebug<AMFIntValue>("Template ID(LOT)", "LOT") = GetLOT();
objectInfo.PushDebug<AMFStringValue>("Object ID", "LWOOBJID") = std::to_string(GetObjectID());
objectInfo.PushDebug<AMFStringValue>("Spawner's Object ID", "LWOOBJID") = std::to_string(GetSpawnerID());
objectInfo.PushDebug<AMFStringValue>("Owner override", "LWOOBJID") = std::to_string(m_OwnerOverride);
auto& children = objectInfo.PushDebug("Child Objects");
int i = 1;
for (const auto* child : m_ChildEntities) {
if (child) children.PushDebug<AMFStringValue>("Child " + std::to_string(i++), "LWOOBJID") = std::to_string(child->GetObjectID());
}
auto& componentDetails = objectInfo.PushDebug("Component Information");
for (const auto [id, component] : m_Components) {

View File

@@ -521,6 +521,10 @@ void BaseCombatAIComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsI
void BaseCombatAIComponent::SetAiState(AiState newState) {
if (newState == this->m_State) return;
GameMessages::NotifyCombatAIStateChange stateMsg;
stateMsg.prevState = this->m_State;
stateMsg.newState = newState;
m_Parent->HandleMsg(stateMsg);
this->m_State = newState;
m_DirtyStateOrTarget = true;
Game::entityManager->SerializeEntity(m_Parent);
@@ -860,7 +864,7 @@ bool BaseCombatAIComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReport
auto& cmptType = reportInfo.info->PushDebug("Base Combat AI");
cmptType.PushDebug<AMFIntValue>("Component ID") = GetComponentID();
auto& targetInfo = cmptType.PushDebug("Current Target Info");
targetInfo.PushDebug<AMFStringValue>("Current Target ID") = std::to_string(m_Target);
targetInfo.PushDebug<AMFStringValue>("Current Target ID", "LWOOBJID") = std::to_string(m_Target);
// if (m_Target != LWOOBJID_EMPTY) {
// LWOGameMessages::ObjGetName nameMsg(m_CurrentTarget);
// SEND_GAMEOBJ_MSG(nameMsg);
@@ -901,10 +905,7 @@ bool BaseCombatAIComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReport
//}
//cmptType.PushDebug("Current Combat Role") = curState;
auto& tetherPoint = cmptType.PushDebug("Tether Point");
tetherPoint.PushDebug<AMFDoubleValue>("X") = m_StartPosition.x;
tetherPoint.PushDebug<AMFDoubleValue>("Y") = m_StartPosition.y;
tetherPoint.PushDebug<AMFDoubleValue>("Z") = m_StartPosition.z;
cmptType.PushDebug("Tether Point").PushDebug(m_StartPosition);
cmptType.PushDebug<AMFDoubleValue>("Hard Tether Radius") = m_HardTetherRadius;
cmptType.PushDebug<AMFDoubleValue>("Soft Tether Radius") = m_SoftTetherRadius;
cmptType.PushDebug<AMFDoubleValue>("Aggro Radius") = m_AggroRadius;

View File

@@ -361,17 +361,11 @@ void ControllablePhysicsComponent::SetStunImmunity(
bool ControllablePhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) {
PhysicsComponent::OnGetObjectReportInfo(reportInfo);
auto& info = reportInfo.subCategory->PushDebug("Controllable Info");
auto& info = reportInfo.subCategory->PushDebug("Controllable Physics");
auto& vel = info.PushDebug("Velocity");
vel.PushDebug<AMFDoubleValue>("x") = m_Velocity.x;
vel.PushDebug<AMFDoubleValue>("y") = m_Velocity.y;
vel.PushDebug<AMFDoubleValue>("z") = m_Velocity.z;
info.PushDebug("Velocity").PushDebug(m_Velocity);
auto& angularVelocity = info.PushDebug("Angular Velocity");
angularVelocity.PushDebug<AMFDoubleValue>("x") = m_AngularVelocity.x;
angularVelocity.PushDebug<AMFDoubleValue>("y") = m_AngularVelocity.y;
angularVelocity.PushDebug<AMFDoubleValue>("z") = m_AngularVelocity.z;
info.PushDebug("Angular Velocity").PushDebug(m_AngularVelocity);
info.PushDebug<AMFBoolValue>("Is On Ground") = m_IsOnGround;
info.PushDebug<AMFBoolValue>("Is On Rail") = m_IsOnRail;
@@ -403,12 +397,13 @@ bool ControllablePhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObject
info.PushDebug<AMFBoolValue>("Is In Bubble") = m_IsInBubble;
info.PushDebug<AMFStringValue>("Bubble Type") = StringifiedEnum::ToString(m_BubbleType).data();
info.PushDebug<AMFBoolValue>("Special Anims") = m_SpecialAnims;
info.PushDebug<AMFIntValue>("Immune To Stun Attack Count") = m_ImmuneToStunAttackCount;
info.PushDebug<AMFIntValue>("Immune To Stun Equip Count") = m_ImmuneToStunEquipCount;
info.PushDebug<AMFIntValue>("Immune To Stun Interact Count") = m_ImmuneToStunInteractCount;
info.PushDebug<AMFIntValue>("Immune To Stun Jump Count") = m_ImmuneToStunJumpCount;
info.PushDebug<AMFIntValue>("Immune To Stun Move Count") = m_ImmuneToStunMoveCount;
info.PushDebug<AMFIntValue>("Immune To Stun Turn Count") = m_ImmuneToStunTurnCount;
info.PushDebug<AMFIntValue>("Immune To Stun UseItem Count") = m_ImmuneToStunUseItemCount;
auto& immunity = info.PushDebug("Immunity Effects");
immunity.PushDebug<AMFBoolValue>("Immune to Stun Move") = m_ImmuneToStunMoveCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Turn") = m_ImmuneToStunTurnCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Attack") = m_ImmuneToStunAttackCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Use Item") = m_ImmuneToStunUseItemCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Equip") = m_ImmuneToStunEquipCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Interact") = m_ImmuneToStunInteractCount != 0;
immunity.PushDebug<AMFBoolValue>("Immune to Stun Jump") = m_ImmuneToStunJumpCount != 0;
return true;
}

View File

@@ -328,7 +328,7 @@ private:
/**
* The effect that plays while using the jetpack
*/
int32_t m_JetpackEffectID;
int32_t m_JetpackEffectID{};
/**
* The current speed multiplier, allowing an entity to run faster

View File

@@ -1146,7 +1146,7 @@ bool DestroyableComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportIn
destroyableInfo.PushDebug<AMFDoubleValue>("Explode Factor") = m_ExplodeFactor;
destroyableInfo.PushDebug<AMFBoolValue>("Has Threats") = m_HasThreats;
destroyableInfo.PushDebug<AMFStringValue>("Killer ID") = std::to_string(m_KillerID);
destroyableInfo.PushDebug<AMFStringValue>("Killer ID", "LWOOBJID") = std::to_string(m_KillerID);
// "Scripts"; idk what to do about scripts yet
auto& immuneCounts = destroyableInfo.PushDebug("Immune Counts");

View File

@@ -108,7 +108,7 @@ bool GhostComponent::OnGetGMInvis(GameMessages::GetGMInvis& gmInvisMsg) {
bool GhostComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportMsg) {
auto& cmptType = reportMsg.info->PushDebug("Ghost");
cmptType.PushDebug<AMFIntValue>("Component ID") = GetComponentID();
cmptType.PushDebug<AMFBoolValue>("Is GM Invis") = false;
cmptType.PushDebug<AMFBoolValue>("Is GM Invis") = m_IsGMInvisible;
return true;
}

View File

@@ -110,15 +110,9 @@ bool HavokVehiclePhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObject
auto& info = reportInfo.subCategory->PushDebug("Havok Vehicle Physics Info");
auto& velocity = info.PushDebug("Velocity");
velocity.PushDebug<AMFDoubleValue>("x") = m_Velocity.x;
velocity.PushDebug<AMFDoubleValue>("y") = m_Velocity.y;
velocity.PushDebug<AMFDoubleValue>("z") = m_Velocity.z;
auto& velocity = info.PushDebug("Velocity").PushDebug(m_Velocity);
auto& angularVelocity = info.PushDebug("Angular Velocity");
angularVelocity.PushDebug<AMFDoubleValue>("x") = m_AngularVelocity.x;
angularVelocity.PushDebug<AMFDoubleValue>("y") = m_AngularVelocity.y;
angularVelocity.PushDebug<AMFDoubleValue>("z") = m_AngularVelocity.z;
auto& angularVelocity = info.PushDebug("Angular Velocity").PushDebug(m_AngularVelocity);
info.PushDebug<AMFBoolValue>("Is On Ground") = m_IsOnGround;
info.PushDebug<AMFBoolValue>("Is On Rail") = m_IsOnRail;

View File

@@ -723,10 +723,6 @@ void InventoryComponent::Serialize(RakNet::BitStream& outBitStream, const bool b
for (const auto& pair : m_Equipped) {
const auto item = pair.second;
if (bIsInitialUpdate) {
AddItemSkills(item.lot);
}
outBitStream.Write(item.id);
outBitStream.Write(item.lot);
@@ -1151,14 +1147,12 @@ LOT InventoryComponent::GetConsumable() const {
void InventoryComponent::AddItemSkills(const LOT lot) {
const auto info = Inventory::FindItemComponent(lot);
const auto slot = FindBehaviorSlot(static_cast<eItemType>(info.itemType));
const auto slot = FindBehaviorSlot(info.equipLocation);
if (slot == BehaviorSlot::Invalid) {
return;
}
const auto index = m_Skills.find(slot);
const auto skill = FindSkill(lot);
SetSkill(slot, skill);
@@ -1186,7 +1180,7 @@ void InventoryComponent::FixInvisibleItems() {
void InventoryComponent::RemoveItemSkills(const LOT lot) {
const auto info = Inventory::FindItemComponent(lot);
const auto slot = FindBehaviorSlot(static_cast<eItemType>(info.itemType));
const auto slot = FindBehaviorSlot(info.equipLocation);
if (slot == BehaviorSlot::Invalid) {
return;
@@ -1198,15 +1192,31 @@ void InventoryComponent::RemoveItemSkills(const LOT lot) {
return;
}
const auto old = index->second;
const auto skillId = FindSkill(lot);
GameMessages::SendRemoveSkill(m_Parent, old);
// Only act on this slot if it still holds the skill from this item.
// Another item may have overwritten the slot since this one was equipped.
if (index->second != skillId) {
return;
}
m_Skills.erase(slot);
// Find another slot that still holds this skillID (if any).
const auto surviving = std::ranges::find_if(m_Skills, [skillId](const auto& pair) {
return pair.second == skillId;
});
// The client stores one acquiredSkillsInfo entry per skillID, tagged with the slotID
// it was originally added with. Always send RemoveSkill to clear that entry, then
// re-add with the surviving slot so the client shows it in the correct place.
GameMessages::SendRemoveSkill(m_Parent, skillId);
if (surviving != m_Skills.end()) {
GameMessages::SendAddSkill(m_Parent, skillId, surviving->first);
}
if (slot == BehaviorSlot::Primary) {
m_Skills.insert_or_assign(BehaviorSlot::Primary, 1);
GameMessages::SendAddSkill(m_Parent, 1, BehaviorSlot::Primary);
}
}
@@ -1298,23 +1308,17 @@ void InventoryComponent::RemoveDatabasePet(LWOOBJID id) {
m_Pets.erase(id);
}
BehaviorSlot InventoryComponent::FindBehaviorSlot(const eItemType type) {
switch (type) {
case eItemType::HAT:
return BehaviorSlot::Head;
case eItemType::NECK:
return BehaviorSlot::Neck;
case eItemType::LEFT_HAND:
return BehaviorSlot::Offhand;
case eItemType::RIGHT_HAND:
return BehaviorSlot::Primary;
case eItemType::CONSUMABLE:
return BehaviorSlot::Consumable;
default:
return BehaviorSlot::Invalid;
}
BehaviorSlot InventoryComponent::FindBehaviorSlot(const std::string& equipLocation) {
// Skill slot is determined by equipLocation, not itemType.
// Mapping confirmed against live captures and client data (issue #1339).
if (equipLocation == "special_r") return BehaviorSlot::Primary;
if (equipLocation == "hair") return BehaviorSlot::Head;
if (equipLocation == "special_l") return BehaviorSlot::Offhand;
if (equipLocation == "clavicle") return BehaviorSlot::Neck;
return BehaviorSlot::Invalid;
}
bool InventoryComponent::IsTransferInventory(eInventoryType type, bool includeVault) {
return type == VENDOR_BUYBACK || (includeVault && (type == VAULT_ITEMS || type == VAULT_MODELS)) || type == TEMP_ITEMS || type == TEMP_MODELS || type == MODELS_IN_BBB;
}
@@ -1655,10 +1659,28 @@ bool InventoryComponent::SetSkill(BehaviorSlot slot, uint32_t skillId) {
const auto index = m_Skills.find(slot);
if (index != m_Skills.end()) {
const auto old = index->second;
GameMessages::SendRemoveSkill(m_Parent, old);
// Only remove the old skill from the client if no other slot still holds it.
// The client's acquiredSkillsInfo is keyed by skillID (one entry per skill),
// so RemoveSkill clears it globally — sending it while another slot still uses
// the same skillID would break that slot on the client.
const auto usedElsewhere = std::ranges::any_of(m_Skills, [&](const auto& pair) {
return pair.first != slot && pair.second == old;
});
if (!usedElsewhere) {
GameMessages::SendRemoveSkill(m_Parent, old);
}
}
// Only send AddSkill if the client doesn't already know about this skillID.
// The client early-exits on duplicate AddSkill (same skillID already in
// acquiredSkillsInfo) without updating the slot — so only send when it's new.
const auto alreadyKnown = std::ranges::any_of(m_Skills, [&](const auto& pair) {
return pair.first != slot && pair.second == skillId;
});
if (!alreadyKnown) {
GameMessages::SendAddSkill(m_Parent, skillId, slot);
}
GameMessages::SendAddSkill(m_Parent, skillId, slot);
m_Skills.insert_or_assign(slot, skillId);
return true;
}
@@ -1838,9 +1860,9 @@ bool InventoryComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo
std::stringstream ss;
ss << "%[Objects_" << item->GetLot() << "_name] Slot " << item->GetSlot();
auto& slot = curInv.PushDebug(ss.str());
slot.PushDebug<AMFStringValue>("Object ID") = std::to_string(item->GetId());
slot.PushDebug<AMFIntValue>("LOT") = item->GetLot();
if (item->GetSubKey() != LWOOBJID_EMPTY) slot.PushDebug<AMFStringValue>("Subkey") = std::to_string(item->GetSubKey());
slot.PushDebug<AMFStringValue>("Object ID", "LWOOBJID") = std::to_string(item->GetId());
slot.PushDebug<AMFIntValue>("LOT", "LOT") = item->GetLot();
if (item->GetSubKey() != LWOOBJID_EMPTY) slot.PushDebug<AMFStringValue>("Subkey", "LWOOBJID") = std::to_string(item->GetSubKey());
slot.PushDebug<AMFIntValue>("Count") = item->GetCount();
slot.PushDebug<AMFIntValue>("Slot") = item->GetSlot();
slot.PushDebug<AMFBoolValue>("Bind on pickup") = item->GetInfo().isBOP;
@@ -1859,7 +1881,8 @@ bool InventoryComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo
ss << "%[Objects_" << info.lot << "_name]";
auto& equipSlot = equipped.PushDebug(ss.str());
equipSlot.PushDebug<AMFStringValue>("Location") = location;
equipSlot.PushDebug<AMFStringValue>("Object ID") = std::to_string(info.id);
equipSlot.PushDebug<AMFStringValue>("Object ID", "LWOOBJID") = std::to_string(info.id);
equipSlot.PushDebug<AMFIntValue>("LOT", "LOT") = info.lot;
equipSlot.PushDebug<AMFIntValue>("Slot") = info.slot;
equipSlot.PushDebug<AMFIntValue>("Count") = info.count;
auto& extra = equipSlot.PushDebug("Extra Info");

View File

@@ -367,11 +367,10 @@ public:
void RemoveDatabasePet(LWOOBJID id);
/**
* Returns the current behavior slot active for the passed item type
* @param type the item type to find the behavior slot for
* @return the current behavior slot active for the passed item type
* Returns the behavior slot for the given equipLocation string.
* This is the authoritative mapping used for skill slot assignment.
*/
static BehaviorSlot FindBehaviorSlot(eItemType type);
static BehaviorSlot FindBehaviorSlot(const std::string& equipLocation);
/**
* Checks if the inventory type is a temp inventory
@@ -403,6 +402,8 @@ public:
std::map<BehaviorSlot, uint32_t> GetSkills() { return m_Skills; };
void ClearSkills() { m_Skills.clear(); };
bool SetSkill(int slot, uint32_t skillId);
bool SetSkill(BehaviorSlot slot, uint32_t skillId);

View File

@@ -652,7 +652,7 @@ void PushMissions(const std::map<uint32_t, Mission*>& missions, AMFArrayValue& V
}
bool MissionComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) {
auto& missionInfo = reportInfo.info->PushDebug("Mission (Laggy)");
auto& missionInfo = reportInfo.info->PushDebug("Mission (Laggy)", "", 1);
missionInfo.PushDebug<AMFIntValue>("Component ID") = GetComponentID();
// Sort the missions so they are easier to parse and present to the end user
std::map<uint32_t, Mission*> achievements;

View File

@@ -355,8 +355,8 @@ bool ModelComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& re
cmptInfo.PushDebug<AMFStringValue>("Name") = "Objects_" + std::to_string(m_Parent->GetLOT()) + "_name";
cmptInfo.PushDebug<AMFBoolValue>("Has Unique Name") = false;
cmptInfo.PushDebug<AMFStringValue>("UGID (from item)") = std::to_string(m_userModelID);
cmptInfo.PushDebug<AMFStringValue>("UGID") = std::to_string(m_userModelID);
cmptInfo.PushDebug<AMFStringValue>("UGID (from item)", "LWOOBJID") = std::to_string(m_userModelID);
cmptInfo.PushDebug<AMFStringValue>("UGID", "LWOOBJID") = std::to_string(m_userModelID);
cmptInfo.PushDebug<AMFStringValue>("Description") = "";
cmptInfo.PushDebug<AMFIntValue>("Behavior Count") = m_Behaviors.size();

View File

@@ -535,20 +535,14 @@ bool MovementAIComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInf
movementInfo.PushDebug<AMFDoubleValue>("Pulling To Point") = m_PullingToPoint;
movementInfo.PushDebug<AMFBoolValue>("At Final Waypoint") = m_AtFinalWaypoint;
auto& pullPointInfo = movementInfo.PushDebug("Pull Point");
pullPointInfo.PushDebug<AMFDoubleValue>("X") = m_PullPoint.x;
pullPointInfo.PushDebug<AMFDoubleValue>("Y") = m_PullPoint.y;
pullPointInfo.PushDebug<AMFDoubleValue>("Z") = m_PullPoint.z;
auto& pullPointInfo = movementInfo.PushDebug("Pull Point").PushDebug(m_PullPoint);
// movementInfo.PushDebug<AMFDoubleValue>("Delay") = m_Delay;
auto& waypoints = movementInfo.PushDebug("Interpolated Waypoints");
int i = 0;
for (const auto& point : m_InterpolatedWaypoints) {
auto& waypoint = waypoints.PushDebug("Waypoint " + std::to_string(++i));
waypoint.PushDebug<AMFDoubleValue>("X") = point.x;
waypoint.PushDebug<AMFDoubleValue>("Y") = point.y;
waypoint.PushDebug<AMFDoubleValue>("Z") = point.z;
waypoints.PushDebug("Waypoint " + std::to_string(++i)).PushDebug(point);
}
i = 0;
@@ -556,10 +550,7 @@ bool MovementAIComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInf
auto pathCopy = m_CurrentPath; // Copy to avoid modifying the original stack
while (!pathCopy.empty()) {
const auto& waypoint = pathCopy.top();
auto& pathWaypoint = currentPath.PushDebug("Waypoint " + std::to_string(++i));
pathWaypoint.PushDebug<AMFDoubleValue>("X") = waypoint.position.x;
pathWaypoint.PushDebug<AMFDoubleValue>("Y") = waypoint.position.y;
pathWaypoint.PushDebug<AMFDoubleValue>("Z") = waypoint.position.z;
currentPath.PushDebug("Waypoint " + std::to_string(++i)).PushDebug(waypoint.position);
pathCopy.pop();
}

View File

@@ -238,10 +238,7 @@ bool PhantomPhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObjectRepor
info.PushDebug<AMFIntValue>("Effect Type") = static_cast<int>(m_EffectType);
info.PushDebug<AMFDoubleValue>("Directional Multiplier") = m_DirectionalMultiplier;
info.PushDebug<AMFBoolValue>("Is Directional") = m_IsDirectional;
auto& direction = info.PushDebug("Direction");
direction.PushDebug<AMFDoubleValue>("x") = m_Direction.x;
direction.PushDebug<AMFDoubleValue>("y") = m_Direction.y;
direction.PushDebug<AMFDoubleValue>("z") = m_Direction.z;
auto& direction = info.PushDebug("Direction").PushDebug(m_Direction);
if (m_MinMax) {
auto& minMaxInfo = info.PushDebug("Min Max Info");
@@ -252,15 +249,8 @@ bool PhantomPhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObjectRepor
if (m_IsRespawnVolume) {
auto& respawnInfo = info.PushDebug("Respawn Info");
respawnInfo.PushDebug<AMFBoolValue>("Is Respawn Volume") = m_IsRespawnVolume;
auto& respawnPos = respawnInfo.PushDebug("Respawn Position");
respawnPos.PushDebug<AMFDoubleValue>("x") = m_RespawnPos.x;
respawnPos.PushDebug<AMFDoubleValue>("y") = m_RespawnPos.y;
respawnPos.PushDebug<AMFDoubleValue>("z") = m_RespawnPos.z;
auto& respawnRot = respawnInfo.PushDebug("Respawn Rotation");
respawnRot.PushDebug<AMFDoubleValue>("w") = m_RespawnRot.w;
respawnRot.PushDebug<AMFDoubleValue>("x") = m_RespawnRot.x;
respawnRot.PushDebug<AMFDoubleValue>("y") = m_RespawnRot.y;
respawnRot.PushDebug<AMFDoubleValue>("z") = m_RespawnRot.z;
respawnInfo.PushDebug("Respawn Position").PushDebug(m_RespawnPos);
respawnInfo.PushDebug("Respawn Rotation").PushDebug(m_RespawnRot);
}
return true;

View File

@@ -249,18 +249,11 @@ bool PhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo&
auto& info = reportInfo.info->PushDebug("Physics");
reportInfo.subCategory = &info;
auto& pos = info.PushDebug("Position");
pos.PushDebug<AMFDoubleValue>("x") = m_Position.x;
pos.PushDebug<AMFDoubleValue>("y") = m_Position.y;
pos.PushDebug<AMFDoubleValue>("z") = m_Position.z;
auto& pos = info.PushDebug("Position").PushDebug(m_Position);
auto& rot = info.PushDebug("Rotation");
rot.PushDebug<AMFDoubleValue>("w") = m_Rotation.w;
rot.PushDebug<AMFDoubleValue>("x") = m_Rotation.x;
rot.PushDebug<AMFDoubleValue>("y") = m_Rotation.y;
rot.PushDebug<AMFDoubleValue>("z") = m_Rotation.z;
auto& rot = info.PushDebug("Rotation").PushDebug(m_Rotation);
info.PushDebug<AMFIntValue>("CollisionGroup") = m_CollisionGroup;
info.PushDebug<AMFIntValue>("Collision Group") = m_CollisionGroup;
return true;
}

View File

@@ -80,8 +80,9 @@ bool ProximityMonitorComponent::OnGetObjectReportInfo(GameMessages::GetObjectRep
proxAmf.PushDebug("Position").PushDebug(entity->GetPosition());
proxAmf.PushDebug("Rotation").PushDebug(entity->GetRotation());
auto& collidingAmf = proxAmf.PushDebug("Colliding Objects");
int i = 1;
for (const auto& colliding : entity->GetCurrentlyCollidingObjects()) {
collidingAmf.PushDebug(std::to_string(colliding));
collidingAmf.PushDebug<AMFStringValue>(std::to_string(i++), "LWOOBJID") = std::to_string(colliding);
}
}

View File

@@ -576,25 +576,25 @@ bool QuickBuildComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInf
auto& quickbuild = reportInfo.info->PushDebug("Quick Build");
quickbuild.PushDebug<AMFStringValue>("State") = StringifiedEnum::ToString(m_State).data();
quickbuild.PushDebug<AMFDoubleValue>("Timer") = m_Timer;
quickbuild.PushDebug<AMFDoubleValue>("TimerIncomplete") = m_TimerIncomplete;
quickbuild.PushDebug("ActivatorPosition").PushDebug(m_ActivatorPosition);
quickbuild.PushDebug<AMFStringValue>("ActivatorId") = std::to_string(m_ActivatorId);
quickbuild.PushDebug<AMFBoolValue>("ShowResetEffect") = m_ShowResetEffect;
quickbuild.PushDebug<AMFDoubleValue>("Timer Incomplete") = m_TimerIncomplete;
quickbuild.PushDebug("Activator Position").PushDebug(m_ActivatorPosition);
quickbuild.PushDebug<AMFStringValue>("Activator ID", "LWOOBJID") = std::to_string(m_ActivatorId);
quickbuild.PushDebug<AMFBoolValue>("Show Reset Effect") = m_ShowResetEffect;
quickbuild.PushDebug<AMFDoubleValue>("Taken") = m_Taken;
quickbuild.PushDebug<AMFDoubleValue>("ResetTime") = m_ResetTime;
quickbuild.PushDebug<AMFDoubleValue>("CompleteTime") = m_CompleteTime;
quickbuild.PushDebug<AMFIntValue>("TakeImagination") = m_TakeImagination;
quickbuild.PushDebug<AMFDoubleValue>("Reset Time") = m_ResetTime;
quickbuild.PushDebug<AMFDoubleValue>("Complete Time") = m_CompleteTime;
quickbuild.PushDebug<AMFIntValue>("Take Imagination") = m_TakeImagination;
quickbuild.PushDebug<AMFBoolValue>("Interruptible") = m_Interruptible;
quickbuild.PushDebug<AMFBoolValue>("SelfActivator") = m_SelfActivator;
auto& modules = quickbuild.PushDebug("CustomModules");
quickbuild.PushDebug<AMFBoolValue>("Self Activator") = m_SelfActivator;
auto& modules = quickbuild.PushDebug("Custom Modules");
for (const auto cmodule : m_CustomModules) modules.PushDebug<AMFIntValue>("Module") = cmodule;
quickbuild.PushDebug<AMFIntValue>("ActivityId") = m_ActivityId;
quickbuild.PushDebug<AMFIntValue>("PostImaginationCost") = m_PostImaginationCost;
quickbuild.PushDebug<AMFDoubleValue>("TimeBeforeSmash") = m_TimeBeforeSmash;
quickbuild.PushDebug<AMFDoubleValue>("TimeBeforeDrain") = m_TimeBeforeDrain;
quickbuild.PushDebug<AMFIntValue>("DrainedImagination") = m_DrainedImagination;
quickbuild.PushDebug<AMFBoolValue>("RepositionPlayer") = m_RepositionPlayer;
quickbuild.PushDebug<AMFDoubleValue>("SoftTimer") = m_SoftTimer;
quickbuild.PushDebug<AMFStringValue>("Builder") = std::to_string(m_Builder);
quickbuild.PushDebug<AMFIntValue>("Activity Id") = m_ActivityId;
quickbuild.PushDebug<AMFIntValue>("Post Imagination Cost") = m_PostImaginationCost;
quickbuild.PushDebug<AMFDoubleValue>("Time Before Smash") = m_TimeBeforeSmash;
quickbuild.PushDebug<AMFDoubleValue>("Time Before Drain") = m_TimeBeforeDrain;
quickbuild.PushDebug<AMFIntValue>("Drained Imagination") = m_DrainedImagination;
quickbuild.PushDebug<AMFBoolValue>("Reposition Player") = m_RepositionPlayer;
quickbuild.PushDebug<AMFDoubleValue>("Soft Timer") = m_SoftTimer;
quickbuild.PushDebug<AMFStringValue>("Builder", "LWOOBJID") = std::to_string(m_Builder);
return true;
}

View File

@@ -16,7 +16,7 @@ ScriptComponent::ScriptComponent(Entity* parent, const int32_t componentID, cons
m_ScriptName = scriptName;
SetScript(scriptName);
RegisterMsg(&ScriptComponent::OnGetObjectReportInfo);
Component::RegisterMsg(&ScriptComponent::OnGetObjectReportInfo);
}
void ScriptComponent::Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) {

View File

@@ -8,6 +8,9 @@
#include "CppScripts.h"
#include "Component.h"
#include "GameMessages.h"
#include <functional>
#include <map>
#include <string>
#include "eReplicaComponentType.h"
@@ -42,9 +45,22 @@ public:
* @param scriptName the name of the script to find
*/
void SetScript(const std::string& scriptName);
bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo);
// Registers a message from a script to be listened for on the parent object
template<typename ScriptClass, typename DerivedMsgType>
void RegisterMsg(ScriptClass* scriptThis, bool (ScriptClass::*scriptHandler)(Entity&, DerivedMsgType&)) {
static_assert(std::is_base_of_v<GameMessages::GameMsg, DerivedMsgType>, "DerivedMsgType must derive from GameMessages::GameMsg base class.");
const auto boundMsg = std::bind(scriptHandler, scriptThis, std::placeholders::_1, std::placeholders::_2);
auto* const parent = m_Parent;
const auto castWrapper = [parent, boundMsg](GameMessages::GameMsg& msg) {
return boundMsg(*parent, static_cast<DerivedMsgType&>(msg));
};
DerivedMsgType msg;
m_Parent->RegisterMsg(msg.msgId, castWrapper);
}
private:
/**

View File

@@ -79,14 +79,8 @@ void SimplePhysicsComponent::SetPhysicsMotionState(uint32_t value) {
bool SimplePhysicsComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) {
PhysicsComponent::OnGetObjectReportInfo(reportInfo);
auto& info = reportInfo.subCategory->PushDebug("Simple Physics Info");
auto& velocity = info.PushDebug("Velocity");
velocity.PushDebug<AMFDoubleValue>("x") = m_Velocity.x;
velocity.PushDebug<AMFDoubleValue>("y") = m_Velocity.y;
velocity.PushDebug<AMFDoubleValue>("z") = m_Velocity.z;
auto& angularVelocity = info.PushDebug("Angular Velocity");
angularVelocity.PushDebug<AMFDoubleValue>("x") = m_AngularVelocity.x;
angularVelocity.PushDebug<AMFDoubleValue>("y") = m_AngularVelocity.y;
angularVelocity.PushDebug<AMFDoubleValue>("z") = m_AngularVelocity.z;
info.PushDebug("Velocity").PushDebug(m_Velocity);
info.PushDebug("Angular Velocity").PushDebug(m_AngularVelocity);
info.PushDebug<AMFIntValue>("Physics Motion State") = m_PhysicsMotionState;
info.PushDebug<AMFStringValue>("Climbable Type") = StringifiedEnum::ToString(m_ClimbableType).data();
return true;

View File

@@ -159,6 +159,10 @@ void GameMessageHandler::HandleMessage(RakNet::BitStream& inStream, const System
InventoryComponent* inv = entity->GetComponent<InventoryComponent>();
if (inv) {
// Clear server-side skill state so AddItemSkills sends fresh AddSkill
// packets to the now-ready client. Skills sent during entity construction
// (Serialize) arrive before LWOSkillComponent is initialized and are dropped.
inv->ClearSkills();
auto items = inv->GetEquippedItems();
for (auto pair : items) {
const auto item = pair.second;

View File

@@ -45,6 +45,7 @@ enum class BehaviorSlot : int32_t;
enum class eVendorTransactionResult : uint32_t;
enum class eReponseMoveItemBetweenInventoryTypeCode : int32_t;
enum class eMissionState : int;
enum class AiState : uint32_t;
enum class eCameraTargetCyclingMode : int32_t {
ALLOW_CYCLE_TEAMMATES,
@@ -980,5 +981,12 @@ namespace GameMessages {
LWOOBJID objectID{};
LOT lot{};
};
struct NotifyCombatAIStateChange : public GameMsg {
NotifyCombatAIStateChange() : GameMsg(MessageType::Game::NOTIFY_COMBAT_AI_STATE_CHANGE) {}
AiState newState{};
AiState prevState{};
};
};
#endif // GAMEMESSAGES_H

View File

@@ -68,6 +68,7 @@ void HandlePacket(Packet* packet);
std::map<uint32_t, std::string> activeSessions;
SystemAddress authServerMasterPeerSysAddr;
SystemAddress chatServerMasterPeerSysAddr;
SystemAddress dashboardServerMasterPeerSysAddr;
int GenerateBCryptPassword(const std::string& password, const int workFactor, char salt[BCRYPT_HASHSIZE], char hash[BCRYPT_HASHSIZE]) {
int32_t bcryptState = ::bcrypt_gensalt(workFactor, salt);
@@ -375,6 +376,11 @@ int main(int argc, char** argv) {
StartAuthServer();
}
// Start web dashboard if enabled
if (Game::config->GetValue("enable_dashboard") == "1") {
StartDashboardServer();
}
auto t = std::chrono::high_resolution_clock::now();
Packet* packet = nullptr;
constexpr uint32_t logFlushTime = 15 * masterFramerate;
@@ -499,6 +505,11 @@ void HandlePacket(Packet* packet) {
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartAuthServer();
}
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartDashboardServer();
}
}
if (packet->data[0] == ID_CONNECTION_LOST) {
@@ -520,6 +531,11 @@ void HandlePacket(Packet* packet) {
authServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartAuthServer();
}
if (packet->systemAddress == dashboardServerMasterPeerSysAddr) {
dashboardServerMasterPeerSysAddr = UNASSIGNED_SYSTEM_ADDRESS;
StartDashboardServer();
}
}
if (packet->length < 4) return;
@@ -601,6 +617,9 @@ void HandlePacket(Packet* packet) {
case ServiceType::AUTH:
authServerMasterPeerSysAddr = packet->systemAddress;
break;
case ServiceType::DASHBOARD:
dashboardServerMasterPeerSysAddr = packet->systemAddress;
break;
default:
// We just ignore any other server type
break;
@@ -866,11 +885,11 @@ int ShutdownSequence(int32_t signal) {
instance->SetIsShuttingDown(true);
}
LOG("Attempting to shutdown instances, max 60 seconds...");
LOG("Attempting to shutdown instances, max 10 seconds...");
auto t = std::chrono::high_resolution_clock::now();
uint32_t framesSinceShutdownStart = 0;
constexpr uint32_t maxShutdownTime = 60 * mediumFramerate;
constexpr uint32_t maxShutdownTime = 10 * mediumFramerate;
bool allInstancesShutdown = false;
Packet* packet = nullptr;
while (true) {
@@ -893,7 +912,10 @@ int ShutdownSequence(int32_t signal) {
}
}
if (allInstancesShutdown && authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
if (allInstancesShutdown && \
authServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
chatServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS && \
dashboardServerMasterPeerSysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Finished shutting down MasterServer!");
break;
}
@@ -905,6 +927,26 @@ int ShutdownSequence(int32_t signal) {
if (framesSinceShutdownStart == maxShutdownTime) {
LOG("Finished shutting down by timeout!");
// log what we were waiting on: worlds, chat, auth, dashboard, etc
if (authServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Auth server did not shutdown in time");
}
if (chatServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Chat server did not shutdown in time");
}
if (dashboardServerMasterPeerSysAddr != UNASSIGNED_SYSTEM_ADDRESS) {
LOG("Dashboard server did not shutdown in time");
}
for (const auto& instance : Game::im->GetInstances()) {
if (instance == nullptr) {
continue;
}
if (!instance->GetShutdownComplete()) {
LOG("Instance zone %i clone %i instance %i port %i did not shutdown in time", instance->GetMapID(), instance->GetCloneID(), instance->GetInstanceID(), instance->GetPort());
}
}
break;
}
}

View File

@@ -107,6 +107,42 @@ uint32_t StartAuthServer() {
return auth_pid;
}
uint32_t StartDashboardServer() {
if (Game::ShouldShutdown()) {
LOG("Currently shutting down. DashboardServer will not be restarted.");
return 0;
}
auto web_path = BinaryPathFinder::GetBinaryDir() / "DashboardServer";
#ifdef _WIN32
web_path.replace_extension(".exe");
auto web_startup = startup;
auto web_info = PROCESS_INFORMATION{};
if (!CreateProcessW(web_path.wstring().data(), web_path.wstring().data(),
nullptr, nullptr, false, 0, nullptr, nullptr,
&web_startup, &web_info))
{
LOG("Failed to launch DashboardServer");
return 0;
}
// get pid and close unused handles
auto web_pid = web_info.dwProcessId;
CloseHandle(web_info.hProcess);
CloseHandle(web_info.hThread);
#else // *nix systems
const auto web_pid = fork();
if (web_pid < 0) {
LOG("Failed to launch DashboardServer");
return 0;
} else if (web_pid == 0) {
// We are the child process
execl(web_path.string().c_str(), web_path.string().c_str(), nullptr);
}
#endif
LOG("DashboardServer PID is %d", web_pid);
return web_pid;
}
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID) {
auto world_path = BinaryPathFinder::GetBinaryDir() / "WorldServer";
#ifdef _WIN32

View File

@@ -3,4 +3,5 @@
uint32_t StartAuthServer();
uint32_t StartChatServer();
uint32_t StartDashboardServer();
uint32_t StartWorldServer(LWOMAPID mapID, uint16_t port, LWOINSTANCEID lastInstanceID, int maxPlayers, LWOCLONEID cloneID);

View File

@@ -94,6 +94,7 @@ void MasterPackets::HandleServerInfo(Packet* packet) {
}
void MasterPackets::SendServerInfo(dServer* server, Packet* packet) {
LOG("SendServerInfo called for server type %i", static_cast<int>(server->GetServerType()));
RakNet::BitStream bitStream;
BitStreamUtils::WriteHeader(bitStream, ServiceType::MASTER, MessageType::Master::SERVER_INFO);

View File

@@ -144,7 +144,7 @@ Packet* dServer::ReceiveFromMaster() {
break;
}
case ID_CONNECTION_REQUEST_ACCEPTED: {
LOG("Established connection to master, zone (%i), instance (%i)", this->GetZoneID(), this->GetInstanceID());
LOG("Established connection to master: ServiceType (%s), Zone (%i), Instance (%i)", StringifiedEnum::ToString(this->GetServerType()).data(), this->GetZoneID(), this->GetInstanceID());
mMasterConnectionActive = true;
mMasterSystemAddress = packet->systemAddress;
MasterPackets::SendServerInfo(this, packet);

View File

@@ -1,4 +1,5 @@
set(DSCRIPTS_SOURCES_02_SERVER_ENEMY_FV
"DragonRonin.cpp"
"FvMaelstromCavalry.cpp"
"FvMaelstromDragon.cpp"
PARENT_SCOPE)

View File

@@ -0,0 +1,6 @@
#include "DragonRonin.h"
void DragonRonin::OnStartup(Entity* self) {
self->SetVar<float>(u"suicideTimer", 40.0f);
CountdownDestroyAI::OnStartup(self);
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "CountdownDestroyAI.h"
class DragonRonin : public CountdownDestroyAI {
public:
void OnStartup(Entity* self) override;
};

View File

@@ -1,6 +1,7 @@
set(DSCRIPTS_SOURCES_02_SERVER_ENEMY_GENERAL
"BaseEnemyMech.cpp"
"BaseEnemyApe.cpp"
"CountdownDestroyAI.cpp"
"GfApeSmashingQB.cpp"
"TreasureChestDragonServer.cpp"
"EnemyNjBuff.cpp"

View File

@@ -0,0 +1,50 @@
#include "CountdownDestroyAI.h"
#include "BaseCombatAIComponent.h"
#include "ScriptComponent.h"
void CountdownDestroyAI::OnStartup(Entity* self) {
CountdownStartup(*self);
auto* scriptComp = self->GetComponent<ScriptComponent>();
if (scriptComp) scriptComp->RegisterMsg(this, &CountdownDestroyAI::OnNotifyCombatAIStateChange);
}
void CountdownDestroyAI::CountdownStartup(Entity& self) {
auto suicideTimer = self.GetVar<float>(u"suicideTimer");
if (suicideTimer == 0.0f) suicideTimer = 60;
self.AddTimer("Dead", suicideTimer);
}
void CountdownDestroyAI::OnHit(Entity* self, Entity* attacker) {
if (!self->GetVar<bool>(u"ShouldBeDead")) return;
self->CancelTimer("IsBeingAttacked");
self->AddTimer("Dead", 5.0f);
}
void CountdownDestroyAI::OnTimerDone(Entity* self, std::string timerName) {
if (timerName == "Dead") {
self->SetVar<bool>(u"ShouldBeDead", true);
if (self->GetVar<bool>(u"Busy")) {
self->AddTimer("IsBeingAttacked", 5.0f);
} else {
self->Smash();
}
} else if (timerName == "IsBeingAttacked") {
self->Smash();
}
}
bool CountdownDestroyAI::OnNotifyCombatAIStateChange(Entity& self, GameMessages::NotifyCombatAIStateChange& notifyMsg) {
const auto curState = notifyMsg.newState;
if (curState == AiState::dead) return true;
if (curState == AiState::aggro || curState == AiState::tether) {
self.SetVar(u"Busy", true);
} else {
self.SetVar(u"Busy", false);
if (self.GetVar<bool>(u"ShouldBeDead")) {
self.Smash();
}
}
return true;
}

Some files were not shown because too many files have changed in this diff Show More