Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand Down
179 changes: 137 additions & 42 deletions src/Delveworn.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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%.
Expand All @@ -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;

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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];
}

/*
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down
34 changes: 34 additions & 0 deletions src/RelicRules.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions test/BalanceBaseline.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions test/Delveworn.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Loading