BareGit

Fix playtest status, inventory, prompts, and character setup

Capture status and complete inventory views in observations. Accept
unrestricted prompt responses and validate character settings against
NetHack's compatibility tables before starting a game.
Author: MetroWind <chris.corsair@gmail.com>
Date: Tue Sep 22 13:10:26 2026 -0700
Commit: 62e770be85788daf34e23df21803c9886bd9655a

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 651d8de..1e0a42d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -171,5 +171,6 @@ target_link_libraries(nethack_mcp PRIVATE
 if(NETHACK_BUILD_ENGINE)
     target_include_directories(nethack_mcp PRIVATE
         "${NETHACK_WORK_DIR}/include")
+    target_compile_definitions(nethack_mcp PRIVATE NETHACK_HAS_ENGINE=1)
     target_link_libraries(nethack_mcp PRIVATE nethack_lib)
 endif()
diff --git a/include/window_adapter.hpp b/include/window_adapter.hpp
index 514dc29..cbbf44b 100644
--- a/include/window_adapter.hpp
+++ b/include/window_adapter.hpp
@@ -31,6 +31,7 @@ private:
         std::string text;
         bool selectable = false;
         bool selected = false;
+        char letter = 0;
         std::vector<unsigned char> identifier;
     };
 
@@ -70,6 +71,8 @@ private:
     std::vector<MenuEntry> menu_entries_;
     std::string menu_prompt_;
     std::string text_window_;
+    bool inventory_requested_ = false;
+    std::map<int, std::string> status_fields_;
 
     static WindowAdapter* active_adapter_;
 };
diff --git a/src/engine_worker.cpp b/src/engine_worker.cpp
index f14feec..dd2a745 100644
--- a/src/engine_worker.cpp
+++ b/src/engine_worker.cpp
@@ -80,7 +80,19 @@ int runEngineWorker(int argc, char* argv[])
         setenv("HOME", run_directory.c_str(), 1);
         setenv("HACKDIR", run_directory.c_str(), 1);
         setenv("NETHACKDIR", run_directory.c_str(), 1);
-        setenv("NETHACKOPTIONS", "showexp,showscore,time", 1);
+        const Json character = start.value("character", Json::object());
+        std::string options = "showexp,showscore,time";
+        for(const char* key : {"role", "race", "gender", "alignment"})
+        {
+            if(character.contains(key))
+            {
+                options += ",";
+                options += key;
+                options += ":";
+                options += character.at(key).get<std::string>();
+            }
+        }
+        setenv("NETHACKOPTIONS", options.c_str(), 1);
 
         WindowAdapter adapter(channel, game_id);
         adapter.install();
@@ -89,12 +101,7 @@ int runEngineWorker(int argc, char* argv[])
         char name_option[] = "-u";
         std::vector<char> name(player_name.begin(), player_name.end());
         name.push_back('\0');
-        char* nethack_argv[] = {
-            program,
-            name_option,
-            name.data(),
-            nullptr,
-        };
+        char* nethack_argv[] = {program, name_option, name.data(), nullptr};
         return nhmain(3, nethack_argv);
     }
     catch(const std::exception& exception)
diff --git a/src/game_session.cpp b/src/game_session.cpp
index 46617a8..9d5301a 100644
--- a/src/game_session.cpp
+++ b/src/game_session.cpp
@@ -9,6 +9,20 @@
 #include <sstream>
 #include <utility>
 
+#ifdef NETHACK_HAS_ENGINE
+extern "C"
+{
+#include "hack.h"
+int str2role(const char*);
+int str2race(const char*);
+int str2gend(const char*);
+int str2align(const char*);
+boolean validrace(int, int);
+boolean validgend(int, int, int);
+boolean validalign(int, int, int);
+}
+#endif
+
 namespace nethack_mcp
 {
 
@@ -27,6 +41,74 @@ std::string jsonString(const Json& value, const char* key,
     return value.at(key).get<std::string>();
 }
 
+#ifdef NETHACK_HAS_ENGINE
+bool resolveCharacter(const Json& arguments, Json& character,
+                      std::string& error)
+{
+    struct Requested
+    {
+        const char* key;
+        int (*parse)(const char*);
+        int value = ROLE_NONE;
+    } requested[] = {
+        {"role", str2role}, {"race", str2race},
+        {"gender", str2gend}, {"alignment", str2align},
+    };
+    bool has_request = false;
+    for(Requested& item : requested)
+    {
+        if(!arguments.contains(item.key)) continue;
+        if(!arguments.at(item.key).is_string())
+        {
+            error = std::string(item.key) + " must be a string";
+            return false;
+        }
+        item.value = item.parse(
+            arguments.at(item.key).get<std::string>().c_str());
+        if(item.value < 0)
+        {
+            error = std::string("unsupported ") + item.key;
+            return false;
+        }
+        has_request = true;
+    }
+    if(!has_request) return true;
+
+    for(int role = 0; roles[role].name.m != nullptr; ++role)
+    {
+        if(requested[0].value >= 0 && requested[0].value != role)
+            continue;
+        for(int race = 0; races[race].noun != nullptr; ++race)
+        {
+            if((requested[1].value >= 0 && requested[1].value != race)
+               || !validrace(role, race)) continue;
+            for(int gender = 0; gender < ROLE_GENDERS; ++gender)
+            {
+                if((requested[2].value >= 0
+                    && requested[2].value != gender)
+                   || !validgend(role, race, gender)) continue;
+                for(int alignment = 0; alignment < ROLE_ALIGNS;
+                    ++alignment)
+                {
+                    if((requested[3].value >= 0
+                        && requested[3].value != alignment)
+                       || !validalign(role, race, alignment)) continue;
+                    character = {
+                        {"role", roles[role].filecode},
+                        {"race", races[race].filecode},
+                        {"gender", genders[gender].filecode},
+                        {"alignment", aligns[alignment].filecode},
+                    };
+                    return true;
+                }
+            }
+        }
+    }
+    error = "requested character settings are incompatible";
+    return false;
+}
+#endif
+
 } // namespace
 
 GameSession::GameSession(std::filesystem::path data_root,
@@ -68,6 +150,24 @@ ToolResult GameSession::newGame(const Json& arguments)
         }
     }
 
+    Json character = Json::object();
+#ifdef NETHACK_HAS_ENGINE
+    std::string character_error;
+    if(!resolveCharacter(arguments, character, character_error))
+    {
+        return errorResult("INVALID_RESPONSE", character_error);
+    }
+#else
+    for(const char* key : {"role", "race", "gender", "alignment"})
+    {
+        if(arguments.contains(key))
+        {
+            return errorResult("ENGINE_FAILURE",
+                               "character selection requires NetHack");
+        }
+    }
+#endif
+
     const std::string game_id = makeGameId();
     const std::filesystem::path run_directory = data_root_ / game_id;
     std::string error;
@@ -97,6 +197,7 @@ ToolResult GameSession::newGame(const Json& arguments)
         {"game_id", game_id},
         {"run_dir", run_directory.string()},
         {"name", name},
+        {"character", character},
     };
     std::string spawn_error;
     if(!process_->start(
@@ -243,7 +344,14 @@ ToolResult GameSession::respond(const Json& arguments)
         }
         const char choice = arguments.at("choice").get<std::string>()[0];
         const std::string choices = pending.value("choices", "");
-        if(choice != 27 && choices.find(choice) == std::string::npos)
+        const unsigned char ascii = static_cast<unsigned char>(choice);
+        if(ascii < 0x20 || ascii > 0x7e)
+        {
+            return errorResult("INVALID_RESPONSE",
+                               "choice must be one printable ASCII byte");
+        }
+        if(pending.value("has_choices", true)
+           && choices.find(choice) == std::string::npos)
         {
             return errorResult("INVALID_RESPONSE",
                                "choice is not in the offered choices");
diff --git a/src/window_adapter.cpp b/src/window_adapter.cpp
index 615ebb9..b1c93ce 100644
--- a/src/window_adapter.cpp
+++ b/src/window_adapter.cpp
@@ -2,6 +2,7 @@
 
 #include <algorithm>
 #include <cctype>
+#include <charconv>
 #include <cstdlib>
 #include <cstdio>
 #include <cstring>
@@ -17,6 +18,7 @@ extern "C"
 #include "global.h"
 #include "wintype.h"
 #include "func_tab.h"
+#include "botl.h"
 }
 
 namespace nethack_mcp
@@ -33,12 +35,47 @@ constexpr int MAP_WIDTH = 79;
 constexpr int MAP_HEIGHT = 21;
 constexpr std::size_t MAX_MESSAGES = 500;
 constexpr std::size_t MAX_TEXT_BYTES = 4096;
+constexpr const char* STATUS_NAMES[MAXBLSTATS] = {
+    "title", "strength", "dexterity", "constitution", "intelligence",
+    "wisdom", "charisma", "alignment", "score", "carrying-capacity",
+    "gold", "power", "power-max", "experience-level", "armor-class",
+    "HD", "time", "hunger", "hitpoints", "hitpoints-max",
+    "dungeon-level", "experience", "condition", "weapon", "armor",
+    "terrain", "version",
+};
 
 std::string copyString(const char* value)
 {
     return value == nullptr ? std::string() : std::string(value);
 }
 
+std::string statusKey(const std::string& name)
+{
+    if(name == "hitpoints") return "hp";
+    if(name == "hitpoints-max") return "max_hp";
+    if(name == "power") return "power";
+    if(name == "power-max") return "max_power";
+    std::string key = name;
+    std::replace(key.begin(), key.end(), '-', '_');
+    return key;
+}
+
+Json statusText(const char* value, bool parse_number)
+{
+    const std::string display = copyString(value);
+    Json result = {{"text", display}};
+    long long number = 0;
+    const auto parsed = std::from_chars(display.data(),
+                                        display.data() + display.size(),
+                                        number);
+    if(parse_number && !display.empty() && parsed.ec == std::errc()
+       && parsed.ptr == display.data() + display.size())
+    {
+        result["value"] = number;
+    }
+    return result;
+}
+
 } // namespace
 
 WindowAdapter* WindowAdapter::active_adapter_ = nullptr;
@@ -210,7 +247,7 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         (void) va_arg(arguments, int);
         (void) va_arg(arguments, const glyph_info*);
         const auto* identifier = va_arg(arguments, const anything*);
-        (void) va_arg(arguments, int);
+        const int letter = va_arg(arguments, int);
         (void) va_arg(arguments, int);
         (void) va_arg(arguments, int);
         (void) va_arg(arguments, int);
@@ -221,6 +258,7 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         entry.text = copyString(text);
         entry.selectable = identifier != nullptr;
         entry.selected = (item_flags & MENU_ITEMFLAGS_SELECTED) != 0;
+        entry.letter = static_cast<char>(letter);
         if(identifier != nullptr)
         {
             entry.identifier.resize(sizeof(anything));
@@ -243,7 +281,27 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         const int window = va_arg(arguments, int);
         const int how = va_arg(arguments, int);
         auto** menu_list = va_arg(arguments, menu_item**);
-        (void) window;
+        if(inventory_requested_ && window == active_menu_window_
+           && how == PICK_ONE && menu_prompt_.empty())
+        {
+            Json entries = Json::array();
+            for(const MenuEntry& entry : menu_entries_)
+            {
+                const unsigned char letter =
+                    static_cast<unsigned char>(entry.letter);
+                if(entry.selectable && std::isprint(letter))
+                {
+                    entries.push_back({
+                        {"letter", std::string(1, entry.letter)},
+                        {"text", entry.text},
+                    });
+                }
+            }
+            inventory_["known"] = true;
+            inventory_["stale"] = false;
+            inventory_["entries"] = std::move(entries);
+        }
+        inventory_requested_ = false;
         Json pending = makePending("menu", name);
         pending["mode"] = how;
         pending["prompt"] = menu_prompt_;
@@ -304,6 +362,28 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         return;
     }
 
+    if(std::strcmp(name, "shim_message_menu") == 0)
+    {
+        const int letter = va_arg(arguments, int);
+        const int how = va_arg(arguments, int);
+        const std::string label = copyString(va_arg(arguments,
+                                                    const char*));
+        if(inventory_requested_ && how == PICK_ONE
+           && std::isprint(static_cast<unsigned char>(letter)))
+        {
+            inventory_["known"] = true;
+            inventory_["stale"] = false;
+            inventory_["entries"] = Json::array({{
+                {"letter", std::string(1, static_cast<char>(letter))},
+                {"text", label},
+            }});
+        }
+        inventory_requested_ = false;
+        addMessage(label.c_str());
+        setCharacterReturn(return_ptr, '\0');
+        return;
+    }
+
     if(std::strcmp(name, "shim_display_file") == 0)
     {
         const std::string filename = copyString(
@@ -344,6 +424,7 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         Json pending = makePending("key", name);
         const Json response = waitForInput(std::move(pending));
         const int value = response.value("value", 27);
+        inventory_requested_ = value == 'i';
         if(std::strcmp(name, "shim_nh_poskey") == 0)
         {
             auto* x = va_arg(arguments, coordxy*);
@@ -360,12 +441,13 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
     if(std::strcmp(name, "shim_yn_function") == 0)
     {
         const std::string query = copyString(va_arg(arguments, const char*));
-        const std::string choices = copyString(
-            va_arg(arguments, const char*));
+        const char* choice_list = va_arg(arguments, const char*);
+        const std::string choices = copyString(choice_list);
         const int default_value = va_arg(arguments, int);
         Json pending = makePending("choice", name);
         pending["query"] = query;
         pending["choices"] = choices;
+        pending["has_choices"] = choice_list != nullptr;
         pending["default"] = default_value;
         const Json response = waitForInput(std::move(pending));
         const int value = response.value("value", 27);
@@ -431,6 +513,76 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
         return;
     }
 
+    if(std::strcmp(name, "shim_status_init") == 0)
+    {
+        status_.clear();
+        status_fields_.clear();
+        for(int field = 0; field < MAXBLSTATS; ++field)
+        {
+            status_fields_[field] = statusKey(STATUS_NAMES[field]);
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_status_enablefield") == 0)
+    {
+        const int field = va_arg(arguments, int);
+        const std::string name = copyString(va_arg(arguments, const char*));
+        (void) va_arg(arguments, const char*);
+        const int enabled = va_arg(arguments, int);
+        if(field >= 0 && field < MAXBLSTATS)
+        {
+            const std::string key = statusKey(name);
+            if(enabled)
+            {
+                status_fields_[field] = key;
+            }
+            else
+            {
+                status_.erase(key);
+                status_fields_.erase(field);
+            }
+        }
+        return;
+    }
+
+    if(std::strcmp(name, "shim_status_update") == 0)
+    {
+        const int field = va_arg(arguments, int);
+        const void* value = va_arg(arguments, const void*);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, int);
+        (void) va_arg(arguments, const unsigned long*);
+        const auto found = status_fields_.find(field);
+        if(found == status_fields_.end() || value == nullptr)
+        {
+            return;
+        }
+        if(field == BL_CONDITION)
+        {
+            const unsigned long mask = *static_cast<const unsigned long*>(
+                value);
+            Json active = Json::array();
+            for(int index = 0; index < CONDITION_COUNT; ++index)
+            {
+                if(mask & conditions[index].mask)
+                {
+                    active.push_back(conditions[index].text[0]);
+                }
+            }
+            status_[found->second] = {
+                {"mask", mask}, {"active", std::move(active)},
+            };
+        }
+        else
+        {
+            status_[found->second] = statusText(
+                static_cast<const char*>(value), field != BL_HUNGER);
+        }
+        return;
+    }
+
     if(std::strcmp(name, "shim_exit_nhwindows") == 0)
     {
         addMessage(va_arg(arguments, const char*));
@@ -446,9 +598,6 @@ void WindowAdapter::handleCallback(const char* name, void* return_ptr,
        || std::strcmp(name, "shim_change_color") == 0
        || std::strcmp(name, "shim_change_background") == 0
        || std::strcmp(name, "shim_preference_update") == 0
-       || std::strcmp(name, "shim_status_init") == 0
-       || std::strcmp(name, "shim_status_enablefield") == 0
-       || std::strcmp(name, "shim_status_update") == 0
        || std::strcmp(name, "shim_player_selection") == 0
        || std::strcmp(name, "shim_ctrl_nhwindow") == 0)
     {
@@ -497,6 +646,10 @@ Json WindowAdapter::makePending(std::string kind, std::string source) const
 
 Json WindowAdapter::waitForInput(Json pending)
 {
+    if(pending.value("kind", "") != "menu")
+    {
+        inventory_requested_ = false;
+    }
     input_id_++;
     pending["input_id"] = input_id_;
     Json snapshot = makeSnapshot();
@@ -521,6 +674,7 @@ Json WindowAdapter::waitForInput(Json pending)
         {
             continue;
         }
+        inventory_["stale"] = true;
         return message.value("response", Json::object());
     }
 }