soroban_snooker/
lib.rs

1/*
2   Date: August 2023
3   Author: Fred Kyung-jin Rezeau <fred@litemint.com>
4   MIT License
5*/
6
7#![no_std]
8
9mod pool;
10
11use soroban_kit::{storage, soroban_tools};
12
13use pool::{Ball, Pocket, Pool};
14use soroban_sdk::{
15    contract, contracterror, contractimpl, contractmeta, contracttype, token, vec, Address, Env,
16    Vec,
17};
18
19contractmeta!(key="desc", val="A snooker game contract with pool physics validation, optional payments and rewards, on Soroban.");
20
21const MAX_BALLS: u32 = 5;
22
23#[contracttype]
24pub enum DataKey {
25    Admin,
26    Table(Address),
27    LedgerTime(Address),
28}
29
30#[storage(Temporary)]
31#[contracttype]
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct Table {
34    pub balls: Vec<Ball>,
35    pub pockets: Vec<Pocket>,
36}
37
38#[storage(Temporary)]
39#[contracttype]
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct Session {
42    pub ledger_time: u64,
43}
44
45#[storage(Instance)]
46#[contracttype]
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct Admin {
49    pub admin: Address,
50    pub payment_token: Address,
51    pub payment_amount: i128,
52    pub reward_token: Address,
53    pub reward_amount: i128,
54}
55
56#[contracterror]
57#[derive(Copy, Clone, Debug)]
58#[repr(u32)]
59pub enum Error {
60    NoAdmin = 1,
61    AlreadyInitialized = 2,
62    InvalidPoolTable = 3,
63}
64
65// Simple RNG for randomizing balls position on the table.
66fn rand(x: &mut u64) -> u16 {
67    *x ^= *x << 21;
68    *x ^= *x >> 35;
69    *x ^= *x << 4;
70    // Use a bitmask to restrict the value to the range [0, 16383]
71    let mask: u64 = (1 << 14) - 1; // 2^14 - 1 = 16383
72    let masked_value = *x & mask;
73    (masked_value as u16) % 5001 + 2500
74}
75
76#[contract]
77struct Snooker;
78
79pub trait SnookerTrait {
80    fn insertcoin(env: Env, player: Address) -> Result<Table, Error>;
81    fn play(env: Env, player: Address, cue_balls: Vec<Ball>) -> Result<u32, Error>;
82    fn initialize(
83        env: Env,
84        admin: Address,
85        payment_token: Address,
86        payment_amount: i128,
87        reward_token: Address,
88        reward_amount: i128,
89    ) -> Result<bool, Error>;
90    fn withdraw(env: Env, account: Address, amount: i128) -> Result<i128, Error>;
91}
92
93#[contractimpl]
94impl SnookerTrait for Snooker {
95    fn insertcoin(env: Env, player: Address) -> Result<Table, Error> {
96        if !storage::has::<DataKey, Admin>(&env, &DataKey::Admin) {
97            return Err(Error::NoAdmin);
98        }
99
100        player.require_auth();
101
102        // Pay contract if required.
103        let admin_data = storage::get::<DataKey, Admin>(&env, &DataKey::Admin).unwrap();
104
105        if admin_data.payment_amount > 0 {
106            let token = token::Client::new(&env, &admin_data.payment_token);
107            token.transfer(
108                &player,
109                &env.current_contract_address(),
110                &admin_data.payment_amount,
111            );
112        }
113
114        // Xorshift RNG is sufficient to randomize the snooker table objects.
115        // Seed with ledger timestamp and sequence.
116        let ledger = env.ledger();
117        let mut seed = ledger.timestamp() + u64::from(ledger.sequence() + 1);
118        let mut balls: Vec<Ball> = vec![&env];
119        let mut pockets: Vec<Pocket> = vec![&env];
120        for _i in 0..MAX_BALLS {
121            balls.push_back(Ball(i128::from(rand(&mut seed)), 6000, 0, 0));
122            pockets.push_back(Pocket(i128::from(rand(&mut seed)), 2000));
123        }
124
125        // Our game data is transient so we use the
126        // cheaper temporary storage, no need for ESS.
127
128        // Store the ledger timestamp.
129        storage::set::<DataKey, Session>(
130            &env,
131            &DataKey::LedgerTime(player.clone()),
132            &Session {
133                ledger_time: ledger.timestamp(),
134            },
135        );
136
137        // Store and return the table.
138        let table_key = DataKey::Table(player.clone());
139        let table = Table { balls, pockets };
140        storage::set::<DataKey, Table>(&env, &table_key, &table);
141        Ok(table)
142    }
143
144    fn play(env: Env, player: Address, cue_balls: Vec<Ball>) -> Result<u32, Error> {
145        player.require_auth();
146
147        // Retrieve the ledger timestamp.
148        let stamp: u64 =
149            storage::get::<DataKey, Session>(&env, &DataKey::LedgerTime(player.clone()))
150                .unwrap()
151                .ledger_time;
152
153        // Retrieve the table.
154        let table_key = &DataKey::Table(player.clone());
155        let table: Table = storage::get::<DataKey, Table>(&env, &table_key).unwrap();
156
157        // Some sanity check.
158        if stamp + 180 < env.ledger().timestamp()
159            || table.balls.len() < MAX_BALLS
160            || table.pockets.len() < MAX_BALLS
161        {
162            return Err(Error::InvalidPoolTable);
163        }
164
165        storage::remove::<DataKey, Table>(&env, &table_key);
166
167        // Implements basic scoring rules (not actual snooker) based on
168        // the winning streak length. 147 still represents the maximum break.
169        let mut score = 0;
170        let mut streak = 0;
171        for i in 0..MAX_BALLS {
172            let cue_ball = cue_balls.get(i).unwrap();
173            let ball = table.balls.get(i).unwrap();
174            let pocket = table.pockets.get(i).unwrap();
175            let mut pool = Pool(cue_ball, ball, pocket);
176
177            // A ball is validated as potted if it collides with pocket
178            // after transfer of momentum following collision with cue ball.
179            if pool.is_potted(&env) {
180                streak += 1;
181                if score == 0 {
182                    score += 12;
183                }
184            } else {
185                streak = 0;
186            }
187            score += streak * 9;
188            if i == MAX_BALLS - 1 {
189                break;
190            }
191        }
192
193        let admin_data = storage::get::<DataKey, Admin>(&env, &DataKey::Admin).unwrap();
194
195        // Reward player for achieving maximum break (147).
196        if score == 147 {
197            if admin_data.reward_amount > 0 {
198                let reward_contract: Address = admin_data.reward_token;
199                let reward_token = token::Client::new(&env, &reward_contract);
200                reward_token.transfer(
201                    &env.current_contract_address(),
202                    &player,
203                    &admin_data.reward_amount,
204                );
205            }
206        }
207
208        Ok(score)
209    }
210
211    fn initialize(
212        env: Env,
213        admin: Address,
214        payment_token: Address,
215        payment_amount: i128,
216        reward_token: Address,
217        reward_amount: i128,
218    ) -> Result<bool, Error> {
219        if storage::has::<DataKey, Admin>(&env, &DataKey::Admin) {
220            return Err(Error::AlreadyInitialized);
221        }
222
223        storage::set::<DataKey, Admin>(
224            &env,
225            &DataKey::Admin,
226            &Admin {
227                admin,
228                payment_token,
229                payment_amount,
230                reward_token,
231                reward_amount,
232            },
233        );
234        Ok(true)
235    }
236
237    fn withdraw(env: Env, account: Address, amount: i128) -> Result<i128, Error> {
238        if !storage::has::<DataKey, Admin>(&env, &DataKey::Admin) {
239            return Err(Error::NoAdmin);
240        }
241
242        let admin_data = storage::get::<DataKey, Admin>(&env, &DataKey::Admin).unwrap();
243
244        admin_data.admin.require_auth();
245
246        let reward_token = token::Client::new(&env, &admin_data.reward_token);
247        let balance = reward_token.balance(&env.current_contract_address());
248        if amount <= balance {
249            reward_token.transfer(&env.current_contract_address(), &account, &amount);
250        }
251        Ok(balance)
252    }
253}
254
255mod test;