- 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
247 lines
8.2 KiB
C++
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;
|
|
}
|