diff --git a/CMake/Tests.cmake b/CMake/Tests.cmake index bfe6bf8a163..d56788e8ac2 100644 --- a/CMake/Tests.cmake +++ b/CMake/Tests.cmake @@ -35,6 +35,7 @@ set(tests stores_test tile_properties_test timedemo_test + town_registry_test townerdat_test writehero_test vendor_test diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index f09f6508482..92360011f7d 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -97,6 +97,7 @@ set(libdevilutionx_SRCS levels/themes.cpp levels/tile_properties.cpp levels/town.cpp + levels/town_data.cpp levels/trigs.cpp lua/autocomplete.cpp @@ -127,6 +128,7 @@ set(libdevilutionx_SRCS lua/modules/render.cpp lua/modules/system.cpp lua/modules/towners.cpp + lua/modules/towns.cpp lua/repl.cpp monsters/validation.cpp diff --git a/Source/diablo.cpp b/Source/diablo.cpp index a2e7fa60b9f..bb4b8c98d04 100644 --- a/Source/diablo.cpp +++ b/Source/diablo.cpp @@ -3219,7 +3219,7 @@ tl::expected LoadGameLevelTown(bool firstflag, lvl_entry lvld IncProgress(); - if (!firstflag && lvldir != ENTRY_LOAD && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) + if (!firstflag && lvldir != ENTRY_LOAD && lvldir != ENTRY_TOWNSWITCH && myPlayer._pLvlVisited[currlevel] && !gbIsMultiplayer) RETURN_IF_ERROR(LoadLevel()); if (gbIsMultiplayer) DeltaLoadLevel(); diff --git a/Source/interfac.cpp b/Source/interfac.cpp index 0ca8a58b24f..ecfe04d3544 100644 --- a/Source/interfac.cpp +++ b/Source/interfac.cpp @@ -35,6 +35,7 @@ #include "game_mode.hpp" #include "headless_mode.hpp" #include "hwcursor.hpp" +#include "levels/town_data.h" #include "loadsave.h" #include "multi.h" #include "pfile.h" @@ -103,6 +104,7 @@ Cutscenes PickCutscene(interface_mode uMsg) case WM_DIABNEWGAME: return CutStart; case WM_DIABRETOWN: + case WM_DIABTOWNSWITCH: return CutTown; case WM_DIABNEXTLVL: case WM_DIABPREVLVL: @@ -462,6 +464,34 @@ void DoLoad(interface_mode uMsg) loadResult = LoadGameLevel(false, ENTRY_MAIN); if (loadResult.has_value()) IncProgress(); break; + case WM_DIABTOWNSWITCH: + IncProgress(); + if (!gbIsMultiplayer) { + pfile_save_level(); + } else { + DeltaSaveLevel(); + } + IncProgress(); + FreeGameMem(); + if (!GetTownRegistry().HasTown(DestinationTownID)) { + LogError("WM_DIABTOWNSWITCH: unknown town '{}'", DestinationTownID); + loadResult = tl::make_unexpected("Unknown destination town"); + break; + } + GetTownRegistry().SetCurrentTown(DestinationTownID); + + if (MyPlayer != nullptr) { + const TownConfig &destTown = GetTownRegistry().GetTown(DestinationTownID); + MyPlayer->_pCurrentTownId = destTown.saveId; + } + + setlevel = false; + currlevel = 0; + leveltype = DTYPE_TOWN; + IncProgress(); + loadResult = LoadGameLevel(false, ENTRY_TOWNSWITCH); + if (loadResult.has_value()) IncProgress(); + break; default: loadResult = tl::make_unexpected("Unknown progress mode"); break; @@ -570,6 +600,19 @@ void ProgressEventHandler(const SDL_Event &event, uint16_t modState) } // namespace +void QueueTownSwitch() +{ + if (MyPlayer != nullptr) { + MyPlayer->_pInvincible = true; + SDL_Event event; + CustomEventToSdlEvent(event, WM_DIABTOWNSWITCH); + if (!SDLC_PushEvent(&event)) { + LogError("QueueTownSwitch: {}", SDL_GetError()); + SDL_ClearError(); + } + } +} + void RegisterCustomEvents() { #ifndef USE_SDL1 diff --git a/Source/interfac.h b/Source/interfac.h index ee4ac6c9256..70791c0ac2b 100644 --- a/Source/interfac.h +++ b/Source/interfac.h @@ -30,6 +30,7 @@ enum interface_mode : uint8_t { WM_DIABTOWNWARP, WM_DIABTWARPUP, WM_DIABRETOWN, + WM_DIABTOWNSWITCH, WM_DIABNEWGAME, WM_DIABLOADGAME, @@ -70,6 +71,9 @@ enum Cutscenes : uint8_t { CutGate, }; +/** @brief Queues WM_DIABTOWNSWITCH for the local player (invincible until load completes). */ +void QueueTownSwitch(); + void interface_msg_pump(); void IncProgress(uint32_t steps = 1); void CompleteProgress(); diff --git a/Source/levels/gendung.cpp b/Source/levels/gendung.cpp index ca3986a76e7..1c9f39872f3 100644 --- a/Source/levels/gendung.cpp +++ b/Source/levels/gendung.cpp @@ -24,6 +24,7 @@ #include "levels/drlg_l4.h" #include "levels/reencode_dun_cels.hpp" #include "levels/town.h" +#include "levels/town_data.h" #include "lighting.h" #include "monster.h" #include "objects.h" @@ -812,6 +813,12 @@ bool IsNearThemeRoom(WorldTilePosition testPosition) void InitLevels() { + // Note: InitializeTristram overwrites any existing "tristram" registration. + // Lua mods that called towns.register("tristram", ...) before InitLevels will + // have their config replaced. There is intentionally no registry Reset() here; + // mod-registered towns persist across new games and are re-populated by the + // Lua runtime at mod load time. + InitializeTristram(); currlevel = 0; leveltype = DTYPE_TOWN; setlevel = false; diff --git a/Source/levels/gendung_defs.hpp b/Source/levels/gendung_defs.hpp index 2b24c4c797e..1821b03e422 100644 --- a/Source/levels/gendung_defs.hpp +++ b/Source/levels/gendung_defs.hpp @@ -32,6 +32,7 @@ enum lvl_entry : uint8_t { ENTRY_WARPLVL, ENTRY_TWARPDN, ENTRY_TWARPUP, + ENTRY_TOWNSWITCH, }; } // namespace devilution diff --git a/Source/levels/town.cpp b/Source/levels/town.cpp index fc1213ddc85..395ee0bfc68 100644 --- a/Source/levels/town.cpp +++ b/Source/levels/town.cpp @@ -7,11 +7,13 @@ #include "engine/world_tile.hpp" #include "game_mode.hpp" #include "levels/drlg_l1.h" +#include "levels/town_data.h" #include "levels/trigs.h" #include "multi.h" #include "player.h" #include "quests.h" #include "utils/endian_swap.hpp" +#include "utils/log.hpp" namespace devilution { @@ -199,48 +201,52 @@ void DrlgTPass3() } } - FillSector("levels\\towndata\\sector1s.dun", 46, 46); - FillSector("levels\\towndata\\sector2s.dun", 46, 0); - FillSector("levels\\towndata\\sector3s.dun", 0, 46); - FillSector("levels\\towndata\\sector4s.dun", 0, 0); - - auto dunData = LoadFileInMem("levels\\towndata\\automap.dun"); - PlaceDunTiles(dunData.get(), { 0, 0 }); - - if (!IsWarpOpen(DTYPE_CATACOMBS)) { - dungeon[20][7] = 10; - dungeon[20][6] = 8; - FillTile(48, 20, 320); + const std::string &townId = GetTownRegistry().GetCurrentTown(); + if (!GetTownRegistry().HasTown(townId)) { + LogError("DrlgTPass3: current town '{}' not registered", townId); + return; } - if (!IsWarpOpen(DTYPE_CAVES)) { - dungeon[4][30] = 8; - FillTile(16, 68, 332); - FillTile(16, 70, 331); + const TownConfig &config = GetTownRegistry().GetTown(townId); + for (const auto §or : config.sectors) { + FillSector(sector.filePath.c_str(), sector.x, sector.y); } - if (!IsWarpOpen(DTYPE_HELL)) { - dungeon[15][35] = 7; - dungeon[16][35] = 7; - dungeon[17][35] = 7; - for (int x = 36; x < 46; x++) { - FillTile(x, 78, PickRandomlyAmong({ 1, 2, 3, 4 })); + + for (const TownWarpPatch &patch : config.warpClosedPatches) { + if (IsWarpOpen(patch.requiredWarp)) + continue; + for (const auto &[pos, cellVal] : patch.dungeonCells) + dungeon[pos.x][pos.y] = static_cast(cellVal); + for (const TownWarpFillTile &ft : patch.fillTiles) + FillTile(ft.x, ft.y, ft.tile); + if (patch.randomGroundStrip.has_value()) { + const TownWarpClosedRandomGroundStrip &strip = *patch.randomGroundStrip; + for (int x = strip.xStart; x < strip.xEndExclusive; x++) { + FillTile(x, strip.y, PickRandomlyAmong({ 1, 2, 3, 4 })); + } } } - if (gbIsHellfire) { - if (IsWarpOpen(DTYPE_NEST)) { - TownOpenHive(); - } else { - TownCloseHive(); + + if (townId == TristramTownId) { + auto dunData = LoadFileInMem("levels\\towndata\\automap.dun"); + PlaceDunTiles(dunData.get(), { 0, 0 }); + + if (gbIsHellfire) { + if (IsWarpOpen(DTYPE_NEST)) { + TownOpenHive(); + } else { + TownCloseHive(); + } + if (IsWarpOpen(DTYPE_CRYPT)) + TownOpenGrave(); + else + TownCloseGrave(); } - if (IsWarpOpen(DTYPE_CRYPT)) - TownOpenGrave(); - else - TownCloseGrave(); - } - if (Quests[Q_PWATER]._qactive != QUEST_DONE && Quests[Q_PWATER]._qactive != QUEST_NOTAVAIL) { - FillTile(60, 70, 342); - } else { - FillTile(60, 70, 71); + if (Quests[Q_PWATER]._qactive != QUEST_DONE && Quests[Q_PWATER]._qactive != QUEST_NOTAVAIL) { + FillTile(60, 70, 342); + } else { + FillTile(60, 70, 71); + } } InitTownPieces(); @@ -358,30 +364,15 @@ void CleanTownFountain() void CreateTown(lvl_entry entry) { - dminPosition = { 10, 10 }; - dmaxPosition = { 84, 84 }; - - if (entry == ENTRY_MAIN) { // New game - ViewPosition = { 75, 68 }; - } else if (entry == ENTRY_PREV) { // Cathedral - ViewPosition = { 25, 31 }; - } else if (entry == ENTRY_TWARPUP) { - if (TWarpFrom == 5) { - ViewPosition = { 49, 22 }; - } - if (TWarpFrom == 9) { - ViewPosition = { 18, 69 }; - } - if (TWarpFrom == 13) { - ViewPosition = { 41, 81 }; - } - if (TWarpFrom == 21) { - ViewPosition = { 36, 25 }; - } - if (TWarpFrom == 17) { - ViewPosition = { 79, 62 }; - } + const std::string &townId = GetTownRegistry().GetCurrentTown(); + if (!GetTownRegistry().HasTown(townId)) { + LogError("CreateTown: current town '{}' not registered", townId); + return; } + const TownConfig &config = GetTownRegistry().GetTown(townId); + dminPosition = config.dminPosition; + dmaxPosition = config.dmaxPosition; + ViewPosition = config.GetEntryPoint(entry, TWarpFrom); DrlgTPass3(); } diff --git a/Source/levels/town_data.cpp b/Source/levels/town_data.cpp new file mode 100644 index 00000000000..9121782fd3d --- /dev/null +++ b/Source/levels/town_data.cpp @@ -0,0 +1,173 @@ +#include "levels/town_data.h" + +#include "utils/log.hpp" + +namespace devilution { + +namespace { + +TownRegistry g_townRegistry; + +/** @brief Spawn used when no TownEntryPoint matches (matches legacy hard-coded default). */ +constexpr Point kDefaultTownEntryPoint = { 75, 68 }; + +} // namespace + +std::string DestinationTownID; + +TownRegistry &GetTownRegistry() +{ + return g_townRegistry; +} + +void TownRegistry::RegisterTown(const std::string &id, const TownConfig &config) +{ + if (HasTown(id)) + LogWarn("RegisterTown: overwriting existing town '{}'", id); + towns[id] = config; + LogInfo("Registered town: {}", id); +} + +const TownConfig &TownRegistry::GetTown(const std::string &id) const +{ + return towns.at(id); +} + +TownConfig &TownRegistry::GetTown(const std::string &id) +{ + return towns.at(id); +} + +bool TownRegistry::HasTown(const std::string &id) const +{ + return towns.count(id) > 0; +} + +void TownRegistry::SetCurrentTown(const std::string &id) +{ + currentTownID = id; +} + +const std::string &TownRegistry::GetCurrentTown() const +{ + return currentTownID; +} + +std::string TownRegistry::GetTownBySaveId(uint8_t saveId) const +{ + for (const auto &[id, config] : towns) { + if (config.saveId == saveId) { + return id; + } + } + if (saveId != 0) + LogWarn("GetTownBySaveId: unknown saveId {}, defaulting to Tristram", saveId); + return { TristramTownId }; +} + +Point TownConfig::GetEntryPoint(lvl_entry entry, int warpFrom) const +{ + // For ENTRY_TWARPUP, match both entry type and warpFromLevel + if (entry == ENTRY_TWARPUP) { + for (const auto &ep : entries) { + if (ep.entryType == entry && ep.warpFromLevel == warpFrom) { + return ep.viewPosition; + } + } + return kDefaultTownEntryPoint; + } + + // For other entry types, just match the type + for (const auto &ep : entries) { + if (ep.entryType == entry) { + return ep.viewPosition; + } + } + + // Default fallback + return kDefaultTownEntryPoint; +} + +void InitializeTristram() +{ + TownConfig tristram; + tristram.name = "Tristram"; + tristram.saveId = 0; + tristram.dminPosition = { 10, 10 }; + tristram.dmaxPosition = { 84, 84 }; + tristram.sectors = { + { "levels\\towndata\\sector1s.dun", 46, 46 }, + { "levels\\towndata\\sector2s.dun", 46, 0 }, + { "levels\\towndata\\sector3s.dun", 0, 46 }, + { "levels\\towndata\\sector4s.dun", 0, 0 }, + }; + // Spawn positions (view): one tile stepped from matching town triggers / stairs. + tristram.entries = { + { ENTRY_MAIN, { 75, 68 }, -1 }, + { ENTRY_PREV, { 25, 31 }, -1 }, + { ENTRY_TWARPUP, { 49, 22 }, 5 }, + { ENTRY_TWARPUP, { 18, 69 }, 9 }, + { ENTRY_TWARPUP, { 41, 81 }, 13 }, + { ENTRY_TWARPUP, { 36, 25 }, 21 }, + { ENTRY_TWARPUP, { 79, 62 }, 17 }, + { ENTRY_TOWNSWITCH, { 75, 68 }, -1 }, + }; + tristram.triggers = { + // Cathedral stairs (down to dungeon level 1) + { { 25, 29 }, WM_DIABNEXTLVL, 0, std::nullopt }, + // Town warp portals (active only when the respective warp is open) + { { 49, 21 }, WM_DIABTOWNWARP, 5, DTYPE_CATACOMBS }, + { { 17, 69 }, WM_DIABTOWNWARP, 9, DTYPE_CAVES }, + { { 41, 80 }, WM_DIABTOWNWARP, 13, DTYPE_HELL }, + { { 80, 62 }, WM_DIABTOWNWARP, 17, DTYPE_NEST }, + { { 36, 24 }, WM_DIABTOWNWARP, 21, DTYPE_CRYPT }, + }; + tristram.warpClosedPatches = { + /* + if (!IsWarpOpen(DTYPE_CATACOMBS)) { + dungeon[20][7] = 10; + dungeon[20][6] = 8; + FillTile(48, 20, 320); + } + */ + { + DTYPE_CATACOMBS, + { { { 20, 7 }, 10 }, { { 20, 6 }, 8 } }, + { { 48, 20, 320 } }, + std::nullopt, + }, + /* + if (!IsWarpOpen(DTYPE_CAVES)) { + dungeon[4][30] = 8; + FillTile(16, 68, 332); + FillTile(16, 70, 331); + } + */ + { + DTYPE_CAVES, + { { { 4, 30 }, 8 } }, + { { 16, 68, 332 }, { 16, 70, 331 } }, + std::nullopt, + }, + /* + if (!IsWarpOpen(DTYPE_HELL)) { + dungeon[15][35] = 7; + dungeon[16][35] = 7; + dungeon[17][35] = 7; + for (int x = 36; x < 46; x++) { + FillTile(x, 78, PickRandomlyAmong({ 1, 2, 3, 4 })); + } + } + */ + { + DTYPE_HELL, + { { { 15, 35 }, 7 }, { { 16, 35 }, 7 }, { { 17, 35 }, 7 } }, + {}, + TownWarpClosedRandomGroundStrip { 36, 46, 78 }, + }, + }; + GetTownRegistry().RegisterTown(TristramTownId, tristram); + GetTownRegistry().SetCurrentTown(TristramTownId); +} + +} // namespace devilution diff --git a/Source/levels/town_data.h b/Source/levels/town_data.h new file mode 100644 index 00000000000..5061ab307fc --- /dev/null +++ b/Source/levels/town_data.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "engine/point.hpp" +#include "interfac.h" +#include "levels/gendung_defs.hpp" + +namespace devilution { + +/** @brief Canonical town registry id for vanilla Tristram (lowercase). */ +inline constexpr char TristramTownId[] = "tristram"; + +/** + * @brief Represents a town sector (map piece) + */ +struct TownSector { + std::string filePath; + int x, y; +}; + +/** + * @brief Represents a town entry/spawn point + */ +struct TownEntryPoint { + lvl_entry entryType; + Point viewPosition; + int warpFromLevel; // Source level for ENTRY_TWARPUP (-1 for any) +}; + +/** + * @brief Position override for a towner NPC + */ +struct TownerPositionOverride { + std::string shortName; // e.g. "griswold", "farnham" + Point position; +}; + +/** + * @brief Dungeon entrance / town warp trigger (see InitTownTriggers) + */ +struct TownTrigger { + Point position; + interface_mode msg; + /** For WM_DIABTOWNWARP; unused for other message types */ + int level = 0; + /** If set, trigger is only active when IsWarpOpen(*warpGate) */ + std::optional warpGate; +}; + +struct TownWarpFillTile { + int x; + int y; + int tile; +}; + +/** + * @brief Fills random ground micros (1-4) for each x in [xStart, xEnd). + */ +struct TownWarpClosedRandomGroundStrip { + int xStart; + int xEndExclusive; + int y; +}; + +/** + * @brief Visual dungeon/dPiece patches when a town warp is still closed (see DrlgTPass3). + */ +struct TownWarpPatch { + dungeon_type requiredWarp; + std::vector> dungeonCells; + std::vector fillTiles; + std::optional randomGroundStrip; +}; + +/** + * @brief Complete configuration for a town + */ +struct TownConfig { + std::string name; + uint8_t saveId = 0; + Point dminPosition = { 10, 10 }; + Point dmaxPosition = { 84, 84 }; + std::vector sectors; + std::vector entries; + std::vector triggers; + std::vector warpClosedPatches; + std::vector townerOverrides; + + /** + * @brief Gets the spawn point for a given entry type and warp source + */ + Point GetEntryPoint(lvl_entry entry, int warpFrom = -1) const; +}; + +/** + * @brief Registry for managing multiple town configurations + */ +class TownRegistry { +private: + std::unordered_map towns; + std::string currentTownID; + +public: + void RegisterTown(const std::string &id, const TownConfig &config); + const TownConfig &GetTown(const std::string &id) const; + TownConfig &GetTown(const std::string &id); + bool HasTown(const std::string &id) const; + void SetCurrentTown(const std::string &id); + const std::string &GetCurrentTown() const; + const std::unordered_map &GetTowns() const { return towns; } + + /** @brief Finds town string ID by its saveId. Returns TristramTownId if not found. */ + std::string GetTownBySaveId(uint8_t saveId) const; +}; + +TownRegistry &GetTownRegistry(); + +/** + * @brief Town ID to switch to, set before queuing WM_DIABTOWNSWITCH. + * Written by: towns Lua module, OnTownTravel (network), NetSendCmdTownTravel. + * Read by: WM_DIABTOWNSWITCH handler in interfac.cpp. + */ +extern std::string DestinationTownID; + +void InitializeTristram(); + +} // namespace devilution diff --git a/Source/levels/trigs.cpp b/Source/levels/trigs.cpp index c3b88ae25c3..800d059dcd2 100644 --- a/Source/levels/trigs.cpp +++ b/Source/levels/trigs.cpp @@ -16,10 +16,12 @@ #include "cursor.h" #include "diablo_msg.hpp" #include "game_mode.hpp" +#include "levels/town_data.h" #include "multi.h" #include "utils/algorithm/container.hpp" #include "utils/is_of.hpp" #include "utils/language.h" +#include "utils/log.hpp" #include "utils/utf8.hpp" namespace devilution { @@ -110,44 +112,27 @@ bool IsWarpOpen(dungeon_type type) void InitTownTriggers() { numtrigs = 0; + trigflag = false; - // Cathedral - trigs[numtrigs].position = { 25, 29 }; - trigs[numtrigs]._tmsg = WM_DIABNEXTLVL; - numtrigs++; - - if (IsWarpOpen(DTYPE_CATACOMBS)) { - trigs[numtrigs].position = { 49, 21 }; - trigs[numtrigs]._tmsg = WM_DIABTOWNWARP; - trigs[numtrigs]._tlvl = 5; - numtrigs++; - } - if (IsWarpOpen(DTYPE_CAVES)) { - trigs[numtrigs].position = { 17, 69 }; - trigs[numtrigs]._tmsg = WM_DIABTOWNWARP; - trigs[numtrigs]._tlvl = 9; - numtrigs++; - } - if (IsWarpOpen(DTYPE_HELL)) { - trigs[numtrigs].position = { 41, 80 }; - trigs[numtrigs]._tmsg = WM_DIABTOWNWARP; - trigs[numtrigs]._tlvl = 13; - numtrigs++; - } - if (IsWarpOpen(DTYPE_NEST)) { - trigs[numtrigs].position = { 80, 62 }; - trigs[numtrigs]._tmsg = WM_DIABTOWNWARP; - trigs[numtrigs]._tlvl = 17; - numtrigs++; + const std::string &townId = GetTownRegistry().GetCurrentTown(); + if (!GetTownRegistry().HasTown(townId)) { + LogError("InitTownTriggers: current town '{}' not registered", townId); + return; } - if (IsWarpOpen(DTYPE_CRYPT)) { - trigs[numtrigs].position = { 36, 24 }; - trigs[numtrigs]._tmsg = WM_DIABTOWNWARP; - trigs[numtrigs]._tlvl = 21; + + const TownConfig &town = GetTownRegistry().GetTown(townId); + for (const TownTrigger &trigger : town.triggers) { + if (trigger.warpGate.has_value() && !IsWarpOpen(*trigger.warpGate)) + continue; + if (numtrigs >= MAXTRIGGERS) { + LogError("InitTownTriggers: more than MAXTRIGGERS ({}) for town '{}'", MAXTRIGGERS, townId); + break; + } + trigs[numtrigs].position = trigger.position; + trigs[numtrigs]._tmsg = trigger.msg; + trigs[numtrigs]._tlvl = trigger.level; numtrigs++; } - - trigflag = false; } void InitL1Triggers() diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 0106587139b..0d90473efce 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -26,6 +26,7 @@ #include "game_mode.hpp" #include "inv.h" #include "levels/dun_tile.hpp" +#include "levels/town_data.h" #include "lighting.h" #include "menu.h" #include "missiles.h" @@ -630,7 +631,9 @@ void LoadPlayer(LoadHelper &file, Player &player) } file.Skip(2); // Available bytes player.wReflections = file.NextLE(); - file.Skip(14); // Available bytes + // Added for multi-town support + player._pCurrentTownId = file.NextLE(); + file.Skip(13); // Available bytes player.pDiabloKillLevel = file.NextLE(); sgGameInitInfo.nDifficulty = static_cast<_difficulty>(file.NextLE()); @@ -1480,7 +1483,10 @@ void SavePlayer(SaveHelper &file, const Player &player) file.WriteLE(player.pOriginalCathedral ? 1 : 0); file.Skip(2); // Available bytes file.WriteLE(player.wReflections); - file.Skip(14); // Available bytes + + // Added for multi-town support + file.WriteLE(player._pCurrentTownId); + file.Skip(13); // Available bytes file.WriteLE(player.pDiabloKillLevel); file.WriteLE(sgGameInitInfo.nDifficulty); @@ -2520,6 +2526,15 @@ tl::expected LoadGame(bool firstflag) LoadPlayer(file, myPlayer); + // Restore current town from player save data + std::string townId = GetTownRegistry().GetTownBySaveId(myPlayer._pCurrentTownId); + if (GetTownRegistry().HasTown(townId)) { + GetTownRegistry().SetCurrentTown(townId); + } else { + GetTownRegistry().SetCurrentTown(TristramTownId); + myPlayer._pCurrentTownId = 0; + } + if (sgGameInitInfo.nDifficulty < DIFF_NORMAL || sgGameInitInfo.nDifficulty > DIFF_HELL) sgGameInitInfo.nDifficulty = DIFF_NORMAL; diff --git a/Source/lua/lua_global.cpp b/Source/lua/lua_global.cpp index 8e2d7007b33..3e4c4692dcf 100644 --- a/Source/lua/lua_global.cpp +++ b/Source/lua/lua_global.cpp @@ -24,6 +24,7 @@ #include "lua/modules/render.hpp" #include "lua/modules/system.hpp" #include "lua/modules/towners.hpp" +#include "lua/modules/towns.hpp" #include "monster.h" #include "options.h" #include "player.h" @@ -290,6 +291,7 @@ void LuaInitialize() "devilutionx.player", LuaPlayerModule(lua), "devilutionx.render", LuaRenderModule(lua), "devilutionx.towners", LuaTownersModule(lua), + "devilutionx.towns", LuaTownsModule(lua), "devilutionx.hellfire", LuaHellfireModule(lua), "devilutionx.system", LuaSystemModule(lua), "devilutionx.floatingnumbers", LuaFloatingNumbersModule(lua), diff --git a/Source/lua/modules/towners.cpp b/Source/lua/modules/towners.cpp index b1dd524ee38..43eb8803ac7 100644 --- a/Source/lua/modules/towners.cpp +++ b/Source/lua/modules/towners.cpp @@ -1,7 +1,7 @@ #include "lua/modules/towners.hpp" #include -#include +#include #include #include @@ -9,28 +9,12 @@ #include "engine/point.hpp" #include "lua/metadoc.hpp" #include "player.h" +#include "stores.h" #include "towners.h" namespace devilution { namespace { -// Map from towner type enum to Lua table name -const std::unordered_map<_talker_id, const char *> TownerTableNames = { - { TOWN_SMITH, "griswold" }, - { TOWN_HEALER, "pepin" }, - { TOWN_DEADGUY, "deadguy" }, - { TOWN_TAVERN, "ogden" }, - { TOWN_STORY, "cain" }, - { TOWN_DRUNK, "farnham" }, - { TOWN_WITCH, "adria" }, - { TOWN_BMAID, "gillian" }, - { TOWN_PEGBOY, "wirt" }, - { TOWN_COW, "cow" }, - { TOWN_FARMER, "lester" }, - { TOWN_GIRL, "celia" }, - { TOWN_COWFARM, "nut" }, -}; - void PopulateTownerTable(_talker_id townerId, sol::table &out) { LuaSetDocFn(out, "position", "()", @@ -48,14 +32,35 @@ sol::table LuaTownersModule(sol::state_view &lua) sol::table table = lua.create_table(); // Iterate over all towner types found in TSV data for (const auto &[townerId, name] : TownerLongNames) { - auto tableNameIt = TownerTableNames.find(townerId); - if (tableNameIt == TownerTableNames.end()) - continue; // Skip if no table name mapping + auto shortNameIt = TownerShortNames.find(townerId); + if (shortNameIt == TownerShortNames.end()) + continue; // Skip if no short name mapping sol::table townerTable = lua.create_table(); PopulateTownerTable(townerId, townerTable); - LuaSetDoc(table, tableNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable)); + LuaSetDoc(table, shortNameIt->second, /*signature=*/"", name.c_str(), std::move(townerTable)); } + + LuaSetDocFn(table, "addDialogOption", + "(townerName: string, getLabel: function, onSelect: function)", + "Adds a dynamic dialog option to a towner's talk menu.\n" + "getLabel() is called each time the dialog opens; return a non-empty string to show\n" + "the option or an empty string/nil to hide it.\n" + "onSelect() is called when the player chooses the option.", + [](std::string_view townerName, const sol::function &getLabel, const sol::function &onSelect) { + RegisterTownerDialogOption( + townerName, + [getLabel]() -> std::string { + sol::object result = getLabel(); + if (result.get_type() == sol::type::string) + return result.as(); + return {}; + }, + [onSelect]() { + onSelect(); + }); + }); + return table; } diff --git a/Source/lua/modules/towns.cpp b/Source/lua/modules/towns.cpp new file mode 100644 index 00000000000..e905eb2e778 --- /dev/null +++ b/Source/lua/modules/towns.cpp @@ -0,0 +1,203 @@ +#include "lua/modules/towns.hpp" + +#include +#include +#include + +#include + +#include "interfac.h" +#include "levels/gendung_defs.hpp" +#include "levels/town_data.h" +#include "lua/metadoc.hpp" +#include "msg.h" +#include "player.h" +#include "utils/log.hpp" + +namespace devilution { + +namespace { + +std::optional ParseWarpGateString(std::string_view w) +{ + if (w == "catacombs") + return DTYPE_CATACOMBS; + if (w == "caves") + return DTYPE_CAVES; + if (w == "hell") + return DTYPE_HELL; + if (w == "nest") + return DTYPE_NEST; + if (w == "crypt") + return DTYPE_CRYPT; + return std::nullopt; +} + +std::string LuaRegisterTown(std::string_view townId, const sol::table &config) +{ + TownConfig townConfig; + sol::optional name = config["name"]; + townConfig.name = name.has_value() ? *name : std::string(townId); + sol::optional saveId = config["saveId"]; + townConfig.saveId = saveId.has_value() ? static_cast(*saveId) : 0; + + if (sol::optional bounds = config["bounds"]) { + if (sol::optional min = (*bounds)["min"]) { + sol::optional minX = (*min)["x"]; + sol::optional minY = (*min)["y"]; + townConfig.dminPosition = { minX.value_or(10), minY.value_or(10) }; + } + if (sol::optional max = (*bounds)["max"]) { + sol::optional maxX = (*max)["x"]; + sol::optional maxY = (*max)["y"]; + townConfig.dmaxPosition = { maxX.value_or(84), maxY.value_or(84) }; + } + } + + if (sol::optional sectors = config["sectors"]) { + for (const auto &kv : *sectors) { + sol::table sector = kv.second.as(); + TownSector s; + sol::optional path = sector["path"]; + s.filePath = path.value_or(""); + sol::optional sx = sector["x"]; + sol::optional sy = sector["y"]; + s.x = sx.value_or(0); + s.y = sy.value_or(0); + townConfig.sectors.push_back(s); + } + } + + if (sol::optional entries = config["entries"]) { + for (const auto &kv : *entries) { + sol::table entry = kv.second.as(); + TownEntryPoint ep; + sol::optional typeStr = entry["type"]; + std::string type = typeStr.value_or("main"); + if (type == "prev") + ep.entryType = ENTRY_PREV; + else if (type == "twarpdn") + ep.entryType = ENTRY_TWARPDN; + else if (type == "twarpup") + ep.entryType = ENTRY_TWARPUP; + else if (type == "townswitch") + ep.entryType = ENTRY_TOWNSWITCH; + else + ep.entryType = ENTRY_MAIN; + sol::optional ex = entry["x"]; + sol::optional ey = entry["y"]; + sol::optional warpFrom = entry["warpFrom"]; + ep.viewPosition = { ex.value_or(75), ey.value_or(68) }; + ep.warpFromLevel = warpFrom.value_or(-1); + townConfig.entries.push_back(ep); + } + } + + if (sol::optional towners = config["towners"]) { + for (const auto &kv : *towners) { + sol::table t = kv.second.as(); + TownerPositionOverride override; + sol::optional tName = t["name"]; + override.shortName = tName.value_or(""); + sol::optional tx = t["x"]; + sol::optional ty = t["y"]; + override.position = { tx.value_or(0), ty.value_or(0) }; + townConfig.townerOverrides.push_back(override); + } + } + + if (sol::optional triggerList = config["triggers"]) { + for (const auto &kv : *triggerList) { + sol::table t = kv.second.as(); + TownTrigger tr; + sol::optional tx = t["x"]; + sol::optional ty = t["y"]; + tr.position = { tx.value_or(0), ty.value_or(0) }; + sol::optional kindStr = t["kind"]; + const std::string kind = kindStr.value_or("nextlevel"); + if (kind == "townwarp") { + tr.msg = WM_DIABTOWNWARP; + sol::optional lvl = t["level"]; + tr.level = lvl.value_or(0); + sol::optional warpStr = t["warp"]; + if (warpStr.has_value() && !warpStr->empty()) { + std::optional gate = ParseWarpGateString(*warpStr); + if (!gate.has_value()) { + LogError("registerTown: unknown triggers[].warp '{}'", *warpStr); + continue; + } + tr.warpGate = gate; + } + } else if (kind == "nextlevel") { + tr.msg = WM_DIABNEXTLVL; + tr.level = 0; + } else { + LogError("registerTown: unknown triggers[].kind '{}', expected nextlevel or townwarp", kind); + continue; + } + townConfig.triggers.push_back(tr); + } + } + + std::string townIdStr(townId); + GetTownRegistry().RegisterTown(townIdStr, townConfig); + return townIdStr; +} + +void LuaTravelToTown(std::string_view townId) +{ + std::string townIdStr(townId); + + if (!GetTownRegistry().HasTown(townIdStr)) { + LogError("Town '{}' not registered", townId); + return; + } + + DestinationTownID = townIdStr; + + if (gbIsMultiplayer) { + NetSendCmdTownTravel(0xFFFFFFFF, townIdStr.c_str()); + return; + } + + QueueTownSwitch(); +} + +std::string LuaGetCurrentTown() +{ + return GetTownRegistry().GetCurrentTown(); +} + +bool LuaHasTown(std::string_view townId) +{ + return GetTownRegistry().HasTown(std::string(townId)); +} + +} // namespace + +sol::table LuaTownsModule(sol::state_view &lua) +{ + sol::table table = lua.create_table(); + + LuaSetDocFn(table, "register", "(townId: string, config: table) -> string", + "Registers a new town from a config table. Returns town ID.\n" + "Optional triggers: array of tables with x, y, kind (\"nextlevel\" or \"townwarp\").\n" + "For townwarp, set level (dungeon level) and warp (\"catacombs\", \"caves\", \"hell\", \"nest\", \"crypt\") for IsWarpOpen gating.", + LuaRegisterTown); + + LuaSetDocFn(table, "travel", "(townId: string)", + "Travels to the specified town.", + LuaTravelToTown); + + LuaSetDocFn(table, "current", "() -> string", + "Returns the current town ID.", + LuaGetCurrentTown); + + LuaSetDocFn(table, "exists", "(townId: string) -> boolean", + "Checks if a town is registered.", + LuaHasTown); + + return table; +} + +} // namespace devilution diff --git a/Source/lua/modules/towns.hpp b/Source/lua/modules/towns.hpp new file mode 100644 index 00000000000..6458921d70c --- /dev/null +++ b/Source/lua/modules/towns.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace devilution { + +sol::table LuaTownsModule(sol::state_view &lua); + +} // namespace devilution diff --git a/Source/msg.cpp b/Source/msg.cpp index bd98a5066ff..36bd9058b98 100644 --- a/Source/msg.cpp +++ b/Source/msg.cpp @@ -34,9 +34,11 @@ #include "engine/random.hpp" #include "engine/world_tile.hpp" #include "gamemenu.h" +#include "interfac.h" #include "items/validation.h" #include "levels/crypt.h" #include "levels/town.h" +#include "levels/town_data.h" #include "levels/trigs.h" #include "lighting.h" #include "missiles.h" @@ -2452,6 +2454,34 @@ size_t OnSetVitality(const TCmdParam1 &message, Player &player) return sizeof(message); } +size_t OnTownTravel(const TCmd &cmd, size_t maxCmdSize, Player & /*player*/) +{ + const auto &message = reinterpret_cast(cmd); + const size_t headerSize = sizeof(message) - sizeof(message.str); + const size_t maxLength = std::min(MAX_SEND_STR_LEN, maxCmdSize - headerSize); + const std::string_view str { message.str, maxLength }; + const auto tokens = SplitByChar(str, '\0'); + const std::string_view townId = *tokens.begin(); + + // gbBufferMsgs != 0 means we are buffering a game-state replay; skip the SDL + // event so we do not queue a second town switch while one is already in flight. + if (gbBufferMsgs == 0) { + // Process town travel on all clients + std::string townIdStr(townId); + if (GetTownRegistry().HasTown(townIdStr)) { + DestinationTownID = townIdStr; + LogInfo("Network: Received town travel to '{}'", townId); + + QueueTownSwitch(); + } else { + LogError("Network: Unknown town ID '{}'", townId); + } + } + + const size_t nullSize = str.size() != townId.size() ? 1 : 0; + return headerSize + townId.size() + nullSize; +} + size_t OnString(const TCmd &cmd, size_t maxCmdSize, Player &player) { const auto &message = reinterpret_cast(cmd); @@ -3304,6 +3334,15 @@ void NetSendCmdString(uint32_t pmask, const char *pszStr) multi_send_msg_packet(pmask, reinterpret_cast(&cmd), strlen(cmd.str) + 2); } +void NetSendCmdTownTravel(uint32_t pmask, const char *townId) +{ + TCmdString cmd; + + cmd.bCmd = CMD_TOWNTRAVEL; + CopyUtf8(cmd.str, townId, sizeof(cmd.str)); + multi_send_msg_packet(pmask, reinterpret_cast(&cmd), strlen(cmd.str) + 2); +} + void delta_close_portal(const Player &player) { memset(&sgJunk.portal[player.getId()], 0xFF, sizeof(sgJunk.portal[player.getId()])); @@ -3447,6 +3486,8 @@ size_t ParseCmd(uint8_t pnum, const TCmd *pCmd, size_t maxCmdSize) return OnDeactivatePortal(*pCmd, player); case CMD_RETOWN: return OnRestartTown(*pCmd, player); + case CMD_TOWNTRAVEL: + return OnTownTravel(*pCmd, maxCmdSize, player); case CMD_SETSTR: return HandleCmd(OnSetStrength, player, pCmd, maxCmdSize); case CMD_SETMAG: diff --git a/Source/msg.h b/Source/msg.h index 10b6131b6bc..e49ae453824 100644 --- a/Source/msg.h +++ b/Source/msg.h @@ -371,6 +371,10 @@ enum _cmd_id : uint8_t { // // body (TCmd) CMD_RETOWN, + // Travel to a different town (multi-town system). + // + // body (TCmdString): townId (null-terminated string, max 32 chars) + CMD_TOWNTRAVEL, // Cast spell with direction at target location (e.g. firewall). // // body (TCmdLocParam5): @@ -759,6 +763,7 @@ void NetSendCmdChBeltItem(bool bHiPri, int beltIndex); void NetSendCmdDamage(bool bHiPri, const Player &player, uint32_t dwDam, DamageType damageType); void NetSendCmdMonDmg(bool bHiPri, uint16_t wMon, uint32_t dwDam); void NetSendCmdString(uint32_t pmask, const char *pszStr); +void NetSendCmdTownTravel(uint32_t pmask, const char *townId); void delta_close_portal(const Player &player); bool ValidateCmdSize(size_t requiredCmdSize, size_t maxCmdSize, size_t playerId); size_t ParseCmd(uint8_t pnum, const TCmd *pCmd, size_t maxCmdSize); diff --git a/Source/player.cpp b/Source/player.cpp index 66faa2b09b3..4ac9965c419 100644 --- a/Source/player.cpp +++ b/Source/player.cpp @@ -2364,6 +2364,9 @@ void CreatePlayer(Player &player, HeroClass c) player.pDamAcFlags = ItemSpecialEffectHf::None; player.wReflections = 0; + // Initialize town data (start in Tristram, ID 0) + player._pCurrentTownId = 0; + InitDungMsgs(player); CreatePlrItems(player); SetRndSeed(0); diff --git a/Source/player.h b/Source/player.h index 5cc99ca1975..04fe0d3764c 100644 --- a/Source/player.h +++ b/Source/player.h @@ -359,6 +359,8 @@ struct Player { uint8_t pDungMsgs2; bool pOriginalCathedral; uint8_t pDiabloKillLevel; + /** @brief Save-file ID of the current town (matches TownConfig::saveId; 0 = Tristram) */ + uint8_t _pCurrentTownId; uint16_t wReflections; ItemSpecialEffectHf pDamAcFlags; diff --git a/Source/stores.cpp b/Source/stores.cpp index 436cb3fabe0..e0d09439f41 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -7,7 +7,10 @@ #include #include +#include #include +#include +#include #include @@ -68,8 +71,39 @@ TalkID OldActiveStore; /** Temporary item used to hold the item being traded */ Item TempItem; +std::unordered_map> ExtraTownerOptions; + +const char *TownerNameForTalkID(TalkID s) +{ + const auto lookup = [](const _talker_id id) -> const char * { + auto it = TownerShortNames.find(id); + return it != TownerShortNames.end() ? it->second : nullptr; + }; + switch (s) { + case TalkID::Smith: return lookup(TOWN_SMITH); + case TalkID::Witch: return lookup(TOWN_WITCH); + case TalkID::Boy: return lookup(TOWN_PEGBOY); + case TalkID::Healer: return lookup(TOWN_HEALER); + case TalkID::Storyteller: return lookup(TOWN_STORY); + case TalkID::Tavern: return lookup(TOWN_TAVERN); + case TalkID::Drunk: return lookup(TOWN_DRUNK); + case TalkID::Barmaid: return lookup(TOWN_BMAID); + default: return nullptr; + } +} + +void RegisterTownerDialogOption(std::string_view townerName, + std::function getLabel, + std::function onSelect) +{ + ExtraTownerOptions[std::string(townerName)].push_back({ std::move(getLabel), std::move(onSelect) }); +} + namespace { +/** Maps dialog line number to ExtraTownerOptions index for options visible in the current dialog session */ +std::unordered_map CurrentExtraOptionIndices; + /** The current towner being interacted with */ _talker_id TownerId; @@ -2217,34 +2251,8 @@ void StartStore(TalkID s) ReleaseStoreBtn(); // Fire StoreOpened Lua event for main store entries - switch (s) { - case TalkID::Smith: - lua::StoreOpened("griswold"); - break; - case TalkID::Witch: - lua::StoreOpened("adria"); - break; - case TalkID::Boy: - lua::StoreOpened("wirt"); - break; - case TalkID::Healer: - lua::StoreOpened("pepin"); - break; - case TalkID::Storyteller: - lua::StoreOpened("cain"); - break; - case TalkID::Tavern: - lua::StoreOpened("ogden"); - break; - case TalkID::Drunk: - lua::StoreOpened("farnham"); - break; - case TalkID::Barmaid: - lua::StoreOpened("gillian"); - break; - default: - break; - } + if (const char *name = TownerNameForTalkID(s); name != nullptr) + lua::StoreOpened(name); switch (s) { case TalkID::Smith: @@ -2331,6 +2339,22 @@ void StartStore(TalkID s) break; } + CurrentExtraOptionIndices.clear(); + if (const char *extraTownerName = TownerNameForTalkID(s); extraTownerName != nullptr) { + if (auto extraIt = ExtraTownerOptions.find(extraTownerName); extraIt != ExtraTownerOptions.end()) { + size_t optIdx = 0; + for (int line = 14; line < 18 && optIdx < extraIt->second.size(); line += 2) { + if (TextLine[line].hasText()) break; + std::string label = extraIt->second[optIdx].getLabel(); + if (!label.empty()) { + AddSText(0, line, label, UiFlags::ColorWhite | UiFlags::AlignCenter, true); + CurrentExtraOptionIndices[line] = optIdx; + } + ++optIdx; + } + } + } + CurrentTextLine = -1; for (int i = 0; i < NumStoreLines; i++) { if (TextLine[i].isSelectable()) { @@ -2595,6 +2619,17 @@ void StoreEnter() } PlaySFX(SfxID::MenuSelect); + + if (auto extraOptIt = CurrentExtraOptionIndices.find(CurrentTextLine); extraOptIt != CurrentExtraOptionIndices.end()) { + if (const char *townerName = TownerNameForTalkID(ActiveStore); townerName != nullptr) { + if (auto it = ExtraTownerOptions.find(townerName); it != ExtraTownerOptions.end() && extraOptIt->second < it->second.size()) { + it->second[extraOptIt->second].onSelect(); + } + } + ActiveStore = TalkID::None; + return; + } + switch (ActiveStore) { case TalkID::Smith: SmithEnter(); diff --git a/Source/stores.h b/Source/stores.h index 0f1d2d7d0c9..9161dbd0415 100644 --- a/Source/stores.h +++ b/Source/stores.h @@ -6,7 +6,12 @@ #pragma once #include +#include #include +#include +#include +#include +#include #include "DiabloUI/ui_flags.hpp" #include "control/control.hpp" @@ -106,6 +111,31 @@ extern DVL_API_FOR_TEST TalkID OldActiveStore; /** Temporary item used to hold the item being traded */ extern DVL_API_FOR_TEST Item TempItem; +struct TownerDialogOption { + std::function getLabel; + std::function onSelect; +}; + +/** Extra dialog options injected by mods, keyed by towner short name. */ +extern DVL_API_FOR_TEST std::unordered_map> ExtraTownerOptions; + +/** + * @brief Returns the towner short name for a top-level TalkID, or nullptr if not a towner store. + */ +DVL_API_FOR_TEST const char *TownerNameForTalkID(TalkID s); + +/** + * @brief Registers a dynamic dialog option for a towner's talk menu. + * + * @param townerName Short name of the towner (e.g. "farnham"). + * @param getLabel Called when the dialog is built; return a non-empty string to show the + * option, or an empty string to hide it. + * @param onSelect Called when the player chooses this option. + */ +void RegisterTownerDialogOption(std::string_view townerName, + std::function getLabel, + std::function onSelect); + void AddStoreHoldRepair(Item *itm, int8_t i); /** Clears premium items sold by Griswold and Wirt. */ diff --git a/Source/towners.cpp b/Source/towners.cpp index 3c8a28d3b2d..8ad96e0df7e 100644 --- a/Source/towners.cpp +++ b/Source/towners.cpp @@ -11,6 +11,8 @@ #include "engine/random.hpp" #include "game_mode.hpp" #include "inv.h" +#include "levels/town_data.h" +#include "lua/lua_event.hpp" #include "minitext.h" #include "stores.h" #include "tables/textdat.h" @@ -704,6 +706,22 @@ std::vector Towners; std::unordered_map<_talker_id, std::string> TownerLongNames; +const std::unordered_map<_talker_id, const char *> TownerShortNames = { + { TOWN_SMITH, "griswold" }, + { TOWN_HEALER, "pepin" }, + { TOWN_DEADGUY, "deadguy" }, + { TOWN_TAVERN, "ogden" }, + { TOWN_STORY, "cain" }, + { TOWN_DRUNK, "farnham" }, + { TOWN_WITCH, "adria" }, + { TOWN_BMAID, "gillian" }, + { TOWN_PEGBOY, "wirt" }, + { TOWN_COW, "cow" }, + { TOWN_FARMER, "lester" }, + { TOWN_GIRL, "celia" }, + { TOWN_COWFARM, "nut" }, +}; + size_t GetNumTownerTypes() { return TownerLongNames.size(); @@ -780,6 +798,22 @@ void InitTowners() InitTownerInfo(Towners.back(), *behaviorIt->second, entry); i++; } + + // Apply towner position overrides from the active town config + const std::string ¤tTown = GetTownRegistry().GetCurrentTown(); + if (GetTownRegistry().HasTown(currentTown)) { + const TownConfig &config = GetTownRegistry().GetTown(currentTown); + for (const auto &override : config.townerOverrides) { + for (auto &towner : Towners) { + auto shortNameIt = TownerShortNames.find(towner._ttype); + if (shortNameIt != TownerShortNames.end() && shortNameIt->second == override.shortName) { + dMonster[towner.position.x][towner.position.y] = 0; + towner.position = override.position; + dMonster[towner.position.x][towner.position.y] = static_cast(&towner - Towners.data() + 1); + } + } + } + } } void FreeTownerGFX() diff --git a/Source/towners.h b/Source/towners.h index 8cbf7916db6..a7aec182266 100644 --- a/Source/towners.h +++ b/Source/towners.h @@ -43,6 +43,7 @@ enum _talker_id : uint8_t { // Runtime mappings built from TSV data extern DVL_API_FOR_TEST std::unordered_map<_talker_id, std::string> TownerLongNames; // Maps towner type enum to display name +extern const std::unordered_map<_talker_id, const char *> TownerShortNames; // Maps towner type enum to Lua/mod short name struct Towner { OptionalOwnedClxSpriteList ownedAnim; diff --git a/test/stores_test.cpp b/test/stores_test.cpp index b4c2e16124a..7a0a2d01cc3 100644 --- a/test/stores_test.cpp +++ b/test/stores_test.cpp @@ -71,4 +71,83 @@ TEST(Stores, AddStoreHoldRepair_normal) EXPECT_EQ(1, item->_ivalue); EXPECT_EQ(1, item->_iIvalue); } + +TEST(Stores, TownerNameForTalkID_knownTowners) +{ + EXPECT_STREQ(TownerNameForTalkID(TalkID::Smith), "griswold"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Witch), "adria"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Boy), "wirt"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Healer), "pepin"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Storyteller), "cain"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Tavern), "ogden"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Drunk), "farnham"); + EXPECT_STREQ(TownerNameForTalkID(TalkID::Barmaid), "gillian"); +} + +TEST(Stores, TownerNameForTalkID_subPagesReturnNull) +{ + // Sub-pages (buy/sell screens) should not fire StoreOpened + EXPECT_EQ(TownerNameForTalkID(TalkID::None), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::SmithBuy), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::SmithSell), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::SmithRepair), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::WitchBuy), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::Gossip), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentify), nullptr); + EXPECT_EQ(TownerNameForTalkID(TalkID::StorytellerIdentifyShow), nullptr); +} + +TEST(Stores, RegisterTownerDialogOption_storesOption) +{ + ExtraTownerOptions.clear(); + + RegisterTownerDialogOption("farnham", []() { return std::string("Go to Tiny Town"); }, []() {}); + + ASSERT_EQ(ExtraTownerOptions.count("farnham"), 1u); + ASSERT_EQ(ExtraTownerOptions.at("farnham").size(), 1u); + EXPECT_EQ(ExtraTownerOptions.at("farnham")[0].getLabel(), "Go to Tiny Town"); + + ExtraTownerOptions.clear(); +} + +TEST(Stores, RegisterTownerDialogOption_callsOnSelect) +{ + ExtraTownerOptions.clear(); + + bool called = false; + RegisterTownerDialogOption("farnham", []() { return std::string("Travel"); }, [&called]() { called = true; }); + + ExtraTownerOptions.at("farnham")[0].onSelect(); + EXPECT_TRUE(called); + + ExtraTownerOptions.clear(); +} + +TEST(Stores, RegisterTownerDialogOption_emptyLabelHidesOption) +{ + ExtraTownerOptions.clear(); + + RegisterTownerDialogOption("farnham", []() { return std::string(""); }, []() {}); + + ASSERT_EQ(ExtraTownerOptions.at("farnham").size(), 1u); + EXPECT_TRUE(ExtraTownerOptions.at("farnham")[0].getLabel().empty()); + + ExtraTownerOptions.clear(); +} + +TEST(Stores, RegisterTownerDialogOption_multipleTowners) +{ + ExtraTownerOptions.clear(); + + RegisterTownerDialogOption("farnham", []() { return std::string("A"); }, []() {}); + RegisterTownerDialogOption("griswold", []() { return std::string("B"); }, []() {}); + + EXPECT_EQ(ExtraTownerOptions.at("farnham").size(), 1u); + EXPECT_EQ(ExtraTownerOptions.at("griswold").size(), 1u); + EXPECT_EQ(ExtraTownerOptions.at("farnham")[0].getLabel(), "A"); + EXPECT_EQ(ExtraTownerOptions.at("griswold")[0].getLabel(), "B"); + + ExtraTownerOptions.clear(); +} + } // namespace diff --git a/test/town_registry_test.cpp b/test/town_registry_test.cpp new file mode 100644 index 00000000000..f2f79497e30 --- /dev/null +++ b/test/town_registry_test.cpp @@ -0,0 +1,178 @@ +/** + * @file town_registry_test.cpp + * + * Unit tests for town registry, entry points, and InitializeTristram. + */ + +#include + +#include + +#include "levels/town_data.h" + +using namespace devilution; + +namespace { + +constexpr size_t kTristramTriggerCount = 6; +constexpr size_t kTristramWarpClosedPatchCount = 3; +constexpr size_t kTristramEntryPointCount = 8; + +constexpr Point kTristramCathedralTrigPosition = { 25, 29 }; + +constexpr Point kTownEntryDefaultFallback = { 75, 68 }; + +constexpr uint8_t kTestTownAlphaSaveId = 7; +constexpr uint8_t kTestTownBetaSaveId = 11; +constexpr uint8_t kUnknownSaveId = 99; + +constexpr int kNonMatchingWarpFromLevel = 999; + +TEST(TownRegistry, HasTown_ReturnsFalseForUnregistered) +{ + TownRegistry registry; + EXPECT_FALSE(registry.HasTown("nowhere")); +} + +TEST(TownRegistry, RegisterTown_HasTown_GetTown_RoundTrip) +{ + TownRegistry registry; + TownConfig cfg; + cfg.name = "Testburg"; + registry.RegisterTown("testburg", cfg); + EXPECT_TRUE(registry.HasTown("testburg")); + EXPECT_EQ(registry.GetTown("testburg").name, "Testburg"); +} + +TEST(TownRegistry, GetTown_ThrowsForUnregistered) +{ + TownRegistry registry; + EXPECT_THROW(static_cast(registry.GetTown("missing")), std::out_of_range); +} + +TEST(TownRegistry, SetCurrentTown_GetCurrentTown_RoundTrip) +{ + TownRegistry registry; + TownConfig cfg; + registry.RegisterTown("a", cfg); + registry.SetCurrentTown("a"); + EXPECT_EQ(registry.GetCurrentTown(), "a"); +} + +TEST(TownRegistry, GetTownBySaveId_FindsBySaveId) +{ + TownRegistry registry; + TownConfig alpha; + alpha.name = "Alpha"; + alpha.saveId = kTestTownAlphaSaveId; + TownConfig beta; + beta.name = "Beta"; + beta.saveId = kTestTownBetaSaveId; + registry.RegisterTown("alpha", alpha); + registry.RegisterTown("beta", beta); + EXPECT_EQ(registry.GetTownBySaveId(kTestTownAlphaSaveId), "alpha"); + EXPECT_EQ(registry.GetTownBySaveId(kTestTownBetaSaveId), "beta"); +} + +TEST(TownRegistry, GetTownBySaveId_ReturnsTristramForUnknownNonZeroSaveId) +{ + TownRegistry registry; + EXPECT_EQ(registry.GetTownBySaveId(kUnknownSaveId), TristramTownId); +} + +TEST(TownRegistry, GetTownBySaveId_ReturnsTristramWhenZeroNotRegistered) +{ + TownRegistry registry; + TownConfig cfg; + cfg.saveId = kTestTownAlphaSaveId; + registry.RegisterTown("only_alpha", cfg); + EXPECT_EQ(registry.GetTownBySaveId(0), TristramTownId); +} + +TEST(TownConfig, GetEntryPoint_ENTRY_MAIN) +{ + TownConfig cfg; + cfg.entries = { + { ENTRY_MAIN, { 10, 20 }, -1 }, + }; + EXPECT_EQ(cfg.GetEntryPoint(ENTRY_MAIN), Point(10, 20)); +} + +TEST(TownConfig, GetEntryPoint_ENTRY_TWARPUP_MatchesWarpFromLevel) +{ + TownConfig cfg; + constexpr int kWarpFromLevel = 5; + cfg.entries = { + { ENTRY_TWARPUP, { 49, 22 }, kWarpFromLevel }, + }; + EXPECT_EQ(cfg.GetEntryPoint(ENTRY_TWARPUP, kWarpFromLevel), Point(49, 22)); +} + +TEST(TownConfig, GetEntryPoint_ENTRY_TWARPUP_NonMatchingWarpUsesDefault) +{ + TownConfig cfg; + cfg.entries = { + { ENTRY_TWARPUP, { 49, 22 }, 5 }, + }; + EXPECT_EQ(cfg.GetEntryPoint(ENTRY_TWARPUP, kNonMatchingWarpFromLevel), kTownEntryDefaultFallback); +} + +TEST(TownConfig, GetEntryPoint_EmptyEntriesUsesDefault) +{ + TownConfig cfg; + cfg.entries.clear(); + EXPECT_EQ(cfg.GetEntryPoint(ENTRY_MAIN), kTownEntryDefaultFallback); +} + +class InitializeTristramTest : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + InitializeTristram(); + } +}; + +TEST_F(InitializeTristramTest, RegistryHasTristram) +{ + TownRegistry ®istry = GetTownRegistry(); + EXPECT_TRUE(registry.HasTown(TristramTownId)); +} + +TEST_F(InitializeTristramTest, CurrentTownIsTristram) +{ + EXPECT_EQ(GetTownRegistry().GetCurrentTown(), TristramTownId); +} + +TEST_F(InitializeTristramTest, GetTownBySaveIdZeroReturnsTristram) +{ + EXPECT_EQ(GetTownRegistry().GetTownBySaveId(0), TristramTownId); +} + +TEST_F(InitializeTristramTest, TristramTriggerCount) +{ + const TownConfig &tristram = GetTownRegistry().GetTown(TristramTownId); + EXPECT_EQ(tristram.triggers.size(), kTristramTriggerCount); +} + +TEST_F(InitializeTristramTest, TristramFirstTriggerIsCathedralStairs) +{ + const TownConfig &tristram = GetTownRegistry().GetTown(TristramTownId); + ASSERT_FALSE(tristram.triggers.empty()); + const TownTrigger &first = tristram.triggers.front(); + EXPECT_EQ(first.position, kTristramCathedralTrigPosition); + EXPECT_EQ(first.msg, WM_DIABNEXTLVL); +} + +TEST_F(InitializeTristramTest, TristramWarpClosedPatchCount) +{ + const TownConfig &tristram = GetTownRegistry().GetTown(TristramTownId); + EXPECT_EQ(tristram.warpClosedPatches.size(), kTristramWarpClosedPatchCount); +} + +TEST_F(InitializeTristramTest, TristramEntryPointCount) +{ + const TownConfig &tristram = GetTownRegistry().GetTown(TristramTownId); + EXPECT_EQ(tristram.entries.size(), kTristramEntryPointCount); +} + +} // namespace