Rock paper scissors (also known by other orderings of the three items, with "rock" sometimes being called "stone," or as Rochambeau, roshambo, or ro-sham-bo) is a hand game originating from China, usually played between two people, in which each player simultaneously forms one of three shapes with an outstretched hand.
These shapes are "rock" (a closed fist), "paper" (a flat hand), and "scissors" (a fist with the index finger and middle finger extended, forming a V). "Scissors" is identical to the two-fingered V sign (also indicating "victory" or "peace") except that it is pointed horizontally instead of being held upright in the air.
Please install this software first on your machine or use online alternative :
git clone https://github.com/marigold-dev/training-dapp-shifumi.git
taq init shifumi
cd shifumi
taq install @taqueria/plugin-ligo
Download the Ligo Shifumi template, and copy the files to Taqueria contracts folder
TAQ_LIGO_IMAGE=ligolang/ligo:1.0.0 taq ligo --command "init contract --template shifumi-jsligo shifumiTemplate"
cp -r shifumiTemplate/src/* contracts/
Compile the contract once, in order to create the default required file main.storageList.jsligo
used at deployment step later
TAQ_LIGO_IMAGE=ligolang/ligo:1.0.0 taq compile main.jsligo
Edit main.storageList.jsligo
initial storage
#import "main.jsligo" "Contract"
const default_storage: Contract.storage = {
metadata: Big_map.literal(
list(
[
["", bytes `tezos-storage:contents`],
[
"contents",
bytes
`
{
"name": "Shifumi Example",
"description": "An Example Shifumi Contract",
"version": "beta",
"license": {
"name": "MIT"
},
"authors": [
"smart-chain <tezos@smart-chain.fr>"
],
"homepage": "https://github.com/ligolang/shifumi-jsligo",
"source": {
"tools": "jsligo",
"location": "https://github.com/ligolang/shifumi-jsligo/contracts"
},
"interfaces": [
"TZIP-016"
]
}
`
]
]
)
) as big_map<string, bytes>,
next_session: 0 as nat,
sessions: Map.empty as map<nat, Contract.Session.t>,
}
Compile again
TAQ_LIGO_IMAGE=ligolang/ligo:1.0.0 taq compile main.jsligo
taq install @taqueria/plugin-taquito
taq deploy main.tz -e "testing"
Note : if it is the first time you use taqueria, I recommend to look at this training first https://github.com/marigold-dev/training-dapp-1#ghostnet-testnet
For advanced users, just go to
.taq/config.local.testing.json
, replace account with alice settings and then redeploy
{ "networkName": "ghostnet", "accounts": { "taqOperatorAccount": { "publicKey": "edpkvGfYw3LyB1UcCahKQk4rF2tvbMUk8GFiTuMjL75uGXrpvKXhjn", "publicKeyHash": "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb", "privateKey": "edsk3QoqBuvdamxouPhin7swCvkQNgq4jP5KZPbwWNnwdZpSpJiEbq" } } }
HOORAY š your smart contract is ready on the Ghostnet !
āāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāā¬āāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Contract ā Address ā Alias ā Balance In Mutez ā Destination ā
āāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāā¼āāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā main.tz ā KT1TnrdiYdWjs83ndMSdAwmRQE3YYUCVWQMD ā main ā 0 ā https://ghostnet.ecadinfra.com ā
āāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāā“āāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
We will use Ionic React to be able to reuse the BeaconSDK (Typescript) on a webview. Beacon is the protocol of communication between the dapp and the wallet.
Note : I do not recommend right know to develop a dapp in Flutter or React Native because you will need to use native beacon library without wallet popup mechanism to confirm transactions
Install Ionic
npm install -g @ionic/cli
ionic start app blank --type react
Generate Smart contract types from taqueria plugin. It will generate Typescript classes from Smart contract interface definition that we will use on our frontend.
taq install @taqueria/plugin-contract-types
taq generate types ./app/src
Uninstall confliting old jest libraries/react-scripts and Install required Tezos web3 dependencies + vite
cd app
npm uninstall -S @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/jest
rm -rf src/components
rm src/setupTests.ts src/App.test.tsx
echo '/// <reference types="vite/client" />' > src/vite-env.d.ts
npm install -S @taquito/taquito @taquito/beacon-wallet @airgap/beacon-sdk @tzkt/sdk-api
npm install -S -D @airgap/beacon-types vite @vitejs/plugin-react-swc @types/react @types/node
ā ļø ā ļø ā ļø Before we start we need to add the following dependencies in order to not get polyfill issues. The reason for this step is that certain required dependencies are Node APIs, thus not included in Browsers. But still needed for communication and interaction with Wallets and Smart Contracts.
For example, in my case, I installed this :
npm i -D process buffer crypto-browserify stream-browserify assert stream-http https-browserify os-browserify url path-browserify
then create a new file nodeSpecific.ts
in the src folder of your project
touch src/nodeSpecific.ts
and edit with this content :
import { Buffer } from "buffer";
globalThis.Buffer = Buffer;
Finally create the vite.config.ts
file
touch vite.config.ts
and edit it with this content :
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default ({ command }) => {
const isBuild = command === "build";
return defineConfig({
define: { "process.env": process.env, global: {} },
plugins: [react()],
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
resolve: {
alias: {
// dedupe @airgap/beacon-sdk
// I almost have no idea why it needs `cjs` on dev and `esm` on build, but this is how it works š¤·āāļø
"@airgap/beacon-sdk": path.resolve(
path.resolve(),
`./node_modules/@airgap/beacon-sdk/dist/${
isBuild ? "esm" : "cjs"
}/index.js`
),
stream: "stream-browserify",
os: "os-browserify/browser",
util: "util",
process: "process/browser",
buffer: "buffer",
crypto: "crypto-browserify",
assert: "assert",
http: "stream-http",
https: "https-browserify",
url: "url",
path: "path-browserify",
},
},
});
};
Edit index.html
, it fixes the Node buffer issue with nodeSpecific.ts
file and points to our css file :
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Ionic App</title>
<base href="/" />
<meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<link rel="manifest" href="/manifest.json" />
<link href="assets/styles.css" rel="stylesheet" />
<link rel="shortcut icon" type="image/png" href="/favicon.png" />
<!-- add to homescreen for ios -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Ionic App" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/nodeSpecific.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Edit `src/main.tsx`` to force dark mode and remove React strict mode :
import { createRoot } from "react-dom/client";
import App from "./App";
const container = document.getElementById("root");
const root = createRoot(container!);
// Add or remove the "dark" class based on if the media query matches
document.body.classList.add("dark");
root.render(<App />);
Modify the default package.json
default scripts to use vite instead of default react scripts
"scripts": {
"dev": "jq -r '\"VITE_CONTRACT_ADDRESS=\" + last(.tasks[]).output[0].address' ../.taq/testing-state.json > .env && vite --host",
"ionic:build": "tsc -v && tsc && vite build",
"build": " tsc -v && tsc && vite build",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"ionic:serve": "vite dev --host",
"sync": "npm run build && ionic cap sync --no-build"
},
Edit src/App.tsx
main file
import {
IonApp,
IonRouterOutlet,
RefresherEventDetail,
setupIonicReact,
} from "@ionic/react";
import { IonReactRouter } from "@ionic/react-router";
import { Redirect, Route } from "react-router-dom";
/* Core CSS required for Ionic components to work properly */
import "@ionic/react/css/core.css";
/* Basic CSS for apps built with Ionic */
import "@ionic/react/css/normalize.css";
import "@ionic/react/css/structure.css";
import "@ionic/react/css/typography.css";
/* Optional CSS utils that can be commented out */
import "@ionic/react/css/display.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/padding.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
/* Theme variables */
import "./theme/variables.css";
import { NetworkType } from "@airgap/beacon-types";
import { BeaconWallet } from "@taquito/beacon-wallet";
import { InternalOperationResult } from "@taquito/rpc";
import {
PollingSubscribeProvider,
Subscription,
TezosToolkit,
} from "@taquito/taquito";
import React, { Dispatch, SetStateAction, useEffect, useState } from "react";
import { MainWalletType, Storage } from "./main.types";
import { HomeScreen } from "./pages/HomeScreen";
import { RulesScreen } from "./pages/Rules";
import { SessionScreen } from "./pages/SessionScreen";
import { TopPlayersScreen } from "./pages/TopPlayersScreen";
import {
MMap,
address,
bytes,
mutez,
nat,
timestamp,
unit,
} from "./type-aliases";
setupIonicReact();
export class Action implements ActionCisor, ActionPaper, ActionStone {
cisor?: unit;
paper?: unit;
stone?: unit;
constructor(cisor?: unit, paper?: unit, stone?: unit) {
this.cisor = cisor;
this.paper = paper;
this.stone = stone;
}
}
export type ActionCisor = { cisor?: unit };
export type ActionPaper = { paper?: unit };
export type ActionStone = { stone?: unit };
export type Session = {
asleep: timestamp;
board: MMap<nat, { Some: address } | null>;
current_round: nat;
decoded_rounds: MMap<
nat,
Array<{
action: { cisor: unit } | { paper: unit } | { stone: unit };
player: address;
}>
>;
players: Array<address>;
pool: mutez;
result: { draw: unit } | { inplay: unit } | { winner: address };
rounds: MMap<
nat,
Array<{
action: bytes;
player: address;
}>
>;
total_rounds: nat;
};
export type UserContextType = {
storage: Storage | null;
setStorage: Dispatch<SetStateAction<Storage | null>>;
userAddress: string;
setUserAddress: Dispatch<SetStateAction<string>>;
userBalance: number;
setUserBalance: Dispatch<SetStateAction<number>>;
Tezos: TezosToolkit;
wallet: BeaconWallet;
mainWalletType: MainWalletType | null;
loading: boolean;
setLoading: Dispatch<SetStateAction<boolean>>;
refreshStorage: (event?: CustomEvent<RefresherEventDetail>) => Promise<void>;
subReveal: Subscription<InternalOperationResult> | undefined;
subNewRound: Subscription<InternalOperationResult> | undefined;
};
export const UserContext = React.createContext<UserContextType | null>(null);
const App: React.FC = () => {
const Tezos = new TezosToolkit("https://ghostnet.tezos.marigold.dev");
const wallet = new BeaconWallet({
name: "Training",
preferredNetwork: NetworkType.GHOSTNET,
});
Tezos.setWalletProvider(wallet);
Tezos.setStreamProvider(
Tezos.getFactory(PollingSubscribeProvider)({
shouldObservableSubscriptionRetry: true,
pollingIntervalMilliseconds: 1500,
})
);
const [userAddress, setUserAddress] = useState<string>("");
const [userBalance, setUserBalance] = useState<number>(0);
const [storage, setStorage] = useState<Storage | null>(null);
const [mainWalletType, setMainWalletType] = useState<MainWalletType | null>(
null
);
const [loading, setLoading] = useState<boolean>(false);
const [subscriptionsDone, setSubscriptionsDone] = useState<boolean>(false);
const [subReveal, setSubReveal] =
useState<Subscription<InternalOperationResult>>();
const [subNewRound, setSubNewRound] =
useState<Subscription<InternalOperationResult>>();
const refreshStorage = async (
event?: CustomEvent<RefresherEventDetail>
): Promise<void> => {
try {
if (!userAddress) {
const activeAccount = await wallet.client.getActiveAccount();
let userAddress: string;
if (activeAccount) {
userAddress = activeAccount.address;
setUserAddress(userAddress);
const balance = await Tezos.tz.getBalance(userAddress);
setUserBalance(balance.toNumber());
}
}
console.log(
"VITE_CONTRACT_ADDRESS:",
import.meta.env.VITE_CONTRACT_ADDRESS
);
const mainWalletType: MainWalletType =
await Tezos.wallet.at<MainWalletType>(
import.meta.env.VITE_CONTRACT_ADDRESS
);
const storage: Storage = await mainWalletType.storage();
setMainWalletType(mainWalletType);
setStorage(storage);
console.log("Storage refreshed");
event?.detail.complete();
} catch (error) {
console.log("error refreshing storage", error);
}
};
useEffect(() => {
try {
if (!subscriptionsDone) {
const sub = Tezos.stream.subscribeEvent({
tag: "gameStatus",
address: import.meta.env.VITE_CONTRACT_ADDRESS!,
});
sub.on("data", (e) => {
console.log("on gameStatus event :", e);
refreshStorage();
});
setSubReveal(
Tezos.stream.subscribeEvent({
tag: "reveal",
address: import.meta.env.VITE_CONTRACT_ADDRESS,
})
);
setSubNewRound(
Tezos.stream.subscribeEvent({
tag: "newRound",
address: import.meta.env.VITE_CONTRACT_ADDRESS,
})
);
} else {
console.warn("Tezos.stream.subscribeEvent already done ... ignoring");
}
} catch (e) {
console.log("Error with Smart contract event pooling", e);
}
console.log("Tezos.stream.subscribeEvent DONE");
setSubscriptionsDone(true);
}, []);
useEffect(() => {
if (userAddress) {
console.warn("userAddress changed", wallet);
(async () => await refreshStorage())();
}
}, [userAddress]);
return (
<IonApp>
<UserContext.Provider
value={{
userAddress,
userBalance,
Tezos,
wallet,
storage,
mainWalletType,
setUserAddress,
setUserBalance,
setStorage,
loading,
setLoading,
refreshStorage,
subReveal,
subNewRound,
}}
>
<IonReactRouter>
<IonRouterOutlet>
<Route path={PAGES.HOME} component={HomeScreen} />
<Route path={`${PAGES.SESSION}/:id`} component={SessionScreen} />
<Route path={PAGES.TOPPLAYERS} component={TopPlayersScreen} />
<Route path={PAGES.RULES} component={RulesScreen} />
<Redirect exact from="/" to={PAGES.HOME} />
</IonRouterOutlet>
</IonReactRouter>
</UserContext.Provider>
</IonApp>
);
};
export enum PAGES {
HOME = "/home",
SESSION = "/session",
TOPPLAYERS = "/topplayers",
RULES = "/rules",
}
export default App;
Explanations :
import "@ionic..."
: Default standard Ionic importsimport ... from "@airgap/beacon-types" ... from "@taquito/beacon-wallet" ... from "@taquito/taquito"
: Require libraries to interact with the Tezos node and the walletexport class Action implements ActionCisor, ActionPaper, ActionStone {...}
: Representation of the ligo variant Action
in Typescript, we will need it when passing arguments on Play
functionexport type Session = {...}
: Taqueria export the global Storage type but sadly not this sub-type from the Storage type, we will need it later, so we extract a copyexport const UserContext = React.createContext<UserContextType | null>(null)
: Global React context that is passed along pages. More info on React context hereconst refreshStorage = async (event?: CustomEvent<RefresherEventDetail>): Promise<void> => {...
: useful fonction to force the smart contract Storage to refresh on React state changes (user balance, state of the game)useEffect(() => { ... Tezos.setStreamProvider(...) ... Tezos.stream.subscribeEvent({...
: During Application initialization, we configure the wallet, the websocket listening to smart contract events<IonApp><UserContext.Provider ... ><IonReactRouter><IonRouterOutlet><Route path={PAGES.HOME} component={HomeScreen} /> ...
: We inject the React context to all pages. We declare the global routing of the applicationexport enum PAGES { HOME = "/home", ...
: Declaration of the global routesTo add the default theming (CSS, pictures, etc...), copy the content of the git repository folder named assets
folder to your local project (considering you cloned the repo and assets folder is on root folder)
cp -r ../../assets/* .
We will declare 2 React Button components and fetch the user public hash key + balance
Let's create the 2 missing src component files and put code in it.
On app
folder, create these files.
touch src/ConnectWallet.tsx
touch src/DisconnectWallet.tsx
ConnectWallet button will create an instance wallet, get user permissions via a popup and then retrieve account information
Edit ConnectWallet.tsx
import { NetworkType } from "@airgap/beacon-types";
import { IonButton } from "@ionic/react";
import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";
import { Dispatch, SetStateAction } from "react";
type ButtonProps = {
Tezos: TezosToolkit;
setUserAddress: Dispatch<SetStateAction<string>>;
setUserBalance: Dispatch<SetStateAction<number>>;
wallet: BeaconWallet;
};
const ConnectButton = ({
Tezos,
setUserAddress,
setUserBalance,
wallet,
}: ButtonProps): JSX.Element => {
const connectWallet = async (): Promise<void> => {
try {
console.log("before requestPermissions");
await wallet.requestPermissions({
network: {
type: NetworkType.GHOSTNET,
rpcUrl: "https://ghostnet.tezos.marigold.dev",
},
});
console.log("after requestPermissions");
// gets user's address
const userAddress = await wallet.getPKH();
const balance = await Tezos.tz.getBalance(userAddress);
setUserBalance(balance.toNumber());
setUserAddress(userAddress);
} catch (error) {
console.log("error connectWallet", error);
}
};
return (
<IonButton expand="full" onClick={connectWallet}>
Connect Wallet
</IonButton>
);
};
export default ConnectButton;
DisconnectWallet button will clean wallet instance and all linked objects
import { IonFab, IonFabButton, IonIcon } from "@ionic/react";
import { BeaconWallet } from "@taquito/beacon-wallet";
import { power } from "ionicons/icons";
import { Dispatch, SetStateAction } from "react";
interface ButtonProps {
wallet: BeaconWallet;
setUserAddress: Dispatch<SetStateAction<string>>;
setUserBalance: Dispatch<SetStateAction<number>>;
}
const DisconnectButton = ({
wallet,
setUserAddress,
setUserBalance,
}: ButtonProps): JSX.Element => {
const disconnectWallet = async (): Promise<void> => {
setUserAddress("");
setUserBalance(0);
console.log("disconnecting wallet");
await wallet.clearActiveAccount();
};
return (
<IonFab slot="fixed" vertical="top" horizontal="end">
<IonFabButton>
<IonIcon icon={power} onClick={disconnectWallet} />
</IonFabButton>
</IonFab>
);
};
export default DisconnectButton;
Save both file
Let's create the missing pages and the error utility class
touch src/pages/HomeScreen.tsx
touch src/pages/SessionScreen.tsx
touch src/pages/Rules.tsx
touch src/pages/TopPlayersScreen.tsx
touch src/TransactionInvalidBeaconError.ts
TransactionInvalidBeaconError.ts
utility class is used to display human readable message from Beacon errors
Edit all files
import {
IonButton,
IonButtons,
IonContent,
IonFooter,
IonHeader,
IonIcon,
IonImg,
IonInput,
IonItem,
IonLabel,
IonList,
IonModal,
IonPage,
IonRefresher,
IonRefresherContent,
IonSpinner,
IonTitle,
IonToolbar,
useIonAlert,
} from "@ionic/react";
import { BigNumber } from "bignumber.js";
import { person } from "ionicons/icons";
import React, { useEffect, useRef, useState } from "react";
import { useHistory } from "react-router-dom";
import { PAGES, Session, UserContext, UserContextType } from "../App";
import ConnectButton from "../ConnectWallet";
import DisconnectButton from "../DisconnectWallet";
import { TransactionInvalidBeaconError } from "../TransactionInvalidBeaconError";
import Paper from "../assets/paper-logo.webp";
import Scissor from "../assets/scissor-logo.webp";
import Stone from "../assets/stone-logo.webp";
import XTZLogo from "../assets/xtz.webp";
import { SelectMembers } from "../components/TzCommunitySelectMembers";
import { address, nat } from "../type-aliases";
export const HomeScreen: React.FC = () => {
const [presentAlert] = useIonAlert();
const { push } = useHistory();
const createGameModal = useRef<HTMLIonModalElement>(null);
const selectGameModal = useRef<HTMLIonModalElement>(null);
function dismissCreateGameModal() {
console.log("dismissCreateGameModal");
createGameModal.current?.dismiss();
}
function dismissSelectGameModal() {
selectGameModal.current?.dismiss();
const element = document.getElementById("home");
setTimeout(() => {
return element && element.remove();
}, 1000); // Give a little time to properly unmount your previous page before removing the old one
}
const {
Tezos,
wallet,
userAddress,
userBalance,
storage,
mainWalletType,
setStorage,
setUserAddress,
setUserBalance,
setLoading,
loading,
refreshStorage,
} = React.useContext(UserContext) as UserContextType;
const [newPlayer, setNewPlayer] = useState<address>("" as address);
const [total_rounds, setTotal_rounds] = useState<nat>(
new BigNumber(1) as nat
);
const [myGames, setMyGames] = useState<Map<nat, Session>>();
useEffect(() => {
(async () => {
if (storage) {
const myGames = new Map(); //filtering our games
Array.from(storage.sessions.keys()).forEach((key) => {
const session = storage.sessions.get(key);
if (
session.players.indexOf(userAddress as address) >= 0 &&
"inplay" in session.result
) {
myGames.set(key, session);
}
});
setMyGames(myGames);
} else {
console.log("storage is not ready yet");
}
})();
}, [storage]);
const createSession = async (
e: React.MouseEvent<HTMLIonButtonElement, MouseEvent>
) => {
console.log("createSession");
e.preventDefault();
try {
setLoading(true);
const op = await mainWalletType?.methods
.createSession(total_rounds, [userAddress as address, newPlayer])
.send();
await op?.confirmation();
const newStorage = await mainWalletType?.storage();
setStorage(newStorage!);
setLoading(false);
dismissCreateGameModal();
setTimeout(
() => push(PAGES.SESSION + "/" + storage?.next_session.toString()),
500
);
//it was the id created
console.log("newStorage", newStorage);
} catch (error) {
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
const tibe: TransactionInvalidBeaconError =
new TransactionInvalidBeaconError(error);
presentAlert({
header: "Error",
message: tibe.data_message,
buttons: ["Close"],
});
setLoading(false);
}
setLoading(false);
};
return (
<IonPage className="container">
<IonHeader>
<IonToolbar>
<IonTitle>Shifumi</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonRefresher slot="fixed" onIonRefresh={refreshStorage}>
<IonRefresherContent></IonRefresherContent>
</IonRefresher>
{loading ? (
<div className="loading">
<IonItem>
<IonLabel>Refreshing ...</IonLabel>
<IonSpinner className="spinner"></IonSpinner>
</IonItem>
</div>
) : (
<IonList inset={true}>
{!userAddress ? (
<>
<div
style={{
display: "flex",
flexDirection: "row",
padding: "4em",
justifyContent: "space-around",
}}
>
<IonImg src={Stone} className="logo" />
<IonImg src={Paper} className="logo" />
<IonImg src={Scissor} className="logo" />
</div>
<IonList inset={true}>
<ConnectButton
Tezos={Tezos}
setUserAddress={setUserAddress}
setUserBalance={setUserBalance}
wallet={wallet}
/>
</IonList>
</>
) : (
<IonList>
<IonItem style={{ padding: 0, margin: 0 }}>
<IonIcon icon={person} />
<IonLabel style={{ fontSize: "0.8em", direction: "rtl" }}>
{userAddress}
</IonLabel>
</IonItem>
<IonItem style={{ padding: 0, margin: 0 }}>
<IonImg style={{ height: 24, width: 24 }} src={XTZLogo} />
<IonLabel style={{ direction: "rtl" }}>
{userBalance / 1000000}
</IonLabel>
</IonItem>
<div
style={{
display: "flex",
flexDirection: "row",
paddingTop: "10vh",
paddingBottom: "10vh",
justifyContent: "space-around",
width: "100%",
}}
>
<IonImg src={Stone} className="logo" />
<IonImg src={Paper} className="logo" />
<IonImg src={Scissor} className="logo" />
</div>
<IonButton id="createGameModalVisible" expand="full">
New game
</IonButton>
<IonModal
ref={createGameModal}
trigger="createGameModalVisible"
>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={() => dismissCreateGameModal()}>
Cancel
</IonButton>
</IonButtons>
<IonTitle>New Game</IonTitle>
<IonButtons slot="end">
<IonButton
strong={true}
onClick={(e) => createSession(e)}
id="createGameModal"
>
Create
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">
<h2>How many total rounds ?</h2>
<IonItem key="total_rounds">
<IonLabel position="stacked" className="text"></IonLabel>
<IonInput
onIonChange={(str: any) => {
if (str.detail.value === undefined) return;
setTotal_rounds(
new BigNumber(str.target.value) as nat
);
}}
value={total_rounds.toString()}
placeholder="total_rounds"
type="number"
label="Total Rounds"
/>
</IonItem>
<h2>Choose your opponent player</h2>
<SelectMembers
Tezos={Tezos}
member={newPlayer}
setMember={setNewPlayer}
/>
<IonItem key="newPlayer">
<IonInput
onIonChange={(str: any) => {
if (str.detail.value === undefined) return;
setNewPlayer(str.detail.value as address);
}}
labelPlacement="floating"
class="address"
value={newPlayer}
placeholder="...tz1"
type="text"
label="Tezos Address "
/>
</IonItem>
</IonContent>
</IonModal>
<IonButton id="selectGameModalVisible" expand="full">
Join game
</IonButton>
<IonModal
ref={selectGameModal}
trigger="selectGameModalVisible"
>
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={() => dismissSelectGameModal()}>
Cancel
</IonButton>
</IonButtons>
<IonTitle>Select Game</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<IonList inset={true}>
{myGames
? Array.from(myGames.entries()).map(([key, _]) => (
<IonButton
key={"Game-" + key.toString()}
expand="full"
routerLink={PAGES.SESSION + "/" + key.toString()}
onClick={dismissSelectGameModal}
>
{"Game nĀ°" + key.toString()}
</IonButton>
))
: []}
</IonList>
</IonContent>
</IonModal>
<IonButton routerLink={PAGES.TOPPLAYERS} expand="full">
Top Players
</IonButton>
</IonList>
)}
</IonList>
)}
</IonContent>
<IonFooter>
<IonToolbar>
<IonTitle>
<IonButton color="primary" routerLink={PAGES.RULES} expand="full">
Rules
</IonButton>
</IonTitle>
</IonToolbar>
</IonFooter>
{userAddress ? (
<DisconnectButton
wallet={wallet}
setUserAddress={setUserAddress}
setUserBalance={setUserBalance}
/>
) : (
<></>
)}
</IonPage>
);
};
Explanation :
const createGameModal
: The popup to create a new game
const selectGameModal
: The popup to select a game to join
const [newPlayer, setNewPlayer] = useState<address>("" as address)
: Used on New Game
popup form to add an opponent
const [total_rounds, setTotal_rounds] = useState<nat>(new BigNumber(1) as nat)
: Used on New Game
popup form to set number of round for one game
const [myGames, setMyGames] = useState<Map<nat, Session>>()
: Used on Join Game
popup window to display the games we have created or we are invited to
Array.from(storage.sessions.keys()).forEach((key) => { ... if (session.players.indexOf(userAddress as address) >= 0 && "inplay" in session.result ...
: On storage change event, we fetch and filter only games we can join and play (i.e with inplay
status and where user appears on player list)
const createSession = async (...) => { ... const op = await mainWalletType!.methods.createSession([userAddress as address, newPlayer], total_rounds).send(); ...
: createSession function will call the Smart contract entrypoint passing on arguments : current user address,opponent address and total rounds, then it will redirect to the newly created game page
{...<IonButton ... routerLink={PAGES.SESSION + "/" + key.toString()}
: If you click on a game button from the list it will redirect you to the game to play
SessionScreen.tsx : it is the game page where you can play on limited rounds and where the result of the game will be displayed at the end
import { IonPage } from "@ionic/react";
import React from "react";
export const SessionScreen: React.FC = () => {
return <IonPage className="container"></IonPage>;
};
We leave it empty for now and we will edit it later and explain what to write
import { IonPage } from "@ionic/react";
import React from "react";
export const TopPlayersScreen: React.FC = () => {
return <IonPage className="container"></IonPage>;
};
We leave it empty for now and edit it later too
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonImg,
IonItem,
IonList,
IonPage,
IonTitle,
IonToolbar,
} from "@ionic/react";
import React from "react";
import { useHistory } from "react-router-dom";
import Clock from "../assets/clock.webp";
import Legend from "../assets/legend.webp";
import Paper from "../assets/paper-logo.webp";
import Scissor from "../assets/scissor-logo.webp";
import Stone from "../assets/stone-logo.webp";
export const RulesScreen: React.FC = () => {
const { goBack } = useHistory();
/* 2. Get the param */
return (
<IonPage className="container">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={goBack}>Back</IonButton>
</IonButtons>
<IonTitle>Rules</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<div style={{ textAlign: "left" }}>
<IonList>
<IonItem className="nopm">
<IonImg src={Stone} className="logo" />
Stone (Clenched Fist). Rock beats the scissors by hitting it
</IonItem>
<IonItem className="nopm">
<IonImg src={Paper} className="logo" />
Paper (open and extended hand) . Paper wins over stone by enveloping
it
</IonItem>
<IonItem className="nopm">
<IonImg src={Scissor} className="logo" />
Scissors (closed hand with the two fingers) . Scissors wins paper cutting
it
</IonItem>
<IonItem className="nopm">
<IonImg src={Clock} className="logo" />
If you are inactive for more than 10 minutes your opponent can claim
the victory
</IonItem>
<IonItem className="nopm">
<IonImg src={Legend} className="logo" />
<ul style={{ listStyle: "none" }}>
<li className="win">Won round</li>
<li className="lose">Lost round</li>
<li className="draw">Draw</li>
<li className="current">Current Round</li>
<li className="missing">Missing Rounds</li>
</ul>
</IonItem>
</IonList>
</div>
</IonContent>
</IonPage>
);
};
export class TransactionInvalidBeaconError {
name: string;
title: string;
message: string;
description: string;
data_contract_handle: string;
data_expected_form: string;
data_message: string;
/**
*
* @param transactionInvalidBeaconError {
"name": "UnknownBeaconError",
"title": "Aborted",
"message": "[ABORTED_ERROR]:The action was aborted by the user.",
"description": "The action was aborted by the user."
}
*/
constructor(transactionInvalidBeaconError: any) {
this.name = transactionInvalidBeaconError.name;
this.title = transactionInvalidBeaconError.title;
this.message = transactionInvalidBeaconError.message;
this.description = transactionInvalidBeaconError.description;
this.data_contract_handle = "";
this.data_expected_form = "";
this.data_message = this.message;
if (transactionInvalidBeaconError.data !== undefined) {
let dataArray = Array.from<any>(
new Map(
Object.entries<any>(transactionInvalidBeaconError.data)
).values()
);
let contract_handle = dataArray.find(
(obj) => obj.contract_handle !== undefined
);
this.data_contract_handle =
contract_handle !== undefined ? contract_handle.contract_handle : "";
let expected_form = dataArray.find(
(obj) => obj.expected_form !== undefined
);
this.data_expected_form =
expected_form !== undefined
? expected_form.expected_form +
":" +
expected_form.wrong_expression.string
: "";
this.data_message =
(this.data_contract_handle
? "Error on contract : " + this.data_contract_handle + " "
: "") +
(this.data_expected_form
? "error : " + this.data_expected_form + " "
: "");
}
}
}
To test in web
npm run dev
We consider that your wallet is well configured and has some Tez on Ghostnet, so click on Connect button.
Note : If you don't have tokens, to get some free XTZ on Ghostnet, follow this link to the faucet
On the popup, select your Wallet, then your account and connect.
š You are "logged"
Optional : Click on the Disconnect button to test the logout
Click on New Game
button from Home page and then create a new game
Confirm the operation with your wallet
You are redirected the new game session page (that is blank page right now)
Edit the file ./src/SessionScreen.tsx
import {
IonButton,
IonButtons,
IonContent,
IonFooter,
IonHeader,
IonIcon,
IonImg,
IonItem,
IonLabel,
IonList,
IonPage,
IonRefresher,
IonRefresherContent,
IonSpinner,
IonTitle,
IonToolbar,
useIonAlert,
} from "@ionic/react";
import { MichelsonV1ExpressionBase, PackDataParams } from "@taquito/rpc";
import { MichelCodecPacker } from "@taquito/taquito";
import { BigNumber } from "bignumber.js";
import * as crypto from "crypto";
import { eye, stopCircle } from "ionicons/icons";
import React, { useEffect, useState } from "react";
import { RouteComponentProps, useHistory } from "react-router-dom";
import { Action, PAGES, UserContext, UserContextType } from "../App";
import { TransactionInvalidBeaconError } from "../TransactionInvalidBeaconError";
import Paper from "../assets/paper-logo.webp";
import Scissor from "../assets/scissor-logo.webp";
import Stone from "../assets/stone-logo.webp";
import { bytes, nat, unit } from "../type-aliases";
export enum STATUS {
PLAY = "Play !",
WAIT_YOUR_OPPONENT_PLAY = "Wait for your opponent move",
REVEAL = "Reveal your choice now",
WAIT_YOUR_OPPONENT_REVEAL = "Wait for your opponent to reveal his choice",
FINISHED = "Game ended",
}
type SessionScreenProps = RouteComponentProps<{
id: string;
}>;
export const SessionScreen: React.FC<SessionScreenProps> = ({ match }) => {
const [presentAlert] = useIonAlert();
const { goBack } = useHistory();
const id: string = match.params.id;
const {
Tezos,
userAddress,
storage,
mainWalletType,
setStorage,
setLoading,
loading,
refreshStorage,
subReveal,
subNewRound,
} = React.useContext(UserContext) as UserContextType;
const [status, setStatus] = useState<STATUS>();
const [remainingTime, setRemainingTime] = useState<number>(10 * 60);
const [sessionEventRegistrationDone, setSessionEventRegistrationDone] =
useState<boolean>(false);
const registerSessionEvents = async () => {
if (!sessionEventRegistrationDone) {
if (subReveal)
subReveal.on("data", async (e) => {
console.log("on reveal event", e, id, UserContext);
if (
(!e.result.errors || e.result.errors.length === 0) &&
(e.payload as MichelsonV1ExpressionBase).int === id
) {
await revealPlay();
} else
console.warn(
"Warning : here we ignore this transaction event for session ",
id
);
});
if (subNewRound)
subNewRound.on("data", (e) => {
if (
(!e.result.errors || e.result.errors.length === 0) &&
(e.payload as MichelsonV1ExpressionBase).int === id
) {
console.log("on new round event :", e);
refreshStorage();
} else
console.log("Warning : here we ignore this transaction event", e);
});
console.log("registerSessionEvents registered", subReveal, subNewRound);
setSessionEventRegistrationDone(true);
}
};
const buildSessionStorageKey = (
userAddress: string,
sessionNumber: number,
roundNumber: number
): string => {
return (
import.meta.env.VITE_CONTRACT_ADDRESS +
"-" +
userAddress +
"-" +
sessionNumber +
"-" +
roundNumber
);
};
const buildSessionStorageValue = (secret: number, action: Action): string => {
return (
secret + "-" + (action.cisor ? "cisor" : action.paper ? "paper" : "stone")
);
};
const extractSessionStorageValue = (
value: string
): { secret: number; action: Action } => {
const actionStr = value.split("-")[1];
return {
secret: Number(value.split("-")[0]),
action:
actionStr === "cisor"
? new Action(true as unit, undefined, undefined)
: actionStr === "paper"
? new Action(undefined, true as unit, undefined)
: new Action(undefined, undefined, true as unit),
};
};
useEffect(() => {
if (storage) {
const session = storage?.sessions.get(new BigNumber(id) as nat);
console.log(
"Session has changed",
session,
"round",
session?.current_round.toNumber(),
"session.decoded_rounds.get(session.current_round)",
session?.decoded_rounds.get(session?.current_round)
);
if (session && ("winner" in session.result || "draw" in session.result)) {
setStatus(STATUS.FINISHED);
} else if (session) {
if (
session.decoded_rounds &&
session.decoded_rounds.get(session.current_round) &&
session.decoded_rounds.get(session.current_round).length === 1 &&
session.decoded_rounds.get(session.current_round)[0].player ===
userAddress
) {
setStatus(STATUS.WAIT_YOUR_OPPONENT_REVEAL);
} else if (
session.rounds &&
session.rounds.get(session.current_round) &&
session.rounds.get(session.current_round).length === 2
) {
setStatus(STATUS.REVEAL);
} else if (
session.rounds &&
session.rounds.get(session.current_round) &&
session.rounds.get(session.current_round).length === 1 &&
session.rounds.get(session.current_round)[0].player === userAddress
) {
setStatus(STATUS.WAIT_YOUR_OPPONENT_PLAY);
} else {
setStatus(STATUS.PLAY);
}
}
(async () => await registerSessionEvents())();
} else {
console.log("Wait parent to init storage ...");
}
}, [storage?.sessions.get(new BigNumber(id) as nat)]);
//setRemainingTime
useEffect(() => {
const interval = setInterval(() => {
const diff = Math.round(
(new Date(
storage?.sessions.get(new BigNumber(id) as nat).asleep!
).getTime() -
Date.now()) /
1000
);
if (diff <= 0) {
setRemainingTime(0);
} else {
setRemainingTime(diff);
}
}, 1000);
return () => clearInterval(interval);
}, [storage?.sessions.get(new BigNumber(id) as nat)]);
const play = async (action: Action) => {
const session_id = new BigNumber(id) as nat;
const current_session = storage?.sessions.get(session_id);
try {
setLoading(true);
const secret = Math.round(Math.random() * 63); //FIXME it should be 654843, but we limit the size of the output hexa because expo-crypto is buggy
// see https://forums.expo.dev/t/how-to-hash-buffer-with-expo-for-an-array-reopen/64587 or https://github.com/expo/expo/issues/20706 );
localStorage.setItem(
buildSessionStorageKey(
userAddress,
Number(id),
storage!.sessions
.get(new BigNumber(id) as nat)
.current_round.toNumber()
),
buildSessionStorageValue(secret, action)
);
console.log("PLAY - pushing to session storage ", secret, action);
const encryptedAction = await create_bytes(action, secret);
console.log(
"encryptedAction",
encryptedAction,
"session_id",
session_id,
"current_round",
current_session?.current_round
);
const preparedCall = mainWalletType?.methods.play(
session_id,
current_session!.current_round,
encryptedAction
);
const { gasLimit, storageLimit, suggestedFeeMutez } =
await Tezos.estimate.transfer({
...preparedCall!.toTransferParams(),
amount: 1,
mutez: false,
});
console.log({ gasLimit, storageLimit, suggestedFeeMutez });
const op = await preparedCall!.send({
gasLimit: gasLimit * 2, //we take a margin +1000 for an eventual event in case of paralell execution
fee: suggestedFeeMutez * 2,
storageLimit: storageLimit,
amount: 1,
mutez: false,
});
await op?.confirmation();
const newStorage = await mainWalletType?.storage();
setStorage(newStorage!);
setLoading(false);
console.log("newStorage", newStorage);
} catch (error) {
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
const tibe: TransactionInvalidBeaconError =
new TransactionInvalidBeaconError(error);
presentAlert({
header: "Error",
message: tibe.data_message,
buttons: ["Close"],
});
setLoading(false);
}
setLoading(false);
};
const revealPlay = async () => {
const session_id = new BigNumber(id) as nat;
//force refresh in case of events
const storage = await mainWalletType?.storage();
const current_session = storage!.sessions.get(session_id)!;
console.warn(
"refresh storage because events can trigger it outisde react scope ...",
userAddress,
current_session.current_round
);
//fecth from session storage
const secretActionStr = localStorage.getItem(
buildSessionStorageKey(
userAddress,
session_id.toNumber(),
current_session!.current_round.toNumber()
)
);
if (!secretActionStr) {
presentAlert({
header: "Internal error",
message:
"You lose the session/round " +
session_id +
"/" +
current_session!.current_round.toNumber() +
" storage, no more possible to retrieve secrets, stop Session please",
buttons: ["Close"],
});
setLoading(false);
return;
}
const secretAction = extractSessionStorageValue(secretActionStr);
console.log("REVEAL - Fetch from session storage", secretAction);
try {
setLoading(true);
const encryptedAction = await packAction(secretAction.action);
const preparedCall = mainWalletType?.methods.revealPlay(
session_id,
current_session?.current_round!,
encryptedAction as bytes,
new BigNumber(secretAction.secret) as nat
);
const { gasLimit, storageLimit, suggestedFeeMutez } =
await Tezos.estimate.transfer(preparedCall!.toTransferParams());
//console.log({ gasLimit, storageLimit, suggestedFeeMutez });
const op = await preparedCall!.send({
gasLimit: gasLimit * 3,
fee: suggestedFeeMutez * 2,
storageLimit: storageLimit * 4, //we take a margin in case of paralell execution
});
await op?.confirmation();
const newStorage = await mainWalletType?.storage();
setStorage(newStorage!);
setLoading(false);
console.log("newStorage", newStorage);
} catch (error) {
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
const tibe: TransactionInvalidBeaconError =
new TransactionInvalidBeaconError(error);
presentAlert({
header: "Error",
message: tibe.data_message,
buttons: ["Close"],
});
setLoading(false);
}
setLoading(false);
};
/** Pack an action variant to bytes. Same is Pack.bytes() */
async function packAction(action: Action): Promise<string> {
const p = new MichelCodecPacker();
const actionbytes: PackDataParams = {
data: action.stone
? { prim: "Left", args: [{ prim: "Unit" }] }
: action.paper
? { prim: "Right", args: [{ prim: "Left", args: [{ prim: "Unit" }] }] }
: {
prim: "Right",
args: [{ prim: "Right", args: [{ prim: "Unit" }] }],
},
type: {
prim: "Or",
annots: ["%action"],
args: [
{ prim: "Unit", annots: ["%stone"] },
{
prim: "Or",
args: [
{ prim: "Unit", annots: ["%paper"] },
{ prim: "Unit", annots: ["%cisor"] },
],
},
],
},
};
return (await p.packData(actionbytes)).packed;
}
/** Pack an pair [actionBytes,secret] to bytes. Same is Pack.bytes() */
async function packActionBytesSecret(
actionBytes: bytes,
secret: number
): Promise<string> {
const p = new MichelCodecPacker();
const actionBytesSecretbytes: PackDataParams = {
data: {
prim: "Pair",
args: [{ bytes: actionBytes }, { int: secret.toString() }],
},
type: {
prim: "pair",
args: [
{
prim: "bytes",
},
{ prim: "nat" },
],
},
};
return (await p.packData(actionBytesSecretbytes)).packed;
}
const stopSession = async () => {
try {
setLoading(true);
const op = await mainWalletType?.methods
.stopSession(new BigNumber(id) as nat)
.send();
await op?.confirmation(2);
const newStorage = await mainWalletType?.storage();
setStorage(newStorage!);
setLoading(false);
console.log("newStorage", newStorage);
} catch (error) {
console.table(`Error: ${JSON.stringify(error, null, 2)}`);
const tibe: TransactionInvalidBeaconError =
new TransactionInvalidBeaconError(error);
presentAlert({
header: "Error",
message: tibe.data_message,
buttons: ["Close"],
});
setLoading(false);
}
setLoading(false);
};
const create_bytes = async (
action: Action,
secret: number
): Promise<bytes> => {
const actionBytes = (await packAction(action)) as bytes;
console.log("actionBytes", actionBytes);
const bytes = (await packActionBytesSecret(actionBytes, secret)) as bytes;
console.log("bytes", bytes);
/* correct implemetation with a REAL library */
const encryptedActionSecret = crypto
.createHash("sha512")
.update(Buffer.from(bytes, "hex"))
.digest("hex") as bytes;
console.log("encryptedActionSecret", encryptedActionSecret);
return encryptedActionSecret;
};
const getFinalResult = (): string | undefined => {
if (storage) {
const result = storage.sessions.get(new BigNumber(id) as nat).result;
if ("winner" in result && result.winner === userAddress) return "win";
if ("winner" in result && result.winner !== userAddress) return "lose";
if ("draw" in result) return "draw";
}
};
const isDesktop = () => {
const { innerWidth } = window;
if (innerWidth > 800) return true;
else return false;
};
return (
<IonPage className="container">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={goBack}>Back</IonButton>
</IonButtons>
<IonTitle>Game nĀ°{id}</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonRefresher slot="fixed" onIonRefresh={refreshStorage}>
<IonRefresherContent></IonRefresherContent>
</IonRefresher>
{loading ? (
<div className="loading">
<IonItem>
<IonLabel>Refreshing ...</IonLabel>
<IonSpinner className="spinner"></IonSpinner>
</IonItem>
</div>
) : (
<>
<IonList inset={true} style={{ textAlign: "left" }}>
{status !== STATUS.FINISHED ? (
<IonItem className="nopm">Status : {status}</IonItem>
) : (
""
)}
<IonItem className="nopm">
<span>
Opponent :{" "}
{storage?.sessions
.get(new BigNumber(id) as nat)
.players.find((userItem) => userItem !== userAddress)}
</span>
</IonItem>
{status !== STATUS.FINISHED ? (
<IonItem className="nopm">
Round :
{Array.from(
Array(
storage?.sessions
.get(new BigNumber(id) as nat)
.total_rounds.toNumber()
).keys()
).map((roundId) => {
const currentRound: number = storage
? storage?.sessions
.get(new BigNumber(id) as nat)
.current_round?.toNumber() - 1
: 0;
const roundwinner = storage?.sessions
.get(new BigNumber(id) as nat)
.board.get(new BigNumber(roundId + 1) as nat);
return (
<div
key={roundId + "-" + roundwinner}
className={
!roundwinner && roundId > currentRound
? "missing"
: !roundwinner && roundId === currentRound
? "current"
: !roundwinner
? "draw"
: roundwinner.Some === userAddress
? "win"
: "lose"
}
></div>
);
})}
</IonItem>
) : (
""
)}
{status !== STATUS.FINISHED ? (
<IonItem className="nopm">
{"Remaining time :" + remainingTime + " s"}
</IonItem>
) : (
""
)}
</IonList>
{status === STATUS.FINISHED ? (
<IonImg
className={"logo-XXL" + (isDesktop() ? "" : " mobile")}
src={"assets/" + getFinalResult() + ".png"}
/>
) : (
""
)}
{status === STATUS.PLAY ? (
<IonList lines="none" style={{ marginLeft: "calc(50vw - 70px)" }}>
<IonItem style={{ margin: 0, padding: 0 }}>
<IonButton
style={{ height: "auto" }}
onClick={() =>
play(new Action(true as unit, undefined, undefined))
}
>
<IonImg src={Scissor} className="logo" />
</IonButton>
</IonItem>
<IonItem style={{ margin: 0, padding: 0 }}>
<IonButton
style={{ height: "auto" }}
onClick={() =>
play(new Action(undefined, true as unit, undefined))
}
>
<IonImg src={Paper} className="logo" />
</IonButton>
</IonItem>
<IonItem style={{ margin: 0, padding: 0 }}>
<IonButton
style={{ height: "auto" }}
onClick={() =>
play(new Action(undefined, undefined, true as unit))
}
>
<IonImg src={Stone} className="logo" />
</IonButton>
</IonItem>
</IonList>
) : (
""
)}
{status === STATUS.REVEAL ? (
<IonButton onClick={() => revealPlay()}>
<IonIcon icon={eye} />
Reveal
</IonButton>
) : (
""
)}
{remainingTime === 0 && status !== STATUS.FINISHED ? (
<IonButton onClick={() => stopSession()}>
<IonIcon icon={stopCircle} />
Claim victory
</IonButton>
) : (
""
)}
</>
)}
</IonContent>
<IonFooter>
<IonToolbar>
<IonTitle>
<IonButton routerLink={PAGES.RULES} expand="full">
Rules
</IonButton>
</IonTitle>
</IonToolbar>
</IonFooter>
</IonPage>
);
};
Explanations :
export enum STATUS {...
: This enum is used to guess what is the actual status of the game based on different field values. It gives for connected user the next action to do, and so control the display of the buttonsconst subReveal = Tezos.stream.subscribeEvent({tag: "reveal",...
: websocket subscription to smartcontract reveal
event. When is time to reveal, we can trigger the action from the mobile without asking the user to click on the buttonconst subNewRound = Tezos.stream.subscribeEvent({tag: "newRound",...
: websocket subscription to smartcontract newround
event. when a new round is ready, this event notifies the mobile to refresh the current game so the player can play on next roundconst buildSessionStorageKey ...
: this function is an helper to store on browser storage a unique secret key of the player. This secret is a salt that is added to encrypt the Play action and then to decrypt the Reveal actionconst buildSessionStorageValue ...
: same as above but for the value stored as a stringconst play = async (action: Action) => { ...
: Play action. We create a player secret for this Play action randomly Math.round(Math.random() * 63)
and store it on the browser storage localStorage.setItem(buildSessionStorageKey(...
. Then we pack and ecrypt the Play action calling create_bytes(action, secret)
, we estimate the cost of the transaction and we add an extra for the event cost mainWalletType!.methods.play(encryptedAction,current_session!.current_round,session_id) ... Tezos.estimate.transfer(...) ... preparedCall.send({gasLimit: gasLimit + 1000, ...
. We ask 1 XTZ for each player doing a Play action. This money is stacked on the contract and free/dispatched when game is ended. Shifumi game does not take any extra fee by default. Only players win or lose moneyconst revealPlay = async () => {...
: Reveal action. We fetch the secret from localStorage.getItem(...
, then we pack the secret action and we reveal what was the secret mainWalletType!.methods.revealPlay(encryptedAction as bytes,new BigNumber(secretAction.secret) as nat,current_session!.current_round,session_id);
. We add again some extra gas limit gasLimit: gasLimit * 3
. Note on why to increase the gas limit : the reason is because if two players reveal actions are on the same block, the primary estimation of gas made by the wallet will not be enough. The reason is that the execution of the second reveal play action will execute another business logic because the first action will modify the initial state, so the estimation at this time (i.e with this previous state) is no more validconst getFinalResult
: based on some fields, it will give the final Status of the game once is ended. when game is ended the winner will get the money stacked by the loser. In case of draw, stacked money is sent back to the players.const stopSession = async () => {...
: There is a countdown of 10min while inaction. If no player wants to play anymore and the game is unfinished, someone can claim the victory and close the game calling mainWalletType!.methods.stopSession(
. The smart contract will look at different configuration to guess if there is someone guilty or it is just a draw because no one want to play anymore. Gains will be sent to the winner or in a case of draw, will be sent back to playersWhen page refreshed, then you can see the game session :bowtie:
Last step is to see the score of all players
Edit TopPlayersScreen.tsx
import {
IonButton,
IonButtons,
IonCol,
IonContent,
IonGrid,
IonHeader,
IonImg,
IonPage,
IonRefresher,
IonRefresherContent,
IonRow,
IonTitle,
IonToolbar,
} from "@ionic/react";
import React, { useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { UserContext, UserContextType } from "../App";
import Ranking from "../assets/ranking.webp";
import { nat } from "../type-aliases";
export const TopPlayersScreen: React.FC = () => {
const { goBack } = useHistory();
const { storage, refreshStorage } = React.useContext(
UserContext
) as UserContextType;
const [ranking, setRanking] = useState<Map<string, number>>(new Map());
useEffect(() => {
(async () => {
if (storage) {
const ranking = new Map(); //force refresh
Array.from(storage.sessions.keys()).forEach((key: nat) => {
const result = storage.sessions.get(key).result;
if ("winner" in result) {
const winner = result.winner;
let score = ranking.get(winner);
if (score) score++;
else score = 1;
ranking.set(winner, score);
}
});
setRanking(ranking);
} else {
console.log("storage is not ready yet");
}
})();
}, [storage]);
/* 2. Get the param */
return (
<IonPage className="container">
<IonHeader>
<IonToolbar>
<IonButtons slot="start">
<IonButton onClick={goBack}>Back</IonButton>
</IonButtons>
<IonTitle>Top Players</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent fullscreen>
<IonRefresher slot="fixed" onIonRefresh={refreshStorage}>
<IonRefresherContent></IonRefresherContent>
</IonRefresher>
<div style={{ marginLeft: "40vw" }}>
<IonImg
src={Ranking}
className="ranking"
style={{ height: "10em", width: "5em" }}
/>
</div>
<IonGrid fixed={true} style={{ color: "white", padding: "2vh" }}>
<IonRow
style={{
backgroundColor: "var(--ion-color-primary)",
}}
>
<IonCol className="col">Address</IonCol>
<IonCol size="2" className="col">
Won
</IonCol>
</IonRow>
{ranking && ranking.size > 0
? Array.from(ranking).map(([address, count]) => (
<IonRow
key={address}
style={{
backgroundColor: "var(--ion-color-secondary)",
}}
>
<IonCol className="col tiny">{address}</IonCol>
<IonCol size="2" className="col">
{count}
</IonCol>
</IonRow>
))
: []}
</IonGrid>
</IonContent>
</IonPage>
);
};
Explanations :
let ranking = new Map()
: we prepare a map to count the score for each winner. Looping of all sessions storage.sessions.keys()).forEach
we take only where there is a winner if ("winner" in result)
then we increment score if (score) score++;else score = 1
and push it to the map ranking.set(winner, score);
Ok, so we have all pages now. The Game dapp is done ! š
If you want the Android version of the game, follow below instructions
Note : you need to install Android SDK or iOS stack. Recommendation : Easier to start with Android
To modify the name of your app, open the capacitor.config.json
file and change "appId":"dev.marigold.shifumi"
and "appName": "Tezos Shifumi"
properties
Hack : to build on android, change vite.config.ts to remove global field here
export default defineConfig({
define: {
"process.env": process.env,
//global: {},
},
Also change the ionic config to move from react to custom type build on ionic.config.json
{
"name": "shifumi",
"integrations": {
"capacitor": {}
},
"type": "custom"
}
Stay on the app folder, and prepare Android release Then these lines will copy all to android folder + the images ressources used by the store
ionic capacitor add android
ionic capacitor copy android
npm install -g cordova-res
cordova-res android --skip-config --copy
ionic capacitor sync android
ionic capacitor update android
Open Android Studio and do a Build
or Make Project
action
Note 1 : in case of broken gradle :
ionic capacitor sync android
and click on sync on Android studio > buildNote 2 : If you have
WSL2
and difficulties to run an emulator on it, I advice you to install Android studio on Windows and build, test and package all on this OS. Push your files on your git repo, and check on .gitignore forandroid
folder that there is no filters on assets. Comment end lines on fileapp/android/.gitignore
# Cordova plugins for Capacitor #capacitor-cordova-android-plugins # Copied web assets #app/src/main/assets/public # Generated Config files #app/src/main/assets/capacitor.config.json #app/src/main/assets/capacitor.plugins.json #app/src/main/res/xml/config.xml
and comment also the
node_modules
anddist
at your root project because it will require files from @capacitor and you need to install this libraries#node_modules/ #/dist
Force it to be included on committed files :
git add -f android/app/src/main/assets/ ; git add -f android/capacitor-cordova-android-plugins/ ; git add -f node_modules ;
and push to git Then try againBuild
orMake Project
action on Android Studio
Start the emulator of your choice (or a physical device) and click on Run app
I recommend to connect with a web wallet like Kukai (because some mobile wallet does not work on the emulator)
Once connected, you can start a new game
Invite Alice to play, click on the address of the opponent player and enter this on your Android Studio terminal
adb shell input text "tz1VSUr8wwNhLAzempoch5d6hLRiTh8Cjcjb"
then click on Create on top right button Confirm the transaction in Kukai and come back to the app
Perfect, the round is starting !
Now you can run the web version on VScode, connect with alice and play the party with your 2 players
Watch the video here to see how to play a party
To publish your app to Android store, read the Google documentation. You will need a developer account : https://developer.android.com/distribute/console/
It costs 25$ for life (for information : Apple developer account costs 99$/ year ...)
Go to Build > Generate Signed bundle / APK...
Follow the Google instruction to set your keystore, and click next. Watch where your binary is stored and upload it to your Google Play console app
After passing a (long) configuration of your application on Google Play Store and passed all Google validations, your app will be published and everyone can download it on Earth
Play with your friends and follow other Marigold trainings here