diff --git a/Source/lua/lua_global.cpp b/Source/lua/lua_global.cpp index c590c94f5..da24610b8 100644 --- a/Source/lua/lua_global.cpp +++ b/Source/lua/lua_global.cpp @@ -1,11 +1,24 @@ #include "lua/lua_global.hpp" +#include #include +#include #include +#include #include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include #include @@ -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 &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 compiledScripts = {}; - sol::environment sandbox = {}; - sol::table events = {}; + sol::state sol; + sol::table commonPackages; + ankerl::unordered_dense::segmented_map compiledScripts; + sol::environment sandbox; + sol::table events; + + LuaState() + : sol(sol::c_call) + { + } }; std::optional 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 env, std::string_view pack return SafeCallResult(fn(), optional); } -void LuaPanic(sol::optional 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 } }); + 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; } diff --git a/Source/lua/modules/towners.cpp b/Source/lua/modules/towners.cpp index b1dd524ee..805188e8f 100644 --- a/Source/lua/modules/towners.cpp +++ b/Source/lua/modules/towners.cpp @@ -1,7 +1,7 @@ #include "lua/modules/towners.hpp" #include -#include +#include #include #include @@ -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(); + return {}; + }, + [onSelect]() { + onSelect(); + }); + }); + return table; } diff --git a/Source/stores.cpp b/Source/stores.cpp index 781b5c228..70ed54d06 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -7,7 +7,11 @@ #include #include +#include +#include #include +#include +#include #include @@ -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>> 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 *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 getLabel, + std::function 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 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 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(); diff --git a/Source/stores.h b/Source/stores.h index 0f1d2d7d0..26549fbde 100644 --- a/Source/stores.h +++ b/Source/stores.h @@ -6,7 +6,12 @@ #pragma once #include +#include #include +#include +#include +#include +#include #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 getLabel; + std::function onSelect; +}; + +/** Extra dialog options injected by mods, keyed by towner short name. */ +extern DVL_API_FOR_TEST std::vector>> 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 getLabel, + std::function 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. */ diff --git a/Source/towners.cpp b/Source/towners.cpp index 3c8a28d3b..586480e13 100644 --- a/Source/towners.cpp +++ b/Source/towners.cpp @@ -704,6 +704,22 @@ std::vector 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(); diff --git a/Source/towners.h b/Source/towners.h index 8cbf7916d..a7aec1822 100644 --- a/Source/towners.h +++ b/Source/towners.h @@ -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; diff --git a/test/stores_test.cpp b/test/stores_test.cpp index b4c2e1612..e7bb6a4f1 100644 --- a/test/stores_test.cpp +++ b/test/stores_test.cpp @@ -6,6 +6,16 @@ using namespace devilution; namespace { +/** Helper to find the options vector for a towner by short name, or nullptr. */ +std::vector *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