Symbolブロックチェーンを使ってクラウドレス環境でターン制ゲームを作る

ゼロ知識証明、自律分散型組織、自己主権型アイデンティティ...

とかく難しい分野への取り組みに利用されがちなブロックチェーンですが、実は既存のシステムを柔軟に拡張することが可能で、こういった用途に優れた特徴を生かせるブロックチェーンもあります。

Symbolブロックチェーンもその一つで、ノードと共に稼働しているREST APIやWebSocketを使ってブロックチェーンを利用契約不要なゼロダウンタイムのデータベースサーバのように活用することができます。
今回はクラウドサーバを構築することなく、ブロックチェーン上に"価値"と"状態"を記録することで運用可能な「ターン制ゲーム」をテーマにブロックチェーンの活用方法について検討してみたいと思います。


##### クラウドレスとは？

今のところ、「特定のクラウド業者に依存せずに」という私の造語ですが、同じキーワードで深く洞察されている記事を見かけましたので紹介しておきます。

[クラウドは「クラウドレス」へ ｜ HPE 日本](https://www.hpe.com/jp/ja/japan/insights/articles/cloudless-1906.html)

> クラウドレスコンピューティングの基板は、3つの「ファブリック」で構成されています。それは信頼、接続性、価値です。
> クラウドネイティブな開発者は、相互接続型メッシュ、動的構造の観点から考えます。そのため、現代のアプリケーションアーキテクチャはクモの巣状になっています。
> クラウドレスコンピューティングは、ソフトウェアの開発、配信、消費方法の新しいアプローチです。開発者やユーザーによる、エンタープライズアプリケーションを支えるツール、サービス、データへのアクセスを劇的に簡素化し、一般化します。クラウドレスワークロードのエンドポイントは、従来の境界セキュリティの制御に制限されることなく、相互に、個々に認証、証明、動作します。
> サーバーレスがサーバーを使用しないのではなく、単にサーバーのオーケストレーションを排除するように、クラウドレスコンピューティングもクラウドを利用しないわけではありません。単にクラウド間の壁を壊し、パブリックとプライベートの間の明確な区別をなくそうというものです。


##### ターン制ゲームについて

https://ja.wikipedia.org/wiki/%E3%82%BF%E3%83%BC%E3%83%B3%E5%88%B6%E3%82%B9%E3%83%88%E3%83%A9%E3%83%86%E3%82%B8%E3%83%BC


# 特徴

#### 通常作るターン制ゲームとの違い

- クラウドレスでも構築可能(あってもよい、多少はあると便利)
- プレイヤーの認証不要（署名で把握）
- プレイ順序(ターン)をトークンで管理
- プレイヤーへの通知をトランザクション発生時のWebSocketで管理
- ポイント運用のための設備不要


#### ブロックチェーンでよく語られるゲームとの違い

今回、ブロックチェーンを活用したゲームについて解説しますが、以下のような内容ではありません。

- ゲーム進行役さえルールを改ざんできない仕組み、とかではないです。
- 他のプレーヤーの手札が秘匿される仕組み、とかではないです。


# ルール

ゲームマスターが場となるルームを作成し、そのルーム内に隠されたキーワードを早く言い当てることを競うゲームです。ゲームはルームにエントリーしたプレイヤー順にターン制で進行します。


### 役割
- ゲームマスター(進行役：1人)
- プレイヤー(3人、とします)

### 使用アカウント
##### 進行用
- マスターアカウント
- ルームアカウント

##### ユーザー用
- プレイヤーアカウント(一人につき一つ)

### 使用トークン
- 参加費(XYM TESTNET、勝者に払い戻し)
- ボール(ターン制御用)

### 進行の流れ

0.事前にゲーム進行（ターン制御）のためのボールトークンをウォレットで作成しておく

1.ゲームマスター（進行役）がスクリプトを実行して、ルームを作成
2.マスターはルームにボールトークンを1つ投入し、プレイヤーにルームアドレスを周知
3.プレイヤーは参加費をルームに送金しエントリーを行う
4.エントリー数が定員に達したら、ゲームマスターはプレイヤーの1人にボールを送信してゲームスタート(play ball)
5.ボールを受け取ったプレイヤーはルームに向かってボールと共にコマンドを送信。（今回はマスターが隠したアルファベットを当てるゲーム）
6.プレイヤーのコマンドが間違っていた場合は、次のプレイヤーにボールを送信（以後、5,6を繰り返す）
7.プレイヤーのコマンドが正解していた場合は、正解プレイヤーに参加費全額を払い戻す。

:::note alert
賭博法に抵触する可能性があるため、上記内容のゲームをメインネットのXYMで行わないよう、ご注意ください。
:::

# 動作検証

Symbolをブラウザ上で動作検証する方法については、以下のページをご参考ください。

https://qiita.com/nem_takanobu/items/e88d5ea76cd609e76bbf


```js:共通.js
(script = document.createElement('script')).src 
= 'https://xembook.github.io/nem2-browserify/symbol-sdk-pack-1.0.3.js';
document.getElementsByTagName('head')[0].appendChild(script);
```

```js:master.js
const masterPrivateKey = "08AE81044A69E260C8B6077A86E6567E2DF0EC825A7787B2497EA1879D***";
const ballMosaic = "792AFE495F942253";
const roomCapacity = 3;
const nodeURL = 'https://sym-test.opening-line.jp:3001';

NODE = window.origin;
const sym  = require("/node_modules/symbol-sdk");
const op  = require("/node_modules/rxjs/operators");
const rxjs = require("/node_modules/rxjs");

//変数
isPlaying = false;
isOver = false;
players = [];
playerIndex = 0;

// メイン処理
(async () => {
	repo = new sym.RepositoryFactoryHttp(NODE);
	accountRepo = repo.createAccountRepository();
	txRepo = repo.createTransactionRepository();
	nsRepo = repo.createNamespaceRepository();
	receiptRepo = repo.createReceiptRepository();
	wsEndpoint = NODE.replace('http', 'ws') + "/ws";
	transactionService = new sym.TransactionService(txRepo, receiptRepo);

	//ネットワーク設定値
	epochAdjustment = await repo.getEpochAdjustment().toPromise();
	generationHash = await repo.getGenerationHash().toPromise();
	currencyId = (await repo.getCurrencies().toPromise()).currency.mosaicId.toHex();
	networkCurrency = (await repo.getCurrencies().toPromise()).currency;
	networkType = await repo.getNetworkType().toPromise();

	masterAccount =  sym.Account.createFromPrivateKey(masterPrivateKey,networkType);
	roomAccount = sym.Account.generateNewAccount(networkType);

	async function listenerKeepOpening(){

		listener = new sym.Listener(wsEndpoint,nsRepo,WebSocket);
		await listener.open();
		listener.newBlock().subscribe();
		listener.webSocket.onclose = async function(){

			console.log("listener.webSocket.onclose");
			await listenerKeepOpening();
		}

		listener.unconfirmedAdded(roomAccount.address)
		.pipe(
			op.filter(tx=>{
				return tx.signer.address.plain() === roomAccount.address.plain() 
				|| tx.signer.address.plain() === masterAccount.address.plain() 
			}),
			op.filter(tx=> tx.recipientAddress.plain() === roomAccount.address.plain()),
		)
		.subscribe(tx=>console.log("notice: " + tx.message.payload));

		listener.confirmed(roomAccount.address)
		.pipe(
			op.filter(tx=>tx.signer.address.plain() !== masterAccount.address.plain()),
			op.filter(tx=>tx.signer.address.plain() !== roomAccount.address.plain())
		)
		.subscribe(tx=>{
			masterProcess(tx);
		});
	}
	await listenerKeepOpening();

	console.log("create room: " + roomAccount.address.plain());
	toRoomTx = createTxBallTo(roomAccount,'create room');
	signedTx = masterAccount.sign(toRoomTx,generationHash);
	transactionService.announce(signedTx,listener)
	.subscribe(tx=>{
		console.log(tx);
		console.log("set ball in the room");
	})

})();

function masterProcess(tx){

	if(isPlaying){
		hasBall = tx.mosaics.some(mosaic => mosaic.id.toHex() === ballMosaic);
		isPlayer = players.some(player => player.address.plain() === tx.signer.address.plain() );

		if(hasBall && isPlayer){

			command = tx.message.payload;
			console.log("command: " + command);
			//コマンド内容チェック
			if(command==="a"){
				isOver = true;
			}

			//ゲーム終了チェック
			if(isOver){
				//返金
				refund(tx.signer);
				console.log("success: " + tx.signer.address.plain());
				console.log("game over")
			}else{
				//ゲーム継続
				console.log("failed: " + tx.signer.address.plain());
				passBall(nextPlayer(),'your turn');
			}
		}else{
			console.log("no follow the order");
			console.log(tx);
		}
	}else{
		//重複エントリーチェック
		if(!players.find(player => player.address.plain() === tx.signer.address.plain())){

			//参加資格チェック（チップ額等）
			players.push(tx.signer);
			console.log("enter " + tx.signer.address.plain())
			if(players.length == roomCapacity){
				isPlaying = true;
				notice("play ball!");
				passBall(nextPlayer(),'your turn');
			}
		}else{
			console.log("duplicate enter " + tx.signer.address.plain())
		}
	}
}

function nextPlayer(){
	player = players[playerIndex];
	playerIndex += 1;
	if(playerIndex > roomCapacity){
		playerIndex = 0;
	}
	return player;
}

function passBall(account,message){

	const tx = createTxBallTo(account,message);
	console.log(tx);
	signedTx = roomAccount.sign(tx,generationHash);
	transactionService.announce(signedTx,listener)
	.subscribe(tx=>{
		console.log("ball to: " + tx.recipientAddress.plain());
	})
}

function notice(message){
	const tx =  sym.TransferTransaction.create(
		sym.Deadline.create(epochAdjustment),
		roomAccount.address, 
		[],
		sym.PlainMessage.create(message),
		networkType
	).setMaxFee(100);
	signedTx = roomAccount.sign(tx,generationHash);
	txRepo.announce(signedTx);
}

function createTxBallTo(account,message){
	return sym.TransferTransaction.create(
		sym.Deadline.create(epochAdjustment),
		account.address, 
		[new sym.Mosaic(new sym.MosaicId(ballMosaic), sym.UInt64.fromUint(1))],
		sym.PlainMessage.create(message),
		networkType
	).setMaxFee(100);
}

async function refund(account){

	let tx =  sym.TransferTransaction.create(
		sym.Deadline.create(epochAdjustment),
		account.address, 
		[networkCurrency.createRelative(1)],
		sym.PlainMessage.create("you win"),
		networkType
	).setMaxFee(100);

	accountInfo = (await accountRepo.getAccountInfo(roomAccount.address).toPromise())
	xym = accountInfo.mosaics.find(mosaic=>mosaic.id.toHex() === networkCurrency.mosaicId.toHex()).amount.compact() - tx.size * 100;
	tx.mosaics[0].amount = sym.UInt64.fromUint(xym);

	signedTx = roomAccount.sign(tx,generationHash);
	transactionService.announce(signedTx,listener)
	.subscribe(tx=>{
		console.log("prize to: " + account.address.plain());
	});
}
```

```js:player.js	
NODE = window.origin;
const sym  = require("/node_modules/symbol-sdk");
const op  = require("/node_modules/rxjs/operators");
const rxjs = require("/node_modules/rxjs");

// メイン処理
(async () => {
	repo = new sym.RepositoryFactoryHttp(NODE);
	accountRepo = repo.createAccountRepository();
	txRepo = repo.createTransactionRepository();
	nsRepo = repo.createNamespaceRepository();
	receiptRepo = repo.createReceiptRepository();
	wsEndpoint = NODE.replace('http', 'ws') + "/ws";
	transactionService = new sym.TransactionService(txRepo, receiptRepo);

	//ネットワーク設定値
	epochAdjustment = await repo.getEpochAdjustment().toPromise();
	generationHash = await repo.getGenerationHash().toPromise();
	currencyId = (await repo.getCurrencies().toPromise()).currency.mosaicId.toHex();
	networkCurrency = (await repo.getCurrencies().toPromise()).currency;
	networkType = await repo.getNetworkType().toPromise();
})();

async function listenerKeepOpening(account,rawAddress){

	roomAddress = sym.Address.createFromRawAddress(rawAddress);

	listener = new sym.Listener(wsEndpoint,nsRepo,WebSocket);
	await listener.open();
	listener.newBlock().subscribe();
	listener.webSocket.onclose = async function(){

		await listenerKeepOpening(account,rawAddress);
	}

	listener.confirmed(account.address)
	.pipe(
		op.filter(tx => tx.signer.address.plain() === roomAddress.plain()),
		op.filter(tx => tx.mosaics.some(mosaic => mosaic.id.toHex() === ballMosaic))
	)
	.subscribe(tx=>{
		console.log("message: " + tx.message.payload);
		console.log(tx);
	})

	listener.unconfirmedAdded(roomAddress)
	.pipe(
		op.filter(tx=>tx.signer.address.plain() === roomAddress.plain()),
		op.filter(tx=> tx.recipientAddress.plain() === roomAddress.plain()),
	)
	.subscribe(tx=>console.log("notice: " + tx.message.payload));
}

function entry(account){

	const tx  = sym.TransferTransaction.create(
		sym.Deadline.create(epochAdjustment),
		roomAddress, 
		[networkCurrency.createRelative(1)],
		sym.PlainMessage.create('enter room'),
		networkType
	).setMaxFee(100);

	signedTx = account.sign(tx,generationHash);
	txRepo.announce(signedTx).subscribe(x=>console.log(x));

}

function command(account,message){

	const tx  = sym.TransferTransaction.create(
		sym.Deadline.create(epochAdjustment),
		roomAddress, 
		[new sym.Mosaic(new sym.MosaicId(ballMosaic), sym.UInt64.fromUint(1))],
		sym.PlainMessage.create(message),
		networkType
	).setMaxFee(100);

	signedTx = account.sign(tx,generationHash);
	txRepo.announce(signedTx).subscribe(x=>console.log(x));
}
```

```js:player1.js
const ballMosaic = "792AFE495F942253";
const roomRawAddress = "TDKN32ACL3VBLQWDE4IR24YMMVDBHSAIRNUZSPQ";
const playerPrivateKey = "FD7E321574BC81C3D90FA4030C84BC590BFB5E598E4ABB7FA2AD862*********";
player =  sym.Account.createFromPrivateKey(playerPrivateKey,networkType);
await listenerKeepOpening(player,roomRawAddress);

//エントリー
entry(player);

//コマンド
command(player,"1")
```


# 実行方法

あらかじめ、マスターアカウントとボールモザイクを作成しておきます。
ゲーム進行役はブラウザのF12で開発者コンソールを開き、共通.js,master.jsをコピペで実行します。
masterPrivateKey,ballMosaicは作成したものに置き換えてください。
ルームアカウントは自動生成されます。
roomCapacityはルームの定員の数です。多すぎると検証が面倒なので適宜減らしてください。

次にプレイヤーの数だけブラウザタブを開き、共通.jsとplayer.jsをコピペで実行します。
次にプレイヤーの数だけplayer1.jsに変更を加えて実行します。

ballMosaic,roomRawAddress はマスターから通知されるボールトークンIDとルームアドレスです。
ゲームごとに指定しましょう。

playerPrivateKeyはプレイヤーごとに異なる秘密鍵を指定してください。
await listenerKeepOpening(player,roomRawAddress);　を実行して通知を受け取る準備をします。

entry(player);
を入力して定員がうまるのを待ちましょう。
マスター側には

```js
> enter TCVDXCWHM45N55OSNDEHKIX4CLUBOWBQYGZ6XGQ
> enter TB5G2OCMFNMNTSKHSCDCI3S2GJTBN6HGCU2I4LA
> enter TB22Z7CKAYXTS3SO3EZP3CH2NN5JOVAJPJWG54Y
```

といった感じで状況が確認できます。
定員に達すると

```js
> notice: play ball!
```
と全員に通知が走るのでゲームスタートです。自分の順番に回ってきたプレイヤーには

```js
> message: your turn
```

と表示されるので、コマンドを入力しましょう。

```js
command(player,"1")
```

今回のゲームは隠されたキーワードを当てるゲームです。"1"と推理してみました。
マスターのコンソールには以下のように表示されます。

```js
failed: TCVDXCWHM45N55OSNDEHKIX4CLUBOWBQYGZ6XGQ
ball to: TB5G2OCMFNMNTSKHSCDCI3S2GJTBN6HGCU2I4LA
```

間違っていたので、次のプレイヤーにボールが投げられました。
(今回のサンプルプログラムでは、簡略化のためにプレイヤー側に間違っていたことは通知されません。)

さて、次のプレイヤーは正解の"a"を入力したとします。

```js
command(player,"a")

//マスター側の表示
>command: a
>success: TB5G2OCMFNMNTSKHSCDCI3S2GJTBN6HGCU2I4LA
>game over
>prize to: TB5G2OCMFNMNTSKHSCDCI3S2GJTBN6HGCU2I4LA
```

無事マスター側のプログラムで正解が判定されて、参加費が正解者に送られました。

# 改善のポイント

サンプルプログラムが少しややこしくなるために、今回は導入を見合わせました。

##### マルチシグの導入

現在、誰がゲームに参加しているのかというのはマスタープログラムの配列で管理しており、PCのフリーズなどで状態が消失してしまう可能性があります（トランザクション履歴を追えば復元できますが）。この状態をマルチシグで管理してみても面白いかもしれません。

##### アグリゲートトランザクションの導入

最後の払い戻し額についてゲームマスターに一存することになりますが、参加費の支払い時にプレイヤーが決めておくことも可能です。以下のページの10.分配が参考になるかもしれません。どんな実装になるか検討してみてください。

https://qiita.com/nem_takanobu/items/cc2aa777ad1fa78d9ede#10%E5%88%86%E9%85%8D

また、ゲーム制御用のボールをゲームマスターに投げて、次のプレイヤーに渡すというロジックに使えば時間短縮にも使えます。

##### テストネットの併用

もし、メインネットでのゲームを検討している場合は、通知など特に重要でない部分は同じロジックをそのまま使えるテストネットを併用してもよいかもしれません。テストネットなのでもちろんリセットされる可能性があり、その場合はネットワークIDなどが変更になることにも注意が必要です。


# さいごに

無事稼働が確認できましたでしょうか？ブロックチェーンの可能性を少しでも多くの人に伝えることができたならうれしいです。

冒頭でクラウドレスとは「特定のクラウド業者に依存せずに」と説明しましたが、その延長線上には「メタバース上で特定のマーケットプレイスに頼ることなく」という意味も込めています。仮想空間上で決められたお題だけで遊ぶのではなく、みんなが主役となってゲームを主催して楽しむことが出来るようになればとても素晴らしいと思います。

現在、多くのブロックチェーンは金融部分のロジックのみ実稼働させ、投資機関からの支援を受けつつ未解決の課題に取り組むスキームがトレンドですが、Symbolは実際に使えるブロックチェーンとして過去に多くの企業からのフィードバックを得てすでに開発が一端完了したプロジェクトです（Symbol2という未解決に取り組むプロジェクトもあります）。現在は様々なユースケースの模索と共に、それを利用するコミュニティも高度に成長していて、とても素晴らしいエコシステムを形成しています。

ブロックチェーンはもはや投資家に夢を見せるだけの技術ではありません。Symbolでは実際に使ってくれるコミュニティがすでに存在し、エコシステムが動き出しつつあります。今まさに既存の技術をさらに拡張させるために、アプリケーションレベルでの現役技術者の参加が期待されています。

現在NEMTUSにてゲームを作るハッカソンが開催されているようですので、興味のある方はぜひご参加ください。

https://hackathon-2022.nemtus.com/


https://blog.hackathon-2022.nemtus.com/