Skip to content

Commit

Permalink
Make hex-game create temporary chains for each game. (#2384)
Browse files Browse the repository at this point in the history
  • Loading branch information
afck authored Aug 20, 2024
1 parent 694ef2c commit ab7c408
Show file tree
Hide file tree
Showing 11 changed files with 465 additions and 165 deletions.
3 changes: 3 additions & 0 deletions examples/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ rand = "0.8.5"
serde = { version = "1.0.152", features = ["derive"] }
serde_json = "1.0.93"
sha3 = "0.10.8"
test-log = { version = "0.2.15", default-features = false, features = ["trace"] }
tokenizers = { git = "https://github.com/christos-h/tokenizers", default-features = false, features = ["unstable_wasm"] }
tokio = { version = "1.25.0", features = ["macros", "rt-multi-thread"] }

Expand Down
4 changes: 0 additions & 4 deletions examples/fungible/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,3 @@ impl FungibleTokenContract {
}
}
}

// Dummy ComplexObject implementation, required by the graphql(complex) attribute in state.rs.
#[async_graphql::ComplexObject]
impl FungibleToken {}
3 changes: 3 additions & 0 deletions examples/hex-game/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition = "2021"
[dependencies]
async-graphql.workspace = true
linera-sdk.workspace = true
log.workspace = true
serde.workspace = true

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
Expand All @@ -15,6 +16,8 @@ tokio = { workspace = true, features = ["rt", "sync"] }

[dev-dependencies]
linera-sdk = { workspace = true, features = ["test"] }
serde_json.workspace = true
test-log.workspace = true

[[bin]]
name = "hex_game_contract"
Expand Down
81 changes: 62 additions & 19 deletions examples/hex-game/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ It consists of `s * s` hexagonal cells, indexed like this:

The players alternate placing a stone in their color on an empty cell until one of them wins.

This implementation shows how to write a game that is meant to be played on a shared chain:
This implementation shows how to write a game that is played on a shared temporary chain:
Users make turns by submitting operations to the chain, not by sending messages, so a player
does not have to wait for any other chain owner to accept any message.

Expand Down Expand Up @@ -47,8 +47,6 @@ We use the test-only CLI option `--testing-prng-seed` to make keys deterministic
explanation.

```bash
OWNER_1=df44403a282330a8b086603516277c014c844a4b418835873aced1132a3adcd5
OWNER_2=43c319a4eab3747afcd608d32b73a2472fcaee390ec6bed3e694b4908f55772d
CHAIN_1=e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65
```

Expand All @@ -58,27 +56,71 @@ We open a new chain owned by both `$OWNER_1` and `$OWNER_2`, create the applicat
start the node service.

```bash
PUB_KEY_1=$(linera -w0 keygen)
PUB_KEY_2=$(linera -w1 keygen)

read -d '' MESSAGE_ID HEX_CHAIN < <(linera -w0 --wait-for-outgoing-messages open-multi-owner-chain \
--from $CHAIN_1 \
--owner-public-keys $PUB_KEY_1 $PUB_KEY_2 \
--initial-balance 1; printf '\0')

linera -w0 assign --key $PUB_KEY_1 --message-id $MESSAGE_ID
linera -w1 assign --key $PUB_KEY_2 --message-id $MESSAGE_ID

APP_ID=$(linera -w0 --wait-for-outgoing-messages \
project publish-and-create examples/hex-game hex_game $HEX_CHAIN \
project publish-and-create examples/hex-game hex_game $CHAIN_1 \
--json-argument "{
\"players\": [\"$OWNER_1\", \"$OWNER_2\"],
\"boardSize\": 9,
\"startTime\": 600000000,
\"increment\": 600000000,
\"blockDelay\": 100000000
}")

PUB_KEY_1=$(linera -w0 keygen)
PUB_KEY_2=$(linera -w1 keygen)

linera -w0 service --port 8080 &
sleep 1
```

The `start` mutation starts a new game. We specify the two players using their new public keys,
on [`http://localhost:8080/chains/$CHAIN_1/applications/$APP_ID`][main_chain]:

```gql,uri=http://localhost:8080/chains/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000
mutation {
start(
players: [
"8a21aedaef74697db8b676c3e03ddf965bf4a808dc2bcabb6d70d6e6e3022ff7",
"80265761fee067b68ba47cce7464cbc7f1da5b7044d8f68ffc898db5ccb563a5"
],
boardSize: 11
)
}
```

The app's main chain keeps track of the games in progress, by public key:

```gql,uri=http://localhost:8080/chains/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000
query {
gameChains {
keys(count: 3)
}
}
```

It contains the temporary chain's ID, and the ID of the message that created it:

```gql,uri=http://localhost:8080/chains/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000
query {
gameChains {
entry(key: "8a21aedaef74697db8b676c3e03ddf965bf4a808dc2bcabb6d70d6e6e3022ff7") {
value {
messageId chainId
}
}
}
}
```

Using the message ID, we can assign the new chain to the key in each wallet:

```bash
kill %% && sleep 1 # Kill the service so we can use CLI commands for wallet 0.

HEX_CHAIN=a393137daba303e8b561cb3a5bff50efba1fb7f24950db28f1844b7ac2c1cf27
MESSAGE_ID=e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65050000000000000000000000

linera -w0 assign --key $PUB_KEY_1 --message-id $MESSAGE_ID
linera -w1 assign --key $PUB_KEY_2 --message-id $MESSAGE_ID

linera -w0 service --port 8080 &
linera -w1 service --port 8081 &
sleep 1
Expand All @@ -98,7 +140,8 @@ And the second player player at [`http://localhost:8080/chains/$HEX_CHAIN/applic
mutation { makeMove(x: 4, y: 5) }
```

[first_player]: http://localhost:8080/chains/c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0/applications/c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0000000000000000000000000c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0020000000000000000000000
[second_player]: http://localhost:8081/chains/c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0/applications/c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0000000000000000000000000c06f52a2a3cc991e6981d5628c11b03ad39f7509c4486893623a41d1f7ec49a0020000000000000000000000
[main_chain]: http://localhost:8080/chains/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000
[first_player]: http://localhost:8080/chains/a393137daba303e8b561cb3a5bff50efba1fb7f24950db28f1844b7ac2c1cf27/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000
[second_player]: http://localhost:8081/chains/a393137daba303e8b561cb3a5bff50efba1fb7f24950db28f1844b7ac2c1cf27/applications/e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65010000000000000001000000e476187f6ddfeb9d588c7b45d3df334d5501d6499b3f9ad5595cae86cce16a65030000000000000000000000

<!-- cargo-rdme end -->
197 changes: 161 additions & 36 deletions examples/hex-game/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@

mod state;

use hex_game::{Board, Clock, HexAbi, InstantiationArgument, MoveOutcome, Operation};
use async_graphql::ComplexObject;
use hex_game::{Board, Clock, HexAbi, HexOutcome, Operation, Timeouts};
use linera_sdk::{
base::WithContractAbi,
base::{
Amount, ApplicationPermissions, ChainId, ChainOwnership, Owner, PublicKey, TimeoutConfig,
WithContractAbi,
},
views::{RootView, View, ViewStorageContext},
Contract, ContractRuntime,
};
use state::HexState;
use serde::{Deserialize, Serialize};
use state::{GameChain, HexState};

pub struct HexContract {
state: HexState,
Expand All @@ -25,8 +30,8 @@ impl WithContractAbi for HexContract {
}

impl Contract for HexContract {
type Message = ();
type InstantiationArgument = InstantiationArgument;
type Message = Message;
type InstantiationArgument = Timeouts;
type Parameters = ();

async fn load(runtime: ContractRuntime<Self>) -> Self {
Expand All @@ -36,38 +41,70 @@ impl Contract for HexContract {
HexContract { state, runtime }
}

async fn instantiate(&mut self, arg: Self::InstantiationArgument) {
async fn instantiate(&mut self, arg: Timeouts) {
log::trace!("Instantiating");
self.runtime.application_parameters(); // Verifies that these are empty.
self.state
.clock
.set(Clock::new(self.runtime.system_time(), &arg));
self.state.owners.set(Some(arg.players));
self.state.board.set(Board::new(arg.board_size));
self.state.timeouts.set(arg);
}

async fn execute_operation(&mut self, operation: Operation) -> MoveOutcome {
async fn execute_operation(&mut self, operation: Operation) -> HexOutcome {
log::trace!("Handling operation {:?}", operation);
let outcome = match operation {
Operation::MakeMove { x, y } => self.execute_make_move(x, y),
Operation::ClaimVictory => self.execute_claim_victory(),
Operation::Start {
players,
board_size,
timeouts,
} => self.execute_start(players, board_size, timeouts).await,
};
self.handle_winner(outcome)
}

async fn execute_message(&mut self, message: Message) {
log::trace!("Handling message {:?}", message);
match message {
Message::Start {
players,
board_size,
timeouts,
} => {
let clock = Clock::new(self.runtime.system_time(), &timeouts);
self.state.clock.set(clock);
let owners = [Owner::from(&players[0]), Owner::from(&players[1])];
self.state.public_keys.set(Some(players));
self.state.owners.set(Some(owners));
self.state.board.set(Board::new(board_size));
}
Message::End { winner, loser } => {
let message_id = self.runtime.message_id().unwrap();
for owner in [&winner, &loser] {
let chain_set = self
.state
.game_chains
.get_mut_or_default(owner)
.await
.unwrap();
chain_set.retain(|game_chain| game_chain.chain_id != message_id.chain_id);
if chain_set.is_empty() {
self.state.game_chains.remove(owner).unwrap();
}
}
}
}
}

async fn store(mut self) {
self.state.save().await.expect("Failed to save state");
}
}

impl HexContract {
fn execute_make_move(&mut self, x: u16, y: u16) -> HexOutcome {
assert!(self.runtime.chain_id() != self.main_chain_id());
let active = self.state.board.get().active_player();
let block_time = self.runtime.system_time();
let clock = self.state.clock.get_mut();
let (x, y) = match operation {
Operation::MakeMove { x, y } => (x, y),
Operation::ClaimVictory => {
assert_eq!(
self.runtime.authenticated_signer(),
Some(self.state.owners.get().unwrap()[active.other().index()]),
"Victory can only be claimed by the player whose turn it is not."
);
assert!(
clock.timed_out(block_time, active),
"Player has not timed out yet."
);
assert!(
self.state.board.get().winner().is_none(),
"The game has already ended."
);
return MoveOutcome::Winner(active.other());
}
};
let block_time = self.runtime.system_time();
assert_eq!(
self.runtime.authenticated_signer(),
Some(self.state.owners.get().unwrap()[active.index()]),
Expand All @@ -79,11 +116,99 @@ impl Contract for HexContract {
self.state.board.get_mut().make_move(x, y)
}

async fn execute_message(&mut self, _message: ()) {
panic!("The Hex application doesn't support any cross-chain messages");
fn execute_claim_victory(&mut self) -> HexOutcome {
assert!(self.runtime.chain_id() != self.main_chain_id());
let active = self.state.board.get().active_player();
let clock = self.state.clock.get_mut();
let block_time = self.runtime.system_time();
assert_eq!(
self.runtime.authenticated_signer(),
Some(self.state.owners.get().unwrap()[active.other().index()]),
"Victory can only be claimed by the player whose turn it is not."
);
assert!(
clock.timed_out(block_time, active),
"Player has not timed out yet."
);
assert!(
self.state.board.get().winner().is_none(),
"The game has already ended."
);
HexOutcome::Winner(active.other())
}

async fn execute_start(
&mut self,
players: [PublicKey; 2],
board_size: u16,
timeouts: Option<Timeouts>,
) -> HexOutcome {
assert_eq!(self.runtime.chain_id(), self.main_chain_id());
let ownership = ChainOwnership::multiple(
[(players[0], 100), (players[1], 100)],
100,
TimeoutConfig::default(),
);
let app_id = self.runtime.application_id();
let permissions = ApplicationPermissions::new_single(app_id.forget_abi());
let (message_id, chain_id) = self
.runtime
.open_chain(ownership, permissions, Amount::ZERO);
for public_key in &players {
self.state
.game_chains
.get_mut_or_default(public_key)
.await
.unwrap()
.insert(GameChain {
message_id,
chain_id,
});
}
self.runtime.send_message(
chain_id,
Message::Start {
players,
board_size,
timeouts: timeouts.unwrap_or_else(|| self.state.timeouts.get().clone()),
},
);
HexOutcome::Ok
}

fn handle_winner(&mut self, outcome: HexOutcome) -> HexOutcome {
let HexOutcome::Winner(player) = outcome else {
return outcome;
};
let winner = self.state.public_keys.get().unwrap()[player.index()];
let loser = self.state.public_keys.get().unwrap()[player.other().index()];
let chain_id = self.main_chain_id();
let message = Message::End { winner, loser };
self.runtime.send_message(chain_id, message);
self.runtime.close_chain().unwrap();
outcome
}

async fn store(mut self) {
self.state.save().await.expect("Failed to save state");
fn main_chain_id(&mut self) -> ChainId {
self.runtime.application_id().creation.chain_id
}
}

#[derive(Debug, Serialize, Deserialize)]
pub enum Message {
/// Initializes a game. Sent from the main chain to a temporary chain.
Start {
/// The players.
players: [PublicKey; 2],
/// The side length of the board. A typical size is 11.
board_size: u16,
/// Settings that determine how much time the players have to think about their turns.
timeouts: Timeouts,
},
/// Reports the outcome of a game. Sent from a closed chain to the main chain.
End { winner: PublicKey, loser: PublicKey },
}

/// This implementation is only nonempty in the service.
#[ComplexObject]
impl HexState {}
Loading

0 comments on commit ab7c408

Please sign in to comment.