Fix Windows socket WSAENOTCONN disconnect; add boss encounter frames

Socket fixes (fixes Windows-only connection failure):
- WorldSocket::connect() now waits for non-blocking connect to complete with
  select() before returning, preventing WSAENOTCONN on the first recv() call
  on Windows (Linux handles this implicitly but Windows requires writability
  poll after non-blocking connect)
- Add net::isConnectionClosed() helper: treats WSAENOTCONN/WSAECONNRESET/
  WSAESHUTDOWN/WSAECONNABORTED as graceful peer-close rather than recv errors
- Apply isConnectionClosed() in both WorldSocket and TCPSocket recv loops

UI:
- Add renderBossFrames(): displays boss unit health bars in top-right corner
  when SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT has active slots; supports
  click-to-target and color-coded health bars (red→orange→yellow as HP drops)
This commit is contained in:
Kelsi 2026-03-09 20:05:09 -07:00
parent b6dfa8b747
commit 1c1cdf0f23
6 changed files with 135 additions and 1 deletions

View file

@ -772,6 +772,9 @@ public:
bool extended = false;
};
const std::vector<InstanceLockout>& getInstanceLockouts() const { return instanceLockouts_; }
// Boss encounter unit tracking (SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
static constexpr uint32_t kMaxEncounterSlots = 5;
// Returns boss unit guid for the given encounter slot (0 if none)
uint64_t getEncounterUnitGuid(uint32_t slot) const {
return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0;
@ -1743,7 +1746,6 @@ private:
std::vector<InstanceLockout> instanceLockouts_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
static constexpr uint32_t kMaxEncounterSlots = 5;
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
// LFG / Dungeon Finder state

View file

@ -91,6 +91,20 @@ inline bool isWouldBlock(int err) {
#endif
}
// Returns true for errors that mean the peer closed the connection cleanly.
// On Windows, WSAENOTCONN / WSAECONNRESET / WSAESHUTDOWN can be returned by
// recv() when the server closes the connection, rather than returning 0.
inline bool isConnectionClosed(int err) {
#ifdef _WIN32
return err == WSAENOTCONN || // socket not connected (server closed)
err == WSAECONNRESET || // connection reset by peer
err == WSAESHUTDOWN || // socket shut down
err == WSAECONNABORTED; // connection aborted
#else
return err == ENOTCONN || err == ECONNRESET;
#endif
}
inline bool isInProgress(int err) {
#ifdef _WIN32
return err == WSAEWOULDBLOCK || err == WSAEALREADY;

View file

@ -210,6 +210,7 @@ private:
void renderMirrorTimers(game::GameHandler& gameHandler);
void renderCombatText(game::GameHandler& gameHandler);
void renderPartyFrames(game::GameHandler& gameHandler);
void renderBossFrames(game::GameHandler& gameHandler);
void renderGroupInvitePopup(game::GameHandler& gameHandler);
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);

View file

@ -153,6 +153,11 @@ void TCPSocket::update() {
if (net::isWouldBlock(err)) {
break;
}
if (net::isConnectionClosed(err)) {
// Peer closed the connection — treat the same as recv() returning 0
sawClose = true;
break;
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();

View file

@ -128,6 +128,39 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) {
sockfd = INVALID_SOCK;
return false;
}
// Non-blocking connect in progress — wait up to 10s for completion.
// On Windows, calling recv() before the connect completes returns
// WSAENOTCONN; we must poll writability before declaring connected.
fd_set writefds, errfds;
FD_ZERO(&writefds);
FD_ZERO(&errfds);
FD_SET(sockfd, &writefds);
FD_SET(sockfd, &errfds);
struct timeval tv;
tv.tv_sec = 10;
tv.tv_usec = 0;
int sel = ::select(static_cast<int>(sockfd) + 1, nullptr, &writefds, &errfds, &tv);
if (sel <= 0) {
LOG_ERROR("World server connection timed out (", host, ":", port, ")");
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
return false;
}
// Verify the socket error code — writeable doesn't guarantee success on all platforms
int sockErr = 0;
socklen_t errLen = sizeof(sockErr);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR,
reinterpret_cast<char*>(&sockErr), &errLen);
if (sockErr != 0) {
LOG_ERROR("Failed to connect to world server: ", net::errorString(sockErr));
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
return false;
}
}
connected = true;
@ -369,6 +402,11 @@ void WorldSocket::update() {
if (net::isWouldBlock(err)) {
break;
}
if (net::isConnectionClosed(err)) {
// Peer closed the connection — treat the same as recv() returning 0
sawClose = true;
break;
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();

View file

@ -403,6 +403,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
if (showNameplates_) renderNameplates(gameHandler);
renderCombatText(gameHandler);
renderPartyFrames(gameHandler);
renderBossFrames(gameHandler);
renderGroupInvitePopup(gameHandler);
renderDuelRequestPopup(gameHandler);
renderLootRollPopup(gameHandler);
@ -4893,6 +4894,79 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
ImGui::PopStyleVar();
}
// ============================================================
// Boss Encounter Frames
// ============================================================
void GameScreen::renderBossFrames(game::GameHandler& gameHandler) {
// Collect active boss unit slots
struct BossSlot { uint32_t slot; uint64_t guid; };
std::vector<BossSlot> active;
for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) {
uint64_t g = gameHandler.getEncounterUnitGuid(s);
if (g != 0) active.push_back({s, g});
}
if (active.empty()) return;
const float frameW = 200.0f;
const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f;
float frameY = 120.0f;
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f));
ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
if (ImGui::Begin("##BossFrames", nullptr, flags)) {
for (const auto& bs : active) {
ImGui::PushID(static_cast<int>(bs.guid));
// Try to resolve name and health from entity manager
std::string name = "Boss";
uint32_t hp = 0, maxHp = 0;
auto entity = gameHandler.getEntityManager().getEntity(bs.guid);
if (entity && (entity->getType() == game::ObjectType::UNIT ||
entity->getType() == game::ObjectType::PLAYER)) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
const auto& n = unit->getName();
if (!n.empty()) name = n;
hp = unit->getHealth();
maxHp = unit->getMaxHealth();
}
// Clickable name to target
if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) {
gameHandler.setTarget(bs.guid);
}
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
// Boss health bar in red shades
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.8f, 0.2f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) :
ImVec4(1.0f, 0.8f, 0.1f, 1.0f));
char label[32];
std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), label);
ImGui::PopStyleColor();
}
ImGui::PopID();
ImGui::Spacing();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Group Invite Popup (Phase 4)
// ============================================================