Files
voxelforge/tests/testEquipment.cpp
T
2026-09-11 18:05:49 +02:00

380 lines
15 KiB
C++

#include "testHarness.h"
#include "vf/Equipment.h"
#include <string>
#include <vector>
using namespace vf;
namespace {
const CraftingRegistry& sharedRegistry() {
static CraftingRegistry registry;
return registry;
}
EnchantSet withEnchant(EnchantKind kind, int level) {
EnchantSet set;
addEnchant(set, kind, level);
return set;
}
Equipment fullDiamond() {
Equipment gear;
gear.equipArmor(makeArmor(Armor::Material::Diamond, Armor::Slot::Helmet));
gear.equipArmor(makeArmor(Armor::Material::Diamond, Armor::Slot::Chestplate));
gear.equipArmor(makeArmor(Armor::Material::Diamond, Armor::Slot::Leggings));
gear.equipArmor(makeArmor(Armor::Material::Diamond, Armor::Slot::Boots));
return gear;
}
float modifierTotal(const EquipmentStats& stats, AttributeModifier::Attribute attribute) {
float total = 0.0f;
for (const AttributeModifier& modifier : stats.modifiers) {
if (modifier.attribute == attribute) total += modifier.amount;
}
return total;
}
CombatStats readyToSwing() {
CombatStats stats;
stats.attackChargeTicks = 200;
return stats;
}
} // namespace
VF_TEST(everyEquipSlotAndAttributeHasAName) {
for (int i = 0; i < static_cast<int>(EquipSlot::Count); ++i) {
CHECK(std::string(equipSlotName(static_cast<EquipSlot>(i))) != std::string("unknown"));
}
for (int i = 0; i < static_cast<int>(AttributeModifier::Attribute::Count); ++i) {
CHECK(std::string(attributeName(static_cast<AttributeModifier::Attribute>(i))) !=
std::string("unknown"));
}
CHECK(isArmorSlot(EquipSlot::Head));
CHECK(isArmorSlot(EquipSlot::Feet));
CHECK(!isArmorSlot(EquipSlot::MainHand));
CHECK(isHandSlot(EquipSlot::OffHand));
CHECK(armorSlotFor(EquipSlot::Legs) == Armor::Slot::Leggings);
CHECK(armorSlotFor(EquipSlot::MainHand) == Armor::Slot::Count);
CHECK(equipSlotFor(Armor::Slot::Boots) == EquipSlot::Feet);
CHECK(equipSlotFor(Armor::Slot::Count) == EquipSlot::Count);
}
VF_TEST(anArmorSlotRefusesAnyItemStackBecauseNoItemIsWearable) {
Equipment gear;
MetaStack helmetShaped = makeStack(ItemId::Stone, 1);
CHECK(!gear.accepts(EquipSlot::Head, helmetShaped));
CHECK(!gear.equip(EquipSlot::Head, helmetShaped));
CHECK(!gear.equip(EquipSlot::Feet, makeTool(ItemId::DiamondSword)));
CHECK(gear.armorPiece(EquipSlot::Head).material == Armor::Material::None);
CHECK(gear.equip(EquipSlot::MainHand, makeTool(ItemId::DiamondSword)));
CHECK(gear.mainHand().item == ItemId::DiamondSword);
CHECK(!gear.equip(EquipSlot::MainHand, MetaStack{}));
CHECK(gear.mainHand().item == ItemId::DiamondSword);
CHECK(gear.armorPiece(EquipSlot::MainHand).material == Armor::Material::None);
CHECK(gear.hand(EquipSlot::Head).empty());
}
VF_TEST(aPieceOfArmorOnlyGoesOnTheSlotItWasMadeFor) {
Equipment gear;
Armor helmet = makeArmor(Armor::Material::Iron, Armor::Slot::Helmet);
CHECK(!gear.equipArmorAt(EquipSlot::Chest, helmet));
CHECK(!gear.equipArmorAt(EquipSlot::MainHand, helmet));
CHECK(gear.equipArmorAt(EquipSlot::Head, helmet));
CHECK(gear.armorPiece(EquipSlot::Head).material == Armor::Material::Iron);
CHECK_EQ(gear.wornPieces(), 1);
CHECK(!gear.empty());
CHECK(!gear.equipArmor(Armor{}));
Armor removed = gear.unequipArmor(EquipSlot::Head);
CHECK(removed.material == Armor::Material::Iron);
CHECK_EQ(gear.wornPieces(), 0);
CHECK(gear.empty());
CHECK(gear.unequipArmor(EquipSlot::OffHand).material == Armor::Material::None);
}
VF_TEST(statsSumArmorPointsAndToughnessAcrossTheWholeSet) {
Equipment gear = fullDiamond();
EquipmentStats stats = gear.stats();
CHECK_EQ(stats.armorPoints, gear.armor().totalPoints());
CHECK_EQ(stats.armorPoints, 20);
CHECK_NEAR(stats.armorToughness, 8.0f, 0.0001f);
CHECK_NEAR(modifierTotal(stats, AttributeModifier::Attribute::ArmorPoints), 20.0f, 0.0001f);
CHECK_NEAR(modifierTotal(stats, AttributeModifier::Attribute::ArmorToughness), 8.0f, 0.0001f);
Equipment bare;
EquipmentStats naked = bare.stats();
CHECK_EQ(naked.armorPoints, 0);
CHECK_NEAR(naked.armorToughness, 0.0f, 0.0001f);
CHECK(naked.modifiers.empty());
}
VF_TEST(equipmentStatsFeedTheCombatPipelineDamageAlreadyUnderstands) {
CombatStats bare;
CombatStats armored;
fullDiamond().stats().applyTo(armored);
DamageEvent blow;
blow.source = meleeSource(1, Vec3{0.0f, 0.0f, 0.0f}, false);
blow.amount = 12.0f;
EffectSet none;
uint32_t rngBare = 42u;
uint32_t rngArmored = 42u;
CombatResult unprotected = applyDamage(bare, none, blow, rngBare);
CombatResult protectedHit = applyDamage(armored, none, blow, rngArmored);
CHECK(unprotected.applied);
CHECK(protectedHit.applied);
CHECK(protectedHit.healthLost < unprotected.healthLost);
CHECK(protectedHit.armorDurabilityLost > 0);
CHECK_EQ(unprotected.armorDurabilityLost, 0);
CHECK(armored.armor.totalPoints() > 0);
}
VF_TEST(theHeldToolDecidesAttackDamageSpeedAndReach) {
CHECK_NEAR(weaponAttackDamage(ItemId::DiamondSword), 7.0f, 0.0001f);
CHECK_NEAR(weaponAttackDamage(ItemId::WoodSword), 4.0f, 0.0001f);
CHECK_NEAR(weaponAttackDamage(ItemId::DiamondAxe), 9.0f, 0.0001f);
CHECK_NEAR(weaponAttackDamage(ItemId::Stone), unarmedAttackDamage, 0.0001f);
CHECK_NEAR(weaponAttackSpeed(ItemId::DiamondSword), 1.6f, 0.0001f);
CHECK(weaponAttackSpeed(ItemId::DiamondAxe) < weaponAttackSpeed(ItemId::DiamondSword));
CHECK(weaponAttackReach(ItemId::DiamondSword) > weaponAttackReach(ItemId::DiamondPickaxe));
CHECK_NEAR(weaponAttackReach(ItemId::Stone), unarmedAttackReach, 0.0001f);
Equipment gear;
gear.equip(EquipSlot::MainHand, makeTool(ItemId::DiamondSword));
EquipmentStats stats = gear.stats();
CHECK_NEAR(stats.attackDamage, 7.0f, 0.0001f);
CHECK_NEAR(stats.attackSpeed, 1.6f, 0.0001f);
CHECK_NEAR(stats.attackReach, unarmedAttackReach + 0.5f, 0.0001f);
CHECK_NEAR(modifierTotal(stats, AttributeModifier::Attribute::AttackDamage), 6.0f, 0.0001f);
CombatStats combat;
stats.applyTo(combat);
CHECK_NEAR(combat.baseAttackDamage, 7.0f, 0.0001f);
CHECK_NEAR(combat.attackSpeed, 1.6f, 0.0001f);
}
VF_TEST(sharpnessReachesCombatThroughTheEnchantSetAndNotThroughAttackDamage) {
Equipment plain;
plain.equip(EquipSlot::MainHand, makeTool(ItemId::DiamondSword));
Equipment sharp;
sharp.equip(EquipSlot::MainHand,
makeEnchantedTool(ItemId::DiamondSword, withEnchant(EnchantKind::Sharpness, 5)));
// The item value is identical; only the enchantment set differs.
CHECK_NEAR(plain.stats().attackDamage, sharp.stats().attackDamage, 0.0001f);
CHECK(plain.stats().weaponEnchants.empty());
CHECK_EQ(sharp.stats().weaponEnchants.levelOf(EnchantKind::Sharpness), 5);
EffectSet none;
CombatStats attacker = readyToSwing();
float plainSwing = equippedMeleeDamage(plain, attacker, none, false, false, false);
float sharpSwing = equippedMeleeDamage(sharp, attacker, none, false, false, false);
CHECK(sharpSwing > plainSwing);
CHECK_NEAR(sharpSwing - plainSwing, 3.0f, 0.05f);
Equipment unarmed;
CHECK(equippedMeleeDamage(unarmed, attacker, none, false, false, false) < plainSwing);
}
VF_TEST(efficiencyRaisesMiningSpeedWhileTheCombatNumbersStayPut) {
Equipment plain;
plain.equip(EquipSlot::MainHand, makeTool(ItemId::DiamondPickaxe));
Equipment quick;
quick.equip(EquipSlot::MainHand,
makeEnchantedTool(ItemId::DiamondPickaxe, withEnchant(EnchantKind::Efficiency, 4)));
EquipmentStats plainStats = plain.stats();
EquipmentStats quickStats = quick.stats();
CHECK_NEAR(plainStats.miningSpeed, toolTierSpeedMultiplier(ToolTier::Diamond), 0.0001f);
CHECK_NEAR(quickStats.miningSpeed, plainStats.miningSpeed + 17.0f, 0.0001f);
CHECK_NEAR(quickStats.attackDamage, plainStats.attackDamage, 0.0001f);
CHECK(modifierTotal(quickStats, AttributeModifier::Attribute::MiningSpeed) >
modifierTotal(plainStats, AttributeModifier::Attribute::MiningSpeed));
}
VF_TEST(armorWeightCostsMovementSpeedInProportionToItsPoints) {
Equipment bare;
CHECK_NEAR(bare.stats().movementSpeed, 1.0f, 0.0001f);
Equipment leather;
leather.equipArmor(makeArmor(Armor::Material::Leather, Armor::Slot::Helmet));
Equipment diamond = fullDiamond();
CHECK(leather.stats().movementSpeed < 1.0f);
CHECK(diamond.stats().movementSpeed < leather.stats().movementSpeed);
CHECK_NEAR(diamond.stats().movementSpeed, 1.0f - 0.20f, 0.0001f);
CHECK_NEAR(modifierTotal(diamond.stats(), AttributeModifier::Attribute::MovementSpeed), -0.20f,
0.0001f);
CHECK_NEAR(armorWeightPenalty(0), 0.0f, 0.0001f);
CHECK_NEAR(armorWeightPenalty(100), 0.35f, 0.0001f);
}
VF_TEST(armorWearsDownWhenHitsLandAndAPieceVanishesAtZero) {
Equipment gear;
Armor helmet = makeArmor(Armor::Material::Leather, Armor::Slot::Helmet);
gear.equipArmor(helmet);
int startingDurability = gear.armorPiece(EquipSlot::Head).durability;
CHECK(startingDurability > 0);
uint32_t rng = 8u;
int spent = gear.wearFromHit(8.0f, rng);
CHECK(spent > 0);
CHECK_EQ(gear.armorPiece(EquipSlot::Head).durability, startingDurability - spent);
int guard = 0;
while (gear.wornPieces() > 0 && guard < 5000) {
gear.wearFromHit(20.0f, rng);
++guard;
}
CHECK(guard < 5000);
CHECK_EQ(gear.wornPieces(), 0);
CHECK(gear.armorPiece(EquipSlot::Head).material == Armor::Material::None);
CHECK_EQ(gear.stats().armorPoints, 0);
}
VF_TEST(unbreakingArmorSurvivesNoticeablyMoreHits) {
uint32_t plainRng = 31u;
Equipment plain;
plain.equipArmor(makeArmor(Armor::Material::Iron, Armor::Slot::Chestplate));
int plainHits = 0;
while (plain.wornPieces() > 0 && plainHits < 20000) {
plain.wearFromHit(4.0f, plainRng);
++plainHits;
}
uint32_t toughRng = 31u;
Equipment tough;
Armor reinforced = makeArmor(Armor::Material::Iron, Armor::Slot::Chestplate);
addEnchant(reinforced.enchants, EnchantKind::Unbreaking, 3);
tough.equipArmor(reinforced);
int toughHits = 0;
while (tough.wornPieces() > 0 && toughHits < 20000) {
tough.wearFromHit(4.0f, toughRng);
++toughHits;
}
CHECK(plainHits > 0);
CHECK(toughHits > plainHits * 2);
CHECK(toughHits < 20000);
}
VF_TEST(theHeldToolBreaksAtZeroUnlessItIsUnbreakable) {
uint32_t rng = 17u;
Equipment gear;
gear.equip(EquipSlot::MainHand, makeTool(ItemId::WoodShovel));
int max = gear.mainHand().meta.durability.max;
for (int hit = 1; hit < max; ++hit) {
CHECK(gear.useHeld(EquipSlot::MainHand, 1, rng) == WearResult::Damaged);
}
CHECK(gear.useHeld(EquipSlot::MainHand, 1, rng) == WearResult::Broke);
CHECK(gear.mainHand().empty());
MetaStack indestructible = makeTool(ItemId::WoodShovel);
indestructible.meta.unbreakable = true;
gear.equip(EquipSlot::OffHand, indestructible);
CHECK(gear.useHeld(EquipSlot::OffHand, 5000, rng) == WearResult::Absorbed);
CHECK(!gear.offHand().empty());
CHECK(gear.useHeld(EquipSlot::Head, 1, rng) == WearResult::NotDamageable);
}
VF_TEST(experiencePickedUpRepairsMendingGearBeforeTheHeldTool) {
Equipment gear;
Armor boots = makeArmor(Armor::Material::Iron, Armor::Slot::Boots);
addEnchant(boots.enchants, EnchantKind::Mending, 1);
boots.durability = boots.maxDurability - 30;
gear.equipArmor(boots);
MetaStack pickaxe = makeEnchantedTool(ItemId::IronPickaxe, withEnchant(EnchantKind::Mending, 1));
pickaxe.meta.durability.used = 100;
gear.equip(EquipSlot::MainHand, pickaxe);
int repaired = gear.absorbExperience(10);
CHECK_EQ(repaired, 20);
CHECK_EQ(gear.armorPiece(EquipSlot::Feet).durability, boots.maxDurability - 10);
CHECK_EQ(gear.mainHand().meta.durability.used, 100);
int spilled = gear.absorbExperience(100);
CHECK_EQ(gear.armorPiece(EquipSlot::Feet).durability, boots.maxDurability);
CHECK(gear.mainHand().meta.durability.used < 100);
CHECK(spilled > 10);
Equipment plain;
plain.equipArmor(makeArmor(Armor::Material::Iron, Armor::Slot::Boots));
CHECK_EQ(plain.absorbExperience(50), 0);
CHECK_EQ(plain.absorbExperience(0), 0);
}
VF_TEST(theHandsRoundTripThroughAnOpenMenu) {
ContainerMenu menu(MenuKind::PlayerInventory, &sharedRegistry());
const MenuLayout& layout = menu.layout();
MetaStack sword = makeEnchantedTool(ItemId::IronSword, withEnchant(EnchantKind::FireAspect, 2));
sword.meta.customName = "Emberfang";
menu.slot(layout.hotbarFirst + 4).cell.stack = sword;
menu.slot(layout.offHandSlot).cell.stack = makeStack(ItemId::Bread, 5);
Equipment gear;
loadHandsFromMenu(gear, menu, 4);
CHECK(stacksIdentical(gear.mainHand(), sword));
CHECK_EQ(gear.offHand().count, 5);
CHECK_EQ(gear.stats().weaponEnchants.levelOf(EnchantKind::FireAspect), 2);
uint32_t rng = 4u;
gear.useHeld(EquipSlot::MainHand, 3, rng);
storeHandsIntoMenu(gear, menu, 4);
CHECK_EQ(menu.stackAt(layout.hotbarFirst + 4).meta.durability.used, 3);
CHECK(menu.stackAt(layout.hotbarFirst + 4).meta.customName == std::string("Emberfang"));
// A chest screen has no off-hand slot, and loading from it leaves that hand
// exactly as it was.
ContainerMenu chest(MenuKind::Chest, &sharedRegistry());
chest.slot(chest.layout().hotbarFirst).cell.stack = makeStack(ItemId::Stone, 2);
loadHandsFromMenu(gear, chest, 0);
CHECK(gear.mainHand().item == ItemId::Stone);
CHECK_EQ(gear.offHand().count, 5);
loadHandsFromMenu(gear, chest, 99);
CHECK(gear.mainHand().item == ItemId::Stone);
}
VF_TEST(theModifierListAddsUpToTheStatsItIsDescribing) {
Equipment gear = fullDiamond();
gear.equip(EquipSlot::MainHand,
makeEnchantedTool(ItemId::IronAxe, withEnchant(EnchantKind::Efficiency, 2)));
EquipmentStats stats = gear.stats();
CHECK_NEAR(unarmedAttackDamage +
modifierTotal(stats, AttributeModifier::Attribute::AttackDamage),
stats.attackDamage, 0.0001f);
CHECK_NEAR(unarmedAttackSpeed + modifierTotal(stats, AttributeModifier::Attribute::AttackSpeed),
stats.attackSpeed, 0.0001f);
CHECK_NEAR(unarmedAttackReach + modifierTotal(stats, AttributeModifier::Attribute::AttackReach),
stats.attackReach, 0.0001f);
CHECK_NEAR(1.0f + modifierTotal(stats, AttributeModifier::Attribute::MiningSpeed),
stats.miningSpeed, 0.0001f);
CHECK_NEAR(1.0f + modifierTotal(stats, AttributeModifier::Attribute::MovementSpeed),
stats.movementSpeed, 0.0001f);
CHECK_NEAR(modifierTotal(stats, AttributeModifier::Attribute::ArmorPoints),
static_cast<float>(stats.armorPoints), 0.0001f);
int handModifiers = 0;
for (const AttributeModifier& modifier : stats.modifiers) {
if (modifier.slot == EquipSlot::MainHand) ++handModifiers;
}
CHECK(handModifiers >= 4);
}