Skip to content

Commit 3afccc2

Browse files
authored
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.
1 parent 78f7728 commit 3afccc2

File tree

7 files changed

+377
-66
lines changed

7 files changed

+377
-66
lines changed

Source/lua/lua_global.cpp

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
#include "lua/lua_global.hpp"
22

3+
#include <algorithm>
34
#include <optional>
5+
#include <string>
46
#include <string_view>
7+
#include <vector>
58

69
#include <ankerl/unordered_dense.h>
10+
#include <expected.hpp>
11+
#include <function_ref.hpp>
12+
#include <sol/bytecode.hpp>
713
#include <sol/debug.hpp>
8-
#include <sol/sol.hpp>
14+
#include <sol/environment.hpp>
15+
#include <sol/forward.hpp>
16+
#include <sol/function_types_templated.hpp>
17+
#include <sol/protected_function.hpp>
18+
#include <sol/stack_push.hpp>
19+
#include <sol/state.hpp>
20+
#include <sol/table.hpp>
21+
#include <sol/types.hpp>
922

1023
#include <config.h>
1124

@@ -24,10 +37,9 @@
2437
#include "lua/modules/render.hpp"
2538
#include "lua/modules/system.hpp"
2639
#include "lua/modules/towners.hpp"
27-
#include "monster.h"
2840
#include "options.h"
29-
#include "player.h"
3041
#include "plrmsg.h"
42+
#include "stores.h"
3143
#include "utils/console.h"
3244
#include "utils/log.hpp"
3345
#include "utils/str_cat.hpp"
@@ -41,12 +53,23 @@ namespace devilution {
4153

4254
namespace {
4355

56+
void LuaPanic(const std::optional<std::string> &message)
57+
{
58+
LogError("Lua is in a panic state and will now abort() the application:\n{}",
59+
message.value_or("unknown error"));
60+
}
61+
4462
struct LuaState {
45-
sol::state sol = {};
46-
sol::table commonPackages = {};
47-
ankerl::unordered_dense::segmented_map<std::string, sol::bytecode> compiledScripts = {};
48-
sol::environment sandbox = {};
49-
sol::table events = {};
63+
sol::state sol;
64+
sol::table commonPackages;
65+
ankerl::unordered_dense::segmented_map<std::string, sol::bytecode> compiledScripts;
66+
sol::environment sandbox;
67+
sol::table events;
68+
69+
LuaState()
70+
: sol(sol::c_call<decltype(&LuaPanic), &LuaPanic>)
71+
{
72+
}
5073
};
5174

5275
std::optional<LuaState> CurrentLuaState;
@@ -74,6 +97,9 @@ end
7497

7598
sol::object LuaLoadScriptFromAssets(std::string_view packageName)
7699
{
100+
if (!CurrentLuaState.has_value()) {
101+
app_fatal("Lua state is not initialized");
102+
}
77103
LuaState &luaState = *CurrentLuaState;
78104
constexpr std::string_view PathPrefix = "lua\\";
79105
constexpr std::string_view PathSuffix = ".lua";
@@ -145,12 +171,6 @@ sol::object RunScript(std::optional<sol::environment> env, std::string_view pack
145171
return SafeCallResult(fn(), optional);
146172
}
147173

148-
void LuaPanic(sol::optional<std::string> message)
149-
{
150-
LogError("Lua is in a panic state and will now abort() the application:\n{}",
151-
message.value_or("unknown error"));
152-
}
153-
154174
} // namespace
155175

156176
void Sol2DebugPrintStack(lua_State *state)
@@ -166,7 +186,7 @@ void Sol2DebugPrintSection(const std::string &message, lua_State *state)
166186
sol::environment CreateLuaSandbox()
167187
{
168188
sol::state &lua = CurrentLuaState->sol;
169-
sol::environment sandbox(CurrentLuaState->sol, sol::create);
189+
sol::environment sandbox(lua, sol::create);
170190

171191
// Registering globals
172192
sandbox.set(
@@ -223,6 +243,8 @@ void LuaReloadActiveMods()
223243
CurrentLuaState->events = RunScript(/*env=*/std::nullopt, "devilutionx.events", /*optional=*/false);
224244
CurrentLuaState->commonPackages["devilutionx.events"] = CurrentLuaState->events;
225245

246+
ClearTownerDialogOptions();
247+
226248
gbIsHellfire = false;
227249
UnloadModArchives();
228250

@@ -260,7 +282,7 @@ void LuaReloadActiveMods()
260282

261283
void LuaInitialize()
262284
{
263-
CurrentLuaState.emplace(LuaState { .sol = { sol::c_call<decltype(&LuaPanic), &LuaPanic> } });
285+
CurrentLuaState.emplace();
264286
sol::state &lua = CurrentLuaState->sol;
265287
lua_setwarnf(lua.lua_state(), LuaWarn, /*ud=*/nullptr);
266288
lua.open_libraries(
@@ -312,6 +334,9 @@ void LuaShutdown()
312334
#ifdef _DEBUG
313335
LuaReplShutdown();
314336
#endif
337+
// Must clear before destroying the Lua state: registered callbacks
338+
// capture sol::function handles that reference CurrentLuaState.
339+
ClearTownerDialogOptions();
315340
CurrentLuaState = std::nullopt;
316341
}
317342

Source/lua/modules/towners.cpp

Lines changed: 28 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,36 @@ 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.\n"
50+
"All options are cleared when mods reload or Lua shuts down; register from mod init.",
51+
[](std::string_view townerName, const sol::function &getLabel, const sol::function &onSelect) {
52+
RegisterTownerDialogOption(
53+
townerName,
54+
[getLabel]() -> std::string {
55+
sol::object result = getLabel();
56+
if (result.get_type() == sol::type::string)
57+
return result.as<std::string>();
58+
return {};
59+
},
60+
[onSelect]() {
61+
onSelect();
62+
});
63+
});
64+
5965
return table;
6066
}
6167

Source/stores.cpp

Lines changed: 118 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
#include <algorithm>
99
#include <cstdint>
10+
#include <optional>
11+
#include <string>
1012
#include <string_view>
13+
#include <utility>
14+
#include <vector>
1115

1216
#include <fmt/format.h>
1317

@@ -32,6 +36,7 @@
3236
#include "towners.h"
3337
#include "utils/format_int.hpp"
3438
#include "utils/language.h"
39+
#include "utils/log.hpp"
3540
#include "utils/str_cat.hpp"
3641
#include "utils/utf8.hpp"
3742

@@ -68,6 +73,71 @@ TalkID OldActiveStore;
6873
/** Temporary item used to hold the item being traded */
6974
Item TempItem;
7075

76+
std::vector<std::pair<std::string, std::vector<TownerDialogOption>>> ExtraTownerOptions;
77+
78+
const char *TownerNameForTalkID(TalkID s)
79+
{
80+
switch (s) {
81+
case TalkID::Smith: return "griswold";
82+
case TalkID::Witch: return "adria";
83+
case TalkID::Boy: return "wirt";
84+
case TalkID::Healer: return "pepin";
85+
case TalkID::Storyteller: return "cain";
86+
case TalkID::Tavern: return "ogden";
87+
case TalkID::Drunk: return "farnham";
88+
case TalkID::Barmaid: return "gillian";
89+
default: return nullptr;
90+
}
91+
}
92+
93+
/** Finds the entry for a towner in ExtraTownerOptions, or nullptr if none. */
94+
static std::vector<TownerDialogOption> *FindExtraTownerOptions(std::string_view townerName)
95+
{
96+
for (auto &[name, opts] : ExtraTownerOptions) {
97+
if (name == townerName)
98+
return &opts;
99+
}
100+
return nullptr;
101+
}
102+
103+
void RegisterTownerDialogOption(std::string_view townerName,
104+
std::function<std::string()> getLabel,
105+
std::function<void()> onSelect)
106+
{
107+
// Validate that the towner name is known.
108+
bool found = false;
109+
for (const auto &[id, shortName] : TownerShortNames) {
110+
if (shortName == townerName) {
111+
found = true;
112+
break;
113+
}
114+
}
115+
if (!found) {
116+
LogWarn("RegisterTownerDialogOption: unknown towner name \"{}\"", townerName);
117+
}
118+
119+
if (auto *opts = FindExtraTownerOptions(townerName); opts != nullptr) {
120+
opts->push_back({ std::move(getLabel), std::move(onSelect) });
121+
} else {
122+
std::vector<TownerDialogOption> newOpts;
123+
newOpts.push_back({ std::move(getLabel), std::move(onSelect) });
124+
ExtraTownerOptions.emplace_back(std::string(townerName), std::move(newOpts));
125+
}
126+
}
127+
128+
/**
129+
* Maps dialog line number to ExtraTownerOptions vector index for
130+
* options visible in the current dialog session.
131+
* Indexed by text line number (0..NumStoreLines-1).
132+
*/
133+
static std::optional<size_t> CurrentExtraOptionIndices[NumStoreLines];
134+
135+
void ClearTownerDialogOptions()
136+
{
137+
ExtraTownerOptions.clear();
138+
std::fill(std::begin(CurrentExtraOptionIndices), std::end(CurrentExtraOptionIndices), std::nullopt);
139+
}
140+
71141
namespace {
72142

73143
/** The current towner being interacted with */
@@ -2217,34 +2287,8 @@ void StartStore(TalkID s)
22172287
ReleaseStoreBtn();
22182288

22192289
// 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-
}
2290+
if (const char *name = TownerNameForTalkID(s); name != nullptr)
2291+
lua::StoreOpened(name);
22482292

22492293
switch (s) {
22502294
case TalkID::Smith:
@@ -2331,6 +2375,36 @@ void StartStore(TalkID s)
23312375
break;
23322376
}
23332377

2378+
std::fill(std::begin(CurrentExtraOptionIndices), std::end(CurrentExtraOptionIndices), std::nullopt);
2379+
if (const char *extraTownerName = TownerNameForTalkID(s); extraTownerName != nullptr) {
2380+
if (auto *extraOpts = FindExtraTownerOptions(extraTownerName); extraOpts != nullptr) {
2381+
// Find the last selectable line (the "leave"/"say goodbye" option).
2382+
int lastSelectableLine = -1;
2383+
for (int i = NumStoreLines - 1; i >= 0; --i) {
2384+
if (TextLine[i].isSelectable()) {
2385+
lastSelectableLine = i;
2386+
break;
2387+
}
2388+
}
2389+
2390+
// Insert extra options into empty even-numbered lines before the leave option.
2391+
size_t optIdx = 0;
2392+
for (int line = 10; line < lastSelectableLine && optIdx < extraOpts->size(); line += 2) {
2393+
if (TextLine[line].hasText()) continue;
2394+
std::string label = (*extraOpts)[optIdx].getLabel();
2395+
if (!label.empty()) {
2396+
AddSText(0, line, label, UiFlags::ColorWhite | UiFlags::AlignCenter, true);
2397+
CurrentExtraOptionIndices[line] = optIdx;
2398+
}
2399+
++optIdx;
2400+
}
2401+
if (optIdx < extraOpts->size()) {
2402+
LogWarn("Towner \"{}\" dialog: {} extra option(s) could not be placed (no empty lines)",
2403+
extraTownerName, extraOpts->size() - optIdx);
2404+
}
2405+
}
2406+
}
2407+
23342408
CurrentTextLine = -1;
23352409
for (int i = 0; i < NumStoreLines; i++) {
23362410
if (TextLine[i].isSelectable()) {
@@ -2595,6 +2669,22 @@ void StoreEnter()
25952669
}
25962670

25972671
PlaySFX(SfxID::MenuSelect);
2672+
2673+
if (CurrentTextLine >= 0 && CurrentTextLine < NumStoreLines && CurrentExtraOptionIndices[CurrentTextLine].has_value()) {
2674+
size_t optIdx = *CurrentExtraOptionIndices[CurrentTextLine];
2675+
if (const char *townerName = TownerNameForTalkID(ActiveStore); townerName != nullptr) {
2676+
if (auto *extraOpts = FindExtraTownerOptions(townerName); extraOpts != nullptr && optIdx < extraOpts->size()) {
2677+
ActiveStore = TalkID::None;
2678+
(*extraOpts)[optIdx].onSelect();
2679+
// If onSelect() set ActiveStore (e.g. to open a sub-dialog), preserve it.
2680+
// Otherwise it stays TalkID::None (dialog closed).
2681+
return;
2682+
}
2683+
}
2684+
ActiveStore = TalkID::None;
2685+
return;
2686+
}
2687+
25982688
switch (ActiveStore) {
25992689
case TalkID::Smith:
26002690
SmithEnter();

0 commit comments

Comments
 (0)