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:
722
bot/main.cpp
Normal file
722
bot/main.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user