├── .babelrc ├── .env ├── .eslintignore ├── .eslintrc.js ├── .flowconfig ├── .gitignore ├── .prettierrc.yaml ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── cluster-devnet.env ├── cluster-mainnet-beta.env ├── cluster-testnet.env ├── dist ├── img │ └── solana-logo-horizontal.svg ├── index.css └── index.html ├── flow-typed ├── bs58.js ├── buffer-layout.js ├── cbor.js ├── event-emitter.js ├── json-to-pretty-yaml.js ├── mkdirp-promise_vx.x.x.js ├── npm │ └── mz_vx.x.x.js ├── readline-promise.js └── semver.js ├── package-lock.json ├── package.json ├── program-bpf-c ├── .gitignore ├── makefile └── src │ └── tictactoe │ ├── program_command.h │ ├── program_state.h │ ├── test_tictactoe.c │ └── tictactoe.c ├── program-bpf-rust ├── .gitignore ├── Cargo.toml ├── README.md ├── Xargo.toml ├── do.sh └── src │ ├── dashboard.rs │ ├── error.rs │ ├── game.rs │ ├── lib.rs │ ├── program_command.rs │ ├── program_state.rs │ └── simple_serde.rs ├── src ├── cli │ └── main.js ├── program │ ├── program-command.js │ ├── program-state.js │ ├── tic-tac-toe-dashboard.js │ └── tic-tac-toe.js ├── server │ ├── config.js │ ├── main.js │ └── store.js ├── util │ ├── new-system-account-with-airdrop.js │ ├── send-and-confirm-transaction.js │ └── sleep.js └── webapp │ ├── Board.js │ ├── Game.js │ └── index.js ├── url.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "flow", 5 | "react", 6 | "stage-2", 7 | ], 8 | "plugins": [ 9 | "transform-class-properties", 10 | "transform-function-bind", 11 | "transform-runtime", 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | LIVE=1 2 | CLUSTER=devnet 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // eslint-disable-line import/no-commonjs 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | plugins: ['react'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:import/errors', 12 | 'plugin:import/warnings', 13 | 'plugin:react/recommended', 14 | ], 15 | parser: 'babel-eslint', 16 | parserOptions: { 17 | sourceType: 'module', 18 | ecmaVersion: 8, 19 | }, 20 | rules: { 21 | 'no-trailing-spaces': ['error'], 22 | 'import/first': ['error'], 23 | 'import/no-commonjs': ['error'], 24 | 'import/order': [ 25 | 'error', 26 | { 27 | groups: [ 28 | ['internal', 'external', 'builtin'], 29 | ['index', 'sibling', 'parent'], 30 | ], 31 | 'newlines-between': 'always', 32 | }, 33 | ], 34 | indent: [ 35 | 'error', 36 | 2, 37 | { 38 | MemberExpression: 1, 39 | SwitchCase: 1, 40 | }, 41 | ], 42 | 'linebreak-style': ['error', 'unix'], 43 | 'no-console': [0], 44 | quotes: [ 45 | 'error', 46 | 'single', 47 | {avoidEscape: true, allowTemplateLiterals: true}, 48 | ], 49 | 'require-await': ['error'], 50 | semi: ['error', 'always'], 51 | }, 52 | }; 53 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | /node_modules/* 3 | 4 | [include] 5 | 6 | [libs] 7 | node_modules/@solana/web3.js/module.flow.js 8 | flow-typed/ 9 | 10 | [options] 11 | 12 | emoji=true 13 | esproposal.class_instance_fields=enable 14 | esproposal.class_static_fields=enable 15 | esproposal.decorators=ignore 16 | esproposal.export_star_as=enable 17 | module.system.node.resolve_dirname=./src 18 | module.use_strict=true 19 | experimental.const_params=true 20 | include_warnings=true 21 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe 22 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | *.sw[po] 3 | /dist/*bundle.js 4 | /dist/*worker.js 5 | /dist/config.json 6 | /dist/program/ 7 | /.cargo 8 | env 9 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | arrowParens: "avoid" 2 | bracketSpacing: false 3 | jsxBracketSameLine: false 4 | semi: true 5 | singleQuote: true 6 | tabWidth: 2 7 | trailingComma: "all" 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | sudo: required 3 | language: rust 4 | services: 5 | - docker 6 | cache: 7 | cargo: true 8 | directories: 9 | - "~/.npm" 10 | notifications: 11 | email: false 12 | 13 | install: 14 | - cargo --version 15 | - docker --version 16 | - wget -O - https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add - 17 | - sudo apt-add-repository "deb http://apt.llvm.org/bionic/ llvm-toolchain-bionic-10 main" 18 | - sudo apt-get update 19 | - sudo apt-get install -y clang-7 --allow-unauthenticated 20 | - sudo apt-get install -y openssl --allow-unauthenticated 21 | - sudo apt-get install -y libssl-dev --allow-unauthenticated 22 | - sudo apt-get install -y libssl1.1 --allow-unauthenticated 23 | - clang-7 --version 24 | - PATH=$HOME/.cargo/bin:$PATH 25 | - rustup --version 26 | - nvm install node 27 | - node --version 28 | - npm install 29 | 30 | script: 31 | - npm run build:bpf-c 32 | - npm run build:bpf-rust 33 | - npm run test 34 | 35 | before_deploy: 36 | - git add -f dist/program/tictactoe.so 37 | - git commit -m dist/program/tictactoe.so 38 | 39 | deploy: 40 | - provider: heroku 41 | api_key: 42 | secure: RVWMuB8pfYnWxvKDGb4vGHf+475iKGDXBC9MsqMogdx3F2l9oh5AKkhlibPL77Ltuhg+v3o46Ow6ITr6rwDTgZ3mZPI66lTSng0aQRUNGyXaO2EVrXYRMqKQxfiJ2AQ+zke6zGf/IeQAqh5rMh8KxY4+vbPYA/ha3tyxYLSHP0Pxg62F4dJx7hzdGRl8BJPcbFAc4aYR2HOKIX52KjY7ZIMyfuD0jyK7HNFXeV/RdIVcDZI0udJEFDwWg7+wJIS0Ea81++cGUYvbekealDoLqbMOYvAVCc4+3W99tPIIMtaTOWZQGCKF6pxxMS9LZ9+D/pK/hQBkpDLg5t0IWVv0v+OwExpv6n67Nxu8qk8RXHXWeaD9F/Qe+0CpHVKUd8TOBoarxuPzcZ6u8G1F/vmSw7hZ1qe5WTmGcrSJBDFP9Qq7iOxrfR8nl9XaBAj13nTLK/ee7t0Uj4chChoS2//iRUHM+/OMPGHOT3bhHStM73asuwmc2RQgP+26GN4W63N3sBUF9EKWSLPVm+JVmG2+UNhf1FyAGQRoMi4JdkRioZ6o3mHed5BrpyksqTnyHh4h16Nm0Axwo3ruV4rum8N+O1axyuOzmUa1B5YV7+UqF9KQJu6yj/oeVLyBNN41FR6NuqKhoPm7JdE48kfU8UsjFkEAijMCEj10syd8eW+dFzY= 43 | app: solana-example-tictactoe 44 | strategy: git 45 | on: 46 | repo: solana-labs/example-tictactoe 47 | branch: v1.1 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Solana Labs, Inc 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start-server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build status][travis-image]][travis-url] 2 | 3 | [travis-image]: https://travis-ci.org/solana-labs/example-tictactoe.svg?branch=v1.1 4 | [travis-url]: https://travis-ci.org/solana-labs/example-tictactoe 5 | 6 | # Tic-Tac-Toe on Solana 7 | 8 | This project demonstrates how to use the [Solana Javascript API](https://github.com/solana-labs/solana-web3.js) 9 | to build, deploy, and interact with programs on the Solana blockchain, implementing an interactive tic-tac-toe game between two users. 10 | To see the final product, go to https://solana-example-tictactoe.herokuapp.com/ and wait for another player to join. 11 | (Direct a second browser window to the web app to play against yourself.) 12 | 13 | The project comprises: 14 | 15 | * The on-chain Tic-Tac-Toe program, a BPF program written in Rust `program-bpf-rust` and C `program-bpf-c` 16 | * Easy program build and deployment using the `@solana/web3.js` library 17 | * Command-line and web front-end: `src/` 18 | 19 | ## Learn about Solana 20 | 21 | More information about how Solana works is available in the [Book](https://docs.solana.com/book/) 22 | 23 | ## Getting Started 24 | 25 | The following dependencies are required to build and run this example, 26 | depending on your OS they may already be installed: 27 | 28 | ```sh 29 | $ npm --version 30 | $ docker -v 31 | $ wget --version 32 | $ rustc --version 33 | ``` 34 | 35 | Next fetch the npm dependencies, including `@solana/web3.js`, by running: 36 | ```sh 37 | $ npm install 38 | ``` 39 | 40 | ### Select a Network 41 | The example connects to a local Solana cluster by default. 42 | 43 | To enable on-chain program logs, set the `RUST_LOG` environment variable: 44 | ```sh 45 | $ export RUST_LOG=solana_runtime::native_loader=trace,solana_runtime::system_instruction_processor=trace,solana_runtime::bank=debug,solana_bpf_loader=debug,solana_rbpf=debug 46 | ``` 47 | 48 | To start a local Solana cluster run: 49 | ```sh 50 | $ npm run localnet:update 51 | $ npm run localnet:up 52 | ``` 53 | 54 | Solana cluster logs are available with: 55 | ```sh 56 | $ npm run localnet:logs 57 | ``` 58 | 59 | To stop the local solana cluster run: 60 | ```sh 61 | $ npm run localnet:down 62 | ``` 63 | 64 | For more details on working with a local cluster, see the [full instructions](https://github.com/solana-labs/solana-web3.js#local-network). 65 | 66 | ### Build the BPF program 67 | ```sh 68 | $ npm run build:bpf-rust 69 | ``` 70 | or 71 | ``` 72 | $ npm run build:bpf-c 73 | ``` 74 | 75 | The compiler places output files in `dist/program`. Program build scripts contain the compiler settings and can be found in the [Solana SDK](https://github.com/solana-labs/solana/tree/master/sdk/bpf/rust) 76 | 77 | ### Run the Command-Line Front End 78 | After building the program, 79 | 80 | ```sh 81 | $ npm run start 82 | ``` 83 | 84 | This script uses the Solana Javascript API `BpfLoader` to deploy the Tic-Tac-Toe program to the blockchain. 85 | Once the deploy transaction is confirmed on the chain, the script calls the program to instantiate a new dashboard 86 | to track the open and completed games (`findDashboard`), and starts a new game (`dashboard.startGame`), waiting for an opponent. 87 | 88 | To play the game, open a second terminal and again run the `npm run start` script. 89 | 90 | To see the program or game state on the blockchain, send a `getAccountInfo` [JSON-RPC request](https://solana-labs.github.io/solana/jsonrpc-api.html#getaccountinfo) to the cluster, using the id printed by the script, eg.: 91 | * `Dashboard programId: HFA4x4oZKWeGcRVbUYaCHM59i5AFfP3nCfc4NkrBvVtP` 92 | * `Dashboard: HmAEDrGpsRK2PkR51E9mQrKQG7Qa3iyv4SvZND9uEkdR` 93 | * `Advertising our game (Gx1kjBieYgaPgDhaovzvvZapUTg5Mz6nhXTLWSQJpNMv)` 94 | 95 | ### Run the WebApp Front End 96 | After building the program, 97 | 98 | ```sh 99 | $ npm run dev 100 | ``` 101 | 102 | This script deploys the program to the blockchain and also boots up a local webserver 103 | for gameplay. 104 | 105 | To instantiate a dashboard and game, open your browser to [http://localhost:8080/](http://localhost:8080/). 106 | 107 | ## Customizing the Program 108 | To customize Tic-Tac-Toe, make changes to the program in `program-bpf-rust/src`, rebuild it, and restart the network. 109 | Now when you run `npm run start`, you should see your changes. 110 | 111 | To deploy a program with a different name, edit `src/server/config.js`. 112 | 113 | ## Pointing to a public Solana cluster 114 | 115 | Solana maintains three public clusters: 116 | - `devnet` - Development cluster with airdrops enabled 117 | - `testnet` - Tour De Sol test cluster without airdrops enabled 118 | - `mainnet-beta` - Main cluster 119 | 120 | Use npm scripts to configure which cluster. 121 | 122 | To point to `devnet`: 123 | ```bash 124 | $ npm run cluster:devnet 125 | ``` 126 | 127 | To point back to the local cluster: 128 | ```bash 129 | $ npm run cluster:localnet 130 | ``` -------------------------------------------------------------------------------- /cluster-devnet.env: -------------------------------------------------------------------------------- 1 | LIVE=1 2 | CLUSTER=devnet 3 | -------------------------------------------------------------------------------- /cluster-mainnet-beta.env: -------------------------------------------------------------------------------- 1 | LIVE=1 2 | CLUSTER=mainnet-beta 3 | -------------------------------------------------------------------------------- /cluster-testnet.env: -------------------------------------------------------------------------------- 1 | LIVE=1 2 | CLUSTER=testnet 3 | -------------------------------------------------------------------------------- /dist/img/solana-logo-horizontal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dist/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | height: 100%; 7 | background-color: #303030; 8 | font-family: "Rubik", sans-serif; 9 | } 10 | 11 | #app { 12 | height: 100%; 13 | width:100%; 14 | } 15 | 16 | h2 { 17 | margin-bottom: 24px; 18 | } 19 | 20 | .well { 21 | text-align: center; 22 | } 23 | 24 | .well, .panel, .alert { 25 | background-color: #1e1e1e; 26 | border-color: #000; 27 | box-shadow: 0 2px 8px 0 rgba(0,0,0, .25); 28 | color: white; 29 | margin: 20px auto; 30 | max-width: 600px; 31 | } 32 | 33 | .panel-default > .panel-heading { 34 | background-color: #000; 35 | border-color: #000; 36 | color: #fff; 37 | } 38 | 39 | .btn-primary { 40 | border-color: #00ffbb; 41 | background-color: #00ffbb; 42 | color: #000; 43 | -webkit-transition: .2s ease-in-out all; 44 | -o-transition: .2s ease-in-out all; 45 | transition: .2s ease-in-out all; 46 | } 47 | 48 | .btn-primary:hover, 49 | .btn-primary:focus, 50 | .btn-primary:active, 51 | .btn-primary:active:hover { 52 | border-color: #00ffbb !important; 53 | background-color: #00ffbb !important; 54 | color: #000; 55 | -webkit-transform: scale(1.01); 56 | transform: scale(1.01); 57 | } 58 | 59 | .board-row .btn-default, 60 | .board-row .btn-default:disabled, 61 | .board-row .btn-default:disabled:hover, 62 | .board-row .btn-default:disabled:active { 63 | background-color: #303030; 64 | border-radius: 0; 65 | border-color: rgba(0,0,0, .5); 66 | color: #fff; 67 | -webkit-transition: .2s ease-in-out all; 68 | -o-transition: .2s ease-in-out all; 69 | transition: .2s ease-in-out all; 70 | } 71 | 72 | .board-row .btn-default:hover { 73 | background-color: #00ffbb; 74 | border-color: #00ffbb; 75 | color: #101010; 76 | box-shadow: 0 0 8px 0 rgba(0, 0, 0, .8); 77 | } 78 | 79 | .board-row .btn-default:focus { 80 | background-color: #00ffbb; 81 | border-color: #00ffbb; 82 | color: #101010; 83 | box-shadow: 0 0 8px 0 rgba(0, 0, 0, .8); 84 | outline: 0; 85 | } 86 | 87 | .board-row .btn-default:active { 88 | border-color: #29d8a9; 89 | background-color: #29d8a9; 90 | box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, .35); 91 | } 92 | 93 | .board-row .btn-default:first-of-type { 94 | border-left-width: 2px; 95 | } 96 | 97 | .board-row .btn-default:last-of-type { 98 | border-right-width: 2px; 99 | } 100 | 101 | .board-row:first-of-type .btn-default { 102 | border-top-width: 2px; 103 | } 104 | 105 | .board-row:last-of-type .btn-default { 106 | border-bottom-width: 2px; 107 | } 108 | 109 | .logo { 110 | display: block; 111 | margin-left: auto; 112 | margin-right: auto; 113 | margin-top: 20px; 114 | width: 50%; 115 | } 116 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Tic-Tac-Toe 5 | 11 | 12 | 13 | 14 | 15 | 16 | Fork me on GitHub 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /flow-typed/bs58.js: -------------------------------------------------------------------------------- 1 | declare module 'bs58' { 2 | declare module.exports: { 3 | encode(input: Buffer): string; 4 | decode(input: string): Buffer; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/buffer-layout.js: -------------------------------------------------------------------------------- 1 | declare module 'buffer-layout' { 2 | // TODO: Fill in types 3 | declare module.exports: any; 4 | } 5 | -------------------------------------------------------------------------------- /flow-typed/cbor.js: -------------------------------------------------------------------------------- 1 | declare module 'cbor' { 2 | declare module.exports: { 3 | decode(input: Buffer): Object; 4 | encode(input: any): Buffer; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /flow-typed/event-emitter.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 4f92d81ee3831cb415b4b216cc0679d9 2 | // flow-typed version: <>/event-emitter_v0.3.5/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'event-emitter' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'event-emitter' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'event-emitter/all-off' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'event-emitter/benchmark/many-on' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'event-emitter/benchmark/single-on' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'event-emitter/emit-error' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'event-emitter/has-listeners' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'event-emitter/pipe' { 46 | declare module.exports: any; 47 | } 48 | 49 | declare module 'event-emitter/test/all-off' { 50 | declare module.exports: any; 51 | } 52 | 53 | declare module 'event-emitter/test/emit-error' { 54 | declare module.exports: any; 55 | } 56 | 57 | declare module 'event-emitter/test/has-listeners' { 58 | declare module.exports: any; 59 | } 60 | 61 | declare module 'event-emitter/test/index' { 62 | declare module.exports: any; 63 | } 64 | 65 | declare module 'event-emitter/test/pipe' { 66 | declare module.exports: any; 67 | } 68 | 69 | declare module 'event-emitter/test/unify' { 70 | declare module.exports: any; 71 | } 72 | 73 | declare module 'event-emitter/unify' { 74 | declare module.exports: any; 75 | } 76 | 77 | // Filename aliases 78 | declare module 'event-emitter/all-off.js' { 79 | declare module.exports: $Exports<'event-emitter/all-off'>; 80 | } 81 | declare module 'event-emitter/benchmark/many-on.js' { 82 | declare module.exports: $Exports<'event-emitter/benchmark/many-on'>; 83 | } 84 | declare module 'event-emitter/benchmark/single-on.js' { 85 | declare module.exports: $Exports<'event-emitter/benchmark/single-on'>; 86 | } 87 | declare module 'event-emitter/emit-error.js' { 88 | declare module.exports: $Exports<'event-emitter/emit-error'>; 89 | } 90 | declare module 'event-emitter/has-listeners.js' { 91 | declare module.exports: $Exports<'event-emitter/has-listeners'>; 92 | } 93 | declare module 'event-emitter/index' { 94 | declare module.exports: $Exports<'event-emitter'>; 95 | } 96 | declare module 'event-emitter/index.js' { 97 | declare module.exports: $Exports<'event-emitter'>; 98 | } 99 | declare module 'event-emitter/pipe.js' { 100 | declare module.exports: $Exports<'event-emitter/pipe'>; 101 | } 102 | declare module 'event-emitter/test/all-off.js' { 103 | declare module.exports: $Exports<'event-emitter/test/all-off'>; 104 | } 105 | declare module 'event-emitter/test/emit-error.js' { 106 | declare module.exports: $Exports<'event-emitter/test/emit-error'>; 107 | } 108 | declare module 'event-emitter/test/has-listeners.js' { 109 | declare module.exports: $Exports<'event-emitter/test/has-listeners'>; 110 | } 111 | declare module 'event-emitter/test/index.js' { 112 | declare module.exports: $Exports<'event-emitter/test/index'>; 113 | } 114 | declare module 'event-emitter/test/pipe.js' { 115 | declare module.exports: $Exports<'event-emitter/test/pipe'>; 116 | } 117 | declare module 'event-emitter/test/unify.js' { 118 | declare module.exports: $Exports<'event-emitter/test/unify'>; 119 | } 120 | declare module 'event-emitter/unify.js' { 121 | declare module.exports: $Exports<'event-emitter/unify'>; 122 | } 123 | -------------------------------------------------------------------------------- /flow-typed/json-to-pretty-yaml.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: a65f8ee05f35bc382c3b0f8740bc609d 2 | // flow-typed version: <>/json-to-pretty-yaml_v1.2.2/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'json-to-pretty-yaml' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'json-to-pretty-yaml' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'json-to-pretty-yaml/index.test' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'json-to-pretty-yaml/index' { 31 | declare module.exports: $Exports<'json-to-pretty-yaml'>; 32 | } 33 | declare module 'json-to-pretty-yaml/index.js' { 34 | declare module.exports: $Exports<'json-to-pretty-yaml'>; 35 | } 36 | declare module 'json-to-pretty-yaml/index.test.js' { 37 | declare module.exports: $Exports<'json-to-pretty-yaml/index.test'>; 38 | } 39 | -------------------------------------------------------------------------------- /flow-typed/mkdirp-promise_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 65e18196703cbb222ea294226e99826d 2 | // flow-typed version: <>/mkdirp-promise_v5.0.1/flow_v0.84.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'mkdirp-promise' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'mkdirp-promise' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'mkdirp-promise/lib/index' { 26 | declare module.exports: any; 27 | } 28 | 29 | // Filename aliases 30 | declare module 'mkdirp-promise/lib/index.js' { 31 | declare module.exports: $Exports<'mkdirp-promise/lib/index'>; 32 | } 33 | -------------------------------------------------------------------------------- /flow-typed/npm/mz_vx.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: ed29f42bf4f4916e4f3ba1f5e7343c9d 2 | // flow-typed version: <>/mz_v2.7.0/flow_v0.81.0 3 | 4 | /** 5 | * This is an autogenerated libdef stub for: 6 | * 7 | * 'mz' 8 | * 9 | * Fill this stub out by replacing all the `any` types. 10 | * 11 | * Once filled out, we encourage you to share your work with the 12 | * community by sending a pull request to: 13 | * https://github.com/flowtype/flow-typed 14 | */ 15 | 16 | declare module 'mz' { 17 | declare module.exports: any; 18 | } 19 | 20 | /** 21 | * We include stubs for each file inside this npm package in case you need to 22 | * require those files directly. Feel free to delete any files that aren't 23 | * needed. 24 | */ 25 | declare module 'mz/child_process' { 26 | declare module.exports: any; 27 | } 28 | 29 | declare module 'mz/crypto' { 30 | declare module.exports: any; 31 | } 32 | 33 | declare module 'mz/dns' { 34 | declare module.exports: any; 35 | } 36 | 37 | declare module 'mz/fs' { 38 | declare module.exports: any; 39 | } 40 | 41 | declare module 'mz/readline' { 42 | declare module.exports: any; 43 | } 44 | 45 | declare module 'mz/zlib' { 46 | declare module.exports: any; 47 | } 48 | 49 | // Filename aliases 50 | declare module 'mz/child_process.js' { 51 | declare module.exports: $Exports<'mz/child_process'>; 52 | } 53 | declare module 'mz/crypto.js' { 54 | declare module.exports: $Exports<'mz/crypto'>; 55 | } 56 | declare module 'mz/dns.js' { 57 | declare module.exports: $Exports<'mz/dns'>; 58 | } 59 | declare module 'mz/fs.js' { 60 | declare module.exports: $Exports<'mz/fs'>; 61 | } 62 | declare module 'mz/index' { 63 | declare module.exports: $Exports<'mz'>; 64 | } 65 | declare module 'mz/index.js' { 66 | declare module.exports: $Exports<'mz'>; 67 | } 68 | declare module 'mz/readline.js' { 69 | declare module.exports: $Exports<'mz/readline'>; 70 | } 71 | declare module 'mz/zlib.js' { 72 | declare module.exports: $Exports<'mz/zlib'>; 73 | } 74 | -------------------------------------------------------------------------------- /flow-typed/readline-promise.js: -------------------------------------------------------------------------------- 1 | declare module 'readline-promise' { 2 | 3 | declare class ReadLine { 4 | questionAsync(prompt: string): Promise; 5 | write(text: string): void; 6 | } 7 | 8 | declare module.exports: { 9 | createInterface({ 10 | input: Object, 11 | output: Object, 12 | terminal: boolean 13 | }): ReadLine; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /flow-typed/semver.js: -------------------------------------------------------------------------------- 1 | declare module 'semver' { 2 | declare module.exports: any; 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tictactoe", 3 | "version": "0.0.1", 4 | "description": "", 5 | "testnetDefaultChannel": "v1.1.8", 6 | "scripts": { 7 | "start": "babel-node src/cli/main.js", 8 | "start-server": "babel-node src/server/main.js", 9 | "lint": "npm run pretty && eslint .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "flow": "flow", 12 | "flow:watch": "watch 'flow' . --wait=1 --ignoreDirectoryPattern=/doc/", 13 | "lint:watch": "watch 'npm run lint:fix' . --wait=1", 14 | "test": "npm run lint && npm run flow", 15 | "bpf-sdk:update": "solana-bpf-sdk-install node_modules/@solana/web3.js && npm run clean", 16 | "clean": "npm run clean:bpf-c && npm run clean:bpf-rust", 17 | "build": "webpack --mode=production", 18 | "builddev": "webpack --mode=development", 19 | "dev": "set -ex; npm run build:bpf-rust && babel-node src/server/config; webpack-dev-server --config ./webpack.config.js --mode development", 20 | "build:bpf-c": "rm ./dist/program/tictactoe.so; V=1 make -C program-bpf-c", 21 | "clean:bpf-c": "make -C program-bpf-c clean", 22 | "build:bpf-rust": "./program-bpf-rust/do.sh build", 23 | "clean:bpf-rust": "./program-bpf-rust/do.sh clean", 24 | "cluster:localnet": "rm -f .env", 25 | "cluster:devnet": "cp cluster-devnet.env .env", 26 | "cluster:testnet": "cp cluster-testnet.env .env", 27 | "cluster:mainnet-beta": "cp cluster-mainnet-beta.env .env", 28 | "localnet:update": "solana-localnet update", 29 | "localnet:up": "set -x; solana-localnet down; set -e; solana-localnet up", 30 | "localnet:down": "solana-localnet down", 31 | "localnet:logs": "solana-localnet logs -f", 32 | "pretty": "prettier --write '{,src/**/}*.js'", 33 | "postinstall": "npm run build; npm run bpf-sdk:update" 34 | }, 35 | "keywords": [], 36 | "author": "", 37 | "license": "MIT", 38 | "devDependencies": { 39 | "prettier": "^1.14.3" 40 | }, 41 | "dependencies": { 42 | "@solana/web3.js": "0.47.1", 43 | "babel-cli": "^6.26.0", 44 | "babel-core": "^6.26.3", 45 | "babel-eslint": "^10.0.1", 46 | "babel-loader": "^7.1.5", 47 | "babel-plugin-transform-class-properties": "^6.24.1", 48 | "babel-plugin-transform-function-bind": "^6.22.0", 49 | "babel-plugin-transform-runtime": "^6.23.0", 50 | "babel-preset-env": "^1.7.0", 51 | "babel-preset-flow": "^6.23.0", 52 | "babel-preset-react": "^6.24.1", 53 | "babel-preset-stage-2": "^6.24.1", 54 | "babel-runtime": "^6.26.0", 55 | "body-parser": "^1.18.3", 56 | "buffer-layout": "^1.2.0", 57 | "css-loader": "^3.1.0", 58 | "dotenv": "8.2.0", 59 | "eslint": "^6.1.0", 60 | "eslint-loader": "^3.0.0", 61 | "eslint-plugin-import": "^2.13.0", 62 | "eslint-plugin-react": "^7.11.1", 63 | "event-emitter": "^0.3.5", 64 | "express": "^4.16.4", 65 | "flow-bin": "0.119.1", 66 | "flow-typed": "^3.0.0", 67 | "http-server": "^0.12.0", 68 | "jayson": "^3.0.1", 69 | "json-to-pretty-yaml": "^1.2.2", 70 | "mkdirp-promise": "^5.0.1", 71 | "moment": "^2.22.2", 72 | "mz": "^2.7.0", 73 | "node-fetch": "^2.2.0", 74 | "react": "^16.5.2", 75 | "react-bootstrap": "^0.33.0", 76 | "react-dom": "^16.5.2", 77 | "readline-promise": "^1.0.3", 78 | "semver": "^7.0.0", 79 | "superstruct": "^0.8.0", 80 | "watch": "^1.0.2", 81 | "webpack": "^4.20.2", 82 | "webpack-cli": "^3.1.1", 83 | "webpack-dev-server": "^3.1.9" 84 | }, 85 | "engines": { 86 | "node": "11.x" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /program-bpf-c/.gitignore: -------------------------------------------------------------------------------- 1 | /out/ 2 | -------------------------------------------------------------------------------- /program-bpf-c/makefile: -------------------------------------------------------------------------------- 1 | OUT_DIR := ../dist/program 2 | include ../node_modules/@solana/web3.js/bpf-sdk/c/bpf.mk 3 | 4 | all: 5 | rm -f ../dist/config.json 6 | -------------------------------------------------------------------------------- /program-bpf-c/src/tictactoe/program_command.h: -------------------------------------------------------------------------------- 1 | /** 2 | * Transactions sent to the tic-tac-toe program contain commands that are 3 | * defined in this file: 4 | * - keys will vary by the specified Command 5 | * - instruction data is a Command enum (as a 32 bit value) followed by a CommandData union. 6 | */ 7 | #pragma once 8 | 9 | typedef enum { 10 | /* 11 | * Initialize a dashboard account 12 | * 13 | * key[0] - dashboard account 14 | * 15 | * CommandData: none 16 | */ 17 | Command_InitDashboard = 0, 18 | 19 | /* 20 | * Initialize a player account 21 | * 22 | * key[0] - dashboard account 23 | * key[1] - player account 24 | * 25 | * CommandData: none 26 | */ 27 | Command_InitPlayer, 28 | 29 | /* 30 | * Initialize a game account 31 | * 32 | * key[0] - game account 33 | * key[1] - dashboard account 34 | * key[2] - player X 35 | * 36 | * CommandData: none 37 | */ 38 | Command_InitGame, 39 | 40 | /* 41 | * Used by Player X to advertise their game 42 | * 43 | * key[0] - player X 44 | * key[1] - dashboard account 45 | * key[2] - game account 46 | * 47 | * CommandData: none 48 | */ 49 | Command_Advertise, 50 | 51 | /* 52 | * Player O wants to join 53 | * 54 | * key[0] - player O 55 | * key[1] - dashboard account 56 | * key[2] - game account 57 | * 58 | * CommandData: none 59 | */ 60 | Command_Join, 61 | 62 | /* 63 | * Player X/O keep alive 64 | * 65 | * key[0] - player X or O 66 | * key[1] - dashboard account 67 | * key[2] - game account 68 | * 69 | * CommandData: none 70 | */ 71 | Command_KeepAlive, 72 | 73 | /* 74 | * Player X/O mark board position (x, y) 75 | * 76 | * key[0] - player X or O 77 | * key[1] - dashboard account 78 | * key[2] - game account 79 | * 80 | * CommandData: move 81 | */ 82 | Command_Move, 83 | 84 | /* 85 | * Force the enum to be 64 bits 86 | */ 87 | Command_MakeEnum64Bits = 0xffffffffffffffff, 88 | 89 | } Command; 90 | 91 | typedef union { 92 | struct { 93 | uint8_t x; 94 | uint8_t y; 95 | } move; 96 | } CommandData; 97 | -------------------------------------------------------------------------------- /program-bpf-c/src/tictactoe/program_state.h: -------------------------------------------------------------------------------- 1 | /** 2 | * Tic-tac-toe account data contains a State enum (as a 32 bit value) followed by a StateData union 3 | */ 4 | 5 | #pragma once 6 | #include 7 | 8 | typedef enum { 9 | State_Uninitialized = 0, /* State is not initialized yet */ 10 | State_Dashboard, /* State holds dashboard state */ 11 | State_Game, /* State holds game state */ 12 | State_MakeEnum64Bits = 0xffffffffffffffff, 13 | } State; 14 | 15 | typedef enum { 16 | GameState_Waiting = 0, /* Player X is waiting for Player O to join */ 17 | GameState_XMove, 18 | GameState_OMove, 19 | GameState_XWon, 20 | GameState_OWon, 21 | GameState_Draw, 22 | GameState_MakeEnum64Bits = 0xffffffffffffffff, 23 | } GameState; 24 | 25 | typedef enum { 26 | BoardItem_Free = 0, 27 | BoardItem_X, 28 | BoardItem_O, 29 | } BoardItem; 30 | 31 | /** 32 | * Game state 33 | * 34 | * Board Coordinates 35 | * | 0,0 | 1,0 | 2,0 | 36 | * | 0,1 | 1,1 | 2,1 | 37 | * | 0,2 | 1,2 | 2,2 | 38 | */ 39 | typedef struct { 40 | uint64_t keep_alive[2]; /* Keep alive current_slot for each player (0 == x, 1 == y) */ 41 | uint8_t game_state; /* Current state of the game (GameState) */ 42 | SolPubkey player_x; /* Player who initialized the game */ 43 | SolPubkey player_o; /* Player who joined the game */ 44 | uint8_t board[9]; /* Tracks the player moves (BoardItem) */ 45 | } Game; 46 | 47 | 48 | /** 49 | * Dashboard state 50 | */ 51 | #define MAX_COMPLETED_GAMES 5 52 | typedef struct { 53 | uint64_t total_games; /* Total number of completed games */ 54 | SolPubkey pending_game; /* Latest pending game */ 55 | SolPubkey completed_games[MAX_COMPLETED_GAMES]; /* Last N completed games */ 56 | uint8_t latest_completed_game_index; /* Index of the latest completed game */ 57 | } Dashboard; 58 | 59 | 60 | typedef union { 61 | Game game; 62 | Dashboard dashboard; 63 | } StateData; 64 | 65 | 66 | SOL_FN_PREFIX bool state_deserialize(SolAccountInfo *ka, State **state, StateData **state_data) { 67 | if (ka->data_len < sizeof(uint32_t) + sizeof(StateData)) { 68 | sol_log("Error: invalid data_len"); 69 | sol_log_64(ka->data_len, sizeof(uint32_t) + sizeof(StateData), 0, 0, 0); 70 | return false; 71 | } 72 | *state = (uint64_t *) ka->data; 73 | *state_data = (StateData *) (ka->data + sizeof(uint64_t)); 74 | return true; 75 | } 76 | -------------------------------------------------------------------------------- /program-bpf-c/src/tictactoe/test_tictactoe.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include "tictactoe.c" 3 | 4 | static Game game = {}; 5 | static SolPubkey player_x = {.x = {1,}}; 6 | static SolPubkey player_o = {.x = {2,}}; 7 | 8 | void start_game(void) { 9 | cr_assert(!SolPubkey_same(&player_x, &player_o)); 10 | 11 | sol_memset(&game, 0, sizeof(game)); 12 | cr_assert_eq(game.game_state, GameState_Waiting); 13 | game_create(&game, &player_x, 0); 14 | cr_assert_eq(game.game_state, GameState_Waiting); 15 | game_join(&game, &player_o, 0); 16 | cr_assert_eq(game.game_state, GameState_XMove); 17 | } 18 | 19 | Test(game, column_1_x_wins, .init = start_game) { 20 | /* 21 | X|O| 22 | -+-+- 23 | X|O| 24 | -+-+- 25 | X| | 26 | */ 27 | 28 | cr_assert(game_move(&game, &player_x, 0, 0)); 29 | cr_assert_eq(game.game_state, GameState_OMove); 30 | cr_assert(game_move(&game, &player_o, 1, 0)); 31 | cr_assert_eq(game.game_state, GameState_XMove); 32 | cr_assert(game_move(&game, &player_x, 0, 1)); 33 | cr_assert_eq(game.game_state, GameState_OMove); 34 | cr_assert(game_move(&game, &player_o, 1, 1)); 35 | cr_assert_eq(game.game_state, GameState_XMove); 36 | cr_assert(game_move(&game, &player_x, 0, 2)); 37 | cr_assert_eq(game.game_state, GameState_XWon); 38 | } 39 | 40 | Test(game, right_diagonal_x_wins, .init = start_game) { 41 | /* 42 | X|O|X 43 | -+-+- 44 | O|X|O 45 | -+-+- 46 | X| | 47 | */ 48 | 49 | cr_assert(game_move(&game, &player_x, 0, 0)); 50 | cr_assert(game_move(&game, &player_o, 1, 0)); 51 | cr_assert(game_move(&game, &player_x, 2, 0)); 52 | cr_assert(game_move(&game, &player_o, 0, 1)); 53 | cr_assert(game_move(&game, &player_x, 1, 1)); 54 | cr_assert(game_move(&game, &player_o, 2, 1)); 55 | cr_assert(game_move(&game, &player_x, 0, 2)); 56 | cr_assert_eq(game.game_state, GameState_XWon); 57 | 58 | cr_assert(!game_move(&game, &player_o, 1, 2)); 59 | } 60 | 61 | Test(game, bottom_row_o_wins, .init = start_game) { 62 | /* 63 | X|X| 64 | -+-+- 65 | X| | 66 | -+-+- 67 | O|O|O 68 | */ 69 | 70 | cr_assert(game_move(&game, &player_x, 0, 0)); 71 | cr_assert(game_move(&game, &player_o, 0, 2)); 72 | cr_assert(game_move(&game, &player_x, 1, 0)); 73 | cr_assert(game_move(&game, &player_o, 1, 2)); 74 | cr_assert(game_move(&game, &player_x, 0, 1)); 75 | cr_assert(game_move(&game, &player_o, 2, 2)); 76 | cr_assert_eq(game.game_state, GameState_OWon); 77 | 78 | cr_assert(!game_move(&game, &player_x, 1, 2)); 79 | } 80 | 81 | Test(game, left_diagonal_x_wins, .init = start_game) { 82 | /* 83 | X|O|X 84 | -+-+- 85 | O|X|O 86 | -+-+- 87 | O|X|X 88 | */ 89 | 90 | cr_assert(game_move(&game, &player_x, 0, 0)); 91 | cr_assert(game_move(&game, &player_o, 1, 0)); 92 | cr_assert(game_move(&game, &player_x, 2, 0)); 93 | cr_assert(game_move(&game, &player_o, 0, 1)); 94 | cr_assert(game_move(&game, &player_x, 1, 1)); 95 | cr_assert(game_move(&game, &player_o, 2, 1)); 96 | cr_assert(game_move(&game, &player_x, 1, 2)); 97 | cr_assert(game_move(&game, &player_o, 0, 2)); 98 | cr_assert(game_move(&game, &player_x, 2, 2)); 99 | cr_assert_eq(game.game_state, GameState_XWon); 100 | } 101 | 102 | Test(game, draw, .init = start_game) { 103 | /* 104 | X|O|O 105 | -+-+- 106 | O|O|X 107 | -+-+- 108 | X|X|O 109 | */ 110 | 111 | cr_assert(game_move(&game, &player_x, 0, 0)); 112 | cr_assert(game_move(&game, &player_o, 1, 1)); 113 | cr_assert(game_move(&game, &player_x, 0, 2)); 114 | cr_assert(game_move(&game, &player_o, 0, 1)); 115 | cr_assert(game_move(&game, &player_x, 2, 1)); 116 | cr_assert(game_move(&game, &player_o, 1, 0)); 117 | cr_assert(game_move(&game, &player_x, 1, 2)); 118 | cr_assert(game_move(&game, &player_o, 2, 2)); 119 | cr_assert(game_move(&game, &player_x, 2, 0)); 120 | 121 | cr_assert_eq(game.game_state, GameState_Draw); 122 | } 123 | 124 | Test(game, solo_game) { 125 | /* 126 | X|O| 127 | -+-+- 128 | | | 129 | -+-+- 130 | | | 131 | */ 132 | 133 | sol_memset(&game, 0, sizeof(game)); 134 | cr_assert_eq(game.game_state, GameState_Waiting); 135 | game_create(&game, &player_x, 0); 136 | cr_assert_eq(game.game_state, GameState_Waiting); 137 | game_join(&game, &player_x, 0); 138 | 139 | cr_assert_eq(game.game_state, GameState_XMove); 140 | cr_assert(game_move(&game, &player_x, 0, 0)); 141 | cr_assert_eq(game.game_state, GameState_OMove); 142 | cr_assert(game_move(&game, &player_x, 1, 0)); 143 | cr_assert_eq(game.game_state, GameState_XMove); 144 | } 145 | -------------------------------------------------------------------------------- /program-bpf-c/src/tictactoe/tictactoe.c: -------------------------------------------------------------------------------- 1 | /** 2 | * @brief TicTacToe Dashboard C-based BPF program 3 | */ 4 | #include 5 | 6 | #include "program_command.h" 7 | #include "program_state.h" 8 | 9 | #define FAILURE 1 10 | 11 | SOL_FN_PREFIX void game_dump_board(Game *self) { 12 | sol_log_64(0x9, 0x9, 0x9, 0x9, 0x9); 13 | sol_log_64(0, 0, self->board[0], self->board[1], self->board[2]); 14 | sol_log_64(0, 0, self->board[3], self->board[4], self->board[5]); 15 | sol_log_64(0, 0, self->board[6], self->board[7], self->board[8]); 16 | sol_log_64(0x9, 0x9, 0x9, 0x9, 0x9); 17 | } 18 | 19 | SOL_FN_PREFIX void game_create(Game *self, SolPubkey *player_x, uint64_t current_slot) { 20 | // account memory is zero-initialized 21 | sol_memcpy(&self->player_x, player_x, sizeof(*player_x)); 22 | self->keep_alive[0] = current_slot; 23 | } 24 | 25 | SOL_FN_PREFIX void game_join( 26 | Game *self, 27 | SolPubkey *player_o, 28 | uint64_t current_slot 29 | ) { 30 | if (self->game_state != GameState_Waiting) { 31 | sol_log("Unable to join, game is not in the waiting state"); 32 | sol_log_64(self->game_state, 0, 0, 0, 0); 33 | } else { 34 | sol_memcpy(self->player_o.x, player_o, sizeof(*player_o)); 35 | self->game_state = GameState_XMove; 36 | 37 | sol_log("Game joined"); 38 | self->keep_alive[1] = current_slot; 39 | } 40 | } 41 | 42 | SOL_FN_PREFIX bool game_same( 43 | BoardItem x_or_o, 44 | BoardItem one, 45 | BoardItem two, 46 | BoardItem three 47 | ) { 48 | if (x_or_o == one && x_or_o == two && x_or_o == three) { 49 | return true; 50 | } 51 | return false; 52 | } 53 | 54 | SOL_FN_PREFIX bool game_same_player(SolPubkey *one, SolPubkey *two) { 55 | return sol_memcmp(one, two, sizeof(*one)) == 0; 56 | } 57 | 58 | SOL_FN_PREFIX bool game_move( 59 | Game *self, 60 | SolPubkey *player, 61 | int x, 62 | int y 63 | ) { 64 | int board_index = y * 3 + x; 65 | if (board_index >= 9 || self->board[board_index] != BoardItem_Free) { 66 | sol_log("Invalid move"); 67 | return false; 68 | } 69 | 70 | BoardItem x_or_o; 71 | GameState won_state; 72 | 73 | switch (self->game_state) { 74 | case GameState_XMove: 75 | if (!game_same_player(player, &self->player_x)) { 76 | sol_log("Invalid player for x move"); 77 | return false; 78 | } 79 | self->game_state = GameState_OMove; 80 | x_or_o = BoardItem_X; 81 | won_state = GameState_XWon; 82 | break; 83 | 84 | case GameState_OMove: 85 | if (!game_same_player(player, &self->player_o)) { 86 | sol_log("Invalid player for o move"); 87 | return false; 88 | } 89 | self->game_state = GameState_XMove; 90 | x_or_o = BoardItem_O; 91 | won_state = GameState_OWon; 92 | break; 93 | 94 | default: 95 | sol_log("Game is not in progress"); 96 | return false; 97 | } 98 | 99 | self->board[board_index] = x_or_o; 100 | 101 | // game_dump_board(self); 102 | 103 | bool winner = 104 | // Check rows 105 | game_same(x_or_o, self->board[0], self->board[1], self->board[2]) || 106 | game_same(x_or_o, self->board[3], self->board[4], self->board[5]) || 107 | game_same(x_or_o, self->board[6], self->board[7], self->board[8]) || 108 | // Check columns 109 | game_same(x_or_o, self->board[0], self->board[3], self->board[6]) || 110 | game_same(x_or_o, self->board[1], self->board[4], self->board[7]) || 111 | game_same(x_or_o, self->board[2], self->board[5], self->board[8]) || 112 | // Check both diagonals 113 | game_same(x_or_o, self->board[0], self->board[4], self->board[8]) || 114 | game_same(x_or_o, self->board[2], self->board[4], self->board[6]); 115 | 116 | if (winner) { 117 | self->game_state = won_state; 118 | } else { 119 | int draw = true; 120 | for (int i = 0; i < 9; i++) { 121 | if (BoardItem_Free == self->board[i]) { 122 | draw = false; 123 | break; 124 | } 125 | } 126 | if (draw) { 127 | self->game_state = GameState_Draw; 128 | } 129 | } 130 | return true; 131 | } 132 | 133 | SOL_FN_PREFIX bool game_keep_alive( 134 | Game *self, 135 | SolPubkey *player, 136 | uint64_t current_slot 137 | ) { 138 | switch (self->game_state) { 139 | case GameState_Waiting: 140 | case GameState_XMove: 141 | case GameState_OMove: 142 | if (game_same_player(player, &self->player_x)) { 143 | if (current_slot <= self->keep_alive[0]) { 144 | sol_log("Invalid player x keep_alive"); 145 | sol_log_64(current_slot, self->keep_alive[0], 0, 0, 0); 146 | return false; 147 | } 148 | sol_log("Player x keep_alive"); 149 | sol_log_64(current_slot, 0, 0, 0, 0); 150 | self->keep_alive[0] = current_slot; 151 | } else if (game_same_player(player, &self->player_o)) { 152 | if (current_slot <= self->keep_alive[1]) { 153 | sol_log("Invalid player o keep_alive"); 154 | sol_log_64(current_slot, self->keep_alive[1], 0, 0, 0); 155 | return false; 156 | } 157 | sol_log("Player o keep_alive"); 158 | sol_log_64(current_slot, 0, 0, 0, 0); 159 | self->keep_alive[1] = current_slot; 160 | } else { 161 | sol_log("Unknown player"); 162 | return false; 163 | } 164 | break; 165 | 166 | default: 167 | sol_log("Invalid game state"); 168 | return false; 169 | } 170 | return true; 171 | } 172 | 173 | SOL_FN_PREFIX void dashboard_update( 174 | Dashboard *self, 175 | SolPubkey const *game_pubkey, 176 | Game const *game, 177 | uint64_t current_slot 178 | ) { 179 | switch (game->game_state) { 180 | case GameState_Waiting: 181 | sol_memcpy(&self->pending_game, game_pubkey, sizeof(*game_pubkey)); 182 | break; 183 | case GameState_XMove: 184 | case GameState_OMove: 185 | // Nothing to do 186 | break; 187 | case GameState_XWon: 188 | case GameState_OWon: 189 | case GameState_Draw: 190 | for (int i = 0; i < MAX_COMPLETED_GAMES; i++) { 191 | if (SolPubkey_same(&self->completed_games[i], game_pubkey)) { 192 | sol_log("Ignoring known completed game"); 193 | return; 194 | } 195 | } 196 | sol_log("Adding new completed game"); 197 | 198 | // NOTE: current_slot could be used here to ensure that old games are not 199 | // being re-added and causing total to increment incorrectly. 200 | self->total_games += 1; 201 | self->latest_completed_game_index = 202 | (self->latest_completed_game_index + 1) % MAX_COMPLETED_GAMES; 203 | sol_memcpy( 204 | &self->completed_games[self->latest_completed_game_index], 205 | game_pubkey, 206 | sizeof(*game_pubkey) 207 | ); 208 | break; 209 | 210 | default: 211 | break; 212 | } 213 | } 214 | 215 | SOL_FN_PREFIX uint32_t fund_to_cover_rent(SolAccountInfo *dashboard_ka, SolAccountInfo *ka) { 216 | #define LOW_LAMPORT_WATERMARK 300 217 | if (*dashboard_ka->lamports <= 1) { 218 | sol_log("Dashboard is out of lamports"); 219 | return FAILURE; 220 | } else if (*ka->lamports < LOW_LAMPORT_WATERMARK) { 221 | // Fund the player or game account with enough lamports to pay for rent 222 | int to_fund = LOW_LAMPORT_WATERMARK - *(ka->lamports); 223 | *(ka->lamports) += to_fund; 224 | *(dashboard_ka->lamports) -= to_fund; 225 | } 226 | return SUCCESS; 227 | } 228 | 229 | extern uint64_t entrypoint(const uint8_t *input) { 230 | SolAccountInfo ka[4]; 231 | SolParameters params = (SolParameters) { .ka = ka }; 232 | 233 | sol_log("tic-tac-toe C program entrypoint"); 234 | 235 | if (!sol_deserialize(input, ¶ms, SOL_ARRAY_SIZE(ka))) { 236 | sol_log("Error: deserialize failed"); 237 | return FAILURE; 238 | } 239 | 240 | if (!params.ka[0].is_signer) { 241 | sol_log("Transaction not signed by key 0"); 242 | return FAILURE; 243 | } 244 | 245 | if (params.data_len < sizeof(uint32_t) + sizeof(CommandData)) { 246 | sol_log("Error: invalid instruction_data_len"); 247 | sol_log_64(params.data_len, sizeof(uint32_t) + sizeof(CommandData), 0, 0, 0); 248 | return FAILURE; 249 | } 250 | Command const cmd = *(uint32_t *) params.data; 251 | CommandData const *cmd_data = (CommandData *) (params.data + sizeof(uint32_t)); 252 | sol_log_64(cmd, 0, 0, 0, 0); 253 | 254 | State *dashboard_state = NULL; 255 | StateData *dashboard_state_data = NULL; 256 | 257 | if (cmd == Command_InitDashboard) { 258 | sol_log("Command_InitDashboard"); 259 | if (params.ka_num != 1) { 260 | sol_log("Error: one key expected"); 261 | return FAILURE; 262 | } 263 | 264 | if (!state_deserialize(¶ms.ka[0], &dashboard_state, &dashboard_state_data)) { 265 | return FAILURE; 266 | } 267 | 268 | if (*dashboard_state != State_Uninitialized) { 269 | sol_log("Dashboard is already uninitialized"); 270 | return FAILURE; 271 | } 272 | 273 | *dashboard_state = State_Dashboard; 274 | return SUCCESS; 275 | } 276 | 277 | if (cmd == Command_InitPlayer) { 278 | sol_log("Command_InitPlayer"); 279 | if (params.ka_num != 2) { 280 | sol_log("Error: two keys expected"); 281 | return FAILURE; 282 | } 283 | 284 | if (!state_deserialize(¶ms.ka[0], &dashboard_state, &dashboard_state_data)) { 285 | return FAILURE; 286 | } 287 | 288 | if (*dashboard_state != State_Dashboard) { 289 | sol_log("Invalid dashboard account"); 290 | return FAILURE; 291 | } 292 | 293 | if (!SolPubkey_same(params.ka[0].owner, params.ka[1].owner) || params.ka[1].data_len != 0) { 294 | sol_log("Invalid player account"); 295 | return FAILURE; 296 | } 297 | // Distribute funds to the player for their next transaction 298 | return fund_to_cover_rent(¶ms.ka[0], ¶ms.ka[1]); 299 | } 300 | if (params.ka_num != 4) { 301 | sol_log("Error: three keys expected"); 302 | return FAILURE; 303 | } 304 | 305 | if (!state_deserialize(¶ms.ka[1], &dashboard_state, &dashboard_state_data)) { 306 | sol_log("dashboard deserialize failed"); 307 | return FAILURE; 308 | } 309 | 310 | if (*dashboard_state != State_Dashboard) { 311 | sol_log("Invalid dashboard account"); 312 | return FAILURE; 313 | } 314 | 315 | State *game_state = NULL; 316 | StateData *game_state_data = NULL; 317 | 318 | if (cmd == Command_InitGame) { 319 | sol_log("Command_InitGame"); 320 | if (!state_deserialize(&ka[0], &game_state, &game_state_data)) { 321 | return FAILURE; 322 | } 323 | 324 | uint64_t current_slot = *(uint64_t*)params.ka[3].data; 325 | 326 | if (*game_state != State_Uninitialized) { 327 | sol_log("Account is already uninitialized"); 328 | return FAILURE; 329 | } 330 | 331 | if (!SolPubkey_same(ka[0].owner, params.ka[2].owner) ||params.ka[2].data_len != 0) { 332 | sol_log("Invalid player account"); 333 | return FAILURE; 334 | } 335 | 336 | *game_state = State_Game; 337 | SolPubkey *player_x = params.ka[2].key; 338 | game_create(&game_state_data->game, player_x, current_slot); 339 | 340 | dashboard_update( 341 | &dashboard_state_data->dashboard, 342 | params.ka[0].key, 343 | &game_state_data->game, 344 | current_slot 345 | ); 346 | 347 | // Distribute funds to the player for their next transaction, and to the 348 | // game account to keep it's state loaded 349 | return fund_to_cover_rent(¶ms.ka[1], ¶ms.ka[0]) && fund_to_cover_rent(&ka[1], ¶ms.ka[2]); 350 | } 351 | 352 | if (!state_deserialize(¶ms.ka[2], &game_state, &game_state_data)) { 353 | sol_log("game deserialize failed"); 354 | return FAILURE; 355 | } 356 | 357 | uint64_t current_slot = *(uint64_t*)params.ka[3].data; 358 | 359 | if (*game_state != State_Game) { 360 | sol_log("Invalid game account"); 361 | return FAILURE; 362 | } 363 | 364 | if (!SolPubkey_same(params.ka[0].owner, params.ka[1].owner) || params.ka[0].data_len != 0) { 365 | sol_log("Invalid player account"); 366 | return FAILURE; 367 | } 368 | 369 | SolPubkey *player = params.ka[0].key; 370 | switch (cmd) { 371 | case Command_Advertise: 372 | sol_log("Command_Advertise"); 373 | // Nothing to do here beyond the dashboard_update() below 374 | break; 375 | case Command_Join: 376 | sol_log("Command_Join"); 377 | game_join(&game_state_data->game, player, current_slot); 378 | break; 379 | case Command_Move: 380 | sol_log("Command_Move"); 381 | sol_log_64(cmd_data->move.x, cmd_data->move.y, 0, 0, 0); 382 | if (!game_move(&game_state_data->game, player, cmd_data->move.x, cmd_data->move.y)) { 383 | return FAILURE; 384 | } 385 | break; 386 | case Command_KeepAlive: 387 | sol_log("Command_KeepAlive"); 388 | if (!game_keep_alive(&game_state_data->game, player, current_slot)) { 389 | return FAILURE; 390 | } 391 | break; 392 | default: 393 | sol_log("Error: Invalid command"); 394 | return FAILURE; 395 | } 396 | 397 | dashboard_update( 398 | &dashboard_state_data->dashboard, 399 | params.ka[2].key, 400 | &game_state_data->game, 401 | current_slot 402 | ); 403 | 404 | // Distribute funds to the player for their next transaction 405 | return fund_to_cover_rent(¶ms.ka[1], ¶ms.ka[0]); 406 | } 407 | -------------------------------------------------------------------------------- /program-bpf-rust/.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | 3 | Cargo.lock 4 | -------------------------------------------------------------------------------- /program-bpf-rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | # Note: This crate must be built using build.sh 3 | 4 | [package] 5 | name = "tictactoe" 6 | version = "0.1.0" 7 | description = "TicTacToe program written in Rust" 8 | authors = ["Solana Maintainers "] 9 | repository = "https://github.com/solana-labs/solana" 10 | license = "Apache-2.0" 11 | homepage = "https://solana.com/" 12 | edition = "2018" 13 | 14 | [dependencies] 15 | num-derive = "0.2" 16 | num-traits = "0.2" 17 | serde = { version = "1.0.100", default-features = false, features = ["alloc", "derive"] } 18 | serde_derive = "1.0" 19 | solana-sdk = { version="=1.1.1", default-features = false } 20 | thiserror = "1.0" 21 | 22 | [dev_dependencies] 23 | solana-sdk-bpf-test = { path = "../node_modules/@solana/web3.js/bpf-sdk/rust/test"} 24 | 25 | [features] 26 | program = ["solana-sdk/program"] 27 | default = ["program"] 28 | 29 | [workspace] 30 | members = [] 31 | 32 | [lib] 33 | name = "tictactoe" 34 | crate-type = ["cdylib"] 35 | -------------------------------------------------------------------------------- /program-bpf-rust/README.md: -------------------------------------------------------------------------------- 1 | 2 | ### Building 3 | 4 | This project cannot be built diretly via cargo and instead requires the build scripts located in Solana's BPF-SDK. 5 | 6 | To build via NPM, from the repo's root directory: 7 | 8 | `npm run build:bpf-rust` 9 | 10 | You can also refer to the `build:bpf-rust` script in `package.json` as an example of how to call the build scripts directly 11 | 12 | ### Testing 13 | 14 | Unit tests contained within this project can be built via: 15 | 16 | `cargo +nightly test` 17 | 18 | ### Clippy 19 | 20 | Clippy is also supported via: 21 | 22 | `cargo +nightly clippy` 23 | -------------------------------------------------------------------------------- /program-bpf-rust/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] -------------------------------------------------------------------------------- /program-bpf-rust/do.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | usage() { 6 | cat <"${dump}-mangled.txt" 74 | greadelf \ 75 | -aW \ 76 | "$so" \ 77 | >>"${dump}-mangled.txt" 78 | "$sdkDir/dependencies/llvm-native/bin/llvm-objdump" \ 79 | -print-imm-hex \ 80 | --source \ 81 | --disassemble \ 82 | "$so" \ 83 | >>"${dump}-mangled.txt" 84 | sed \ 85 | s/://g \ 86 | < "${dump}-mangled.txt" \ 87 | | rustfilt \ 88 | > "${dump}.txt" 89 | else 90 | echo "Warning: No dump created, cannot find: $so" 91 | fi 92 | ) 93 | ;; 94 | help) 95 | usage 96 | exit 97 | ;; 98 | *) 99 | echo "Error: Unknown command" 100 | usage 101 | exit 102 | ;; 103 | esac 104 | } 105 | 106 | set -e 107 | 108 | perform_action "$@" 109 | -------------------------------------------------------------------------------- /program-bpf-rust/src/dashboard.rs: -------------------------------------------------------------------------------- 1 | use crate::game::{Game, GameState}; 2 | use solana_sdk::{entrypoint::ProgramResult, pubkey::Pubkey}; 3 | 4 | const MAX_COMPLETED_GAMES: usize = 5; 5 | 6 | #[repr(C)] 7 | #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] 8 | pub struct Dashboard { 9 | /// Total number of completed games 10 | total_games: u64, 11 | /// Latest pending game 12 | pending_game: Pubkey, 13 | /// Last N completed games 14 | completed_games: [Pubkey; MAX_COMPLETED_GAMES], 15 | /// Index of the latest completed game 16 | latest_completed_game_index: u8, 17 | } 18 | 19 | impl Dashboard { 20 | pub fn update(self: &mut Dashboard, game_pubkey: &Pubkey, game: &Game) -> ProgramResult { 21 | match game.game_state { 22 | GameState::Waiting => { 23 | self.pending_game = *game_pubkey; 24 | } 25 | GameState::XMove | GameState::OMove => { 26 | // Nothing to do. In progress games are not managed by the dashboard 27 | } 28 | GameState::XWon | GameState::OWon | GameState::Draw => { 29 | if !self 30 | .completed_games 31 | .iter() 32 | .any(|pubkey| pubkey == game_pubkey) 33 | { 34 | self.total_games += 1; 35 | self.latest_completed_game_index = 36 | (self.latest_completed_game_index + 1) % MAX_COMPLETED_GAMES as u8; 37 | self.completed_games[self.latest_completed_game_index as usize] = *game_pubkey; 38 | } 39 | } 40 | } 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /program-bpf-rust/src/error.rs: -------------------------------------------------------------------------------- 1 | use num_derive::FromPrimitive; 2 | use num_traits::FromPrimitive; 3 | use solana_sdk::{ 4 | info, 5 | program_error::{PrintProgramError, ProgramError}, 6 | program_utils::DecodeError, 7 | }; 8 | use thiserror::Error; 9 | 10 | #[derive(Clone, Debug, Eq, Error, FromPrimitive, PartialEq)] 11 | pub enum TicTacToeError { 12 | #[error("deserialization failed")] 13 | DeserializationFailed, 14 | #[error("game in progress")] 15 | GameInProgress, 16 | #[error("invalid move")] 17 | InvalidMove, 18 | #[error("invaid timestamp")] 19 | InvalidTimestamp, 20 | #[error("not your turn")] 21 | NotYourTurn, 22 | #[error("player not found")] 23 | PlayerNotFound, 24 | } 25 | 26 | impl From for ProgramError { 27 | fn from(e: TicTacToeError) -> Self { 28 | ProgramError::CustomError(e as u32) 29 | } 30 | } 31 | 32 | impl DecodeError for TicTacToeError { 33 | fn type_of() -> &'static str { 34 | "TicTacToeError" 35 | } 36 | } 37 | 38 | impl PrintProgramError for TicTacToeError { 39 | fn print(&self) 40 | where 41 | E: 'static + std::error::Error + DecodeError + PrintProgramError + FromPrimitive, 42 | { 43 | match self { 44 | TicTacToeError::DeserializationFailed => info!("Error: deserialization failed"), 45 | TicTacToeError::GameInProgress => info!("Error: game in progress"), 46 | TicTacToeError::InvalidMove => info!("Error: invalid move"), 47 | TicTacToeError::InvalidTimestamp => info!("Error: invalid timestamp"), 48 | TicTacToeError::NotYourTurn => info!("Error: not your turn"), 49 | TicTacToeError::PlayerNotFound => info!("Error: player not found"), 50 | } 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod test { 56 | use super::*; 57 | 58 | fn return_tittactoe_error_as_program_error() -> ProgramError { 59 | TicTacToeError::PlayerNotFound.into() 60 | } 61 | 62 | #[test] 63 | fn test_print_error() { 64 | let error = return_tittactoe_error_as_program_error(); 65 | error.print::(); 66 | } 67 | 68 | #[test] 69 | #[should_panic(expected = "CustomError(5)")] 70 | fn test_error_unwrap() { 71 | Err::<(), ProgramError>(return_tittactoe_error_as_program_error()).unwrap(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /program-bpf-rust/src/game.rs: -------------------------------------------------------------------------------- 1 | use crate::error::TicTacToeError; 2 | use solana_sdk::{entrypoint::ProgramResult, info, pubkey::Pubkey}; 3 | 4 | const BOARD_ITEM_FREE: u8 = 0; // Free slot 5 | const BOARD_ITEM_X: u8 = 1; // Player X 6 | const BOARD_ITEM_O: u8 = 2; // Player O 7 | 8 | #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] 9 | pub enum GameState { 10 | Waiting, 11 | XMove, 12 | OMove, 13 | XWon, 14 | OWon, 15 | Draw, 16 | } 17 | impl Default for GameState { 18 | fn default() -> GameState { 19 | GameState::Waiting 20 | } 21 | } 22 | 23 | #[repr(C)] 24 | #[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)] 25 | pub struct Game { 26 | /// Keep alive timestamp for each player 27 | keep_alive: [u64; 2], 28 | /// Current state of the game 29 | pub game_state: GameState, 30 | /// Player who initialized the game 31 | player_x: Pubkey, 32 | /// Player who joined the game 33 | player_o: Pubkey, 34 | /// Tracks the player moves (BOARD_ITEM_xyz) 35 | board: [u8; 9], 36 | } 37 | 38 | impl Game { 39 | pub fn create(player_x: &Pubkey) -> Game { 40 | let mut game = Game::default(); 41 | game.player_x = *player_x; 42 | assert_eq!(game.game_state, GameState::Waiting); 43 | game 44 | } 45 | 46 | #[cfg(test)] 47 | pub fn new(player_x: Pubkey, player_o: Pubkey) -> Game { 48 | let mut game = Game::create(&player_x); 49 | game.join(player_o, 1).unwrap(); 50 | game 51 | } 52 | 53 | pub fn join(self: &mut Game, player_o: Pubkey, timestamp: u64) -> ProgramResult { 54 | if self.game_state == GameState::Waiting { 55 | self.player_o = player_o; 56 | self.game_state = GameState::XMove; 57 | 58 | if timestamp <= self.keep_alive[1] { 59 | Err(TicTacToeError::InvalidTimestamp.into()) 60 | } else { 61 | self.keep_alive[1] = timestamp; 62 | Ok(()) 63 | } 64 | } else { 65 | Err(TicTacToeError::GameInProgress.into()) 66 | } 67 | } 68 | 69 | fn same(x_or_o: u8, triple: &[u8]) -> bool { 70 | triple.iter().all(|&i| i == x_or_o) 71 | } 72 | 73 | pub fn next_move(self: &mut Game, player: Pubkey, x: usize, y: usize) -> ProgramResult { 74 | let board_index = y * 3 + x; 75 | if board_index >= self.board.len() || self.board[board_index] != BOARD_ITEM_FREE { 76 | return Err(TicTacToeError::InvalidMove.into()); 77 | } 78 | 79 | let (x_or_o, won_state) = match self.game_state { 80 | GameState::XMove => { 81 | if player != self.player_x { 82 | return Err(TicTacToeError::PlayerNotFound.into()); 83 | } 84 | self.game_state = GameState::OMove; 85 | (BOARD_ITEM_X, GameState::XWon) 86 | } 87 | GameState::OMove => { 88 | if player != self.player_o { 89 | return Err(TicTacToeError::PlayerNotFound.into()); 90 | } 91 | self.game_state = GameState::XMove; 92 | (BOARD_ITEM_O, GameState::OWon) 93 | } 94 | _ => { 95 | return Err(TicTacToeError::NotYourTurn.into()); 96 | } 97 | }; 98 | self.board[board_index] = x_or_o; 99 | 100 | let winner = 101 | // Check rows 102 | Game::same(x_or_o, &self.board[0..3]) 103 | || Game::same(x_or_o, &self.board[3..6]) 104 | || Game::same(x_or_o, &self.board[6..9]) 105 | // Check columns 106 | || Game::same(x_or_o, &[self.board[0], self.board[3], self.board[6]]) 107 | || Game::same(x_or_o, &[self.board[1], self.board[4], self.board[7]]) 108 | || Game::same(x_or_o, &[self.board[2], self.board[5], self.board[8]]) 109 | // Check both diagonals 110 | || Game::same(x_or_o, &[self.board[0], self.board[4], self.board[8]]) 111 | || Game::same(x_or_o, &[self.board[2], self.board[4], self.board[6]]); 112 | 113 | if winner { 114 | self.game_state = won_state; 115 | } else if self.board.iter().all(|&p| p != BOARD_ITEM_FREE) { 116 | self.game_state = GameState::Draw; 117 | } 118 | 119 | Ok(()) 120 | } 121 | 122 | pub fn keep_alive(self: &mut Game, player: Pubkey, timestamp: u64) -> ProgramResult { 123 | match self.game_state { 124 | GameState::Waiting | GameState::XMove | GameState::OMove => { 125 | if player == self.player_x { 126 | info!("Player x keep_alive"); 127 | info!(timestamp, 0, 0, 0, 0); 128 | if timestamp <= self.keep_alive[0] { 129 | return Err(TicTacToeError::InvalidTimestamp.into()); 130 | } 131 | self.keep_alive[0] = timestamp; 132 | } else if player == self.player_o { 133 | info!("Player o keep_alive"); 134 | info!(timestamp, 0, 0, 0, 0); 135 | if timestamp <= self.keep_alive[1] { 136 | return Err(TicTacToeError::InvalidTimestamp.into()); 137 | } 138 | self.keep_alive[1] = timestamp; 139 | } else { 140 | return Err(TicTacToeError::PlayerNotFound.into()); 141 | } 142 | } 143 | // Ignore keep_alive when game is no longer in progress 144 | GameState::XWon | GameState::OWon | GameState::Draw => {} 145 | }; 146 | Ok(()) 147 | } 148 | } 149 | 150 | #[cfg(test)] 151 | mod test { 152 | use super::*; 153 | 154 | #[no_mangle] 155 | pub fn sol_log_(message: *const u8, length: u64) { 156 | std::println!("sol_log_"); 157 | let slice = unsafe { std::slice::from_raw_parts(message, length as usize) }; 158 | let string = std::str::from_utf8(&slice).unwrap(); 159 | std::println!("{}", string); 160 | } 161 | 162 | #[test] 163 | pub fn column_1_x_wins() { 164 | /* 165 | X|O| 166 | -+-+- 167 | X|O| 168 | -+-+- 169 | X| | 170 | */ 171 | 172 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 173 | let player_o: Pubkey = Pubkey::new(&[1; 32]); 174 | 175 | let mut g = Game::new(player_x, player_o); 176 | assert_eq!(g.game_state, GameState::XMove); 177 | 178 | g.next_move(player_x, 0, 0).unwrap(); 179 | assert_eq!(g.game_state, GameState::OMove); 180 | g.next_move(player_o, 1, 0).unwrap(); 181 | assert_eq!(g.game_state, GameState::XMove); 182 | g.next_move(player_x, 0, 1).unwrap(); 183 | assert_eq!(g.game_state, GameState::OMove); 184 | g.next_move(player_o, 1, 1).unwrap(); 185 | assert_eq!(g.game_state, GameState::XMove); 186 | g.next_move(player_x, 0, 2).unwrap(); 187 | assert_eq!(g.game_state, GameState::XWon); 188 | } 189 | 190 | #[test] 191 | pub fn right_diagonal_x_wins() { 192 | /* 193 | X|O|X 194 | -+-+- 195 | O|X|O 196 | -+-+- 197 | X| | 198 | */ 199 | 200 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 201 | let player_o: Pubkey = Pubkey::new(&[1; 32]); 202 | let mut g = Game::new(player_x, player_o); 203 | 204 | g.next_move(player_x, 0, 0).unwrap(); 205 | g.next_move(player_o, 1, 0).unwrap(); 206 | g.next_move(player_x, 2, 0).unwrap(); 207 | g.next_move(player_o, 0, 1).unwrap(); 208 | g.next_move(player_x, 1, 1).unwrap(); 209 | g.next_move(player_o, 2, 1).unwrap(); 210 | g.next_move(player_x, 0, 2).unwrap(); 211 | assert_eq!(g.game_state, GameState::XWon); 212 | 213 | assert!(g.next_move(player_o, 1, 2).is_err()); 214 | } 215 | 216 | #[test] 217 | pub fn bottom_row_o_wins() { 218 | /* 219 | X|X| 220 | -+-+- 221 | X| | 222 | -+-+- 223 | O|O|O 224 | */ 225 | 226 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 227 | let player_o: Pubkey = Pubkey::new(&[1; 32]); 228 | let mut g = Game::new(player_x, player_o); 229 | 230 | g.next_move(player_x, 0, 0).unwrap(); 231 | g.next_move(player_o, 0, 2).unwrap(); 232 | g.next_move(player_x, 1, 0).unwrap(); 233 | g.next_move(player_o, 1, 2).unwrap(); 234 | g.next_move(player_x, 0, 1).unwrap(); 235 | g.next_move(player_o, 2, 2).unwrap(); 236 | assert_eq!(g.game_state, GameState::OWon); 237 | 238 | assert!(g.next_move(player_x, 1, 2).is_err()); 239 | } 240 | 241 | #[test] 242 | pub fn left_diagonal_x_wins() { 243 | /* 244 | X|O|X 245 | -+-+- 246 | O|X|O 247 | -+-+- 248 | O|X|X 249 | */ 250 | 251 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 252 | let player_o: Pubkey = Pubkey::new(&[1; 32]); 253 | let mut g = Game::new(player_x, player_o); 254 | 255 | g.next_move(player_x, 0, 0).unwrap(); 256 | g.next_move(player_o, 1, 0).unwrap(); 257 | g.next_move(player_x, 2, 0).unwrap(); 258 | g.next_move(player_o, 0, 1).unwrap(); 259 | g.next_move(player_x, 1, 1).unwrap(); 260 | g.next_move(player_o, 2, 1).unwrap(); 261 | g.next_move(player_x, 1, 2).unwrap(); 262 | g.next_move(player_o, 0, 2).unwrap(); 263 | g.next_move(player_x, 2, 2).unwrap(); 264 | assert_eq!(g.game_state, GameState::XWon); 265 | } 266 | 267 | #[test] 268 | pub fn draw() { 269 | /* 270 | X|O|O 271 | -+-+- 272 | O|O|X 273 | -+-+- 274 | X|X|O 275 | */ 276 | 277 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 278 | let player_o: Pubkey = Pubkey::new(&[1; 32]); 279 | let mut g = Game::new(player_x, player_o); 280 | 281 | g.next_move(player_x, 0, 0).unwrap(); 282 | g.next_move(player_o, 1, 1).unwrap(); 283 | g.next_move(player_x, 0, 2).unwrap(); 284 | g.next_move(player_o, 0, 1).unwrap(); 285 | g.next_move(player_x, 2, 1).unwrap(); 286 | g.next_move(player_o, 1, 0).unwrap(); 287 | g.next_move(player_x, 1, 2).unwrap(); 288 | g.next_move(player_o, 2, 2).unwrap(); 289 | g.next_move(player_x, 2, 0).unwrap(); 290 | 291 | assert_eq!(g.game_state, GameState::Draw); 292 | } 293 | 294 | #[test] 295 | pub fn solo() { 296 | /* 297 | X|O| 298 | -+-+- 299 | | | 300 | -+-+- 301 | | | 302 | */ 303 | 304 | let player_x: Pubkey = Pubkey::new(&[1; 32]); 305 | 306 | let mut g = Game::new(player_x, player_x); 307 | assert_eq!(g.game_state, GameState::XMove); 308 | g.next_move(player_x, 0, 0).unwrap(); 309 | assert_eq!(g.game_state, GameState::OMove); 310 | g.next_move(player_x, 1, 0).unwrap(); 311 | assert_eq!(g.game_state, GameState::XMove); 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /program-bpf-rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate serde; 2 | #[macro_use] 3 | extern crate serde_derive; 4 | extern crate solana_sdk; 5 | 6 | mod dashboard; 7 | mod error; 8 | mod game; 9 | mod program_command; 10 | mod program_state; 11 | mod simple_serde; 12 | 13 | use crate::error::TicTacToeError; 14 | use program_command::Command; 15 | use program_state::State; 16 | use simple_serde::SimpleSerde; 17 | use solana_sdk::{ 18 | account_info::AccountInfo, 19 | entrypoint, 20 | entrypoint::ProgramResult, 21 | info, 22 | program_error::{PrintProgramError, ProgramError}, 23 | program_utils::next_account_info, 24 | pubkey::Pubkey, 25 | sysvar::{clock::Clock, Sysvar}, 26 | }; 27 | 28 | fn fund_to_cover_rent( 29 | dashboard_account: &AccountInfo, 30 | account_to_fund: &AccountInfo, 31 | ) -> ProgramResult { 32 | static LOW_LAMPORT_WATERMARK: u64 = 300; 33 | if dashboard_account.lamports() <= 1 { 34 | info!("Dashboard is out of lamports"); 35 | return Err(ProgramError::InvalidArgument); 36 | } 37 | if account_to_fund.lamports() < LOW_LAMPORT_WATERMARK { 38 | info!("Fund account"); 39 | info!( 40 | 0, 41 | 0, 42 | 0, 43 | account_to_fund.lamports(), 44 | dashboard_account.lamports() 45 | ); 46 | // Fund the player or game account with enough lamports to pay for rent 47 | let to_fund = LOW_LAMPORT_WATERMARK - account_to_fund.lamports(); 48 | **account_to_fund.lamports.borrow_mut() += to_fund; 49 | **dashboard_account.lamports.borrow_mut() -= to_fund; 50 | } 51 | Ok(()) 52 | } 53 | 54 | fn process_instruction( 55 | _program_id: &Pubkey, 56 | accounts: &[AccountInfo], 57 | instruction_data: &[u8], 58 | ) -> ProgramResult { 59 | info!("tic-tac-toe Rust program entrypoint"); 60 | 61 | if !accounts[0].is_signer { 62 | info!("Account 0 did not sign the transaction"); 63 | return Err(ProgramError::MissingRequiredSignature); 64 | } 65 | 66 | let command = Command::deserialize(instruction_data)?; 67 | let account_info_iter = &mut accounts.iter(); 68 | 69 | if command == Command::InitDashboard { 70 | info!("init dashboard"); 71 | let dashboard_account = next_account_info(account_info_iter)?; 72 | 73 | let mut dashboard_state = State::deserialize(&dashboard_account.data.borrow())?; 74 | match dashboard_state { 75 | State::Uninitialized => dashboard_state = State::Dashboard(Default::default()), 76 | _ => { 77 | info!("Invalid dashboard state for InitDashboard"); 78 | return Err(ProgramError::InvalidArgument); 79 | } 80 | }; 81 | 82 | dashboard_state.serialize(&mut dashboard_account.data.borrow_mut())?; 83 | return Ok(()); 84 | } 85 | 86 | if command == Command::InitPlayer { 87 | info!("init player"); 88 | let dashboard_account = next_account_info(account_info_iter)?; 89 | let player_account = next_account_info(account_info_iter)?; 90 | match State::deserialize(&dashboard_account.data.borrow())? { 91 | State::Dashboard(_) => (), 92 | _ => { 93 | info!("Invalid dashboard state"); 94 | return Err(ProgramError::InvalidArgument); 95 | } 96 | }; 97 | 98 | if dashboard_account.owner != player_account.owner || !player_account.data_is_empty() { 99 | info!("Invalid player account"); 100 | return Err(ProgramError::InvalidArgument); 101 | } 102 | 103 | return fund_to_cover_rent(dashboard_account, player_account); 104 | } 105 | 106 | let first_account = next_account_info(account_info_iter)?; 107 | let dashboard_account = next_account_info(account_info_iter)?; 108 | let mut dashboard_state = State::deserialize(&dashboard_account.data.borrow())?; 109 | match dashboard_state { 110 | State::Dashboard(_) => Ok(()), 111 | _ => { 112 | info!("Invalid dashboard state"); 113 | Err(ProgramError::InvalidArgument) 114 | } 115 | }?; 116 | 117 | if command == Command::InitGame { 118 | info!("init game"); 119 | let game_account = first_account; 120 | let player_account = next_account_info(account_info_iter)?; 121 | 122 | if game_account.owner != dashboard_account.owner { 123 | info!("Invalid game account"); 124 | return Err(ProgramError::InvalidArgument); 125 | } 126 | if game_account.owner != player_account.owner || !player_account.data_is_empty() { 127 | info!("Invalid player account"); 128 | return Err(ProgramError::InvalidArgument); 129 | } 130 | 131 | let mut game_state = State::deserialize(&game_account.data.borrow())?; 132 | match game_state { 133 | State::Uninitialized => { 134 | let game = game::Game::create(&player_account.key); 135 | match dashboard_state { 136 | State::Dashboard(ref mut dashboard) => { 137 | dashboard.update(&game_account.key, &game)? 138 | } 139 | _ => { 140 | info!("Invalid dashboard state"); 141 | return Err(ProgramError::InvalidArgument); 142 | } 143 | } 144 | game_state = State::Game(game); 145 | } 146 | _ => { 147 | info!("Invalid game state"); 148 | return Err(ProgramError::InvalidArgument); 149 | } 150 | } 151 | 152 | dashboard_state.serialize(&mut dashboard_account.data.borrow_mut())?; 153 | game_state.serialize(&mut game_account.data.borrow_mut())?; 154 | fund_to_cover_rent(dashboard_account, game_account)?; 155 | return fund_to_cover_rent(dashboard_account, player_account); 156 | } 157 | 158 | let player_account = first_account; 159 | let game_account = next_account_info(account_info_iter)?; 160 | let sysvar_account = next_account_info(account_info_iter)?; 161 | 162 | if player_account.owner != dashboard_account.owner || !player_account.data_is_empty() { 163 | info!("Invalid player account"); 164 | return Err(ProgramError::InvalidArgument); 165 | } 166 | if dashboard_account.owner != game_account.owner { 167 | info!("Invalid game account"); 168 | return Err(ProgramError::InvalidArgument); 169 | } 170 | 171 | let mut game_state = State::deserialize(&game_account.data.borrow())?; 172 | match game_state { 173 | State::Game(ref mut game) => { 174 | let player = player_account.key; 175 | let current_slot = Clock::from_account_info(sysvar_account)?.slot; 176 | 177 | match command { 178 | Command::Advertise => { 179 | // Nothing to do here beyond the dashboard_update() below 180 | info!("advertise game") 181 | } 182 | Command::Join => { 183 | info!("join game"); 184 | game.join(*player, current_slot)? 185 | } 186 | Command::Move(x, y) => { 187 | info!("move"); 188 | game.next_move(*player, x as usize, y as usize)? 189 | } 190 | Command::KeepAlive => { 191 | info!("keep alive"); 192 | game.keep_alive(*player, current_slot)? 193 | } 194 | _ => { 195 | info!("invalid command for State::Game"); 196 | return Err(ProgramError::InvalidArgument); 197 | } 198 | } 199 | 200 | match dashboard_state { 201 | State::Dashboard(ref mut dashboard) => { 202 | dashboard.update(&game_account.key, &game)? 203 | } 204 | _ => { 205 | info!("Invalid dashboard state"); 206 | return Err(ProgramError::InvalidArgument); 207 | } 208 | } 209 | } 210 | _ => { 211 | info!("Invalid game state}"); 212 | return Err(ProgramError::InvalidArgument); 213 | } 214 | } 215 | 216 | dashboard_state.serialize(&mut dashboard_account.data.borrow_mut())?; 217 | game_state.serialize(&mut game_account.data.borrow_mut())?; 218 | fund_to_cover_rent(dashboard_account, game_account)?; 219 | fund_to_cover_rent(dashboard_account, player_account) 220 | } 221 | 222 | entrypoint!(_entrypoint); 223 | fn _entrypoint( 224 | program_id: &Pubkey, 225 | accounts: &[AccountInfo], 226 | instruction_data: &[u8], 227 | ) -> ProgramResult { 228 | if let Err(error) = process_instruction(program_id, accounts, instruction_data) { 229 | // catch the error so we can print it 230 | error.print::(); 231 | return Err(error); 232 | } 233 | Ok(()) 234 | } 235 | -------------------------------------------------------------------------------- /program-bpf-rust/src/program_command.rs: -------------------------------------------------------------------------------- 1 | use crate::simple_serde::SimpleSerde; 2 | 3 | #[repr(C)] 4 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 5 | pub enum Command { 6 | /// Initialize a dashboard account 7 | InitDashboard, 8 | /// Initialize a player account 9 | InitPlayer, 10 | /// Initialize a game account 11 | InitGame, 12 | /// Used by Player X to advertise their game 13 | Advertise, 14 | /// Player O wants to join 15 | Join, 16 | /// Player X/O keep alive 17 | KeepAlive, 18 | /// Player X/O mark board position (x, y) 19 | Move(u8, u8), 20 | } 21 | impl SimpleSerde for Command {} 22 | 23 | #[cfg(test)] 24 | mod test { 25 | use super::*; 26 | 27 | #[test] 28 | pub fn serialize() { 29 | let cmd = Command::InitDashboard; 30 | let mut b = vec![0; 16]; 31 | cmd.serialize(&mut b).unwrap(); 32 | assert_eq!(b[0..4], [0, 0, 0, 0]); 33 | 34 | let cmd = Command::InitPlayer; 35 | let mut b = vec![0; 16]; 36 | cmd.serialize(&mut b).unwrap(); 37 | assert_eq!(b[0..4], [1, 0, 0, 0]); 38 | 39 | let cmd = Command::InitGame; 40 | let mut b = vec![0; 16]; 41 | cmd.serialize(&mut b).unwrap(); 42 | assert_eq!(b[0..4], [2, 0, 0, 0]); 43 | 44 | let cmd = Command::Advertise; 45 | let mut b = vec![0; 16]; 46 | cmd.serialize(&mut b).unwrap(); 47 | assert_eq!(b[0..4], [3, 0, 0, 0]); 48 | 49 | let cmd = Command::Join; 50 | let mut b = vec![0; 16]; 51 | cmd.serialize(&mut b).unwrap(); 52 | assert_eq!(b[0..4], [4, 0, 0, 0]); 53 | 54 | let cmd = Command::KeepAlive; 55 | let mut b = vec![0; 16]; 56 | cmd.serialize(&mut b).unwrap(); 57 | assert_eq!(b[0..4], [5, 0, 0, 0]); 58 | 59 | let cmd = Command::Move(1, 2); 60 | let mut b = vec![0; 16]; 61 | cmd.serialize(&mut b).unwrap(); 62 | assert_eq!(b, [6, 0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); 63 | } 64 | 65 | #[test] 66 | fn pull_in_externs() { 67 | // Rust on Linux excludes the solana_sdk_bpf_test library unless there is a 68 | // direct dependency, use this test to force the pull in of the library. 69 | // This is not necessary on macos and unfortunate on Linux 70 | // Issue: https://github.com/solana-labs/solana/issues/4972 71 | extern crate solana_sdk_bpf_test; 72 | use solana_sdk_bpf_test::*; 73 | unsafe { sol_log_("X".as_ptr(), 1) }; 74 | sol_log_64_(1, 2, 3, 4, 5); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /program-bpf-rust/src/program_state.rs: -------------------------------------------------------------------------------- 1 | use crate::dashboard; 2 | use crate::game; 3 | use crate::simple_serde::SimpleSerde; 4 | 5 | #[repr(C)] 6 | #[derive(Clone, Debug, Serialize, Deserialize)] 7 | #[allow(clippy::large_enum_variant)] 8 | pub enum State { 9 | /// State is not initialized yet 10 | Uninitialized, 11 | /// State holds dashboard state 12 | Dashboard(dashboard::Dashboard), 13 | /// State holds game state 14 | Game(game::Game), 15 | } 16 | impl SimpleSerde for State {} 17 | -------------------------------------------------------------------------------- /program-bpf-rust/src/simple_serde.rs: -------------------------------------------------------------------------------- 1 | use crate::error::TicTacToeError; 2 | use solana_sdk::{entrypoint::ProgramResult, info, program_error::ProgramError}; 3 | use std::mem::size_of; 4 | 5 | pub trait SimpleSerde: Clone { 6 | fn deserialize<'a>(input: &'a [u8]) -> Result 7 | where 8 | Self: serde::Deserialize<'a>, 9 | { 10 | if input.len() < size_of::() { 11 | info!("deserialize fail: input too small"); 12 | info!(0, 0, 0, input.len(), size_of::()); 13 | Err(TicTacToeError::DeserializationFailed.into()) 14 | } else { 15 | let s: &Self = unsafe { &*(&input[0] as *const u8 as *const Self) }; 16 | let c = (*s).clone(); 17 | Ok(c) 18 | } 19 | } 20 | 21 | fn serialize(self: &Self, output: &mut [u8]) -> ProgramResult 22 | where 23 | Self: std::marker::Sized + serde::Serialize, 24 | { 25 | if output.len() < size_of::() { 26 | info!("serialize fail: output too small"); 27 | Err(TicTacToeError::DeserializationFailed.into()) 28 | } else { 29 | let state = unsafe { &mut *(&mut output[0] as *mut u8 as *mut Self) }; 30 | *state = (*self).clone(); 31 | Ok(()) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/cli/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implements the command-line based game interface 3 | * 4 | * @flow 5 | */ 6 | import readline from 'readline-promise'; 7 | 8 | import {sleep} from '../util/sleep'; 9 | import {TicTacToe} from '../program/tic-tac-toe'; 10 | import type {Board} from '../program/program-state'; 11 | import {fetchDashboard} from '../server/config'; 12 | 13 | function renderBoard(board: Board): string { 14 | return [ 15 | board.slice(0, 3).join('|'), 16 | '-+-+-', 17 | board.slice(3, 6).join('|'), 18 | '-+-+-', 19 | board.slice(6, 9).join('|'), 20 | ].join('\n'); 21 | } 22 | 23 | function checkCoords(board: Board, x: number, y: number): boolean { 24 | if (board[x + y * 3] == ' ') { 25 | return true; 26 | } 27 | return false; 28 | } 29 | 30 | async function main() { 31 | const rl = readline.createInterface({ 32 | input: process.stdin, 33 | output: process.stdout, 34 | terminal: true, 35 | }); 36 | 37 | rl.write(`Connecting to network...\n`); 38 | 39 | // Create/load the game dashboard 40 | const {dashboard, connection} = await fetchDashboard(); 41 | 42 | rl.write(`Total games played: ${dashboard.state.totalGames}\n\n`); 43 | try { 44 | for (const [i, gamePublicKey] of dashboard.state.completedGames.entries()) { 45 | const state = await TicTacToe.getGameState(connection, gamePublicKey); 46 | const lastMove = new Date(Math.max(...state.keepAlive)); 47 | 48 | rl.write(`Game #${i}: ${state.gameState}\n`); 49 | rl.write(`${renderBoard(state.board)}\n`); 50 | rl.write( 51 | `${lastMove.getSeconds() === 0 ? '?' : lastMove.toLocaleString()}\n\n`, 52 | ); 53 | } 54 | } catch (err) { 55 | console.log(err); 56 | } 57 | 58 | // Find opponent 59 | rl.write('Looking for another player\n'); 60 | const ttt = await dashboard.startGame(); 61 | 62 | // 63 | // Main game loop 64 | // 65 | rl.write(`\nThe game has started. You are ${ttt.isX ? 'X' : 'O'}\n`); 66 | let showBoard = false; 67 | 68 | let gameUpdateCounter = 0; 69 | ttt.onChange(() => ++gameUpdateCounter); 70 | for (;;) { 71 | if (showBoard) { 72 | rl.write(`\n${renderBoard(ttt.state.board)}\n`); 73 | } 74 | showBoard = false; 75 | 76 | if (!ttt.inProgress) { 77 | break; 78 | } 79 | if (!ttt.myTurn) { 80 | rl.write('.'); 81 | await sleep(250); 82 | continue; 83 | } 84 | rl.write(`\nYour turn.\n${renderBoard(ttt.state.board)}\n`); 85 | 86 | let x = 0; 87 | let y = 0; 88 | for (;;) { 89 | const coords = await rl.questionAsync('Enter column and row (eg. 1x3): '); 90 | if (!/^[123]x[123]$/.test(coords)) { 91 | rl.write(`Invalid response: ${coords}\n`); 92 | } 93 | x = Number(coords[0]) - 1; 94 | y = Number(coords[2]) - 1; 95 | if (checkCoords(ttt.state.board, x, y)) { 96 | break; 97 | } 98 | rl.write(`That move has already been made, try again\n\n`); 99 | } 100 | 101 | const currentGameUpdateCounter = gameUpdateCounter; 102 | await ttt.move(x, y); 103 | while (currentGameUpdateCounter === gameUpdateCounter) { 104 | rl.write('.'); 105 | await sleep(250); 106 | } 107 | showBoard = true; 108 | } 109 | 110 | // 111 | // Display result 112 | // 113 | if (ttt.abandoned) { 114 | rl.write('\nGame has been abandoned\n'); 115 | return; 116 | } 117 | 118 | // Notify the dashboard that the game has completed. 119 | // await dashboard.submitGameState(ttt.gamePublicKey); 120 | 121 | rl.write(`\nGame Over\n=========\n\n${renderBoard(ttt.state.board)}\n\n`); 122 | if (ttt.winner) { 123 | rl.write('You won!\n'); 124 | } else if (ttt.draw) { 125 | rl.write('Draw.\n'); 126 | } else { 127 | rl.write('You lost.\n'); 128 | } 129 | } 130 | 131 | main() 132 | .catch(err => { 133 | console.error(err); 134 | }) 135 | .then(() => process.exit()); 136 | -------------------------------------------------------------------------------- /src/program/program-command.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The commands (encoded as Transaction Instructions) that are accepted by the 3 | * TicTacToe Game and Dashboard program 4 | * 5 | * @flow 6 | */ 7 | 8 | import * as BufferLayout from 'buffer-layout'; 9 | import {PublicKey} from '@solana/web3.js'; 10 | 11 | const COMMAND_LENGTH = 8; 12 | 13 | const Command = { 14 | InitDashboard: 0, // Initialize a dashboard account 15 | InitPlayer: 1, // Initialize a player account 16 | InitGame: 2, // Initialize a game account 17 | Advertise: 3, // Used by Player X to advertise their game 18 | Join: 4, // Player O wants to join 19 | KeepAlive: 5, // Player X/O keep alive 20 | Move: 6, // Player X/O mark board position (x, y) 21 | }; 22 | 23 | function zeroPad(command: Buffer): Buffer { 24 | if (command.length > COMMAND_LENGTH) { 25 | throw new Error( 26 | `command buffer too large: ${command.length} > ${COMMAND_LENGTH}`, 27 | ); 28 | } 29 | const buffer = Buffer.alloc(COMMAND_LENGTH); 30 | command.copy(buffer); 31 | return buffer; 32 | } 33 | 34 | function commandWithNoArgs(command: number): Buffer { 35 | const layout = BufferLayout.struct([BufferLayout.u32('command')]); 36 | const buffer = Buffer.alloc(layout.span); 37 | layout.encode({command}, buffer); 38 | return zeroPad(buffer); 39 | } 40 | 41 | export function initDashboard(): Buffer { 42 | return commandWithNoArgs(Command.InitDashboard); 43 | } 44 | 45 | export function initPlayer(): Buffer { 46 | return commandWithNoArgs(Command.InitPlayer); 47 | } 48 | 49 | export function initGame(): Buffer { 50 | return commandWithNoArgs(Command.InitGame); 51 | } 52 | 53 | export function advertiseGame(): Buffer { 54 | return commandWithNoArgs(Command.Advertise); 55 | } 56 | 57 | export function joinGame(): Buffer { 58 | return commandWithNoArgs(Command.Join); 59 | } 60 | 61 | export function keepAlive(): Buffer { 62 | return commandWithNoArgs(Command.KeepAlive); 63 | } 64 | 65 | export function move(x: number, y: number): Buffer { 66 | const layout = BufferLayout.struct([ 67 | BufferLayout.u32('command'), 68 | BufferLayout.u8('x'), 69 | BufferLayout.u8('y'), 70 | ]); 71 | 72 | const buffer = Buffer.alloc(layout.span); 73 | layout.encode({command: Command.Move, x, y}, buffer); 74 | return zeroPad(buffer); 75 | } 76 | 77 | /** 78 | * Public key that identifies the Clock Sysvar Account Public Key 79 | */ 80 | export function getSysvarClockPublicKey(): PublicKey { 81 | return new PublicKey('SysvarC1ock11111111111111111111111111111111'); 82 | } 83 | -------------------------------------------------------------------------------- /src/program/program-state.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Functions to deserialize TicTacToe Game and Dashboard account data 3 | * 4 | * @flow 5 | */ 6 | 7 | import * as BufferLayout from 'buffer-layout'; 8 | import {PublicKey} from '@solana/web3.js'; 9 | import type {AccountInfo} from '@solana/web3.js'; 10 | 11 | const emptyKey = new PublicKey('0x0'); 12 | 13 | const publicKeyLayout = (property: string = 'publicKey'): Object => { 14 | return BufferLayout.blob(32, property); 15 | }; 16 | 17 | export type DashboardState = { 18 | pendingGame: PublicKey | null, 19 | completedGames: Array, 20 | totalGames: number, 21 | }; 22 | 23 | export type Board = Array<' ' | 'X' | 'O'>; 24 | 25 | export type GameState = { 26 | playerX: PublicKey | null, 27 | playerO: PublicKey | null, 28 | gameState: 'Waiting' | 'XMove' | 'OMove' | 'Draw' | 'XWon' | 'OWon', 29 | board: Board, 30 | keepAlive: [number, number], 31 | }; 32 | 33 | export function deserializeGameState(accountInfo: AccountInfo): GameState { 34 | const gameLayout = BufferLayout.struct([ 35 | BufferLayout.nu64('stateType'), 36 | BufferLayout.seq(BufferLayout.nu64(), 2, 'keepAlive'), 37 | BufferLayout.u8('gameState'), 38 | publicKeyLayout('playerX'), 39 | publicKeyLayout('playerO'), 40 | BufferLayout.seq(BufferLayout.u8(), 9, 'board'), 41 | ]); 42 | const game = gameLayout.decode(accountInfo.data); 43 | if (game.stateType != 2 /* StateType_Game */) { 44 | throw new Error(`Invalid game stateType: ${game.stateType}`); 45 | } 46 | 47 | const gameStates = ['Waiting', 'XMove', 'OMove', 'XWon', 'OWon', 'Draw']; 48 | if (game.gameState >= gameStates.length) { 49 | throw new Error(`Invalid game state: ${game.gameState}`); 50 | } 51 | 52 | const boardItemMap = [' ', 'X', 'O']; 53 | return { 54 | gameState: gameStates[game.gameState], 55 | playerX: new PublicKey(game.playerX), 56 | playerO: new PublicKey(game.playerO), 57 | board: game.board.map(item => boardItemMap[item]), 58 | keepAlive: game.keepAlive, 59 | }; 60 | } 61 | 62 | export function deserializeDashboardState( 63 | accountInfo: AccountInfo, 64 | ): DashboardState { 65 | const dashboardLayout = BufferLayout.struct([ 66 | BufferLayout.nu64('stateType'), 67 | BufferLayout.nu64('totalGames'), 68 | publicKeyLayout('pendingGame'), 69 | BufferLayout.seq( 70 | publicKeyLayout(), 71 | 5 /*MAX_COMPLETED_GAMES*/, 72 | 'completedGames', 73 | ), 74 | BufferLayout.u8('lastGameIndex'), 75 | ]); 76 | 77 | const dashboard = dashboardLayout.decode(accountInfo.data); 78 | if (dashboard.stateType != 1 /* StateType_Dashboard */) { 79 | throw new Error(`Invalid dashboard stateType: ${dashboard.stateType}`); 80 | } 81 | 82 | let completedGames = []; 83 | for (let i = dashboard.lastGameIndex; i >= 0; i--) { 84 | completedGames.unshift(dashboard.completedGames[i]); 85 | } 86 | for ( 87 | let i = dashboard.completedGames.length; 88 | i > dashboard.lastGameIndex; 89 | i-- 90 | ) { 91 | completedGames.unshift(dashboard.completedGames[i]); 92 | } 93 | 94 | const pending = new PublicKey(dashboard.pendingGame); 95 | return { 96 | pendingGame: pending.equals(emptyKey) ? null : pending, 97 | completedGames: completedGames 98 | .map(a => new PublicKey(a)) 99 | .filter(a => !a.equals(emptyKey)), 100 | totalGames: dashboard.totalGames, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /src/program/tic-tac-toe-dashboard.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * The TicTacToe Dashboard class exported by this file is used to interact with the 4 | * on-chain tic-tac-toe dashboard program. 5 | * 6 | * @flow 7 | */ 8 | 9 | import invariant from 'assert'; 10 | import EventEmitter from 'event-emitter'; 11 | import { 12 | Account, 13 | PublicKey, 14 | Transaction, 15 | SystemProgram, 16 | sendAndConfirmRawTransaction, 17 | } from '@solana/web3.js'; 18 | import type {AccountInfo, Connection} from '@solana/web3.js'; 19 | 20 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 21 | import {sleep} from '../util/sleep'; 22 | import {sendAndConfirmTransaction} from '../util/send-and-confirm-transaction'; 23 | import * as ProgramCommand from './program-command'; 24 | import {deserializeDashboardState} from './program-state'; 25 | import type {DashboardState} from './program-state'; 26 | import {TicTacToe} from './tic-tac-toe'; 27 | 28 | export class TicTacToeDashboard { 29 | state: DashboardState; 30 | connection: Connection; 31 | programId: PublicKey; 32 | publicKey: PublicKey; 33 | _dashboardAccount: Account; 34 | _ee: EventEmitter; 35 | _changeSubscriptionId: number | null; 36 | 37 | /** 38 | * @private 39 | */ 40 | constructor( 41 | connection: Connection, 42 | programId: PublicKey, 43 | dashboardAccount: Account, 44 | ) { 45 | const {publicKey} = dashboardAccount; 46 | 47 | const state = { 48 | pendingGame: null, 49 | completedGames: [], 50 | totalGames: 0, 51 | }; 52 | Object.assign(this, { 53 | connection, 54 | programId, 55 | _dashboardAccount: dashboardAccount, 56 | publicKey, 57 | state, 58 | _changeSubscriptionId: connection.onAccountChange( 59 | publicKey, 60 | this._onAccountChange.bind(this), 61 | ), 62 | _ee: new EventEmitter(), 63 | }); 64 | } 65 | 66 | /** 67 | * Creates a new dashboard 68 | */ 69 | static async create( 70 | connection: Connection, 71 | programId: PublicKey, 72 | ): Promise { 73 | const SizeOfDashBoardData = 255; 74 | const {feeCalculator} = await connection.getRecentBlockhash(); 75 | const lamports = 1000000000; // enough to cover rent for game and player accounts 76 | const balanceNeeded = 77 | feeCalculator.lamportsPerSignature * 3 /* payer + 2 signers */ + 78 | (await connection.getMinimumBalanceForRentExemption(SizeOfDashBoardData)); 79 | const tempAccount = await newSystemAccountWithAirdrop( 80 | connection, 81 | lamports + balanceNeeded, 82 | ); 83 | 84 | const dashboardAccount = new Account(); 85 | 86 | const transaction = SystemProgram.createAccount({ 87 | fromPubkey: tempAccount.publicKey, 88 | newAccountPubkey: dashboardAccount.publicKey, 89 | lamports: lamports, 90 | space: SizeOfDashBoardData, 91 | programId, 92 | }); 93 | transaction.add({ 94 | keys: [ 95 | {pubkey: dashboardAccount.publicKey, isSigner: true, isWritable: true}, 96 | ], 97 | programId, 98 | data: ProgramCommand.initDashboard(), 99 | }); 100 | await sendAndConfirmTransaction( 101 | 'initDashboard', 102 | connection, 103 | transaction, 104 | tempAccount, 105 | dashboardAccount, 106 | ); 107 | 108 | return new TicTacToeDashboard(connection, programId, dashboardAccount); 109 | } 110 | 111 | /** 112 | * Connects to an existing dashboard 113 | */ 114 | static async connect( 115 | connection: Connection, 116 | dashboardAccount: Account, 117 | ): Promise { 118 | const accountInfo = await connection.getAccountInfo( 119 | dashboardAccount.publicKey, 120 | ); 121 | if (accountInfo === null) { 122 | throw new Error('Failed to get dashboard account information'); 123 | } 124 | const {owner} = accountInfo; 125 | 126 | const dashboard = new TicTacToeDashboard( 127 | connection, 128 | owner, 129 | dashboardAccount, 130 | ); 131 | dashboard.state = deserializeDashboardState(accountInfo); 132 | return dashboard; 133 | } 134 | 135 | /** 136 | * @private 137 | */ 138 | _onAccountChange(accountInfo: AccountInfo) { 139 | this.state = deserializeDashboardState(accountInfo); 140 | this._ee.emit('change'); 141 | } 142 | 143 | /** 144 | * Register a callback for notification when the dashboard state changes 145 | */ 146 | onChange(fn: Function) { 147 | this._ee.on('change', fn); 148 | } 149 | 150 | /** 151 | * Remove a previously registered onChange callback 152 | */ 153 | removeChangeListener(fn: Function) { 154 | this._ee.off('change', fn); 155 | } 156 | 157 | /** 158 | * Request a partially signed Transaction that will enable the player to 159 | * initiate a Game. 160 | * 161 | * Note: Although the current implementation of this method is inline, in 162 | * production this function would issue an RPC request to a server somewhere 163 | * that hosts the dashboard's secret key. 164 | * 165 | * Upon return, the player must sign the Transaction with their secretKey and 166 | * send it to the cluster. 167 | */ 168 | async _requestPlayerAccountTransaction( 169 | playerPublicKey: PublicKey, 170 | ): Promise { 171 | const { 172 | blockhash: recentBlockhash, 173 | feeCalculator, 174 | } = await this.connection.getRecentBlockhash(); 175 | let transaction = new Transaction({recentBlockhash}); 176 | transaction.add( 177 | SystemProgram.assign({ 178 | fromPubkey: playerPublicKey, 179 | programId: this.programId, 180 | }), 181 | { 182 | keys: [ 183 | { 184 | pubkey: this._dashboardAccount.publicKey, 185 | isSigner: true, 186 | isWritable: true, 187 | }, 188 | {pubkey: playerPublicKey, isSigner: true, isWritable: true}, 189 | ], 190 | programId: this.programId, 191 | data: ProgramCommand.initPlayer(), 192 | }, 193 | ); 194 | const accountStorageOverhead = 128; 195 | const balanceNeeded = 196 | feeCalculator.lamportsPerSignature * 3 /* payer + 2 signer keys */ + 197 | (await this.connection.getMinimumBalanceForRentExemption( 198 | accountStorageOverhead, 199 | )); 200 | const payerAccount = await newSystemAccountWithAirdrop( 201 | this.connection, 202 | balanceNeeded, 203 | ); 204 | 205 | transaction.signPartial( 206 | payerAccount, 207 | this._dashboardAccount, 208 | playerPublicKey, 209 | ); 210 | return transaction; 211 | } 212 | 213 | /** 214 | * Finds another player and starts a game 215 | */ 216 | async startGame(): Promise { 217 | const playerAccount = new Account(); 218 | const transaction = await this._requestPlayerAccountTransaction( 219 | playerAccount.publicKey, 220 | ); 221 | transaction.addSigner(playerAccount); 222 | await sendAndConfirmRawTransaction( 223 | this.connection, 224 | transaction.serialize(), 225 | ); 226 | 227 | let myGame: TicTacToe | null = null; 228 | 229 | // Look for pending games from others, while trying to advertise our game. 230 | for (;;) { 231 | if (myGame) { 232 | if (myGame.inProgress) { 233 | // Another player joined our game 234 | console.log( 235 | `Another player accepted our game (${myGame.gamePublicKey.toString()})`, 236 | ); 237 | return myGame; 238 | } 239 | 240 | if (myGame.disconnected) { 241 | throw new Error('game disconnected'); 242 | } 243 | } 244 | 245 | const pendingGamePublicKey = this.state.pendingGame; 246 | 247 | if ( 248 | pendingGamePublicKey !== null && 249 | (!myGame || !myGame.gamePublicKey.equals(pendingGamePublicKey)) 250 | ) { 251 | try { 252 | console.log(`Trying to join ${pendingGamePublicKey.toString()}`); 253 | const theirGame = await TicTacToe.join( 254 | this.connection, 255 | this.programId, 256 | this.publicKey, 257 | playerAccount, 258 | pendingGamePublicKey, 259 | ); 260 | if (theirGame !== null) { 261 | console.log(`Joined game ${theirGame.gamePublicKey.toString()}`); 262 | if (myGame) { 263 | myGame.abandon(); 264 | } 265 | return theirGame; 266 | } 267 | } catch (err) { 268 | console.log(err.message); 269 | } 270 | } 271 | 272 | if (!myGame) { 273 | myGame = await TicTacToe.create( 274 | this.connection, 275 | this.programId, 276 | this.publicKey, 277 | playerAccount, 278 | ); 279 | } 280 | 281 | if ( 282 | pendingGamePublicKey === null || 283 | !myGame.gamePublicKey.equals(pendingGamePublicKey) 284 | ) { 285 | // Advertise myGame as the pending game for others to see and join 286 | console.log( 287 | `Advertising our game (${myGame.gamePublicKey.toString()})`, 288 | ); 289 | const transaction = new Transaction().add({ 290 | keys: [ 291 | { 292 | pubkey: playerAccount.publicKey, 293 | isSigner: true, 294 | isWritable: true, 295 | }, 296 | {pubkey: this.publicKey, isSigner: false, isWritable: true}, 297 | {pubkey: myGame.gamePublicKey, isSigner: false, isWritable: true}, 298 | { 299 | pubkey: ProgramCommand.getSysvarClockPublicKey(), 300 | isSigner: false, 301 | isWritable: false, 302 | }, 303 | ], 304 | programId: this.programId, 305 | data: ProgramCommand.advertiseGame(), 306 | }); 307 | await sendAndConfirmTransaction( 308 | 'advertiseGame', 309 | this.connection, 310 | transaction, 311 | playerAccount, 312 | ); 313 | } 314 | 315 | // Wait for a bite 316 | await sleep(500); 317 | } 318 | invariant(false); //eslint-disable-line no-unreachable 319 | } 320 | 321 | async disconnect() { 322 | const {_changeSubscriptionId} = this; 323 | if (_changeSubscriptionId !== null) { 324 | this._changeSubscriptionId = null; 325 | try { 326 | await this.connection.removeAccountChangeListener( 327 | _changeSubscriptionId, 328 | ); 329 | } catch (err) { 330 | console.error('Failed to remove account change listener', err); 331 | } 332 | } 333 | } 334 | } 335 | -------------------------------------------------------------------------------- /src/program/tic-tac-toe.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * The TicTacToe class exported by this file is used to interact with the 4 | * on-chain tic-tac-toe game program. 5 | * 6 | * @flow 7 | */ 8 | 9 | import EventEmitter from 'event-emitter'; 10 | import {Account, PublicKey, Transaction, SystemProgram} from '@solana/web3.js'; 11 | import type {AccountInfo, Connection} from '@solana/web3.js'; 12 | 13 | import * as ProgramCommand from './program-command'; 14 | import {deserializeGameState} from './program-state'; 15 | import type {GameState} from './program-state'; 16 | import {sendAndConfirmTransaction} from '../util/send-and-confirm-transaction'; 17 | 18 | export class TicTacToe { 19 | abandoned: boolean; 20 | disconnected: boolean; 21 | connection: Connection; 22 | programId: PublicKey; 23 | dashboard: PublicKey; 24 | gamePublicKey: PublicKey; 25 | isX: boolean; 26 | playerAccount: Account; 27 | state: GameState; 28 | inProgress: boolean; 29 | myTurn: boolean; 30 | draw: boolean; 31 | winner: boolean; 32 | keepAliveCache: [number, number]; 33 | _keepAliveErrorCount: number; 34 | 35 | _ee: EventEmitter; 36 | _changeSubscriptionId: number | null; 37 | 38 | /** 39 | * @private 40 | */ 41 | constructor( 42 | connection: Connection, 43 | programId: PublicKey, 44 | dashboard: PublicKey, 45 | gamePublicKey: PublicKey, 46 | isX: boolean, 47 | playerAccount: Account, 48 | ) { 49 | const state = { 50 | board: [], 51 | gameState: 'Waiting', 52 | keepAlive: [0, 0], 53 | playerO: null, 54 | playerX: null, 55 | }; 56 | 57 | Object.assign(this, { 58 | _changeSubscriptionId: null, 59 | _ee: new EventEmitter(), 60 | abandoned: false, 61 | disconnected: false, 62 | connection, 63 | draw: false, 64 | gamePublicKey, 65 | inProgress: false, 66 | isX, 67 | myTurn: false, 68 | playerAccount, 69 | programId, 70 | dashboard, 71 | state, 72 | winner: false, 73 | keepAliveCache: [0, 0], 74 | _keepAliveErrorCount: 0, 75 | }); 76 | } 77 | 78 | /** 79 | * @private 80 | */ 81 | scheduleNextKeepAlive() { 82 | if (this._changeSubscriptionId === null) { 83 | this._changeSubscriptionId = this.connection.onAccountChange( 84 | this.gamePublicKey, 85 | this._onAccountChange.bind(this), 86 | ); 87 | } 88 | setTimeout(async () => { 89 | if (this.abandoned || this.disconnected) { 90 | const {_changeSubscriptionId} = this; 91 | if (_changeSubscriptionId !== null) { 92 | this._changeSubscriptionId = null; 93 | this.connection.removeAccountChangeListener(_changeSubscriptionId); 94 | } 95 | 96 | //console.log(`\nKeepalive exit, Game abandoned: ${this.gamePublicKey}\n`); 97 | return; 98 | } 99 | if (['XWon', 'OWon', 'Draw'].includes(this.state.gameState)) { 100 | //console.log(`\nKeepalive exit, Game over: ${this.gamePublicKey}\n`); 101 | return; 102 | } 103 | try { 104 | await this.keepAlive(); 105 | this._keepAliveErrorCount = 0; 106 | } catch (err) { 107 | ++this._keepAliveErrorCount; 108 | console.error( 109 | `keepAlive() failed #${this._keepAliveErrorCount}: ${err}`, 110 | ); 111 | if (this._keepAliveErrorCount > 3) { 112 | this.disconnected = true; 113 | this.inProgress = false; 114 | this._ee.emit('change'); 115 | } 116 | } 117 | this.scheduleNextKeepAlive(); 118 | }, 2000); 119 | } 120 | 121 | /** 122 | * Creates a new game 123 | */ 124 | static async create( 125 | connection: Connection, 126 | programId: PublicKey, 127 | dashboard: PublicKey, 128 | playerXAccount: Account, 129 | ): Promise { 130 | const invalidAccount = new Account(); 131 | const gameAccount = new Account(); 132 | 133 | const transaction = SystemProgram.createAccount({ 134 | // The initGame instruction funds `gameAccount`, so the account here can 135 | // be one with zero lamports (an invalid account) 136 | fromPubkey: invalidAccount.publicKey, 137 | newAccountPubkey: gameAccount.publicKey, 138 | lamports: 0, 139 | space: 255, // data space 140 | programId, 141 | }); 142 | transaction.add({ 143 | keys: [ 144 | {pubkey: gameAccount.publicKey, isSigner: true, isWritable: true}, 145 | {pubkey: dashboard, isSigner: false, isWritable: true}, 146 | {pubkey: playerXAccount.publicKey, isSigner: true, isWritable: true}, 147 | { 148 | pubkey: ProgramCommand.getSysvarClockPublicKey(), 149 | isSigner: false, 150 | isWritable: false, 151 | }, 152 | ], 153 | programId, 154 | data: ProgramCommand.initGame(), 155 | }); 156 | 157 | await sendAndConfirmTransaction( 158 | 'initGame', 159 | connection, 160 | transaction, 161 | playerXAccount, 162 | invalidAccount, 163 | gameAccount, 164 | ); 165 | 166 | const ttt = new TicTacToe( 167 | connection, 168 | programId, 169 | dashboard, 170 | gameAccount.publicKey, 171 | true, 172 | playerXAccount, 173 | ); 174 | ttt.scheduleNextKeepAlive(); 175 | return ttt; 176 | } 177 | 178 | /** 179 | * Join an existing game as player O 180 | */ 181 | static async join( 182 | connection: Connection, 183 | programId: PublicKey, 184 | dashboard: PublicKey, 185 | playerOAccount: Account, 186 | gamePublicKey: PublicKey, 187 | ): Promise { 188 | const ttt = new TicTacToe( 189 | connection, 190 | programId, 191 | dashboard, 192 | gamePublicKey, 193 | false, 194 | playerOAccount, 195 | ); 196 | { 197 | const transaction = new Transaction().add({ 198 | keys: [ 199 | {pubkey: playerOAccount.publicKey, isSigner: true, isWritable: true}, 200 | {pubkey: dashboard, isSigner: false, isWritable: true}, 201 | {pubkey: gamePublicKey, isSigner: false, isWritable: true}, 202 | { 203 | pubkey: ProgramCommand.getSysvarClockPublicKey(), 204 | isSigner: false, 205 | isWritable: false, 206 | }, 207 | ], 208 | programId, 209 | data: ProgramCommand.joinGame(), 210 | }); 211 | await sendAndConfirmTransaction( 212 | 'joinGame', 213 | connection, 214 | transaction, 215 | playerOAccount, 216 | ); 217 | } 218 | 219 | const accountInfo = await connection.getAccountInfo(gamePublicKey); 220 | if (accountInfo === null) { 221 | return null; 222 | } 223 | 224 | ttt._onAccountChange(accountInfo); 225 | if (!ttt.inProgress) { 226 | return null; 227 | } 228 | if (ttt.state.playerO === null) { 229 | return null; 230 | } 231 | if (!playerOAccount.publicKey.equals(ttt.state.playerO)) { 232 | return null; 233 | } 234 | ttt.scheduleNextKeepAlive(); 235 | return ttt; 236 | } 237 | 238 | /** 239 | * Send a keep-alive message to inform the other player that we're still alive 240 | */ 241 | async keepAlive(): Promise { 242 | const transaction = new Transaction().add({ 243 | keys: [ 244 | { 245 | pubkey: this.playerAccount.publicKey, 246 | isSigner: true, 247 | isWritable: true, 248 | }, 249 | {pubkey: this.dashboard, isSigner: false, isWritable: true}, 250 | {pubkey: this.gamePublicKey, isSigner: false, isWritable: true}, 251 | { 252 | pubkey: ProgramCommand.getSysvarClockPublicKey(), 253 | isSigner: false, 254 | isWritable: false, 255 | }, 256 | ], 257 | programId: this.programId, 258 | data: ProgramCommand.keepAlive(), 259 | }); 260 | await sendAndConfirmTransaction( 261 | 'keepAlive', 262 | this.connection, 263 | transaction, 264 | this.playerAccount, 265 | ); 266 | } 267 | 268 | /** 269 | * Leave the game 270 | */ 271 | abandon() { 272 | this.abandoned = true; 273 | } 274 | 275 | /** 276 | * Attempt to make a move. 277 | */ 278 | async move(x: number, y: number): Promise { 279 | const transaction = new Transaction().add({ 280 | keys: [ 281 | { 282 | pubkey: this.playerAccount.publicKey, 283 | isSigner: true, 284 | isWritable: true, 285 | }, 286 | {pubkey: this.dashboard, isSigner: false, isWritable: true}, 287 | {pubkey: this.gamePublicKey, isSigner: false, isWritable: true}, 288 | { 289 | pubkey: ProgramCommand.getSysvarClockPublicKey(), 290 | isSigner: false, 291 | isWritable: false, 292 | }, 293 | ], 294 | programId: this.programId, 295 | data: ProgramCommand.move(x, y), 296 | }); 297 | await sendAndConfirmTransaction( 298 | `move(${x + 1},${y + 1})`, 299 | this.connection, 300 | transaction, 301 | this.playerAccount, 302 | ); 303 | } 304 | 305 | /** 306 | * Fetch the latest state of the specified game 307 | */ 308 | static async getGameState( 309 | connection: Connection, 310 | gamePublicKey: PublicKey, 311 | ): Promise { 312 | const accountInfo = await connection.getAccountInfo(gamePublicKey); 313 | if (accountInfo === null) { 314 | throw new Error('Failed to get game state'); 315 | } 316 | return deserializeGameState(accountInfo); 317 | } 318 | 319 | isPeerAlive(): boolean { 320 | const keepAliveDiff = Math.abs( 321 | this.state.keepAlive[0] - this.state.keepAlive[1], 322 | ); 323 | return keepAliveDiff < 100; // ~10 seconds 324 | } 325 | 326 | /** 327 | * Update the `state` field with the latest state 328 | * 329 | * @private 330 | */ 331 | _onAccountChange(accountInfo: AccountInfo) { 332 | let tempState = deserializeGameState(accountInfo); 333 | if ( 334 | (tempState.keepAlive[0] > 0 && 335 | this.keepAliveCache[0] > tempState.keepAlive[0]) || 336 | (tempState.keepAlive[1] > 0 && 337 | this.keepAliveCache[1] > tempState.keepAlive[1]) 338 | ) { 339 | return; 340 | } else { 341 | this.keepAliveCache = tempState.keepAlive; 342 | this.state = tempState; 343 | } 344 | 345 | this.inProgress = false; 346 | this.myTurn = false; 347 | this.draw = false; 348 | this.winner = false; 349 | 350 | switch (this.state.gameState) { 351 | case 'Waiting': 352 | break; 353 | case 'XMove': 354 | this.inProgress = true; 355 | this.myTurn = this.isX; 356 | break; 357 | case 'OMove': 358 | this.inProgress = true; 359 | this.myTurn = !this.isX; 360 | break; 361 | case 'Draw': 362 | this.draw = true; 363 | break; 364 | case 'XWon': 365 | this.winner = this.isX; 366 | break; 367 | case 'OWon': 368 | this.winner = !this.isX; 369 | break; 370 | default: 371 | throw new Error(`Unhandled game state: ${this.state.gameState}`); 372 | } 373 | 374 | if (this.inProgress && !this.isPeerAlive()) { 375 | this.inProgress = false; 376 | this.abandoned = true; 377 | } 378 | this._ee.emit('change'); 379 | } 380 | 381 | /** 382 | * Register a callback for notification when the game state changes 383 | */ 384 | onChange(fn: Function) { 385 | this._ee.on('change', fn); 386 | } 387 | 388 | /** 389 | * Remove a previously registered onChange callback 390 | */ 391 | removeChangeListener(fn: Function) { 392 | this._ee.off('change', fn); 393 | } 394 | } 395 | -------------------------------------------------------------------------------- /src/server/config.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import {BpfLoader, Connection, Account} from '@solana/web3.js'; 4 | import fs from 'mz/fs'; 5 | import path from 'path'; 6 | import semver from 'semver'; 7 | 8 | import {url, urlTls} from '../../url'; 9 | import {Store} from './store'; 10 | import {TicTacToeDashboard} from '../program/tic-tac-toe-dashboard'; 11 | import {newSystemAccountWithAirdrop} from '../util/new-system-account-with-airdrop'; 12 | 13 | const NUM_RETRIES = 500; /* allow some number of retries */ 14 | 15 | let connection; 16 | let commitment; 17 | 18 | async function getConnection(): Promise { 19 | if (connection) return {connection, commitment}; 20 | 21 | let newConnection = new Connection(url); 22 | const version = await newConnection.getVersion(); 23 | 24 | // commitment params are only supported >= 0.21.0 25 | const solanaCoreVersion = version['solana-core'].split(' ')[0]; 26 | if (semver.gte(solanaCoreVersion, '0.21.0')) { 27 | commitment = 'recent'; 28 | newConnection = new Connection(url, commitment); 29 | } 30 | 31 | // eslint-disable-next-line require-atomic-updates 32 | connection = newConnection; 33 | console.log('Connection to cluster established:', url, version); 34 | return {connection, commitment}; 35 | } 36 | 37 | /** 38 | * Obtain the Dashboard singleton object 39 | */ 40 | export async function findDashboard(): Promise { 41 | const store = new Store(); 42 | const {connection, commitment} = await getConnection(); 43 | const config = await store.load('../../../dist/config.json'); 44 | const dashboard = await TicTacToeDashboard.connect( 45 | connection, 46 | new Account(Buffer.from(config.secretKey, 'hex')), 47 | ); 48 | return {dashboard, connection, commitment}; 49 | } 50 | 51 | /** 52 | * Load the TTT program and then create the Dashboard singleton object 53 | */ 54 | export async function createDashboard(): Promise { 55 | const store = new Store(); 56 | const {connection, commitment} = await getConnection(); 57 | 58 | let elf; 59 | try { 60 | elf = await fs.readFile( 61 | path.join(__dirname, '..', '..', 'dist', 'program', 'tictactoe.so'), 62 | ); 63 | } catch (err) { 64 | console.error(err); 65 | process.exit(1); 66 | return; 67 | } 68 | 69 | const {feeCalculator} = await connection.getRecentBlockhash(); 70 | const balanceNeeded = 71 | feeCalculator.lamportsPerSignature * 72 | (BpfLoader.getMinNumSignatures(elf.length) + NUM_RETRIES) + 73 | (await connection.getMinimumBalanceForRentExemption(elf.length)); 74 | const loaderAccount = await newSystemAccountWithAirdrop( 75 | connection, 76 | balanceNeeded, 77 | ); 78 | 79 | let program = new Account(); 80 | let attempts = 5; 81 | while (attempts > 0) { 82 | try { 83 | console.log('Loading BPF program...'); 84 | await BpfLoader.load(connection, loaderAccount, program, elf); 85 | break; 86 | } catch (err) { 87 | program = new Account(); 88 | attempts--; 89 | console.log( 90 | `Error loading BPF program, ${attempts} attempts remaining:`, 91 | err.message, 92 | ); 93 | } 94 | } 95 | 96 | if (attempts === 0) { 97 | throw new Error('Unable to load program'); 98 | } 99 | 100 | const programId = program.publicKey; 101 | console.log('Creating dashboard for programId:', programId.toString()); 102 | const dashboard = await TicTacToeDashboard.create(connection, programId); 103 | await store.save('../../../dist/config.json', { 104 | url: urlTls, 105 | commitment, 106 | secretKey: Buffer.from(dashboard._dashboardAccount.secretKey).toString( 107 | 'hex', 108 | ), 109 | }); 110 | return {dashboard, connection, commitment}; 111 | } 112 | 113 | /** 114 | * Used when invoking from the command line. First checks for existing dashboard, 115 | * if that fails, attempts to create a new one. 116 | */ 117 | export async function fetchDashboard(): Promise { 118 | try { 119 | let ret = await findDashboard(); 120 | console.log('Dashboard:', ret.dashboard.publicKey.toBase58()); 121 | return ret; 122 | } catch (err) { 123 | // ignore error, try to create instead 124 | } 125 | 126 | try { 127 | let ret = await createDashboard(); 128 | console.log('Dashboard:', ret.dashboard.publicKey.toBase58()); 129 | return ret; 130 | } catch (err) { 131 | console.error('Failed to create dashboard: ', err); 132 | throw err; 133 | } 134 | } 135 | 136 | if (require.main === module) { 137 | fetchDashboard() 138 | .then(process.exit) 139 | .catch(console.error) 140 | .then(() => 1) 141 | .then(process.exit); 142 | } 143 | -------------------------------------------------------------------------------- /src/server/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * http server entrypoint 3 | * 4 | * This http server exists only to dynamically serve /config.json containing the 5 | * latest Dashboard public key 6 | */ 7 | import bodyParser from 'body-parser'; 8 | import express from 'express'; 9 | import jayson from 'jayson'; 10 | import path from 'path'; 11 | import {struct} from 'superstruct'; 12 | 13 | import {createDashboard, findDashboard} from './config'; 14 | import {sleep} from '../util/sleep'; 15 | import {urlTls} from '../../url'; 16 | 17 | const port = process.env.PORT || 8080; 18 | const app = express(); 19 | 20 | async function getDashboard(): Promise { 21 | try { 22 | return await findDashboard(); 23 | } catch (err) { 24 | // ignore 25 | } 26 | 27 | if (app.locals.creating) { 28 | return null; 29 | } 30 | 31 | try { 32 | app.locals.creating = true; 33 | return await createDashboard(); 34 | } finally { 35 | // eslint-disable-next-line require-atomic-updates 36 | app.locals.creating = false; 37 | } 38 | } 39 | 40 | app.get('/config.json', async (req, res) => { 41 | try { 42 | const ret = await getDashboard(); 43 | let response = { 44 | creating: true, 45 | }; 46 | 47 | if (ret) { 48 | const {dashboard, commitment} = ret; 49 | response = { 50 | url: urlTls, 51 | commitment, 52 | secretKey: Buffer.from(dashboard._dashboardAccount.secretKey).toString( 53 | 'hex', 54 | ), 55 | }; 56 | } 57 | 58 | res.send(JSON.stringify(response)).end(); 59 | } catch (err) { 60 | console.log('findDashboard failed:', err); 61 | res.status(500).end(); 62 | } 63 | }); 64 | app.use(bodyParser.json()); 65 | app.use(express.static(path.join(__dirname, '../../dist'))); 66 | 67 | async function loadDashboard() { 68 | for (;;) { 69 | try { 70 | console.log('loading dashboard'); 71 | const {dashboard} = await getDashboard(); 72 | const publicKey = dashboard.publicKey.toBase58(); 73 | console.log('dashboard loaded:', publicKey); 74 | return; 75 | } catch (err) { 76 | console.log('findDashboard at startup failed:', err); 77 | } 78 | await sleep(500); 79 | } 80 | } 81 | 82 | const rpcServer = jayson.server({ 83 | ping: (rawargs, callback) => { 84 | try { 85 | console.log('ping rawargs', rawargs); 86 | const pingMessage = struct([struct.literal('hi')]); 87 | const args = pingMessage(rawargs); 88 | console.log('ping args', args); 89 | callback(null, 'pong'); 90 | } catch (err) { 91 | console.log('ping failed:', err); 92 | callback(err); 93 | } 94 | }, 95 | }); 96 | 97 | app.use(rpcServer.middleware()); 98 | 99 | loadDashboard(); 100 | app.listen(port); 101 | console.log('Listening on port', port); 102 | -------------------------------------------------------------------------------- /src/server/store.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple file-based datastore 3 | * 4 | * @flow 5 | */ 6 | 7 | import path from 'path'; 8 | import fs from 'mz/fs'; 9 | import mkdirp from 'mkdirp-promise'; 10 | 11 | export class Store { 12 | dir = path.join(__dirname, 'store'); 13 | 14 | async load(uri: string): Promise { 15 | const filename = path.join(this.dir, uri); 16 | const data = await fs.readFile(filename, 'utf8'); 17 | const config = JSON.parse(data); 18 | return config; 19 | } 20 | 21 | async save(uri: string, config: Object): Promise { 22 | await mkdirp(this.dir); 23 | const filename = path.join(this.dir, uri); 24 | await fs.writeFile(filename, JSON.stringify(config), 'utf8'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/util/new-system-account-with-airdrop.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import {Account, Connection} from '@solana/web3.js'; 4 | 5 | /** 6 | * Create a new system account and airdrop it some lamports 7 | * 8 | * @private 9 | */ 10 | export async function newSystemAccountWithAirdrop( 11 | connection: Connection, 12 | lamports: number = 1, 13 | ): Promise { 14 | const account = new Account(); 15 | await connection.requestAirdrop(account.publicKey, lamports); 16 | return account; 17 | } 18 | -------------------------------------------------------------------------------- /src/util/send-and-confirm-transaction.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import {sendAndConfirmTransaction as realSendAndConfirmTransaction} from '@solana/web3.js'; 4 | import type {Account, Connection, Transaction} from '@solana/web3.js'; 5 | import YAML from 'json-to-pretty-yaml'; 6 | 7 | import {newSystemAccountWithAirdrop} from './new-system-account-with-airdrop'; 8 | 9 | type TransactionNotification = (string, string) => void; 10 | 11 | let notify: TransactionNotification = () => undefined; 12 | 13 | export function onTransaction(callback: TransactionNotification) { 14 | notify = callback; 15 | } 16 | 17 | let payerAccount: Account | null = null; 18 | export async function sendAndConfirmTransaction( 19 | title: string, 20 | connection: Connection, 21 | transaction: Transaction, 22 | ...signers: Array 23 | ): Promise { 24 | const when = Date.now(); 25 | 26 | const {feeCalculator} = await connection.getRecentBlockhash(); 27 | const high_lamport_watermark = feeCalculator.lamportsPerSignature * 100; // wag 28 | const low_lamport_watermark = feeCalculator.lamportsPerSignature * 10; // enough to cover any transaction 29 | if (!payerAccount) { 30 | const newPayerAccount = await newSystemAccountWithAirdrop( 31 | connection, 32 | high_lamport_watermark, 33 | ); 34 | // eslint-disable-next-line require-atomic-updates 35 | payerAccount = payerAccount || newPayerAccount; 36 | } 37 | const payerBalance = await connection.getBalance(payerAccount.publicKey); 38 | // Top off payer if necessary 39 | if (payerBalance < low_lamport_watermark) { 40 | await connection.requestAirdrop( 41 | payerAccount.publicKey, 42 | high_lamport_watermark - payerBalance, 43 | ); 44 | } 45 | 46 | let signature; 47 | try { 48 | signature = await realSendAndConfirmTransaction( 49 | connection, 50 | transaction, 51 | payerAccount, 52 | ...signers, 53 | ); 54 | } catch (err) { 55 | // Transaction failed to confirm, it's possible the network restarted 56 | // eslint-disable-next-line require-atomic-updates 57 | payerAccount = null; 58 | throw err; 59 | } 60 | 61 | const body = { 62 | time: new Date(when).toString(), 63 | from: signers[0].publicKey.toBase58(), 64 | signature, 65 | instructions: transaction.instructions.map(i => { 66 | return { 67 | keys: i.keys.map(keyObj => keyObj.pubkey.toBase58()), 68 | programId: i.programId.toBase58(), 69 | data: '0x' + i.data.toString('hex'), 70 | }; 71 | }), 72 | }; 73 | 74 | notify(title, YAML.stringify(body).replace(/"/g, '')); 75 | } 76 | -------------------------------------------------------------------------------- /src/util/sleep.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // zzz 4 | export function sleep(ms: number): Promise { 5 | return new Promise(resolve => setTimeout(resolve, ms)); 6 | } 7 | -------------------------------------------------------------------------------- /src/webapp/Board.js: -------------------------------------------------------------------------------- 1 | /** 2 | * React component for a single board 3 | */ 4 | import React from 'react'; 5 | import {Button} from 'react-bootstrap'; 6 | import PropTypes from 'prop-types'; 7 | 8 | function Square(props) { 9 | const style = 10 | props.bsSize === 'large' 11 | ? {width: '70px', height: '70px', fontSize: '32px'} 12 | : {width: '35px', height: '35px'}; 13 | return ( 14 | 17 | ); 18 | } 19 | Square.propTypes = { 20 | bsSize: PropTypes.oneOf(['xsmall', 'large']), 21 | value: PropTypes.oneOf([' ', 'X', 'O']), 22 | onClick: PropTypes.func, 23 | disabled: PropTypes.boolean, 24 | }; 25 | 26 | export class Board extends React.Component { 27 | renderSquare(i: number) { 28 | return ( 29 | this.props.onClick(i)} 34 | /> 35 | ); 36 | } 37 | 38 | render() { 39 | return ( 40 |
41 |
42 | {this.renderSquare(0)} 43 | {this.renderSquare(1)} 44 | {this.renderSquare(2)} 45 |
46 |
47 | {this.renderSquare(3)} 48 | {this.renderSquare(4)} 49 | {this.renderSquare(5)} 50 |
51 |
52 | {this.renderSquare(6)} 53 | {this.renderSquare(7)} 54 | {this.renderSquare(8)} 55 |
56 |
57 | ); 58 | } 59 | } 60 | Board.propTypes = { 61 | bsSize: PropTypes.oneOf(['xsmall', 'large']), 62 | squares: PropTypes.arrayOf(PropTypes.oneOf([' ', 'X', 'O'])), 63 | onClick: PropTypes.func, 64 | disabled: PropTypes.boolean, 65 | }; 66 | -------------------------------------------------------------------------------- /src/webapp/Game.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Primary React component for the Game. 3 | */ 4 | 5 | import React from 'react'; 6 | import {Button, ButtonGroup, Panel, PanelGroup} from 'react-bootstrap'; 7 | import PropTypes from 'prop-types'; 8 | 9 | import {TicTacToe} from '../program/tic-tac-toe'; 10 | import {Board} from './Board'; 11 | import {onTransaction} from '../util/send-and-confirm-transaction'; 12 | 13 | export class Game extends React.Component { 14 | constructor(props) { 15 | super(props); 16 | this.recentGameState = {}; 17 | this.state = { 18 | currentGame: null, 19 | currentGameStatusMessage: '', 20 | pause: false, 21 | transactions: [], 22 | totalTransactions: 0, 23 | }; 24 | } 25 | 26 | onTransaction = (title: string, body: string) => { 27 | const {pause, transactions, totalTransactions} = this.state; 28 | 29 | if (pause) { 30 | return; 31 | } 32 | 33 | transactions.unshift({title, body}); 34 | while (transactions.length > 100) { 35 | transactions.pop(); 36 | } 37 | this.setState({ 38 | transactions, 39 | totalTransactions: totalTransactions + 1, 40 | }); 41 | }; 42 | 43 | componentDidMount() { 44 | const {dashboard} = this.props; 45 | onTransaction(this.onTransaction); 46 | dashboard.onChange(this.onDashboardChange); 47 | 48 | setTimeout(() => { 49 | this.startGame(); 50 | }); 51 | } 52 | 53 | componentWillUnmount() { 54 | const {dashboard} = this.props; 55 | dashboard.removeChangeListener(this.onDashboardChange); 56 | } 57 | 58 | async startGame() { 59 | const {dashboard} = this.props; 60 | let {currentGame} = this.state; 61 | 62 | if (currentGame) { 63 | currentGame.abandon(); 64 | currentGame.removeChangeListener(this.onGameChange); 65 | } 66 | 67 | this.setState({ 68 | currentGame: null, 69 | currentGameStatusMessage: '', 70 | }); 71 | 72 | try { 73 | this.setState({ 74 | currentGameStatusMessage: 'Waiting for another player to join...', 75 | }); 76 | currentGame = await dashboard.startGame(); 77 | this.setState({ 78 | currentGame, 79 | currentGameStatusMessage: 'Game has started', 80 | }); 81 | currentGame.onChange(this.onGameChange); 82 | this.onGameChange(); 83 | } catch (err) { 84 | console.log('Unable to start game:', err); 85 | this.setState({ 86 | currentGameStatusMessage: `Unable to start game: ${err.message}`, 87 | }); 88 | this.props.reconnect(); 89 | } 90 | } 91 | 92 | onGameChange = () => { 93 | console.log('onGameChange()...'); 94 | 95 | const {currentGame} = this.state; 96 | if (currentGame === null) { 97 | console.log('Warning: currentGame is not expected to be null'); 98 | return; 99 | } 100 | 101 | let currentGameStatusMessage; 102 | 103 | if (currentGame.inProgress) { 104 | if (currentGame.myTurn) { 105 | currentGameStatusMessage = `You are ${ 106 | currentGame.isX ? 'X' : 'O' 107 | }, make your move`; 108 | } else { 109 | currentGameStatusMessage = 'Waiting for opponent to move...'; 110 | } 111 | } else { 112 | currentGameStatusMessage = 'Game Over. '; 113 | if (currentGame.disconnected) { 114 | currentGameStatusMessage += 'Game was disconnected.'; 115 | } else if (currentGame.abandoned) { 116 | currentGameStatusMessage += 'Opponent abandoned the game.'; 117 | } else if (currentGame.winner) { 118 | currentGameStatusMessage += 'You won!'; 119 | } else if (currentGame.draw) { 120 | currentGameStatusMessage += 'Draw.'; 121 | } else { 122 | currentGameStatusMessage += 'You lost.'; 123 | } 124 | } 125 | 126 | this.setState({ 127 | currentGame, 128 | currentGameStatusMessage, 129 | }); 130 | }; 131 | 132 | onDashboardChange = async () => { 133 | console.log('onDashboardChange()...'); 134 | const {dashboard} = this.props; 135 | for (const [, gamePublicKey] of dashboard.state.completedGames.entries()) { 136 | if (!(gamePublicKey in this.recentGameState)) { 137 | try { 138 | this.recentGameState[gamePublicKey] = await TicTacToe.getGameState( 139 | dashboard.connection, 140 | gamePublicKey, 141 | ); 142 | } catch (err) { 143 | console.log(err); 144 | } 145 | } 146 | } 147 | 148 | this.setState({ 149 | totalGames: dashboard.state.totalGames, 150 | completedGames: dashboard.state.completedGames.map( 151 | key => this.recentGameState[key], 152 | ), 153 | }); 154 | }; 155 | 156 | async handleClick(i: number) { 157 | const x = Math.floor(i % 3); 158 | const y = Math.floor(i / 3); 159 | console.log('Move', x, y); 160 | 161 | const {currentGame} = this.state; 162 | try { 163 | // Update the UI immediately with the move for better user feedback, but 164 | // also send the move to the on-chain program for a final decision 165 | currentGame.state.board[i] = currentGame.isX ? 'X' : 'O'; 166 | this.setState({currentGame}); 167 | await currentGame.move(x, y); 168 | } catch (err) { 169 | console.log(`Unable to move to ${x}x${y}: ${err.message}`); 170 | } 171 | } 172 | 173 | render() { 174 | const {currentGame, currentGameStatusMessage} = this.state; 175 | 176 | /* 177 | const gameHistory = 178 | completedGames.length === 0 ? ( 179 | None 180 | ) : ( 181 | 182 | 183 | Total Games Played: {totalGames} 184 | 185 | 186 | 187 | {completedGames.map((game, i) => { 188 | let {gameState} = game; 189 | switch (game.gameState) { 190 | case 'OWon': 191 | gameState = 'O Won'; 192 | break; 193 | case 'XWon': 194 | gameState = 'X Won'; 195 | break; 196 | default: 197 | break; 198 | } 199 | const lastMove = new Date(Math.max(...game.keepAlive)); 200 | return ( 201 | 202 |
203 |

{gameState}

204 |

205 | 206 | {lastMove.getSeconds() === 0 207 | ? '' 208 | : moment(lastMove).fromNow()} 209 | 210 |

211 | undefined} 216 | /> 217 |
218 |
219 | ); 220 | })} 221 |
222 |
223 |
224 | ); 225 | */ 226 | 227 | let playAgain = null; 228 | if (currentGame && !currentGame.inProgress) { 229 | playAgain = ( 230 |
231 |
232 | 241 |
242 | ); 243 | } 244 | 245 | const transactions = this.state.transactions.map((tx, index) => { 246 | return ( 247 | 248 | 249 | {tx.title} 250 | 251 | 252 |
{tx.body}
253 |
254 |
255 | ); 256 | }); 257 | 258 | return ( 259 |
260 | 261 | 262 | Current Game 263 | 264 | 265 |
266 |

{currentGameStatusMessage}

267 | this.handleClick(i)} 274 | /> 275 |
276 |
277 | {playAgain} 278 |
279 |
280 | 281 | 282 | 283 | 284 | 288 | 300 |
285 | Transactions: {this.state.totalTransactions} 286 |   287 | 289 | 290 | 295 | 298 | 299 |
301 |
302 |
303 | 304 | 305 | {transactions} 306 | 307 | 308 |
309 |
310 | ); 311 | } 312 | } 313 | Game.propTypes = { 314 | dashboard: PropTypes.object, // TicTacToeDashboard 315 | reconnect: PropTypes.funciton, 316 | }; 317 | -------------------------------------------------------------------------------- /src/webapp/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Webapp entrypoint 3 | */ 4 | import fetch from 'node-fetch'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import {Well} from 'react-bootstrap'; 8 | import {Connection, Account} from '@solana/web3.js'; 9 | 10 | import {Game} from './Game'; 11 | import {TicTacToeDashboard} from '../program/tic-tac-toe-dashboard'; 12 | import {sleep} from '../util/sleep'; 13 | 14 | class App extends React.Component { 15 | state = { 16 | initialized: false, 17 | initMessage: '', 18 | }; 19 | 20 | initialize = async () => { 21 | await sleep(0); // Exit caller's stack frame 22 | 23 | let attempt = 0; 24 | let initMessage = 'Fetching configuration...'; 25 | 26 | for (;;) { 27 | let config; 28 | 29 | if (this.state.dashboard) { 30 | this.state.dashboard.disconnect(); 31 | } 32 | 33 | try { 34 | this.setState({ 35 | initialized: false, 36 | initMessage, 37 | }); 38 | 39 | // Load the dashboard account from the server so it remains the 40 | // same for all users 41 | const configUrl = window.location.origin + '/config.json'; 42 | const response = await fetch(configUrl); 43 | if (!response.ok) { 44 | throw new Error( 45 | `Config fetch request failed with code: ${response.status}`, 46 | ); 47 | } 48 | 49 | let jsonResponse = await response.json(); 50 | if (jsonResponse.creating) { 51 | initMessage = 'Waiting for app to initialize...'; 52 | await sleep(1000); 53 | continue; 54 | } else { 55 | config = jsonResponse; 56 | } 57 | } catch (err) { 58 | console.error(err); 59 | await sleep(1000); 60 | continue; 61 | } 62 | 63 | try { 64 | const url = config.url; 65 | const commitment = config.commitment; 66 | this.setState({ 67 | initMessage: 68 | `Connecting to ${url}... ` + (attempt === 0 ? '' : `(#${attempt})`), 69 | }); 70 | const connection = new Connection(url, commitment); 71 | await connection.getRecentBlockhash(); 72 | 73 | this.setState({initMessage: `Loading dashboard state...`}); 74 | const dashboard = await TicTacToeDashboard.connect( 75 | connection, 76 | new Account(Buffer.from(config.secretKey, 'hex')), 77 | ); 78 | 79 | this.setState({initialized: true, dashboard}); 80 | break; 81 | } catch (err) { 82 | console.log(err); 83 | await sleep(1000); 84 | attempt++; 85 | } 86 | } 87 | }; 88 | 89 | componentDidMount() { 90 | this.initialize(); 91 | } 92 | 93 | render() { 94 | if (!this.state.initialized) { 95 | return {this.state.initMessage}; 96 | } 97 | return ( 98 | 99 | ); 100 | } 101 | } 102 | 103 | ReactDOM.render(, document.getElementById('app')); 104 | module.hot.accept(); 105 | -------------------------------------------------------------------------------- /url.js: -------------------------------------------------------------------------------- 1 | // To connect to a public cluster, set `export LIVE=1` in your 2 | // environment. By default, `LIVE=1` will connect to the devnet cluster. 3 | 4 | import {clusterApiUrl, Cluster} from '@solana/web3.js'; 5 | import dotenv from 'dotenv'; 6 | 7 | function chooseCluster(): Cluster | undefined { 8 | dotenv.config(); 9 | if (!process.env.LIVE) return; 10 | switch (process.env.CLUSTER) { 11 | case 'devnet': 12 | case 'testnet': 13 | case 'mainnet-beta': { 14 | return process.env.CLUSTER; 15 | } 16 | } 17 | throw 'Unknown cluster "' + process.env.CLUSTER + '", check the .env file'; 18 | } 19 | 20 | export const cluster = chooseCluster(); 21 | 22 | export const url = 23 | process.env.RPC_URL || 24 | (process.env.LIVE ? clusterApiUrl(cluster, false) : 'http://localhost:8899'); 25 | 26 | export const urlTls = 27 | process.env.RPC_URL || 28 | (process.env.LIVE ? clusterApiUrl(cluster, true) : 'http://localhost:8899'); 29 | 30 | export let walletUrl = 31 | process.env.WALLET_URL || 'https://solana-example-webwallet.herokuapp.com/'; 32 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint import/no-commonjs:0 */ 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: './src/webapp/index.js', 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.(js|jsx)$/, 10 | exclude: /node_modules/, 11 | use: ['babel-loader'], 12 | }, 13 | { 14 | test: /\.js$/, 15 | exclude: /node_modules/, 16 | use: ['babel-loader', 'eslint-loader'], 17 | }, 18 | { 19 | test: /\.css$/, 20 | use: ['style-loader', 'css-loader'], 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | extensions: ['*', '.js', '.jsx'], 26 | }, 27 | node: { 28 | fs: 'empty', 29 | }, 30 | output: { 31 | path: __dirname + '/dist', 32 | publicPath: '/', 33 | filename: 'bundle.js', 34 | }, 35 | plugins: [ 36 | new webpack.HotModuleReplacementPlugin(), 37 | new webpack.DefinePlugin({ 38 | 'process.env': { 39 | LIVE: JSON.stringify(process.env.LIVE), 40 | }, 41 | }), 42 | ], 43 | devServer: { 44 | disableHostCheck: true, 45 | contentBase: './dist', 46 | hot: true, 47 | host: '0.0.0.0', 48 | historyApiFallback: { 49 | index: 'index.html', 50 | }, 51 | }, 52 | }; 53 | --------------------------------------------------------------------------------