You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
932 lines
26 KiB
932 lines
26 KiB
/** |
|
* @file store_transaction_test.cpp |
|
* |
|
* End-to-end tests for text-based store transactions. |
|
* |
|
* These tests drive the store state machine through its TalkID transitions |
|
* (StartStore → browse items → select → Confirm → commit) and assert only |
|
* on **game state outcomes**: player gold, inventory contents, item |
|
* properties, vendor inventory changes. |
|
* |
|
* No assertions are made on text lines, rendering, or UI layout — so these |
|
* tests remain valid when the text-based store is replaced with a new UI, |
|
* provided the replacement exposes equivalent "buy / sell / repair / identify |
|
* / recharge" entry points with the same game-state semantics. |
|
* |
|
* The store state machine works as follows: |
|
* - StartStore(TalkID) sets up the text UI and sets ActiveStore at the end. |
|
* - StoreEnter() dispatches on ActiveStore to the appropriate *Enter() fn. |
|
* - *Enter() functions check CurrentTextLine to decide what action to take: |
|
* - For item lists, item index = ScrollPos + ((CurrentTextLine - 5) / 4) |
|
* where 5 is PreviousScrollPos (set by ScrollVendorStore). |
|
* - For confirmations, line 18 = Yes, line 20 = No. |
|
* - On buy: checks afford → checks room → copies to TempItem → Confirm. |
|
* - ConfirmEnter dispatches to the actual transaction function on Yes. |
|
*/ |
|
|
|
#include <gtest/gtest.h> |
|
|
|
#include "ui_test.hpp" |
|
|
|
#include "control/control.hpp" |
|
#include "engine/random.hpp" |
|
#include "inv.h" |
|
#include "items.h" |
|
#include "minitext.h" |
|
#include "options.h" |
|
#include "player.h" |
|
#include "qol/stash.h" |
|
#include "quests.h" |
|
#include "stores.h" |
|
#include "storm/storm_net.hpp" |
|
#include "tables/itemdat.h" |
|
#include "tables/playerdat.hpp" |
|
#include "tables/spelldat.h" |
|
|
|
namespace devilution { |
|
namespace { |
|
|
|
// --------------------------------------------------------------------------- |
|
// Helpers to drive the store state machine at a high level. |
|
// |
|
// These abstract over the text-line / scroll-position encoding so that |
|
// tests read as "select item 0, confirm yes" rather than |
|
// "set CurrentTextLine=5, ScrollPos=0, call StoreEnter, ...". |
|
// --------------------------------------------------------------------------- |
|
|
|
/** |
|
* @brief Open a vendor's top-level menu. |
|
* |
|
* Equivalent to the player clicking on a towner NPC. |
|
*/ |
|
void OpenVendor(TalkID vendor) |
|
{ |
|
StartStore(vendor); |
|
} |
|
|
|
/** |
|
* @brief In a top-level vendor menu, select a menu option by its text line. |
|
* |
|
* The line numbers are fixed by the Start*() functions: |
|
* Smith: 12=Buy, 14=Premium, 16=Sell, 18=Repair, 20=Leave |
|
* Witch: 12=Talk, 14=Buy, 16=Sell, 18=Recharge, 20=Leave |
|
* Healer: 12=Talk, 14=Buy, 18=Leave |
|
* Storyteller: 12=Talk, 14=Identify, 18=Leave |
|
* Boy: 18=What have you got? (if item exists) |
|
*/ |
|
void SelectMenuLine(int line) |
|
{ |
|
CurrentTextLine = line; |
|
StoreEnter(); |
|
} |
|
|
|
/** |
|
* @brief In an item list (buy/sell/repair/identify/recharge), select item |
|
* at the given 0-based index. |
|
* |
|
* The store text layout puts items starting at line 5 (PreviousScrollPos), |
|
* with each item taking 4 lines. So item N is at line 5 + N*4 when |
|
* ScrollPos is 0. |
|
*/ |
|
void SelectItemAtIndex(int itemIndex) |
|
{ |
|
ScrollPos = 0; |
|
CurrentTextLine = 5 + itemIndex * 4; |
|
StoreEnter(); |
|
} |
|
|
|
/** |
|
* @brief Confirm a pending transaction (press "Yes" in the Confirm dialog). |
|
* |
|
* Precondition: ActiveStore == TalkID::Confirm. |
|
*/ |
|
void ConfirmYes() |
|
{ |
|
CurrentTextLine = 18; |
|
StoreEnter(); |
|
} |
|
|
|
/** |
|
* @brief Decline a pending transaction (press "No" in the Confirm dialog). |
|
* |
|
* Precondition: ActiveStore == TalkID::Confirm. |
|
*/ |
|
void ConfirmNo() |
|
{ |
|
CurrentTextLine = 20; |
|
StoreEnter(); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Test fixture |
|
// --------------------------------------------------------------------------- |
|
|
|
class StoreTransactionTest : public UITest { |
|
protected: |
|
void SetUp() override |
|
{ |
|
UITest::SetUp(); |
|
|
|
// Seed the RNG for deterministic item generation. |
|
SetRndSeed(42); |
|
|
|
// Make sure visualStoreUI is off (the base fixture does this too, |
|
// but be explicit). |
|
GetOptions().Gameplay.visualStoreUI.SetValue(false); |
|
} |
|
|
|
/** |
|
* @brief Populate all town vendors with items appropriate for a level-25 player. |
|
* |
|
* Must be called after SetUp() and after any adjustments to player level. |
|
*/ |
|
void PopulateVendors() |
|
{ |
|
SetRndSeed(42); |
|
int l = 16; // max store level |
|
SpawnSmith(l); |
|
SpawnWitch(l); |
|
SpawnHealer(l); |
|
SpawnBoy(MyPlayer->getCharacterLevel()); |
|
SpawnPremium(*MyPlayer); |
|
} |
|
|
|
/** |
|
* @brief Create a simple melee weapon item suitable for selling to the smith. |
|
*/ |
|
Item MakeSellableSword() |
|
{ |
|
Item item {}; |
|
InitializeItem(item, IDI_BARDSWORD); |
|
item._iIdentified = true; |
|
return item; |
|
} |
|
|
|
/** |
|
* @brief Create a magic item that is unidentified (for Cain tests). |
|
*/ |
|
Item MakeUnidentifiedMagicItem() |
|
{ |
|
Item item {}; |
|
InitializeItem(item, IDI_BARDSWORD); |
|
item._iMagical = ITEM_QUALITY_MAGIC; |
|
item._iIdentified = false; |
|
item._iIvalue = 2000; |
|
item._ivalue = 2000; |
|
return item; |
|
} |
|
|
|
/** |
|
* @brief Create a damaged item suitable for repair at the smith. |
|
*/ |
|
Item MakeDamagedSword() |
|
{ |
|
Item item {}; |
|
InitializeItem(item, IDI_BARDSWORD); |
|
item._iIdentified = true; |
|
item._iMaxDur = 40; |
|
item._iDurability = 10; |
|
item._ivalue = 2000; |
|
item._iIvalue = 2000; |
|
item._itype = ItemType::Sword; |
|
return item; |
|
} |
|
}; |
|
|
|
// =========================================================================== |
|
// Level A: Transaction primitive tests (completely UI-agnostic) |
|
// =========================================================================== |
|
|
|
TEST_F(StoreTransactionTest, PlayerCanAfford_SufficientGold) |
|
{ |
|
SetPlayerGold(10000); |
|
EXPECT_TRUE(PlayerCanAfford(5000)); |
|
EXPECT_TRUE(PlayerCanAfford(10000)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, PlayerCanAfford_InsufficientGold) |
|
{ |
|
SetPlayerGold(1000); |
|
EXPECT_FALSE(PlayerCanAfford(5000)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, PlayerCanAfford_IncludesStashGold) |
|
{ |
|
SetPlayerGold(2000); |
|
Stash.gold = 3000; |
|
EXPECT_TRUE(PlayerCanAfford(5000)); |
|
EXPECT_TRUE(PlayerCanAfford(4999)); |
|
EXPECT_FALSE(PlayerCanAfford(5001)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, TakePlrsMoney_DeductsFromInventory) |
|
{ |
|
SetPlayerGold(10000); |
|
int goldBefore = MyPlayer->_pGold; |
|
|
|
TakePlrsMoney(3000); |
|
|
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 3000); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, TakePlrsMoney_OverflowsToStash) |
|
{ |
|
SetPlayerGold(2000); |
|
Stash.gold = 5000; |
|
|
|
TakePlrsMoney(4000); |
|
|
|
// 2000 from inventory + 2000 from stash |
|
EXPECT_EQ(MyPlayer->_pGold, 0); |
|
EXPECT_EQ(Stash.gold, 3000); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, StoreAutoPlace_EmptyInventory) |
|
{ |
|
ClearInventory(); |
|
ClearBelt(); |
|
ClearEquipment(); |
|
|
|
Item item {}; |
|
InitializeItem(item, IDI_HEAL); |
|
|
|
// Dry-run: should succeed. |
|
EXPECT_TRUE(StoreAutoPlace(item, false)); |
|
|
|
// Persist: item should appear in player's inventory/belt/equipment. |
|
EXPECT_TRUE(StoreAutoPlace(item, true)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithWillBuy_AcceptsMeleeWeapon) |
|
{ |
|
Item sword {}; |
|
InitializeItem(sword, IDI_BARDSWORD); |
|
sword._iIdentified = true; |
|
EXPECT_TRUE(SmithWillBuy(sword)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithWillBuy_RejectsMiscItems) |
|
{ |
|
Item scroll {}; |
|
InitializeItem(scroll, IDI_HEAL); |
|
EXPECT_FALSE(SmithWillBuy(scroll)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, WitchWillBuy_AcceptsStaff) |
|
{ |
|
// Witch buys staves and misc magical items but not most weapons. |
|
Item staff {}; |
|
InitializeItem(staff, IDI_SHORTSTAFF); |
|
staff._iIdentified = true; |
|
EXPECT_TRUE(WitchWillBuy(staff)); |
|
} |
|
|
|
TEST_F(StoreTransactionTest, WitchWillBuy_RejectsSword) |
|
{ |
|
Item sword {}; |
|
InitializeItem(sword, IDI_BARDSWORD); |
|
sword._iIdentified = true; |
|
EXPECT_FALSE(WitchWillBuy(sword)); |
|
} |
|
|
|
// =========================================================================== |
|
// Level B: End-to-end store flows through the state machine |
|
// =========================================================================== |
|
|
|
// ---- Smith Buy ------------------------------------------------------------ |
|
|
|
TEST_F(StoreTransactionTest, SmithBuy_Success) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
// Record state before the transaction. |
|
const int itemPrice = SmithItems[0]._iIvalue; |
|
const size_t vendorCountBefore = SmithItems.size(); |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 1000); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Drive the state machine: open Smith → browse items → select first → confirm. |
|
OpenVendor(TalkID::SmithBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithBuy); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm) |
|
<< "Selecting an affordable item should go to Confirm"; |
|
|
|
ConfirmYes(); |
|
|
|
// Assertions on game state. |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice) |
|
<< "Player gold should decrease by item price"; |
|
EXPECT_EQ(SmithItems.size(), vendorCountBefore - 1) |
|
<< "Purchased item should be removed from vendor inventory"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithBuy_CantAfford) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
SetPlayerGold(0); |
|
Stash.gold = 0; |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithBuy); |
|
|
|
const size_t vendorCountBefore = SmithItems.size(); |
|
|
|
SelectItemAtIndex(0); |
|
EXPECT_EQ(ActiveStore, TalkID::NoMoney) |
|
<< "Should transition to NoMoney when player can't afford item"; |
|
EXPECT_EQ(SmithItems.size(), vendorCountBefore) |
|
<< "Vendor inventory should be unchanged"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithBuy_NoRoom) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
// Give the player enough gold but fill the inventory completely. |
|
SetPlayerGold(100000); |
|
|
|
// Fill every inventory grid cell, every belt slot, and every equipment slot |
|
// so StoreAutoPlace returns false. |
|
for (int i = 0; i < InventoryGridCells; i++) { |
|
MyPlayer->InvList[i]._itype = ItemType::Misc; |
|
MyPlayer->InvGrid[i] = static_cast<int8_t>(i + 1); |
|
} |
|
MyPlayer->_pNumInv = InventoryGridCells; |
|
for (int i = 0; i < MaxBeltItems; i++) { |
|
MyPlayer->SpdList[i]._itype = ItemType::Misc; |
|
} |
|
for (auto &bodyItem : MyPlayer->InvBody) { |
|
bodyItem._itype = ItemType::Misc; |
|
} |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithBuy); |
|
|
|
const size_t vendorCountBefore = SmithItems.size(); |
|
|
|
SelectItemAtIndex(0); |
|
EXPECT_EQ(ActiveStore, TalkID::NoRoom) |
|
<< "Should transition to NoRoom when inventory is full"; |
|
EXPECT_EQ(SmithItems.size(), vendorCountBefore) |
|
<< "Vendor inventory should be unchanged"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithBuy_ConfirmNo_NoChange) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
const int itemPrice = SmithItems[0]._iIvalue; |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 1000); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
const size_t vendorCountBefore = SmithItems.size(); |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmNo(); |
|
|
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore) |
|
<< "Declining should not change gold"; |
|
EXPECT_EQ(SmithItems.size(), vendorCountBefore) |
|
<< "Declining should not remove item from vendor"; |
|
} |
|
|
|
// ---- Smith Sell ----------------------------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, SmithSell_Success) |
|
{ |
|
StripPlayer(); |
|
SetPlayerGold(0); |
|
|
|
// Place a sellable sword in the player's inventory. |
|
Item sword = MakeSellableSword(); |
|
int invIdx = PlaceItemInInventory(sword); |
|
ASSERT_GE(invIdx, 0); |
|
|
|
ASSERT_EQ(MyPlayer->_pNumInv, 1); |
|
|
|
// Open the sell sub-store directly (Smith menu line 16 → SmithSell). |
|
OpenVendor(TalkID::SmithSell); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithSell); |
|
|
|
// The sell list should contain our sword. |
|
ASSERT_GT(CurrentItemIndex, 0) << "Smith should see at least one sellable item"; |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
// The sword should be gone and gold should have increased. |
|
EXPECT_GT(MyPlayer->_pGold, 0) << "Player should have received gold from the sale"; |
|
} |
|
|
|
// ---- Smith Repair --------------------------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, SmithRepair_RestoresDurability) |
|
{ |
|
StripPlayer(); |
|
|
|
// Equip a damaged sword in the right hand. |
|
Item damaged = MakeDamagedSword(); |
|
const int maxDur = damaged._iMaxDur; |
|
ASSERT_LT(damaged._iDurability, maxDur); |
|
|
|
MyPlayer->InvBody[INVLOC_HAND_RIGHT] = damaged; |
|
SetPlayerGold(100000); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Open the repair sub-store directly. |
|
OpenVendor(TalkID::SmithRepair); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithRepair); |
|
|
|
// The repair list should contain our damaged sword. |
|
ASSERT_GT(CurrentItemIndex, 0) << "Smith should see the damaged item"; |
|
|
|
// Record the repair cost. |
|
const int repairCost = PlayerItems[0]._iIvalue; |
|
ASSERT_GT(repairCost, 0); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
EXPECT_EQ(MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iDurability, |
|
MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iMaxDur) |
|
<< "Durability should be fully restored after repair"; |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - repairCost) |
|
<< "Repair cost should be deducted from gold"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, SmithRepair_CantAfford) |
|
{ |
|
StripPlayer(); |
|
|
|
Item damaged = MakeDamagedSword(); |
|
const int originalDur = damaged._iDurability; |
|
MyPlayer->InvBody[INVLOC_HAND_RIGHT] = damaged; |
|
SetPlayerGold(0); |
|
Stash.gold = 0; |
|
|
|
OpenVendor(TalkID::SmithRepair); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithRepair); |
|
ASSERT_GT(CurrentItemIndex, 0); |
|
|
|
SelectItemAtIndex(0); |
|
EXPECT_EQ(ActiveStore, TalkID::NoMoney) |
|
<< "Should transition to NoMoney when can't afford repair"; |
|
EXPECT_EQ(MyPlayer->InvBody[INVLOC_HAND_RIGHT]._iDurability, originalDur) |
|
<< "Durability should be unchanged"; |
|
} |
|
|
|
// ---- Healer --------------------------------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, Healer_FreeHealOnTalk) |
|
{ |
|
// Damage the player. |
|
MyPlayer->_pHitPoints = MyPlayer->_pMaxHP / 2; |
|
MyPlayer->_pHPBase = MyPlayer->_pMaxHPBase / 2; |
|
ASSERT_NE(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Just opening the healer menu heals the player for free. |
|
OpenVendor(TalkID::Healer); |
|
ASSERT_EQ(ActiveStore, TalkID::Healer); |
|
|
|
EXPECT_EQ(MyPlayer->_pHitPoints, MyPlayer->_pMaxHP) |
|
<< "Player should be fully healed just by talking to Pepin"; |
|
EXPECT_EQ(MyPlayer->_pHPBase, MyPlayer->_pMaxHPBase) |
|
<< "Player HP base should also be restored"; |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore) |
|
<< "Healing at Pepin is free — gold should be unchanged"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, HealerBuy_Success) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(HealerItems.empty()); |
|
|
|
StripPlayer(); |
|
|
|
const int itemPrice = HealerItems[0]._iIvalue; |
|
SetPlayerGold(itemPrice + 1000); |
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Navigate through the healer menu like a real player would: |
|
// First open the healer top-level menu, then select "Buy items" (line 14). |
|
// This ensures StartHealerBuy() is called, which sets PreviousScrollPos |
|
// correctly via ScrollVendorStore. |
|
OpenVendor(TalkID::Healer); |
|
ASSERT_EQ(ActiveStore, TalkID::Healer); |
|
|
|
SelectMenuLine(14); |
|
ASSERT_EQ(ActiveStore, TalkID::HealerBuy); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice) |
|
<< "Gold should decrease by the price of the healing item"; |
|
} |
|
|
|
// ---- Boy (Wirt) ----------------------------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, BoyBuy_Success) |
|
{ |
|
PopulateVendors(); |
|
// Wirt must have an item. |
|
if (BoyItem.isEmpty()) { |
|
GTEST_SKIP() << "Wirt has no item with this seed — skipping"; |
|
} |
|
|
|
// Wirt charges a 50g viewing fee, then the item price with markup. |
|
int price = BoyItem._iIvalue; |
|
price += BoyItem._iIvalue / 2; // Diablo 50% markup |
|
|
|
StripPlayer(); |
|
SetPlayerGold(price + 100); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Open Wirt's menu. |
|
OpenVendor(TalkID::Boy); |
|
ASSERT_EQ(ActiveStore, TalkID::Boy); |
|
|
|
// Pay the viewing fee (line 18 when Wirt has an item). |
|
SelectMenuLine(18); |
|
// This should deduct 50g and transition to BoyBuy. |
|
ASSERT_EQ(ActiveStore, TalkID::BoyBuy) |
|
<< "After paying viewing fee, should see Wirt's item"; |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 50) |
|
<< "50 gold viewing fee should be deducted"; |
|
|
|
// Select the item (it's at line 10 for Boy). |
|
CurrentTextLine = 10; |
|
StoreEnter(); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 50 - price) |
|
<< "Gold should decrease by viewing fee + item price"; |
|
EXPECT_TRUE(BoyItem.isEmpty()) |
|
<< "Wirt's item should be cleared after purchase"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, BoyBuy_CantAffordViewingFee) |
|
{ |
|
PopulateVendors(); |
|
if (BoyItem.isEmpty()) { |
|
GTEST_SKIP() << "Wirt has no item with this seed — skipping"; |
|
} |
|
|
|
SetPlayerGold(30); // Less than 50g viewing fee. |
|
Stash.gold = 0; |
|
|
|
OpenVendor(TalkID::Boy); |
|
ASSERT_EQ(ActiveStore, TalkID::Boy); |
|
|
|
SelectMenuLine(18); |
|
EXPECT_EQ(ActiveStore, TalkID::NoMoney) |
|
<< "Should get NoMoney when can't afford viewing fee"; |
|
} |
|
|
|
// ---- Storyteller (Cain) — Identify ---------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, StorytellerIdentify_Success) |
|
{ |
|
StripPlayer(); |
|
SetPlayerGold(10000); |
|
|
|
// Place an unidentified magic item in inventory. |
|
Item magic = MakeUnidentifiedMagicItem(); |
|
int invIdx = PlaceItemInInventory(magic); |
|
ASSERT_GE(invIdx, 0); |
|
ASSERT_FALSE(MyPlayer->InvList[invIdx]._iIdentified); |
|
|
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
// Open identify. |
|
OpenVendor(TalkID::StorytellerIdentify); |
|
ASSERT_EQ(ActiveStore, TalkID::StorytellerIdentify); |
|
ASSERT_GT(CurrentItemIndex, 0) << "Cain should see the unidentified item"; |
|
|
|
// The identify cost is always 100 gold. |
|
EXPECT_EQ(PlayerItems[0]._iIvalue, 100); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
// After ConfirmEnter for identify, it transitions to IdentifyShow. |
|
EXPECT_EQ(ActiveStore, TalkID::StorytellerIdentifyShow) |
|
<< "Should show the identified item"; |
|
|
|
// The actual item in the player's inventory should now be identified. |
|
EXPECT_TRUE(MyPlayer->InvList[invIdx]._iIdentified) |
|
<< "Item should be identified after Cain's service"; |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - 100) |
|
<< "Identification costs 100 gold"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, StorytellerIdentify_CantAfford) |
|
{ |
|
StripPlayer(); |
|
SetPlayerGold(50); // Less than 100g identify cost. |
|
Stash.gold = 0; |
|
|
|
Item magic = MakeUnidentifiedMagicItem(); |
|
int invIdx = PlaceItemInInventory(magic); |
|
ASSERT_GE(invIdx, 0); |
|
|
|
OpenVendor(TalkID::StorytellerIdentify); |
|
ASSERT_EQ(ActiveStore, TalkID::StorytellerIdentify); |
|
ASSERT_GT(CurrentItemIndex, 0); |
|
|
|
SelectItemAtIndex(0); |
|
EXPECT_EQ(ActiveStore, TalkID::NoMoney) |
|
<< "Should get NoMoney when can't afford identification"; |
|
EXPECT_FALSE(MyPlayer->InvList[invIdx]._iIdentified) |
|
<< "Item should remain unidentified"; |
|
} |
|
|
|
// ---- Witch Buy ------------------------------------------------------------ |
|
|
|
TEST_F(StoreTransactionTest, WitchBuy_PinnedItemsRemainAfterPurchase) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_GE(WitchItems.size(), static_cast<size_t>(NumWitchPinnedItems)); |
|
|
|
// Buy the first pinned item (e.g., mana potion). |
|
const int itemPrice = WitchItems[0]._iIvalue; |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 1000); |
|
|
|
const size_t vendorCountBefore = WitchItems.size(); |
|
|
|
OpenVendor(TalkID::WitchBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::WitchBuy); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
// Pinned items (first NumWitchPinnedItems) should NOT be removed. |
|
EXPECT_EQ(WitchItems.size(), vendorCountBefore) |
|
<< "Pinned witch items should remain after purchase (infinite stock)"; |
|
} |
|
|
|
TEST_F(StoreTransactionTest, WitchBuy_NonPinnedItemRemoved) |
|
{ |
|
PopulateVendors(); |
|
|
|
// Skip past the pinned items. We need at least one non-pinned item. |
|
if (WitchItems.size() <= static_cast<size_t>(NumWitchPinnedItems)) { |
|
GTEST_SKIP() << "Not enough non-pinned witch items"; |
|
} |
|
|
|
const int idx = NumWitchPinnedItems; // First non-pinned item. |
|
const int itemPrice = WitchItems[idx]._iIvalue; |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 1000); |
|
|
|
const size_t vendorCountBefore = WitchItems.size(); |
|
|
|
OpenVendor(TalkID::WitchBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::WitchBuy); |
|
|
|
SelectItemAtIndex(idx); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
EXPECT_EQ(WitchItems.size(), vendorCountBefore - 1) |
|
<< "Non-pinned witch item should be removed after purchase"; |
|
} |
|
|
|
// ---- Repair cost calculation (extends existing stores_test.cpp) ----------- |
|
|
|
TEST_F(StoreTransactionTest, RepairCost_MagicItem_ScalesWithDurabilityLoss) |
|
{ |
|
Item item {}; |
|
InitializeItem(item, IDI_BARDSWORD); |
|
item._iMagical = ITEM_QUALITY_MAGIC; |
|
item._iIdentified = true; |
|
item._iMaxDur = 60; |
|
item._iIvalue = 19000; |
|
item._ivalue = 2000; |
|
|
|
// Test a range of durability losses. |
|
for (int dur = 1; dur < item._iMaxDur; dur++) { |
|
item._iDurability = dur; |
|
item._ivalue = 2000; |
|
item._iIvalue = 19000; |
|
CurrentItemIndex = 0; |
|
AddStoreHoldRepair(&item, 0); |
|
|
|
if (CurrentItemIndex > 0) { |
|
const int due = item._iMaxDur - dur; |
|
const int expectedCost = 30 * 19000 * due / (item._iMaxDur * 100 * 2); |
|
if (expectedCost > 0) { |
|
EXPECT_EQ(PlayerItems[0]._iIvalue, expectedCost) |
|
<< "Repair cost mismatch at durability " << dur; |
|
} |
|
} |
|
} |
|
} |
|
|
|
TEST_F(StoreTransactionTest, RepairCost_NormalItem_MinimumOneGold) |
|
{ |
|
Item item {}; |
|
InitializeItem(item, IDI_BARDSWORD); |
|
item._iMagical = ITEM_QUALITY_NORMAL; |
|
item._iIdentified = true; |
|
item._iMaxDur = 20; |
|
item._ivalue = 10; |
|
item._iIvalue = 10; |
|
item._iDurability = 19; // Only 1 durability lost. |
|
|
|
CurrentItemIndex = 0; |
|
AddStoreHoldRepair(&item, 0); |
|
|
|
ASSERT_EQ(CurrentItemIndex, 1); |
|
EXPECT_GE(PlayerItems[0]._iIvalue, 1) |
|
<< "Repair cost should be at least 1 gold for normal items"; |
|
} |
|
|
|
// ---- Gold from stash used in transactions --------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, BuyUsingStashGold) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
const int itemPrice = SmithItems[0]._iIvalue; |
|
ASSERT_GT(itemPrice, 0); |
|
|
|
// Give the player less gold than the price in inventory, make up |
|
// the difference with stash gold. |
|
const int inventoryGold = itemPrice / 3; |
|
const int stashGold = itemPrice - inventoryGold + 500; |
|
StripPlayer(); |
|
SetPlayerGold(inventoryGold); |
|
Stash.gold = stashGold; |
|
|
|
ASSERT_TRUE(PlayerCanAfford(itemPrice)); |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
ConfirmYes(); |
|
|
|
// Total gold (inventory + stash) should have decreased by itemPrice. |
|
const int totalGoldAfter = MyPlayer->_pGold + Stash.gold; |
|
const int expectedTotal = inventoryGold + stashGold - itemPrice; |
|
EXPECT_EQ(totalGoldAfter, expectedTotal) |
|
<< "Total gold (inventory + stash) should decrease by item price"; |
|
} |
|
|
|
// ---- Multiple transactions ------------------------------------------------ |
|
|
|
TEST_F(StoreTransactionTest, SmithBuy_MultipleItemsPurchased) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_GE(SmithItems.size(), 3u); |
|
|
|
const size_t initialCount = SmithItems.size(); |
|
int totalSpent = 0; |
|
|
|
// Buy three items in succession. Strip and re-fund between purchases |
|
// because purchased items occupy inventory slots and gold piles also |
|
// occupy slots — we need room for both the gold and the next item. |
|
for (int purchase = 0; purchase < 3; purchase++) { |
|
ASSERT_FALSE(SmithItems.empty()); |
|
const int price = SmithItems[0]._iIvalue; |
|
|
|
StripPlayer(); |
|
SetPlayerGold(price + 1000); |
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm) |
|
<< "Purchase " << purchase << " should reach Confirm"; |
|
ConfirmYes(); |
|
|
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - price) |
|
<< "Gold should decrease by item price on purchase " << purchase; |
|
totalSpent += price; |
|
} |
|
|
|
EXPECT_EQ(SmithItems.size(), initialCount - 3) |
|
<< "Three items should have been removed from Smith's inventory"; |
|
} |
|
|
|
// ---- Store leaves correct state after ESC --------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, StoreESC_ClosesStore) |
|
{ |
|
OpenVendor(TalkID::Smith); |
|
ASSERT_EQ(ActiveStore, TalkID::Smith); |
|
ASSERT_TRUE(IsPlayerInStore()); |
|
|
|
// Select "Leave" (line 20 for Smith without visual store, or the last option). |
|
SelectMenuLine(20); |
|
|
|
EXPECT_EQ(ActiveStore, TalkID::None) |
|
<< "Leaving the store should set ActiveStore to None"; |
|
EXPECT_FALSE(IsPlayerInStore()); |
|
} |
|
|
|
// ---- Confirm dialog returns to correct sub-store -------------------------- |
|
|
|
TEST_F(StoreTransactionTest, ConfirmNo_ReturnsToItemList) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
const int itemPrice = SmithItems[0]._iIvalue; |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 1000); |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmNo(); |
|
|
|
EXPECT_EQ(ActiveStore, TalkID::SmithBuy) |
|
<< "Declining should return to the buy list"; |
|
} |
|
|
|
// ---- NoMoney returns to correct sub-store on enter ------------------------ |
|
|
|
TEST_F(StoreTransactionTest, NoMoney_ReturnsToItemListOnEnter) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(SmithItems.empty()); |
|
|
|
SetPlayerGold(0); |
|
Stash.gold = 0; |
|
|
|
OpenVendor(TalkID::SmithBuy); |
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::NoMoney); |
|
|
|
// Pressing enter on NoMoney should return to the original item list. |
|
StoreEnter(); |
|
|
|
EXPECT_EQ(ActiveStore, TalkID::SmithBuy) |
|
<< "Entering on NoMoney should return to the buy list"; |
|
} |
|
|
|
// ---- Premium items -------------------------------------------------------- |
|
|
|
TEST_F(StoreTransactionTest, SmithPremiumBuy_ReplacesSlot) |
|
{ |
|
PopulateVendors(); |
|
ASSERT_FALSE(PremiumItems.empty()); |
|
|
|
const int itemPrice = PremiumItems[0]._iIvalue; |
|
StripPlayer(); |
|
SetPlayerGold(itemPrice + 5000); |
|
|
|
const size_t premiumCountBefore = PremiumItems.size(); |
|
const int goldBefore = MyPlayer->_pGold; |
|
|
|
OpenVendor(TalkID::SmithPremiumBuy); |
|
ASSERT_EQ(ActiveStore, TalkID::SmithPremiumBuy); |
|
|
|
SelectItemAtIndex(0); |
|
ASSERT_EQ(ActiveStore, TalkID::Confirm); |
|
|
|
ConfirmYes(); |
|
|
|
// Premium items are _replaced_, not removed. Count should stay the same. |
|
EXPECT_EQ(PremiumItems.size(), premiumCountBefore) |
|
<< "Premium item slot should be replaced, not removed"; |
|
EXPECT_EQ(MyPlayer->_pGold, goldBefore - itemPrice) |
|
<< "Gold should decrease by premium item price"; |
|
} |
|
|
|
} // namespace |
|
} // namespace devilution
|