diff --git a/CMake/Assets.cmake b/CMake/Assets.cmake index 8b973ad6c92..9f37e2721c2 100644 --- a/CMake/Assets.cmake +++ b/CMake/Assets.cmake @@ -203,6 +203,8 @@ set(devilutionx_assets txtdata/sound/effects.tsv txtdata/spells/spelldat.tsv txtdata/text/textdat.tsv + txtdata/towners/quest_dialog.tsv + txtdata/towners/towners.tsv ui_art/diablo.pal ui_art/creditsw.clx ui_art/dvl_but_sml.clx diff --git a/CMake/Mods.cmake b/CMake/Mods.cmake index 354a96401c5..a7c551bcbba 100644 --- a/CMake/Mods.cmake +++ b/CMake/Mods.cmake @@ -18,6 +18,8 @@ set(hellfire_mod txtdata/monsters/monstdat.tsv txtdata/sound/effects.tsv txtdata/spells/spelldat.tsv + txtdata/towners/quest_dialog.tsv + txtdata/towners/towners.tsv ui_art/diablo.pal ui_art/hf_titlew.clx ui_art/supportw.clx diff --git a/CMake/Tests.cmake b/CMake/Tests.cmake index 019e10b14fa..51f0033df5f 100644 --- a/CMake/Tests.cmake +++ b/CMake/Tests.cmake @@ -35,6 +35,7 @@ set(tests stores_test tile_properties_test timedemo_test + townerdat_test writehero_test vendor_test ) diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 8f16d8641a2..164dd97cf1e 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -34,6 +34,7 @@ set(libdevilutionx_SRCS sync.cpp textdat.cpp tmsg.cpp + townerdat.cpp towners.cpp track.cpp diff --git a/Source/lua/modules/towners.cpp b/Source/lua/modules/towners.cpp index b04704b5de7..b1dd524ee38 100644 --- a/Source/lua/modules/towners.cpp +++ b/Source/lua/modules/towners.cpp @@ -1,6 +1,7 @@ #include "lua/modules/towners.hpp" #include +#include #include #include @@ -13,20 +14,21 @@ namespace devilution { namespace { -const char *const TownerTableNames[NUM_TOWNER_TYPES] { - "griswold", - "pepin", - "deadguy", - "ogden", - "cain", - "farnham", - "adria", - "gillian", - "wirt", - "cow", - "lester", - "celia", - "nut", +// 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) @@ -44,10 +46,15 @@ void PopulateTownerTable(_talker_id townerId, sol::table &out) sol::table LuaTownersModule(sol::state_view &lua) { sol::table table = lua.create_table(); - for (uint8_t townerId = TOWN_SMITH; townerId < NUM_TOWNER_TYPES; ++townerId) { + // 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 + sol::table townerTable = lua.create_table(); - PopulateTownerTable(static_cast<_talker_id>(townerId), townerTable); - LuaSetDoc(table, TownerTableNames[townerId], /*signature=*/"", TownerLongNames[townerId], std::move(townerTable)); + PopulateTownerTable(townerId, townerTable); + LuaSetDoc(table, tableNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable)); } return table; } diff --git a/Source/msg.cpp b/Source/msg.cpp index 29fd9d930c2..f5cb1b9fa24 100644 --- a/Source/msg.cpp +++ b/Source/msg.cpp @@ -1953,7 +1953,7 @@ size_t OnTalkXY(const TCmdLocParam1 &message, Player &player) const Point position { message.x, message.y }; const uint16_t townerIdx = Swap16LE(message.wParam1); - if (gbBufferMsgs != 1 && player.isOnActiveLevel() && InDungeonBounds(position) && townerIdx < NUM_TOWNERS) { + if (gbBufferMsgs != 1 && player.isOnActiveLevel() && InDungeonBounds(position) && townerIdx < GetNumTowners()) { MakePlrPath(player, position, false); player.destAction = ACTION_TALK; player.destParam1 = townerIdx; diff --git a/Source/quests.cpp b/Source/quests.cpp index 9c1fa68d5da..c5442d4cca0 100644 --- a/Source/quests.cpp +++ b/Source/quests.cpp @@ -29,6 +29,7 @@ #include "options.h" #include "panels/ui_panels.hpp" #include "stores.h" +#include "townerdat.hpp" #include "towners.h" #include "utils/endian_swap.hpp" #include "utils/is_of.hpp" @@ -197,8 +198,8 @@ void StartPWaterPurify() void InitQuests() { - QuestDialogTable[TOWN_HEALER][Q_MUSHROOM] = TEXT_NONE; - QuestDialogTable[TOWN_WITCH][Q_MUSHROOM] = TEXT_MUSH9; + SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_NONE); + SetTownerQuestDialog(TOWN_WITCH, Q_MUSHROOM, TEXT_MUSH9); QuestLogIsOpen = false; WaterDone = 0; @@ -626,10 +627,10 @@ void ResyncQuests() } else { if (Quests[Q_MUSHROOM]._qactive == QUEST_ACTIVE) { if (Quests[Q_MUSHROOM]._qvar1 >= QS_MUSHGIVEN) { - QuestDialogTable[TOWN_WITCH][Q_MUSHROOM] = TEXT_NONE; - QuestDialogTable[TOWN_HEALER][Q_MUSHROOM] = TEXT_MUSH3; + SetTownerQuestDialog(TOWN_WITCH, Q_MUSHROOM, TEXT_NONE); + SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_MUSH3); } else if (Quests[Q_MUSHROOM]._qvar1 >= QS_BRAINGIVEN) { - QuestDialogTable[TOWN_HEALER][Q_MUSHROOM] = TEXT_NONE; + SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_NONE); } } } diff --git a/Source/stores.cpp b/Source/stores.cpp index e8d255d72ab..0ddabdba005 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -26,6 +26,7 @@ #include "options.h" #include "panels/info_box.hpp" #include "qol/stash.h" +#include "townerdat.hpp" #include "towners.h" #include "utils/format_int.hpp" #include "utils/language.h" @@ -1207,7 +1208,7 @@ void StartTalk() int sn = 0; for (auto &quest : Quests) { - if (quest._qactive == QUEST_ACTIVE && QuestDialogTable[TownerId][quest._qidx] != TEXT_NONE && quest._qlog) + if (quest._qactive == QUEST_ACTIVE && GetTownerQuestDialog(TownerId, quest._qidx) != TEXT_NONE && quest._qlog) sn++; } @@ -1222,7 +1223,7 @@ void StartTalk() const int sn2 = sn - 2; for (auto &quest : Quests) { - if (quest._qactive == QUEST_ACTIVE && QuestDialogTable[TownerId][quest._qidx] != TEXT_NONE && quest._qlog) { + if (quest._qactive == QUEST_ACTIVE && GetTownerQuestDialog(TownerId, quest._qidx) != TEXT_NONE && quest._qlog) { AddSText(0, sn, _(QuestsData[quest._qidx]._qlstr), UiFlags::ColorWhite | UiFlags::AlignCenter, true); sn += la; } @@ -1911,7 +1912,7 @@ void TalkEnter() int sn = 0; for (auto &quest : Quests) { - if (quest._qactive == QUEST_ACTIVE && QuestDialogTable[TownerId][quest._qidx] != TEXT_NONE && quest._qlog) + if (quest._qactive == QUEST_ACTIVE && GetTownerQuestDialog(TownerId, quest._qidx) != TEXT_NONE && quest._qlog) sn++; } int la = 2; @@ -1930,9 +1931,9 @@ void TalkEnter() } for (auto &quest : Quests) { - if (quest._qactive == QUEST_ACTIVE && QuestDialogTable[TownerId][quest._qidx] != TEXT_NONE && quest._qlog) { + if (quest._qactive == QUEST_ACTIVE && GetTownerQuestDialog(TownerId, quest._qidx) != TEXT_NONE && quest._qlog) { if (sn == CurrentTextLine) { - InitQTextMsg(QuestDialogTable[TownerId][quest._qidx]); + InitQTextMsg(GetTownerQuestDialog(TownerId, quest._qidx)); } sn += la; } diff --git a/Source/townerdat.cpp b/Source/townerdat.cpp new file mode 100644 index 00000000000..13b44974930 --- /dev/null +++ b/Source/townerdat.cpp @@ -0,0 +1,245 @@ +/** + * @file townerdat.cpp + * + * Implementation of towner data loading from TSV files. + */ +#include "townerdat.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include "data/file.hpp" +#include "data/record_reader.hpp" + +namespace devilution { + +std::vector TownersDataEntries; +std::unordered_map<_talker_id, std::array<_speech_id, MAXQUESTS>> TownerQuestDialogTable; + +namespace { + +/** + * @brief Generic enum parser using magic_enum. + * @tparam EnumT The enum type to parse + * @param value The string representation of the enum value + * @return The parsed enum value, or an error message + */ +template +tl::expected ParseEnum(std::string_view value) +{ + const auto enumValueOpt = magic_enum::enum_cast(value); + if (enumValueOpt.has_value()) { + return enumValueOpt.value(); + } + return tl::make_unexpected("Unknown enum value"); +} + +/** + * @brief Parses a comma-separated list of values. + * @tparam T The output type + * @tparam Parser A callable that converts string_view to optional + * @param value The comma-separated string + * @param out Vector to store parsed values (cleared first) + * @param parser Function to parse individual tokens + */ +template +void ParseCommaSeparatedList(std::string_view value, std::vector &out, Parser parser) +{ + out.clear(); + if (value.empty()) return; + + size_t start = 0; + while (start < value.size()) { + size_t end = value.find(',', start); + if (end == std::string_view::npos) end = value.size(); + + std::string_view token = value.substr(start, end - start); + if (auto result = parser(token)) { + out.push_back(*result); + } + + start = end + 1; + } +} + +/** + * @brief Parses a comma-separated list of speech IDs. + */ +void ParseGossipTexts(std::string_view value, std::vector<_speech_id> &out) +{ + ParseCommaSeparatedList(value, out, [](std::string_view token) -> std::optional<_speech_id> { + if (auto result = ParseSpeechId(token); result.has_value()) { + return result.value(); + } + return std::nullopt; + }); +} + +/** + * @brief Parses a comma-separated list of integers for animation frame order. + */ +void ParseAnimOrder(std::string_view value, std::vector &out) +{ + ParseCommaSeparatedList(value, out, [](std::string_view token) -> std::optional { + int val = 0; + if (auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), val); ec == std::errc()) { + return static_cast(val); + } + return std::nullopt; + }); +} + +void LoadTownersFromFile() +{ + const std::string_view filename = "txtdata\\towners\\towners.tsv"; + DataFile dataFile = DataFile::loadOrDie(filename); + dataFile.skipHeaderOrDie(filename); + + TownersDataEntries.clear(); + TownersDataEntries.reserve(dataFile.numRecords()); + + for (DataFileRecord record : dataFile) { + RecordReader reader { record, filename }; + TownerDataEntry &entry = TownersDataEntries.emplace_back(); + + reader.read("type", entry.type, ParseEnum<_talker_id>); + reader.readString("name", entry.name); + reader.readInt("position_x", entry.position.x); + reader.readInt("position_y", entry.position.y); + reader.read("direction", entry.direction, ParseEnum); + reader.readInt("animWidth", entry.animWidth); + reader.readString("animPath", entry.animPath); + reader.readOptionalInt("animFrames", entry.animFrames); + reader.readOptionalInt("animDelay", entry.animDelay); + + std::string gossipStr; + reader.readString("gossipTexts", gossipStr); + ParseGossipTexts(gossipStr, entry.gossipTexts); + + std::string animOrderStr; + reader.readString("animOrder", animOrderStr); + ParseAnimOrder(animOrderStr, entry.animOrder); + } + + TownersDataEntries.shrink_to_fit(); +} + +void LoadQuestDialogFromFile() +{ + const std::string_view filename = "txtdata\\towners\\quest_dialog.tsv"; + DataFile dataFile = DataFile::loadOrDie(filename); + + // Initialize table (will be populated as we read rows) + TownerQuestDialogTable.clear(); + + // Parse header to find which quest columns exist + // Store the iterator to avoid temporary lifetime issues + auto headerIt = dataFile.begin(); + DataFileRecord headerRecord = *headerIt; + std::unordered_map columnMap; + unsigned columnIndex = 0; + for (DataFileField field : headerRecord) { + columnMap[std::string(field.value())] = columnIndex++; + } + + // Reset header position and skip for data reading + dataFile.resetHeader(); + dataFile.skipHeaderOrDie(filename); + + // Find the towner_type column index + if (!columnMap.contains("towner_type")) { + return; // Invalid file format + } + unsigned townerTypeColIndex = columnMap["towner_type"]; + + // Build quest column index map + std::unordered_map questColumnMap; + for (quest_id quest : magic_enum::enum_values()) { + if (quest == Q_INVALID || quest >= MAXQUESTS) continue; + + auto questName = std::string(magic_enum::enum_name(quest)); + if (columnMap.contains(questName)) { + questColumnMap[quest] = columnMap[questName]; + } + } + + // Read data rows + for (DataFileRecord record : dataFile) { + // Read all fields into a map keyed by column index for indexed access + std::unordered_map fields; + for (DataFileField field : record) { + fields[field.column()] = field.value(); + } + + // Read towner_type + if (!fields.contains(townerTypeColIndex)) { + continue; // Invalid row + } + + auto townerTypeResult = ParseEnum<_talker_id>(fields[townerTypeColIndex]); + if (!townerTypeResult.has_value()) { + continue; // Invalid towner type + } + _talker_id townerType = townerTypeResult.value(); + + // Initialize row if it doesn't exist, then get reference + auto [it, inserted] = TownerQuestDialogTable.try_emplace(townerType); + if (inserted) { + it->second.fill(TEXT_NONE); + } + auto &dialogRow = it->second; + + // Read quest columns that exist in this file + for (const auto &[quest, colIndex] : questColumnMap) { + if (!fields.contains(colIndex)) { + continue; // Column missing in this row + } + + auto speechResult = ParseSpeechId(fields[colIndex]); + if (speechResult.has_value()) { + dialogRow[quest] = speechResult.value(); + } + } + } +} + +} // namespace + +void LoadTownerData() +{ + LoadTownersFromFile(); + LoadQuestDialogFromFile(); +} + +_speech_id GetTownerQuestDialog(_talker_id type, quest_id quest) +{ + if (quest < 0 || quest >= MAXQUESTS) { + return TEXT_NONE; + } + auto it = TownerQuestDialogTable.find(type); + if (it == TownerQuestDialogTable.end()) { + return TEXT_NONE; + } + return it->second[quest]; +} + +void SetTownerQuestDialog(_talker_id type, quest_id quest, _speech_id speech) +{ + if (quest < 0 || quest >= MAXQUESTS) { + return; + } + // Initialize row if it doesn't exist + auto [it, inserted] = TownerQuestDialogTable.try_emplace(type); + if (inserted) { + it->second.fill(TEXT_NONE); + } + it->second[quest] = speech; +} + +} // namespace devilution diff --git a/Source/townerdat.hpp b/Source/townerdat.hpp new file mode 100644 index 00000000000..2b76465a50c --- /dev/null +++ b/Source/townerdat.hpp @@ -0,0 +1,68 @@ +/** + * @file townerdat.hpp + * + * Interface for loading towner data from TSV files. + */ +#pragma once + +#include +#include +#include +#include + +#include "engine/direction.hpp" +#include "levels/gendung.h" +#include "objdat.h" +#include "textdat.h" +#include "towners.h" + +namespace devilution { + +/** + * @brief Data for a single towner entry loaded from TSV. + */ +struct TownerDataEntry { + _talker_id type; // Parsed from TSV using magic_enum + std::string name; + Point position; + Direction direction; + uint16_t animWidth; + std::string animPath; + uint8_t animFrames; + int16_t animDelay; + std::vector<_speech_id> gossipTexts; + std::vector animOrder; +}; + +/** Contains the data for all towners loaded from TSV. */ +extern std::vector TownersDataEntries; + +/** Contains the quest dialog table loaded from TSV. Indexed by [towner_type][quest_id]. */ +extern std::unordered_map<_talker_id, std::array<_speech_id, MAXQUESTS>> TownerQuestDialogTable; + +/** + * @brief Loads towner data from TSV files. + * + * This function loads data from: + * - txtdata/towners/towners.tsv - Main towner definitions + * - txtdata/towners/quest_dialog.tsv - Quest dialog mappings + */ +void LoadTownerData(); + +/** + * @brief Gets the quest dialog speech ID for a towner and quest combination. + * @param type The towner type + * @param quest The quest ID + * @return The speech ID for the dialog, or TEXT_NONE if not available + */ +_speech_id GetTownerQuestDialog(_talker_id type, quest_id quest); + +/** + * @brief Sets the quest dialog speech ID for a towner and quest combination. + * @param type The towner type + * @param quest The quest ID + * @param speech The speech ID to set + */ +void SetTownerQuestDialog(_talker_id type, quest_id quest, _speech_id speech); + +} // namespace devilution diff --git a/Source/towners.cpp b/Source/towners.cpp index d3294ffab94..db0f77fb578 100644 --- a/Source/towners.cpp +++ b/Source/towners.cpp @@ -1,6 +1,8 @@ #include "towners.h" +#include #include +#include #include "cursor.h" #include "engine/clx_sprite.hpp" @@ -12,6 +14,7 @@ #include "minitext.h" #include "stores.h" #include "textdat.h" +#include "townerdat.hpp" #include "utils/is_of.hpp" #include "utils/language.h" #include "utils/str_case.hpp" @@ -26,14 +29,56 @@ int CowClicks; /** Specifies the active sound effect ID for interacting with cows. */ SfxID CowPlaying = SfxID::None; +/** Storage for animation order data loaded from TSV (needs stable addresses for span). */ +std::vector> TownerAnimOrderStorage; + +/** + * @brief Defines the behavior (init and talk functions) for each towner type. + * + * The actual data (position, animation, gossip) comes from TSV files. + * This struct only holds the code that can't be data-driven. + */ struct TownerData { _talker_id type; - Point position; - Direction dir; - void (*init)(Towner &towner, const TownerData &townerData); + /** Custom initialization function, or nullptr to use the default InitTownerFromData. */ + void (*init)(Towner &towner, const TownerDataEntry &entry); + /** Function called when the player talks to this towner. */ void (*talk)(Player &player, Towner &towner); }; +/** + * @brief Lookup table from towner type to its behavior data. + * + * Populated during InitTowners() from the TownersData array. + */ +std::unordered_map<_talker_id, const TownerData *> TownerBehaviors; + +/** + * @brief Default towner initialization using TSV data. + * + * Sets up animation, gossip texts, and other properties from the TSV entry. + * Used for most towners; special cases (cows, cow farmer) have custom init functions. + */ +void InitTownerFromData(Towner &towner, const TownerDataEntry &entry); + +#ifdef _DEBUG +/** + * @brief Finds the towner data entry from TSV for a given type. + */ +const TownerDataEntry *FindTownerDataEntry(_talker_id type, Point position = {}) +{ + for (const auto &entry : TownersDataEntries) { + if (entry.type == type) { + // For types with multiple instances (like cows), match by position + if (position != Point {} && entry.position != position) + continue; + return &entry; + } + } + return nullptr; +} +#endif + void NewTownerAnim(Towner &towner, ClxSpriteList sprites, uint8_t numFrames, int delay) { towner.anim.emplace(sprites); @@ -43,22 +88,19 @@ void NewTownerAnim(Towner &towner, ClxSpriteList sprites, uint8_t numFrames, int towner._tAnimDelay = delay; } -void InitTownerInfo(Towner &towner, const TownerData &townerData) +void InitTownerInfo(Towner &towner, const TownerData &townerData, const TownerDataEntry &entry) { towner._ttype = townerData.type; - towner.name = _(TownerLongNames[townerData.type]); - towner.position = townerData.position; + auto nameIt = TownerLongNames.find(townerData.type); + towner.name = nameIt != TownerLongNames.end() ? _(nameIt->second.c_str()) : std::string_view(entry.name); + towner.position = entry.position; towner.talk = townerData.talk; - townerData.init(towner, townerData); -} - -void InitTownerInfo(int16_t i, const TownerData &townerData) -{ - // It's necessary to assign this before invoking townerData.init() - // specifically for the cows that need to read this value to fill adjacent tiles - dMonster[townerData.position.x][townerData.position.y] = i + 1; - InitTownerInfo(Towners[i], townerData); + if (townerData.init != nullptr) { + townerData.init(towner, entry); + } else { + InitTownerFromData(towner, entry); + } } void LoadTownerAnimations(Towner &towner, const char *path, int frames, int delay) @@ -69,153 +111,49 @@ void LoadTownerAnimations(Towner &towner, const char *path, int frames, int dela } /** - * @brief Load Griswold into the game + * @brief Default towner initialization using TSV data. */ -void InitSmith(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\smith\\smithn", 16, 3); - towner.gossip = PickRandomlyAmong({ TEXT_GRISWOLD2, TEXT_GRISWOLD3, TEXT_GRISWOLD4, TEXT_GRISWOLD5, TEXT_GRISWOLD6, TEXT_GRISWOLD7, TEXT_GRISWOLD8, TEXT_GRISWOLD9, TEXT_GRISWOLD10, TEXT_GRISWOLD12, TEXT_GRISWOLD13 }); -} - -void InitBarOwner(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 1, 0, 15, 14, 13, 13, 14, 15, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\twnf\\twnfn", 16, 3); - towner.gossip = PickRandomlyAmong({ TEXT_OGDEN2, TEXT_OGDEN3, TEXT_OGDEN4, TEXT_OGDEN5, TEXT_OGDEN6, TEXT_OGDEN8, TEXT_OGDEN9, TEXT_OGDEN10 }); -} - -void InitTownDead(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - towner.animOrder = {}; - LoadTownerAnimations(towner, "towners\\butch\\deadguy", 8, 6); -} - -void InitWitch(Towner &towner, const TownerData &townerData) +void InitTownerFromData(Towner &towner, const TownerDataEntry &entry) { - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 3, 3, 3, 4, 5, 5, 5, 4, 3, 14, 13, 12, 12, 12, 13, 14, 3, 4, 5, 5, 5, 4, - 3, 3, 3, 4, 5, 5, 5, 4, 3, 14, 13, 12, 12, 12, 13, 14, 3, 4, 5, 5, 5, 4, - 3, 3, 3, 4, 5, 5, 5, 4, 3, 14, 13, 12, 12, 12, 13, 14, 3, 4, 5, 5, 5, 4, - 3, 2, 1, 0, 18, 17, 18, 0, 1, 0, 18, 17, 18, 0, 1, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, - 14, 14, 13, 12, 12, 12, 12, 13, 14, - 14, 14, 13, 12, 11, 11, 11, 10, 9, 9, 9, 8, - 7, 8, 9, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 0, 1, 0, 18, 17, 18, 0, 1, 0, 1, 2 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\townwmn1\\witch", 19, 6); - towner.gossip = PickRandomlyAmong({ TEXT_ADRIA2, TEXT_ADRIA3, TEXT_ADRIA4, TEXT_ADRIA5, TEXT_ADRIA6, TEXT_ADRIA7, TEXT_ADRIA8, TEXT_ADRIA9, TEXT_ADRIA10, TEXT_ADRIA12, TEXT_ADRIA13 }); -} + towner._tAnimWidth = entry.animWidth; -void InitBarmaid(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - towner.animOrder = {}; - LoadTownerAnimations(towner, "towners\\townwmn1\\wmnn", 18, 6); - towner.gossip = PickRandomlyAmong({ TEXT_GILLIAN2, TEXT_GILLIAN3, TEXT_GILLIAN4, TEXT_GILLIAN5, TEXT_GILLIAN6, TEXT_GILLIAN7, TEXT_GILLIAN9, TEXT_GILLIAN10 }); + // Store animation order and set the span + if (!entry.animOrder.empty()) { + TownerAnimOrderStorage.push_back(entry.animOrder); + towner.animOrder = { TownerAnimOrderStorage.back() }; + } else { + towner.animOrder = {}; + } + + if (!entry.animPath.empty()) { + LoadTownerAnimations(towner, entry.animPath.c_str(), entry.animFrames, entry.animDelay); + } + + // Set gossip from TSV data + if (!entry.gossipTexts.empty()) { + const auto index = std::max(GenerateRnd(static_cast(entry.gossipTexts.size())), 0); + towner.gossip = entry.gossipTexts[index]; + } } -void InitBoy(Towner &towner, const TownerData &townerData) +/** + * @brief Special initialization for cows. + * + * Cows differ from other towners: + * - They share a sprite sheet (CowSprites) instead of loading individual animations + * - They occupy multiple tiles (4 tiles for collision purposes) + * - Animation frame is randomized on spawn + */ +void InitCows(Towner &towner, const TownerDataEntry &entry) { - towner._tAnimWidth = 96; - towner.animOrder = {}; - LoadTownerAnimations(towner, "towners\\townboy\\pegkid1", 20, 6); - towner.gossip = PickRandomlyAmong({ TEXT_WIRT2, TEXT_WIRT3, TEXT_WIRT4, TEXT_WIRT5, TEXT_WIRT6, TEXT_WIRT7, TEXT_WIRT8, TEXT_WIRT9, TEXT_WIRT11, TEXT_WIRT12 }); -} - -void InitHealer(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 0, 1, 2, 2, 1, 0, 19, 18, 18, 19, - 0, 1, 2, 2, 1, 0, 19, 18, 18, 19, - 0, 1, 2, 2, 1, 0, 19, 18, 18, 19, - 0, 1, 2, 2, 1, 0, 19, 18, 18, 19, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, - 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, - 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, - 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\healer\\healer", 20, 6); - towner.gossip = PickRandomlyAmong({ TEXT_PEPIN2, TEXT_PEPIN3, TEXT_PEPIN4, TEXT_PEPIN5, TEXT_PEPIN6, TEXT_PEPIN7, TEXT_PEPIN9, TEXT_PEPIN10, TEXT_PEPIN11 }); -} - -void InitTeller(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 0, 0, 24, 24, 23, 22, 21, 20, 19, 18, 17, 16, 15, 14, - 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 24, 24, 0, 0, 0, 24, - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, - 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\strytell\\strytell", 25, 3); - towner.gossip = PickRandomlyAmong({ TEXT_STORY2, TEXT_STORY3, TEXT_STORY4, TEXT_STORY5, TEXT_STORY6, TEXT_STORY7, TEXT_STORY9, TEXT_STORY10, TEXT_STORY11 }); -} - -void InitDrunk(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - static const uint8_t AnimOrder[] = { - // clang-format off - 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 10, 10, 10, 11, 12, 13, 14, 15, 16, 17, 17, - 0, 0, 0, 17, 16, 15, 14, 13, 12, 11, 10, 9, 10, 11, 12, 13, 14, 15, 16, 17, - 0, 1, 2, 3, 4, 4, 4, 3, 2, 1 - // clang-format on - }; - towner.animOrder = { AnimOrder }; - LoadTownerAnimations(towner, "towners\\drunk\\twndrunk", 18, 3); - towner.gossip = PickRandomlyAmong({ TEXT_FARNHAM2, TEXT_FARNHAM3, TEXT_FARNHAM4, TEXT_FARNHAM5, TEXT_FARNHAM6, TEXT_FARNHAM8, TEXT_FARNHAM9, TEXT_FARNHAM10, TEXT_FARNHAM11, TEXT_FARNHAM12, TEXT_FARNHAM13 }); -} - -void InitCows(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 128; + // Cows use a shared sprite sheet and need special handling + towner._tAnimWidth = entry.animWidth; towner.animOrder = {}; - NewTownerAnim(towner, (*CowSprites)[static_cast(townerData.dir)], 12, 3); + NewTownerAnim(towner, (*CowSprites)[static_cast(entry.direction)], 12, 3); towner._tAnimFrame = GenerateRnd(11); - const Point position = townerData.position; + const Point position = entry.position; const int16_t cowId = dMonster[position.x][position.y]; // Cows are large sprites so take up multiple tiles. Vanilla Diablo/Hellfire allowed the player to stand adjacent @@ -231,31 +169,24 @@ void InitCows(Towner &towner, const TownerData &townerData) dMonster[offset.x][offset.y] = -cowId; } -void InitFarmer(Towner &towner, const TownerData &townerData) +/** + * @brief Special initialization for the cow farmer (Complete Nut). + * + * Uses different sprites depending on whether the Jersey quest is complete. + */ +void InitCowFarmer(Towner &towner, const TownerDataEntry &entry) { - towner._tAnimWidth = 96; + towner._tAnimWidth = entry.animWidth; towner.animOrder = {}; - LoadTownerAnimations(towner, "towners\\farmer\\farmrn2", 15, 3); -} -void InitCowFarmer(Towner &towner, const TownerData &townerData) -{ + // CowFarmer has special logic for quest state const char *celPath = "towners\\farmer\\cfrmrn2"; if (Quests[Q_JERSEY]._qactive == QUEST_DONE) { celPath = "towners\\farmer\\mfrmrn2"; } - towner._tAnimWidth = 96; - towner.animOrder = {}; LoadTownerAnimations(towner, celPath, 15, 3); } -void InitGirl(Towner &towner, const TownerData &townerData) -{ - towner._tAnimWidth = 96; - towner.animOrder = {}; - LoadTownerAnimations(towner, "towners\\girl\\girlw1", 20, 6); -} - void TownDead(Towner &towner) { if (qtextflag) { @@ -421,8 +352,8 @@ void TalkToWitch(Player &player, Towner & /*witch*/) if (Quests[Q_MUSHROOM]._qvar1 >= QS_TOMEGIVEN && Quests[Q_MUSHROOM]._qvar1 < QS_MUSHGIVEN) { if (RemoveInventoryItemById(player, IDI_MUSHROOM)) { Quests[Q_MUSHROOM]._qvar1 = QS_MUSHGIVEN; - QuestDialogTable[TOWN_HEALER][Q_MUSHROOM] = TEXT_MUSH3; - QuestDialogTable[TOWN_WITCH][Q_MUSHROOM] = TEXT_NONE; + SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_MUSH3); + SetTownerQuestDialog(TOWN_WITCH, Q_MUSHROOM, TEXT_NONE); Quests[Q_MUSHROOM]._qmsg = TEXT_MUSH10; NetSendCmdQuest(true, Quests[Q_MUSHROOM]); InitQTextMsg(TEXT_MUSH10); @@ -505,7 +436,7 @@ void TalkToHealer(Player &player, Towner &healer) SpawnQuestItem(IDI_SPECELIX, healer.position + Displacement { 0, 1 }, 0, SelectionRegion::None, true); InitQTextMsg(TEXT_MUSH4); blackMushroom._qvar1 = QS_BRAINGIVEN; - QuestDialogTable[TOWN_HEALER][Q_MUSHROOM] = TEXT_NONE; + SetTownerQuestDialog(TOWN_HEALER, Q_MUSHROOM, TEXT_NONE); NetSendCmdQuest(true, blackMushroom); return; } @@ -750,64 +681,38 @@ void TalkToGirl(Player &player, Towner &girl) const TownerData TownersData[] = { // clang-format off - // type position dir init talk - { TOWN_SMITH, { 62, 63 }, Direction::SouthWest, InitSmith, TalkToBlackSmith }, - { TOWN_HEALER, { 55, 79 }, Direction::SouthEast, InitHealer, TalkToHealer }, - { TOWN_DEADGUY, { 24, 32 }, Direction::North, InitTownDead, TalkToDeadguy }, - { TOWN_TAVERN, { 55, 62 }, Direction::SouthWest, InitBarOwner, TalkToBarOwner }, - { TOWN_STORY, { 62, 71 }, Direction::South, InitTeller, TalkToStoryteller }, - { TOWN_DRUNK, { 71, 84 }, Direction::South, InitDrunk, TalkToDrunk }, - { TOWN_WITCH, { 80, 20 }, Direction::South, InitWitch, TalkToWitch }, - { TOWN_BMAID, { 43, 66 }, Direction::South, InitBarmaid, TalkToBarmaid }, - { TOWN_PEGBOY, { 11, 53 }, Direction::South, InitBoy, TalkToBoy }, - { TOWN_COW, { 58, 16 }, Direction::SouthWest, InitCows, TalkToCow }, - { TOWN_COW, { 56, 14 }, Direction::NorthWest, InitCows, TalkToCow }, - { TOWN_COW, { 59, 20 }, Direction::North, InitCows, TalkToCow }, - { TOWN_COWFARM, { 61, 22 }, Direction::SouthWest, InitCowFarmer, TalkToCowFarmer }, - { TOWN_FARMER, { 62, 16 }, Direction::South, InitFarmer, TalkToFarmer }, - { TOWN_GIRL, { 77, 43 }, Direction::South, InitGirl, TalkToGirl }, + // type init (nullptr = default) talk + { TOWN_SMITH, nullptr, TalkToBlackSmith }, + { TOWN_HEALER, nullptr, TalkToHealer }, + { TOWN_DEADGUY, nullptr, TalkToDeadguy }, + { TOWN_TAVERN, nullptr, TalkToBarOwner }, + { TOWN_STORY, nullptr, TalkToStoryteller }, + { TOWN_DRUNK, nullptr, TalkToDrunk }, + { TOWN_WITCH, nullptr, TalkToWitch }, + { TOWN_BMAID, nullptr, TalkToBarmaid }, + { TOWN_PEGBOY, nullptr, TalkToBoy }, + { TOWN_COW, InitCows, TalkToCow }, + { TOWN_COWFARM, InitCowFarmer, TalkToCowFarmer }, + { TOWN_FARMER, nullptr, TalkToFarmer }, + { TOWN_GIRL, nullptr, TalkToGirl }, // clang-format on }; } // namespace -Towner Towners[NUM_TOWNERS]; - -const char *const TownerLongNames[NUM_TOWNER_TYPES] { - N_("Griswold the Blacksmith"), - N_("Pepin the Healer"), - N_("Wounded Townsman"), - N_("Ogden the Tavern owner"), - N_("Cain the Elder"), - N_("Farnham the Drunk"), - N_("Adria the Witch"), - N_("Gillian the Barmaid"), - N_("Wirt the Peg-legged boy"), - N_("Cow"), - N_("Lester the farmer"), - N_("Celia"), - N_("Complete Nut") -}; +std::vector Towners; -/** Contains the data related to quest gossip for each towner ID. */ -_speech_id QuestDialogTable[NUM_TOWNER_TYPES][MAXQUESTS] = { - // clang-format off - // Q_ROCK, Q_MUSHROOM, Q_GARBUD, Q_ZHAR, Q_VEIL, Q_DIABLO, Q_BUTCHER, Q_LTBANNER, Q_BLIND, Q_BLOOD, Q_ANVIL, Q_WARLORD, Q_SKELKING, Q_PWATER, Q_SCHAMB, Q_BETRAYER, Q_GRAVE, Q_FARMER, Q_GIRL, Q_TRADER, Q_DEFILER, Q_NAKRUL, Q_CORNSTN, Q_JERSEY - /*TOWN_SMITH*/ { TEXT_INFRA6, TEXT_MUSH6, TEXT_NONE, TEXT_NONE, TEXT_VEIL5, TEXT_NONE, TEXT_BUTCH5, TEXT_BANNER6, TEXT_BLIND5, TEXT_BLOOD5, TEXT_ANVIL6, TEXT_WARLRD5, TEXT_KING7, TEXT_POISON7, TEXT_BONE5, TEXT_VILE9, TEXT_GRAVE2, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_HEALER*/ { TEXT_INFRA3, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_VEIL3, TEXT_NONE, TEXT_BUTCH3, TEXT_BANNER4, TEXT_BLIND3, TEXT_BLOOD3, TEXT_ANVIL3, TEXT_WARLRD3, TEXT_KING5, TEXT_POISON4, TEXT_BONE3, TEXT_VILE7, TEXT_GRAVE3, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_DEADGUY*/ { TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_TAVERN*/ { TEXT_INFRA2, TEXT_MUSH2, TEXT_NONE, TEXT_NONE, TEXT_VEIL2, TEXT_NONE, TEXT_BUTCH2, TEXT_NONE, TEXT_BLIND2, TEXT_BLOOD2, TEXT_ANVIL2, TEXT_WARLRD2, TEXT_KING3, TEXT_POISON2, TEXT_BONE2, TEXT_VILE4, TEXT_GRAVE5, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_STORY*/ { TEXT_INFRA1, TEXT_MUSH1, TEXT_NONE, TEXT_NONE, TEXT_VEIL1, TEXT_VILE3, TEXT_BUTCH1, TEXT_BANNER1, TEXT_BLIND1, TEXT_BLOOD1, TEXT_ANVIL1, TEXT_WARLRD1, TEXT_KING1, TEXT_POISON1, TEXT_BONE1, TEXT_VILE2, TEXT_GRAVE6, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_DRUNK*/ { TEXT_INFRA8, TEXT_MUSH7, TEXT_NONE, TEXT_NONE, TEXT_VEIL6, TEXT_NONE, TEXT_BUTCH6, TEXT_BANNER7, TEXT_BLIND6, TEXT_BLOOD6, TEXT_ANVIL8, TEXT_WARLRD6, TEXT_KING8, TEXT_POISON8, TEXT_BONE6, TEXT_VILE10, TEXT_GRAVE7, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_WITCH*/ { TEXT_INFRA9, TEXT_MUSH9, TEXT_NONE, TEXT_NONE, TEXT_VEIL7, TEXT_NONE, TEXT_BUTCH7, TEXT_BANNER8, TEXT_BLIND7, TEXT_BLOOD7, TEXT_ANVIL9, TEXT_WARLRD7, TEXT_KING9, TEXT_POISON9, TEXT_BONE7, TEXT_VILE11, TEXT_GRAVE1, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_BMAID*/ { TEXT_INFRA4, TEXT_MUSH5, TEXT_NONE, TEXT_NONE, TEXT_VEIL4, TEXT_NONE, TEXT_BUTCH4, TEXT_BANNER5, TEXT_BLIND4, TEXT_BLOOD4, TEXT_ANVIL4, TEXT_WARLRD4, TEXT_KING6, TEXT_POISON6, TEXT_BONE4, TEXT_VILE8, TEXT_GRAVE8, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_PEGBOY*/ { TEXT_INFRA10, TEXT_MUSH13, TEXT_NONE, TEXT_NONE, TEXT_VEIL8, TEXT_NONE, TEXT_BUTCH8, TEXT_BANNER9, TEXT_BLIND8, TEXT_BLOOD8, TEXT_ANVIL10, TEXT_WARLRD8, TEXT_KING10, TEXT_POISON10, TEXT_BONE8, TEXT_VILE12, TEXT_GRAVE9, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_COW*/ { TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_FARMER*/ { TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_GIRL*/ { TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - /*TOWN_COWFARM*/ { TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE, TEXT_NONE }, - // clang-format on -}; +std::unordered_map<_talker_id, std::string> TownerLongNames; + +size_t GetNumTownerTypes() +{ + return TownerLongNames.size(); +} + +size_t GetNumTowners() +{ + return Towners.size(); +} bool IsTownerPresent(_talker_id npc) { @@ -838,14 +743,41 @@ void InitTowners() { assert(!CowSprites); + // Load towner data from TSV files + LoadTownerData(); + TownerAnimOrderStorage.clear(); + + // Build lookup table for towner behaviors + TownerBehaviors.clear(); + for (const auto &behavior : TownersData) { + TownerBehaviors[behavior.type] = &behavior; + } + + // Build TownerLongNames from TSV data (first occurrence of each type wins) + TownerLongNames.clear(); + for (const auto &entry : TownersDataEntries) { + TownerLongNames.try_emplace(entry.type, entry.name); + } + CowSprites.emplace(LoadCelSheet("towners\\animals\\cow", 128)); + Towners.clear(); + Towners.reserve(TownersDataEntries.size()); int16_t i = 0; - for (const auto &townerData : TownersData) { - if (!IsTownerPresent(townerData.type)) + for (const auto &entry : TownersDataEntries) { + if (!IsTownerPresent(entry.type)) + continue; + + auto behaviorIt = TownerBehaviors.find(entry.type); + if (behaviorIt == TownerBehaviors.end() || behaviorIt->second == nullptr) continue; - InitTownerInfo(i, townerData); + // It's necessary to assign this before invoking townerData.init() + // specifically for the cows that need to read this value to fill adjacent tiles + dMonster[entry.position.x][entry.position.y] = i + 1; + + Towners.emplace_back(); + InitTownerInfo(Towners.back(), *behaviorIt->second, entry); i++; } } @@ -928,17 +860,22 @@ bool DebugTalkToTowner(_talker_id type) // cows have an init function that differs from the rest and isn't compatible with this code, skip them :( if (type == TOWN_COW) return false; + + const TownerData *behavior = TownerBehaviors[type]; + if (behavior == nullptr) + return false; + + const TownerDataEntry *entry = FindTownerDataEntry(type); + if (entry == nullptr) + return false; + SetupTownStores(); Player &myPlayer = *MyPlayer; - for (const TownerData &townerData : TownersData) { - if (townerData.type != type) continue; - Towner fakeTowner; - InitTownerInfo(fakeTowner, townerData); - fakeTowner.position = myPlayer.position.tile; - townerData.talk(myPlayer, fakeTowner); - return true; - } - return false; + Towner fakeTowner; + InitTownerInfo(fakeTowner, *behavior, *entry); + fakeTowner.position = myPlayer.position.tile; + behavior->talk(myPlayer, fakeTowner); + return true; } #endif diff --git a/Source/towners.h b/Source/towners.h index fd7c7ccf1ed..026de3dc248 100644 --- a/Source/towners.h +++ b/Source/towners.h @@ -9,7 +9,10 @@ #include #include #include +#include #include +#include +#include #include "engine/clx_sprite.hpp" #include "items.h" @@ -19,8 +22,6 @@ namespace devilution { -#define NUM_TOWNERS 16 - enum _talker_id : uint8_t { TOWN_SMITH, TOWN_HEALER, @@ -35,10 +36,12 @@ enum _talker_id : uint8_t { TOWN_FARMER, TOWN_GIRL, TOWN_COWFARM, - NUM_TOWNER_TYPES, + // Note: Enum values are parsed from TSV using magic_enum + // The actual count is determined dynamically from TSV data }; -extern const char *const TownerLongNames[NUM_TOWNER_TYPES]; +// Runtime mappings built from TSV data +extern std::unordered_map<_talker_id, std::string> TownerLongNames; // Maps towner type enum to display name struct Towner { OptionalOwnedClxSpriteList ownedAnim; @@ -75,7 +78,20 @@ struct Towner { } }; -extern Towner Towners[NUM_TOWNERS]; +extern std::vector Towners; + +/** + * @brief Returns the number of unique towner types found in TSV data. + * This is dynamically determined from the loaded towner data. + */ +size_t GetNumTownerTypes(); + +/** + * @brief Returns the number of towner instances (actual spawned towners). + * This is dynamically determined from the loaded towner data. + */ +size_t GetNumTowners(); + bool IsTownerPresent(_talker_id npc); /** * @brief Maps from a _talker_id value to a pointer to the Towner object, if they have been initialised @@ -95,6 +111,5 @@ void UpdateCowFarmerAnimAfterQuestComplete(); #ifdef _DEBUG bool DebugTalkToTowner(_talker_id type); #endif -extern _speech_id QuestDialogTable[NUM_TOWNER_TYPES][MAXQUESTS]; } // namespace devilution diff --git a/assets/txtdata/towners/quest_dialog.tsv b/assets/txtdata/towners/quest_dialog.tsv new file mode 100644 index 00000000000..45d3cb104be --- /dev/null +++ b/assets/txtdata/towners/quest_dialog.tsv @@ -0,0 +1,11 @@ +towner_type Q_ROCK Q_MUSHROOM Q_GARBUD Q_ZHAR Q_VEIL Q_DIABLO Q_BUTCHER Q_LTBANNER Q_BLIND Q_BLOOD Q_ANVIL Q_WARLORD Q_SKELKING Q_PWATER Q_SCHAMB Q_BETRAYER Q_GRAVE Q_TRADER +TOWN_SMITH TEXT_INFRA6 TEXT_MUSH6 TEXT_NONE TEXT_NONE TEXT_VEIL5 TEXT_NONE TEXT_BUTCH5 TEXT_BANNER6 TEXT_BLIND5 TEXT_BLOOD5 TEXT_ANVIL6 TEXT_WARLRD5 TEXT_KING7 TEXT_POISON7 TEXT_BONE5 TEXT_VILE9 TEXT_GRAVE2 TEXT_NONE +TOWN_HEALER TEXT_INFRA3 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_VEIL3 TEXT_NONE TEXT_BUTCH3 TEXT_BANNER4 TEXT_BLIND3 TEXT_BLOOD3 TEXT_ANVIL3 TEXT_WARLRD3 TEXT_KING5 TEXT_POISON4 TEXT_BONE3 TEXT_VILE7 TEXT_GRAVE3 TEXT_NONE +TOWN_DEADGUY TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_TAVERN TEXT_INFRA2 TEXT_MUSH2 TEXT_NONE TEXT_NONE TEXT_VEIL2 TEXT_NONE TEXT_BUTCH2 TEXT_NONE TEXT_BLIND2 TEXT_BLOOD2 TEXT_ANVIL2 TEXT_WARLRD2 TEXT_KING3 TEXT_POISON2 TEXT_BONE2 TEXT_VILE4 TEXT_GRAVE5 TEXT_NONE +TOWN_STORY TEXT_INFRA1 TEXT_MUSH1 TEXT_NONE TEXT_NONE TEXT_VEIL1 TEXT_VILE3 TEXT_BUTCH1 TEXT_BANNER1 TEXT_BLIND1 TEXT_BLOOD1 TEXT_ANVIL1 TEXT_WARLRD1 TEXT_KING1 TEXT_POISON1 TEXT_BONE1 TEXT_VILE2 TEXT_GRAVE6 TEXT_NONE +TOWN_DRUNK TEXT_INFRA8 TEXT_MUSH7 TEXT_NONE TEXT_NONE TEXT_VEIL6 TEXT_NONE TEXT_BUTCH6 TEXT_BANNER7 TEXT_BLIND6 TEXT_BLOOD6 TEXT_ANVIL8 TEXT_WARLRD6 TEXT_KING8 TEXT_POISON8 TEXT_BONE6 TEXT_VILE10 TEXT_GRAVE7 TEXT_NONE +TOWN_WITCH TEXT_INFRA9 TEXT_MUSH9 TEXT_NONE TEXT_NONE TEXT_VEIL7 TEXT_NONE TEXT_BUTCH7 TEXT_BANNER8 TEXT_BLIND7 TEXT_BLOOD7 TEXT_ANVIL9 TEXT_WARLRD7 TEXT_KING9 TEXT_POISON9 TEXT_BONE7 TEXT_VILE11 TEXT_GRAVE1 TEXT_NONE +TOWN_BMAID TEXT_INFRA4 TEXT_MUSH5 TEXT_NONE TEXT_NONE TEXT_VEIL4 TEXT_NONE TEXT_BUTCH4 TEXT_BANNER5 TEXT_BLIND4 TEXT_BLOOD4 TEXT_ANVIL4 TEXT_WARLRD4 TEXT_KING6 TEXT_POISON6 TEXT_BONE4 TEXT_VILE8 TEXT_GRAVE8 TEXT_NONE +TOWN_PEGBOY TEXT_INFRA10 TEXT_MUSH13 TEXT_NONE TEXT_NONE TEXT_VEIL8 TEXT_NONE TEXT_BUTCH8 TEXT_BANNER9 TEXT_BLIND8 TEXT_BLOOD8 TEXT_ANVIL10 TEXT_WARLRD8 TEXT_KING10 TEXT_POISON10 TEXT_BONE8 TEXT_VILE12 TEXT_GRAVE9 TEXT_NONE +TOWN_COW TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE diff --git a/assets/txtdata/towners/towners.tsv b/assets/txtdata/towners/towners.tsv new file mode 100644 index 00000000000..14f8a0cebcb --- /dev/null +++ b/assets/txtdata/towners/towners.tsv @@ -0,0 +1,13 @@ +type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder +TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 +TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 +TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 +TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 +TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 +TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 +TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 +TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 +TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 +TOWN_COW Cow 58 16 SouthWest 128 12 3 +TOWN_COW Cow 56 14 NorthWest 128 12 3 +TOWN_COW Cow 59 20 North 128 12 3 diff --git a/mods/Hellfire/txtdata/towners/quest_dialog.tsv b/mods/Hellfire/txtdata/towners/quest_dialog.tsv new file mode 100644 index 00000000000..d487f4feb6a --- /dev/null +++ b/mods/Hellfire/txtdata/towners/quest_dialog.tsv @@ -0,0 +1,14 @@ +towner_type Q_ROCK Q_MUSHROOM Q_GARBUD Q_ZHAR Q_VEIL Q_DIABLO Q_BUTCHER Q_LTBANNER Q_BLIND Q_BLOOD Q_ANVIL Q_WARLORD Q_SKELKING Q_PWATER Q_SCHAMB Q_BETRAYER Q_GRAVE Q_FARMER Q_GIRL Q_TRADER Q_DEFILER Q_NAKRUL Q_CORNSTN Q_JERSEY +TOWN_SMITH TEXT_INFRA6 TEXT_MUSH6 TEXT_NONE TEXT_NONE TEXT_VEIL5 TEXT_NONE TEXT_BUTCH5 TEXT_BANNER6 TEXT_BLIND5 TEXT_BLOOD5 TEXT_ANVIL6 TEXT_WARLRD5 TEXT_KING7 TEXT_POISON7 TEXT_BONE5 TEXT_VILE9 TEXT_GRAVE2 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_HEALER TEXT_INFRA3 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_VEIL3 TEXT_NONE TEXT_BUTCH3 TEXT_BANNER4 TEXT_BLIND3 TEXT_BLOOD3 TEXT_ANVIL3 TEXT_WARLRD3 TEXT_KING5 TEXT_POISON4 TEXT_BONE3 TEXT_VILE7 TEXT_GRAVE3 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_DEADGUY TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_TAVERN TEXT_INFRA2 TEXT_MUSH2 TEXT_NONE TEXT_NONE TEXT_VEIL2 TEXT_NONE TEXT_BUTCH2 TEXT_NONE TEXT_BLIND2 TEXT_BLOOD2 TEXT_ANVIL2 TEXT_WARLRD2 TEXT_KING3 TEXT_POISON2 TEXT_BONE2 TEXT_VILE4 TEXT_GRAVE5 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_STORY TEXT_INFRA1 TEXT_MUSH1 TEXT_NONE TEXT_NONE TEXT_VEIL1 TEXT_VILE3 TEXT_BUTCH1 TEXT_BANNER1 TEXT_BLIND1 TEXT_BLOOD1 TEXT_ANVIL1 TEXT_WARLRD1 TEXT_KING1 TEXT_POISON1 TEXT_BONE1 TEXT_VILE2 TEXT_GRAVE6 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_DRUNK TEXT_INFRA8 TEXT_MUSH7 TEXT_NONE TEXT_NONE TEXT_VEIL6 TEXT_NONE TEXT_BUTCH6 TEXT_BANNER7 TEXT_BLIND6 TEXT_BLOOD6 TEXT_ANVIL8 TEXT_WARLRD6 TEXT_KING8 TEXT_POISON8 TEXT_BONE6 TEXT_VILE10 TEXT_GRAVE7 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_WITCH TEXT_INFRA9 TEXT_MUSH9 TEXT_NONE TEXT_NONE TEXT_VEIL7 TEXT_NONE TEXT_BUTCH7 TEXT_BANNER8 TEXT_BLIND7 TEXT_BLOOD7 TEXT_ANVIL9 TEXT_WARLRD7 TEXT_KING9 TEXT_POISON9 TEXT_BONE7 TEXT_VILE11 TEXT_GRAVE1 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_BMAID TEXT_INFRA4 TEXT_MUSH5 TEXT_NONE TEXT_NONE TEXT_VEIL4 TEXT_NONE TEXT_BUTCH4 TEXT_BANNER5 TEXT_BLIND4 TEXT_BLOOD4 TEXT_ANVIL4 TEXT_WARLRD4 TEXT_KING6 TEXT_POISON6 TEXT_BONE4 TEXT_VILE8 TEXT_GRAVE8 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_PEGBOY TEXT_INFRA10 TEXT_MUSH13 TEXT_NONE TEXT_NONE TEXT_VEIL8 TEXT_NONE TEXT_BUTCH8 TEXT_BANNER9 TEXT_BLIND8 TEXT_BLOOD8 TEXT_ANVIL10 TEXT_WARLRD8 TEXT_KING10 TEXT_POISON10 TEXT_BONE8 TEXT_VILE12 TEXT_GRAVE9 TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_COW TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_FARMER TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_GIRL TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE +TOWN_COWFARM TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE TEXT_NONE diff --git a/mods/Hellfire/txtdata/towners/towners.tsv b/mods/Hellfire/txtdata/towners/towners.tsv new file mode 100644 index 00000000000..0f218b67478 --- /dev/null +++ b/mods/Hellfire/txtdata/towners/towners.tsv @@ -0,0 +1,16 @@ +type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder +TOWN_SMITH Griswold the Blacksmith 62 63 SouthWest 96 towners\smith\smithn 16 3 TEXT_GRISWOLD2,TEXT_GRISWOLD3,TEXT_GRISWOLD4,TEXT_GRISWOLD5,TEXT_GRISWOLD6,TEXT_GRISWOLD7,TEXT_GRISWOLD8,TEXT_GRISWOLD9,TEXT_GRISWOLD10,TEXT_GRISWOLD12,TEXT_GRISWOLD13 4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,13,12,11,10,9,8,7,6,5,4,4,5,6,7,8,9,10,11,12,13,14,15,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3 +TOWN_HEALER Pepin the Healer 55 79 SouthEast 96 towners\healer\healer 20 6 TEXT_PEPIN2,TEXT_PEPIN3,TEXT_PEPIN4,TEXT_PEPIN5,TEXT_PEPIN6,TEXT_PEPIN7,TEXT_PEPIN9,TEXT_PEPIN10,TEXT_PEPIN11 0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,2,1,0,19,18,18,19,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,14,13,12,11,10,9,8,7,6,5,4,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19 +TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6 +TOWN_TAVERN Ogden the Tavern owner 55 62 SouthWest 96 towners\twnf\twnfn 16 3 TEXT_OGDEN2,TEXT_OGDEN3,TEXT_OGDEN4,TEXT_OGDEN5,TEXT_OGDEN6,TEXT_OGDEN8,TEXT_OGDEN9,TEXT_OGDEN10 0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,2,1,0,15,14,13,13,14,15,0,1,2,1,0,15,14,13,13,14,15,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 +TOWN_STORY Cain the Elder 62 71 South 96 towners\strytell\strytell 25 3 TEXT_STORY2,TEXT_STORY3,TEXT_STORY4,TEXT_STORY5,TEXT_STORY6,TEXT_STORY7,TEXT_STORY9,TEXT_STORY10,TEXT_STORY11 0,0,24,24,23,22,21,20,19,18,17,16,15,14,15,16,17,18,19,20,21,22,23,24,24,24,0,0,0,24,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0 +TOWN_DRUNK Farnham the Drunk 71 84 South 96 towners\drunk\twndrunk 18 3 TEXT_FARNHAM2,TEXT_FARNHAM3,TEXT_FARNHAM4,TEXT_FARNHAM5,TEXT_FARNHAM6,TEXT_FARNHAM8,TEXT_FARNHAM9,TEXT_FARNHAM10,TEXT_FARNHAM11,TEXT_FARNHAM12,TEXT_FARNHAM13 0,0,0,1,2,3,4,5,6,7,8,9,10,10,10,10,11,12,13,14,15,16,17,17,0,0,0,17,16,15,14,13,12,11,10,9,10,11,12,13,14,15,16,17,0,1,2,3,4,4,4,3,2,1 +TOWN_WITCH Adria the Witch 80 20 South 96 towners\townwmn1\witch 19 6 TEXT_ADRIA2,TEXT_ADRIA3,TEXT_ADRIA4,TEXT_ADRIA5,TEXT_ADRIA6,TEXT_ADRIA7,TEXT_ADRIA8,TEXT_ADRIA9,TEXT_ADRIA10,TEXT_ADRIA12,TEXT_ADRIA13 3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,3,3,4,5,5,5,4,3,14,13,12,12,12,13,14,3,4,5,5,5,4,3,2,1,0,18,17,18,0,1,0,18,17,18,0,1,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,14,14,13,12,12,12,12,13,14,14,14,13,12,11,11,11,10,9,9,9,8,7,8,9,9,10,11,12,13,14,15,16,17,18,0,1,0,18,17,18,0,1,0,1,2 +TOWN_BMAID Gillian the Barmaid 43 66 South 96 towners\townwmn1\wmnn 18 6 TEXT_GILLIAN2,TEXT_GILLIAN3,TEXT_GILLIAN4,TEXT_GILLIAN5,TEXT_GILLIAN6,TEXT_GILLIAN7,TEXT_GILLIAN9,TEXT_GILLIAN10 +TOWN_PEGBOY Wirt the Peg-legged boy 11 53 South 96 towners\townboy\pegkid1 20 6 TEXT_WIRT2,TEXT_WIRT3,TEXT_WIRT4,TEXT_WIRT5,TEXT_WIRT6,TEXT_WIRT7,TEXT_WIRT8,TEXT_WIRT9,TEXT_WIRT11,TEXT_WIRT12 +TOWN_COW Cow 58 16 SouthWest 128 12 3 +TOWN_COW Cow 56 14 NorthWest 128 12 3 +TOWN_COW Cow 59 20 North 128 12 3 +TOWN_COWFARM Complete Nut 61 22 SouthWest 96 towners\farmer\cfrmrn2 15 3 +TOWN_FARMER Lester the farmer 62 16 South 96 towners\farmer\farmrn2 15 3 +TOWN_GIRL Celia 77 43 South 96 towners\girl\girlw1 20 6 diff --git a/test/townerdat_test.cpp b/test/townerdat_test.cpp new file mode 100644 index 00000000000..d9ca84f3881 --- /dev/null +++ b/test/townerdat_test.cpp @@ -0,0 +1,278 @@ +#include + +#ifdef USE_SDL3 +#include +#else +#include +#endif + +#include "engine/assets.hpp" +#include "townerdat.hpp" +#include "towners.h" +#include "utils/paths.h" + +namespace devilution { + +namespace { + +void SetTestAssetsPath() +{ + const std::string assetsPath = paths::BasePath() + "/assets/"; + paths::SetAssetsPath(assetsPath); +} + +void InitializeSDL() +{ +#ifdef USE_SDL3 + if (!SDL_Init(SDL_INIT_EVENTS)) { + // SDL_Init returns 0 on success in SDL3 + return; + } +#elif !defined(USE_SDL1) + if (SDL_Init(SDL_INIT_EVENTS) >= 0) { + return; + } +#else + if (SDL_Init(0) >= 0) { + return; + } +#endif + // If we get here, SDL initialization failed + // In tests, we'll continue anyway as file operations might still work +} + +/** + * @brief Helper to find a towner data entry by type. + */ +const TownerDataEntry *FindTownerDataByType(_talker_id type) +{ + for (const auto &entry : TownersDataEntries) { + if (entry.type == type) { + return &entry; + } + } + return nullptr; +} + +} // namespace + +TEST(TownerDat, LoadTownerData) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Verify we loaded the expected number of towners from assets + ASSERT_GE(TownersDataEntries.size(), 4u) << "Should load at least 4 towners from assets"; + + // Check Griswold (TOWN_SMITH) + const TownerDataEntry *smith = FindTownerDataByType(TOWN_SMITH); + ASSERT_NE(smith, nullptr) << "Should find TOWN_SMITH data"; + EXPECT_EQ(smith->type, TOWN_SMITH); + EXPECT_EQ(smith->name, "Griswold the Blacksmith"); + EXPECT_EQ(smith->position.x, 62); + EXPECT_EQ(smith->position.y, 63); + EXPECT_EQ(smith->direction, Direction::SouthWest); + EXPECT_EQ(smith->animWidth, 96); + EXPECT_EQ(smith->animPath, "towners\\smith\\smithn"); + EXPECT_EQ(smith->animFrames, 16); + EXPECT_EQ(smith->animDelay, 3); + EXPECT_EQ(smith->gossipTexts.size(), 11u); + EXPECT_EQ(smith->gossipTexts[0], TEXT_GRISWOLD2); + EXPECT_EQ(smith->gossipTexts[10], TEXT_GRISWOLD13); + ASSERT_GE(smith->animOrder.size(), 4u); + EXPECT_EQ(smith->animOrder[0], 4); + EXPECT_EQ(smith->animOrder[3], 7); + + // Check Pepin (TOWN_HEALER) + const TownerDataEntry *healer = FindTownerDataByType(TOWN_HEALER); + ASSERT_NE(healer, nullptr) << "Should find TOWN_HEALER data"; + EXPECT_EQ(healer->type, TOWN_HEALER); + EXPECT_EQ(healer->name, "Pepin the Healer"); + EXPECT_EQ(healer->position.x, 55); + EXPECT_EQ(healer->position.y, 79); + EXPECT_EQ(healer->direction, Direction::SouthEast); + EXPECT_EQ(healer->animFrames, 20); + EXPECT_EQ(healer->gossipTexts.size(), 9u); + ASSERT_GE(healer->animOrder.size(), 3u); + + // Check Dead Guy (TOWN_DEADGUY) - has empty gossip texts and animOrder + const TownerDataEntry *deadguy = FindTownerDataByType(TOWN_DEADGUY); + ASSERT_NE(deadguy, nullptr) << "Should find TOWN_DEADGUY data"; + EXPECT_EQ(deadguy->type, TOWN_DEADGUY); + EXPECT_EQ(deadguy->name, "Wounded Townsman"); + EXPECT_EQ(deadguy->direction, Direction::North); + EXPECT_TRUE(deadguy->gossipTexts.empty()) << "Dead guy should have no gossip texts"; + EXPECT_TRUE(deadguy->animOrder.empty()) << "Dead guy should have no custom anim order"; + + // Check Cow (TOWN_COW) - has empty animPath but animFrames and animDelay are set + const TownerDataEntry *cow = FindTownerDataByType(TOWN_COW); + ASSERT_NE(cow, nullptr) << "Should find TOWN_COW data"; + EXPECT_EQ(cow->type, TOWN_COW); + EXPECT_EQ(cow->name, "Cow"); + EXPECT_EQ(cow->position.x, 58); + EXPECT_EQ(cow->position.y, 16); + EXPECT_EQ(cow->direction, Direction::SouthWest); + EXPECT_EQ(cow->animWidth, 128); + EXPECT_TRUE(cow->animPath.empty()) << "Cow should have empty animPath"; + EXPECT_EQ(cow->animFrames, 12); + EXPECT_EQ(cow->animDelay, 3); + EXPECT_TRUE(cow->gossipTexts.empty()) << "Cow should have no gossip texts"; + EXPECT_TRUE(cow->animOrder.empty()) << "Cow should have no custom anim order"; +} + +TEST(TownerDat, LoadQuestDialogTable) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Check Smith quest dialogs + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_BUTCHER), TEXT_BUTCH5); + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_LTBANNER), TEXT_BANNER6); + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_SKELKING), TEXT_KING7); + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_ROCK), TEXT_INFRA6); + + // Check Healer quest dialogs + EXPECT_EQ(GetTownerQuestDialog(TOWN_HEALER, Q_BUTCHER), TEXT_BUTCH3); + EXPECT_EQ(GetTownerQuestDialog(TOWN_HEALER, Q_LTBANNER), TEXT_BANNER4); + EXPECT_EQ(GetTownerQuestDialog(TOWN_HEALER, Q_SKELKING), TEXT_KING5); + + // Check Dead guy quest dialogs + EXPECT_EQ(GetTownerQuestDialog(TOWN_DEADGUY, Q_BUTCHER), TEXT_NONE); + EXPECT_EQ(GetTownerQuestDialog(TOWN_DEADGUY, Q_LTBANNER), TEXT_NONE); +} + +TEST(TownerDat, SetTownerQuestDialog) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Verify initial value from assets + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_MUSHROOM), TEXT_MUSH6); + + // Modify it + SetTownerQuestDialog(TOWN_SMITH, Q_MUSHROOM, TEXT_MUSH1); + + // Verify it changed + EXPECT_EQ(GetTownerQuestDialog(TOWN_SMITH, Q_MUSHROOM), TEXT_MUSH1); + + // Reset to original value for other tests + SetTownerQuestDialog(TOWN_SMITH, Q_MUSHROOM, TEXT_MUSH6); +} + +TEST(TownerDat, GetQuestDialogInvalidType) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Invalid towner type should return TEXT_NONE + // Use a value that's guaranteed to be invalid (beyond enum range) + _talker_id invalidType = static_cast<_talker_id>(255); + _speech_id result = GetTownerQuestDialog(invalidType, Q_BUTCHER); + EXPECT_EQ(result, TEXT_NONE) << "Should return TEXT_NONE for invalid towner type"; +} + +TEST(TownerDat, GetQuestDialogInvalidQuest) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Invalid quest ID should return TEXT_NONE + _speech_id result = GetTownerQuestDialog(TOWN_SMITH, static_cast(-1)); + EXPECT_EQ(result, TEXT_NONE) << "Should return TEXT_NONE for invalid quest ID"; + + result = GetTownerQuestDialog(TOWN_SMITH, static_cast(MAXQUESTS)); + EXPECT_EQ(result, TEXT_NONE) << "Should return TEXT_NONE for out-of-range quest ID"; +} + +TEST(TownerDat, TownerLongNamesPopulated) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Build TownerLongNames as InitTowners() does + TownerLongNames.clear(); + for (const auto &entry : TownersDataEntries) { + TownerLongNames.try_emplace(entry.type, entry.name); + } + + // Verify TownerLongNames is populated correctly + EXPECT_FALSE(TownerLongNames.empty()) << "TownerLongNames should not be empty after loading"; + + // Check specific entries + auto smithIt = TownerLongNames.find(TOWN_SMITH); + ASSERT_NE(smithIt, TownerLongNames.end()) << "Should find TOWN_SMITH in TownerLongNames"; + EXPECT_EQ(smithIt->second, "Griswold the Blacksmith"); + + auto healerIt = TownerLongNames.find(TOWN_HEALER); + ASSERT_NE(healerIt, TownerLongNames.end()) << "Should find TOWN_HEALER in TownerLongNames"; + EXPECT_EQ(healerIt->second, "Pepin the Healer"); +} + +TEST(TownerDat, GetNumTownerTypes) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Build TownerLongNames as InitTowners() does + TownerLongNames.clear(); + for (const auto &entry : TownersDataEntries) { + TownerLongNames.try_emplace(entry.type, entry.name); + } + + // GetNumTownerTypes should return the number of unique towner types + size_t numTypes = GetNumTownerTypes(); + EXPECT_GT(numTypes, 0u) << "Should have at least one towner type"; + EXPECT_EQ(numTypes, TownerLongNames.size()) << "GetNumTownerTypes should match TownerLongNames size"; +} + +TEST(TownerDat, MultipleCowsOnlyOneType) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Count how many TOWN_COW entries exist in the data + size_t cowCount = 0; + for (const auto &entry : TownersDataEntries) { + if (entry.type == TOWN_COW) { + cowCount++; + } + } + + // There should be multiple cows but only one type entry + EXPECT_GT(cowCount, 1u) << "TSV should have multiple cow entries"; + + // Build TownerLongNames + TownerLongNames.clear(); + for (const auto &entry : TownersDataEntries) { + TownerLongNames.try_emplace(entry.type, entry.name); + } + + // But only one entry in TownerLongNames for TOWN_COW + auto cowIt = TownerLongNames.find(TOWN_COW); + ASSERT_NE(cowIt, TownerLongNames.end()) << "Should find TOWN_COW in TownerLongNames"; + EXPECT_EQ(cowIt->second, "Cow"); +} + +TEST(TownerDat, QuestDialogOptionalColumns) +{ + InitializeSDL(); + SetTestAssetsPath(); + LoadTownerData(); + + // Verify that missing quest columns default to TEXT_NONE + // Q_FARMER, Q_GIRL, Q_DEFILER, Q_NAKRUL, Q_CORNSTN, Q_JERSEY may not be in base TSV + // but the code should handle them gracefully + _speech_id result = GetTownerQuestDialog(TOWN_SMITH, Q_FARMER); + // Should be TEXT_NONE since TOWN_SMITH doesn't have farmer quest dialog + EXPECT_EQ(result, TEXT_NONE) << "Should return TEXT_NONE for unused quest columns"; +} + +} // namespace devilution