From 9084b89da80953cb781913ba526f77a9a3b12714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Rodr=C3=ADguez?= Date: Fri, 8 Sep 2023 18:29:01 +0200 Subject: [PATCH] feat: Example card game (#2135) Resolves https://github.com/AztecProtocol/aztec-packages/issues/1463 --- .circleci/config.yml | 13 + yarn-project/acir-simulator/src/acvm/acvm.ts | 2 +- .../end-to-end/src/e2e_card_game.test.ts | 288 ++++++++++++++++++ .../contracts/card_game_contract/Nargo.toml | 9 + .../contracts/card_game_contract/src/cards.nr | 235 ++++++++++++++ .../contracts/card_game_contract/src/game.nr | 172 +++++++++++ .../contracts/card_game_contract/src/main.nr | 270 ++++++++++++++++ 7 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 yarn-project/end-to-end/src/e2e_card_game.test.ts create mode 100644 yarn-project/noir-contracts/src/contracts/card_game_contract/Nargo.toml create mode 100644 yarn-project/noir-contracts/src/contracts/card_game_contract/src/cards.nr create mode 100644 yarn-project/noir-contracts/src/contracts/card_game_contract/src/game.nr create mode 100644 yarn-project/noir-contracts/src/contracts/card_game_contract/src/main.nr diff --git a/.circleci/config.yml b/.circleci/config.yml index c82c96bcf7e..71834a0ccb3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -947,6 +947,17 @@ jobs: command: ./scripts/cond_run_script end-to-end $JOB_NAME ./scripts/run_tests_local e2e_aztec_js_browser.test.ts ./scripts/docker-compose-e2e-sandbox.yml working_directory: yarn-project/end-to-end + e2e-card-game: + machine: + image: ubuntu-2004:202010-01 + steps: + - *checkout + - *setup_env + - run: + name: "Test" + command: ./scripts/cond_run_script end-to-end $JOB_NAME ./scripts/run_tests_local e2e_card_game.test.ts + working_directory: yarn-project/end-to-end + aztec-rpc-sandbox: machine: image: ubuntu-2004:202010-01 @@ -1416,6 +1427,7 @@ workflows: - e2e-p2p: *e2e_test - e2e-canary-test: *e2e_test - e2e-browser-sandbox: *e2e_test + - e2e-card-game: *e2e_test - aztec-rpc-sandbox: *e2e_test - guides-writing-an-account-contract: *e2e_test - guides-dapp-testing: *e2e_test @@ -1447,6 +1459,7 @@ workflows: - e2e-p2p - e2e-browser-sandbox - e2e-canary-test + - e2e-card-game - aztec-rpc-sandbox - guides-writing-an-account-contract - guides-dapp-testing diff --git a/yarn-project/acir-simulator/src/acvm/acvm.ts b/yarn-project/acir-simulator/src/acvm/acvm.ts index 505d0e039d4..82c0e53e22b 100644 --- a/yarn-project/acir-simulator/src/acvm/acvm.ts +++ b/yarn-project/acir-simulator/src/acvm/acvm.ts @@ -91,7 +91,7 @@ function getSourceCodeLocationsFromOpcodeLocation( const { path, source } = files[fileId]; - const locationText = source.substring(span.start, span.end + 1); + const locationText = source.substring(span.start, span.end); const precedingText = source.substring(0, span.start); const previousLines = precedingText.split('\n'); // Lines and columns in stacks are one indexed. diff --git a/yarn-project/end-to-end/src/e2e_card_game.test.ts b/yarn-project/end-to-end/src/e2e_card_game.test.ts new file mode 100644 index 00000000000..cc2c2163630 --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_card_game.test.ts @@ -0,0 +1,288 @@ +import { AztecNodeService } from '@aztec/aztec-node'; +import { AztecRPCServer } from '@aztec/aztec-rpc'; +import { AztecAddress, Wallet } from '@aztec/aztec.js'; +import { DebugLogger } from '@aztec/foundation/log'; +import { CardGameContract } from '@aztec/noir-contracts/types'; +import { AztecRPC, CompleteAddress } from '@aztec/types'; + +import { setup } from './fixtures/utils.js'; + +/* eslint-disable camelcase */ + +interface Card { + points: bigint; + strength: bigint; +} + +const cardToField = (card: Card): bigint => { + return card.strength + card.points * 65536n; +}; + +interface PlayerGameEntry { + address: bigint; + deck_strength: bigint; + points: bigint; +} + +interface Game { + players: PlayerGameEntry[]; + rounds_cards: Card[]; + started: boolean; + finished: boolean; + claimed: boolean; + current_player: bigint; + current_round: bigint; +} + +interface NoirOption { + _is_some: boolean; + _value: T; +} + +function unwrapOptions(options: NoirOption[]): T[] { + return options.filter((option: any) => option._is_some).map((option: any) => option._value); +} + +const GAME_ID = 42; + +describe('e2e_card_game', () => { + let aztecNode: AztecNodeService | undefined; + let aztecRpcServer: AztecRPC; + let wallet: Wallet; + let logger: DebugLogger; + let firstPlayer: AztecAddress; + let secondPlayer: AztecAddress; + let thirdPlayer: AztecAddress; + + let contract: CardGameContract; + + beforeEach(async () => { + let accounts: CompleteAddress[]; + ({ aztecNode, aztecRpcServer, accounts, wallet, logger } = await setup(3)); + firstPlayer = accounts[0].address; + secondPlayer = accounts[1].address; + thirdPlayer = accounts[2].address; + await deployContract(); + }, 100_000); + + afterEach(async () => { + await aztecNode?.stop(); + if (aztecRpcServer instanceof AztecRPCServer) { + await aztecRpcServer?.stop(); + } + }); + + const deployContract = async () => { + logger(`Deploying L2 contract...`); + contract = await CardGameContract.deploy(wallet).send().deployed(); + logger(`L2 contract deployed at ${contract.address}`); + }; + + const firstPlayerCollection: Card[] = [ + { + points: 45778n, + strength: 7074n, + }, + { + points: 60338n, + strength: 53787n, + }, + { + points: 13035n, + strength: 45778n, + }, + ]; + + it('should be able to buy packs', async () => { + await contract.methods.buy_pack(27n).send({ origin: firstPlayer }).wait(); + const collection = await contract.methods.view_collection_cards(firstPlayer, 0).view({ from: firstPlayer }); + expect(unwrapOptions(collection)).toEqual(firstPlayerCollection); + }, 30_000); + + describe('game join', () => { + beforeEach(async () => { + await Promise.all([ + contract.methods.buy_pack(27n).send({ origin: firstPlayer }).wait(), + contract.methods.buy_pack(27n).send({ origin: secondPlayer }).wait(), + ]); + }, 30_000); + + it('should be able to join games', async () => { + await contract.methods + .join_game(GAME_ID, [cardToField(firstPlayerCollection[0]), cardToField(firstPlayerCollection[2])]) + .send({ origin: firstPlayer }) + .wait(); + + await expect( + contract.methods + .join_game(GAME_ID, [cardToField(firstPlayerCollection[0]), cardToField(firstPlayerCollection[1])]) + .send({ origin: secondPlayer }) + .wait(), + ).rejects.toThrow(/Card not found/); + + const collection = await contract.methods.view_collection_cards(firstPlayer, 0).view({ from: firstPlayer }); + expect(unwrapOptions(collection)).toEqual([ + { + points: 60338n, + strength: 53787n, + }, + ]); + + expect((await contract.methods.view_game(GAME_ID).view({ from: firstPlayer })) as Game).toMatchObject({ + players: [ + { + address: firstPlayer.toBigInt(), + deck_strength: 52852n, + points: 0n, + }, + { + address: 0n, + deck_strength: 0n, + points: 0n, + }, + ], + started: false, + finished: false, + claimed: false, + current_player: 0n, + }); + }, 30_000); + + it('should start games', async () => { + const secondPlayerCollection = unwrapOptions( + (await contract.methods + .view_collection_cards(secondPlayer, 0) + .view({ from: secondPlayer })) as NoirOption[], + ); + + await Promise.all([ + contract.methods + .join_game(GAME_ID, [cardToField(firstPlayerCollection[0]), cardToField(firstPlayerCollection[2])]) + .send({ origin: firstPlayer }) + .wait(), + contract.methods + .join_game(GAME_ID, [cardToField(secondPlayerCollection[0]), cardToField(secondPlayerCollection[2])]) + .send({ origin: secondPlayer }) + .wait(), + ]); + + await contract.methods.start_game(GAME_ID).send({ origin: firstPlayer }).wait(); + + expect((await contract.methods.view_game(GAME_ID).view({ from: firstPlayer })) as Game).toMatchObject({ + players: expect.arrayContaining([ + { + address: firstPlayer.toBigInt(), + deck_strength: 52852n, + points: 0n, + }, + { + address: secondPlayer.toBigInt(), + deck_strength: expect.anything(), + points: 0n, + }, + ]), + started: true, + finished: false, + claimed: false, + current_player: 0n, + }); + }, 30_000); + }); + + describe('game play', () => { + let secondPlayerCollection: Card[]; + let thirdPlayerCOllection: Card[]; + + beforeEach(async () => { + await Promise.all([ + contract.methods.buy_pack(27n).send({ origin: firstPlayer }).wait(), + contract.methods.buy_pack(27n).send({ origin: secondPlayer }).wait(), + contract.methods.buy_pack(27n).send({ origin: thirdPlayer }).wait(), + ]); + + secondPlayerCollection = unwrapOptions( + await contract.methods.view_collection_cards(secondPlayer, 0).view({ from: secondPlayer }), + ); + + thirdPlayerCOllection = unwrapOptions( + await contract.methods.view_collection_cards(thirdPlayer, 0).view({ from: thirdPlayer }), + ); + }, 60_000); + + async function joinGame(playerAddress: AztecAddress, cards: Card[], id = GAME_ID) { + await contract.methods.join_game(id, cards.map(cardToField)).send({ origin: playerAddress }).wait(); + } + + async function playGame(playerDecks: { address: AztecAddress; deck: Card[] }[], id = GAME_ID) { + const initialGameState = (await contract.methods.view_game(id).view({ from: firstPlayer })) as Game; + const players = initialGameState.players.map(player => player.address); + const cards = players.map( + player => playerDecks.find(playerDeckEntry => playerDeckEntry.address.toBigInt() === player)!.deck, + ); + + for (let roundIndex = 0; roundIndex < cards.length; roundIndex++) { + for (let playerIndex = 0; playerIndex < players.length; playerIndex++) { + const player = players[playerIndex]; + const card = cards[playerIndex][roundIndex]; + await contract.methods + .play_card(id, card) + .send({ origin: AztecAddress.fromBigInt(player) }) + .wait(); + } + } + + const finalGameState = (await contract.methods.view_game(id).view({ from: firstPlayer })) as Game; + + expect(finalGameState.finished).toBe(true); + return finalGameState; + } + + it('should play a game, claim the winned cards and play another match with winned cards', async () => { + const firstPlayerGameDeck = [firstPlayerCollection[0], firstPlayerCollection[2]]; + const secondPlayerGameDeck = [secondPlayerCollection[0], secondPlayerCollection[2]]; + await Promise.all([joinGame(firstPlayer, firstPlayerGameDeck), joinGame(secondPlayer, secondPlayerGameDeck)]); + await contract.methods.start_game(GAME_ID).send({ origin: firstPlayer }).wait(); + + let game = await playGame([ + { address: firstPlayer, deck: firstPlayerGameDeck }, + { address: secondPlayer, deck: secondPlayerGameDeck }, + ]); + + const sotedByPoints = game.players.sort((a, b) => Number(b.points - a.points)); + const winner = AztecAddress.fromBigInt(sotedByPoints[0].address); + const loser = AztecAddress.fromBigInt(sotedByPoints[1].address); + + await expect( + contract.methods.claim_cards(GAME_ID, game.rounds_cards.map(cardToField)).send({ origin: loser }).wait(), + ).rejects.toThrow(/Not the winner/); + + await contract.methods.claim_cards(GAME_ID, game.rounds_cards.map(cardToField)).send({ origin: winner }).wait(); + + const winnerCollection = unwrapOptions( + (await contract.methods.view_collection_cards(winner, 0).view({ from: winner })) as NoirOption[], + ); + + const winnerGameDeck = [winnerCollection[0], winnerCollection[3]]; + const thirdPlayerGameDeck = [thirdPlayerCOllection[0], thirdPlayerCOllection[2]]; + + await Promise.all([ + joinGame(winner, winnerGameDeck, GAME_ID + 1), + joinGame(thirdPlayer, thirdPlayerGameDeck, GAME_ID + 1), + ]); + + await contract.methods + .start_game(GAME_ID + 1) + .send({ origin: winner }) + .wait(); + game = await playGame( + [ + { address: winner, deck: winnerGameDeck }, + { address: thirdPlayer, deck: thirdPlayerGameDeck }, + ], + GAME_ID + 1, + ); + + expect(game.finished).toBe(true); + }, 180_000); + }); +}); diff --git a/yarn-project/noir-contracts/src/contracts/card_game_contract/Nargo.toml b/yarn-project/noir-contracts/src/contracts/card_game_contract/Nargo.toml new file mode 100644 index 00000000000..40f9e3fbffa --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/card_game_contract/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "card_game_contract" +authors = [""] +compiler_version = "0.1" +type = "contract" + +[dependencies] +aztec = { path = "../../../../noir-libs/noir-aztec" } +value_note = { path = "../../../../noir-libs/value-note"} diff --git a/yarn-project/noir-contracts/src/contracts/card_game_contract/src/cards.nr b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/cards.nr new file mode 100644 index 00000000000..e0bb5a538b7 --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/cards.nr @@ -0,0 +1,235 @@ +use dep::aztec::{ + context::{PrivateContext, PublicContext}, + constants_gen::{MAX_NOTES_PER_PAGE, MAX_READ_REQUESTS_PER_CALL}, + log::emit_encrypted_log, + note::{ + note_getter_options::NoteGetterOptions, + note_viewer_options::NoteViewerOptions, + note_getter::view_notes, + }, + oracle::{ + get_public_key::get_public_key, + get_secret_key::get_secret_key, + }, + state_vars::set::Set, + types::point::Point, +}; +use dep::std; +use dep::std::{ + option::Option, +}; +use dep::value_note::{ + value_note::{ValueNote, ValueNoteMethods, VALUE_NOTE_LEN}, +}; + +struct Card { + strength: u16, + points: u16, +} + +impl Card { + fn from_field(field: Field) -> Card { + let value_bytes = field.to_le_bytes(32); + let strength = (value_bytes[0] as u16) + (value_bytes[1] as u16) * 256; + let points = (value_bytes[2] as u16) + (value_bytes[3] as u16) * 256; + Card { + strength, + points, + } + } + + fn to_field(self) -> Field { + self.strength as Field + (self.points as Field)*65536 + } + + fn serialize(self) -> [Field; 2] { + [self.strength as Field, self.points as Field] + } +} + +#[test] +fn test_to_from_field() { + let field = 1234567890; + let card = Card::from_field(field); + assert(card.to_field() == field); +} + + +struct CardNote { + card: Card, + note: ValueNote, +} + +impl CardNote { + fn new( + strength: u16, + points: u16, + owner: Field, + ) -> Self { + let card = Card { + strength, + points, + }; + CardNote::from_card(card, owner) + } + + fn from_card(card: Card, owner: Field) -> CardNote { + CardNote { + card, + note: ValueNote::new(card.to_field(), owner), + } + } + + fn from_note(note: ValueNote) -> CardNote { + CardNote { + card: Card::from_field(note.value), + note, + } + } +} + +struct Deck { + set: Set, +} + +fn filter_cards(notes: [Option; MAX_READ_REQUESTS_PER_CALL], desired_cards: [Card; N]) -> [Option; MAX_READ_REQUESTS_PER_CALL] { + let mut selected = [Option::none(); MAX_READ_REQUESTS_PER_CALL]; + + let mut found = [false; N]; + + for i in 0..notes.len() { + let note = notes[i]; + if note.is_some() { + let card_note = CardNote::from_note( + note.unwrap_unchecked() + ); + for j in 0..N { + if !found[j] & (card_note.card.strength == desired_cards[j].strength) & (card_note.card.points == desired_cards[j].points) { + selected[i] = note; + found[j] = true; + } + } + } + + } + + selected +} + + +impl Deck { + fn new( + private_context: Option<&mut PrivateContext>, + public_context: Option<&mut PublicContext>, + storage_slot: Field, + ) -> Self { + let set = Set { + private_context, + public_context, + storage_slot, + note_interface: ValueNoteMethods, + }; + Deck { + set + } + } + + fn add_cards(&mut self, cards: [Card; N], owner: Field) -> [CardNote]{ + let owner_key = get_public_key(owner); + let context = self.set.private_context.unwrap(); + + let mut inserted_cards = []; + for card in cards { + let mut card_note = CardNote::from_card(card, owner); + self.set.insert(&mut card_note.note); + emit_encrypted_log( + context, + (*context).this_address(), + self.set.storage_slot, + owner_key, + card_note.note.serialise(), + ); + inserted_cards = inserted_cards.push_back(card_note); + } + + inserted_cards + } + + fn get_cards(&mut self, cards: [Card; N], owner: Field) -> [CardNote; N] { + let options = NoteGetterOptions::with_filter(filter_cards, cards); + let maybe_notes = self.set.get_notes(options); + let mut found_cards = [Option::none(); N]; + for i in 0..maybe_notes.len() { + if maybe_notes[i].is_some() { + let card_note = CardNote::from_note( + maybe_notes[i].unwrap_unchecked() + ); + // Ensure the notes are actually owned by the owner (to prevent user from generating a valid proof while + // spending someone else's notes). + assert(card_note.note.owner == owner); + + for j in 0..cards.len() { + if found_cards[j].is_none() & (cards[j].strength == card_note.card.strength) & (cards[j].points == card_note.card.points) { + found_cards[j] = Option::some(card_note); + } + } + } + } + + found_cards.map(|card_note: Option| { + assert(card_note.is_some(), "Card not found"); + card_note.unwrap_unchecked() + }) + } + + fn remove_cards(&mut self, cards: [Card; N], owner: Field) { + let card_notes = self.get_cards(cards, owner); + for card_note in card_notes { + self.set.remove(card_note.note); + } + } + + unconstrained fn view_cards(self, offset: u32) -> [Option; MAX_NOTES_PER_PAGE] { + let options = NoteViewerOptions::new().set_offset(offset); + let opt_notes = self.set.view_notes(options); + let mut opt_cards = [Option::none(); MAX_NOTES_PER_PAGE]; + + for i in 0..opt_notes.len() { + opt_cards[i] = opt_notes[i].map(|note: ValueNote| Card::from_field(note.value)); + } + + opt_cards + } + +} + +global PACK_CARDS = 3; // Limited by number of write requests (max 4) + +fn get_pack_cards( + seed: Field, + owner_address: Field +) -> [Card; PACK_CARDS] { + // generate pseudo randomness deterministically from 'seed' and user secret + let secret = get_secret_key(owner_address); + let mix = secret.high + secret.low + seed; + let random_bytes = std::hash::sha256(mix.to_le_bytes(32)); + + let mut cards = [Card::from_field(0); PACK_CARDS]; + // we generate PACK_CARDS cards + assert((PACK_CARDS as u64) < 8, "Cannot generate more than 8 cards"); + for i in 0..PACK_CARDS { + let strength = (random_bytes[i] as u16) + (random_bytes[i + 1] as u16) * 256; + let points = (random_bytes[i + 2] as u16) + (random_bytes[i + 3] as u16) * 256; + cards[i] = Card { + strength, points + }; + } + + cards +} + +fn compute_deck_strength(cards: [Card; N]) -> Field { + cards.fold(0, |acc, card: Card| { + acc + card.strength as Field + }) +} \ No newline at end of file diff --git a/yarn-project/noir-contracts/src/contracts/card_game_contract/src/game.nr b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/game.nr new file mode 100644 index 00000000000..e68e8799f6e --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/game.nr @@ -0,0 +1,172 @@ +use dep::aztec::types::type_serialisation::TypeSerialisationInterface; +use crate::cards::Card; + +global NUMBER_OF_PLAYERS = 2; +global NUMBER_OF_CARDS_DECK = 2; + +struct PlayerEntry { + address: Field, + deck_strength: u32, + points: u120, +} + +impl PlayerEntry { + fn is_initialised(self) -> bool { + self.address != 0 + } +} + +global PLAYABLE_CARDS = 4; + +struct Game { + players: [PlayerEntry; NUMBER_OF_PLAYERS], + rounds_cards: [Card; PLAYABLE_CARDS], + started: bool, + finished: bool, + claimed: bool, + current_player: u32, + current_round: u32, +} + +global GAME_SERIALISED_LEN: Field = 15; + +fn deserialiseGame(fields: [Field; GAME_SERIALISED_LEN]) -> Game { + let players = [ + PlayerEntry { + address: fields[0], + deck_strength: fields[1] as u32, + points: fields[2] as u120, + }, + PlayerEntry { + address: fields[3], + deck_strength: fields[4] as u32, + points: fields[5] as u120, + }, + ]; + let rounds_cards = [ + Card::from_field(fields[6]), Card::from_field(fields[7]), + Card::from_field(fields[8]), Card::from_field(fields[9]), + ]; + Game { + players, + rounds_cards, + started: fields[10] as bool, + finished: fields[11] as bool, + claimed: fields[12] as bool, + current_player: fields[13] as u32, + current_round: fields[14] as u32, + } +} + +fn serialiseGame(game: Game) -> [Field; GAME_SERIALISED_LEN] { + [ + game.players[0].address, + game.players[0].deck_strength as Field, + game.players[0].points as Field, + game.players[1].address, + game.players[1].deck_strength as Field, + game.players[1].points as Field, + game.rounds_cards[0].to_field(), + game.rounds_cards[1].to_field(), + game.rounds_cards[2].to_field(), + game.rounds_cards[3].to_field(), + game.started as Field, + game.finished as Field, + game.claimed as Field, + game.current_player as Field, + game.current_round as Field, + ] +} + +impl Game { + fn serialize(self: Self) -> [Field; GAME_SERIALISED_LEN] { + serialiseGame(self) + } + + fn add_player(&mut self, player_entry: PlayerEntry) -> bool { + let mut added = false; + + for i in 0..NUMBER_OF_PLAYERS { + let entry = self.players[i]; + if entry.is_initialised() { + assert(entry.address != player_entry.address, "Player already in game"); + } else if !added { + self.players[i] = player_entry; + added = true; + } + } + + added + } + + fn start_game(&mut self) { + assert(!self.started, "Game already started"); + for i in 0..NUMBER_OF_PLAYERS { + let entry = self.players[i]; + assert(entry.is_initialised(), "Game not full"); + } + let sorted_by_deck_strength = self.players.sort_via(|a: PlayerEntry, b: PlayerEntry| a.deck_strength < b.deck_strength); + self.players = sorted_by_deck_strength; + self.started = true; + } + + fn current_player(self) -> PlayerEntry { + assert(self.started, "Game not started"); + assert(!self.finished, "Game finished"); + self.players[self.current_player] + } + + fn winner(self) -> PlayerEntry { + assert(self.finished, "Game not finished"); + let mut winner = self.players[0]; + for i in 1..NUMBER_OF_PLAYERS { + let entry = self.players[i]; + if entry.points > winner.points { + winner = entry; + } + } + winner + } + + fn play_card(&mut self, card: Card) { + assert(self.started, "Game not started"); + assert(!self.finished, "Game finished"); + + let round_offset = self.current_round * NUMBER_OF_PLAYERS; + + self.rounds_cards[round_offset + self.current_player] = card; + self.current_player = (self.current_player + 1) % NUMBER_OF_PLAYERS; + + if self.current_player == 0 { + self._finish_round(); + } + } + + fn _finish_round(&mut self) { + let round_offset = self.current_round * NUMBER_OF_PLAYERS; + self.current_round += 1; + + let mut winner_index = 0; + let mut winner_strength = 0; + let mut round_points = 0; + + for i in 0..NUMBER_OF_PLAYERS { + let card = self.rounds_cards[round_offset + i]; + round_points += (card.points as u120); + if card.strength > winner_strength { + winner_strength = card.strength; + winner_index = i; + } + } + + self.players[winner_index].points += round_points; + if self.current_round == NUMBER_OF_CARDS_DECK { + self.finished = true; + } + } +} + +global GameSerialisationMethods = TypeSerialisationInterface { + deserialise: deserialiseGame, + serialise: serialiseGame, +}; \ No newline at end of file diff --git a/yarn-project/noir-contracts/src/contracts/card_game_contract/src/main.nr b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/main.nr new file mode 100644 index 00000000000..a1d8b0ce3ca --- /dev/null +++ b/yarn-project/noir-contracts/src/contracts/card_game_contract/src/main.nr @@ -0,0 +1,270 @@ +mod cards; +mod game; + +use dep::aztec::{ + context::{PrivateContext, PublicContext}, + state_vars::{ + map::Map, + public_state::PublicState, + }, +}; + +use dep::std::option::Option; + +use cards::{Deck}; +use game::{Game, GameSerialisationMethods, GAME_SERIALISED_LEN}; + +struct Storage { + collections: Map, + game_decks: Map>, + games: Map>, +} + +impl Storage { + fn init( + private_context: Option<&mut PrivateContext>, + public_context: Option<&mut PublicContext>, + ) -> Self { + Storage { + collections: Map::new( + private_context, + public_context, + 1, + |private_context, public_context, slot| { + Deck::new( + private_context, + public_context, + slot, + ) + }, + ), + game_decks: Map::new( + private_context, + public_context, + 2, + |private_context, public_context, slot| { + Map::new( + private_context, + public_context, + slot, + |private_context, public_context, slot|{ + Deck::new( + private_context, + public_context, + slot, + ) + } + ) + }, + ), + games: Map::new( + private_context, + public_context, + 3, + |private_context, public_context, slot| { + PublicState::new( + private_context, + public_context, + slot, + GameSerialisationMethods, + ) + }, + ) + } + } +} + +contract CardGame { + use dep::std::option::Option; + use dep::value_note::{ + balance_utils, + value_note::{ + ValueNoteMethods, + VALUE_NOTE_LEN, + }, + }; + + use dep::aztec::{ + abi, + constants_gen::{MAX_NOTES_PER_PAGE}, + abi::{ + Hasher, PrivateContextInputs, + }, + context::PrivateContext, + note::{ + note_header::NoteHeader, + utils as note_utils, + }, + oracle::compute_selector::compute_selector + }; + + use crate::Storage; + use crate::cards::{ + PACK_CARDS, + Deck, + Card, + get_pack_cards, + compute_deck_strength, + }; + use crate::game::{ + NUMBER_OF_PLAYERS, + NUMBER_OF_CARDS_DECK, + PLAYABLE_CARDS, + PlayerEntry, + Game + }; + + #[aztec(private)] + fn constructor() {} + + #[aztec(private)] + fn buy_pack( + seed: Field, // The randomness used to generate the cards. Passed in for now. + ) { + let storage = Storage::init(Option::some(&mut context), Option::none()); + let buyer = context.msg_sender(); + let mut cards = get_pack_cards(seed, buyer); + + let mut collection = storage.collections.at(buyer); + let _inserted_cards = collection.add_cards(cards, buyer); + } + + #[aztec(private)] + fn join_game( + game: u32, + cards_fields: [Field; 2], + ) { + let cards = cards_fields.map(|card_field| Card::from_field(card_field)); + let storage = Storage::init(Option::some(&mut context), Option::none()); + let player = context.msg_sender(); + + let mut collection = storage.collections.at(player); + collection.remove_cards(cards, player); + let mut game_deck = storage.game_decks.at(game as Field).at(player); + let _added_to_game_deck = game_deck.add_cards(cards, player); + let selector = compute_selector("on_game_joined(u32,Field,u32)"); + let strength = compute_deck_strength(cards); + context.call_public_function(context.this_address(), selector, [game as Field, player, strength]); + } + + #[aztec(public)] + internal fn on_game_joined( + game: u32, + player: Field, + deck_strength: u32, + ) { + let storage = Storage::init(Option::none(), Option::some(&mut context)); + let game_storage = storage.games.at(game as Field); + + let mut game_data = game_storage.read(); + assert(game_data.add_player(PlayerEntry {address: player, deck_strength, points: 0}), "Game full"); + + game_storage.write(game_data); + } + + #[aztec(public)] + fn start_game(game: u32) { + let storage = Storage::init(Option::none(), Option::some(&mut context)); + let game_storage = storage.games.at(game as Field); + + let mut game_data = game_storage.read(); + game_data.start_game(); + game_storage.write(game_data); + } + + #[aztec(private)] + fn play_card( + game: u32, + card: Card, + ) { + let storage = Storage::init(Option::some(&mut context), Option::none()); + let player = context.msg_sender(); + + let mut game_deck = storage.game_decks.at(game as Field).at(player); + game_deck.remove_cards([card], player); + + let selector = compute_selector("on_card_played(u32,Field,Field)"); + context.call_public_function(context.this_address(), selector, [game as Field, player, card.to_field()]); + } + + #[aztec(public)] + internal fn on_card_played(game: u32, player: Field, card_as_field: Field) { + let storage = Storage::init(Option::none(), Option::some(&mut context)); + let game_storage = storage.games.at(game as Field); + + let mut game_data = game_storage.read(); + + let card = Card::from_field(card_as_field); + let current_player = game_data.current_player(); + assert(current_player.address == player, "Not your turn"); + game_data.play_card(card); + + game_storage.write(game_data); + } + + #[aztec(private)] + fn claim_cards( + game: u32, + cards_fields: [Field; PLAYABLE_CARDS], + ) { + let storage = Storage::init(Option::some(&mut context), Option::none()); + let player = context.msg_sender(); + let cards = cards_fields.map(|card_field| Card::from_field(card_field)); + + let mut collection = storage.collections.at(player); + let _inserted_cards = collection.add_cards(cards, player); + + let selector = compute_selector("on_cards_claimed(u32,Field,Field)"); + context.call_public_function( + context.this_address(), + selector, + [game as Field, player, dep::std::hash::pedersen(cards_fields)[0]] + ); + } + + #[aztec(public)] + internal fn on_cards_claimed(game: u32, player: Field, cards_hash: Field) { + let storage = Storage::init(Option::none(), Option::some(&mut context)); + let game_storage = storage.games.at(game as Field); + let mut game_data = game_storage.read(); + + assert(!game_data.claimed, "Already claimed"); + game_data.claimed = true; + + assert_eq( + cards_hash, + dep::std::hash::pedersen(game_data.rounds_cards.map(|card: Card| card.to_field()))[0] + ); + + let winner = game_data.winner(); + assert_eq(player, winner.address, "Not the winner"); + + game_storage.write(game_data); + } + + unconstrained fn view_collection_cards(owner: Field, offset: u32) -> [Option; MAX_NOTES_PER_PAGE] { + let storage = Storage::init(Option::none(), Option::none()); + let collection = storage.collections.at(owner); + + collection.view_cards(offset) + } + + unconstrained fn view_game_cards(game: u32, player: Field, offset: u32) -> [Option; MAX_NOTES_PER_PAGE] { + let storage = Storage::init(Option::none(), Option::none()); + let game_deck = storage.game_decks.at(game as Field).at(player); + + game_deck.view_cards(offset) + } + + unconstrained fn view_game(game: u32) -> Game { + Storage::init(Option::none(), Option::none()).games.at(game as Field).read() + } + + // Computes note hash and nullifier. + // Note 1: Needs to be defined by every contract producing logs. + // Note 2: Having it in all the contracts gives us the ability to compute the note hash and nullifier differently for different kind of notes. + unconstrained fn compute_note_hash_and_nullifier(contract_address: Field, nonce: Field, storage_slot: Field, preimage: [Field; VALUE_NOTE_LEN]) -> [Field; 4] { + let note_header = NoteHeader { contract_address, nonce, storage_slot }; + note_utils::compute_note_hash_and_nullifier(ValueNoteMethods, note_header, preimage) + } +}