Browse Source

Load towners via tsv

pull/8294/head
Yuri Pourre 4 months ago committed by GitHub
parent
commit
d8b1f00cb5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CMake/Assets.cmake
  2. 2
      CMake/Mods.cmake
  3. 1
      CMake/Tests.cmake
  4. 1
      Source/CMakeLists.txt
  5. 41
      Source/lua/modules/towners.cpp
  6. 2
      Source/msg.cpp
  7. 11
      Source/quests.cpp
  8. 11
      Source/stores.cpp
  9. 245
      Source/townerdat.cpp
  10. 68
      Source/townerdat.hpp
  11. 405
      Source/towners.cpp
  12. 27
      Source/towners.h
  13. 11
      assets/txtdata/towners/quest_dialog.tsv
  14. 13
      assets/txtdata/towners/towners.tsv
  15. 14
      mods/Hellfire/txtdata/towners/quest_dialog.tsv
  16. 16
      mods/Hellfire/txtdata/towners/towners.tsv
  17. 278
      test/townerdat_test.cpp

2
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

2
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

1
CMake/Tests.cmake

@ -35,6 +35,7 @@ set(tests
stores_test
tile_properties_test
timedemo_test
townerdat_test
writehero_test
vendor_test
)

1
Source/CMakeLists.txt

@ -34,6 +34,7 @@ set(libdevilutionx_SRCS
sync.cpp
textdat.cpp
tmsg.cpp
townerdat.cpp
towners.cpp
track.cpp

41
Source/lua/modules/towners.cpp

@ -1,6 +1,7 @@
#include "lua/modules/towners.hpp"
#include <optional>
#include <unordered_map>
#include <utility>
#include <sol/sol.hpp>
@ -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;
}

2
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;

11
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);
}
}
}

11
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;
}

245
Source/townerdat.cpp

@ -0,0 +1,245 @@
/**
* @file townerdat.cpp
*
* Implementation of towner data loading from TSV files.
*/
#include "townerdat.hpp"
#include <charconv>
#include <optional>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <expected.hpp>
#include <magic_enum/magic_enum.hpp>
#include "data/file.hpp"
#include "data/record_reader.hpp"
namespace devilution {
std::vector<TownerDataEntry> 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 <typename EnumT>
tl::expected<EnumT, std::string> ParseEnum(std::string_view value)
{
const auto enumValueOpt = magic_enum::enum_cast<EnumT>(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<T>
* @param value The comma-separated string
* @param out Vector to store parsed values (cleared first)
* @param parser Function to parse individual tokens
*/
template <typename T, typename Parser>
void ParseCommaSeparatedList(std::string_view value, std::vector<T> &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<uint8_t> &out)
{
ParseCommaSeparatedList(value, out, [](std::string_view token) -> std::optional<uint8_t> {
int val = 0;
if (auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), val); ec == std::errc()) {
return static_cast<uint8_t>(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<Direction>);
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<std::string, unsigned> 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<quest_id, unsigned> questColumnMap;
for (quest_id quest : magic_enum::enum_values<quest_id>()) {
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<unsigned, std::string_view> 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

68
Source/townerdat.hpp

@ -0,0 +1,68 @@
/**
* @file townerdat.hpp
*
* Interface for loading towner data from TSV files.
*/
#pragma once
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
#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<uint8_t> animOrder;
};
/** Contains the data for all towners loaded from TSV. */
extern std::vector<TownerDataEntry> 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

405
Source/towners.cpp

@ -1,6 +1,8 @@
#include "towners.h"
#include <algorithm>
#include <cstdint>
#include <unordered_map>
#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<std::vector<uint8_t>> 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<int32_t>(GenerateRnd(static_cast<int32_t>(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<size_t>(townerData.dir)], 12, 3);
NewTownerAnim(towner, (*CowSprites)[static_cast<size_t>(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<Towner> 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

27
Source/towners.h

@ -9,7 +9,10 @@
#include <cstdint>
#include <memory>
#include <span>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#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<Towner> 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

11
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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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

13
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
1 type name position_x position_y direction animWidth animPath animFrames animDelay gossipTexts animOrder
2 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
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
4 TOWN_DEADGUY Wounded Townsman 24 32 North 96 towners\butch\deadguy 8 6
5 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
6 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
7 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
8 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
9 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
10 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
11 TOWN_COW Cow 58 16 SouthWest 128 12 3
12 TOWN_COW Cow 56 14 NorthWest 128 12 3
13 TOWN_COW Cow 59 20 North 128 12 3

14
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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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

16
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
Can't render this file because it has a wrong number of fields in line 11.

278
test/townerdat_test.cpp

@ -0,0 +1,278 @@
#include <gtest/gtest.h>
#ifdef USE_SDL3
#include <SDL3/SDL.h>
#else
#include <SDL.h>
#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<quest_id>(-1));
EXPECT_EQ(result, TEXT_NONE) << "Should return TEXT_NONE for invalid quest ID";
result = GetTownerQuestDialog(TOWN_SMITH, static_cast<quest_id>(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
Loading…
Cancel
Save