Add Lua towner API and dynamic dialog options (prework for multi-town PR) (#8539)

* Add Lua towner API and dynamic dialog options

Expose TownerShortNames to Lua (devilutionx.towners), per-towner position
helpers, and RegisterTownerDialogOption for mod-driven store dialog lines.
Includes stores integration and tests.
This commit is contained in:
Yuri Pourre
2026-04-11 00:47:07 +00:00
committed by GitHub
parent 78f77285e9
commit 3afccc256d
7 changed files with 377 additions and 66 deletions
+41 -16
View File
@@ -1,11 +1,24 @@
#include "lua/lua_global.hpp"
#include <algorithm>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <ankerl/unordered_dense.h>
#include <expected.hpp>
#include <function_ref.hpp>
#include <sol/bytecode.hpp>
#include <sol/debug.hpp>
#include <sol/sol.hpp>
#include <sol/environment.hpp>
#include <sol/forward.hpp>
#include <sol/function_types_templated.hpp>
#include <sol/protected_function.hpp>
#include <sol/stack_push.hpp>
#include <sol/state.hpp>
#include <sol/table.hpp>
#include <sol/types.hpp>
#include <config.h>
@@ -24,10 +37,9 @@
#include "lua/modules/render.hpp"
#include "lua/modules/system.hpp"
#include "lua/modules/towners.hpp"
#include "monster.h"
#include "options.h"
#include "player.h"
#include "plrmsg.h"
#include "stores.h"
#include "utils/console.h"
#include "utils/log.hpp"
#include "utils/str_cat.hpp"
@@ -41,12 +53,23 @@ namespace devilution {
namespace {
void LuaPanic(const std::optional<std::string> &message)
{
LogError("Lua is in a panic state and will now abort() the application:\n{}",
message.value_or("unknown error"));
}
struct LuaState {
sol::state sol = {};
sol::table commonPackages = {};
ankerl::unordered_dense::segmented_map<std::string, sol::bytecode> compiledScripts = {};
sol::environment sandbox = {};
sol::table events = {};
sol::state sol;
sol::table commonPackages;
ankerl::unordered_dense::segmented_map<std::string, sol::bytecode> compiledScripts;
sol::environment sandbox;
sol::table events;
LuaState()
: sol(sol::c_call<decltype(&LuaPanic), &LuaPanic>)
{
}
};
std::optional<LuaState> CurrentLuaState;
@@ -74,6 +97,9 @@ end
sol::object LuaLoadScriptFromAssets(std::string_view packageName)
{
if (!CurrentLuaState.has_value()) {
app_fatal("Lua state is not initialized");
}
LuaState &luaState = *CurrentLuaState;
constexpr std::string_view PathPrefix = "lua\\";
constexpr std::string_view PathSuffix = ".lua";
@@ -145,12 +171,6 @@ sol::object RunScript(std::optional<sol::environment> env, std::string_view pack
return SafeCallResult(fn(), optional);
}
void LuaPanic(sol::optional<std::string> message)
{
LogError("Lua is in a panic state and will now abort() the application:\n{}",
message.value_or("unknown error"));
}
} // namespace
void Sol2DebugPrintStack(lua_State *state)
@@ -166,7 +186,7 @@ void Sol2DebugPrintSection(const std::string &message, lua_State *state)
sol::environment CreateLuaSandbox()
{
sol::state &lua = CurrentLuaState->sol;
sol::environment sandbox(CurrentLuaState->sol, sol::create);
sol::environment sandbox(lua, sol::create);
// Registering globals
sandbox.set(
@@ -223,6 +243,8 @@ void LuaReloadActiveMods()
CurrentLuaState->events = RunScript(/*env=*/std::nullopt, "devilutionx.events", /*optional=*/false);
CurrentLuaState->commonPackages["devilutionx.events"] = CurrentLuaState->events;
ClearTownerDialogOptions();
gbIsHellfire = false;
UnloadModArchives();
@@ -260,7 +282,7 @@ void LuaReloadActiveMods()
void LuaInitialize()
{
CurrentLuaState.emplace(LuaState { .sol = { sol::c_call<decltype(&LuaPanic), &LuaPanic> } });
CurrentLuaState.emplace();
sol::state &lua = CurrentLuaState->sol;
lua_setwarnf(lua.lua_state(), LuaWarn, /*ud=*/nullptr);
lua.open_libraries(
@@ -312,6 +334,9 @@ void LuaShutdown()
#ifdef _DEBUG
LuaReplShutdown();
#endif
// Must clear before destroying the Lua state: registered callbacks
// capture sol::function handles that reference CurrentLuaState.
ClearTownerDialogOptions();
CurrentLuaState = std::nullopt;
}
+28 -22
View File
@@ -1,7 +1,7 @@
#include "lua/modules/towners.hpp"
#include <optional>
#include <unordered_map>
#include <string>
#include <utility>
#include <sol/sol.hpp>
@@ -9,28 +9,12 @@
#include "engine/point.hpp"
#include "lua/metadoc.hpp"
#include "player.h"
#include "stores.h"
#include "towners.h"
namespace devilution {
namespace {
// Map from towner type enum to Lua table name
const std::unordered_map<_talker_id, const char *> TownerTableNames = {
{ TOWN_SMITH, "griswold" },
{ TOWN_HEALER, "pepin" },
{ TOWN_DEADGUY, "deadguy" },
{ TOWN_TAVERN, "ogden" },
{ TOWN_STORY, "cain" },
{ TOWN_DRUNK, "farnham" },
{ TOWN_WITCH, "adria" },
{ TOWN_BMAID, "gillian" },
{ TOWN_PEGBOY, "wirt" },
{ TOWN_COW, "cow" },
{ TOWN_FARMER, "lester" },
{ TOWN_GIRL, "celia" },
{ TOWN_COWFARM, "nut" },
};
void PopulateTownerTable(_talker_id townerId, sol::table &out)
{
LuaSetDocFn(out, "position", "()",
@@ -48,14 +32,36 @@ sol::table LuaTownersModule(sol::state_view &lua)
sol::table table = lua.create_table();
// Iterate over all towner types found in TSV data
for (const auto &[townerId, name] : TownerLongNames) {
auto tableNameIt = TownerTableNames.find(townerId);
if (tableNameIt == TownerTableNames.end())
continue; // Skip if no table name mapping
auto shortNameIt = TownerShortNames.find(townerId);
if (shortNameIt == TownerShortNames.end())
continue; // Skip if no short name mapping
sol::table townerTable = lua.create_table();
PopulateTownerTable(townerId, townerTable);
LuaSetDoc(table, tableNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable));
LuaSetDoc(table, shortNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable));
}
LuaSetDocFn(table, "addDialogOption",
"(townerName: string, getLabel: function, onSelect: function)",
"Adds a dynamic dialog option to a towner's talk menu.\n"
"getLabel() is called each time the dialog opens; return a non-empty string to show\n"
"the option or an empty string/nil to hide it.\n"
"onSelect() is called when the player chooses the option.\n"
"All options are cleared when mods reload or Lua shuts down; register from mod init.",
[](std::string_view townerName, const sol::function &getLabel, const sol::function &onSelect) {
RegisterTownerDialogOption(
townerName,
[getLabel]() -> std::string {
sol::object result = getLabel();
if (result.get_type() == sol::type::string)
return result.as<std::string>();
return {};
},
[onSelect]() {
onSelect();
});
});
return table;
}
+118 -28
View File
@@ -7,7 +7,11 @@
#include <algorithm>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <fmt/format.h>
@@ -32,6 +36,7 @@
#include "towners.h"
#include "utils/format_int.hpp"
#include "utils/language.h"
#include "utils/log.hpp"
#include "utils/str_cat.hpp"
#include "utils/utf8.hpp"
@@ -68,6 +73,71 @@ TalkID OldActiveStore;
/** Temporary item used to hold the item being traded */
Item TempItem;
std::vector<std::pair<std::string, std::vector<TownerDialogOption>>> ExtraTownerOptions;
const char *TownerNameForTalkID(TalkID s)
{
switch (s) {
case TalkID::Smith: return "griswold";
case TalkID::Witch: return "adria";
case TalkID::Boy: return "wirt";
case TalkID::Healer: return "pepin";
case TalkID::Storyteller: return "cain";
case TalkID::Tavern: return "ogden";
case TalkID::Drunk: return "farnham";
case TalkID::Barmaid: return "gillian";
default: return nullptr;
}
}
/** Finds the entry for a towner in ExtraTownerOptions, or nullptr if none. */
static std::vector<TownerDialogOption> *FindExtraTownerOptions(std::string_view townerName)
{
for (auto &[name, opts] : ExtraTownerOptions) {
if (name == townerName)
return &opts;
}
return nullptr;
}
void RegisterTownerDialogOption(std::string_view townerName,
std::function<std::string()> getLabel,
std::function<void()> onSelect)
{
// Validate that the towner name is known.
bool found = false;
for (const auto &[id, shortName] : TownerShortNames) {
if (shortName == townerName) {
found = true;
break;
}
}
if (!found) {
LogWarn("RegisterTownerDialogOption: unknown towner name \"{}\"", townerName);
}
if (auto *opts = FindExtraTownerOptions(townerName); opts != nullptr) {
opts->push_back({ std::move(getLabel), std::move(onSelect) });
} else {
std::vector<TownerDialogOption> newOpts;
newOpts.push_back({ std::move(getLabel), std::move(onSelect) });
ExtraTownerOptions.emplace_back(std::string(townerName), std::move(newOpts));
}
}
/**
* Maps dialog line number to ExtraTownerOptions vector index for
* options visible in the current dialog session.
* Indexed by text line number (0..NumStoreLines-1).
*/
static std::optional<size_t> CurrentExtraOptionIndices[NumStoreLines];
void ClearTownerDialogOptions()
{
ExtraTownerOptions.clear();
std::fill(std::begin(CurrentExtraOptionIndices), std::end(CurrentExtraOptionIndices), std::nullopt);
}
namespace {
/** The current towner being interacted with */
@@ -2217,34 +2287,8 @@ void StartStore(TalkID s)
ReleaseStoreBtn();
// Fire StoreOpened Lua event for main store entries
switch (s) {
case TalkID::Smith:
lua::StoreOpened("griswold");
break;
case TalkID::Witch:
lua::StoreOpened("adria");
break;
case TalkID::Boy:
lua::StoreOpened("wirt");
break;
case TalkID::Healer:
lua::StoreOpened("pepin");
break;
case TalkID::Storyteller:
lua::StoreOpened("cain");
break;
case TalkID::Tavern:
lua::StoreOpened("ogden");
break;
case TalkID::Drunk:
lua::StoreOpened("farnham");
break;
case TalkID::Barmaid:
lua::StoreOpened("gillian");
break;
default:
break;
}
if (const char *name = TownerNameForTalkID(s); name != nullptr)
lua::StoreOpened(name);
switch (s) {
case TalkID::Smith:
@@ -2331,6 +2375,36 @@ void StartStore(TalkID s)
break;
}
std::fill(std::begin(CurrentExtraOptionIndices), std::end(CurrentExtraOptionIndices), std::nullopt);
if (const char *extraTownerName = TownerNameForTalkID(s); extraTownerName != nullptr) {
if (auto *extraOpts = FindExtraTownerOptions(extraTownerName); extraOpts != nullptr) {
// Find the last selectable line (the "leave"/"say goodbye" option).
int lastSelectableLine = -1;
for (int i = NumStoreLines - 1; i >= 0; --i) {
if (TextLine[i].isSelectable()) {
lastSelectableLine = i;
break;
}
}
// Insert extra options into empty even-numbered lines before the leave option.
size_t optIdx = 0;
for (int line = 10; line < lastSelectableLine && optIdx < extraOpts->size(); line += 2) {
if (TextLine[line].hasText()) continue;
std::string label = (*extraOpts)[optIdx].getLabel();
if (!label.empty()) {
AddSText(0, line, label, UiFlags::ColorWhite | UiFlags::AlignCenter, true);
CurrentExtraOptionIndices[line] = optIdx;
}
++optIdx;
}
if (optIdx < extraOpts->size()) {
LogWarn("Towner \"{}\" dialog: {} extra option(s) could not be placed (no empty lines)",
extraTownerName, extraOpts->size() - optIdx);
}
}
}
CurrentTextLine = -1;
for (int i = 0; i < NumStoreLines; i++) {
if (TextLine[i].isSelectable()) {
@@ -2595,6 +2669,22 @@ void StoreEnter()
}
PlaySFX(SfxID::MenuSelect);
if (CurrentTextLine >= 0 && CurrentTextLine < NumStoreLines && CurrentExtraOptionIndices[CurrentTextLine].has_value()) {
size_t optIdx = *CurrentExtraOptionIndices[CurrentTextLine];
if (const char *townerName = TownerNameForTalkID(ActiveStore); townerName != nullptr) {
if (auto *extraOpts = FindExtraTownerOptions(townerName); extraOpts != nullptr && optIdx < extraOpts->size()) {
ActiveStore = TalkID::None;
(*extraOpts)[optIdx].onSelect();
// If onSelect() set ActiveStore (e.g. to open a sub-dialog), preserve it.
// Otherwise it stays TalkID::None (dialog closed).
return;
}
}
ActiveStore = TalkID::None;
return;
}
switch (ActiveStore) {
case TalkID::Smith:
SmithEnter();
+50
View File
@@ -6,7 +6,12 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "DiabloUI/ui_flags.hpp"
#include "control/control.hpp"
@@ -106,6 +111,51 @@ extern DVL_API_FOR_TEST TalkID OldActiveStore;
/** Temporary item used to hold the item being traded */
extern DVL_API_FOR_TEST Item TempItem;
struct TownerDialogOption {
std::function<std::string()> getLabel;
std::function<void()> onSelect;
};
/** Extra dialog options injected by mods, keyed by towner short name. */
extern DVL_API_FOR_TEST std::vector<std::pair<std::string, std::vector<TownerDialogOption>>> ExtraTownerOptions;
/**
* @brief Returns the towner short name for a top-level TalkID, or nullptr if not a towner store.
*
* Only maps top-level store entries (Smith, Witch, Boy, Healer, Storyteller, Tavern, Drunk, Barmaid).
* Sub-pages (SmithBuy, WitchSell, etc.) return nullptr.
*/
DVL_API_FOR_TEST const char *TownerNameForTalkID(TalkID s);
/**
* @brief Registers a dynamic dialog option for a towner's talk menu.
*
* Options are inserted into empty even-numbered lines before the towner's "leave" option.
* If no empty lines are available the option is silently skipped for that dialog session
* and a warning is logged.
*
* When the player selects a mod option, onSelect() is called. By default the dialog is
* closed afterwards. If onSelect() sets ActiveStore to a value other than TalkID::None,
* that value is preserved (e.g. to open a sub-dialog).
*
* @param townerName Short name of the towner (e.g. "farnham"). Must match one of the
* names in TownerShortNames; a warning is logged for unknown names.
* @param getLabel Called when the dialog is built; return a non-empty string to show the
* option, or an empty string to hide it.
* @param onSelect Called when the player chooses this option.
*/
void RegisterTownerDialogOption(std::string_view townerName,
std::function<std::string()> getLabel,
std::function<void()> onSelect);
/**
* @brief Clears all mod-registered towner dialog options.
*
* Must be called before the Lua state is destroyed, since registered callbacks
* capture sol::function handles that reference the Lua state.
*/
void ClearTownerDialogOptions();
void AddStoreHoldRepair(Item *itm, int8_t i);
/** Clears premium items sold by Griswold and Wirt. */
+16
View File
@@ -704,6 +704,22 @@ std::vector<Towner> Towners;
std::unordered_map<_talker_id, std::string> TownerLongNames;
const std::unordered_map<_talker_id, const char *> TownerShortNames = {
{ TOWN_SMITH, "griswold" },
{ TOWN_HEALER, "pepin" },
{ TOWN_DEADGUY, "deadguy" },
{ TOWN_TAVERN, "ogden" },
{ TOWN_STORY, "cain" },
{ TOWN_DRUNK, "farnham" },
{ TOWN_WITCH, "adria" },
{ TOWN_BMAID, "gillian" },
{ TOWN_PEGBOY, "wirt" },
{ TOWN_COW, "cow" },
{ TOWN_FARMER, "lester" },
{ TOWN_GIRL, "celia" },
{ TOWN_COWFARM, "nut" },
};
size_t GetNumTownerTypes()
{
return TownerLongNames.size();
+1
View File
@@ -43,6 +43,7 @@ enum _talker_id : uint8_t {
// Runtime mappings built from TSV data
extern DVL_API_FOR_TEST std::unordered_map<_talker_id, std::string> TownerLongNames; // Maps towner type enum to display name
extern const std::unordered_map<_talker_id, const char *> TownerShortNames; // Maps towner type enum to Lua/mod short name
struct Towner {
OptionalOwnedClxSpriteList ownedAnim;
+123
View File
@@ -6,6 +6,16 @@ using namespace devilution;
namespace {
/** Helper to find the options vector for a towner by short name, or nullptr. */
std::vector<TownerDialogOption> *FindOptions(std::string_view name)
{
for (auto &[key, opts] : ExtraTownerOptions) {
if (key == name)
return &opts;
}
return nullptr;
}
TEST(Stores, AddStoreHoldRepair_magic)
{
devilution::Item *item;
@@ -71,4 +81,117 @@ TEST(Stores, AddStoreHoldRepair_normal)
EXPECT_EQ(1, item->_ivalue);
EXPECT_EQ(1, item->_iIvalue);
}
TEST(Stores, TownerNameForTalkID_knownTowners)
{
EXPECT_STREQ(TownerNameForTalkID(TalkID::Smith), "griswold");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Witch), "adria");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Boy), "wirt");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Healer), "pepin");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Storyteller), "cain");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Tavern), "ogden");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Drunk), "farnham");
EXPECT_STREQ(TownerNameForTalkID(TalkID::Barmaid), "gillian");
}
TEST(Stores, TownerNameForTalkID_subPagesReturnNull)
{
// Sub-pages (buy/sell screens) should not fire StoreOpened
EXPECT_EQ(TownerNameForTalkID(TalkID::None), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithBuy), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithSell), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithRepair), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::WitchBuy), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::Gossip), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentify), nullptr);
EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentifyShow), nullptr);
}
TEST(Stores, ClearTownerDialogOptions_removesRegistrations)
{
RegisterTownerDialogOption("farnham", []() { return std::string("A"); }, []() {});
ASSERT_FALSE(ExtraTownerOptions.empty());
ClearTownerDialogOptions();
EXPECT_TRUE(ExtraTownerOptions.empty());
}
TEST(Stores, RegisterTownerDialogOption_afterClearDoesNotAccumulate)
{
RegisterTownerDialogOption("farnham", []() { return std::string("First"); }, []() {});
ClearTownerDialogOptions();
RegisterTownerDialogOption("farnham", []() { return std::string("Second"); }, []() {});
auto *opts = FindOptions("farnham");
ASSERT_NE(opts, nullptr);
ASSERT_EQ(opts->size(), 1u);
EXPECT_EQ((*opts)[0].getLabel(), "Second");
ClearTownerDialogOptions();
}
TEST(Stores, RegisterTownerDialogOption_storesOption)
{
ClearTownerDialogOptions();
RegisterTownerDialogOption("farnham", []() { return std::string("Go to Tiny Town"); }, []() {});
auto *opts = FindOptions("farnham");
ASSERT_NE(opts, nullptr);
ASSERT_EQ(opts->size(), 1u);
EXPECT_EQ((*opts)[0].getLabel(), "Go to Tiny Town");
ClearTownerDialogOptions();
}
TEST(Stores, RegisterTownerDialogOption_callsOnSelect)
{
ClearTownerDialogOptions();
bool called = false;
RegisterTownerDialogOption("farnham", []() { return std::string("Travel"); }, [&called]() { called = true; });
auto *opts = FindOptions("farnham");
ASSERT_NE(opts, nullptr);
ASSERT_EQ(opts->size(), 1u);
(*opts)[0].onSelect();
EXPECT_TRUE(called);
ClearTownerDialogOptions();
}
TEST(Stores, RegisterTownerDialogOption_emptyLabelHidesOption)
{
ClearTownerDialogOptions();
RegisterTownerDialogOption("farnham", []() { return std::string(""); }, []() {});
auto *opts = FindOptions("farnham");
ASSERT_NE(opts, nullptr);
ASSERT_EQ(opts->size(), 1u);
EXPECT_TRUE((*opts)[0].getLabel().empty());
ClearTownerDialogOptions();
}
TEST(Stores, RegisterTownerDialogOption_multipleTowners)
{
ClearTownerDialogOptions();
RegisterTownerDialogOption("farnham", []() { return std::string("A"); }, []() {});
RegisterTownerDialogOption("griswold", []() { return std::string("B"); }, []() {});
auto *farnhamOpts = FindOptions("farnham");
auto *griswoldOpts = FindOptions("griswold");
ASSERT_NE(farnhamOpts, nullptr);
ASSERT_NE(griswoldOpts, nullptr);
EXPECT_EQ(farnhamOpts->size(), 1u);
EXPECT_EQ(griswoldOpts->size(), 1u);
EXPECT_EQ((*farnhamOpts)[0].getLabel(), "A");
EXPECT_EQ((*griswoldOpts)[0].getLabel(), "B");
ClearTownerDialogOptions();
}
} // namespace