// SPDX-License-Identifier: MIT pragma solidity ^0.8.30; import {ERC721Enumerable, ERC721} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Base64} from "@openzeppelin/contracts/utils/Base64.sol"; import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /// @notice Independently owned, trainable bulls. All tradeable stats are on-chain. /// @dev Fixed-price optional rest; no random mint attributes, prize settlement, upgrade, or admin stat edits. contract RanchBulls is ERC721Enumerable, Ownable2Step, ReentrancyGuard { using Strings for uint256; using SafeERC20 for IERC20; uint256 public constant REST_PRICE = 10_000 * 1e18; IERC20 public immutable paymentToken; address public immutable treasury; uint256 public constant MAX_SUPPLY = 10_000; uint256 public nextTokenId = 1; bool public mintOpen; mapping(address => bool) public hasClaimed; struct Bull { string name; uint32 bornAt; uint32 careDay; uint32 xp; uint8 strength; uint8 agility; uint8 stamina; uint8 focus; uint8 energy; uint8 fullness; uint8 happiness; uint8 actions; } mapping(uint256 => Bull) private _bulls; event BullBorn(uint256 indexed tokenId, address indexed keeper, string name); event BullCaredFor(uint256 indexed tokenId, uint8 activity, uint32 xp); event BullRenamed(uint256 indexed tokenId, string name); event MintOpenChanged(bool open); event RestPurchased(uint256 indexed tokenId, address indexed keeper, address indexed recipient, uint256 price); error InvalidName(); error NotKeeper(); error MintClosed(); error AlreadyClaimed(); error SoldOut(); error NoActions(); error LowEnergy(); error InvalidActivity(); error AlreadyFull(); error InvalidPaymentToken(); error RestNotNeeded(); error UnsupportedPayment(); constructor(address operator, address token) ERC721("Cowboy Red Bulls", "CRBULL") Ownable(operator) { if (token.code.length == 0 || IERC20Metadata(token).decimals() != 18) revert InvalidPaymentToken(); paymentToken = IERC20(token); treasury = operator; } function setMintOpen(bool open) external onlyOwner { mintOpen = open; emit MintOpenChanged(open); } /// @notice One free starter per address, lifetime. Gas is paid to the network. function mint(string calldata bullName) external nonReentrant returns (uint256 id) { if (!mintOpen) revert MintClosed(); if (hasClaimed[msg.sender]) revert AlreadyClaimed(); if (nextTokenId > MAX_SUPPLY) revert SoldOut(); _checkName(bullName); hasClaimed[msg.sender] = true; id = nextTokenId++; _bulls[id] = Bull(bullName, uint32(block.timestamp), uint32(block.timestamp / 1 days), 0, 18, 15, 16, 12, 100, 100, 85, 3); _safeMint(msg.sender, id); emit BullBorn(id, msg.sender, bullName); } function getBull(uint256 id) public view returns (Bull memory b) { _requireOwned(id); b = _bulls[id]; uint256 today = block.timestamp / 1 days; uint256 elapsed = today - b.careDay; if (elapsed > 0) { b.fullness = uint8(elapsed * 22 >= b.fullness ? 0 : b.fullness - elapsed * 22); b.energy = _cap(uint256(b.energy) + elapsed * 45); b.happiness = _cap(uint256(b.happiness) + elapsed * 8); b.actions = 3; b.careDay = uint32(today); } } /// @notice 0=hay feeding; 1=strength training; 2=trail training. Three actions each UTC day. /// @dev Only the current NFT owner can act, including after a transfer. No admin/stat oracle. function care(uint256 id, uint8 activity) external nonReentrant { if (ownerOf(id) != msg.sender) revert NotKeeper(); if (activity > 2) revert InvalidActivity(); Bull memory b = getBull(id); if (b.actions == 0) revert NoActions(); if (activity == 0) { if (b.energy == 100 && b.fullness == 100 && b.happiness == 100) revert AlreadyFull(); b.energy = _cap(uint256(b.energy) + 20); b.fullness = _cap(uint256(b.fullness) + 30); b.happiness = _cap(uint256(b.happiness) + 10); b.xp += 6; } else { if (b.energy < 15) revert LowEnergy(); b.energy -= 15; b.fullness = b.fullness > 5 ? b.fullness - 5 : 0; b.happiness = b.happiness > 3 ? b.happiness - 3 : 0; if (activity == 1) { b.strength = _cap(uint256(b.strength) + 3); b.stamina = _cap(uint256(b.stamina) + 1); } else { b.agility = _cap(uint256(b.agility) + 3); b.focus = _cap(uint256(b.focus) + 1); } b.xp += 12; } --b.actions; _bulls[id] = b; emit BullCaredFor(id, activity, b.xp); } /// @notice Exact 10,000 COWB payment for three care actions and full energy, with no random effect. /// @dev Payment and rest settle atomically. No free refresh, XP, feed, or tournament payout is created. function buyRest(uint256 id) external nonReentrant { if (ownerOf(id) != msg.sender) revert NotKeeper(); Bull memory b = getBull(id); if (b.actions == 3 && b.energy == 100) revert RestNotNeeded(); b.actions = 3; b.energy = 100; _bulls[id] = b; uint256 beforeBalance = paymentToken.balanceOf(treasury); paymentToken.safeTransferFrom(msg.sender, treasury, REST_PRICE); uint256 afterBalance = paymentToken.balanceOf(treasury); uint256 expectedDelta = msg.sender == treasury ? 0 : REST_PRICE; if (afterBalance < beforeBalance || afterBalance - beforeBalance != expectedDelta) revert UnsupportedPayment(); emit RestPurchased(id, msg.sender, treasury, REST_PRICE); } function rename(uint256 id, string calldata bullName) external { if (ownerOf(id) != msg.sender) revert NotKeeper(); _checkName(bullName); _bulls[id].name = bullName; emit BullRenamed(id, bullName); } function tokenURI(uint256 id) public view override returns (string memory) { Bull memory b = getBull(id); string memory number = id.toString(); string memory svg = string.concat( 'COWBOY RED', b.name, 'BULL #', number, ' / RAISE A LITTLE LEGEND' ); string memory attributes = string.concat( _attribute("Strength", b.strength), ',', _attribute("Agility", b.agility), ',', _attribute("Stamina", b.stamina), ',', _attribute("Focus", b.focus), ',', _attribute("Level", uint256(b.xp) / 30 + 1), ',', _attribute("Fullness", b.fullness), ',', _attribute("Energy", b.energy), ',', _attribute("Happiness", b.happiness) ); return string.concat('data:application/json;base64,', Base64.encode(bytes(string.concat( '{"name":"', b.name, ' #', number, '","description":"A Cowboy Red bull. Ownership and trained stats live on-chain. Care changes with real UTC days. No prize or yield entitlement.","external_url":"https://cowboy.red/#market","image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '","attributes":[', attributes, ']}' )))); } function _attribute(string memory key, uint256 value) private pure returns (string memory) { return string.concat('{"trait_type":"', key, '","value":', value.toString(), '}'); } function _cap(uint256 value) private pure returns (uint8) { return uint8(value > 100 ? 100 : value); } function _checkName(string memory value) private pure { bytes memory b = bytes(value); if (b.length < 1 || b.length > 24 || b[0] == 0x20 || b[b.length - 1] == 0x20) revert InvalidName(); for (uint256 i; i < b.length; ++i) { bytes1 c = b[i]; if (!((c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) || c == 0x20 || c == 0x2d)) revert InvalidName(); } } }