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
This commit is contained in:
Gabe
2026-10-02 15:20:27 -05:00
commit 238cd9c6c3
3409 changed files with 100493 additions and 0 deletions

559
server/Creatures.cpp Normal file
View File

@@ -0,0 +1,559 @@
// Wild creature population and behaviour, tall-grass encounters, captures and
// party followers.
#include "Game.h"
#include "Log.h"
#include <algorithm>
#include <cmath>
#include <ctime>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
constexpr int kWanderers = 16;
constexpr int kSwimmers = 5;
constexpr int kMaxEmergedPerPlayer = 4;
constexpr float kWanderSpeed = 1.4f;
constexpr float kSwimSpeed = 0.35f;
constexpr float kFleeSpeed = 4.5f;
} // namespace
Creature& Game::addCreature(uint16_t species, glm::vec2 pos, COrigin origin) {
uint32_t id = nextEntity_++;
Creature& c = creatures_[id];
c.id = id;
c.species = species;
c.origin = origin;
c.pos = pos;
c.y = zone_.groundHeight(pos);
c.yaw = rng_.range(-kPi, kPi);
return c;
}
void Game::removeCreature(uint32_t id) {
auto it = creatures_.find(id);
if (it != creatures_.end()) it->second.dead = true;
}
float Game::nearestPlayerDist(glm::vec2 p, Player** who) {
float best = 1e9f;
if (who) *who = nullptr;
for (auto& [peer, pl] : players_) {
if (!pl->spawned) continue;
float d = glm::length(pl->move.pos - p);
if (d < best) {
best = d;
if (who) *who = pl.get();
}
}
return best;
}
void Game::announceAppeared(const Creature& c) {
for (auto& [peer, p] : players_) {
if (!p->spawned || glm::length(p->move.pos - c.pos) > 10.0f) continue;
auto it = creatures_.find(c.id);
if (it != creatures_.end()) it->second.seenBy.insert(p->entity);
noteSeen(*p, c.species);
}
}
void Game::faceNearestPlayer(Creature& c) {
Player* who = nullptr;
float d = nearestPlayerDist(c.pos, &who);
if (who && d < 12.0f && d > 0.05f) c.yaw = yawFromDir(who->move.pos - c.pos);
}
// Grass creatures that popped up for this player and are still around it (stale ones left
// behind in another patch do not block new encounters).
int Game::emergedCount(const Player& p) const {
int n = 0;
for (auto& [id, c] : creatures_)
n += !c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Grass && c.emergedFor == p.entity &&
glm::length(c.pos - p.move.pos) < 15.0f;
return n;
}
// ------------------------------------------------------------------ population
bool Game::spawnWanderer() {
uint16_t species;
if (!tables_.pick(rng_, SpawnHabitat::Land, species)) return false;
glm::vec2 spot, fallback;
bool have = false, haveFallback = false;
for (int i = 0; i < 8 && !have; ++i) {
glm::vec2 p;
if (!zone_.randomWanderSpot(rng_, p)) continue;
if (nearestPlayerDist(p) > 14.0f) spot = p, have = true;
else if (!haveFallback) fallback = p, haveFallback = true;
}
if (!have && !haveFallback) return false;
Creature& c = addCreature(species, have ? spot : fallback, COrigin::Wanderer);
c.mode = CMode::Wander;
c.anim = Anim::Idle;
c.timer = rng_.range(0.5f, 4.0f);
c.lifetime = rng_.range(120.0f, 300.0f);
announceAppeared(c);
return true;
}
bool Game::spawnSwimmer(bool announce) {
uint16_t species;
glm::vec2 p;
if (!tables_.pick(rng_, SpawnHabitat::Water, species) || !zone_.randomWater(rng_, p, true)) return false;
Creature& c = addCreature(species, p, COrigin::Swimmer);
c.y = Zone::kWaterLevel;
c.lifetime = 1e9f; // swimmers rotate by diving
if (announce) {
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
GameEvent s;
s.kind = EventKind::Splash;
s.p = glm::vec3(p.x, Zone::kWaterLevel, p.y);
sendEvent(s);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.i = 1;
e.p = s.p;
sendEvent(e);
} else {
c.mode = CMode::Swim;
c.anim = Anim::Swim;
}
announceAppeared(c);
return true;
}
void Game::maintainPopulation(float dt) {
int wanderers = 0, swimmers = 0;
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Wild) continue;
wanderers += c.origin == COrigin::Wanderer;
swimmers += c.origin == COrigin::Swimmer;
}
wanderSpawnTimer_ -= dt;
if (wanderers < kWanderers && wanderSpawnTimer_ <= 0.0f) {
spawnWanderer();
wanderSpawnTimer_ = 1.5f;
}
// One swimmer dives (Splash, despawn) every 10-25 s and resurfaces elsewhere a few seconds later.
swimmerTimer_ -= dt;
if (swimmerTimer_ <= 0.0f) {
swimmerTimer_ = rng_.range(10.0f, 25.0f);
std::vector<uint32_t> candidates;
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Wild && c.origin == COrigin::Swimmer && c.mode == CMode::Swim)
candidates.push_back(id);
if (!candidates.empty()) {
Creature& c = creatures_[candidates[size_t(rng_.range(0, int(candidates.size()) - 1))]];
GameEvent s;
s.kind = EventKind::Splash;
s.p = glm::vec3(c.pos.x, Zone::kWaterLevel, c.pos.y);
sendEvent(s);
removeCreature(c.id);
--swimmers;
swimmerRespawn_ = rng_.range(2.0f, 5.0f);
}
}
if (swimmerRespawn_ > 0.0f) {
swimmerRespawn_ -= dt;
if (swimmerRespawn_ <= 0.0f && swimmers < kSwimmers) spawnSwimmer(true), ++swimmers;
} else if (swimmers < kSwimmers) {
swimmerRespawn_ = rng_.range(2.0f, 5.0f); // replace caught swimmers
}
}
// ------------------------------------------------------------------ tall grass
void Game::grassEncounters(Player& p) {
while (p.grassDist >= 0.5f) {
p.grassDist -= 0.5f;
float chance = 0.07f * (p.move.running ? 2.2f : 1.0f);
uint16_t species;
glm::vec2 spot;
if (rng_.chance(chance) && emergedCount(p) < kMaxEmergedPerPlayer &&
tables_.pick(rng_, SpawnHabitat::Land, species) &&
zone_.randomTallGrassNear(rng_, p.move.pos, 2.0f, 6.0f, spot)) {
Creature& c = addCreature(species, spot, COrigin::Grass);
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
c.emergedFor = p.entity;
c.yaw = yawFromDir(p.move.pos - spot);
c.lifetime = rng_.range(60.0f, 120.0f);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.i = 0;
e.p = glm::vec3(spot.x, c.y, spot.y);
sendEvent(e);
announceAppeared(c);
const Species* sp = roster_.get(species);
logf("a wild %s (entity %u) emerged near %s", sp ? sp->name.c_str() : "?", c.id, p.row.name.c_str());
continue;
}
if (p.rustleCooldown <= 0.0f && rng_.chance(0.25f) &&
zone_.randomTallGrassNear(rng_, p.move.pos, 1.5f, 6.0f, spot)) {
p.rustleCooldown = rng_.range(0.8f, 1.6f);
GameEvent e;
e.kind = EventKind::Rustle;
e.p = glm::vec3(spot.x, zone_.groundHeight(spot), spot.y);
sendEvent(e);
}
}
}
// ------------------------------------------------------------------ behaviour
bool Game::pickWanderTarget(Creature& c) {
for (int i = 0; i < 6; ++i) {
glm::vec2 t;
if (c.origin == COrigin::Grass && zone_.inTallGrass(c.pos)) {
if (!zone_.randomTallGrassNear(rng_, c.pos, 2.0f, 6.0f, t)) continue;
} else {
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 6.0f);
t = c.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
}
bool ok = zone_.creatureWalkable(t, false);
for (int k = 1; k < 5 && ok; ++k) ok = zone_.creatureWalkable(glm::mix(c.pos, t, float(k) / 5.0f), false);
if (!ok) continue;
c.target = t;
c.walking = true;
return true;
}
return false;
}
void Game::wander(Creature& c, float dt, float speed) {
if (c.walking) {
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, speed * dt);
if (len < 0.05f || !zone_.creatureWalkable(next, false)) {
c.walking = false;
c.timer = rng_.range(1.0f, 4.0f);
c.anim = Anim::Idle;
} else {
c.pos = next;
c.yaw = yawFromDir(d);
c.anim = Anim::Walk;
}
} else {
c.anim = Anim::Idle;
c.timer -= dt;
if (c.timer <= 0.0f && !pickWanderTarget(c)) c.timer = 1.0f;
}
c.y = zone_.groundHeight(c.pos);
}
void Game::updateWild(Creature& c, float dt) {
// dex: wanderers (and anything else) register as seen once a player comes within 6 units
for (auto& [peer, p] : players_) {
if (!p->spawned || c.seenBy.count(p->entity) || glm::length(p->move.pos - c.pos) > 6.0f) continue;
c.seenBy.insert(p->entity);
noteSeen(*p, c.species);
}
switch (c.mode) {
case CMode::Emerge:
c.timer -= dt;
if (c.timer <= 0.0f) {
if (c.origin == COrigin::Swimmer) {
c.mode = CMode::Swim;
c.anim = Anim::Swim;
} else {
c.mode = CMode::Alert;
c.anim = Anim::Alert;
c.timer = 2.5f;
Player* who = byEntity(c.emergedFor);
if (who) c.yaw = yawFromDir(who->move.pos - c.pos);
else faceNearestPlayer(c);
}
}
break;
case CMode::Alert:
c.anim = Anim::Alert;
faceNearestPlayer(c);
c.timer -= dt;
if (c.timer <= 0.0f) {
c.hookBonus = false;
c.walking = false;
c.timer = rng_.range(0.5f, 2.0f);
bool water = zone_.isWater(c.pos);
c.mode = water ? CMode::Swim : CMode::Wander;
c.anim = water ? Anim::Swim : Anim::Idle;
}
break;
case CMode::Wander:
wander(c, dt, kWanderSpeed);
break;
case CMode::Swim: {
c.anim = Anim::Swim;
c.y = Zone::kWaterLevel;
if (!c.walking) {
glm::vec2 t;
if (zone_.randomWaterNear(rng_, c.pos, 3.0f, t) && zone_.isWater(glm::mix(c.pos, t, 0.5f))) {
c.target = t;
c.walking = true;
}
} else {
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
glm::vec2 next = c.pos + d / std::max(len, 1e-4f) * std::min(len, kSwimSpeed * dt);
if (len < 0.05f || !zone_.isWater(next)) c.walking = false;
else c.pos = next, c.yaw = yawFromDir(d);
}
break;
}
case CMode::Capturing:
c.captureTimer -= dt;
if (c.captureTimer <= 0.0f) resolveCapture(c);
break;
case CMode::Flee: {
c.timer -= dt;
glm::vec2 d = c.target - c.pos;
float len = glm::length(d);
if (len > 0.05f) {
glm::vec2 next = c.pos + d / len * std::min(len, kFleeSpeed * dt);
bool water = zone_.isWater(c.pos);
if (zone_.creatureWalkable(next, water)) c.pos = next;
c.yaw = yawFromDir(d);
}
if (!zone_.isWater(c.pos)) c.y = zone_.groundHeight(c.pos);
if (c.timer <= 0.0f) removeCreature(c.id);
break;
}
case CMode::Hooked: {
c.timer -= dt;
float t = std::clamp(1.0f - c.timer / 1.0f, 0.0f, 1.0f);
c.pos = glm::mix(c.arcFrom, c.arcTo, t);
float groundY = zone_.isWater(c.arcTo) ? Zone::kWaterLevel : zone_.groundHeight(c.arcTo);
c.y = glm::mix(c.arcFromY, groundY, t) + 1.6f * std::sin(kPi * t);
if (glm::length(c.arcTo - c.arcFrom) > 0.05f) c.yaw = yawFromDir(c.arcTo - c.arcFrom);
if (c.timer <= 0.0f) {
c.pos = c.arcTo;
c.y = groundY;
c.mode = CMode::Alert;
c.anim = Anim::Alert;
c.timer = 6.0f;
c.hookBonus = true;
c.owner = 0;
faceNearestPlayer(c);
}
break;
}
case CMode::Follow: break;
}
// Idle creatures nobody is near eventually leave (captures/hooks/flee never time out here).
if (c.mode == CMode::Wander || c.mode == CMode::Swim) {
c.lifetime -= dt;
if (c.lifetime <= 0.0f && nearestPlayerDist(c.pos) > 12.0f) removeCreature(c.id);
}
}
void Game::updateCreatures(float dt) {
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Wild) updateWild(c, dt);
}
// ------------------------------------------------------------------ capture
void Game::resolveCapture(Creature& c) {
Player* p = byEntity(c.owner);
const Species* sp = roster_.get(c.species);
if (!p || !sp) { // thrower left: the capsule pops open
c.mode = c.resumeMode;
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
c.timer = 2.5f;
c.owner = 0;
return;
}
GameEvent r;
r.kind = EventKind::CaptureResult;
r.a = p->entity;
r.b = c.id;
r.i = c.captureSuccess ? 1 : 0;
r.j = c.wobbles;
r.p = glm::vec3(c.pos.x, c.y, c.pos.y);
sendEvent(r);
if (c.captureSuccess) {
int party = int(std::count_if(p->creatures.begin(), p->creatures.end(), [](auto& o) { return o.inParty; }));
bool inParty = party < kMaxParty;
bool first = p->caught.count(c.species) == 0;
OwnedCreature oc;
oc.species = c.species;
oc.inParty = inParty;
oc.caughtAt = int64_t(std::time(nullptr));
oc.uid = db_.insertCreature(p->row.id, oc.species, oc.caughtAt, inParty);
if (!oc.uid) {
removeCreature(c.id);
sendToast(*p, "The capsule malfunctioned and the creature escaped");
return;
}
p->creatures.push_back(oc);
p->caught.insert(c.species);
p->seen.insert(c.species);
db_.markCaught(p->row.id, c.species);
sendProfile(*p);
GameEvent e;
e.kind = EventKind::Caught;
e.a = p->entity;
e.b = c.species;
e.i = first ? 1 : 0;
e.p = r.p;
sendEvent(e);
logf("%s caught %s (uid %u, %s)%s", p->row.name.c_str(), sp->name.c_str(), oc.uid,
inParty ? "party" : "box", first ? " - new species!" : "");
if (inParty) {
// the wild entity turns into the follower (same entity id)
c.kind = EntityKind::Follower;
c.origin = COrigin::Caught;
c.mode = CMode::Follow;
c.anim = Anim::Idle;
c.owner = p->entity;
c.uid = oc.uid;
c.y = zone_.groundHeight(c.pos);
c.hookBonus = false;
syncFollowers(*p);
} else {
removeCreature(c.id);
}
return;
}
++c.failedThrows;
float flee = sp->fleeRate * (c.failedThrows >= 2 ? 1.5f : 1.0f);
if (rng_.chance(flee)) {
GameEvent e;
e.kind = EventKind::Fled;
e.a = c.id;
e.p = r.p;
sendEvent(e);
c.mode = CMode::Flee;
c.anim = Anim::Flee;
c.timer = 1.5f;
glm::vec2 away = c.pos - p->move.pos;
if (glm::length(away) < 0.05f) away = dirFromYaw(c.yaw);
c.target = c.pos + glm::normalize(away) * 8.0f;
c.yaw = yawFromDir(away);
c.owner = p->entity;
logf("%s fled from %s", sp->name.c_str(), p->row.name.c_str());
} else {
c.mode = c.resumeMode;
c.anim = c.mode == CMode::Swim ? Anim::Swim : Anim::Alert;
c.timer = 2.5f;
c.owner = 0;
c.walking = false;
if (c.mode == CMode::Alert) c.yaw = yawFromDir(p->move.pos - c.pos);
}
}
// ------------------------------------------------------------------ followers
void Game::syncFollowers(Player& p) {
std::vector<const OwnedCreature*> party;
for (const OwnedCreature& o : p.creatures)
if (o.inParty && party.size() < size_t(kMaxParty)) party.push_back(&o);
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
auto it = std::find_if(party.begin(), party.end(), [&](const OwnedCreature* o) { return o->uid == c.uid; });
if (it == party.end()) removeCreature(id);
else c.order = uint8_t(it - party.begin());
}
for (size_t k = 0; k < party.size(); ++k) {
bool exists = false;
for (auto& [id, c] : creatures_)
if (!c.dead && c.kind == EntityKind::Follower && c.owner == p.entity && c.uid == party[k]->uid) exists = true;
if (exists) continue;
// trail points are >= 0.15 apart (seeded 0.3 apart): ~3 points back per chain slot
size_t back = p.trail.empty() ? 0 : std::min(p.trail.size() - 1, (k + 1) * 3);
Creature& c = addCreature(party[k]->species, p.trail.empty() ? p.move.pos : p.trail[back], COrigin::Caught);
c.kind = EntityKind::Follower;
c.mode = CMode::Follow;
c.anim = Anim::Idle;
c.owner = p.entity;
c.uid = party[k]->uid;
c.order = uint8_t(k);
c.yaw = p.move.yaw;
c.lifetime = 1e9f;
}
}
void Game::updateFollowers(Player& p, float dt) {
for (auto& [id, c] : creatures_) {
if (c.dead || c.kind != EntityKind::Follower || c.owner != p.entity) continue;
// target: ~0.95 * (order + 1) units back along the breadcrumb trail
float want = 0.95f * float(c.order + 1);
glm::vec2 prev = p.move.pos, target = prev;
float acc = 0.0f;
bool found = false;
for (const glm::vec2& t : p.trail) {
float seg = glm::length(t - prev);
if (seg > 1e-4f && acc + seg >= want) {
target = prev + (t - prev) * ((want - acc) / seg);
found = true;
break;
}
acc += seg;
prev = t;
}
if (!found) target = prev;
glm::vec2 d = target - c.pos;
float len = glm::length(d);
float moved = 0.0f;
if (len > 8.0f) {
c.pos = target;
} else if (len > 0.08f) {
float speed = (p.move.running ? kRunSpeed : kWalkSpeed) * 1.15f;
moved = std::min(len, speed * dt);
c.pos += d / len * moved;
c.yaw = yawFromDir(d);
}
float v = moved / dt;
c.anim = v < 0.3f ? Anim::Idle : v > kWalkSpeed * 1.1f ? Anim::Run : Anim::Walk;
c.y = zone_.groundHeight(c.pos);
}
}
Creature* Game::spawnForConsole(uint16_t species) {
Player* first = nullptr;
for (auto& [peer, p] : players_)
if (p->spawned) {
first = p.get();
break;
}
if (!first) return nullptr;
glm::vec2 spot;
bool ok = zone_.randomTallGrassNear(rng_, first->move.pos, 2.0f, 5.0f, spot);
for (int i = 0; i < 40 && !ok; ++i) {
float a = rng_.range(-kPi, kPi), r = rng_.range(2.0f, 5.0f);
spot = first->move.pos + glm::vec2(std::cos(a), std::sin(a)) * r;
ok = zone_.creatureWalkable(spot, false);
}
if (!ok) spot = first->move.pos + dirFromYaw(first->move.yaw) * 2.0f;
Creature& c = addCreature(species, spot, COrigin::Console);
c.mode = CMode::Emerge;
c.anim = Anim::Emerge;
c.timer = 0.9f;
c.emergedFor = first->entity;
c.yaw = yawFromDir(first->move.pos - spot);
c.lifetime = rng_.range(120.0f, 300.0f);
GameEvent e;
e.kind = EventKind::Emerge;
e.a = c.id;
e.p = glm::vec3(spot.x, c.y, spot.y);
sendEvent(e);
announceAppeared(c);
return &c;
}
} // namespace bs

320
server/Database.cpp Normal file
View File

@@ -0,0 +1,320 @@
#include "Database.h"
#include <nlohmann/json.hpp>
#include <sqlite3.h>
#include <cstdio>
#include <filesystem>
namespace bs {
namespace {
const char* kSchema = R"SQL(
CREATE TABLE IF NOT EXISTS players(
id INTEGER PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
appearance TEXT NOT NULL,
pos_x REAL NOT NULL,
pos_z REAL NOT NULL,
yaw REAL NOT NULL DEFAULT 0,
capsules INTEGER NOT NULL DEFAULT 0,
intro_done INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS creatures(
uid INTEGER PRIMARY KEY AUTOINCREMENT,
owner INTEGER NOT NULL REFERENCES players(id),
species INTEGER NOT NULL,
caught_at INTEGER NOT NULL,
in_party INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS creatures_owner ON creatures(owner);
CREATE TABLE IF NOT EXISTS dex(
owner INTEGER NOT NULL REFERENCES players(id),
species INTEGER NOT NULL,
seen INTEGER NOT NULL DEFAULT 0,
caught INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(owner, species));
CREATE TABLE IF NOT EXISTS world(
key TEXT PRIMARY KEY,
value TEXT NOT NULL);
)SQL";
std::string text(sqlite3_stmt* s, int col) {
const unsigned char* t = sqlite3_column_text(s, col);
return t ? reinterpret_cast<const char*>(t) : "";
}
void bindText(sqlite3_stmt* s, int i, const std::string& v) {
sqlite3_bind_text(s, i, v.data(), int(v.size()), SQLITE_TRANSIENT);
}
} // namespace
std::string appearanceToJson(const Appearance& a) {
nlohmann::json j;
j["body"] = a.body;
j["skin"] = a.skin;
nlohmann::json items = nlohmann::json::object(), colors = nlohmann::json::object();
for (int s = 0; s < kSlotCount; ++s) {
items[slotName(s)] = a.items[s];
colors[slotName(s)] = hexColor(a.colors[s]);
}
j["items"] = items;
j["colors"] = colors;
return j.dump();
}
Appearance appearanceFromJson(const std::string& s) {
Appearance a;
try {
nlohmann::json j = nlohmann::json::parse(s);
a.body = j.value("body", a.body);
a.skin = uint8_t(j.value("skin", 0));
for (int i = 0; i < kSlotCount; ++i) {
if (j.contains("items")) a.items[i] = j["items"].value(slotName(i), "");
if (j.contains("colors")) a.colors[i] = parseHexColor(j["colors"].value(slotName(i), "#ffffff"));
}
} catch (const std::exception&) {
}
return a;
}
Database::~Database() { close(); }
bool Database::open(const std::string& path, std::string* error) {
std::error_code ec;
std::filesystem::path p(path);
if (p.has_parent_path()) std::filesystem::create_directories(p.parent_path(), ec);
if (sqlite3_open(path.c_str(), &db_) != SQLITE_OK) {
if (error) *error = db_ ? sqlite3_errmsg(db_) : "sqlite3_open failed";
close();
return false;
}
sqlite3_busy_timeout(db_, 2000);
exec("PRAGMA journal_mode=WAL;");
exec("PRAGMA synchronous=NORMAL;");
exec("PRAGMA foreign_keys=ON;");
if (!exec(kSchema)) {
if (error) *error = sqlite3_errmsg(db_);
close();
return false;
}
return true;
}
void Database::close() {
for (auto& [sql, s] : stmts_) sqlite3_finalize(s);
stmts_.clear();
if (db_) sqlite3_close(db_);
db_ = nullptr;
}
bool Database::exec(const char* sql) {
char* err = nullptr;
if (sqlite3_exec(db_, sql, nullptr, nullptr, &err) != SQLITE_OK) {
std::fprintf(stderr, "[db] %s\n", err ? err : "error");
sqlite3_free(err);
return false;
}
return true;
}
sqlite3_stmt* Database::prepare(const char* sql) {
if (!db_) return nullptr;
auto it = stmts_.find(sql);
if (it != stmts_.end()) {
sqlite3_reset(it->second);
sqlite3_clear_bindings(it->second);
return it->second;
}
sqlite3_stmt* s = nullptr;
if (sqlite3_prepare_v2(db_, sql, -1, &s, nullptr) != SQLITE_OK) {
std::fprintf(stderr, "[db] prepare failed: %s\n", sqlite3_errmsg(db_));
return nullptr;
}
stmts_[sql] = s;
return s;
}
void Database::begin() {
if (db_) exec("BEGIN;");
}
void Database::commit() {
if (db_) exec("COMMIT;");
}
bool Database::loadPlayer(const std::string& token, PlayerRow& out) {
sqlite3_stmt* s = prepare(
"SELECT id, name, appearance, pos_x, pos_z, yaw, capsules, intro_done, created_at, last_seen "
"FROM players WHERE token = ?");
if (!s) return false;
bindText(s, 1, token);
bool found = sqlite3_step(s) == SQLITE_ROW;
if (found) {
out.id = sqlite3_column_int64(s, 0);
out.token = token;
out.name = text(s, 1);
out.look = appearanceFromJson(text(s, 2));
out.x = float(sqlite3_column_double(s, 3));
out.z = float(sqlite3_column_double(s, 4));
out.yaw = float(sqlite3_column_double(s, 5));
out.capsules = sqlite3_column_int(s, 6);
out.introDone = sqlite3_column_int(s, 7) != 0;
out.createdAt = sqlite3_column_int64(s, 8);
out.lastSeen = sqlite3_column_int64(s, 9);
}
sqlite3_reset(s);
return found;
}
int64_t Database::createPlayer(const PlayerRow& r) {
sqlite3_stmt* s = prepare(
"INSERT INTO players(token, name, appearance, pos_x, pos_z, yaw, capsules, intro_done, created_at, last_seen) "
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
if (!s) return 0;
bindText(s, 1, r.token);
bindText(s, 2, r.name);
bindText(s, 3, appearanceToJson(r.look));
sqlite3_bind_double(s, 4, r.x);
sqlite3_bind_double(s, 5, r.z);
sqlite3_bind_double(s, 6, r.yaw);
sqlite3_bind_int(s, 7, r.capsules);
sqlite3_bind_int(s, 8, r.introDone);
sqlite3_bind_int64(s, 9, r.createdAt);
sqlite3_bind_int64(s, 10, r.lastSeen);
int64_t id = 0;
if (sqlite3_step(s) == SQLITE_DONE) id = sqlite3_last_insert_rowid(db_);
else std::fprintf(stderr, "[db] createPlayer: %s\n", sqlite3_errmsg(db_));
sqlite3_reset(s);
return id;
}
void Database::savePlayer(const PlayerRow& r) {
sqlite3_stmt* s = prepare(
"UPDATE players SET name = ?, appearance = ?, pos_x = ?, pos_z = ?, yaw = ?, capsules = ?, intro_done = ?, "
"last_seen = ? WHERE id = ?");
if (!s) return;
bindText(s, 1, r.name);
bindText(s, 2, appearanceToJson(r.look));
sqlite3_bind_double(s, 3, r.x);
sqlite3_bind_double(s, 4, r.z);
sqlite3_bind_double(s, 5, r.yaw);
sqlite3_bind_int(s, 6, r.capsules);
sqlite3_bind_int(s, 7, r.introDone);
sqlite3_bind_int64(s, 8, r.lastSeen);
sqlite3_bind_int64(s, 9, r.id);
if (sqlite3_step(s) != SQLITE_DONE) std::fprintf(stderr, "[db] savePlayer: %s\n", sqlite3_errmsg(db_));
sqlite3_reset(s);
}
void Database::savePosition(int64_t id, float x, float z, float yaw) {
sqlite3_stmt* s = prepare("UPDATE players SET pos_x = ?, pos_z = ?, yaw = ?, last_seen = strftime('%s','now') WHERE id = ?");
if (!s) return;
sqlite3_bind_double(s, 1, x);
sqlite3_bind_double(s, 2, z);
sqlite3_bind_double(s, 3, yaw);
sqlite3_bind_int64(s, 4, id);
sqlite3_step(s);
sqlite3_reset(s);
}
std::vector<OwnedCreature> Database::loadCreatures(int64_t owner) {
std::vector<OwnedCreature> out;
sqlite3_stmt* s = prepare("SELECT uid, species, in_party, caught_at FROM creatures WHERE owner = ? ORDER BY uid");
if (!s) return out;
sqlite3_bind_int64(s, 1, owner);
while (sqlite3_step(s) == SQLITE_ROW) {
OwnedCreature c;
c.uid = uint32_t(sqlite3_column_int64(s, 0));
c.species = uint16_t(sqlite3_column_int(s, 1));
c.inParty = sqlite3_column_int(s, 2) != 0;
c.caughtAt = sqlite3_column_int64(s, 3);
out.push_back(c);
}
sqlite3_reset(s);
return out;
}
uint32_t Database::insertCreature(int64_t owner, uint16_t species, int64_t caughtAt, bool inParty) {
sqlite3_stmt* s = prepare("INSERT INTO creatures(owner, species, caught_at, in_party) VALUES(?, ?, ?, ?)");
if (!s) return 0;
sqlite3_bind_int64(s, 1, owner);
sqlite3_bind_int(s, 2, species);
sqlite3_bind_int64(s, 3, caughtAt);
sqlite3_bind_int(s, 4, inParty);
uint32_t uid = 0;
if (sqlite3_step(s) == SQLITE_DONE) uid = uint32_t(sqlite3_last_insert_rowid(db_));
else std::fprintf(stderr, "[db] insertCreature: %s\n", sqlite3_errmsg(db_));
sqlite3_reset(s);
return uid;
}
void Database::setInParty(uint32_t uid, bool inParty) {
sqlite3_stmt* s = prepare("UPDATE creatures SET in_party = ? WHERE uid = ?");
if (!s) return;
sqlite3_bind_int(s, 1, inParty);
sqlite3_bind_int64(s, 2, uid);
sqlite3_step(s);
sqlite3_reset(s);
}
void Database::loadDex(int64_t owner, std::set<uint16_t>& seen, std::set<uint16_t>& caught) {
seen.clear();
caught.clear();
sqlite3_stmt* s = prepare("SELECT species, seen, caught FROM dex WHERE owner = ?");
if (!s) return;
sqlite3_bind_int64(s, 1, owner);
while (sqlite3_step(s) == SQLITE_ROW) {
uint16_t sp = uint16_t(sqlite3_column_int(s, 0));
if (sqlite3_column_int(s, 1)) seen.insert(sp);
if (sqlite3_column_int(s, 2)) caught.insert(sp);
}
sqlite3_reset(s);
}
void Database::markSeen(int64_t owner, uint16_t species) {
sqlite3_stmt* s = prepare(
"INSERT INTO dex(owner, species, seen, caught) VALUES(?, ?, 1, 0) "
"ON CONFLICT(owner, species) DO UPDATE SET seen = 1");
if (!s) return;
sqlite3_bind_int64(s, 1, owner);
sqlite3_bind_int(s, 2, species);
sqlite3_step(s);
sqlite3_reset(s);
}
void Database::markCaught(int64_t owner, uint16_t species) {
sqlite3_stmt* s = prepare(
"INSERT INTO dex(owner, species, seen, caught) VALUES(?, ?, 1, 1) "
"ON CONFLICT(owner, species) DO UPDATE SET seen = 1, caught = 1");
if (!s) return;
sqlite3_bind_int64(s, 1, owner);
sqlite3_bind_int(s, 2, species);
sqlite3_step(s);
sqlite3_reset(s);
}
bool Database::getWorld(const std::string& key, std::string& value) {
sqlite3_stmt* s = prepare("SELECT value FROM world WHERE key = ?");
if (!s) return false;
bindText(s, 1, key);
bool found = sqlite3_step(s) == SQLITE_ROW;
if (found) value = text(s, 0);
sqlite3_reset(s);
return found;
}
void Database::setWorld(const std::string& key, const std::string& value) {
sqlite3_stmt* s = prepare(
"INSERT INTO world(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
if (!s) return;
bindText(s, 1, key);
bindText(s, 2, value);
sqlite3_step(s);
sqlite3_reset(s);
}
} // namespace bs

69
server/Database.h Normal file
View File

@@ -0,0 +1,69 @@
#pragma once
// SQLite persistence (WAL): accounts/characters, owned creatures, dex and a
// small key/value table for world state (clock, weather).
#include "Appearance.h"
#include "Protocol.h"
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
struct sqlite3;
struct sqlite3_stmt;
namespace bs {
struct PlayerRow {
int64_t id = 0;
std::string token, name;
Appearance look;
float x = 0, z = 0, yaw = 0;
int capsules = 0;
bool introDone = false;
int64_t createdAt = 0, lastSeen = 0;
};
class Database {
public:
Database() = default;
Database(const Database&) = delete;
Database& operator=(const Database&) = delete;
~Database();
bool open(const std::string& path, std::string* error);
void close();
bool loadPlayer(const std::string& token, PlayerRow& out);
int64_t createPlayer(const PlayerRow& row); // returns the new id, 0 on failure
void savePlayer(const PlayerRow& row); // everything but token / created_at
void savePosition(int64_t id, float x, float z, float yaw);
std::vector<OwnedCreature> loadCreatures(int64_t owner);
uint32_t insertCreature(int64_t owner, uint16_t species, int64_t caughtAt, bool inParty); // uid, 0 on failure
void setInParty(uint32_t uid, bool inParty);
void loadDex(int64_t owner, std::set<uint16_t>& seen, std::set<uint16_t>& caught);
void markSeen(int64_t owner, uint16_t species);
void markCaught(int64_t owner, uint16_t species);
bool getWorld(const std::string& key, std::string& value);
void setWorld(const std::string& key, const std::string& value);
// Groups many writes into one transaction (shutdown, periodic saves).
void begin();
void commit();
private:
sqlite3_stmt* prepare(const char* sql); // cached, reset before reuse
bool exec(const char* sql);
sqlite3* db_ = nullptr;
std::unordered_map<const char*, sqlite3_stmt*> stmts_; // keyed by the SQL literal
};
std::string appearanceToJson(const Appearance& a);
Appearance appearanceFromJson(const std::string& s);
} // namespace bs

868
server/Game.cpp Normal file
View File

@@ -0,0 +1,868 @@
#include "Game.h"
#include "Log.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <ctime>
#include <sstream>
namespace bs {
namespace {
constexpr float kPi = 3.14159265f;
constexpr size_t kSnapshotBudget = 1200;
constexpr size_t kSnapshotHeader = 1 + 4 + 4 + 4 + 1 + 4 + 2;
constexpr size_t kEntityBytes = 4 + 1 + 1 + 2 + 4 + 12 + 4 + 1;
constexpr size_t kMaxSnapshotEntities = (kSnapshotBudget - kSnapshotHeader) / kEntityBytes; // 40
constexpr float kCullDist = 45.0f;
int64_t unixNow() { return int64_t(std::time(nullptr)); }
bool validToken(const std::string& t) {
if (t.size() < 16 || t.size() > 64) return false;
return std::all_of(t.begin(), t.end(), [](char c) { return std::isxdigit(uint8_t(c)) != 0; });
}
std::string lower(std::string s) {
for (char& c : s) c = char(std::tolower(uint8_t(c)));
return s;
}
std::string trim(const std::string& s) {
size_t a = s.find_first_not_of(" \t\r\n");
if (a == std::string::npos) return {};
size_t b = s.find_last_not_of(" \t\r\n");
return s.substr(a, b - a + 1);
}
// Printable ASCII only, single spaces, at most 16 characters.
std::string cleanName(const std::string& in) {
std::string out;
for (char ch : in) {
uint8_t c = uint8_t(ch);
if (c < 0x20 || c > 0x7e) continue;
if (c == ' ' && (out.empty() || out.back() == ' ')) continue;
out.push_back(char(c));
if (out.size() >= 16) break;
}
out = trim(out);
return out.empty() ? "Ranger" : out;
}
// Control characters become spaces; trimmed; cut to 120 bytes on a UTF-8 boundary.
std::string cleanChat(const std::string& in) {
std::string s = in;
for (char& ch : s)
if (uint8_t(ch) < 0x20 || uint8_t(ch) == 0x7f) ch = ' ';
s = trim(s);
if (s.size() > 120) {
size_t n = 120;
while (n > 0 && (uint8_t(s[n]) & 0xC0) == 0x80) --n;
s = trim(s.substr(0, n));
}
return s;
}
float wrapAngle(float a) {
while (a > kPi) a -= 2.0f * kPi;
while (a < -kPi) a += 2.0f * kPi;
return a;
}
} // namespace
Game::Game(net::Host& host, Database& db, Zone& zone, const Roster& roster, const CharacterManifest& manifest,
GameConfig cfg)
: host_(host), db_(db), zone_(zone), roster_(roster), manifest_(manifest), cfg_(std::move(cfg)),
rng_(uint64_t(unixNow()) ^ 0xB5A1u) {
std::string v;
if (cfg_.hour >= 0.0f) hour_ = std::fmod(cfg_.hour, 24.0f);
else if (db_.getWorld("hour", v)) hour_ = std::clamp(std::strtof(v.c_str(), nullptr), 0.0f, 23.999f);
Weather w = Weather::Clear;
if (cfg_.weather >= 0) weather_ = Weather(cfg_.weather);
else if (db_.getWorld("weather", v) && parseWeather(v, w)) weather_ = w;
weatherTimer_ = rng_.range(180.0f, 480.0f);
if (cfg_.weather < 0 && db_.getWorld("weather_timer", v))
weatherTimer_ = std::clamp(std::strtof(v.c_str(), nullptr), 5.0f, 480.0f);
henryYaw_ = zone_.henryYaw;
tables_.update(roster_, zone_.id(), hour_, weather_);
for (int i = 0; i < 16; ++i) spawnWanderer();
for (int i = 0; i < 5; ++i) spawnSwimmer(false);
logf("world: %s seed %u, %.2fh, %s, %zu species, %zu wild", zone_.id().c_str(), zone_.seed(), hour_,
weatherName(weather_), roster_.size(), creatures_.size());
}
// ------------------------------------------------------------------ sessions
Player* Game::byPeer(uint32_t peer) {
auto it = players_.find(peer);
return it == players_.end() ? nullptr : it->second.get();
}
Player* Game::byEntity(uint32_t entity) {
if (!entity) return nullptr;
for (auto& [peer, p] : players_)
if (p->spawned && p->entity == entity) return p.get();
return nullptr;
}
int Game::playerCount() const {
int n = 0;
for (auto& [peer, p] : players_) n += p->spawned;
return n;
}
void Game::onConnect(uint32_t peer) {
auto p = std::make_unique<Player>();
p->peer = peer;
players_[peer] = std::move(p);
logf("peer %u connected from %s", peer, host_.address(peer).c_str());
}
void Game::onDisconnect(uint32_t peer) {
Player* p = byPeer(peer);
if (!p) return;
if (p->spawned) logf("%s (entity %u) left", p->row.name.c_str(), p->entity);
else logf("peer %u disconnected", peer);
despawnPlayer(*p);
players_.erase(peer);
}
void Game::reject(uint32_t peer, const std::string& reason) {
Writer w(Msg::Reject);
w.str(reason);
host_.send(peer, net::Reliable, w.data());
if (Player* p = byPeer(peer)) despawnPlayer(*p);
host_.disconnect(peer);
players_.erase(peer);
logf("rejected peer %u: %s", peer, reason.c_str());
}
void Game::onReceive(uint32_t peer, const std::vector<uint8_t>& data) {
Player* p = byPeer(peer);
if (!p || data.empty()) return;
Reader r(data);
Msg m = Msg(r.u8());
if (m == Msg::Hello) return handleHello(*p, r);
if (!p->helloDone) return;
if (m == Msg::CreateCharacter) return handleCreate(*p, r);
if (!p->spawned) return;
switch (m) {
case Msg::Input: handleInput(*p, r); break;
case Msg::Action: handleAction(*p, r); break;
case Msg::Chat: handleChat(*p, r); break;
case Msg::DialogueDone: handleDialogueDone(*p, r); break;
default: break;
}
}
void Game::handleHello(Player& p, Reader& r) {
HelloMsg h;
h.read(r);
if (!r.ok() || p.helloDone) return;
uint32_t peer = p.peer;
if (h.version != kProtocolVersion) {
return reject(peer, "Version mismatch: the server runs protocol v" + std::to_string(kProtocolVersion) +
", your game uses v" + std::to_string(h.version) + ". Please update.");
}
if (!validToken(h.token)) return reject(peer, "Invalid account token.");
std::string token = lower(h.token);
// Same account connecting again: the older connection is kicked.
std::vector<uint32_t> older;
for (auto& [op, other] : players_)
if (op != peer && other->helloDone && other->row.token == token) older.push_back(op);
for (uint32_t op : older) reject(op, "Signed in from another location.");
p.helloDone = true;
p.row.token = token;
p.hasCharacter = db_.loadPlayer(token, p.row);
if (p.hasCharacter) p.entity = nextEntity_++;
WelcomeMsg wm;
wm.entityId = p.entity;
wm.hasCharacter = p.hasCharacter;
wm.zoneId = zone_.id();
wm.zoneSeed = zone_.seed();
wm.serverName = cfg_.serverName;
Writer w(Msg::Welcome);
wm.write(w);
send(p, w);
logf("peer %u hello (%s account)", peer, p.hasCharacter ? "returning" : "new");
if (p.hasCharacter) spawnPlayer(p);
}
void Game::handleCreate(Player& p, Reader& r) {
CreateCharacterMsg m;
m.read(r);
if (!r.ok() || p.hasCharacter) return;
p.row.name = cleanName(m.name);
p.row.look = m.look;
manifest_.sanitize(p.row.look);
p.row.x = zone_.spawn.x;
p.row.z = zone_.spawn.y;
p.row.yaw = zone_.spawnYaw;
p.row.capsules = 0;
p.row.introDone = false;
p.row.createdAt = p.row.lastSeen = unixNow();
p.row.id = db_.createPlayer(p.row);
if (!p.row.id) return reject(p.peer, "The server could not create your character.");
p.hasCharacter = true;
p.entity = nextEntity_++;
logf("new character \"%s\" (account %lld)", p.row.name.c_str(), (long long)p.row.id);
spawnPlayer(p);
}
void Game::spawnPlayer(Player& p) {
p.creatures = db_.loadCreatures(p.row.id);
db_.loadDex(p.row.id, p.seen, p.caught);
glm::vec2 pos(p.row.x, p.row.z);
bool ok = std::isfinite(pos.x) && std::isfinite(pos.y) && zone_.walkable(pos, pos) &&
(zone_.onDock(pos) || !zone_.isWater(pos));
if (!ok) pos = zone_.spawn;
p.move = MoveState{};
p.move.pos = pos;
p.move.yaw = std::isfinite(p.row.yaw) ? p.row.yaw : zone_.spawnYaw;
p.spawned = true;
p.lastSeq = 0;
p.inputBudget = 2.0f;
p.trail.clear();
p.trail.push_back(pos);
// Seed the trail behind the player so followers start in a line.
glm::vec2 back = -dirFromYaw(p.move.yaw);
for (float d = 0.3f; d <= 6.0f; d += 0.3f) p.trail.push_back(pos + back * d);
sendProfile(p);
Writer self(Msg::PlayerInfo);
playerInfo(p).write(self);
broadcast(self);
for (auto& [peer, o] : players_) {
if (o.get() == &p || !o->spawned) continue;
Writer w(Msg::PlayerInfo);
playerInfo(*o).write(w);
send(p, w);
}
syncFollowers(p);
if (!p.row.introDone) p.introTimer = 1.2f;
logf("%s spawned as entity %u at (%.1f, %.1f), %d capsules, %zu creatures, intro %s", p.row.name.c_str(),
p.entity, pos.x, pos.y, p.row.capsules, p.creatures.size(), p.row.introDone ? "done" : "pending");
}
void Game::despawnPlayer(Player& p) {
if (!p.spawned) return;
savePlayer(p);
p.spawned = false;
Writer w(Msg::PlayerLeft);
w.u32(p.entity);
broadcast(w);
for (auto& [id, c] : creatures_) {
if (c.dead) continue;
if (c.kind == EntityKind::Follower && c.owner == p.entity) removeCreature(id);
else if (c.owner == p.entity && c.mode != CMode::Capturing) c.owner = 0; // captures resolve as aborted
if (c.emergedFor == p.entity) c.emergedFor = 0;
}
}
// ------------------------------------------------------------------ messaging
void Game::send(const Player& p, const Writer& w, bool reliable) {
host_.send(p.peer, reliable ? net::Reliable : net::Unreliable, w.data());
}
void Game::broadcast(const Writer& w, bool reliable) {
for (auto& [peer, p] : players_)
if (p->spawned) send(*p, w, reliable);
}
PlayerInfoMsg Game::playerInfo(const Player& p) const {
PlayerInfoMsg m;
m.entityId = p.entity;
m.name = p.row.name;
m.look = p.row.look;
return m;
}
void Game::sendProfile(Player& p) {
if (!p.spawned) return;
ProfileMsg m;
m.entityId = p.entity;
m.name = p.row.name;
m.look = p.row.look;
m.capsules = p.row.capsules;
m.introDone = p.row.introDone;
m.seen = uint32_t(p.seen.size());
m.caught = uint32_t(p.caught.size());
m.creatures = p.creatures;
Writer w(Msg::Profile);
m.write(w);
send(p, w);
}
void Game::sendEvent(const GameEvent& e) {
Writer w(Msg::Event);
e.write(w);
broadcast(w);
}
void Game::sendToast(Player& p, const std::string& text) {
Writer w(Msg::Toast);
w.str(text);
send(p, w);
}
void Game::sendDialogue(Player& p, const std::string& id) {
Writer w(Msg::Dialogue);
w.str(id);
send(p, w);
}
Anim Game::playerAnim(const Player& p) const {
if (p.line != LineState::None) return Anim::Fish;
if (p.throwTimer > 0.0f) return Anim::Throw;
if (p.move.moving) return p.move.running ? Anim::Run : Anim::Walk;
return Anim::Idle;
}
void Game::sendSnapshots() {
struct Item {
EntityState s;
glm::vec2 p;
bool always;
};
std::vector<Item> all;
all.reserve(creatures_.size() + players_.size() + 1);
{
EntityState h;
h.id = kHenryEntityId;
h.kind = EntityKind::Npc;
h.species = 0;
h.pos = pos3(zone_.henry);
h.yaw = henryYaw_;
all.push_back({h, zone_.henry, true});
}
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
EntityState s;
s.id = p->entity;
s.kind = EntityKind::Player;
s.anim = playerAnim(*p);
s.pos = pos3(p->move.pos);
s.yaw = p->move.yaw;
all.push_back({s, p->move.pos, true});
}
for (auto& [id, c] : creatures_) {
if (c.dead) continue;
EntityState s;
s.id = c.id;
s.kind = c.kind;
s.anim = c.anim;
s.species = c.species;
s.owner = c.owner;
s.pos = glm::vec3(c.pos.x, c.y, c.pos.y);
s.yaw = c.yaw;
s.order = c.order;
all.push_back({s, c.pos, false});
}
std::vector<std::pair<float, size_t>> near;
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
SnapshotMsg m;
m.tick = tick_;
m.ackInput = p->lastSeq;
m.hour = hour_;
m.weather = weather_;
m.weatherBlend = weatherBlend_;
near.clear();
for (size_t i = 0; i < all.size(); ++i) {
if (all[i].always) {
if (m.entities.size() < kMaxSnapshotEntities) m.entities.push_back(all[i].s);
continue;
}
float d = glm::length(all[i].p - p->move.pos);
if (d <= kCullDist) near.push_back({d, i});
}
std::sort(near.begin(), near.end());
for (auto& [d, i] : near) {
if (m.entities.size() >= kMaxSnapshotEntities) break;
m.entities.push_back(all[i].s);
}
Writer w(Msg::Snapshot);
m.write(w);
send(*p, w, false);
}
}
// ------------------------------------------------------------------ persistence
void Game::savePlayer(Player& p) {
if (!p.hasCharacter || !p.row.id) return;
if (p.spawned) {
p.row.x = p.move.pos.x;
p.row.z = p.move.pos.y;
p.row.yaw = p.move.yaw;
}
p.row.lastSeen = unixNow();
db_.savePlayer(p.row);
p.posDirty = false;
}
void Game::saveWorld() {
char buf[32];
std::snprintf(buf, sizeof buf, "%.4f", hour_);
db_.setWorld("hour", buf);
db_.setWorld("weather", weatherName(weather_));
std::snprintf(buf, sizeof buf, "%.1f", weatherTimer_);
db_.setWorld("weather_timer", buf);
}
void Game::saveAll() {
db_.begin();
for (auto& [peer, p] : players_)
if (p->spawned) savePlayer(*p);
saveWorld();
db_.commit();
}
// ------------------------------------------------------------------ players
void Game::handleInput(Player& p, Reader& r) {
InputMsg m;
m.read(r);
if (!r.ok()) return;
for (const InputCmd& c : m.cmds) {
if (c.seq <= p.lastSeq) continue;
if (p.inputBudget < 1.0f) break; // faster than real time: drop the rest
p.inputBudget -= 1.0f;
applyInput(p, c);
p.lastSeq = c.seq;
}
}
void Game::applyInput(Player& p, const InputCmd& c) {
glm::vec2 prev = p.move.pos;
stepMovement(zone_, p.move, c, kTickDt);
p.idleTicks = 0;
float moved = glm::length(p.move.pos - prev);
if (moved <= 0.0f) return;
p.posDirty = true;
if (zone_.inTallGrass(p.move.pos)) p.grassDist += moved;
if (p.trail.empty() || glm::length(p.move.pos - p.trail.front()) > 0.15f) {
p.trail.push_front(p.move.pos);
if (p.trail.size() > 64) p.trail.pop_back();
}
if (p.line != LineState::None && glm::length(p.move.pos - p.castPos) > 0.6f) cancelLine(p);
}
void Game::cancelLine(Player& p) {
if (p.line == LineState::None) return;
p.line = LineState::None;
p.fishCooldown = 1.0f;
GameEvent e;
e.kind = EventKind::LineLost;
e.a = p.entity;
e.p = glm::vec3(p.bobber.x, Zone::kWaterLevel, p.bobber.y);
sendEvent(e);
}
void Game::noteSeen(Player& p, uint16_t species) {
if (!p.spawned || !p.seen.insert(species).second) return;
db_.markSeen(p.row.id, species);
sendProfile(p);
}
void Game::updatePlayer(Player& p, float dt) {
p.inputBudget = std::min(6.0f, p.inputBudget + 1.0f);
if (++p.idleTicks > 4) p.move.moving = p.move.running = false;
p.throwTimer = std::max(0.0f, p.throwTimer - dt);
p.fishCooldown = std::max(0.0f, p.fishCooldown - dt);
p.rustleCooldown = std::max(0.0f, p.rustleCooldown - dt);
if (p.introTimer > 0.0f) {
p.introTimer -= dt;
if (p.introTimer <= 0.0f) {
p.introTimer = -1.0f;
if (!p.row.introDone) {
p.introOffered = true;
sendDialogue(p, "henry_intro");
}
}
}
if (p.line != LineState::None) {
p.lineTimer -= dt;
if (p.lineTimer <= 0.0f) {
GameEvent e;
e.a = p.entity;
e.p = glm::vec3(p.bobber.x, Zone::kWaterLevel, p.bobber.y);
if (p.line == LineState::Waiting) {
p.line = LineState::Biting;
p.lineTimer = 1.3f;
e.kind = EventKind::Bite;
sendEvent(e);
} else {
cancelLine(p); // too late
}
}
}
grassEncounters(p);
}
// ------------------------------------------------------------------ actions
void Game::handleAction(Player& p, Reader& r) {
ActionMsg a;
a.read(r);
if (!r.ok()) return;
switch (a.kind) {
case ActionKind::Throw: actionThrow(p, a.target); break;
case ActionKind::Cast: actionCast(p, {a.x, a.z}); break;
case ActionKind::Reel: actionReel(p); break;
case ActionKind::Interact: actionInteract(p, a.target); break;
case ActionKind::ToggleParty: actionToggleParty(p, a.target); break;
default: break;
}
}
void Game::actionCast(Player& p, glm::vec2 dir) {
if (p.line != LineState::None || p.fishCooldown > 0.0f) return;
if (!std::isfinite(dir.x) || !std::isfinite(dir.y) || glm::length(dir) < 0.1f) return;
if (zone_.isWater(p.move.pos)) return;
glm::vec2 bobber;
if (!zone_.fishingTarget(p.move.pos, dir, bobber)) {
sendToast(p, "There's no water in reach to cast into");
return;
}
p.line = LineState::Waiting;
p.lineTimer = rng_.range(2.0f, 7.0f);
p.castPos = p.move.pos;
p.bobber = bobber;
p.move.yaw = yawFromDir(glm::normalize(dir));
GameEvent e;
e.kind = EventKind::Cast;
e.a = p.entity;
e.p = glm::vec3(bobber.x, Zone::kWaterLevel, bobber.y);
sendEvent(e);
logf("%s cast a line at (%.1f, %.1f)", p.row.name.c_str(), bobber.x, bobber.y);
}
bool Game::landingSpot(glm::vec2 bobber, glm::vec2 player, glm::vec2& out) const {
glm::vec2 d = player - bobber;
float len = glm::length(d);
if (len > 1e-3f) {
for (float t = 0.25f; t <= len; t += 0.25f) {
glm::vec2 q = bobber + d / len * t;
if (zone_.creatureWalkable(q, false)) {
out = q;
return true;
}
}
}
// Player stands on a dock or the shore is behind them: nearest land around the player.
for (float r = 1.0f; r <= 10.0f; r += 0.5f)
for (int k = 0; k < 24; ++k) {
float a = float(k) / 24.0f * 2.0f * kPi;
glm::vec2 q = player + glm::vec2(std::cos(a), std::sin(a)) * r;
if (zone_.creatureWalkable(q, false)) {
out = q;
return true;
}
}
return false;
}
void Game::actionReel(Player& p) {
if (p.line == LineState::None) return;
if (p.line == LineState::Waiting) {
logf("%s reeled in too early", p.row.name.c_str());
return cancelLine(p);
}
uint16_t species;
tables_.update(roster_, zone_.id(), hour_, weather_);
if (!tables_.pick(rng_, SpawnHabitat::Water, species)) return cancelLine(p);
glm::vec2 bobber = p.bobber;
p.line = LineState::None;
p.fishCooldown = 1.0f;
Creature& c = addCreature(species, bobber, COrigin::Fished);
c.mode = CMode::Hooked;
c.anim = Anim::Hooked;
c.owner = p.entity;
c.timer = 1.0f;
c.arcFrom = bobber;
c.arcFromY = Zone::kWaterLevel;
c.y = Zone::kWaterLevel;
c.arcTo = bobber;
glm::vec2 land;
if (landingSpot(bobber, p.move.pos, land)) c.arcTo = land;
c.yaw = yawFromDir(p.move.pos - bobber);
c.lifetime = rng_.range(60.0f, 120.0f);
GameEvent e;
e.kind = EventKind::Hooked;
e.a = p.entity;
e.b = c.id;
e.p = glm::vec3(bobber.x, Zone::kWaterLevel, bobber.y);
sendEvent(e);
announceAppeared(c);
const Species* sp = roster_.get(species);
logf("%s hooked a %s (entity %u)", p.row.name.c_str(), sp ? sp->name.c_str() : "?", c.id);
}
void Game::actionThrow(Player& p, uint32_t target) {
auto it = creatures_.find(target);
if (it == creatures_.end() || it->second.dead || it->second.kind != EntityKind::Wild) return;
Creature& c = it->second;
if (c.mode == CMode::Capturing || c.mode == CMode::Flee || c.mode == CMode::Hooked) return;
float dist = glm::length(c.pos - p.move.pos);
if (dist > 8.0f) return;
if (p.row.capsules <= 0) {
sendToast(p, "You're out of capsules. Professor Henry can restock you.");
return;
}
const Species* sp = roster_.get(c.species);
if (!sp) return;
p.row.capsules -= 1;
savePlayer(p);
sendProfile(p);
float flight = dist / 10.0f + 0.15f;
p.throwTimer = 0.5f;
if (dist > 0.01f) p.move.yaw = yawFromDir(c.pos - p.move.pos);
bool still = c.mode == CMode::Alert || c.mode == CMode::Emerge || (c.mode == CMode::Wander && !c.walking);
float prob = sp->catchRate * (still ? 1.25f : 1.0f);
if (c.hookBonus && c.mode == CMode::Alert) prob *= 1.35f;
prob *= dist < 3.0f ? 1.1f : dist > 6.0f ? 0.85f : 1.0f;
prob = std::clamp(prob, 0.03f, 0.95f);
float roll = rng_.uniform();
c.captureSuccess = roll < prob;
if (c.captureSuccess) {
c.wobbles = 3;
} else {
float miss = (roll - prob) / std::max(1e-3f, 1.0f - prob); // 0 = nearly caught
c.wobbles = miss < 0.3f ? 2 : miss < 0.65f ? 1 : 0;
}
c.resumeMode = c.mode == CMode::Swim ? CMode::Swim : CMode::Alert;
c.mode = CMode::Capturing;
c.anim = Anim::Capturing;
c.walking = false;
c.owner = p.entity;
c.captureTimer = flight + float(c.wobbles) * 0.55f + 0.3f;
GameEvent e;
e.kind = EventKind::Throw;
e.a = p.entity;
e.b = c.id;
e.p = pos3(p.move.pos) + glm::vec3(0.0f, 1.0f, 0.0f);
e.q = glm::vec3(c.pos.x, c.y, c.pos.y);
e.f = flight;
sendEvent(e);
logf("%s threw a capsule at %s (entity %u, %.1f u, p=%.2f) -> %s", p.row.name.c_str(), sp->name.c_str(), c.id,
dist, prob, c.captureSuccess ? "caught" : "miss");
}
void Game::actionInteract(Player& p, uint32_t target) {
if (target != kHenryEntityId) return;
if (glm::length(p.move.pos - zone_.henry) > 2.6f) return;
if (!p.row.introDone) {
p.introOffered = true;
p.introTimer = -1.0f;
sendDialogue(p, "henry_intro");
return;
}
sendDialogue(p, "henry_tips");
if (p.row.capsules < kStartCapsules) {
p.row.capsules = kStartCapsules;
savePlayer(p);
sendProfile(p);
sendToast(p, "Professor Henry restocked your capsules");
}
logf("%s talked to Professor Henry", p.row.name.c_str());
}
void Game::actionToggleParty(Player& p, uint32_t uid) {
auto it = std::find_if(p.creatures.begin(), p.creatures.end(), [&](const OwnedCreature& c) { return c.uid == uid; });
if (it == p.creatures.end()) return;
if (!it->inParty) {
int party = int(std::count_if(p.creatures.begin(), p.creatures.end(), [](auto& c) { return c.inParty; }));
if (party >= kMaxParty) {
sendToast(p, "Your party is full (6). Send one to the box first.");
return;
}
}
it->inParty = !it->inParty;
db_.setInParty(uid, it->inParty);
sendProfile(p);
syncFollowers(p);
}
void Game::handleDialogueDone(Player& p, Reader& r) {
std::string id = r.str();
if (!r.ok()) return;
if (id == "henry_intro" && (p.introOffered || glm::length(p.move.pos - zone_.henry) <= 6.0f)) finishIntro(p);
}
void Game::finishIntro(Player& p) {
p.row.introDone = true;
p.introOffered = false;
p.introTimer = -1.0f;
int before = p.row.capsules;
p.row.capsules = std::max(p.row.capsules, kStartCapsules);
savePlayer(p);
sendProfile(p);
int given = p.row.capsules - before;
sendToast(p, given > 0 ? "Professor Henry gave you " + std::to_string(given) + " Safari Capsules"
: "Professor Henry wishes you luck out there");
logf("%s finished the intro (%d capsules)", p.row.name.c_str(), p.row.capsules);
}
void Game::handleChat(Player& p, Reader& r) {
std::string text = r.str();
if (!r.ok()) return;
text = cleanChat(text);
if (text.empty() || time_ - p.lastChat < 1.0) return;
p.lastChat = time_;
Writer w(Msg::ChatLine);
w.u32(p.entity);
w.str(p.row.name);
w.str(text);
broadcast(w);
logf("<%s> %s", p.row.name.c_str(), text.c_str());
}
// ------------------------------------------------------------------ world
void Game::setWeather(Weather w) {
weatherTimer_ = rng_.range(180.0f, 480.0f);
if (w == weather_) return;
weather_ = w;
weatherBlend_ = 0.0f;
logf("weather is now %s", weatherName(w));
}
void Game::updateWorld(float dt) {
hour_ += dt * 24.0f / std::max(0.1f, cfg_.dayMinutes * 60.0f);
if (hour_ >= 24.0f) hour_ -= 24.0f;
weatherBlend_ = std::min(1.0f, weatherBlend_ + dt / 20.0f);
weatherTimer_ -= dt;
if (weatherTimer_ <= 0.0f) setWeather(nextWeather(rng_, weather_, hour_));
tables_.update(roster_, zone_.id(), hour_, weather_);
saveTimer_ -= dt;
if (saveTimer_ <= 0.0f) {
saveTimer_ = 10.0f;
db_.begin();
for (auto& [peer, p] : players_) {
if (!p->spawned || !p->posDirty) continue;
db_.savePosition(p->row.id, p->move.pos.x, p->move.pos.y, p->move.yaw);
p->posDirty = false;
}
saveWorld();
db_.commit();
}
}
void Game::updateHenry(float dt) {
Player* who = nullptr;
float d = nearestPlayerDist(zone_.henry, &who);
float want = zone_.henryYaw;
if (who && d <= 5.0f && d > 0.05f) want = yawFromDir(who->move.pos - zone_.henry);
float diff = wrapAngle(want - henryYaw_);
float step = 5.0f * dt;
henryYaw_ = wrapAngle(henryYaw_ + std::clamp(diff, -step, step));
}
void Game::tick() {
const float dt = kTickDt;
++tick_;
time_ += dt;
updateWorld(dt);
for (auto& [peer, p] : players_)
if (p->spawned) updatePlayer(*p, dt);
updateHenry(dt);
updateCreatures(dt);
for (auto& [peer, p] : players_)
if (p->spawned) updateFollowers(*p, dt);
maintainPopulation(dt);
for (auto it = creatures_.begin(); it != creatures_.end();)
it = it->second.dead ? creatures_.erase(it) : std::next(it);
sendSnapshots();
}
// ------------------------------------------------------------------ console
bool Game::console(const std::string& lineIn) {
std::istringstream in(trim(lineIn));
std::vector<std::string> args;
for (std::string a; in >> a;) args.push_back(a);
if (args.empty()) return true;
const std::string cmd = lower(args[0]);
if (cmd == "help") {
logf("commands: help | players | time <h> | weather <clear|cloudy|rain|storm|fog> | "
"give <player> capsules <n> | spawn <speciesId|name> | save | quit");
} else if (cmd == "players") {
int n = 0;
for (auto& [peer, p] : players_) {
if (!p->spawned) continue;
++n;
int party = int(std::count_if(p->creatures.begin(), p->creatures.end(), [](auto& c) { return c.inParty; }));
logf(" %-16s entity %-4u (%.1f, %.1f) capsules %d party %d box %d dex %zu/%zu intro %s [%s, %ums]",
p->row.name.c_str(), p->entity, p->move.pos.x, p->move.pos.y, p->row.capsules, party,
int(p->creatures.size()) - party, p->caught.size(), p->seen.size(), p->row.introDone ? "yes" : "no",
host_.address(peer).c_str(), host_.roundTripMs(peer));
}
logf("%d player(s) online, %zu creatures in the world", n, creatures_.size());
} else if (cmd == "time" && args.size() >= 2) {
float h = std::strtof(args[1].c_str(), nullptr);
hour_ = std::fmod(std::fmod(h, 24.0f) + 24.0f, 24.0f);
logf("time set to %.2fh", hour_);
} else if (cmd == "weather" && args.size() >= 2) {
Weather w;
if (!parseWeather(lower(args[1]), w)) logf("unknown weather '%s'", args[1].c_str());
else setWeather(w), logf("weather set to %s", weatherName(w));
} else if (cmd == "give" && args.size() >= 4 && lower(args[args.size() - 2]) == "capsules") {
std::string who;
for (size_t i = 1; i + 2 < args.size(); ++i) who += (who.empty() ? "" : " ") + args[i];
int n = std::atoi(args.back().c_str());
Player* target = nullptr;
for (auto& [peer, p] : players_)
if (p->spawned && (lower(p->row.name) == lower(who) || std::to_string(p->entity) == who)) target = p.get();
if (!target) {
logf("no player '%s' online", who.c_str());
} else {
target->row.capsules = std::clamp(target->row.capsules + n, 0, 9999);
savePlayer(*target);
sendProfile(*target);
sendToast(*target, "You received " + std::to_string(n) + " capsules");
logf("%s now has %d capsules", target->row.name.c_str(), target->row.capsules);
}
} else if (cmd == "spawn" && args.size() >= 2) {
std::string q;
for (size_t i = 1; i < args.size(); ++i) q += (q.empty() ? "" : " ") + args[i];
const Species* found = nullptr;
if (std::all_of(q.begin(), q.end(), [](char c) { return std::isdigit(uint8_t(c)); })) {
found = roster_.get(uint16_t(std::atoi(q.c_str())));
} else {
std::string lq = lower(q);
for (const Species& s : roster_.all())
if (lower(s.name) == lq || s.slug == lq) found = &s;
if (!found)
for (const Species& s : roster_.all())
if (lower(s.name).find(lq) != std::string::npos) {
found = &s;
break;
}
}
if (!found) logf("no species matches '%s'", q.c_str());
else if (Creature* c = spawnForConsole(found->id))
logf("spawned %s (#%u) as entity %u at (%.1f, %.1f)", found->name.c_str(), found->id, c->id, c->pos.x,
c->pos.y);
else logf("no player online to spawn near");
} else if (cmd == "save") {
saveAll();
logf("saved");
} else if (cmd == "quit" || cmd == "exit") {
return false;
} else {
logf("unknown command '%s' (try help)", lineIn.c_str());
}
return true;
}
} // namespace bs

219
server/Game.h Normal file
View File

@@ -0,0 +1,219 @@
#pragma once
// Authoritative game simulation: sessions/accounts, player movement, wild
// creatures, fishing, catching, followers, Professor Henry, chat, world clock.
#include "Appearance.h"
#include "Database.h"
#include "Movement.h"
#include "Net.h"
#include "Protocol.h"
#include "Rng.h"
#include "Roster.h"
#include "Spawns.h"
#include "Zone.h"
#include <cstdint>
#include <deque>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
namespace bs {
struct GameConfig {
std::string serverName = "Brandling Safari";
uint32_t seed = 1;
std::string zoneId = "meadow";
float dayMinutes = 30.0f;
float hour = -1.0f; // < 0: resume the saved clock (default 9:00)
int weather = -1; // < 0: resume the saved weather
};
// Professor Henry always has this entity id; dynamic entities start above it.
constexpr uint32_t kHenryEntityId = 1;
enum class LineState { None, Waiting, Biting };
struct Player {
uint32_t peer = 0;
uint32_t entity = 0; // 0 until the character is spawned
bool helloDone = false;
bool hasCharacter = false;
bool spawned = false;
PlayerRow row; // persistent fields (id, token, name, look, capsules, intro)
MoveState move;
uint32_t lastSeq = 0;
float inputBudget = 2.0f;
int idleTicks = 0; // ticks without an applied input (stops the walk anim)
std::vector<OwnedCreature> creatures;
std::set<uint16_t> seen, caught;
float throwTimer = 0.0f;
LineState line = LineState::None;
glm::vec2 bobber{0.0f}, castPos{0.0f};
float lineTimer = 0.0f, fishCooldown = 0.0f;
float grassDist = 0.0f;
float rustleCooldown = 0.0f; // misses shake the grass at most about once a second
std::deque<glm::vec2> trail; // newest first
float introTimer = -1.0f; // > 0: henry_intro dialogue pending
bool introOffered = false; // henry_intro was sent and not answered yet
double lastChat = -10.0;
bool posDirty = false;
};
enum class CMode { Wander, Emerge, Alert, Capturing, Flee, Hooked, Swim, Follow };
enum class COrigin { Wanderer, Grass, Swimmer, Fished, Console, Caught };
struct Creature {
uint32_t id = 0;
uint16_t species = 0;
EntityKind kind = EntityKind::Wild;
COrigin origin = COrigin::Wanderer;
CMode mode = CMode::Wander;
Anim anim = Anim::Idle;
glm::vec2 pos{0.0f};
float y = 0.0f;
float yaw = 0.0f;
uint32_t owner = 0; // follower owner / player hooking, capturing or scaring it
uint8_t order = 0;
uint32_t uid = 0; // follower: owned creature uid
bool dead = false; // removed at the end of the tick
uint32_t emergedFor = 0; // grass spawn: player entity it popped up for (per-player cap)
float timer = 0.0f; // time left in the current mode / pause
bool walking = false;
glm::vec2 target{0.0f};
float lifetime = 120.0f; // despawn once this runs out and nobody is within 12 units
bool hookBonus = false; // Alert after being fished out
int failedThrows = 0;
std::set<uint32_t> seenBy; // player entities that already had it registered as seen
// capture in flight
float captureTimer = 0.0f;
bool captureSuccess = false;
int wobbles = 0;
CMode resumeMode = CMode::Wander;
// hook arc
glm::vec2 arcFrom{0.0f}, arcTo{0.0f};
float arcFromY = 0.0f;
};
class Game {
public:
Game(net::Host& host, Database& db, Zone& zone, const Roster& roster, const CharacterManifest& manifest,
GameConfig cfg);
void onConnect(uint32_t peer);
void onDisconnect(uint32_t peer);
void onReceive(uint32_t peer, const std::vector<uint8_t>& data);
void tick();
void saveAll();
// Console command; returns false for "quit".
bool console(const std::string& line);
int playerCount() const;
const std::string& serverName() const { return cfg_.serverName; }
private:
// --- sessions
Player* byPeer(uint32_t peer);
Player* byEntity(uint32_t entity);
void handleHello(Player& p, Reader& r);
void handleCreate(Player& p, Reader& r);
void handleInput(Player& p, Reader& r);
void handleAction(Player& p, Reader& r);
void handleChat(Player& p, Reader& r);
void handleDialogueDone(Player& p, Reader& r);
void spawnPlayer(Player& p);
void despawnPlayer(Player& p); // save + PlayerLeft + remove followers
void reject(uint32_t peer, const std::string& reason);
// --- messaging
void send(const Player& p, const Writer& w, bool reliable = true);
void broadcast(const Writer& w, bool reliable = true);
void sendProfile(Player& p);
void sendEvent(const GameEvent& e);
void sendToast(Player& p, const std::string& text);
void sendDialogue(Player& p, const std::string& id);
PlayerInfoMsg playerInfo(const Player& p) const;
void sendSnapshots();
// --- persistence
void savePlayer(Player& p);
void saveWorld();
// --- players
void applyInput(Player& p, const InputCmd& c);
void updatePlayer(Player& p, float dt);
void cancelLine(Player& p);
void noteSeen(Player& p, uint16_t species);
glm::vec3 pos3(glm::vec2 p) const { return {p.x, zone_.groundHeight(p), p.y}; }
Anim playerAnim(const Player& p) const;
// --- creatures
Creature& addCreature(uint16_t species, glm::vec2 pos, COrigin origin);
void removeCreature(uint32_t id);
void announceAppeared(const Creature& c);
void updateCreatures(float dt);
void updateWild(Creature& c, float dt);
void updateFollowers(Player& p, float dt);
void wander(Creature& c, float dt, float speed);
bool pickWanderTarget(Creature& c);
void faceNearestPlayer(Creature& c);
float nearestPlayerDist(glm::vec2 p, Player** who = nullptr);
int emergedCount(const Player& p) const;
void maintainPopulation(float dt);
bool spawnWanderer();
bool spawnSwimmer(bool announce);
void grassEncounters(Player& p);
void resolveCapture(Creature& c);
void syncFollowers(Player& p);
Creature* spawnForConsole(uint16_t species);
// --- actions
void actionThrow(Player& p, uint32_t target);
void actionCast(Player& p, glm::vec2 dir);
void actionReel(Player& p);
void actionInteract(Player& p, uint32_t target);
void actionToggleParty(Player& p, uint32_t uid);
void finishIntro(Player& p);
bool landingSpot(glm::vec2 bobber, glm::vec2 player, glm::vec2& out) const;
// --- world
void updateWorld(float dt);
void updateHenry(float dt);
void setWeather(Weather w);
net::Host& host_;
Database& db_;
Zone& zone_;
const Roster& roster_;
const CharacterManifest& manifest_;
GameConfig cfg_;
Rng rng_;
SpawnTables tables_;
std::map<uint32_t, std::unique_ptr<Player>> players_; // by peer
std::map<uint32_t, Creature> creatures_; // wild + followers by entity id
uint32_t nextEntity_ = kHenryEntityId + 1;
uint32_t tick_ = 0;
double time_ = 0.0; // seconds since start
float hour_ = 9.0f;
Weather weather_ = Weather::Clear;
float weatherTimer_ = 300.0f;
float weatherBlend_ = 1.0f;
float saveTimer_ = 10.0f;
float henryYaw_ = 0.0f;
float wanderSpawnTimer_ = 0.0f;
float swimmerTimer_ = 10.0f; // next surface/dive
float swimmerRespawn_ = -1.0f; // > 0: a dived swimmer resurfaces elsewhere
};
} // namespace bs

30
server/Log.h Normal file
View File

@@ -0,0 +1,30 @@
#pragma once
// Timestamped console log lines for the dedicated server.
#include <cstdarg>
#include <cstdio>
#include <ctime>
namespace bs {
#if defined(__GNUC__)
__attribute__((format(printf, 1, 2)))
#endif
inline void logf(const char* fmt, ...) {
std::time_t t = std::time(nullptr);
std::tm tm{};
#if defined(_WIN32)
localtime_s(&tm, &t);
#else
localtime_r(&t, &tm);
#endif
std::printf("[%02d:%02d:%02d] ", tm.tm_hour, tm.tm_min, tm.tm_sec);
va_list ap;
va_start(ap, fmt);
std::vprintf(fmt, ap);
va_end(ap);
std::printf("\n");
std::fflush(stdout);
}
} // namespace bs

122
server/Spawns.cpp Normal file
View File

@@ -0,0 +1,122 @@
#include "Spawns.h"
#include <algorithm>
#include <map>
namespace bs {
namespace {
float rarityWeight(Rarity r) {
switch (r) {
case Rarity::Uncommon: return 0.45f;
case Rarity::Rare: return 0.12f;
case Rarity::Legendary: return 0.025f;
default: return 1.0f;
}
}
float landTypeWeight(const std::string& zoneId, const std::string& type) {
// Only "meadow" exists today; other zones fall back to the same table.
(void)zoneId;
static const std::map<std::string, float> meadow = {
{"grass", 3.0f}, {"bug", 3.0f}, {"normal", 3.0f}, {"ground", 2.0f}, {"flying", 2.0f},
{"rock", 1.0f}, {"poison", 1.0f}, {"fighting", 1.0f}, {"electric", 1.0f}, {"psychic", 0.6f},
{"fire", 0.6f}, {"ice", 0.3f}, {"ghost", 0.4f}, {"dragon", 0.2f}, {"water", 0.5f},
};
auto it = meadow.find(type);
return it == meadow.end() ? 0.5f : it->second;
}
float waterTypeWeight(const std::string& type) {
if (type == "water") return 5.0f;
if (type == "ice") return 1.0f;
return 0.2f;
}
float conditionWeight(const std::string& type, bool night, Weather w) {
float m = 1.0f;
if (night) {
if (type == "ghost") m *= 3.0f;
else if (type == "psychic") m *= 2.0f;
else if (type == "bug") m *= 1.5f;
else if (type == "fire") m *= 0.6f;
} else {
if (type == "fire") m *= 1.4f;
else if (type == "grass") m *= 1.2f;
}
if (w == Weather::Rain || w == Weather::Storm) {
if (type == "water") m *= 2.0f;
if (type == "electric") m *= w == Weather::Storm ? 3.0f : 1.4f;
}
if (w == Weather::Fog && type == "ghost") m *= 2.0f;
return m;
}
} // namespace
void SpawnTables::update(const Roster& roster, const std::string& zoneId, float hour, Weather weather) {
bool night = isNight(hour);
int key = int(night) * int(Weather::Count) + int(weather);
if (key == key_ && roster_ == &roster) return;
key_ = key;
roster_ = &roster;
for (int h = 0; h < 2; ++h) {
Table& t = tables_[h];
t.cumulative.clear();
t.ids.clear();
float total = 0.0f;
for (const Species& s : roster.all()) {
bool ok = h == int(SpawnHabitat::Land) ? s.habitat != Habitat::Water : s.habitat != Habitat::Grass;
if (!ok || s.retired) continue; // retired companies keep their id but no longer spawn
auto typeW = [&](const std::string& type) {
float z = h == int(SpawnHabitat::Land) ? landTypeWeight(zoneId, type) : waterTypeWeight(type);
return z * conditionWeight(type, night, weather);
};
float tw = typeW(s.type);
if (!s.type2.empty()) tw = std::max(tw, 0.6f * typeW(s.type2));
float w = rarityWeight(s.rarity) * tw;
if (w <= 0.0f) continue;
total += w;
t.cumulative.push_back(total);
t.ids.push_back(s.id);
}
}
}
bool SpawnTables::pick(Rng& rng, SpawnHabitat h, uint16_t& species) const {
const Table& t = tables_[int(h)];
if (t.ids.empty()) return false;
float r = rng.uniform() * t.cumulative.back();
size_t i = size_t(std::upper_bound(t.cumulative.begin(), t.cumulative.end(), r) - t.cumulative.begin());
species = t.ids[std::min(i, t.ids.size() - 1)];
return true;
}
Weather nextWeather(Rng& rng, Weather cur, float hour) {
bool morning = hour >= 4.0f && hour <= 8.0f; // fog season
float w[int(Weather::Count)] = {};
auto& clear = w[int(Weather::Clear)];
auto& cloudy = w[int(Weather::Cloudy)];
auto& rain = w[int(Weather::Rain)];
auto& storm = w[int(Weather::Storm)];
auto& fog = w[int(Weather::Fog)];
switch (cur) {
case Weather::Clear: clear = 5, cloudy = 3, rain = 0.2f, storm = 0.03f, fog = morning ? 3.5f : 0.2f; break;
case Weather::Cloudy: clear = 3, cloudy = 2, rain = 3, storm = 0.3f, fog = morning ? 2.0f : 0.3f; break;
case Weather::Rain: clear = 1, cloudy = 3, rain = 2, storm = 0.5f, fog = morning ? 1.0f : 0.1f; break;
case Weather::Storm: clear = 0.3f, cloudy = 1.5f, rain = 3, storm = 0.4f; break;
case Weather::Fog: clear = 3, cloudy = 2, rain = 0.3f, fog = morning ? 3.0f : 0.4f; break;
default: clear = 1; break;
}
float total = 0;
for (float v : w) total += v;
float r = rng.uniform() * total;
for (int i = 0; i < int(Weather::Count); ++i) {
if (r < w[i]) return Weather(i);
r -= w[i];
}
return Weather::Clear;
}
} // namespace bs

38
server/Spawns.h Normal file
View File

@@ -0,0 +1,38 @@
#pragma once
// Species selection for wild spawns (habitat, rarity, zone type weights and
// time/weather conditions) and the weather Markov chain.
#include "Protocol.h"
#include "Rng.h"
#include "Roster.h"
#include <string>
#include <vector>
namespace bs {
enum class SpawnHabitat { Land = 0, Water = 1 };
inline bool isNight(float hour) { return hour < 5.5f || hour > 19.5f; }
class SpawnTables {
public:
// Rebuilds the cumulative tables when (night, weather) differs from the cached condition.
void update(const Roster& roster, const std::string& zoneId, float hour, Weather weather);
// Returns false when no species fits the habitat.
bool pick(Rng& rng, SpawnHabitat h, uint16_t& species) const;
private:
struct Table {
std::vector<float> cumulative;
std::vector<uint16_t> ids;
};
Table tables_[2];
int key_ = -1;
const Roster* roster_ = nullptr;
};
// Next weather state; called every 3-8 real minutes.
Weather nextWeather(Rng& rng, Weather current, float hour);
} // namespace bs

246
server/main.cpp Normal file
View File

@@ -0,0 +1,246 @@
// 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;
}