Skip to content

Commit 2797144

Browse files
committed
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.
1 parent 70c9b90 commit 2797144

6 files changed

Lines changed: 224 additions & 52 deletions

File tree

Source/lua/modules/towners.cpp

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,20 @@
11
#include "lua/modules/towners.hpp"
22

33
#include <optional>
4-
#include <unordered_map>
4+
#include <string>
55
#include <utility>
66

77
#include <sol/sol.hpp>
88

99
#include "engine/point.hpp"
1010
#include "lua/metadoc.hpp"
1111
#include "player.h"
12+
#include "stores.h"
1213
#include "towners.h"
1314

1415
namespace devilution {
1516
namespace {
1617

17-
// Map from towner type enum to Lua table name
18-
const std::unordered_map<_talker_id, const char *> TownerTableNames = {
19-
{ TOWN_SMITH, "griswold" },
20-
{ TOWN_HEALER, "pepin" },
21-
{ TOWN_DEADGUY, "deadguy" },
22-
{ TOWN_TAVERN, "ogden" },
23-
{ TOWN_STORY, "cain" },
24-
{ TOWN_DRUNK, "farnham" },
25-
{ TOWN_WITCH, "adria" },
26-
{ TOWN_BMAID, "gillian" },
27-
{ TOWN_PEGBOY, "wirt" },
28-
{ TOWN_COW, "cow" },
29-
{ TOWN_FARMER, "lester" },
30-
{ TOWN_GIRL, "celia" },
31-
{ TOWN_COWFARM, "nut" },
32-
};
33-
3418
void PopulateTownerTable(_talker_id townerId, sol::table &out)
3519
{
3620
LuaSetDocFn(out, "position", "()",
@@ -48,14 +32,35 @@ sol::table LuaTownersModule(sol::state_view &lua)
4832
sol::table table = lua.create_table();
4933
// Iterate over all towner types found in TSV data
5034
for (const auto &[townerId, name] : TownerLongNames) {
51-
auto tableNameIt = TownerTableNames.find(townerId);
52-
if (tableNameIt == TownerTableNames.end())
53-
continue; // Skip if no table name mapping
35+
auto shortNameIt = TownerShortNames.find(townerId);
36+
if (shortNameIt == TownerShortNames.end())
37+
continue; // Skip if no short name mapping
5438

5539
sol::table townerTable = lua.create_table();
5640
PopulateTownerTable(townerId, townerTable);
57-
LuaSetDoc(table, tableNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable));
41+
LuaSetDoc(table, shortNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable));
5842
}
43+
44+
LuaSetDocFn(table, "addDialogOption",
45+
"(townerName: string, getLabel: function, onSelect: function)",
46+
"Adds a dynamic dialog option to a towner's talk menu.\n"
47+
"getLabel() is called each time the dialog opens; return a non-empty string to show\n"
48+
"the option or an empty string/nil to hide it.\n"
49+
"onSelect() is called when the player chooses the option.",
50+
[](std::string_view townerName, const sol::function &getLabel, const sol::function &onSelect) {
51+
RegisterTownerDialogOption(
52+
townerName,
53+
[getLabel]() -> std::string {
54+
sol::object result = getLabel();
55+
if (result.get_type() == sol::type::string)
56+
return result.as<std::string>();
57+
return {};
58+
},
59+
[onSelect]() {
60+
onSelect();
61+
});
62+
});
63+
5964
return table;
6065
}
6166

Source/stores.cpp

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77

88
#include <algorithm>
99
#include <cstdint>
10+
#include <string>
1011
#include <string_view>
12+
#include <unordered_map>
13+
#include <vector>
1114

1215
#include <fmt/format.h>
1316

@@ -68,8 +71,39 @@ TalkID OldActiveStore;
6871
/** Temporary item used to hold the item being traded */
6972
Item TempItem;
7073

74+
std::unordered_map<std::string, std::vector<TownerDialogOption>> ExtraTownerOptions;
75+
76+
const char *TownerNameForTalkID(TalkID s)
77+
{
78+
const auto lookup = [](const _talker_id id) -> const char * {
79+
auto it = TownerShortNames.find(id);
80+
return it != TownerShortNames.end() ? it->second : nullptr;
81+
};
82+
switch (s) {
83+
case TalkID::Smith: return lookup(TOWN_SMITH);
84+
case TalkID::Witch: return lookup(TOWN_WITCH);
85+
case TalkID::Boy: return lookup(TOWN_PEGBOY);
86+
case TalkID::Healer: return lookup(TOWN_HEALER);
87+
case TalkID::Storyteller: return lookup(TOWN_STORY);
88+
case TalkID::Tavern: return lookup(TOWN_TAVERN);
89+
case TalkID::Drunk: return lookup(TOWN_DRUNK);
90+
case TalkID::Barmaid: return lookup(TOWN_BMAID);
91+
default: return nullptr;
92+
}
93+
}
94+
95+
void RegisterTownerDialogOption(std::string_view townerName,
96+
std::function<std::string()> getLabel,
97+
std::function<void()> onSelect)
98+
{
99+
ExtraTownerOptions[std::string(townerName)].push_back({ std::move(getLabel), std::move(onSelect) });
100+
}
101+
71102
namespace {
72103

104+
/** Maps dialog line number to ExtraTownerOptions index for options visible in the current dialog session */
105+
std::unordered_map<int, size_t> CurrentExtraOptionIndices;
106+
73107
/** The current towner being interacted with */
74108
_talker_id TownerId;
75109

@@ -2216,35 +2250,11 @@ void StartStore(TalkID s)
22162250
ClearSText(0, NumStoreLines);
22172251
ReleaseStoreBtn();
22182252

2253+
ActiveStore = s;
2254+
22192255
// Fire StoreOpened Lua event for main store entries
2220-
switch (s) {
2221-
case TalkID::Smith:
2222-
lua::StoreOpened("griswold");
2223-
break;
2224-
case TalkID::Witch:
2225-
lua::StoreOpened("adria");
2226-
break;
2227-
case TalkID::Boy:
2228-
lua::StoreOpened("wirt");
2229-
break;
2230-
case TalkID::Healer:
2231-
lua::StoreOpened("pepin");
2232-
break;
2233-
case TalkID::Storyteller:
2234-
lua::StoreOpened("cain");
2235-
break;
2236-
case TalkID::Tavern:
2237-
lua::StoreOpened("ogden");
2238-
break;
2239-
case TalkID::Drunk:
2240-
lua::StoreOpened("farnham");
2241-
break;
2242-
case TalkID::Barmaid:
2243-
lua::StoreOpened("gillian");
2244-
break;
2245-
default:
2246-
break;
2247-
}
2256+
if (const char *name = TownerNameForTalkID(s); name != nullptr)
2257+
lua::StoreOpened(name);
22482258

22492259
switch (s) {
22502260
case TalkID::Smith:
@@ -2331,15 +2341,29 @@ void StartStore(TalkID s)
23312341
break;
23322342
}
23332343

2344+
CurrentExtraOptionIndices.clear();
2345+
if (const char *extraTownerName = TownerNameForTalkID(ActiveStore); extraTownerName != nullptr) {
2346+
if (auto extraIt = ExtraTownerOptions.find(extraTownerName); extraIt != ExtraTownerOptions.end()) {
2347+
size_t optIdx = 0;
2348+
for (int line = 14; line < 18 && optIdx < extraIt->second.size(); line += 2) {
2349+
if (TextLine[line].hasText()) break;
2350+
std::string label = extraIt->second[optIdx].getLabel();
2351+
if (!label.empty()) {
2352+
AddSText(0, line, label, UiFlags::ColorWhite | UiFlags::AlignCenter, true);
2353+
CurrentExtraOptionIndices[line] = optIdx;
2354+
}
2355+
++optIdx;
2356+
}
2357+
}
2358+
}
2359+
23342360
CurrentTextLine = -1;
23352361
for (int i = 0; i < NumStoreLines; i++) {
23362362
if (TextLine[i].isSelectable()) {
23372363
CurrentTextLine = i;
23382364
break;
23392365
}
23402366
}
2341-
2342-
ActiveStore = s;
23432367
}
23442368

23452369
void DrawSText(const Surface &out)
@@ -2595,6 +2619,17 @@ void StoreEnter()
25952619
}
25962620

25972621
PlaySFX(SfxID::MenuSelect);
2622+
2623+
if (auto extraOptIt = CurrentExtraOptionIndices.find(CurrentTextLine); extraOptIt != CurrentExtraOptionIndices.end()) {
2624+
if (const char *townerName = TownerNameForTalkID(ActiveStore); townerName != nullptr) {
2625+
if (auto it = ExtraTownerOptions.find(townerName); it != ExtraTownerOptions.end() && extraOptIt->second < it->second.size()) {
2626+
it->second[extraOptIt->second].onSelect();
2627+
}
2628+
}
2629+
ActiveStore = TalkID::None;
2630+
return;
2631+
}
2632+
25982633
switch (ActiveStore) {
25992634
case TalkID::Smith:
26002635
SmithEnter();

Source/stores.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
#pragma once
77

88
#include <cstdint>
9+
#include <functional>
910
#include <optional>
11+
#include <string>
12+
#include <string_view>
13+
#include <unordered_map>
14+
#include <vector>
1015

1116
#include "DiabloUI/ui_flags.hpp"
1217
#include "control/control.hpp"
@@ -106,6 +111,31 @@ extern DVL_API_FOR_TEST TalkID OldActiveStore;
106111
/** Temporary item used to hold the item being traded */
107112
extern DVL_API_FOR_TEST Item TempItem;
108113

114+
struct TownerDialogOption {
115+
std::function<std::string()> getLabel;
116+
std::function<void()> onSelect;
117+
};
118+
119+
/** Extra dialog options injected by mods, keyed by towner short name. */
120+
extern DVL_API_FOR_TEST std::unordered_map<std::string, std::vector<TownerDialogOption>> ExtraTownerOptions;
121+
122+
/**
123+
* @brief Returns the towner short name for a top-level TalkID, or nullptr if not a towner store.
124+
*/
125+
DVL_API_FOR_TEST const char *TownerNameForTalkID(TalkID s);
126+
127+
/**
128+
* @brief Registers a dynamic dialog option for a towner's talk menu.
129+
*
130+
* @param townerName Short name of the towner (e.g. "farnham").
131+
* @param getLabel Called when the dialog is built; return a non-empty string to show the
132+
* option, or an empty string to hide it.
133+
* @param onSelect Called when the player chooses this option.
134+
*/
135+
void RegisterTownerDialogOption(std::string_view townerName,
136+
std::function<std::string()> getLabel,
137+
std::function<void()> onSelect);
138+
109139
void AddStoreHoldRepair(Item *itm, int8_t i);
110140

111141
/** Clears premium items sold by Griswold and Wirt. */

Source/towners.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,22 @@ std::vector<Towner> Towners;
704704

705705
std::unordered_map<_talker_id, std::string> TownerLongNames;
706706

707+
const std::unordered_map<_talker_id, const char *> TownerShortNames = {
708+
{ TOWN_SMITH, "griswold" },
709+
{ TOWN_HEALER, "pepin" },
710+
{ TOWN_DEADGUY, "deadguy" },
711+
{ TOWN_TAVERN, "ogden" },
712+
{ TOWN_STORY, "cain" },
713+
{ TOWN_DRUNK, "farnham" },
714+
{ TOWN_WITCH, "adria" },
715+
{ TOWN_BMAID, "gillian" },
716+
{ TOWN_PEGBOY, "wirt" },
717+
{ TOWN_COW, "cow" },
718+
{ TOWN_FARMER, "lester" },
719+
{ TOWN_GIRL, "celia" },
720+
{ TOWN_COWFARM, "nut" },
721+
};
722+
707723
size_t GetNumTownerTypes()
708724
{
709725
return TownerLongNames.size();

Source/towners.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ enum _talker_id : uint8_t {
4343

4444
// Runtime mappings built from TSV data
4545
extern DVL_API_FOR_TEST std::unordered_map<_talker_id, std::string> TownerLongNames; // Maps towner type enum to display name
46+
extern const std::unordered_map<_talker_id, const char *> TownerShortNames; // Maps towner type enum to Lua/mod short name
4647

4748
struct Towner {
4849
OptionalOwnedClxSpriteList ownedAnim;

test/stores_test.cpp

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,89 @@ TEST(Stores, AddStoreHoldRepair_normal)
7171
EXPECT_EQ(1, item->_ivalue);
7272
EXPECT_EQ(1, item->_iIvalue);
7373
}
74+
75+
TEST(Stores, TownerNameForTalkID_knownTowners)
76+
{
77+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Smith), "griswold");
78+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Witch), "adria");
79+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Boy), "wirt");
80+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Healer), "pepin");
81+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Storyteller), "cain");
82+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Tavern), "ogden");
83+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Drunk), "farnham");
84+
EXPECT_STREQ(TownerNameForTalkID(TalkID::Barmaid), "gillian");
85+
}
86+
87+
TEST(Stores, TownerNameForTalkID_subPagesReturnNull)
88+
{
89+
// Sub-pages (buy/sell screens) should not fire StoreOpened
90+
EXPECT_EQ(TownerNameForTalkID(TalkID::None), nullptr);
91+
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithBuy), nullptr);
92+
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithSell), nullptr);
93+
EXPECT_EQ(TownerNameForTalkID(TalkID::SmithRepair), nullptr);
94+
EXPECT_EQ(TownerNameForTalkID(TalkID::WitchBuy), nullptr);
95+
EXPECT_EQ(TownerNameForTalkID(TalkID::Gossip), nullptr);
96+
EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentify), nullptr);
97+
EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentifyShow), nullptr);
98+
}
99+
100+
TEST(Stores, RegisterTownerDialogOption_storesOption)
101+
{
102+
ExtraTownerOptions.clear();
103+
104+
RegisterTownerDialogOption("farnham",
105+
[]() { return std::string("Go to Tiny Town"); },
106+
[]() {});
107+
108+
ASSERT_EQ(ExtraTownerOptions.count("farnham"), 1u);
109+
ASSERT_EQ(ExtraTownerOptions.at("farnham").size(), 1u);
110+
EXPECT_EQ(ExtraTownerOptions.at("farnham")[0].getLabel(), "Go to Tiny Town");
111+
112+
ExtraTownerOptions.clear();
113+
}
114+
115+
TEST(Stores, RegisterTownerDialogOption_callsOnSelect)
116+
{
117+
ExtraTownerOptions.clear();
118+
119+
bool called = false;
120+
RegisterTownerDialogOption("farnham",
121+
[]() { return std::string("Travel"); },
122+
[&called]() { called = true; });
123+
124+
ExtraTownerOptions.at("farnham")[0].onSelect();
125+
EXPECT_TRUE(called);
126+
127+
ExtraTownerOptions.clear();
128+
}
129+
130+
TEST(Stores, RegisterTownerDialogOption_emptyLabelHidesOption)
131+
{
132+
ExtraTownerOptions.clear();
133+
134+
RegisterTownerDialogOption("farnham",
135+
[]() { return std::string(""); },
136+
[]() {});
137+
138+
ASSERT_EQ(ExtraTownerOptions.at("farnham").size(), 1u);
139+
EXPECT_TRUE(ExtraTownerOptions.at("farnham")[0].getLabel().empty());
140+
141+
ExtraTownerOptions.clear();
142+
}
143+
144+
TEST(Stores, RegisterTownerDialogOption_multipleTowners)
145+
{
146+
ExtraTownerOptions.clear();
147+
148+
RegisterTownerDialogOption("farnham", []() { return std::string("A"); }, []() {});
149+
RegisterTownerDialogOption("griswold", []() { return std::string("B"); }, []() {});
150+
151+
EXPECT_EQ(ExtraTownerOptions.at("farnham").size(), 1u);
152+
EXPECT_EQ(ExtraTownerOptions.at("griswold").size(), 1u);
153+
EXPECT_EQ(ExtraTownerOptions.at("farnham")[0].getLabel(), "A");
154+
EXPECT_EQ(ExtraTownerOptions.at("griswold")[0].getLabel(), "B");
155+
156+
ExtraTownerOptions.clear();
157+
}
158+
74159
} // namespace

0 commit comments

Comments
 (0)