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

722
bot/main.cpp Normal file
View File

@@ -0,0 +1,722 @@
// Headless test client for the Brandling Safari server.
// safari_bot [--host 127.0.0.1] [--port N] [--token hex] [--seconds 60] [--name Bot]
// Connects, creates a character if needed, finishes Professor Henry's intro, talks to him,
// runs through tall grass throwing capsules at nearby wild creatures, fishes once from the
// dock, then prints a summary. Movement uses the shared stepMovement with client-side
// prediction + reconciliation against the server's acknowledged input sequence.
#include "Appearance.h"
#include "Movement.h"
#include "Net.h"
#include "Protocol.h"
#include "Rng.h"
#include "Zone.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include <deque>
#include <map>
#include <queue>
#include <set>
#include <string>
#include <thread>
#include <vector>
using namespace bs;
namespace {
enum class Phase { Joining, Henry, Grass, Dock, Grass2, Done };
const char* phaseName(Phase p) {
switch (p) {
case Phase::Joining: return "joining";
case Phase::Henry: return "henry";
case Phase::Grass: return "grass";
case Phase::Dock: return "dock";
case Phase::Grass2: return "grass2";
case Phase::Done: return "done";
}
return "?";
}
class Bot {
public:
std::string hostName = "127.0.0.1";
uint16_t port = kDefaultPort;
std::string token, name = "Bot";
double seconds = 60.0;
int run();
private:
void log(const char* fmt, ...);
void handle(const std::vector<uint8_t>& data);
void onSnapshot(const SnapshotMsg& s);
void onEvent(const GameEvent& e);
void update(float dt);
void sendMsg(const Writer& w, bool reliable = true) {
host_.send(peer_, reliable ? net::Reliable : net::Unreliable, w.data());
}
void sendAction(ActionKind k, uint32_t target = 0, float x = 0, float z = 0) {
ActionMsg a;
a.kind = k;
a.target = target;
a.x = x;
a.z = z;
Writer w(Msg::Action);
a.write(w);
sendMsg(w);
}
void sendDialogueDone(const std::string& id) {
Writer w(Msg::DialogueDone);
w.str(id);
sendMsg(w);
log("-> DialogueDone %s", id.c_str());
}
void setPhase(Phase p) {
if (p == phase_) return;
log("phase %s -> %s", phaseName(phase_), phaseName(p));
phase_ = p;
phaseTime_ = 0.0;
path_.clear();
}
// navigation
bool planPath(glm::vec2 goal);
glm::vec2 steer(glm::vec2 goal, float arrive, bool& arrived);
glm::vec2 randomGrassPoint();
net::Host host_;
uint32_t peer_ = 0;
bool connected_ = false, welcomed_ = false, rejected_ = false, spawned_ = false, haveSnap_ = false;
Zone zone_;
CharacterManifest manifest_;
Rng rng_{1};
double time_ = 0.0, phaseTime_ = 0.0;
Phase phase_ = Phase::Joining;
uint32_t me_ = 0;
ProfileMsg profile_;
bool haveProfile_ = false;
MoveState pred_;
uint32_t seq_ = 0, lastAck_ = 0, lastSnapTick_ = 0;
std::deque<InputCmd> pending_;
float maxCorrection_ = 0.0f;
std::map<uint32_t, EntityState> ents_;
std::map<uint32_t, std::string> names_;
std::set<uint32_t> followersSeen_, playersSeen_;
glm::vec2 firstPos_{0.0f};
bool firstPosSet_ = false;
// path following
std::vector<glm::vec2> path_;
size_t pathIdx_ = 0;
glm::vec2 pathGoal_{0.0f};
glm::vec2 lastProgressPos_{0.0f};
float stuckTimer_ = 0.0f;
glm::vec2 grassGoal_{0.0f};
bool grassGoalSet_ = false;
int patch_ = -1;
// behaviour state
float throwCooldown_ = 0.0f;
std::set<uint32_t> pendingThrows_;
std::vector<std::pair<double, std::string>> pendingDialogue_; // reply at time
bool introDialogSeen_ = false, talkedHenry_ = false, interactSent_ = false;
double reelAt_ = -1.0, castSentAt_ = -1.0, nextPlanAt_ = 0.0;
uint32_t hookedId_ = 0;
bool lineOut_ = false;
int casts_ = 0;
double dockDoneAt_ = -1.0;
bool chatSent_ = false;
int toggles_ = 0;
uint32_t toggledUid_ = 0;
double lastSnapLog_ = -10.0;
// summary
int encounters_ = 0, throws_ = 0, catches_ = 0, bites_ = 0, hooks_ = 0, linesLost_ = 0, fled_ = 0, rustles_ = 0;
int capsulesStart_ = -1;
};
void Bot::log(const char* fmt, ...) {
std::printf("[%s %6.2f] ", name.c_str(), time_);
va_list ap;
va_start(ap, fmt);
std::vprintf(fmt, ap);
va_end(ap);
std::printf("\n");
std::fflush(stdout);
}
// ------------------------------------------------------------------ messages
void Bot::handle(const std::vector<uint8_t>& data) {
if (data.empty()) return;
Reader r(data);
Msg m = Msg(r.u8());
switch (m) {
case Msg::Welcome: {
WelcomeMsg w;
w.read(r);
if (!r.ok()) return;
welcomed_ = true;
log("<- Welcome entity %u hasCharacter %d zone %s seed %u server \"%s\"", w.entityId, w.hasCharacter,
w.zoneId.c_str(), w.zoneSeed, w.serverName.c_str());
if (!zone_.generate(w.zoneId, w.zoneSeed)) {
log("unknown zone %s", w.zoneId.c_str());
rejected_ = true;
return;
}
if (w.entityId) me_ = w.entityId;
if (!w.hasCharacter) {
CreateCharacterMsg c;
c.name = name;
c.look = manifest_.defaultLook(rng_.chance(0.5f) ? "male" : "female");
manifest_.sanitize(c.look);
Writer cw(Msg::CreateCharacter);
c.write(cw);
sendMsg(cw);
log("-> CreateCharacter \"%s\" body %s", c.name.c_str(), c.look.body.c_str());
}
break;
}
case Msg::Reject: {
std::string why = r.str();
log("<- Reject: %s", why.c_str());
rejected_ = true;
break;
}
case Msg::Profile: {
ProfileMsg p;
p.read(r);
if (!r.ok()) return;
int party = int(std::count_if(p.creatures.begin(), p.creatures.end(), [](auto& c) { return c.inParty; }));
log("<- Profile entity %u \"%s\" capsules %d intro %d dex %u/%u creatures %zu (party %d)", p.entityId,
p.name.c_str(), p.capsules, p.introDone, p.caught, p.seen, p.creatures.size(), party);
profile_ = p;
haveProfile_ = true;
me_ = p.entityId;
if (capsulesStart_ < 0) capsulesStart_ = p.capsules;
spawned_ = true;
break;
}
case Msg::PlayerInfo: {
PlayerInfoMsg p;
p.read(r);
if (!r.ok()) return;
names_[p.entityId] = p.name;
log("<- PlayerInfo entity %u \"%s\" (%s, hat '%s')%s", p.entityId, p.name.c_str(), p.look.body.c_str(),
p.look.items[Hat].c_str(), p.entityId == me_ ? " (me)" : "");
break;
}
case Msg::PlayerLeft: {
uint32_t id = r.u32();
log("<- PlayerLeft entity %u \"%s\"", id, names_.count(id) ? names_[id].c_str() : "?");
break;
}
case Msg::Snapshot: {
SnapshotMsg s;
s.read(r);
if (r.ok()) onSnapshot(s);
break;
}
case Msg::Event: {
GameEvent e;
e.read(r);
if (r.ok()) onEvent(e);
break;
}
case Msg::Dialogue: {
std::string id = r.str();
log("<- Dialogue %s", id.c_str());
if (id == "henry_intro") introDialogSeen_ = true;
if (id == "henry_tips") talkedHenry_ = true;
pendingDialogue_.push_back({time_ + 0.8, id});
break;
}
case Msg::ChatLine: {
uint32_t id = r.u32();
std::string n = r.str(), text = r.str();
log("<- ChatLine [%u] <%s> %s", id, n.c_str(), text.c_str());
break;
}
case Msg::Toast: {
std::string t = r.str();
log("<- Toast \"%s\"", t.c_str());
break;
}
default: log("<- unknown message %u (%zu bytes)", unsigned(m), data.size()); break;
}
}
void Bot::onSnapshot(const SnapshotMsg& s) {
if (haveSnap_ && s.tick <= lastSnapTick_) return; // out of order
lastSnapTick_ = s.tick;
ents_.clear();
int players = 0, wild = 0, followers = 0, mine = 0, npc = 0;
for (const EntityState& e : s.entities) {
ents_[e.id] = e;
switch (e.kind) {
case EntityKind::Player:
++players;
if (e.id != me_ && playersSeen_.insert(e.id).second)
log("sees player entity %u \"%s\" at (%.1f, %.1f)", e.id,
names_.count(e.id) ? names_[e.id].c_str() : "?", e.pos.x, e.pos.z);
break;
case EntityKind::Wild: ++wild; break;
case EntityKind::Follower:
++followers;
if (e.owner == me_) ++mine;
if (followersSeen_.insert(e.id).second)
log("follower entity %u species %u order %u owner %u%s at (%.1f, %.1f)", e.id, e.species, e.order,
e.owner, e.owner == me_ ? " (mine)" : "", e.pos.x, e.pos.z);
break;
case EntityKind::Npc: ++npc; break;
}
}
auto it = ents_.find(me_);
if (me_ && it != ents_.end()) {
glm::vec2 server(it->second.pos.x, it->second.pos.z);
if (!haveSnap_) {
pred_.pos = server;
pred_.yaw = it->second.yaw;
firstPos_ = server;
firstPosSet_ = true;
log("first snapshot: I am at (%.2f, %.2f), hour %.2f, weather %s", server.x, server.y, s.hour,
weatherName(s.weather));
}
haveSnap_ = true;
if (s.ackInput >= lastAck_) {
lastAck_ = s.ackInput;
while (!pending_.empty() && pending_.front().seq <= s.ackInput) pending_.pop_front();
MoveState st = pred_;
st.pos = server;
for (const InputCmd& c : pending_) stepMovement(zone_, st, c, kTickDt);
maxCorrection_ = std::max(maxCorrection_, glm::length(st.pos - pred_.pos));
pred_ = st;
}
}
if (time_ - lastSnapLog_ >= 5.0) {
lastSnapLog_ = time_;
log("<- Snapshot tick %u ack %u hour %.2f %s blend %.2f: %zu entities (players %d wild %d followers %d "
"[mine %d] npc %d)",
s.tick, s.ackInput, s.hour, weatherName(s.weather), s.weatherBlend, s.entities.size(), players, wild,
followers, mine, npc);
}
}
void Bot::onEvent(const GameEvent& e) {
bool mine = e.a == me_;
switch (e.kind) {
case EventKind::Emerge:
log("<- Event Emerge entity %u %s at (%.1f, %.1f)", e.a, e.i ? "water" : "grass", e.p.x, e.p.z);
if (!e.i && me_ && ents_.count(me_)) {
const EntityState& m = ents_[me_];
if (glm::length(glm::vec2(e.p.x - m.pos.x, e.p.z - m.pos.z)) < 7.0f) ++encounters_;
}
break;
case EventKind::Throw:
log("<- Event Throw player %u -> entity %u flight %.2fs", e.a, e.b, e.f);
break;
case EventKind::CaptureResult:
log("<- Event CaptureResult player %u entity %u %s wobbles %d", e.a, e.b, e.i ? "SUCCESS" : "miss", e.j);
if (mine) pendingThrows_.erase(e.b);
break;
case EventKind::Caught:
log("<- Event Caught player %u species %u%s", e.a, e.b, e.i ? " (first!)" : "");
if (mine) ++catches_;
break;
case EventKind::Fled:
log("<- Event Fled entity %u", e.a);
if (pendingThrows_.count(e.a)) ++fled_;
break;
case EventKind::Cast:
log("<- Event Cast player %u bobber (%.1f, %.1f)", e.a, e.p.x, e.p.z);
if (mine) lineOut_ = true, castSentAt_ = -1.0;
break;
case EventKind::Bite:
log("<- Event Bite player %u", e.a);
if (mine) {
++bites_;
reelAt_ = time_ + 0.25 + rng_.uniform() * 0.4;
}
break;
case EventKind::Hooked:
log("<- Event Hooked player %u entity %u", e.a, e.b);
if (mine) {
++hooks_;
lineOut_ = false;
hookedId_ = e.b;
dockDoneAt_ = time_ + 7.0; // stay a moment to throw at the catch
}
break;
case EventKind::LineLost:
log("<- Event LineLost player %u", e.a);
if (mine) {
++linesLost_;
lineOut_ = false;
reelAt_ = -1.0;
castSentAt_ = -1.0;
}
break;
case EventKind::Splash: log("<- Event Splash at (%.1f, %.1f)", e.p.x, e.p.z); break;
case EventKind::Rustle:
++rustles_;
log("<- Event Rustle at (%.1f, %.1f)", e.p.x, e.p.z);
break;
}
}
// ------------------------------------------------------------------ navigation
bool Bot::planPath(glm::vec2 goal) {
const int W = zone_.width(), D = zone_.depth();
auto idx = [&](int x, int z) { return z * W + x; };
auto center = [](int x, int z) { return glm::vec2(float(x) + 0.5f, float(z) + 0.5f); };
int sx = int(pred_.pos.x), sz = int(pred_.pos.y), gx = int(goal.x), gz = int(goal.y);
if (!zone_.inBounds(sx, sz) || !zone_.inBounds(gx, gz)) return false;
std::vector<float> cost(size_t(W) * D, 1e30f);
std::vector<int> from(size_t(W) * D, -1);
using Node = std::pair<float, int>;
std::priority_queue<Node, std::vector<Node>, std::greater<Node>> open;
cost[idx(sx, sz)] = 0;
open.push({0, idx(sx, sz)});
const int dx[8] = {1, -1, 0, 0, 1, 1, -1, -1}, dz[8] = {0, 0, 1, -1, 1, -1, 1, -1};
bool found = false;
while (!open.empty()) {
auto [f, cur] = open.top();
open.pop();
int cx = cur % W, cz = cur / W;
if (cx == gx && cz == gz) {
found = true;
break;
}
glm::vec2 a = (cx == sx && cz == sz) ? pred_.pos : center(cx, cz);
for (int k = 0; k < 8; ++k) {
int nx = cx + dx[k], nz = cz + dz[k];
if (!zone_.inBounds(nx, nz)) continue;
glm::vec2 b = center(nx, nz);
// generous clearance so the straight-line follow does not clip props
if (!zone_.walkable(a, b, 0.45f) || !zone_.walkable(a, (a + b) * 0.5f, 0.45f)) continue;
if (k >= 4 && (!zone_.walkable(a, center(nx, cz), 0.45f) || !zone_.walkable(a, center(cx, nz), 0.45f)))
continue;
float nc = cost[cur] + (k >= 4 ? 1.414f : 1.0f);
if (nc < cost[idx(nx, nz)]) {
cost[idx(nx, nz)] = nc;
from[idx(nx, nz)] = cur;
float h = glm::length(b - goal);
open.push({nc + h, idx(nx, nz)});
}
}
}
if (!found) return false;
path_.clear();
for (int n = idx(gx, gz); n != -1 && n != idx(sx, sz); n = from[n]) path_.push_back(center(n % W, n / W));
std::reverse(path_.begin(), path_.end());
if (!path_.empty()) path_.back() = goal;
else path_.push_back(goal);
pathIdx_ = 0;
pathGoal_ = goal;
stuckTimer_ = 0.0f;
lastProgressPos_ = pred_.pos;
return true;
}
// Direction toward the goal along the planned path; replans when stuck.
glm::vec2 Bot::steer(glm::vec2 goal, float arrive, bool& arrived) {
arrived = glm::length(goal - pred_.pos) <= arrive;
if (arrived) return glm::vec2(0.0f);
if (path_.empty() || glm::length(pathGoal_ - goal) > 0.5f) {
// A* is not free: at most one plan attempt per 0.7 s
if (time_ < nextPlanAt_) return glm::normalize(goal - pred_.pos);
nextPlanAt_ = time_ + 0.7;
if (!planPath(goal)) {
path_.clear();
return glm::normalize(goal - pred_.pos);
}
}
while (pathIdx_ + 1 < path_.size() && glm::length(path_[pathIdx_] - pred_.pos) < 0.45f) ++pathIdx_;
glm::vec2 d = path_[pathIdx_] - pred_.pos;
if (glm::length(d) < 1e-3f) return glm::vec2(0.0f);
return glm::normalize(d);
}
glm::vec2 Bot::randomGrassPoint() {
const ZonePatch& p = zone_.tallGrassPatches[size_t(patch_)];
for (int i = 0; i < 60; ++i) {
float a = rng_.range(0.0f, 6.2832f), r = rng_.range(0.3f, 0.85f) * p.r;
glm::vec2 q = p.c + glm::vec2(std::cos(a), std::sin(a)) * r;
if (zone_.inTallGrass(q) && zone_.walkable(q, q) && glm::length(q - pred_.pos) > 2.5f) return q;
}
return p.c;
}
// ------------------------------------------------------------------ behaviour
void Bot::update(float dt) {
for (auto it = pendingDialogue_.begin(); it != pendingDialogue_.end();) {
if (time_ >= it->first) {
sendDialogueDone(it->second);
it = pendingDialogue_.erase(it);
} else {
++it;
}
}
if (!spawned_ || !haveSnap_) return;
if (!chatSent_) {
chatSent_ = true;
Writer w(Msg::Chat);
w.str("Hello from " + name + "!");
sendMsg(w);
}
phaseTime_ += dt;
throwCooldown_ = std::max(0.0f, throwCooldown_ - dt);
glm::vec2 dir(0.0f);
bool run = false, arrived = false;
switch (phase_) {
case Phase::Joining:
// wait for the intro dialogue to be answered (or a few seconds for returning players)
if ((haveProfile_ && profile_.introDone) || phaseTime_ > 5.0) setPhase(Phase::Henry);
break;
case Phase::Henry: {
glm::vec2 goal = zone_.henry + glm::normalize(zone_.spawn - zone_.henry) * 1.6f;
dir = steer(goal, 0.4f, arrived);
if (arrived && !interactSent_) {
interactSent_ = true;
sendAction(ActionKind::Interact, 1);
log("-> Interact Henry (entity 1) at %.2f units", glm::length(pred_.pos - zone_.henry));
}
if (talkedHenry_ || phaseTime_ > 15.0) setPhase(Phase::Grass);
break;
}
case Phase::Grass:
case Phase::Grass2: {
if (patch_ < 0) {
float best = 1e9f;
for (size_t i = 0; i < zone_.tallGrassPatches.size(); ++i) {
float d = glm::length(zone_.tallGrassPatches[i].c - pred_.pos);
if (d < best) best = d, patch_ = int(i);
}
log("nearest tall grass patch #%d at (%.1f, %.1f)", patch_, zone_.tallGrassPatches[patch_].c.x,
zone_.tallGrassPatches[patch_].c.y);
}
if (!grassGoalSet_) {
grassGoal_ = zone_.inTallGrass(pred_.pos) ? randomGrassPoint() : zone_.tallGrassPatches[patch_].c;
grassGoalSet_ = true;
}
dir = steer(grassGoal_, 0.5f, arrived);
run = true;
if (arrived || stuckTimer_ > 3.0f) grassGoalSet_ = false, path_.clear();
if (phase_ == Phase::Grass && time_ > seconds * 0.45) setPhase(Phase::Dock);
break;
}
case Phase::Dock: {
const ZoneDock& d = zone_.docks[0];
glm::vec2 goal(d.min.x + 0.7f, (d.min.y + d.max.y) * 0.5f);
if (!lineOut_ && castSentAt_ < 0.0 && dockDoneAt_ < 0.0) {
dir = steer(goal, 0.3f, arrived);
} else if (dockDoneAt_ > 0.0 && ents_.count(hookedId_)) {
// walk up to the creature we just fished out so it is in throwing range
const EntityState& h = ents_[hookedId_];
glm::vec2 hp(h.pos.x, h.pos.z);
if (glm::length(hp - pred_.pos) > 4.0f) dir = steer(hp, 3.5f, arrived);
arrived = true;
} else {
arrived = true;
}
run = !arrived;
if (castSentAt_ > 0.0 && !lineOut_ && time_ - castSentAt_ > 2.0) castSentAt_ = -1.0; // cast refused
if (arrived && !lineOut_ && castSentAt_ < 0.0 && dockDoneAt_ < 0.0 && casts_ < 3 &&
throwCooldown_ <= 0.0f) {
++casts_;
castSentAt_ = time_;
sendAction(ActionKind::Cast, 0, -1.0f, 0.0f);
log("-> Cast west from (%.2f, %.2f)", pred_.pos.x, pred_.pos.y);
throwCooldown_ = 1.5f;
}
if (reelAt_ > 0.0 && time_ >= reelAt_) {
reelAt_ = -1.0;
sendAction(ActionKind::Reel);
log("-> Reel");
}
bool gaveUp = casts_ >= 3 && !lineOut_ && castSentAt_ < 0.0 && dockDoneAt_ < 0.0;
if ((dockDoneAt_ > 0.0 && time_ >= dockDoneAt_) || gaveUp || phaseTime_ > 35.0) {
patch_ = -1;
grassGoalSet_ = false;
setPhase(Phase::Grass2);
}
break;
}
case Phase::Done: break;
}
// near the end: send one party creature to the box and bring it back (ToggleParty)
if (phase_ == Phase::Grass2 && haveProfile_ && toggles_ < 2 && time_ > seconds - 8.0 + 3.0 * toggles_) {
if (!toggledUid_)
for (const OwnedCreature& c : profile_.creatures)
if (c.inParty) {
toggledUid_ = c.uid;
break;
}
if (toggledUid_) {
sendAction(ActionKind::ToggleParty, toggledUid_);
log("-> ToggleParty uid %u (%s)", toggledUid_, toggles_ == 0 ? "to box" : "back to party");
}
++toggles_;
}
// stuck detection for path following
if (glm::length(dir) > 0.0f) {
if (glm::length(pred_.pos - lastProgressPos_) > 0.3f) {
lastProgressPos_ = pred_.pos;
stuckTimer_ = 0.0f;
} else if ((stuckTimer_ += dt) > 1.0f) {
path_.clear(); // replan from here
if (stuckTimer_ > 1.5f) dir = glm::vec2(-dir.y, dir.x); // sidestep
}
}
// throw at any wild creature within 7 units (not while a line is out; keep 3 capsules for the fishing catch)
int reserve = phase_ == Phase::Grass || (phase_ == Phase::Dock && hooks_ == 0) ? 3 : 0;
if (!lineOut_ && haveProfile_ && profile_.capsules > reserve && throwCooldown_ <= 0.0f) {
uint32_t best = 0;
float bestD = 7.0f;
for (auto& [id, e] : ents_) {
if (e.kind != EntityKind::Wild || pendingThrows_.count(id)) continue;
if (e.anim == Anim::Capturing || e.anim == Anim::Flee || e.anim == Anim::Hooked) continue;
float d = glm::length(glm::vec2(e.pos.x, e.pos.z) - pred_.pos);
if (d < bestD) bestD = d, best = id;
}
if (best) {
sendAction(ActionKind::Throw, best);
pendingThrows_.insert(best);
++throws_;
throwCooldown_ = 1.0f;
log("-> Throw at entity %u (species %u, %.1f units)", best, ents_[best].species, bestD);
}
}
InputCmd c = makeInput(++seq_, dir, run);
stepMovement(zone_, pred_, c, kTickDt);
pending_.push_back(c);
while (pending_.size() > 90) pending_.pop_front();
InputMsg im;
for (size_t i = pending_.size() >= 4 ? pending_.size() - 4 : 0; i < pending_.size(); ++i) im.cmds.push_back(pending_[i]);
Writer w(Msg::Input);
im.write(w);
sendMsg(w, false);
}
int Bot::run() {
if (token.empty()) {
Rng seedRng(uint64_t(std::chrono::high_resolution_clock::now().time_since_epoch().count()));
const char* hex = "0123456789abcdef";
for (int i = 0; i < 32; ++i) token.push_back(hex[seedRng.next() % 16]);
}
rng_.reseed(hash32(token.c_str()));
std::string err;
if (!manifest_.load(std::string(BS_ASSET_DIR) + "/characters/manifest.json", &err)) {
std::fprintf(stderr, "manifest: %s\n", err.c_str());
return 1;
}
if (!net::initialize() || !host_.connect(hostName, port)) {
std::fprintf(stderr, "cannot connect to %s:%u\n", hostName.c_str(), unsigned(port));
return 1;
}
log("connecting to %s:%u with token %s", hostName.c_str(), unsigned(port), token.c_str());
using Clock = std::chrono::steady_clock;
const auto step = std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(kTickDt));
auto next = Clock::now();
auto start = next;
bool disconnected = false;
while (!rejected_ && !disconnected) {
net::Event ev;
while (host_.poll(ev, 0)) {
if (ev.type == net::Event::Connect) {
connected_ = true;
peer_ = ev.peer;
HelloMsg h;
h.token = token;
Writer w(Msg::Hello);
h.write(w);
sendMsg(w);
log("-> Hello v%u", unsigned(h.version));
} else if (ev.type == net::Event::Disconnect) {
log("disconnected by server");
disconnected = true;
} else if (ev.type == net::Event::Receive) {
handle(ev.data);
}
}
time_ = std::chrono::duration<double>(Clock::now() - start).count();
if (time_ >= seconds) break;
if (!connected_ && time_ > 5.0) {
log("connection timed out");
return 1;
}
update(kTickDt);
host_.flush();
next += step;
auto now = Clock::now();
if (now - next > std::chrono::milliseconds(200)) next = now; // never burst-send a backlog of inputs
std::this_thread::sleep_until(next);
}
if (connected_ && !disconnected) {
host_.disconnect(peer_);
auto until = Clock::now() + std::chrono::milliseconds(300);
net::Event ev;
while (Clock::now() < until) host_.poll(ev, 20);
}
int party = 0;
for (auto& c : profile_.creatures) party += c.inParty;
std::printf("\n===== %s summary (token %s) =====\n", name.c_str(), token.c_str());
std::printf(" spawned: %s as entity %u, start position (%.2f, %.2f)\n", spawned_ ? "yes" : "no", me_,
firstPos_.x, firstPos_.y);
std::printf(" intro done: %s, talked to Henry: %s\n", profile_.introDone ? "yes" : "no",
talkedHenry_ ? "yes" : "no");
std::printf(" other players seen: %zu, followers seen: %zu\n", playersSeen_.size(), followersSeen_.size());
std::printf(" encounters near me: %d, rustles: %d, throws: %d, catches: %d, fled: %d\n", encounters_, rustles_,
throws_, catches_, fled_);
std::printf(" casts: %d, bites: %d, hooks: %d, lines lost: %d\n", casts_, bites_, hooks_, linesLost_);
std::printf(" capsules: %d (started %d), creatures: %zu, party: %d, dex caught/seen: %u/%u\n", profile_.capsules,
capsulesStart_, profile_.creatures.size(), party, profile_.caught, profile_.seen);
std::printf(" final position (%.2f, %.2f), max prediction correction %.3f units\n", pred_.pos.x, pred_.pos.y,
maxCorrection_);
std::fflush(stdout);
net::shutdown();
return (!rejected_ && spawned_) ? 0 : 1;
}
} // namespace
int main(int argc, char** argv) {
Bot bot;
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 == "--host") bot.hostName = next();
else if (a == "--port") bot.port = uint16_t(std::atoi(next().c_str()));
else if (a == "--token") bot.token = next();
else if (a == "--seconds") bot.seconds = std::atof(next().c_str());
else if (a == "--name") bot.name = next();
else {
std::fprintf(stderr,
"usage: safari_bot [--host 127.0.0.1] [--port N] [--token hex] [--seconds 60] [--name Bot]\n");
return 2;
}
}
return bot.run();
}