diff --git a/README.md b/README.md index 89c8373..d66c2e7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Delveworn currently includes: - Weapon and armor upgrades - Supply stops and camps - Boss encounters +- One random relic drop after every boss +- Relic collection, duplicate counters and between-room relic switching - Scaling enemy stats - Randomness-backed gameplay resolution @@ -54,6 +56,13 @@ Randomness is used for: - Normal attacks - Storm attacks - Potion-related combat outcomes +- Boss relic drops and rarity + +The current frontend integration uses `frontendSnapshotV3()` together with +`claimRelic(bool)` and `equipOwnedRelic(Relic)`. Deploy this contract version +before enabling the collection UI in production; older deployments remain +readable through the frontend's compatibility fallback but do not provide the +new relic progression. ### `VRFProbe.sol` diff --git a/src/Delveworn.sol b/src/Delveworn.sol index 18a7ab0..8bb6e1b 100644 --- a/src/Delveworn.sol +++ b/src/Delveworn.sol @@ -148,6 +148,15 @@ contract Delveworn is IVRFConsumer { bool relicReviveUsed; } + struct FrontendSnapshotV3 { + FrontendSnapshot base; + Relic relicOffer; + uint16 ownedRelicsMask; + uint16[15] relicCounts; + uint256 baseMaxHp; + uint256 stormMax; + } + /* ======================================================== BALANCE @@ -189,9 +198,10 @@ contract Delveworn is IVRFConsumer { /* Relics V2 - Relics are run-only and occupy one slot. After room 5 the killing - VRF callback rolls one rarity tier without an extra randomness - request, then offers all three relics in that tier. + Relics are run-only and occupy one active slot. Every defeated boss + drops one randomly selected relic. The player always owns the drop, + including duplicates, and decides whether to equip it or keep the + currently active relic. Rarity odds: Common 55%, Uncommon 25%, Rare 12%, Epic 6%, Legendary 2%. @@ -201,7 +211,6 @@ contract Delveworn is IVRFConsumer { progressively more run-defining while preserving explicit tradeoffs. */ uint256 public constant BASE_MAX_HP = 100; - uint256 public constant RELIC_OFFER_ROOM = 5; uint256 public constant RELIC_MIN_MAX_HP = 20; uint256 public constant BASE_CRITICAL_CHANCE = 15; @@ -236,6 +245,10 @@ contract Delveworn is IVRFConsumer { mapping(address => Relic) public equippedRelic; mapping(address => bool) public relicOfferAvailable; mapping(address => RelicRarity) public relicOfferRarity; + mapping(address => Relic) public relicOfferId; + mapping(address => uint16) public ownedRelicsMask; + mapping(address => uint16[15]) internal relicDropCounts; + mapping(address => uint256) public playerBaseMaxHp; mapping(address => uint256) public playerMaxHp; mapping(address => bool) public relicReviveUsed; @@ -280,6 +293,12 @@ contract Delveworn is IVRFConsumer { event RelicOfferRolled(address indexed player, RelicRarity rarity); + event RelicDropped(address indexed player, Relic relic, RelicRarity rarity, uint256 copyCount); + + event RelicClaimed(address indexed player, Relic relic, uint256 copyCount, bool equipped); + + event RelicEquipped(address indexed player, Relic previousRelic, Relic relic, uint256 maxHp); + event RelicChosen(address indexed player, Relic relic, RelicRarity rarity, uint256 maxHp); event BloodPricePaid(address indexed player, uint256 newMaxHp, uint256 currentHp); @@ -353,6 +372,10 @@ contract Delveworn is IVRFConsumer { equippedRelic[msg.sender] = Relic.None; relicOfferAvailable[msg.sender] = false; relicOfferRarity[msg.sender] = RelicRarity.None; + relicOfferId[msg.sender] = Relic.None; + ownedRelicsMask[msg.sender] = 0; + delete relicDropCounts[msg.sender]; + playerBaseMaxHp[msg.sender] = BASE_MAX_HP; playerMaxHp[msg.sender] = BASE_MAX_HP; relicReviveUsed[msg.sender] = false; @@ -397,43 +420,33 @@ contract Delveworn is IVRFConsumer { return RelicRarity(RelicRules.rollRarity(entropy)); } - function chooseRelic(Relic relic) external noPending(msg.sender) { - Player storage player = players[msg.sender]; - - require(player.active, "Game is not active"); - require(player.monsterHp == 0, "Choose relic between rooms"); - require(relicOfferAvailable[msg.sender], "No relic offer"); - require(equippedRelic[msg.sender] == Relic.None, "Relic slot occupied"); - require(relic != Relic.None && uint256(relic) <= uint256(Relic.Worldbreaker), "Invalid relic"); - - RelicRarity rarity = relicRarityOf(relic); - require(rarity == relicOfferRarity[msg.sender], "Relic not in offer"); + function ownsRelic(address playerAddress, Relic relic) public view returns (bool) { + if (relic == Relic.None || uint256(relic) > uint256(Relic.Worldbreaker)) return false; + uint16 bit = uint16(1) << (uint8(relic) - 1); + return ownedRelicsMask[playerAddress] & bit != 0; + } - equippedRelic[msg.sender] = relic; - relicOfferAvailable[msg.sender] = false; + function chooseRelic(Relic relic) external noPending(msg.sender) { + require(relic == relicOfferId[msg.sender], "Relic not in offer"); + _claimRelic(msg.sender, true); + } - uint256 currentMaxHp = _maxHp(msg.sender); - uint256 bonus = RelicRules.maxHpBonus(uint8(relic)); - uint256 penalty = RelicRules.maxHpPenalty(uint8(relic)); - uint256 newMaxHp = currentMaxHp + bonus; + function claimRelic(bool equip) external noPending(msg.sender) { + _claimRelic(msg.sender, equip); + } - if (penalty >= newMaxHp - RELIC_MIN_MAX_HP) { - newMaxHp = RELIC_MIN_MAX_HP; - } else { - newMaxHp -= penalty; - } + function equipOwnedRelic(Relic relic) external noPending(msg.sender) { + Player storage player = players[msg.sender]; - if (newMaxHp != currentMaxHp) { - playerMaxHp[msg.sender] = newMaxHp; - if (bonus > 0) { - uint256 newHp = player.hp + bonus; - player.hp = newHp > newMaxHp ? newMaxHp : newHp; - } else if (player.hp > newMaxHp) { - player.hp = newMaxHp; - } - } + require(player.active, "Game is not active"); + require(player.monsterHp == 0, "Equip relic between rooms"); + require(!relicOfferAvailable[msg.sender], "Claim boss relic first"); + require( + relic == Relic.None || (uint256(relic) <= uint256(Relic.Worldbreaker) && ownsRelic(msg.sender, relic)), + "Relic not owned" + ); - emit RelicChosen(msg.sender, relic, rarity, _maxHp(msg.sender)); + _equipRelic(msg.sender, relic, false); } function maxHp(address playerAddress) external view returns (uint256) { @@ -503,6 +516,7 @@ contract Delveworn is IVRFConsumer { require(player.active, "Game over"); require(player.monsterHp == 0, "Defeat monster first"); + require(!relicOfferAvailable[msg.sender], "Claim boss relic first"); _applyRoomEntryRelic(msg.sender, player); _requestRandomness(msg.sender, RequestKind.Monster, 1); @@ -726,6 +740,16 @@ contract Delveworn is IVRFConsumer { return _frontendSnapshot(playerAddress, player); } + function frontendSnapshotV3(address playerAddress) external view returns (FrontendSnapshotV3 memory snapshot) { + Player storage player = players[playerAddress]; + snapshot.base = _frontendSnapshot(playerAddress, player); + snapshot.relicOffer = relicOfferId[playerAddress]; + snapshot.ownedRelicsMask = ownedRelicsMask[playerAddress]; + snapshot.relicCounts = relicDropCounts[playerAddress]; + snapshot.baseMaxHp = _baseMaxHp(playerAddress); + snapshot.stormMax = _applyOutgoingRelicDamage(playerAddress, _playerBaseDamage(player) * 2, true); + } + function playerAttackDamage(address playerAddress) external view returns (uint256) { uint256 baseDamage = _playerBaseDamage(players[playerAddress]); return _applyOutgoingRelicDamage(playerAddress, baseDamage, false); @@ -1141,11 +1165,17 @@ contract Delveworn is IVRFConsumer { _grantLoot(playerAddress, player, lootRoll, amountRoll); _applyKillRelic(playerAddress, player); - if (player.roomsCleared == RELIC_OFFER_ROOM && equippedRelic[playerAddress] == Relic.None) { - RelicRarity rarity = RelicRarity(RelicRules.rollRarity(amountRoll)); + if (player.monsterType == MonsterType.DungeonLord) { + uint256 bossTier = room / 10; + RelicRarity rarity = RelicRarity(RelicRules.rollRarityForBossTier(amountRoll, bossTier)); + Relic relic = Relic(RelicRules.rollRelic(uint8(rarity), amountRoll / 10_000)); relicOfferRarity[playerAddress] = rarity; + relicOfferId[playerAddress] = relic; relicOfferAvailable[playerAddress] = true; emit RelicOfferRolled(playerAddress, rarity); + emit RelicDropped( + playerAddress, relic, rarity, uint256(relicDropCounts[playerAddress][uint8(relic) - 1]) + 1 + ); emit RelicOffered(playerAddress, player.roomsCleared); } @@ -1175,7 +1205,8 @@ contract Delveworn is IVRFConsumer { Player storage player = players[playerAddress]; return player.hasStarted && player.active && player.monsterHp == 0 && player.roomsCleared >= 5 - && (player.roomsCleared % 5) == 0 && pendingRequestId[playerAddress] == 0; + && (player.roomsCleared % 5) == 0 && pendingRequestId[playerAddress] == 0 + && !relicOfferAvailable[playerAddress]; } /* @@ -1256,6 +1287,67 @@ contract Delveworn is IVRFConsumer { ======================================================== */ + function _claimRelic(address playerAddress, bool equip) internal { + Player storage player = players[playerAddress]; + + require(player.active, "Game is not active"); + require(player.monsterHp == 0, "Claim relic between rooms"); + require(relicOfferAvailable[playerAddress], "No relic offer"); + + Relic relic = relicOfferId[playerAddress]; + require(relic != Relic.None && uint256(relic) <= uint256(Relic.Worldbreaker), "Invalid relic offer"); + require(relicRarityOf(relic) == relicOfferRarity[playerAddress], "Relic offer mismatch"); + + bool alreadyOwned = ownsRelic(playerAddress, relic); + uint8 relicIndex = uint8(relic) - 1; + uint16 newCount = relicDropCounts[playerAddress][relicIndex] + 1; + relicDropCounts[playerAddress][relicIndex] = newCount; + + if (!alreadyOwned) { + ownedRelicsMask[playerAddress] |= uint16(1) << relicIndex; + } + + relicOfferAvailable[playerAddress] = false; + relicOfferRarity[playerAddress] = RelicRarity.None; + relicOfferId[playerAddress] = Relic.None; + + if (equip) { + _equipRelic(playerAddress, relic, !alreadyOwned); + emit RelicChosen(playerAddress, relic, relicRarityOf(relic), _maxHp(playerAddress)); + } + + emit RelicClaimed(playerAddress, relic, newCount, equip); + } + + function _equipRelic(address playerAddress, Relic relic, bool healPositiveModifier) internal { + Player storage player = players[playerAddress]; + Relic previousRelic = equippedRelic[playerAddress]; + uint256 newMaxHp = _maxHpForRelic(_baseMaxHp(playerAddress), relic); + uint256 bonusHealing = healPositiveModifier ? RelicRules.maxHpBonus(uint8(relic)) : 0; + + equippedRelic[playerAddress] = relic; + playerMaxHp[playerAddress] = newMaxHp; + + uint256 newHp = player.hp + bonusHealing; + player.hp = newHp > newMaxHp ? newMaxHp : newHp; + + emit RelicEquipped(playerAddress, previousRelic, relic, newMaxHp); + } + + function _baseMaxHp(address playerAddress) internal view returns (uint256) { + uint256 configuredBaseMaxHp = playerBaseMaxHp[playerAddress]; + return configuredBaseMaxHp == 0 ? BASE_MAX_HP : configuredBaseMaxHp; + } + + function _maxHpForRelic(uint256 baseMaxHp, Relic relic) internal pure returns (uint256) { + uint256 bonus = RelicRules.maxHpBonus(uint8(relic)); + uint256 penalty = RelicRules.maxHpPenalty(uint8(relic)); + uint256 modifiedMaxHp = baseMaxHp + bonus; + + if (penalty >= modifiedMaxHp - RELIC_MIN_MAX_HP) return RELIC_MIN_MAX_HP; + return modifiedMaxHp - penalty; + } + function _maxHp(address playerAddress) internal view returns (uint256) { uint256 configuredMaxHp = playerMaxHp[playerAddress]; return configuredMaxHp == 0 ? BASE_MAX_HP : configuredMaxHp; @@ -1277,11 +1369,14 @@ contract Delveworn is IVRFConsumer { uint256 hpLoss = RelicRules.roomMaxHpLoss(uint8(equippedRelic[playerAddress])); if (hpLoss == 0) return; - uint256 currentMaxHp = _maxHp(playerAddress); - if (currentMaxHp <= RELIC_MIN_MAX_HP) return; + uint256 currentBaseMaxHp = _baseMaxHp(playerAddress); + if (currentBaseMaxHp <= RELIC_MIN_MAX_HP) return; - uint256 newMaxHp = currentMaxHp <= RELIC_MIN_MAX_HP + hpLoss ? RELIC_MIN_MAX_HP : currentMaxHp - hpLoss; + uint256 newBaseMaxHp = + currentBaseMaxHp <= RELIC_MIN_MAX_HP + hpLoss ? RELIC_MIN_MAX_HP : currentBaseMaxHp - hpLoss; + uint256 newMaxHp = _maxHpForRelic(newBaseMaxHp, equippedRelic[playerAddress]); + playerBaseMaxHp[playerAddress] = newBaseMaxHp; playerMaxHp[playerAddress] = newMaxHp; if (player.hp > newMaxHp) player.hp = newMaxHp; diff --git a/src/RelicRules.sol b/src/RelicRules.sol index 916d51c..502a638 100644 --- a/src/RelicRules.sol +++ b/src/RelicRules.sol @@ -14,6 +14,12 @@ library RelicRules { uint16 internal constant EPIC_WEIGHT_BPS = 600; uint16 internal constant LEGENDARY_WEIGHT_BPS = 200; + uint16 internal constant LATE_COMMON_WEIGHT_BPS = 5_000; + uint16 internal constant LATE_UNCOMMON_WEIGHT_BPS = 2_500; + uint16 internal constant LATE_RARE_WEIGHT_BPS = 1_400; + uint16 internal constant LATE_EPIC_WEIGHT_BPS = 800; + uint16 internal constant LATE_LEGENDARY_WEIGHT_BPS = 300; + error InvalidRelic(); error InvalidRarity(); @@ -47,6 +53,34 @@ library RelicRules { return 5; } + function rarityWeightBpsAfterTierFour(uint8 rarity) internal pure returns (uint16) { + if (rarity == 1) return LATE_COMMON_WEIGHT_BPS; + if (rarity == 2) return LATE_UNCOMMON_WEIGHT_BPS; + if (rarity == 3) return LATE_RARE_WEIGHT_BPS; + if (rarity == 4) return LATE_EPIC_WEIGHT_BPS; + if (rarity == 5) return LATE_LEGENDARY_WEIGHT_BPS; + revert InvalidRarity(); + } + + function rollRarityAfterTierFour(uint256 entropy) internal pure returns (uint8) { + uint256 roll = entropy % 10_000; + if (roll < 5_000) return 1; + if (roll < 7_500) return 2; + if (roll < 8_900) return 3; + if (roll < 9_700) return 4; + return 5; + } + + function rollRarityForBossTier(uint256 entropy, uint256 bossTier) internal pure returns (uint8) { + return bossTier > 4 ? rollRarityAfterTierFour(entropy) : rollRarity(entropy); + } + + function rollRelic(uint8 rarity, uint256 entropy) internal pure returns (uint8) { + if (rarity == 0 || rarity > RARITY_COUNT) revert InvalidRarity(); + uint8 first = ((rarity - 1) * 3) + 1; + return first + uint8(entropy % 3); + } + /// @dev Percent of normal outgoing damage after the relic modifier. function outgoingDamagePercent(uint8 relic, bool storm) internal pure returns (uint16) { if (relic == 0) return 100; diff --git a/test/BalanceBaseline.t.sol b/test/BalanceBaseline.t.sol index 4091d20..23c3b81 100644 --- a/test/BalanceBaseline.t.sol +++ b/test/BalanceBaseline.t.sol @@ -126,6 +126,11 @@ contract BalanceBaselineTest is Test { continue; } + if (dungeon.relicOfferAvailable(playerAddress)) { + vm.prank(playerAddress); + dungeon.claimRelic(false); + } + _useSupplyStop(playerAddress); _useCamp(playerAddress); _useBetweenRoomPotion(playerAddress); diff --git a/test/Delveworn.t.sol b/test/Delveworn.t.sol index 87f10a2..fb1ac7f 100644 --- a/test/Delveworn.t.sol +++ b/test/Delveworn.t.sol @@ -1055,6 +1055,12 @@ contract DelvewornTest is Test { assertEq(afterBoss.roomsCleared, 10); + assertTrue(dungeon.relicOfferAvailable(player)); + assertFalse(dungeon.supplyAvailable(player)); + + vm.prank(player); + dungeon.claimRelic(false); + assertTrue(dungeon.supplyAvailable(player)); } diff --git a/test/RelicBalance.t.sol b/test/RelicBalance.t.sol index a413bc9..98878b9 100644 --- a/test/RelicBalance.t.sol +++ b/test/RelicBalance.t.sol @@ -5,13 +5,15 @@ import {Test, console2} from "forge-std/Test.sol"; import {Delveworn} from "../src/Delveworn.sol"; import {DevRandomnessAdapter} from "../src/adapters/DevRandomnessAdapter.sol"; -/// @dev Test-only subclass used to keep the legacy Common balance comparison -/// pinned to Common without changing any production offer rules or VRF words. +/// @dev Test-only subclass used to pin the first boss drop to a Common relic +/// without changing production offer rules or VRF words. contract RelicBalanceDungeon is Delveworn { constructor(address coordinatorAddress) Delveworn(coordinatorAddress) {} - function forceRelicOfferRarity(address playerAddress, RelicRarity rarity) external { - relicOfferRarity[playerAddress] = rarity; + function forceRelicOffer(address playerAddress, Relic relic) external { + relicOfferId[playerAddress] = relic; + relicOfferRarity[playerAddress] = relicRarityOf(relic); + relicOfferAvailable[playerAddress] = true; } } @@ -135,13 +137,15 @@ contract RelicBalanceTest is Test { continue; } - if ( - dungeon.relicOfferAvailable(playerAddress) - && dungeon.equippedRelic(playerAddress) == Delveworn.Relic.None - ) { - dungeon.forceRelicOfferRarity(playerAddress, Delveworn.RelicRarity.Common); - vm.prank(playerAddress); - dungeon.chooseRelic(relic); + if (dungeon.relicOfferAvailable(playerAddress)) { + if (dungeon.equippedRelic(playerAddress) == Delveworn.Relic.None) { + dungeon.forceRelicOffer(playerAddress, relic); + vm.prank(playerAddress); + dungeon.chooseRelic(relic); + } else { + vm.prank(playerAddress); + dungeon.claimRelic(false); + } } _useSupplyStop(playerAddress); diff --git a/test/RelicRules.t.sol b/test/RelicRules.t.sol index da3aa8c..373d47e 100644 --- a/test/RelicRules.t.sol +++ b/test/RelicRules.t.sol @@ -21,6 +21,18 @@ contract RelicRulesHarness { return RelicRules.rollRarity(entropy); } + function lateWeight(uint8 rarity) external pure returns (uint16) { + return RelicRules.rarityWeightBpsAfterTierFour(rarity); + } + + function rollForBossTier(uint256 entropy, uint256 bossTier) external pure returns (uint8) { + return RelicRules.rollRarityForBossTier(entropy, bossTier); + } + + function rollRelic(uint8 rarity, uint256 entropy) external pure returns (uint8) { + return RelicRules.rollRelic(rarity, entropy); + } + function outgoing(uint8 relic, uint256 damage, bool storm) external pure returns (uint256) { return RelicRules.scaleOutgoing(relic, damage, storm); } @@ -102,6 +114,29 @@ contract RelicRulesTest is Test { assertEq(rules.roll(9_999), 5); } + function testTierFiveAndLaterSlightlyIncreaseRareDrops() public view { + uint256 total; + for (uint8 rarity = 1; rarity <= 5; rarity++) { + total += rules.lateWeight(rarity); + } + + assertEq(total, 10_000); + assertEq(rules.lateWeight(1), 5_000); + assertEq(rules.lateWeight(2), 2_500); + assertEq(rules.lateWeight(3), 1_400); + assertEq(rules.lateWeight(4), 800); + assertEq(rules.lateWeight(5), 300); + assertEq(rules.rollForBossTier(9_700, 4), 4); + assertEq(rules.rollForBossTier(9_700, 5), 5); + } + + function testOneRelicIsRolledInsideTheSelectedRarity() public view { + assertEq(rules.rollRelic(1, 0), 1); + assertEq(rules.rollRelic(1, 1), 2); + assertEq(rules.rollRelic(1, 2), 3); + assertEq(rules.rollRelic(5, 2), 15); + } + function testCommonCalibrationIsPreserved() public view { assertEq(rules.outgoing(1, 100, false), 110); // Blood Price assertEq(rules.outgoing(2, 100, false), 95); // Iron Shell diff --git a/test/RelicsV1.t.sol b/test/RelicsV1.t.sol index be27110..6ac8fa9 100644 --- a/test/RelicsV1.t.sol +++ b/test/RelicsV1.t.sol @@ -5,46 +5,57 @@ import {Test} from "forge-std/Test.sol"; import {Delveworn} from "../src/Delveworn.sol"; import {MockVRFCoordinator} from "../src/MockVRFCoordinator.sol"; +contract RelicsV1Dungeon is Delveworn { + constructor(address coordinatorAddress) Delveworn(coordinatorAddress) {} + + function forceOffer(address playerAddress, Relic relic) external { + relicOfferId[playerAddress] = relic; + relicOfferRarity[playerAddress] = relicRarityOf(relic); + relicOfferAvailable[playerAddress] = true; + } +} + contract RelicsV1Test is Test { - Delveworn internal dungeon; + RelicsV1Dungeon internal dungeon; MockVRFCoordinator internal mockVRF; address internal player = address(0xA11CE); function setUp() public { mockVRF = new MockVRFCoordinator(); - dungeon = new Delveworn(address(mockVRF)); + dungeon = new RelicsV1Dungeon(address(mockVRF)); } - function testRelicOfferAppearsAfterRoomFiveWithThreeStableChoices() public { + function testOneRandomRelicOfferAppearsAfterFirstBoss() public { _start(); assertFalse(dungeon.relicOfferAvailable(player)); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); - assertEq(dungeon.getPlayer(player).roomsCleared, 5); + assertEq(dungeon.getPlayer(player).roomsCleared, 10); assertTrue(dungeon.relicOfferAvailable(player)); assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.None)); - - (Delveworn.Relic first, Delveworn.Relic second, Delveworn.Relic third) = dungeon.relicChoices(); - - assertEq(uint256(first), uint256(Delveworn.Relic.BloodPrice)); - assertEq(uint256(second), uint256(Delveworn.Relic.IronShell)); - assertEq(uint256(third), uint256(Delveworn.Relic.EchoLens)); + assertEq(uint256(dungeon.relicOfferId(player)), uint256(Delveworn.Relic.BloodPrice)); } - function testControlRunCanSkipRelicOffer() public { + function testBossRelicMustBeClaimedBeforeContinuing() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); + vm.expectRevert("Claim boss relic first"); vm.prank(player); dungeon.enterNextRoom(); - assertTrue(dungeon.relicOfferAvailable(player)); + vm.prank(player); + dungeon.claimRelic(false); + + assertFalse(dungeon.relicOfferAvailable(player)); assertEq(dungeon.maxHp(player), 100); assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.None)); + vm.prank(player); + dungeon.enterNextRoom(); _fulfill(_one(0)); (uint256 minDamage, uint256 maxDamage) = dungeon.playerAttackRange(player); @@ -54,7 +65,7 @@ contract RelicsV1Test is Test { function testBloodPriceBoostsDamageAndPaysMaxHpOnRoomEntry() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.BloodPrice); assertEq(dungeon.maxHp(player), 100); @@ -81,7 +92,7 @@ contract RelicsV1Test is Test { function testBloodPriceRetryDoesNotChargeRoomPenaltyTwice() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.BloodPrice); vm.prank(player); @@ -100,7 +111,7 @@ contract RelicsV1Test is Test { function testIronShellAddsMaxHpAndTradesDamageForDurability() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); uint256 hpBefore = dungeon.getPlayer(player).hp; _choose(Delveworn.Relic.IronShell); @@ -119,7 +130,7 @@ contract RelicsV1Test is Test { function testIronShellHealingUsesNewMaxHp() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.IronShell); Delveworn.Player memory before = dungeon.getPlayer(player); @@ -136,7 +147,7 @@ contract RelicsV1Test is Test { function testEchoLensRaisesCritChanceButCutsStormDamage() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.EchoLens); assertEq(dungeon.playerCriticalChance(player), 20); @@ -152,7 +163,7 @@ contract RelicsV1Test is Test { function testEchoLensNineteenRollBecomesCritical() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.EchoLens); vm.prank(player); @@ -167,16 +178,24 @@ contract RelicsV1Test is Test { assertEq(dungeon.lastPlayerDamage(player), 16); } - function testRelicSlotCannotBeReplaced() public { + function testOwnedRelicsCanBeSwitchedAndUnequippedBetweenRooms() public { _start(); - _clearThroughRoomFive(); + _clearThroughFirstBoss(); _choose(Delveworn.Relic.BloodPrice); - vm.expectRevert("No relic offer"); + dungeon.forceOffer(player, Delveworn.Relic.IronShell); vm.prank(player); - dungeon.chooseRelic(Delveworn.Relic.IronShell); + dungeon.claimRelic(false); assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.BloodPrice)); + + vm.prank(player); + dungeon.equipOwnedRelic(Delveworn.Relic.IronShell); + assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.IronShell)); + + vm.prank(player); + dungeon.equipOwnedRelic(Delveworn.Relic.None); + assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.None)); } function _start() internal { @@ -185,11 +204,11 @@ contract RelicsV1Test is Test { _fulfill(_one(0)); } - function _clearThroughRoomFive() internal { - while (dungeon.getPlayer(player).roomsCleared < 5) { + function _clearThroughFirstBoss() internal { + while (dungeon.getPlayer(player).roomsCleared < 10) { _clearCurrentMonster(); - if (dungeon.getPlayer(player).roomsCleared < 5) { + if (dungeon.getPlayer(player).roomsCleared < 10) { vm.prank(player); dungeon.enterNextRoom(); _fulfill(_one(0)); @@ -209,6 +228,7 @@ contract RelicsV1Test is Test { } function _choose(Delveworn.Relic relic) internal { + dungeon.forceOffer(player, relic); vm.prank(player); dungeon.chooseRelic(relic); diff --git a/test/RelicsV2.t.sol b/test/RelicsV2.t.sol index ddce117..e89de13 100644 --- a/test/RelicsV2.t.sol +++ b/test/RelicsV2.t.sol @@ -8,8 +8,9 @@ import {MockVRFCoordinator} from "../src/MockVRFCoordinator.sol"; contract RelicsV2Dungeon is Delveworn { constructor(address coordinatorAddress) Delveworn(coordinatorAddress) {} - function forceOfferRarity(address playerAddress, RelicRarity rarity) external { - relicOfferRarity[playerAddress] = rarity; + function forceOffer(address playerAddress, Relic relic) external { + relicOfferId[playerAddress] = relic; + relicOfferRarity[playerAddress] = relicRarityOf(relic); relicOfferAvailable[playerAddress] = true; } @@ -55,8 +56,57 @@ contract RelicsV2Test is Test { assertEq(uint256(dungeon.previewRelicRarity(9_800)), uint256(Delveworn.RelicRarity.Legendary)); } + function testDuplicateDropsIncrementCounterWithoutStackingEffects() public { + _reachRoomFiveOffer(); + dungeon.forceOffer(player, Delveworn.Relic.IronShell); + + uint256 hpBefore = dungeon.getPlayer(player).hp; + vm.prank(player); + dungeon.claimRelic(true); + + assertEq(dungeon.frontendSnapshotV3(player).relicCounts[1], 1); + assertEq(dungeon.maxHp(player), 120); + assertEq(dungeon.getPlayer(player).hp, hpBefore + 20); + + dungeon.forceOffer(player, Delveworn.Relic.IronShell); + vm.prank(player); + dungeon.claimRelic(true); + + assertEq(dungeon.frontendSnapshotV3(player).relicCounts[1], 2); + assertEq(dungeon.maxHp(player), 120); + assertEq(dungeon.getPlayer(player).hp, hpBefore + 20); + } + + function testClaimCanKeepCurrentRelicAndAddDropToCollection() public { + _reachRoomFiveOffer(); + _choose(Delveworn.RelicRarity.Common, Delveworn.Relic.BloodPrice); + + dungeon.forceOffer(player, Delveworn.Relic.IronShell); + vm.prank(player); + dungeon.claimRelic(false); + + assertEq(uint256(dungeon.equippedRelic(player)), uint256(Delveworn.Relic.BloodPrice)); + assertTrue(dungeon.ownsRelic(player, Delveworn.Relic.IronShell)); + assertEq(dungeon.frontendSnapshotV3(player).relicCounts[1], 1); + } + + function testFrontendSnapshotV3ExposesOfferCollectionAndBaseMaxHp() public { + _reachRoomFiveOffer(); + dungeon.forceOffer(player, Delveworn.Relic.EchoLens); + + vm.prank(player); + dungeon.claimRelic(false); + + Delveworn.FrontendSnapshotV3 memory snapshot = dungeon.frontendSnapshotV3(player); + assertEq(uint256(snapshot.relicOffer), uint256(Delveworn.Relic.None)); + assertEq(snapshot.ownedRelicsMask, 4); + assertEq(snapshot.relicCounts[2], 1); + assertEq(snapshot.baseMaxHp, 100); + } + function testWrongTierRelicCannotBeChosen() public { _reachRoomFiveOffer(); + dungeon.forceOffer(player, Delveworn.Relic.BloodPrice); assertEq(uint256(dungeon.relicOfferRarity(player)), uint256(Delveworn.RelicRarity.Common)); vm.expectRevert("Relic not in offer"); @@ -235,11 +285,12 @@ contract RelicsV2Test is Test { } } - assertTrue(dungeon.relicOfferAvailable(player)); + assertFalse(dungeon.relicOfferAvailable(player)); } function _choose(Delveworn.RelicRarity rarity, Delveworn.Relic relic) internal { - dungeon.forceOfferRarity(player, rarity); + assertEq(uint256(dungeon.relicRarityOf(relic)), uint256(rarity)); + dungeon.forceOffer(player, relic); vm.prank(player); dungeon.chooseRelic(relic); assertEq(uint256(dungeon.equippedRelic(player)), uint256(relic)); diff --git a/test/RelicsV2Balance.t.sol b/test/RelicsV2Balance.t.sol index c4aa3c2..8dd1a0c 100644 --- a/test/RelicsV2Balance.t.sol +++ b/test/RelicsV2Balance.t.sol @@ -5,14 +5,15 @@ import {Test, console2} from "forge-std/Test.sol"; import {Delveworn} from "../src/Delveworn.sol"; import {DevRandomnessAdapter} from "../src/adapters/DevRandomnessAdapter.sol"; -/// @dev Test-only hook that pins an already-open room-5 offer to the relic's -/// rarity. Combat/loot randomness and all pre-relic state stay genuine. +/// @dev Test-only hook that pins an already-open boss drop to a specific relic. +/// Combat/loot randomness and all pre-relic state stay genuine. contract RelicsV2BalanceDungeon is Delveworn { constructor(address coordinatorAddress) Delveworn(coordinatorAddress) {} - function forceRelicOfferRarity(address playerAddress, RelicRarity rarity) external { + function forceRelicOffer(address playerAddress, Relic relic) external { require(relicOfferAvailable[playerAddress], "Offer not open"); - relicOfferRarity[playerAddress] = rarity; + relicOfferId[playerAddress] = relic; + relicOfferRarity[playerAddress] = relicRarityOf(relic); } } @@ -174,13 +175,15 @@ contract RelicsV2BalanceTest is Test { continue; } - if ( - relic != Delveworn.Relic.None && dungeon.relicOfferAvailable(playerAddress) - && dungeon.equippedRelic(playerAddress) == Delveworn.Relic.None - ) { - dungeon.forceRelicOfferRarity(playerAddress, dungeon.relicRarityOf(relic)); - vm.prank(playerAddress); - dungeon.chooseRelic(relic); + if (dungeon.relicOfferAvailable(playerAddress)) { + if (relic != Delveworn.Relic.None && dungeon.equippedRelic(playerAddress) == Delveworn.Relic.None) { + dungeon.forceRelicOffer(playerAddress, relic); + vm.prank(playerAddress); + dungeon.chooseRelic(relic); + } else { + vm.prank(playerAddress); + dungeon.claimRelic(false); + } } _useSupplyStop(dungeon, playerAddress);