From 431a95a8962bd1ef47124d60e18307ba64e3fd23 Mon Sep 17 00:00:00 2001 From: teefeh-07 Date: Tue, 17 Jun 2025 18:52:35 +0000 Subject: [PATCH] Implement comprehensive spell system with mana, cooldowns, and ownership - Add complete Clarity smart contract with spell creation, casting, and transfer functionality - Implement mana system with automatic regeneration over time - Add spell cooldown mechanics to prevent spam casting - Create comprehensive test suite covering all major functionality - Add detailed README with usage examples and documentation - Update project description in Clarinet.toml Features: - Spell creation with custom names, descriptions, power, and rarity - Mana-based casting system (100 starting mana, regenerates 1 per 10 blocks) - Spell ownership and transfer capabilities - Cooldown system per spell per user - Error handling for insufficient mana, cooldowns, and unauthorized actions - Read-only functions for querying spell and user data --- Clarinet.toml | 4 +- README.md | 193 ++++++++++++++++++++++++++ contracts/Saga.clar | 329 +++++++++++++++++++++++++++++++++++++++++++- tests/Saga_test.ts | 213 ++++++++++++++++++++++++++-- 4 files changed, 718 insertions(+), 21 deletions(-) create mode 100644 README.md diff --git a/Clarinet.toml b/Clarinet.toml index 6bc67ac..a713872 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -1,7 +1,7 @@ [project] name = "SagaSpell" -authors = [] -description = "" +authors = ["teefeh-07"] +description = "A magical spell casting smart contract on Stacks blockchain where users can create, own, and cast spells with different powers and rarities" telemetry = true requirements = [] cache_dir = "/home/runner/Sagaspell/SagaSpell/./.requirements" diff --git a/README.md b/README.md new file mode 100644 index 0000000..5247bb9 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# SagaSpell 🧙‍♂️✨ + +A magical spell casting smart contract built on the Stacks blockchain using Clarity. SagaSpell allows users to create, own, trade, and cast spells with different powers, rarities, and magical effects. + +## Features + +- **Spell Creation**: Users can create custom spells with unique names, descriptions, power levels, and rarities +- **Mana System**: Energy-based casting system with automatic mana regeneration over time +- **Spell Ownership**: Spells are owned as NFT-like assets that can be transferred between users +- **Cooldown Mechanics**: Spells have cooldown periods to prevent spam casting +- **Rarity System**: Spells can have different rarity levels (Common, Rare, Epic, Legendary) +- **Power Scaling**: Spells have different power levels affecting their mana cost and effectiveness + +## Smart Contract Functions + +### Public Functions + +#### `initialize-user` +Initialize a new user account with starting mana (100) and empty spell collection. + +#### `create-spell` +Create a new spell with the following parameters: +- `name`: Spell name (max 50 characters) +- `description`: Spell description (max 200 characters) +- `power`: Spell power level (uint) +- `mana-cost`: Mana required to cast (uint) +- `rarity`: Spell rarity level (string) +- `cooldown-blocks`: Blocks to wait between casts (uint) + +#### `cast-spell` +Cast a spell by its ID. Requires sufficient mana and spell must not be on cooldown. + +#### `transfer-spell` +Transfer spell ownership to another user. Only the current owner can transfer. + +### Read-Only Functions + +#### `get-spell` +Get detailed information about a specific spell. + +#### `get-user` +Get user account information including mana and spell statistics. + +#### `get-user-mana` +Get user's current mana with regeneration calculated. + +#### `get-spell-owner` +Get the current owner of a specific spell. + +#### `check-spell-cooldown` +Check if a spell is currently on cooldown for a specific user. + +#### `get-total-spells` +Get the total number of spells created in the contract. + +## Getting Started + +### Prerequisites + +- [Clarinet](https://github.com/hirosystems/clarinet) - Stacks smart contract development tool +- [Node.js](https://nodejs.org/) - For running tests + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/teefeh-07/SagaSpell.git +cd SagaSpell +``` + +2. Install Clarinet (if not already installed): +```bash +# On macOS +brew install clarinet + +# On other platforms, see: https://github.com/hirosystems/clarinet#installation +``` + +### Development + +1. Check the project: +```bash +clarinet check +``` + +2. Run tests: +```bash +clarinet test +``` + +3. Start a local development environment: +```bash +clarinet integrate +``` + +## Usage Examples + +### Creating Your First Spell + +```clarity +;; Initialize your user account first +(contract-call? .Saga initialize-user) + +;; Create a fireball spell +(contract-call? .Saga create-spell + "Fireball" + "A blazing ball of fire that burns enemies" + u100 + u25 + "Common" + u5) +``` + +### Casting a Spell + +```clarity +;; Cast spell with ID 1 +(contract-call? .Saga cast-spell u1) +``` + +### Transferring a Spell + +```clarity +;; Transfer spell ID 1 to another user +(contract-call? .Saga transfer-spell u1 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5) +``` + +## Game Mechanics + +### Mana System +- Users start with 100 mana +- Mana regenerates at 1 point per 10 blocks +- Maximum mana capacity is 1000 +- Spells consume mana based on their power level + +### Spell Cooldowns +- Each spell has a cooldown period measured in blocks +- Users must wait for the cooldown to expire before recasting +- Cooldowns are per-user, per-spell + +### Spell Rarity Suggestions +- **Common**: Basic spells, low mana cost (10-30) +- **Rare**: Moderate spells, medium mana cost (30-60) +- **Epic**: Powerful spells, high mana cost (60-100) +- **Legendary**: Ultimate spells, very high mana cost (100+) + +## Testing + +The project includes comprehensive tests covering: +- User initialization +- Spell creation and validation +- Spell casting mechanics +- Mana management +- Cooldown systems +- Ownership transfers +- Error handling + +Run all tests: +```bash +clarinet test +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-spell`) +3. Commit your changes (`git commit -m 'Add amazing spell feature'`) +4. Push to the branch (`git push origin feature/amazing-spell`) +5. Open a Pull Request + +## License + +This project is open source and available under the [MIT License](LICENSE). + +## Roadmap + +- [ ] Spell battles between users +- [ ] Spell crafting and combination system +- [ ] Marketplace for spell trading +- [ ] Guild system for collaborative spell casting +- [ ] Achievement system for spell masters +- [ ] Integration with Stacks NFT standards + +## Support + +If you have questions or need help, please: +1. Check the [Issues](https://github.com/teefeh-07/SagaSpell/issues) page +2. Create a new issue if your question isn't answered +3. Join the Stacks community Discord for general Clarity development help + +--- + +Built with ❤️ on the Stacks blockchain using Clarity smart contracts. diff --git a/contracts/Saga.clar b/contracts/Saga.clar index 31a89dc..4d5315e 100644 --- a/contracts/Saga.clar +++ b/contracts/Saga.clar @@ -1,15 +1,332 @@ -;; Saga -;; +;; SagaSpell - A magical spell casting smart contract +;; Users can create, own, and cast spells with different powers and rarities ;; constants -;; +(define-constant CONTRACT-OWNER tx-sender) +(define-constant ERR-OWNER-ONLY (err u100)) +(define-constant ERR-NOT-FOUND (err u101)) +(define-constant ERR-INSUFFICIENT-MANA (err u102)) +(define-constant ERR-SPELL-ON-COOLDOWN (err u103)) +(define-constant ERR-INVALID-SPELL (err u104)) +(define-constant ERR-UNAUTHORIZED (err u105)) ;; data maps and vars -;; +(define-data-var next-spell-id uint u1) +(define-data-var total-spells uint u0) + +;; Map to store spell data +(define-map spells + { spell-id: uint } + { + name: (string-ascii 50), + description: (string-ascii 200), + power: uint, + mana-cost: uint, + rarity: (string-ascii 20), + creator: principal, + owner: principal, + cast-count: uint, + cooldown-blocks: uint, + created-at: uint + } +) + +;; Map to store user data +(define-map users + { user: principal } + { + mana: uint, + total-spells-owned: uint, + total-spells-cast: uint, + last-mana-regen: uint + } +) + +;; Map to track spell ownership +(define-map spell-owners + { spell-id: uint } + { owner: principal } +) + +;; Map to track spell cooldowns +(define-map spell-cooldowns + { spell-id: uint, caster: principal } + { last-cast-block: uint } +) ;; private functions -;; + +;; Calculate mana regeneration for a user +(define-private (calculate-mana-regen (user principal)) + (let ( + (user-data (default-to + { mana: u100, total-spells-owned: u0, total-spells-cast: u0, last-mana-regen: u0 } + (map-get? users { user: user }) + )) + (blocks-passed (- block-height (get last-mana-regen user-data))) + (mana-to-add (/ blocks-passed u10)) ;; 1 mana per 10 blocks + (current-mana (get mana user-data)) + (max-mana u1000) + ) + (if (< current-mana max-mana) + (min max-mana (+ current-mana mana-to-add)) + current-mana + ) + ) +) + +;; Update user mana +(define-private (update-user-mana (user principal)) + (let ( + (current-data (default-to + { mana: u100, total-spells-owned: u0, total-spells-cast: u0, last-mana-regen: u0 } + (map-get? users { user: user }) + )) + (new-mana (calculate-mana-regen user)) + ) + (map-set users + { user: user } + (merge current-data { + mana: new-mana, + last-mana-regen: block-height + }) + ) + (ok new-mana) + ) +) + +;; Check if spell is on cooldown +(define-private (is-spell-on-cooldown (spell-id uint) (caster principal)) + (match (map-get? spell-cooldowns { spell-id: spell-id, caster: caster }) + cooldown-data + (let ( + (spell-data (unwrap! (map-get? spells { spell-id: spell-id }) false)) + (blocks-since-cast (- block-height (get last-cast-block cooldown-data))) + ) + (< blocks-since-cast (get cooldown-blocks spell-data)) + ) + false + ) +) ;; public functions -;; + +;; Initialize user account +(define-public (initialize-user) + (let ( + (user tx-sender) + ) + (match (map-get? users { user: user }) + existing-user (ok "User already initialized") + (begin + (map-set users + { user: user } + { + mana: u100, + total-spells-owned: u0, + total-spells-cast: u0, + last-mana-regen: block-height + } + ) + (ok "User initialized successfully") + ) + ) + ) +) + +;; Create a new spell +(define-public (create-spell + (name (string-ascii 50)) + (description (string-ascii 200)) + (power uint) + (mana-cost uint) + (rarity (string-ascii 20)) + (cooldown-blocks uint) +) + (let ( + (spell-id (var-get next-spell-id)) + (creator tx-sender) + ) + ;; Validate inputs + (asserts! (> (len name) u0) ERR-INVALID-SPELL) + (asserts! (> power u0) ERR-INVALID-SPELL) + (asserts! (> mana-cost u0) ERR-INVALID-SPELL) + + ;; Create the spell + (map-set spells + { spell-id: spell-id } + { + name: name, + description: description, + power: power, + mana-cost: mana-cost, + rarity: rarity, + creator: creator, + owner: creator, + cast-count: u0, + cooldown-blocks: cooldown-blocks, + created-at: block-height + } + ) + + ;; Set spell ownership + (map-set spell-owners + { spell-id: spell-id } + { owner: creator } + ) + + ;; Update user data + (let ( + (user-data (default-to + { mana: u100, total-spells-owned: u0, total-spells-cast: u0, last-mana-regen: block-height } + (map-get? users { user: creator }) + )) + ) + (map-set users + { user: creator } + (merge user-data { total-spells-owned: (+ (get total-spells-owned user-data) u1) }) + ) + ) + + ;; Update contract state + (var-set next-spell-id (+ spell-id u1)) + (var-set total-spells (+ (var-get total-spells) u1)) + + (ok spell-id) + ) +) + +;; Cast a spell +(define-public (cast-spell (spell-id uint)) + (let ( + (caster tx-sender) + (spell-data (unwrap! (map-get? spells { spell-id: spell-id }) ERR-NOT-FOUND)) + ) + ;; Update user mana first + (unwrap! (update-user-mana caster) ERR-INSUFFICIENT-MANA) + + (let ( + (user-data (unwrap! (map-get? users { user: caster }) ERR-NOT-FOUND)) + (mana-cost (get mana-cost spell-data)) + (current-mana (get mana user-data)) + ) + ;; Check if user has enough mana + (asserts! (>= current-mana mana-cost) ERR-INSUFFICIENT-MANA) + + ;; Check if spell is not on cooldown + (asserts! (not (is-spell-on-cooldown spell-id caster)) ERR-SPELL-ON-COOLDOWN) + + ;; Deduct mana + (map-set users + { user: caster } + (merge user-data { + mana: (- current-mana mana-cost), + total-spells-cast: (+ (get total-spells-cast user-data) u1) + }) + ) + + ;; Update spell cast count + (map-set spells + { spell-id: spell-id } + (merge spell-data { cast-count: (+ (get cast-count spell-data) u1) }) + ) + + ;; Set cooldown + (map-set spell-cooldowns + { spell-id: spell-id, caster: caster } + { last-cast-block: block-height } + ) + + (ok { + spell-name: (get name spell-data), + power: (get power spell-data), + caster: caster, + block-height: block-height + }) + ) + ) +) + +;; Transfer spell ownership +(define-public (transfer-spell (spell-id uint) (new-owner principal)) + (let ( + (current-owner tx-sender) + (spell-data (unwrap! (map-get? spells { spell-id: spell-id }) ERR-NOT-FOUND)) + ) + ;; Check if sender is the current owner + (asserts! (is-eq current-owner (get owner spell-data)) ERR-UNAUTHORIZED) + + ;; Update spell ownership + (map-set spells + { spell-id: spell-id } + (merge spell-data { owner: new-owner }) + ) + + (map-set spell-owners + { spell-id: spell-id } + { owner: new-owner } + ) + + ;; Update user spell counts + (let ( + (old-owner-data (default-to + { mana: u100, total-spells-owned: u0, total-spells-cast: u0, last-mana-regen: block-height } + (map-get? users { user: current-owner }) + )) + (new-owner-data (default-to + { mana: u100, total-spells-owned: u0, total-spells-cast: u0, last-mana-regen: block-height } + (map-get? users { user: new-owner }) + )) + ) + (map-set users + { user: current-owner } + (merge old-owner-data { total-spells-owned: (- (get total-spells-owned old-owner-data) u1) }) + ) + + (map-set users + { user: new-owner } + (merge new-owner-data { total-spells-owned: (+ (get total-spells-owned new-owner-data) u1) }) + ) + ) + + (ok true) + ) +) + +;; Read-only functions + +;; Get spell details +(define-read-only (get-spell (spell-id uint)) + (map-get? spells { spell-id: spell-id }) +) + +;; Get user data +(define-read-only (get-user (user principal)) + (map-get? users { user: user }) +) + +;; Get user's current mana (with regeneration calculated) +(define-read-only (get-user-mana (user principal)) + (calculate-mana-regen user) +) + +;; Get spell owner +(define-read-only (get-spell-owner (spell-id uint)) + (map-get? spell-owners { spell-id: spell-id }) +) + +;; Check if spell is on cooldown for a user +(define-read-only (check-spell-cooldown (spell-id uint) (user principal)) + (is-spell-on-cooldown spell-id user) +) + +;; Get total number of spells +(define-read-only (get-total-spells) + (var-get total-spells) +) + +;; Get next spell ID +(define-read-only (get-next-spell-id) + (var-get next-spell-id) +) diff --git a/tests/Saga_test.ts b/tests/Saga_test.ts index 561c544..0ee4821 100644 --- a/tests/Saga_test.ts +++ b/tests/Saga_test.ts @@ -3,24 +3,211 @@ import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarine import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts'; Clarinet.test({ - name: "Ensure that <...>", + name: "User can initialize their account", async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address) + ]); + + assertEquals(block.receipts.length, 1); + assertEquals(block.receipts[0].result, `(ok "User initialized successfully")`); + + // Check user data + let userQuery = chain.callReadOnlyFn('Saga', 'get-user', [types.principal(wallet1.address)], wallet1.address); + assertEquals(userQuery.result.includes('mana: u100'), true); + }, +}); + +Clarinet.test({ + name: "User can create a spell", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Fireball"), + types.ascii("A powerful fire spell that deals massive damage"), + types.uint(100), + types.uint(25), + types.ascii("Common"), + types.uint(5) + ], wallet1.address) + ]); + + assertEquals(block.receipts.length, 2); + assertEquals(block.receipts[1].result, `(ok u1)`); + + // Check spell was created + let spellQuery = chain.callReadOnlyFn('Saga', 'get-spell', [types.uint(1)], wallet1.address); + assertEquals(spellQuery.result.includes('name: "Fireball"'), true); + assertEquals(spellQuery.result.includes('power: u100'), true); + }, +}); + +Clarinet.test({ + name: "User can cast a spell", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Lightning Bolt"), + types.ascii("A quick lightning attack"), + types.uint(75), + types.uint(20), + types.ascii("Common"), + types.uint(3) + ], wallet1.address) + ]); + + // Cast the spell + block = chain.mineBlock([ + Tx.contractCall('Saga', 'cast-spell', [types.uint(1)], wallet1.address) + ]); + + assertEquals(block.receipts.length, 1); + assertEquals(block.receipts[0].result.includes('spell-name: "Lightning Bolt"'), true); + assertEquals(block.receipts[0].result.includes('power: u75'), true); + + // Check mana was deducted + let userQuery = chain.callReadOnlyFn('Saga', 'get-user-mana', [types.principal(wallet1.address)], wallet1.address); + assertEquals(userQuery.result, `u80`); // 100 - 20 = 80 + }, +}); + +Clarinet.test({ + name: "User cannot cast spell without enough mana", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Meteor"), + types.ascii("Devastating meteor strike"), + types.uint(500), + types.uint(150), // More than initial mana + types.ascii("Legendary"), + types.uint(10) + ], wallet1.address) + ]); + + // Try to cast the spell + block = chain.mineBlock([ + Tx.contractCall('Saga', 'cast-spell', [types.uint(1)], wallet1.address) + ]); + + assertEquals(block.receipts.length, 1); + assertEquals(block.receipts[0].result, `(err u102)`); // ERR-INSUFFICIENT-MANA + }, +}); + +Clarinet.test({ + name: "Spell cooldown prevents immediate recasting", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + let block = chain.mineBlock([ - /* - * Add transactions with: - * Tx.contractCall(...) - */ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Ice Shard"), + types.ascii("Sharp ice projectile"), + types.uint(50), + types.uint(15), + types.ascii("Common"), + types.uint(5) // 5 block cooldown + ], wallet1.address) ]); - assertEquals(block.receipts.length, 0); - assertEquals(block.height, 2); + // Cast the spell first time block = chain.mineBlock([ - /* - * Add transactions with: - * Tx.contractCall(...) - */ + Tx.contractCall('Saga', 'cast-spell', [types.uint(1)], wallet1.address) ]); - assertEquals(block.receipts.length, 0); - assertEquals(block.height, 3); + assertEquals(block.receipts[0].result.includes('ok'), true); + + // Try to cast immediately again + block = chain.mineBlock([ + Tx.contractCall('Saga', 'cast-spell', [types.uint(1)], wallet1.address) + ]); + assertEquals(block.receipts[0].result, `(err u103)`); // ERR-SPELL-ON-COOLDOWN + }, +}); + +Clarinet.test({ + name: "User can transfer spell ownership", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + const wallet2 = accounts.get('wallet_2')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'initialize-user', [], wallet2.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Heal"), + types.ascii("Restore health"), + types.uint(30), + types.uint(10), + types.ascii("Common"), + types.uint(2) + ], wallet1.address) + ]); + + // Transfer spell to wallet2 + block = chain.mineBlock([ + Tx.contractCall('Saga', 'transfer-spell', [ + types.uint(1), + types.principal(wallet2.address) + ], wallet1.address) + ]); + + assertEquals(block.receipts.length, 1); + assertEquals(block.receipts[0].result, `(ok true)`); + + // Check new owner + let ownerQuery = chain.callReadOnlyFn('Saga', 'get-spell-owner', [types.uint(1)], wallet1.address); + assertEquals(ownerQuery.result.includes(wallet2.address), true); + }, +}); + +Clarinet.test({ + name: "Non-owner cannot transfer spell", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const wallet1 = accounts.get('wallet_1')!; + const wallet2 = accounts.get('wallet_2')!; + + let block = chain.mineBlock([ + Tx.contractCall('Saga', 'initialize-user', [], wallet1.address), + Tx.contractCall('Saga', 'initialize-user', [], wallet2.address), + Tx.contractCall('Saga', 'create-spell', [ + types.ascii("Shield"), + types.ascii("Protective barrier"), + types.uint(40), + types.uint(12), + types.ascii("Common"), + types.uint(3) + ], wallet1.address) + ]); + + // Try to transfer spell from wallet2 (not owner) + block = chain.mineBlock([ + Tx.contractCall('Saga', 'transfer-spell', [ + types.uint(1), + types.principal(wallet2.address) + ], wallet2.address) + ]); + + assertEquals(block.receipts.length, 1); + assertEquals(block.receipts[0].result, `(err u105)`); // ERR-UNAUTHORIZED }, });