[Add] Implement database persistance and Login UI.

[Build System]
- Integrate 'sqlite3' and 'sqlite_modern_cpp' using FetchContent.
- Enabled 'C' language to allow compilation of 'sqlite3' lib.

[Persistance]
- Adds a 'DatabaseManager' class to handle all SQLite operations.
- Creates a 'players' table on server startup.
- Server uses separate database for single-player 'bettola_sp.db' and
  'bettola.db'

[UI]
- Adds a new 'LoginScreen' UI.
- Game flow is now MainMenu -> LoginScreen -> bootSequence -> Desktop.
- 'LoginScreen' has interactive tabs to switch between "Login" and
  "Create Account" 'modes'.
- Full client-server communication for creating accounts and
  authentication.

[Server]
- Refactor 'NetworkManager' to handle an 'AUTHENTICATING' state for new
  connectiosn.
- Player state is only set to 'ACTIVE' after a successful login
This commit is contained in:
Ritchie Cunningham 2025-10-05 22:45:33 +01:00
parent 564512225f
commit fce3b3aad6
11 changed files with 249 additions and 38 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ compile_commands.json
assets/design_doc.org
.clangd
*.swp
*.db

View File

@ -30,7 +30,7 @@ void GameState::_init_desktop(void) {
void GameState::_run_server(void) {
try {
NetworkManager server;
NetworkManager server("bettola_sp.db");
server.start(SINGLE_PLAYER_PORT);
/*
* Server's start() method is non-blocking, but NetworkManager
@ -123,41 +123,59 @@ void GameState::update(float dt, int draw_calls, int shape_verts, int text_verts
_main_menu.reset(); /* Free mem. */
if(_current_screen == Screen::LOGIN) {
/* Connect to server. */
if(_is_single_player) {
fprintf(stdout, "Starting in single-player mode...\n");
std::thread server_thread(&GameState::_run_server, this);
server_thread.detach();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if(!_network->connect("127.0.0.1", SINGLE_PLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
} else {
if(!_network->connect("127.0.0.1", MULTIPLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
}
_login_screen = std::make_unique<LoginScreen>(_screen_width, _screen_height);
}
}
break;
}
case Screen::LOGIN:
case Screen::LOGIN: {
if(_login_screen && _login_screen->is_login_attempted()) {
/* TODO: Send credentials to server for verification. */
printf("Login attempt: user=%s, pass=%s, host=%s\n",
_login_screen->get_username().c_str(),
_login_screen->get_password().c_str(),
_login_screen->get_hostname().c_str());
std::string username = _login_screen->get_username();
std::string password = _login_screen->get_password();
_current_screen = Screen::BOOTING;
_login_screen.reset(); /* Free mem. */
_boot_sequence = std::make_unique<BootSequence>();
if(_login_screen->is_new_account_mode()) {
std::string hostname = _login_screen->get_hostname();
std::string msg = "C_ACC::" + username + "::" + password + "::" + hostname;
_network->send(msg);
} else {
std::string msg = "LOGIN::" + username + "::" + password;
_network->send(msg);
}
_login_screen->clear_login_attempt(); /* Try to spam my server now b.tch! */
}
break;
/* Check for server response to our login attempt. */
std::string server_msg;
while(_network->poll_message(server_msg)) {
if(server_msg == "LOGIN_SUCCESS" || server_msg == "C_ACC_SUCCESS") {
_current_screen = Screen::BOOTING;
_login_screen.reset(); /* Free mem. */
_boot_sequence = std::make_unique<BootSequence>();
break;
} else if(server_msg == "LOGIN_FAIL") {
_login_screen->set_error_message("Invalid username or password.");
} else if(server_msg == "C_ACC_FAIL") {
_login_screen->set_error_message("Username already exists.");
}
}
} break;
case Screen::BOOTING: {
if(!_boot_sequence) break; /* Shouldn't happen. */
if(_boot_sequence->is_finished()) {
/* Connect to server. */
if(_is_single_player) {
fprintf(stdout, "Starting in single-player mode...\n");
std::thread server_thread(&GameState::_run_server, this);
server_thread.detach();
std::this_thread::sleep_for(std::chrono::milliseconds(200));
if(!_network->connect("127.0.0.1", SINGLE_PLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
} else {
if(!_network->connect("127.0.0.1", MULTIPLAYER_PORT)) {
/* TODO: Handle connection failure. */
}
}
_current_screen = Screen::DESKTOP;
_init_desktop();
_boot_sequence.reset(); /* Free mem. */

View File

@ -5,8 +5,18 @@
#include "ui/ui_renderer.h"
#include "ui/login_screen.h"
LoginScreen::LoginScreen(int screen_width, int screen_height)
: _screen_width(screen_width), _screen_height(screen_height) {}
LoginScreen::LoginScreen(int screen_width, int screen_height) :
_screen_width(screen_width),
_screen_height(screen_height) {
_is_new_account = false; /* Default to login. */
/* Position tabs relative to the form. */
const int form_center_x = _screen_width / 2 - 25;
const int tabs_y = (_screen_height / 2 - 100) + 200;
_login_tab_rect = { form_center_x-90, tabs_y, 80, 30 };
_create_account_tab_rect = { form_center_x+10, tabs_y, 150, 30 };
}
LoginScreen::~LoginScreen(void) {}
@ -18,10 +28,13 @@ void LoginScreen::render(const RenderContext& context) const {
UIRenderer* ui_renderer = context.ui_renderer;
/* Colours. */
const Color bg_color = { 0.06f, 0.07f, 0.09f };
const Color text_color = { 0.8f, 0.8f, 0.8f };
const Color inactive_box_color = { 0.1f, 0.1f, 0.15f };
const Color active_box_color = { 0.15f, 0.15f, 0.2f };
const Color warning_color = { 0.7f, 0.3f, 0.3f };
const Color active_tab_color = { 0.9f, 0.9f, 0.9f };
const Color inactive_tab_color = { 0.4f, 0.5f, 0.6f };
/* Layout. */
const int box_width = 300;
@ -36,11 +49,28 @@ void LoginScreen::render(const RenderContext& context) const {
ui_renderer->begin_shapes();
ui_renderer->begin_text();
/* Draw background. */
ui_renderer->draw_rect(0, 0, _screen_width, _screen_height, bg_color);
/* Draw mode-switch tabs. */
ui_renderer->render_text("[Login]", _login_tab_rect.x, _login_tab_rect.y + 20,
_is_new_account ? inactive_tab_color : active_tab_color);
ui_renderer->render_text("[Create Account]", _create_account_tab_rect.x,
_create_account_tab_rect.y + 20,
_is_new_account ? active_tab_color : inactive_tab_color);
/* Draw title. */
const char* title = _is_new_account ? "Create Account" : "Login";
float title_width = ui_renderer->get_text_renderer()->get_text_width(title, 1.0f);
ui_renderer->render_text(title, (_screen_width-title_width)/2, start_y, text_color);
/* Draw error message if it exists. */
if(!_error_message.empty()) {
float error_width = ui_renderer->get_text_renderer()->get_text_width(_error_message.c_str(), 1.0f);
ui_renderer->render_text(_error_message.c_str(), (_screen_width-error_width)/2,
start_y+20, warning_color);
}
/* Draw input boxes and labels. */
/* Username */
ui_renderer->render_text("Username", username_rect.x, username_rect.y-5, text_color);
@ -99,6 +129,10 @@ void LoginScreen::render(const RenderContext& context) const {
ui_renderer->flush_text();
}
void LoginScreen::set_error_message(const std::string& msg) {
_error_message = msg;
}
void LoginScreen::handle_event(const SDL_Event* event) {
if(event->type == SDL_EVENT_TEXT_INPUT) {
/* Append character to active input string. */
@ -139,11 +173,23 @@ void LoginScreen::handle_event(const SDL_Event* event) {
const Rect password_rect = { center_x, start_y+100, box_width, 30 };
const Rect hostname_rect = { center_x, start_y+160, box_width, 30 };
if(mouse_y >= username_rect.y && mouse_y <= username_rect.y + username_rect.h)
auto is_inside = [&](const Rect& rect) {
return mouse_x >= rect.x && mouse_x <= rect.x + rect.w &&
mouse_y >= rect.y && mouse_y <= rect.y + rect.h;
};
if(is_inside(_login_tab_rect)) {
_is_new_account = false;
_username_input.clear(); _password_input.clear(); _hostname_input.clear();
} else if(is_inside(_create_account_tab_rect)) {
_is_new_account = true;
_username_input.clear(); _password_input.clear(); _hostname_input.clear();
} else if(is_inside(username_rect)) {
_active_field = 0;
else if(mouse_y >= password_rect.y && mouse_y <= password_rect.y + password_rect.h)
} else if(is_inside(password_rect)) {
_active_field = 1;
else if(_is_new_account && (mouse_y >= hostname_rect.y && mouse_y <= hostname_rect.y + hostname_rect.h))
} else if(_is_new_account && is_inside(hostname_rect)) {
_active_field = 2;
}
}
}

View File

@ -15,15 +15,19 @@ public:
void handle_event(const SDL_Event* event);
bool is_login_attempted(void) const { return _login_attempted; }
std::string get_username(void) const { return _username_input; }
std::string get_password(void) const { return _password_input; }
std::string get_hostname(void) const { return _hostname_input; }
std::string get_username(void) const { return _username_input; }
std::string get_password(void) const { return _password_input; }
std::string get_hostname(void) const { return _hostname_input; }
bool is_new_account_mode(void) const { return _is_new_account; }
void clear_login_attempt(void) { _login_attempted = false;}
void set_error_message(const std::string& msg);
private:
/* UI State. */
std::string _username_input;
std::string _password_input;
std::string _hostname_input;
std::string _error_message;
bool _login_attempted = false;
bool _is_new_account = true;
@ -32,4 +36,8 @@ private:
/* Screen dimensions. */
int _screen_width;
int _screen_height;
/* Clickable tabs for mode switching. */
Rect _create_account_tab_rect;
Rect _login_tab_rect;
};

View File

@ -0,0 +1,42 @@
#include "database_manager.h"
DatabaseManager::DatabaseManager(const std::string& db_path) : _db(db_path) {
/* db is opened in the construtor's init list. */
}
DatabaseManager::~DatabaseManager(void) {
/* db is auto closed when _db goes out of scope. */
}
void DatabaseManager::init(void) {
_db << "CREATE TABLE IF NOT EXISTS players("
"id INTEGER PRIMARY KEY AUTOINCREMENT,"
"username TEXT NOT NULL UNIQUE,"
"password TEXT NOT NULL,"
"hostname TEXT NOT NULL"
");";
}
bool DatabaseManager::create_player(const std::string& username, const std::string& password,
const std::string& hostname) {
try {
_db << "INSERT INTO players (username, password, hostname) VALUES (?, ?, ?);"
<< username
<< password
<< hostname;
} catch(const std::exception& e) {
/* Fail if the username exists. */
return false;
}
return true;
}
bool DatabaseManager::auth_player(const std::string& username, const std::string& password) {
bool authed = false;
_db << "SELECT id FROM players WHERE username = ? AND password = ?;"
<< username
<< password
>> [&](long long id) { authed = true; };
return authed;
}

View File

@ -0,0 +1,23 @@
#pragma once
#include <string>
#include "db.h"
class DatabaseManager {
public:
DatabaseManager(const std::string& db_path);
~DatabaseManager(void);
void init(void);
/* Return true on success, false if user already exists. */
bool create_player(const std::string& username, const std::string& password,
const std::string& hostname);
/* Return true if creds are valid. */
bool auth_player(const std::string& username, const std::string& password);
private:
sqlite::database _db;
};

View File

@ -6,11 +6,12 @@
#include "network_manager.h"
#include "net/constants.h"
int main(int argc, char** argv) {
const std::string DB_FILE = "bettola.db";
int main(int argc, char** argv) {
try {
/* We'll keep main thread alive while server runs in background. */
NetworkManager server;
NetworkManager server(DB_FILE);
server.start(MULTIPLAYER_PORT);
while(true) {
std::this_thread::sleep_for(std::chrono::seconds(1));

View File

@ -2,8 +2,10 @@
#include <exception>
#include <functional>
#include <memory>
#include <sstream>
#include "network_manager.h"
#include "db/database_manager.h"
#include "asio/error_code.hpp"
#include "asio/ip/tcp.hpp"
@ -13,7 +15,9 @@
#include "net/tcp_connection.h"
#include "vfs.h"
NetworkManager::NetworkManager(void) : _acceptor(_io_context) {
NetworkManager::NetworkManager(const std::string& db_path) :
_db_manager(std::make_unique<DatabaseManager>(db_path)),
_acceptor(_io_context) {
/* World setup. */
_world_machines["8.8.8.8"] = _machine_manager.create_machine(1000, "dns.google", "npc");
_world_machines["10.0.2.15"] = _machine_manager.create_machine(1001, "corp.internal", "npc");
@ -22,6 +26,8 @@ NetworkManager::NetworkManager(void) : _acceptor(_io_context) {
_world_machines["8.8.8.8"]->services[80] = "HTTPD v2.4";
_world_machines["10.0.2.15"]->services[21] = "FTPd v3.0";
_db_manager->init();
fprintf(stderr, "Created world with %zu networks\n", _world_machines.size());
}
@ -99,6 +105,17 @@ void NetworkManager::start_accept(void) {
});
}
static std::vector<std::string> split_message(const std::string& s, const std::string& delimiter) {
std::vector<std::string> tokens;
size_t start = 0, end = 0;
while((end = s.find(delimiter, start)) != std::string::npos) {
tokens.push_back(s.substr(start, end-start));
start = end + delimiter.length();
}
tokens.push_back(s.substr(start));
return tokens;
}
void NetworkManager::on_message(std::shared_ptr<net::TcpConnection> connection,
const std::string& message) {
Player* player = _players[connection->get_id()].get();
@ -107,6 +124,51 @@ void NetworkManager::on_message(std::shared_ptr<net::TcpConnection> connection,
return;
}
if(player->state == PlayerState::AUTHENTICATING) {
if(message.rfind("C_ACC::", 0) == 0) {
std::string payload = message.substr(7);
auto parts = split_message(payload, "::");
if(parts.size() == 3) {
if(_db_manager->create_player(parts[0], parts[1], parts[2])) {
/*
* TODO: When creating a player, also create their machine and save it.
* for now, they will juse use a temp machine on auth.
*/
player->state = PlayerState::ACTIVE;
connection->send("C_ACC_SUCCESS");
/* send initial prompt. */
std::string prompt = "\n" + get_full_path(player->cmd_processor->get_current_dir());
connection->send(prompt);
} else {
connection->send("C_ACC_FAIL");
}
}
} else if(message.rfind("LOGIN::", 0) == 0) {
std::string payload = message.substr(7);
auto parts = split_message(payload, "::");
if(parts.size() == 2) {
if(_db_manager->auth_player(parts[0], parts[1])) {
/*
* TODO: Load the player's machine from the database.
* For now, just auth them and use a temp machine.
*/
player->state = PlayerState::ACTIVE;
connection->send("LOGIN_SUCCESS");
/* Send initial prompt. */
std::string prompt = "\n" + get_full_path(player->cmd_processor->get_current_dir());
connection->send(prompt);
} else {
connection->send("LOGIN_FAIL");
}
}
} else {
/* Ignore all other messages while authing. */
connection->send("ERR: Not authenticated.\n");
}
return;
}
/* === PLAYER BECOMES ACTIVE HERE === */
/* Check for "special" message prefixes. */
if(message.rfind("WRITEF::", 0) == 0) {
/* Message format: WRITEF::/path/to/file::content. */

View File

@ -12,9 +12,11 @@
#include "player.h"
#include "machine_manager.h"
class DatabaseManager;
class NetworkManager {
public:
NetworkManager();
NetworkManager(const std::string& db_path);
~NetworkManager(void);
void start(uint16_t port);
@ -36,4 +38,5 @@ private:
std::map<std::string, Machine*> _world_machines; /* For NPC's. */
MachineManager _machine_manager;
std::unique_ptr<DatabaseManager> _db_manager;
};

View File

@ -4,7 +4,8 @@
Player::Player(uint32_t new_id, Machine* home_machine,
std::map<std::string, Machine*>& world_machines) :
id(new_id) {
id(new_id),
state(PlayerState::AUTHENTICATING) {
cmd_processor = new CommandProcessor(home_machine, world_machines);
}

View File

@ -7,11 +7,17 @@
#include "command_processor.h"
#include "machine.h"
enum class PlayerState {
AUTHENTICATING,
ACTIVE
};
class Player {
public:
Player(uint32_t id, Machine* home_machine, std::map<std::string, Machine*>& world_machines);
~Player(void);
uint32_t id;
PlayerState state;
CommandProcessor* cmd_processor; /* Manages the VFS state for the remote session. */
};