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());
}
}