Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Example card game #2135

Merged
merged 19 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/acir-simulator/src/acvm/acvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
297 changes: 297 additions & 0 deletions yarn-project/end-to-end/src/e2e_card_game.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

side q:
longer term, is this something that should be handled by a generated typescript class?

also i think there is prover system abstraction leaking through (modulus/overflow is proving system dependent?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah this is only because our macros dont' support arrays of structs yet as arguments to contract functions, when that's updated we don't need to pack the card into a field, we can just pass a card struct

What do you mean about the modulus/overflow? the fact that we can pack the card (32 bits) into a single field? For aztec we can safely assume that we'll always have bn254 I think 🤔

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh OK got it!

ohh right for modulus I forgot aztec has the pinned backend vs "regular" noir. i was thinking if points got really big we could maybe have overflow (in some setting besides the test)

};

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<T> {
_is_some: boolean;
_value: T;
}

function unwrapOptions<T>(options: NoirOption<T>[]): 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 contract.methods.buy_pack(27n).send({ origin: firstPlayer }).wait();
await 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();

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: [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:D nice technique

{
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<Card>[],
);

await contract.methods
.join_game(GAME_ID, [cardToField(firstPlayerCollection[0]), cardToField(firstPlayerCollection[2])])
.send({ origin: firstPlayer })
.wait();

await 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 contract.methods.buy_pack(27n).send({ origin: firstPlayer }).wait();
await contract.methods.buy_pack(27n).send({ origin: secondPlayer }).wait();
await 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 and claim the winned cards', async () => {
const firstPlayerGameDeck = [firstPlayerCollection[0], firstPlayerCollection[2]];
const secondPlayerGameDeck = [secondPlayerCollection[0], secondPlayerCollection[2]];
await joinGame(firstPlayer, firstPlayerGameDeck);
await joinGame(secondPlayer, secondPlayerGameDeck);
await contract.methods.start_game(GAME_ID).send({ origin: firstPlayer }).wait();

const 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 }),
);
expect(winnerCollection).toEqual(expect.arrayContaining([firstPlayerGameDeck, secondPlayerGameDeck].flat()));
}, 120_000);

it('should allow to play with cards won', async () => {
const firstPlayerGameDeck = [firstPlayerCollection[0], firstPlayerCollection[2]];
const secondPlayerGameDeck = [secondPlayerCollection[0], secondPlayerCollection[2]];
await joinGame(firstPlayer, firstPlayerGameDeck);
await 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);
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<Card>[],
);

const winnerGameDeck = [winnerCollection[0], winnerCollection[3]];
const thirdPlayerGameDeck = [thirdPlayerCOllection[0], thirdPlayerCOllection[2]];

await joinGame(winner, winnerGameDeck, GAME_ID + 1);
await 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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[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"}
easy_private_state = { path = "../../../../noir-libs/easy-private-state"}
dan-aztec marked this conversation as resolved.
Show resolved Hide resolved
Loading