diff --git a/Cargo.lock b/Cargo.lock index f7b578b..681c1dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2767,6 +2767,7 @@ dependencies = [ "pallet-grandpa", "pallet-hexalem", "pallet-sudo", + "pallet-tic-tac-toe", "pallet-timestamp", "pallet-transaction-payment", "pallet-transaction-payment-rpc-runtime-api", @@ -4690,6 +4691,22 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-tic-tac-toe" +version = "4.0.0-dev" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", +] + [[package]] name = "pallet-timestamp" version = "4.0.0-dev" diff --git a/Cargo.toml b/Cargo.toml index cf946ec..63df40c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ version = "4.0.0-dev" [workspace] members = [ "node", - "pallets/hexalem", + "pallets/*", "runtime", ] resolver = "2" @@ -111,6 +111,7 @@ try-runtime-cli = { git = "https://github.com/parityt # Substrate Gaming Pallets pallet-hexalem = { path = "pallets/hexalem", default-features = false } +pallet-tic-tac-toe = { path = "pallets/tic-tac-toe", default-features = false } # Substrate Gaming Runtime hexalem-runtime = { path = "runtime" } diff --git a/pallets/tic-tac-toe/Cargo.toml b/pallets/tic-tac-toe/Cargo.toml new file mode 100644 index 0000000..78a4ea4 --- /dev/null +++ b/pallets/tic-tac-toe/Cargo.toml @@ -0,0 +1,46 @@ +[package] +description = "Sample pallet for the tic-tac-toe game" +name = "pallet-tic-tac-toe" + +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +version.workspace = true + +[package.metadata.docs.rs] +targets = [ "x86_64-unknown-linux-gnu" ] + +[dependencies] +# Substrate (wasm) +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +parity-scale-codec = { workspace = true, features = [ "derive", "max-encoded-len" ] } +scale-info = { workspace = true, features = [ "derive" ] } +sp-core = { workspace = true } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +[dev-dependencies] +pallet-balances = { workspace = true } + +[features] +default = [ "std" ] +runtime-benchmarks = [ + "frame-benchmarking", +] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "frame-benchmarking/std", + "frame-support/std", + "frame-system/std", + "pallet-balances/std", + "sp-core/std", + "sp-runtime/std", + "sp-std/std", + "sp-io/std", +] +try-runtime = [ "frame-support/try-runtime" ] diff --git a/pallets/tic-tac-toe/README.md b/pallets/tic-tac-toe/README.md new file mode 100644 index 0000000..a000d6d --- /dev/null +++ b/pallets/tic-tac-toe/README.md @@ -0,0 +1,3 @@ +# Pallet tic-tac-toe + +This pallet is meant to be a template for other game developers into how to create a backend for a Substrate-based game. diff --git a/pallets/tic-tac-toe/benchmarking/Cargo.toml b/pallets/tic-tac-toe/benchmarking/Cargo.toml new file mode 100644 index 0000000..38b5e0c --- /dev/null +++ b/pallets/tic-tac-toe/benchmarking/Cargo.toml @@ -0,0 +1,57 @@ +[package] +description = "Ajuna Network pallet used for NFT Staking benchmarking" +name = "pallet-ajuna-nft-staking-benchmarking" + +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +version.workspace = true + +[package.metadata.docs.rs] +targets = [ "x86_64-unknown-linux-gnu" ] + +[dependencies] +# General (wasm) +log = { workspace = true } + +# Substrate (wasm) +frame-benchmarking = { workspace = true, optional = true } +frame-support = { workspace = true } +frame-system = { workspace = true } +pallet-balances = { workspace = true } +pallet-nfts = { workspace = true } +parity-scale-codec = { workspace = true, features = [ "derive", "max-encoded-len" ] } +scale-info = { workspace = true, features = [ "derive" ] } +sp-io = { workspace = true } +sp-runtime = { workspace = true } +sp-std = { workspace = true } + +# Ajuna +pallet-ajuna-nft-staking = { workspace = true } + +[dev-dependencies] +sp-core = { workspace = true } + +[features] +default = [ "std" ] +runtime-benchmarks = [ + "frame-benchmarking", + "pallet-nfts/runtime-benchmarks", + "pallet-ajuna-nft-staking/runtime-benchmarks", +] +std = [ + "parity-scale-codec/std", + "scale-info/std", + "sp-io/std", + "pallet-balances/std", + "pallet-nfts/std", + "pallet-ajuna-nft-staking/std", + "frame-benchmarking/std", + "frame-support/std", + "frame-system/std", + "sp-runtime/std", + "sp-std/std", + "log/std", +] +try-runtime = [ "frame-support/try-runtime" ] diff --git a/pallets/tic-tac-toe/benchmarking/src/lib.rs b/pallets/tic-tac-toe/benchmarking/src/lib.rs new file mode 100644 index 0000000..a7ca46b --- /dev/null +++ b/pallets/tic-tac-toe/benchmarking/src/lib.rs @@ -0,0 +1,561 @@ +// Ajuna Node +// Copyright (C) 2022 BlogaTech AG + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![cfg(feature = "runtime-benchmarks")] +#![cfg_attr(not(feature = "std"), no_std)] + +mod mock; + +use frame_benchmarking::benchmarks; +use frame_support::{ + pallet_prelude::*, + traits::{ + tokens::nonfungibles_v2::{Create, Mutate}, + Currency, Get, + }, +}; +use frame_system::{pallet_prelude::BlockNumberFor, RawOrigin}; +use pallet_ajuna_nft_staking::{ + BenchmarkHelper as NftStakingBenchmarkHelper, Config as NftStakingConfig, *, +}; +use pallet_nfts::{BenchmarkHelper, ItemConfig}; +use sp_runtime::{ + bounded_vec, + traits::{One, UniqueSaturatedFrom, UniqueSaturatedInto}, + DispatchError, +}; + +// Creator's collections. +const CONTRACT_COLLECTION: u16 = 0; +const REWARD_COLLECTION: u16 = 1; +// Staker's collections. +const STAKE_COLLECTION: u16 = 2; +const FEE_COLLECTION: u16 = 3; +// Sniper's collections. +const SNIPER_STAKE_COLLECTION: u16 = 4; +const SNIPER_FEE_COLLECTION: u16 = 5; +// Unified attribute value for all contracts. +const ATTRIBUTE_VALUE: u8 = 10; + +enum Mode { + Staker, + Sniper, +} +impl Mode { + fn collections(self) -> (u16, u16) { + match self { + Self::Staker => (STAKE_COLLECTION, FEE_COLLECTION), + Self::Sniper => (SNIPER_STAKE_COLLECTION, SNIPER_FEE_COLLECTION), + } + } +} + +pub struct Pallet(pallet_ajuna_nft_staking::Pallet); +pub trait Config: NftStakingConfig + pallet_nfts::Config + pallet_balances::Config {} + +type AccountIdOf = ::AccountId; +type CurrencyOf = ::Currency; +type BalanceOf = as Currency>>::Balance; +type CollectionIdOf = ::CollectionId; +type ItemIdOf = ::ItemId; +type ContractOf = Contract< + BalanceOf, + CollectionIdOf, + ::ItemId, + BlockNumberFor, + ::KeyLimit, + ::ValueLimit, +>; + +type NftCurrencyOf = ::Currency; +type NftBalanceOf = as Currency>>::Balance; +type NftCollectionIdOf = ::CollectionId; +type CollectionDeposit = ::CollectionDeposit; +type ItemDeposit = ::ItemDeposit; +type CollectionConfigOf = + pallet_nfts::CollectionConfig, BlockNumberFor, NftCollectionIdOf>; + +fn account(name: &'static str) -> T::AccountId { + let account = frame_benchmarking::account(name, Default::default(), Default::default()); + CurrencyOf::::make_free_balance_be(&account, 999_999_999_u64.unique_saturated_into()); + account +} + +fn assert_last_event(avatars_event: Event) { + let event = ::RuntimeEvent::from(avatars_event); + frame_system::Pallet::::assert_last_event(event.into()); +} + +fn create_creator(reward_item: Option>) -> Result { + let creator = account::("creator"); + create_contract_collection::(&creator)?; // reserve CONTRACT_COLLECTION + create_collections::(&creator, 1)?; // reserve REWARD_COLLECTION + if let Some(item_ids) = reward_item { + item_ids + .into_iter() + .try_for_each(|item_id| mint_item::(&creator, REWARD_COLLECTION, item_id))?; + } + Creator::::put(&creator); + Ok(creator) +} + +fn create_contract_collection(creator: &T::AccountId) -> DispatchResult { + create_collection::(creator)?; + ContractCollectionId::::put(CollectionIdOf::::from(CONTRACT_COLLECTION)); + Ok(()) +} + +fn create_collections(creator: &T::AccountId, n: usize) -> DispatchResult { + (0..n).try_for_each(|_| create_collection::(creator)) +} + +fn create_collection(owner: &T::AccountId) -> DispatchResult { + NftCurrencyOf::::deposit_creating(owner, CollectionDeposit::::get()); + as Create>>::create_collection( + owner, + owner, + &pallet_nfts::CollectionConfig { + settings: Default::default(), + max_supply: Default::default(), + mint_settings: Default::default(), + }, + )?; + Ok(()) +} + +fn create_contract( + creator: T::AccountId, + contract_id: ItemIdOf, + contract: ContractOf, +) -> DispatchResult { + pallet_ajuna_nft_staking::Pallet::::create( + RawOrigin::Signed(creator).into(), + contract_id, + contract, + None, + None, + ) +} + +fn accept_contract( + num_stake_clauses: u32, + num_fee_clauses: u32, + staker: T::AccountId, + contract_id: ItemIdOf, + mode: Mode, +) -> DispatchResult { + let (stakes, fees) = stakes_and_fees::(num_stake_clauses, num_fee_clauses, &staker, mode)?; + pallet_ajuna_nft_staking::Pallet::::accept( + RawOrigin::Signed(staker).into(), + contract_id, + stakes, + fees, + ) +} + +fn mint_item(owner: &T::AccountId, collection_id: u16, item_id: u16) -> DispatchResult { + let collection_id = &T::Helper::collection(collection_id); + let item_id = &T::Helper::item(item_id); + NftCurrencyOf::::deposit_creating(owner, ItemDeposit::::get()); + as Mutate>::mint_into( + collection_id, + item_id, + owner, + &ItemConfig::default(), + false, + )?; + Ok(()) +} + +fn set_attribute( + collection_id: u16, + item_id: u16, + key: u8, + value: u8, +) -> DispatchResult { + let collection_id = &T::Helper::collection(collection_id); + let item_id = &T::Helper::item(item_id); + as Mutate>::set_attribute( + collection_id, + item_id, + &[key], + &[value], + )?; + Ok(()) +} + +fn stakes_and_fees( + num_stake_clauses: u32, + num_fee_clauses: u32, + who: &T::AccountId, + mode: Mode, +) -> Result<(Vec>, Vec>), DispatchError> { + let (stake_collection, fee_collection) = mode.collections(); + let mut stakes = Vec::new(); + let mut fees = Vec::new(); + for i in 0..num_stake_clauses { + let item_id = i as u16; + let attr_key = i; + mint_item::(who, stake_collection, item_id)?; + set_attribute::(stake_collection, item_id, (attr_key as u8) * 3, ATTRIBUTE_VALUE)?; + stakes.push(NftId( + CollectionIdOf::::unique_saturated_from(stake_collection), + T::BenchmarkHelper::item_id(item_id), + )); + } + for i in num_stake_clauses..num_stake_clauses + num_fee_clauses { + let item_id = i as u16; + let attr_key = i; + mint_item::(who, fee_collection, item_id)?; + set_attribute::(fee_collection, item_id, (attr_key as u8) * 3, ATTRIBUTE_VALUE)?; + fees.push(NftId( + CollectionIdOf::::unique_saturated_from(fee_collection), + T::BenchmarkHelper::item_id(item_id), + )); + } + Ok((stakes, fees)) +} + +fn contract_with( + num_stake_clauses: u32, + num_fee_clauses: u32, + rewards: BoundedRewardsOf, + mode: Mode, +) -> ContractOf { + let (stake_collection, fee_collection) = mode.collections(); + ContractOf:: { + activation: None, + active_duration: 1_u32.unique_saturated_into(), + claim_duration: 1_u32.unique_saturated_into(), + stake_duration: 1_u32.unique_saturated_into(), + stake_clauses: (0..num_stake_clauses) + .map(|i| ContractClause { + namespace: AttributeNamespace::Pallet, + target_index: i as u8, + clause: Clause::HasAttributeWithValue( + CollectionIdOf::::unique_saturated_from(stake_collection), + T::BenchmarkHelper::contract_key((i as u8) * 3), + AttributeValue::Equal(T::BenchmarkHelper::contract_value(ATTRIBUTE_VALUE)), + ), + }) + .collect::>() + .try_into() + .unwrap(), + fee_clauses: (num_stake_clauses..num_stake_clauses + num_fee_clauses) + .map(|i| ContractClause { + namespace: AttributeNamespace::Pallet, + target_index: (i - num_stake_clauses) as u8, + clause: Clause::HasAttributeWithValue( + CollectionIdOf::::unique_saturated_from(fee_collection), + T::BenchmarkHelper::contract_key((i as u8) * 3), + AttributeValue::Equal(T::BenchmarkHelper::contract_value(ATTRIBUTE_VALUE)), + ), + }) + .collect::>() + .try_into() + .unwrap(), + rewards, + cancel_fee: 333_u64.unique_saturated_into(), + nft_stake_amount: num_stake_clauses as u8, + nft_fee_amount: num_fee_clauses as u8, + is_snipeable: true, + } +} + +benchmarks! { + set_creator { + let creator = account::("creator"); + }: _(RawOrigin::Root, creator.clone()) + verify { + assert_last_event::(Event::CreatorSet { creator }) + } + + set_contract_collection_id { + let creator = create_creator::(None)?; + let collection_id = CollectionIdOf::::unique_saturated_from(0_u32); + ContractCollectionId::::kill(); + }: _(RawOrigin::Signed(creator), collection_id) + verify { + assert_last_event::(Event::ContractCollectionSet { collection_id }) + } + + set_global_config { + let creator = create_creator::(None)?; + let new_config = GlobalConfig::default(); + }: _(RawOrigin::Signed(creator), new_config) + verify { + assert_last_event::(Event::SetGlobalConfig { new_config }) + } + + create_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let creator = create_creator::(None)?; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + }: create(RawOrigin::Signed(creator), contract_id, contract, None, None) + verify { + assert_last_event::(Event::Created { contract_id }) + } + + create_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 123_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + let creator = create_creator::(Some(vec![reward_nft_item]))?; + }: create(RawOrigin::Signed(creator), contract_id, contract, None, None) + verify { + assert_last_event::(Event::Created { contract_id }) + } + + remove_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + let creator = create_creator::(None)?; + create_contract::(creator.clone(), contract_id, contract)?; + }: remove(RawOrigin::Signed(creator), contract_id) + verify { + assert_last_event::(Event::Removed { contract_id }) + } + + remove_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 2_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + let creator = create_creator::(Some(vec![reward_nft_item]))?; + create_contract::(creator.clone(), contract_id, contract)?; + }: remove(RawOrigin::Signed(creator), contract_id) + verify { + assert_last_event::(Event::Removed { contract_id }) + } + + accept_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(None)?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + let (stakes, fees) = stakes_and_fees::(m, n, &by, Mode::Staker)?; + }: accept(RawOrigin::Signed(by.clone()), contract_id, stakes, fees) + verify { + assert_last_event::(Event::Accepted { by, contract_id }) + } + + accept_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 2_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft( + NftId(REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let contract = contract_with::(m, n, rewards, Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(Some(vec![reward_nft_item]))?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + let (stakes, fees) = stakes_and_fees::(m, n, &by, Mode::Staker)?; + }: accept(RawOrigin::Signed(by.clone()), contract_id, stakes, fees) + verify { + assert_last_event::(Event::Accepted { by, contract_id }) + } + + cancel_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let mut contract = contract_with::(m, n, rewards, Mode::Staker); + contract.stake_duration = 100_u32.unique_saturated_into(); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(None)?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by.clone(), contract_id, Mode::Staker)?; + }: cancel(RawOrigin::Signed(by.clone()), contract_id) + verify { + assert_last_event::(Event::Cancelled { by, contract_id }) + } + + cancel_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 2_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let mut contract = contract_with::(m, n, rewards, Mode::Staker); + contract.stake_duration = 100_u32.unique_saturated_into(); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(Some(vec![reward_nft_item]))?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by.clone(), contract_id, Mode::Staker)?; + }: cancel(RawOrigin::Signed(by.clone()), contract_id) + verify { + assert_last_event::(Event::Cancelled { by, contract_id }) + } + + claim_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let contract = contract_with::(m, n, rewards.clone(), Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(None)?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by.clone(), contract_id, Mode::Staker)?; + }: claim(RawOrigin::Signed(by.clone()), contract_id, None) + verify { + assert_last_event::(Event::Claimed { by, contract_id, rewards }) + } + + claim_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 2_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let contract = contract_with::(m, n, rewards.clone(), Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let creator = create_creator::(Some(vec![reward_nft_item]))?; + create_contract::(creator, contract_id, contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by.clone(), contract_id, Mode::Staker)?; + }: claim(RawOrigin::Signed(by.clone()), contract_id, None) + verify { + assert_last_event::(Event::Claimed { by, contract_id, rewards }) + } + + snipe_token_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let rewards: BoundedRewardsOf = bounded_vec![Reward::Tokens(123_u64.unique_saturated_into())]; + let contract = contract_with::(m, n, rewards.clone(), Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let mut sniper_contract = contract_with::(m, n, rewards.clone(), Mode::Sniper); + sniper_contract.stake_duration = 100_u32.unique_saturated_into(); + let sniper_contract_id = T::BenchmarkHelper::item_id(1_u16); + + let creator = create_creator::(None)?; + create_contract::(creator.clone(), contract_id, contract.clone())?; + create_contract::(creator, sniper_contract_id, sniper_contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by, contract_id, Mode::Staker)?; + + let sniper = account::("sniper"); + create_collections::(&sniper, 2)?; + accept_contract::(m, n, sniper.clone(), sniper_contract_id, Mode::Sniper)?; + + // Advance block past contract expiry. + frame_system::Pallet::::set_block_number( + contract.stake_duration + contract.claim_duration + One::one() + ); + }: snipe(RawOrigin::Signed(sniper.clone()), contract_id) + verify { + assert_last_event::(Event::Sniped { by: sniper, contract_id, rewards }) + } + + snipe_nft_reward { + let m in 0..T::MaxStakingClauses::get(); + let n in 0..T::MaxFeeClauses::get(); + let reward_nft_item = 2_u16; + let rewards: BoundedRewardsOf = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(reward_nft_item), + ))]; + let contract = contract_with::(m, n, rewards.clone(), Mode::Staker); + let contract_id = T::BenchmarkHelper::item_id(0_u16); + + let sniper_reward_nft_item = 123_u16; + let sniper_rewards = bounded_vec![Reward::Nft(NftId( + REWARD_COLLECTION.unique_saturated_into(), + T::BenchmarkHelper::item_id(sniper_reward_nft_item), + ))]; + let mut sniper_contract = contract_with::(m, n, sniper_rewards, Mode::Sniper); + sniper_contract.stake_duration = 100_u32.unique_saturated_into(); + let sniper_contract_id = T::BenchmarkHelper::item_id(1_u16); + + let creator = create_creator::(Some(vec![reward_nft_item, sniper_reward_nft_item]))?; + create_contract::(creator.clone(), contract_id, contract.clone())?; + create_contract::(creator, sniper_contract_id, sniper_contract)?; + + let by = account::("staker"); + create_collections::(&by, 2)?; + accept_contract::(m, n, by, contract_id, Mode::Staker)?; + + let sniper = account::("sniper"); + create_collections::(&sniper, 2)?; + accept_contract::(m, n, sniper.clone(), sniper_contract_id, Mode::Sniper)?; + + // Advance block past contract expiry. + frame_system::Pallet::::set_block_number( + contract.stake_duration + contract.claim_duration + One::one() + ); + }: snipe(RawOrigin::Signed(sniper.clone()), contract_id) + verify { + assert_last_event::(Event::Sniped { by: sniper, contract_id, rewards }) + } + + impl_benchmark_test_suite!( + Pallet, + crate::mock::new_test_ext(), + crate::mock::Runtime + ); +} diff --git a/pallets/tic-tac-toe/benchmarking/src/mock.rs b/pallets/tic-tac-toe/benchmarking/src/mock.rs new file mode 100644 index 0000000..37215b7 --- /dev/null +++ b/pallets/tic-tac-toe/benchmarking/src/mock.rs @@ -0,0 +1,201 @@ +// Ajuna Node +// Copyright (C) 2022 BlogaTech AG + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![cfg(test)] + +use frame_support::{parameter_types, traits::AsEnsureOriginWithArg, PalletId}; +use frame_system::{EnsureRoot, EnsureSigned}; +use pallet_nfts::PalletFeatures; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::Get; +use sp_runtime::{ + testing::H256, + traits::{BlakeTwo256, IdentifyAccount, IdentityLookup, Verify}, + BuildStorage, MultiSignature, +}; + +pub type MockSignature = MultiSignature; +pub type MockAccountPublic = ::Signer; +pub type MockAccountId = ::AccountId; +pub type MockBlock = frame_system::mocking::MockBlock; +pub type MockBalance = u64; +pub type MockCollectionId = u32; + +impl crate::Config for Runtime {} + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub struct Runtime { + System: frame_system, + Balances: pallet_balances, + Nft: pallet_nfts, + NftStake: pallet_ajuna_nft_staking, + } +); + +impl frame_system::Config for Runtime { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = MockAccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = frame_support::traits::ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = frame_support::traits::ConstU16<42>; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; + type Nonce = u32; + type Block = MockBlock; +} + +parameter_types! { + pub const MockExistentialDeposit: MockBalance = 3; +} + +impl pallet_balances::Config for Runtime { + type Balance = MockBalance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = MockExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxHolds = (); + type MaxFreezes = (); + type RuntimeHoldReason = (); +} + +parameter_types! { + pub const CollectionDeposit: MockBalance = 333; + pub const ItemDeposit: MockBalance = 33; + pub const MetadataDepositBase: MockBalance = 0; + pub const AttributeDepositBase: MockBalance = 0; + pub const DepositPerByte: MockBalance = 0; + pub const StringLimit: u32 = 128; + pub const ApprovalsLimit: u32 = 1; + pub const ItemAttributesApprovalsLimit: u32 = 10; + pub const MaxTips: u32 = 1; + pub const MaxDeadlineDuration: u32 = 1; + pub ConfigFeatures: PalletFeatures = PalletFeatures::all_enabled(); +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct Helper; +#[cfg(feature = "runtime-benchmarks")] +impl, ItemId: From<[u8; 32]>> + pallet_nfts::BenchmarkHelper for Helper +{ + fn collection(i: u16) -> CollectionId { + i.into() + } + fn item(i: u16) -> ItemId { + let mut id = [0_u8; 32]; + let bytes = i.to_be_bytes(); + id[0] = bytes[0]; + id[1] = bytes[1]; + id.into() + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub struct ParameterGet; + +impl Get for ParameterGet { + fn get() -> u32 { + N + } +} + +pub type KeyLimit = ParameterGet<32>; +pub type ValueLimit = ParameterGet<64>; + +impl pallet_nfts::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type CollectionId = MockCollectionId; + type ItemId = H256; + type Currency = Balances; + type ForceOrigin = EnsureRoot; + type CreateOrigin = AsEnsureOriginWithArg>; + type Locker = (); + type CollectionDeposit = CollectionDeposit; + type ItemDeposit = ItemDeposit; + type MetadataDepositBase = MetadataDepositBase; + type AttributeDepositBase = AttributeDepositBase; + type DepositPerByte = DepositPerByte; + type StringLimit = StringLimit; + type KeyLimit = KeyLimit; + type ValueLimit = ValueLimit; + type ApprovalsLimit = ApprovalsLimit; + type ItemAttributesApprovalsLimit = ItemAttributesApprovalsLimit; + type MaxTips = MaxTips; + type MaxDeadlineDuration = MaxDeadlineDuration; + type MaxAttributesPerCall = frame_support::traits::ConstU32<2>; + type Features = ConfigFeatures; + type OffchainSignature = MockSignature; + type OffchainPublic = MockAccountPublic; + pallet_nfts::runtime_benchmarks_enabled! { + type Helper = Helper; + } + type WeightInfo = (); +} + +parameter_types! { + pub const NftStakingPalletId: PalletId = PalletId(*b"aj/nftst"); + pub const MaxContracts: u32 = 100; + pub const MaxStakingClauses: u32 = 10; + pub const MaxFeeClauses: u32 = 1; + pub const MaxMetadataLenght: u32 = 100; +} + +impl pallet_ajuna_nft_staking::Config for Runtime { + type PalletId = NftStakingPalletId; + type RuntimeEvent = RuntimeEvent; + type Currency = Balances; + type CollectionId = MockCollectionId; + type ItemId = H256; + type ItemConfig = pallet_nfts::ItemConfig; + type NftHelper = Nft; + type MaxContracts = MaxContracts; + type MaxStakingClauses = MaxStakingClauses; + type MaxFeeClauses = MaxFeeClauses; + type MaxMetadataLength = MaxMetadataLenght; + type KeyLimit = KeyLimit; + type ValueLimit = ValueLimit; + pallet_ajuna_nft_staking::runtime_benchmarks_enabled! { + type BenchmarkHelper = (); + } + type WeightInfo = (); +} + +pub fn new_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + sp_io::TestExternalities::new(t) +} diff --git a/pallets/tic-tac-toe/src/game.rs b/pallets/tic-tac-toe/src/game.rs new file mode 100644 index 0000000..c1b4a1e --- /dev/null +++ b/pallets/tic-tac-toe/src/game.rs @@ -0,0 +1,254 @@ +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +pub type GameId = u32; + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum Position { + #[default] + One, + Two, + Three, +} + +impl From for usize { + fn from(value: Position) -> Self { + match value { + Position::One => 0, + Position::Two => 1, + Position::Three => 2, + } + } +} + +impl From for Position { + fn from(value: usize) -> Self { + match value { + 0 => Position::One, + 1 => Position::Two, + 2 => Position::Three, + _ => Position::One, + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub struct Coordinates { + row: Position, + col: Position, +} + +impl From for (usize, usize) { + fn from(value: Coordinates) -> Self { + (value.row.into(), value.col.into()) + } +} + +impl From<(usize, usize)> for Coordinates { + fn from(value: (usize, usize)) -> Self { + Self { row: Position::from(value.0), col: Position::from(value.1) } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum CellState { + Circle, + Cross, + #[default] + Empty, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum CurrentTurn { + #[default] + PlayerOne, + PlayerTwo, +} + +impl CurrentTurn { + fn next(&self) -> Self { + match self { + CurrentTurn::PlayerOne => CurrentTurn::PlayerTwo, + CurrentTurn::PlayerTwo => CurrentTurn::PlayerOne, + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum BoardState { + MissingPlayer(AccountId), + Playing(AccountId, AccountId, CurrentTurn), + Finished(AccountId, AccountId), +} + +impl BoardState +where + AccountId: Clone, +{ + fn next_turn(&mut self) { + if let BoardState::Playing(_, _, turn) = self { + *turn = turn.next(); + }; + } +} + +impl BoardState +where + AccountId: Clone, +{ + fn to_playing(&self, other: AccountId) -> Option { + if let BoardState::MissingPlayer(player) = self { + Some(BoardState::Playing(player.clone(), other, CurrentTurn::PlayerOne)) + } else { + None + } + } + + fn to_finished(&self) -> Option { + if let BoardState::Playing(p1, p2, _) = self { + Some(BoardState::Finished(p1.clone(), p2.clone())) + } else { + None + } + } +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub enum PlayResult { + Winner(AccountId), + Draw, + InvalidTurn, + InvalidCell, + GamePending, + GameAlreadyFinished, + Continue, +} + +#[derive(Debug, Copy, Clone, Eq, PartialEq, Encode, Decode, MaxEncodedLen, TypeInfo)] +pub struct Board { + pub(crate) cells: [[CellState; 3]; 3], + pub(crate) state: BoardState, + pub(crate) moves_played: u8, +} + +impl Board +where + AccountId: Clone, +{ + pub(crate) fn new(creator: AccountId) -> Self { + Self { + cells: [[CellState::default(); 3]; 3], + state: BoardState::MissingPlayer(creator), + moves_played: 0, + } + } + + pub(crate) fn get_state(&self) -> BoardState { + self.state.clone() + } + + pub(crate) fn start_game(&mut self, rival: AccountId) + where + AccountId: Clone, + { + self.state = self.state.to_playing(rival).unwrap(); + } + + pub(crate) fn play_turn( + &mut self, + player: &AccountId, + coordinates: Coordinates, + ) -> PlayResult + where + AccountId: PartialEq + Clone, + { + match self.state { + BoardState::MissingPlayer(_) => PlayResult::GamePending, + BoardState::Playing(ref p1, ref p2, turn) => match turn { + CurrentTurn::PlayerOne => + if player == p1 { + self.execute_move(player, CellState::Circle, coordinates) + } else { + PlayResult::InvalidTurn + }, + CurrentTurn::PlayerTwo => + if player == p2 { + self.execute_move(player, CellState::Cross, coordinates) + } else { + PlayResult::InvalidTurn + }, + }, + BoardState::Finished(_, _) => PlayResult::GameAlreadyFinished, + } + } + + fn execute_move( + &mut self, + player: &AccountId, + cell_type: CellState, + coordinates: Coordinates, + ) -> PlayResult + where + AccountId: PartialEq + Clone, + { + let (row, col) = coordinates.into(); + + if self.cells[col][row] == CellState::Empty { + self.cells[col][row] = cell_type; + self.moves_played += 1; + } else { + return PlayResult::InvalidCell + } + + if self.has_player_won(cell_type) { + self.finish_game(); + PlayResult::Winner(player.clone()) + } else if self.moves_played == 9 { + self.finish_game(); + PlayResult::Draw + } else { + self.state.next_turn(); + PlayResult::Continue + } + } + + fn has_player_won(&self, cell_type: CellState) -> bool { + let mut results = self.check_row(0, cell_type) || + self.check_row(1, cell_type) || + self.check_row(2, cell_type); + + results = results || + self.check_col(0, cell_type) || + self.check_col(1, cell_type) || + self.check_col(2, cell_type); + + results = results || self.check_diagonals(cell_type); + + results + } + + fn check_row(&self, row: usize, cell_type: CellState) -> bool { + self.cells[0][row] == cell_type && + self.cells[1][row] == cell_type && + self.cells[2][row] == cell_type + } + + fn check_col(&self, col: usize, cell_type: CellState) -> bool { + self.cells[col][0] == cell_type && + self.cells[col][1] == cell_type && + self.cells[col][2] == cell_type + } + + fn check_diagonals(&self, cell_type: CellState) -> bool { + (self.cells[0][0] == cell_type && + self.cells[1][1] == cell_type && + self.cells[2][2] == cell_type) || + (self.cells[2][0] == cell_type && + self.cells[1][1] == cell_type && + self.cells[0][2] == cell_type) + } + + fn finish_game(&mut self) { + self.state = self.state.to_finished().unwrap(); + } +} diff --git a/pallets/tic-tac-toe/src/lib.rs b/pallets/tic-tac-toe/src/lib.rs new file mode 100644 index 0000000..e45f1b8 --- /dev/null +++ b/pallets/tic-tac-toe/src/lib.rs @@ -0,0 +1,231 @@ +// Ajuna Node +// Copyright (C) 2022 BlogaTech AG + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod test; + +mod game; + +use frame_support::pallet_prelude::*; +use frame_system::pallet_prelude::*; +use sp_std::prelude::*; + +pub(crate) use game::*; + +pub use pallet::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + + pub type BoardOf = Board<::AccountId>; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + } + + #[pallet::storage] + pub type ActivePlayers = StorageMap<_, Identity, T::AccountId, GameId>; + + #[pallet::storage] + pub type PendingGames = StorageMap<_, Identity, GameId, BoardOf>; + + #[pallet::storage] + pub type ActiveGames = StorageMap<_, Identity, GameId, BoardOf>; + + #[pallet::storage] + pub type NextGameId = StorageValue<_, GameId, ValueQuery>; + + #[pallet::storage] + pub type LastActiveGameId = StorageValue<_, GameId, ValueQuery>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new game has been created. + GameCreated { game_id: GameId, by: T::AccountId }, + /// A player has joined a pending game. + GameJoined { game_id: GameId, by: T::AccountId }, + /// A player has executed a move. + MovePlayed { at: Coordinates, by: T::AccountId }, + /// A game has finished, if draw then no winner will be shown. + GameFinished { game_id: GameId, winner: Option }, + } + + #[pallet::error] + pub enum Error { + /// A player that is involved in an active game cannot create new ones. + CannotCreateNewGame, + /// A player that is involved in an active game cannot join another one. + CannotJoinAnotherGame, + /// The player is nt currently involved in any game. + PlayerNotActive, + /// There are no pending games to join, please create one. + NoPendingGamesFound, + /// Tried to play while not in their turn + InvalidTurn, + /// Tried to play on an already filled cell + InvalidCell, + /// The game is in an incorrect state + InvalidGameState, + /// The game needs and additional player to start + GamePending, + /// The game is already over + GameAlreadyFinished, + } + + #[pallet::call] + impl Pallet { + #[pallet::call_index(0)] + #[pallet::weight({10_000})] + pub fn create_game(origin: OriginFor) -> DispatchResult { + let account = ensure_signed(origin)?; + + ensure!(!ActivePlayers::::contains_key(&account), Error::::CannotCreateNewGame); + + let game_id = Self::next_game_id(); + let board = BoardOf::::new(account.clone()); + + ActivePlayers::::insert(account.clone(), game_id); + PendingGames::::insert(game_id, board); + + Self::deposit_event(Event::GameCreated { game_id, by: account }); + + Ok(()) + } + + #[pallet::call_index(1)] + #[pallet::weight({10_000})] + pub fn join_pending_game(origin: OriginFor) -> DispatchResult { + let account = ensure_signed(origin)?; + ensure!(!ActivePlayers::::contains_key(&account), Error::::CannotJoinAnotherGame); + + let game_id = LastActiveGameId::::get(); + ensure!(PendingGames::::contains_key(game_id), Error::::NoPendingGamesFound); + + let game = { + let mut game = PendingGames::::take(game_id).unwrap(); + game.start_game(account.clone()); + + game + }; + + LastActiveGameId::::set(game_id.saturating_add(1) % GameId::MAX); + ActiveGames::::insert(game_id, game); + ActivePlayers::::insert(account.clone(), game_id); + + Self::deposit_event(Event::GameJoined { game_id, by: account }); + + Ok(()) + } + + #[pallet::call_index(2)] + #[pallet::weight({10_000})] + pub fn play_move(origin: OriginFor, at: Coordinates) -> DispatchResult { + let account = ensure_signed(origin)?; + + let maybe_game_id = ActivePlayers::::get(&account); + ensure!(maybe_game_id.is_some(), Error::::PlayerNotActive); + + let game_id = maybe_game_id.unwrap(); + + ActiveGames::::mutate(game_id, |maybe_game| { + if let Some(ref mut game) = maybe_game { + let results = game.play_turn(&account, at); + + match results { + PlayResult::Winner(acc) => + if let BoardState::Finished(p1, p2) = game.get_state() { + Self::remove_players(p1, p2); + Self::deposit_event(Event::::GameFinished { + game_id, + winner: Some(acc), + }); + *maybe_game = None; + Ok(()) + } else { + Self::clear_corrupt_game(game_id); + Err(Error::::InvalidGameState.into()) + }, + PlayResult::Draw => + if let BoardState::Finished(p1, p2) = game.get_state() { + Self::remove_players(p1, p2); + Self::deposit_event(Event::::GameFinished { + game_id, + winner: None, + }); + *maybe_game = None; + Ok(()) + } else { + Self::clear_corrupt_game(game_id); + Err(Error::::InvalidGameState.into()) + }, + PlayResult::InvalidTurn => Err(Error::::InvalidTurn.into()), + PlayResult::InvalidCell => Err(Error::::InvalidCell.into()), + PlayResult::GamePending => Err(Error::::GamePending.into()), + PlayResult::GameAlreadyFinished => + Err(Error::::GameAlreadyFinished.into()), + PlayResult::Continue => { + Self::deposit_event(Event::::MovePlayed { at, by: account }); + Ok(()) + }, + } + } else { + Self::clear_corrupt_game(game_id); + Err(Error::::InvalidGameState.into()) + } + }) + } + } + + impl Pallet { + fn next_game_id() -> GameId { + let next_game_id = NextGameId::::get(); + + NextGameId::::mutate(|value| *value = value.saturating_add(1) % GameId::MAX); + + next_game_id + } + + fn remove_players(player_1: T::AccountId, player_2: T::AccountId) { + ActivePlayers::::remove(player_1); + ActivePlayers::::remove(player_2); + } + + fn clear_corrupt_game(game_id: GameId) { + ActiveGames::::remove(game_id); + PendingGames::::remove(game_id); + + let accounts = ActivePlayers::::iter() + .filter(|(_, id)| *id == game_id) + .map(|(acc, _)| acc) + .collect::>(); + + for acc in accounts { + ActivePlayers::::remove(acc); + } + } + } +} + +sp_core::generate_feature_enabled_macro!(runtime_benchmarks_enabled, feature = "runtime-benchmarks", $); diff --git a/pallets/tic-tac-toe/src/test/mock.rs b/pallets/tic-tac-toe/src/test/mock.rs new file mode 100644 index 0000000..550fa00 --- /dev/null +++ b/pallets/tic-tac-toe/src/test/mock.rs @@ -0,0 +1,119 @@ +// Ajuna Node +// Copyright (C) 2022 BlogaTech AG + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::{self as pallet_tic_tac_toe, *}; +use frame_support::{ + parameter_types, + traits::{ConstU16, ConstU64, Hooks}, +}; +use sp_runtime::{ + testing::H256, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +pub type MockBlock = frame_system::mocking::MockBlock; +pub type MockAccountId = u32; +pub type MockBalance = u64; +pub type MockNonce = u64; + +pub const ALICE: MockAccountId = 1; +pub const BOB: MockAccountId = 2; +pub const CHARLIE: MockAccountId = 3; + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub struct Test { + System: frame_system, + Balances: pallet_balances, + TicTacToe: pallet_tic_tac_toe, + } +); + +impl frame_system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = MockAccountId; + type Lookup = IdentityLookup; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = ConstU16<42>; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; + type Nonce = MockNonce; + type Block = MockBlock; +} + +parameter_types! { + pub const MockExistentialDeposit: MockBalance = 3; +} + +impl pallet_balances::Config for Test { + type Balance = MockBalance; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = MockExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxHolds = (); + type MaxFreezes = (); + type RuntimeHoldReason = (); +} + +impl pallet_tic_tac_toe::Config for Test { + type RuntimeEvent = RuntimeEvent; +} + +pub struct ExtBuilder; + +impl ExtBuilder { + pub fn build(self) -> sp_io::TestExternalities { + let config = + RuntimeGenesisConfig { system: Default::default(), balances: Default::default() }; + + let mut ext: sp_io::TestExternalities = config.build_storage().unwrap().into(); + ext.execute_with(|| System::set_block_number(1)); + ext + } +} + +pub fn run_to_block(n: u64) { + while System::block_number() < n { + if System::block_number() > 1 { + TicTacToe::on_finalize(System::block_number()); + System::on_finalize(System::block_number()); + } + System::set_block_number(System::block_number() + 1); + System::on_initialize(System::block_number()); + TicTacToe::on_initialize(System::block_number()); + } +} diff --git a/pallets/tic-tac-toe/src/test/mod.rs b/pallets/tic-tac-toe/src/test/mod.rs new file mode 100644 index 0000000..99ec1da --- /dev/null +++ b/pallets/tic-tac-toe/src/test/mod.rs @@ -0,0 +1,18 @@ +// Ajuna Node +// Copyright (C) 2022 BlogaTech AG + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. + +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod mock; +mod tests; diff --git a/pallets/tic-tac-toe/src/test/tests.rs b/pallets/tic-tac-toe/src/test/tests.rs new file mode 100644 index 0000000..553d4f8 --- /dev/null +++ b/pallets/tic-tac-toe/src/test/tests.rs @@ -0,0 +1,390 @@ +use crate::{test::mock::*, *}; +use frame_support::{assert_noop, assert_ok}; + +mod create { + use super::*; + + #[test] + fn can_create_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + run_to_block(2); + + let game_id = 0; + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(game_id)); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::GameCreated { + game_id, + by: ALICE, + })); + }); + } + + #[test] + fn different_accounts_can_create_different_games() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(0)); + + run_to_block(2); + + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(BOB)); + assert!(PendingGames::::contains_key(1)); + }); + } + + #[test] + fn cannot_create_mutliple_games_with_signle_account() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(0)); + + run_to_block(2); + + assert_noop!( + TicTacToe::create_game(RuntimeOrigin::signed(ALICE)), + Error::::CannotCreateNewGame + ); + }); + } +} + +mod join { + use super::*; + + #[test] + fn can_join_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + let game_id = 0; + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(game_id)); + + run_to_block(2); + + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::GameJoined { + game_id, + by: BOB, + })); + + assert!(ActivePlayers::::contains_key(BOB)); + + assert!(!PendingGames::::contains_key(0)); + assert!(ActiveGames::::contains_key(0)); + }); + } + + #[test] + fn cannot_join_game_if_host() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(0)); + + run_to_block(2); + + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(BOB)); + assert!(PendingGames::::contains_key(1)); + + assert_noop!( + TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB)), + Error::::CannotJoinAnotherGame + ); + }); + } + + #[test] + fn cannot_join_own_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(PendingGames::::contains_key(0)); + + run_to_block(2); + + assert_noop!( + TicTacToe::join_pending_game(RuntimeOrigin::signed(ALICE)), + Error::::CannotJoinAnotherGame + ); + }); + } + + #[test] + fn cannot_join_started_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + run_to_block(2); + + assert_noop!( + TicTacToe::join_pending_game(RuntimeOrigin::signed(CHARLIE)), + Error::::NoPendingGamesFound + ); + }); + } +} + +mod play { + use super::*; + + #[test] + fn can_play_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(ActivePlayers::::contains_key(BOB)); + + assert!(ActiveGames::::contains_key(0)); + + run_to_block(2); + + let coords = Coordinates::from((0, 0)); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(ALICE), coords)); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::MovePlayed { + at: coords, + by: ALICE, + })); + + let game = ActiveGames::::get(0).unwrap(); + assert_eq!( + game.cells, + [ + [CellState::Circle, CellState::Empty, CellState::Empty], + [CellState::Empty; 3], + [CellState::Empty; 3] + ] + ); + assert_eq!(game.state, BoardState::Playing(ALICE, BOB, CurrentTurn::PlayerTwo)); + assert_eq!(game.moves_played, 1); + + run_to_block(5); + + let coords = Coordinates::from((1, 2)); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), coords)); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::MovePlayed { + at: coords, + by: BOB, + })); + + let game = ActiveGames::::get(0).unwrap(); + assert_eq!( + game.cells, + [ + [CellState::Circle, CellState::Empty, CellState::Empty], + [CellState::Empty; 3], + [CellState::Empty, CellState::Cross, CellState::Empty] + ] + ); + assert_eq!(game.state, BoardState::Playing(ALICE, BOB, CurrentTurn::PlayerOne)); + assert_eq!(game.moves_played, 2); + }); + } + + #[test] + fn can_win_game_horizontal() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(ActivePlayers::::contains_key(BOB)); + + let game_id = 0; + + assert!(ActiveGames::::contains_key(game_id)); + + run_to_block(2); + + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((0, 0)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((0, 2)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((1, 0)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((0, 1)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((2, 0)) + )); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::GameFinished { + game_id, + winner: Some(ALICE), + })); + + assert!(!ActivePlayers::::contains_key(ALICE)); + assert!(!ActivePlayers::::contains_key(BOB)); + assert!(!PendingGames::::contains_key(game_id)); + assert!(!ActiveGames::::contains_key(game_id)); + }); + } + + #[test] + fn can_win_game_diagonal() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(CHARLIE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(CHARLIE)); + assert!(ActivePlayers::::contains_key(BOB)); + + let game_id = 0; + + assert!(ActiveGames::::contains_key(game_id)); + + run_to_block(2); + + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(CHARLIE), + Coordinates::from((2, 0)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((0, 0)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(CHARLIE), + Coordinates::from((2, 1)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((1, 1)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(CHARLIE), + Coordinates::from((0, 2)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((2, 2)))); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::GameFinished { + game_id, + winner: Some(BOB), + })); + + assert!(!ActivePlayers::::contains_key(CHARLIE)); + assert!(!ActivePlayers::::contains_key(BOB)); + assert!(!PendingGames::::contains_key(game_id)); + assert!(!ActiveGames::::contains_key(game_id)); + }); + } + + #[test] + fn can_draw_game() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(ALICE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(ALICE)); + assert!(ActivePlayers::::contains_key(BOB)); + + let game_id = 0; + + assert!(ActiveGames::::contains_key(game_id)); + + run_to_block(2); + + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((0, 0)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((0, 1)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((0, 2)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((1, 0)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((1, 2)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((1, 1)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((2, 0)) + )); + assert_ok!(TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((2, 2)))); + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(ALICE), + Coordinates::from((2, 1)) + )); + + System::assert_last_event(RuntimeEvent::TicTacToe(crate::Event::GameFinished { + game_id, + winner: None, + })); + + assert!(!ActivePlayers::::contains_key(ALICE)); + assert!(!ActivePlayers::::contains_key(BOB)); + assert!(!PendingGames::::contains_key(game_id)); + assert!(!ActiveGames::::contains_key(game_id)); + }); + } + + #[test] + fn cannot_play_out_of_turn() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(CHARLIE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(CHARLIE)); + assert!(ActivePlayers::::contains_key(BOB)); + + let game_id = 0; + + assert!(ActiveGames::::contains_key(game_id)); + + run_to_block(2); + + assert_noop!( + TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((2, 0))), + Error::::InvalidTurn + ); + }); + } + + #[test] + fn cannot_play_on_already_marked_cell() { + ExtBuilder.build().execute_with(|| { + assert_ok!(TicTacToe::create_game(RuntimeOrigin::signed(CHARLIE))); + assert_ok!(TicTacToe::join_pending_game(RuntimeOrigin::signed(BOB))); + + assert!(ActivePlayers::::contains_key(CHARLIE)); + assert!(ActivePlayers::::contains_key(BOB)); + + let game_id = 0; + + assert!(ActiveGames::::contains_key(game_id)); + + run_to_block(2); + + assert_ok!(TicTacToe::play_move( + RuntimeOrigin::signed(CHARLIE), + Coordinates::from((2, 0)) + )); + + assert_noop!( + TicTacToe::play_move(RuntimeOrigin::signed(BOB), Coordinates::from((2, 0))), + Error::::InvalidCell + ); + }); + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 1b70644..94efe1b 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -52,6 +52,7 @@ pallet-transaction-payment-rpc-runtime-api = { workspace = true } # Local Dependencies pallet-hexalem = { workspace = true } +pallet-tic-tac-toe = { workspace = true } [build-dependencies] substrate-wasm-builder = { workspace = true } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 98020d0..ad7bcd8 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -471,6 +471,10 @@ impl pallet_hexalem::Config for Runtime { type TargetGoalHuman = HexalemTargetGoalHuman; } +impl pallet_tic_tac_toe::Config for Runtime { + type RuntimeEvent = RuntimeEvent; +} + // Create the runtime by composing the FRAME pallets that were previously configured. construct_runtime!( pub struct Runtime { @@ -481,6 +485,7 @@ construct_runtime!( Balances: pallet_balances = 10, TransactionPayment: pallet_transaction_payment = 11, HexalemModule: pallet_hexalem = 21, + TicTacToe: pallet_tic_tac_toe = 22, Aura: pallet_aura = 100, Grandpa: pallet_grandpa = 101, }