Files
brandling-safari/server/main.cpp
Gabe 238cd9c6c3 Brandling Safari: LAN safari-zone creature catching on the HD-2D engine
- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass /
  water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon
- Client: title / join / character creator / play screens, prediction + reconciliation,
  interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection)
- Shared deterministic zone generator, movement, protocol, roster, appearance
- Paper-doll character art pipeline (PixelLab base walk + per-item animation edits)
- Brandling roster from the company list (logo colors, jev types), Blender safari props
- Headless test bot
2026-10-02 15:20:27 -05:00

247 lines
8.2 KiB
C++

// Brandling Safari dedicated server.
// safari_server [--port 27420] [--name "Brandling Safari"] [--db <path>] [--seed N] [--zone meadow]
// [--roster <path>] [--manifest <path>] [--day-minutes 30] [--hour H]
// [--weather clear|cloudy|rain|storm|fog]
#include "Database.h"
#include "Game.h"
#include "Log.h"
#include "Net.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <string>
#if defined(_WIN32)
#include <iostream>
#include <mutex>
#include <thread>
#else
#include <poll.h>
#include <unistd.h>
#endif
using namespace bs;
namespace {
std::atomic<bool> gStop{false};
void onSignal(int) { gStop = true; }
std::string defaultDbPath() {
std::string base;
if (const char* x = std::getenv("XDG_DATA_HOME"); x && *x) base = x;
#if defined(_WIN32)
else if (const char* a = std::getenv("APPDATA"); a && *a) base = a;
#endif
else if (const char* h = std::getenv("HOME"); h && *h) base = std::string(h) + "/.local/share";
else base = ".";
return base + "/brandling-safari/world.db";
}
// Non-blocking stdin lines for the console. POSIX polls fd 0 from the tick loop (a reader thread
// blocked inside stdio would deadlock glibc's exit-time stream flush); Windows uses a detached
// reader thread with a process-lifetime queue.
class ConsoleInput {
public:
bool pop(std::string& out) {
#if defined(_WIN32)
static bool started = false;
if (!started) {
started = true;
std::thread([] {
std::string line;
while (std::getline(std::cin, line)) {
std::lock_guard<std::mutex> lock(mutex());
queue().push_back(line);
}
}).detach();
}
std::lock_guard<std::mutex> lock(mutex());
if (queue().empty()) return false;
out = std::move(queue().front());
queue().pop_front();
return true;
#else
while (open_) {
pollfd p{0, POLLIN, 0};
if (::poll(&p, 1, 0) <= 0 || !(p.revents & (POLLIN | POLLHUP))) break;
char buf[512];
ssize_t n = ::read(0, buf, sizeof buf);
if (n <= 0) {
open_ = false; // EOF (or error): no console, keep serving
break;
}
pending_.append(buf, size_t(n));
}
size_t nl = pending_.find('\n');
if (nl == std::string::npos) return false;
out = pending_.substr(0, nl);
pending_.erase(0, nl + 1);
return true;
#endif
}
private:
#if defined(_WIN32)
static std::mutex& mutex() { static std::mutex m; return m; }
static std::deque<std::string>& queue() { static std::deque<std::string> q; return q; }
#else
bool open_ = true;
std::string pending_;
#endif
};
void usage() {
std::printf(
"usage: safari_server [--port N] [--name NAME] [--db PATH] [--seed N] [--zone meadow]\n"
" [--roster PATH] [--manifest PATH] [--day-minutes M] [--hour H]\n"
" [--weather clear|cloudy|rain|storm|fog]\n");
}
} // namespace
int main(int argc, char** argv) {
uint16_t port = kDefaultPort;
GameConfig cfg;
std::string dbPath = defaultDbPath();
std::string rosterPath = std::string(BS_ASSET_DIR) + "/creatures/roster.json";
std::string manifestPath = std::string(BS_ASSET_DIR) + "/characters/manifest.json";
bool seedGiven = false;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
auto next = [&]() -> std::string {
if (i + 1 >= argc) {
std::fprintf(stderr, "missing value for %s\n", a.c_str());
std::exit(2);
}
return argv[++i];
};
if (a == "--port") port = uint16_t(std::atoi(next().c_str()));
else if (a == "--name") cfg.serverName = next().substr(0, 64);
else if (a == "--db") dbPath = next();
else if (a == "--seed") cfg.seed = uint32_t(std::strtoul(next().c_str(), nullptr, 10)), seedGiven = true;
else if (a == "--zone") cfg.zoneId = next();
else if (a == "--roster") rosterPath = next();
else if (a == "--manifest") manifestPath = next();
else if (a == "--day-minutes") cfg.dayMinutes = std::max(0.1f, std::strtof(next().c_str(), nullptr));
else if (a == "--hour") cfg.hour = std::clamp(std::strtof(next().c_str(), nullptr), 0.0f, 23.999f);
else if (a == "--weather") {
Weather w;
std::string v = next();
if (!parseWeather(v, w)) {
std::fprintf(stderr, "unknown weather '%s'\n", v.c_str());
return 2;
}
cfg.weather = int(w);
} else if (a == "--help" || a == "-h") {
usage();
return 0;
} else {
std::fprintf(stderr, "unknown argument '%s'\n", a.c_str());
usage();
return 2;
}
}
std::string err;
Roster roster;
if (!roster.load(rosterPath, &err)) {
std::fprintf(stderr, "roster: %s\n", err.c_str());
return 1;
}
CharacterManifest manifest;
if (!manifest.load(manifestPath, &err)) {
std::fprintf(stderr, "manifest: %s\n", err.c_str());
return 1;
}
Database db;
if (!db.open(dbPath, &err)) {
std::fprintf(stderr, "database %s: %s\n", dbPath.c_str(), err.c_str());
return 1;
}
// The zone seed is part of the world: keep the first one unless --seed overrides it.
std::string saved;
if (!seedGiven && db.getWorld("seed", saved)) cfg.seed = uint32_t(std::strtoul(saved.c_str(), nullptr, 10));
db.setWorld("seed", std::to_string(cfg.seed));
Zone zone;
if (!zone.generate(cfg.zoneId, cfg.seed)) {
std::fprintf(stderr, "unknown zone '%s'\n", cfg.zoneId.c_str());
return 1;
}
if (!net::initialize()) {
std::fprintf(stderr, "enet initialization failed\n");
return 1;
}
{
net::Host host;
if (!host.listen(port, 32)) {
std::fprintf(stderr, "cannot listen on UDP port %u\n", unsigned(port));
net::shutdown();
return 1;
}
net::Beacon beacon;
bool lan = beacon.open(kDiscoveryPort);
Game game(host, db, zone, roster, manifest, cfg);
logf("\"%s\" listening on UDP %u (LAN discovery %s), db %s", cfg.serverName.c_str(), unsigned(port),
lan ? "on" : "unavailable", dbPath.c_str());
logf("type 'help' for console commands");
std::signal(SIGINT, onSignal);
std::signal(SIGTERM, onSignal);
ConsoleInput input;
using Clock = std::chrono::steady_clock;
const auto step = std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(kTickDt));
auto next = Clock::now();
bool running = true;
while (running && !gStop) {
auto now = Clock::now();
uint32_t waitMs = 0;
if (next > now)
waitMs = uint32_t(std::chrono::duration_cast<std::chrono::milliseconds>(next - now).count());
net::Event ev;
bool got = host.poll(ev, waitMs);
while (got) {
switch (ev.type) {
case net::Event::Connect: game.onConnect(ev.peer); break;
case net::Event::Disconnect: game.onDisconnect(ev.peer); break;
case net::Event::Receive: game.onReceive(ev.peer, ev.data); break;
default: break;
}
got = host.poll(ev, 0);
}
std::string line;
while (running && input.pop(line)) running = game.console(line);
now = Clock::now();
int steps = 0;
while (now >= next && steps < 5) {
game.tick();
next += step;
++steps;
}
if (now - next > std::chrono::seconds(1)) next = now; // fell far behind: drop the backlog
beacon.poll(port, cfg.serverName, game.playerCount());
host.flush();
}
logf("shutting down, saving...");
game.saveAll();
host.flush();
}
db.close();
net::shutdown();
logf("bye");
return 0;
}