├── .github └── workflows │ └── mkdist.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── config ├── challenge_shield │ ├── params.yaml │ └── teams.yaml ├── champions_cup │ ├── params.yaml │ └── teams.yaml ├── most_passes_leaderboard │ ├── params.yaml │ └── teams.yaml └── teams.yaml ├── dist ├── .gitignore ├── mkdist-linux ├── mkdist-macos └── mkdist-windows.ps1 ├── frontend ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── package-lock.json ├── package.json ├── src │ ├── actions.js │ ├── api.js │ ├── components │ │ ├── Index.jsx │ │ ├── Launcher.jsx │ │ ├── Main.jsx │ │ ├── launcher │ │ │ ├── CompetitionSettings.jsx │ │ │ ├── GameSettings.jsx │ │ │ ├── NetworkSettings.jsx │ │ │ ├── TeamColorSelector.jsx │ │ │ ├── TeamSelector.jsx │ │ │ ├── TeamSettings.jsx │ │ │ ├── TestSettings.jsx │ │ │ └── WindowSettings.jsx │ │ └── main │ │ │ ├── ActionButton.jsx │ │ │ ├── CenterPanel.jsx │ │ │ ├── ClockPanel.jsx │ │ │ ├── PenaltyButton.jsx │ │ │ ├── PenaltyPanel.jsx │ │ │ ├── PlayerButton.jsx │ │ │ ├── StatePanel.jsx │ │ │ ├── TeamPanel.jsx │ │ │ └── UndoPanel.jsx │ ├── index.jsx │ ├── style.css │ └── utils.js └── webpack.config.js ├── game_controller_app ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── handlers.rs │ └── main.rs └── tauri.conf.json ├── game_controller_core ├── Cargo.toml └── src │ ├── action.rs │ ├── actions │ ├── add_extra_time.rs │ ├── finish_half.rs │ ├── finish_penalty_shot.rs │ ├── finish_set_play.rs │ ├── free_penalty_shot.rs │ ├── free_set_play.rs │ ├── global_game_stuck.rs │ ├── goal.rs │ ├── mod.rs │ ├── penalize.rs │ ├── select_penalty_shot_player.rs │ ├── start_penalty_shootout.rs │ ├── start_set_play.rs │ ├── substitute.rs │ ├── switch_half.rs │ ├── switch_team_mode.rs │ ├── team_message.rs │ ├── timeout.rs │ ├── undo.rs │ ├── unpenalize.rs │ ├── wait_for_penalty_shot.rs │ ├── wait_for_ready.rs │ └── wait_for_set_play.rs │ ├── lib.rs │ ├── log.rs │ ├── timer.rs │ └── types.rs ├── game_controller_logs ├── Cargo.toml └── src │ ├── lib.rs │ ├── main.rs │ ├── statistics.rs │ └── team_communication.rs ├── game_controller_msgs ├── Cargo.toml ├── build.rs ├── headers │ ├── RoboCupGameControlData.h │ └── bindings.h └── src │ ├── bindings.rs │ ├── control_message.rs │ ├── lib.rs │ ├── monitor_request.rs │ └── status_message.rs ├── game_controller_net ├── Cargo.toml └── src │ ├── control_message_sender.rs │ ├── lib.rs │ ├── monitor_request_receiver.rs │ ├── status_message_forwarder.rs │ ├── status_message_receiver.rs │ ├── team_message_receiver.rs │ └── workaround.rs └── game_controller_runtime ├── Cargo.toml └── src ├── cli.rs ├── connection_status.rs ├── launch.rs ├── lib.rs └── logger.rs /.github/workflows/mkdist.yml: -------------------------------------------------------------------------------- 1 | name: Make Distribution 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | workflow_dispatch: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | strategy: 15 | matrix: 16 | os: 17 | - macos-latest 18 | - ubuntu-20.04 19 | - windows-latest 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v3 23 | - if: ${{ matrix.os == 'macos-latest' }} 24 | run: | 25 | rustup target add aarch64-apple-darwin x86_64-apple-darwin 26 | dist/mkdist-macos ${{ github.ref_name }} 27 | - if: ${{ matrix.os == 'ubuntu-20.04' }} 28 | run: | 29 | sudo apt-get update -y 30 | sudo apt-get install -y libclang-dev libgtk-3-dev libwebkit2gtk-4.0-dev librsvg2-dev 31 | dist/mkdist-linux ${{ github.ref_name }} 32 | - if: ${{ matrix.os == 'windows-latest' }} 33 | run: | 34 | dist/mkdist-windows.ps1 ${{ github.ref_name }} 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: dist-${{ matrix.os }} 38 | path: | 39 | dist/*.dmg 40 | dist/*.tar.bz2 41 | dist/*.zip 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | /logs 3 | /target 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | default-members = ["game_controller_app"] 3 | members = [ 4 | "game_controller_app", 5 | "game_controller_core", 6 | "game_controller_msgs", 7 | "game_controller_logs", 8 | "game_controller_net", 9 | "game_controller_runtime", 10 | ] 11 | resolver = "2" 12 | 13 | [workspace.dependencies] 14 | anyhow = { version = "1.0" } 15 | bindgen = { version = "0.69" } 16 | bytes = { version = "1.0" } 17 | clap = { version = "4.2", features = ["derive"] } 18 | enum-map = { version = "2.7", features = ["serde"] } 19 | game_controller_core = { path = "game_controller_core" } 20 | game_controller_msgs = { path = "game_controller_msgs" } 21 | game_controller_net = { path = "game_controller_net" } 22 | game_controller_runtime = { path = "game_controller_runtime" } 23 | network-interface = { version = "1" } 24 | serde = { version = "1.0", features = ["derive"] } 25 | serde_with = { version = "2.3", features = ["base64", "time_0_3"] } 26 | serde_repr = { version = "0.1" } 27 | serde_yaml = { version = "0.9" } 28 | socket2 = { version = "0.5", features = ["all"] } 29 | tauri = { version = "1.6", features = [] } 30 | tauri-build = { version = "1.5", features = [] } 31 | time = { version = "0.3", features = ["formatting", "local-offset", "macros", "serde"] } 32 | tokio = { version = "1.0", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "sync", "time"] } 33 | tokio-util = { version = "0.7" } 34 | trait_enum = { version = "0.5" } 35 | 36 | [workspace.package] 37 | authors = ["Arne Hasselbring "] 38 | edition = "2021" 39 | license = "MIT" 40 | repository = "https://github.com/RoboCup-SPL/GameController3" 41 | rust-version = "1.82" 42 | version = "4.0.0-rc.1" 43 | 44 | [profile.release-dist] 45 | inherits = "release" 46 | lto = true 47 | opt-level = "s" 48 | panic = "abort" 49 | strip = "symbols" 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Arne Hasselbring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/challenge_shield/params.yaml: -------------------------------------------------------------------------------- 1 | name: "Challenge Shield" 2 | playersPerTeam: 5 3 | playersPerTeamFallbackMode: 2 4 | penalties: 5 | noPenalty: 6 | duration: 7 | secs: 0 8 | nanos: 0 9 | incremental: false 10 | substitute: 11 | duration: 12 | secs: 0 13 | nanos: 0 14 | incremental: false 15 | pickedUp: 16 | duration: 17 | secs: 45 18 | nanos: 0 19 | incremental: false 20 | illegalPositionInSet: 21 | duration: 22 | secs: 15 23 | nanos: 0 24 | incremental: false 25 | illegalPosition: 26 | duration: 27 | secs: 45 28 | nanos: 0 29 | incremental: true 30 | motionInStandby: 31 | duration: 32 | secs: 35 33 | nanos: 0 34 | incremental: false 35 | motionInSet: 36 | duration: 37 | secs: 15 38 | nanos: 0 39 | incremental: false 40 | fallenInactive: 41 | duration: 42 | secs: 45 43 | nanos: 0 44 | incremental: false 45 | localGameStuck: 46 | duration: 47 | secs: 45 48 | nanos: 0 49 | incremental: false 50 | ballHolding: 51 | duration: 52 | secs: 45 53 | nanos: 0 54 | incremental: true 55 | playerStance: 56 | duration: 57 | secs: 45 58 | nanos: 0 59 | incremental: true 60 | playerPushing: 61 | duration: 62 | secs: 45 63 | nanos: 0 64 | incremental: true 65 | playingWithArmsHands: 66 | duration: 67 | secs: 45 68 | nanos: 0 69 | incremental: true 70 | leavingTheField: 71 | duration: 72 | secs: 45 73 | nanos: 0 74 | incremental: true 75 | penaltyDurationIncrement: 76 | secs: 10 77 | nanos: 0 78 | setPlays: 79 | noSetPlay: 80 | duration: 81 | secs: 0 82 | nanos: 0 83 | readyDuration: 84 | secs: 0 85 | nanos: 0 86 | kickOff: 87 | duration: 88 | secs: 10 89 | nanos: 0 90 | readyDuration: 91 | secs: 45 92 | nanos: 0 93 | kickIn: 94 | duration: 95 | secs: 30 96 | nanos: 0 97 | readyDuration: 98 | secs: 0 99 | nanos: 0 100 | goalKick: 101 | duration: 102 | secs: 30 103 | nanos: 0 104 | readyDuration: 105 | secs: 0 106 | nanos: 0 107 | cornerKick: 108 | duration: 109 | secs: 30 110 | nanos: 0 111 | readyDuration: 112 | secs: 0 113 | nanos: 0 114 | pushingFreeKick: 115 | duration: 116 | secs: 30 117 | nanos: 0 118 | readyDuration: 119 | secs: 0 120 | nanos: 0 121 | penaltyKick: 122 | duration: 123 | secs: 30 124 | nanos: 0 125 | readyDuration: 126 | secs: 30 127 | nanos: 0 128 | halfDuration: 129 | secs: 600 130 | nanos: 0 131 | halfTimeBreakDuration: 132 | secs: 600 133 | nanos: 0 134 | timeoutDuration: 135 | secs: 300 136 | nanos: 0 137 | timeoutsPerTeam: 1 138 | refereeTimeoutDuration: 139 | secs: 600 140 | nanos: 0 141 | messagesPerTeam: 1200 142 | messagesPerTeamPerExtraMinute: 60 143 | mercyRuleScoreDifference: 10 144 | penaltyShots: 3 145 | suddenDeathPenaltyShots: 3 146 | penaltyShotDuration: 147 | secs: 30 148 | nanos: 0 149 | delayAfterGoal: 150 | secs: 15 151 | nanos: 0 152 | delayAfterPlaying: 153 | secs: 15 154 | nanos: 0 155 | delayAfterReady: 156 | secs: 35 157 | nanos: 0 158 | hideKickingSide: false 159 | -------------------------------------------------------------------------------- /config/challenge_shield/teams.yaml: -------------------------------------------------------------------------------- 1 | - 0 2 | - 8 # Dutch Nao Team 3 | - 31 # UnBeatables 4 | - 33 # NomadZ 5 | - 45 # Naova 6 | - 47 # Rinobot-Jaguar 7 | - 51 # RedBackBots 8 | - 53 # RoboIME 9 | - 55 # AI Rams 10 | - 70 # B-Team 11 | -------------------------------------------------------------------------------- /config/champions_cup/params.yaml: -------------------------------------------------------------------------------- 1 | name: "Champions Cup" 2 | playersPerTeam: 7 3 | penalties: 4 | noPenalty: 5 | duration: 6 | secs: 0 7 | nanos: 0 8 | incremental: false 9 | substitute: 10 | duration: 11 | secs: 0 12 | nanos: 0 13 | incremental: false 14 | pickedUp: 15 | duration: 16 | secs: 45 17 | nanos: 0 18 | incremental: false 19 | illegalPositionInSet: 20 | duration: 21 | secs: 15 22 | nanos: 0 23 | incremental: false 24 | illegalPosition: 25 | duration: 26 | secs: 45 27 | nanos: 0 28 | incremental: true 29 | motionInStandby: 30 | duration: 31 | secs: 45 32 | nanos: 0 33 | incremental: false 34 | motionInSet: 35 | duration: 36 | secs: 15 37 | nanos: 0 38 | incremental: false 39 | fallenInactive: 40 | duration: 41 | secs: 45 42 | nanos: 0 43 | incremental: false 44 | localGameStuck: 45 | duration: 46 | secs: 45 47 | nanos: 0 48 | incremental: false 49 | ballHolding: 50 | duration: 51 | secs: 45 52 | nanos: 0 53 | incremental: true 54 | playerStance: 55 | duration: 56 | secs: 45 57 | nanos: 0 58 | incremental: true 59 | playerPushing: 60 | duration: 61 | secs: 45 62 | nanos: 0 63 | incremental: true 64 | playingWithArmsHands: 65 | duration: 66 | secs: 45 67 | nanos: 0 68 | incremental: true 69 | leavingTheField: 70 | duration: 71 | secs: 45 72 | nanos: 0 73 | incremental: true 74 | penaltyDurationIncrement: 75 | secs: 10 76 | nanos: 0 77 | setPlays: 78 | noSetPlay: 79 | duration: 80 | secs: 0 81 | nanos: 0 82 | readyDuration: 83 | secs: 0 84 | nanos: 0 85 | kickOff: 86 | duration: 87 | secs: 10 88 | nanos: 0 89 | readyDuration: 90 | secs: 45 91 | nanos: 0 92 | kickIn: 93 | duration: 94 | secs: 30 95 | nanos: 0 96 | readyDuration: 97 | secs: 0 98 | nanos: 0 99 | goalKick: 100 | duration: 101 | secs: 30 102 | nanos: 0 103 | readyDuration: 104 | secs: 0 105 | nanos: 0 106 | cornerKick: 107 | duration: 108 | secs: 30 109 | nanos: 0 110 | readyDuration: 111 | secs: 0 112 | nanos: 0 113 | pushingFreeKick: 114 | duration: 115 | secs: 30 116 | nanos: 0 117 | readyDuration: 118 | secs: 0 119 | nanos: 0 120 | penaltyKick: 121 | duration: 122 | secs: 30 123 | nanos: 0 124 | readyDuration: 125 | secs: 30 126 | nanos: 0 127 | halfDuration: 128 | secs: 600 129 | nanos: 0 130 | halfTimeBreakDuration: 131 | secs: 600 132 | nanos: 0 133 | timeoutDuration: 134 | secs: 300 135 | nanos: 0 136 | timeoutsPerTeam: 1 137 | refereeTimeoutDuration: 138 | secs: 600 139 | nanos: 0 140 | messagesPerTeam: 1200 141 | messagesPerTeamPerExtraMinute: 60 142 | mercyRuleScoreDifference: 10 143 | penaltyShots: 3 144 | suddenDeathPenaltyShots: 3 145 | penaltyShotDuration: 146 | secs: 30 147 | nanos: 0 148 | delayAfterGoal: 149 | secs: 15 150 | nanos: 0 151 | delayAfterPlaying: 152 | secs: 15 153 | nanos: 0 154 | delayAfterReady: 155 | secs: 45 156 | nanos: 0 157 | hideKickingSide: true 158 | -------------------------------------------------------------------------------- /config/champions_cup/teams.yaml: -------------------------------------------------------------------------------- 1 | - 0 2 | - 3 # Bembelbots 3 | - 4 # Berlin United 4 | - 5 # B-Human 5 | - 12 # Nao Devils 6 | - 13 # HTWK Robots 7 | - 17 # RoboEireann 8 | - 18 # rUNSWift 9 | - 19 # SPQR Team 10 | - 24 # HULKs 11 | - 54 # WisTex United 12 | - 70 # B-Team 13 | -------------------------------------------------------------------------------- /config/most_passes_leaderboard/params.yaml: -------------------------------------------------------------------------------- 1 | name: "Most Passes Leaderboard" 2 | challengeMode: mostPassesLeaderboard 3 | playersPerTeam: 2 4 | penalties: 5 | noPenalty: 6 | duration: 7 | secs: 0 8 | nanos: 0 9 | incremental: false 10 | substitute: 11 | duration: 12 | secs: 0 13 | nanos: 0 14 | incremental: false 15 | pickedUp: 16 | duration: 17 | secs: 45 18 | nanos: 0 19 | incremental: false 20 | illegalPositionInSet: 21 | duration: 22 | secs: 15 23 | nanos: 0 24 | incremental: false 25 | illegalPosition: 26 | duration: 27 | secs: 45 28 | nanos: 0 29 | incremental: true 30 | motionInStandby: 31 | duration: 32 | secs: 45 33 | nanos: 0 34 | incremental: false 35 | motionInSet: 36 | duration: 37 | secs: 15 38 | nanos: 0 39 | incremental: false 40 | fallenInactive: 41 | duration: 42 | secs: 45 43 | nanos: 0 44 | incremental: false 45 | localGameStuck: 46 | duration: 47 | secs: 45 48 | nanos: 0 49 | incremental: false 50 | ballHolding: 51 | duration: 52 | secs: 45 53 | nanos: 0 54 | incremental: true 55 | playerStance: 56 | duration: 57 | secs: 45 58 | nanos: 0 59 | incremental: true 60 | playerPushing: 61 | duration: 62 | secs: 45 63 | nanos: 0 64 | incremental: true 65 | playingWithArmsHands: 66 | duration: 67 | secs: 45 68 | nanos: 0 69 | incremental: true 70 | leavingTheField: 71 | duration: 72 | secs: 45 73 | nanos: 0 74 | incremental: true 75 | penaltyDurationIncrement: 76 | secs: 10 77 | nanos: 0 78 | setPlays: 79 | noSetPlay: 80 | duration: 81 | secs: 0 82 | nanos: 0 83 | readyDuration: 84 | secs: 0 85 | nanos: 0 86 | kickOff: 87 | duration: 88 | secs: 0 89 | nanos: 0 90 | readyDuration: 91 | secs: 0 92 | nanos: 1 93 | kickIn: 94 | duration: 95 | secs: 30 96 | nanos: 0 97 | readyDuration: 98 | secs: 0 99 | nanos: 0 100 | goalKick: 101 | duration: 102 | secs: 30 103 | nanos: 0 104 | readyDuration: 105 | secs: 0 106 | nanos: 0 107 | cornerKick: 108 | duration: 109 | secs: 30 110 | nanos: 0 111 | readyDuration: 112 | secs: 0 113 | nanos: 0 114 | pushingFreeKick: 115 | duration: 116 | secs: 30 117 | nanos: 0 118 | readyDuration: 119 | secs: 0 120 | nanos: 0 121 | penaltyKick: 122 | duration: 123 | secs: 30 124 | nanos: 0 125 | readyDuration: 126 | secs: 30 127 | nanos: 0 128 | halfDuration: 129 | secs: 180 130 | nanos: 0 131 | halfTimeBreakDuration: 132 | secs: 600 133 | nanos: 0 134 | timeoutDuration: 135 | secs: 300 136 | nanos: 0 137 | timeoutsPerTeam: 1 138 | refereeTimeoutDuration: 139 | secs: 600 140 | nanos: 0 141 | messagesPerTeam: 1200 142 | messagesPerTeamPerExtraMinute: 60 143 | mercyRuleScoreDifference: 10 144 | penaltyShots: 3 145 | suddenDeathPenaltyShots: 3 146 | penaltyShotDuration: 147 | secs: 30 148 | nanos: 0 149 | delayAfterGoal: 150 | secs: 15 151 | nanos: 0 152 | delayAfterPlaying: 153 | secs: 0 154 | nanos: 0 155 | delayAfterReady: 156 | secs: 0 157 | nanos: 0 158 | hideKickingSide: false 159 | -------------------------------------------------------------------------------- /config/most_passes_leaderboard/teams.yaml: -------------------------------------------------------------------------------- 1 | - 0 2 | - 3 # Bembelbots 3 | - 4 # Berlin United 4 | - 5 # B-Human 5 | - 8 # Dutch Nao Team 6 | - 12 # Nao Devils 7 | - 13 # HTWK Robots 8 | - 17 # RoboEireann 9 | - 18 # rUNSWift 10 | - 19 # SPQR Team 11 | - 24 # HULKs 12 | - 31 # UnBeatables 13 | - 33 # NomadZ 14 | - 45 # Naova 15 | - 47 # Rinobot-Jaguar 16 | - 51 # RedBackBots 17 | - 53 # RoboIME 18 | - 54 # WisTex United 19 | - 55 # AI Rams 20 | - 70 # B-Team 21 | -------------------------------------------------------------------------------- /config/teams.yaml: -------------------------------------------------------------------------------- 1 | - name: Invisibles 2 | number: 0 3 | fieldPlayerColors: [red, blue, yellow, black, white, green, orange, purple, brown, gray] 4 | goalkeeperColors: [blue, yellow, black, white, green, orange, purple, brown, gray, red] 5 | 6 | - name: UT Austin Villa 7 | number: 1 8 | fieldPlayerColors: [white, orange] 9 | goalkeeperColors: [orange, white, blue, red] 10 | 11 | - name: Austrian Kangaroos 12 | number: 2 13 | fieldPlayerColors: [blue, red] 14 | goalkeeperColors: [blue, red] 15 | 16 | - name: Bembelbots 17 | number: 3 18 | fieldPlayerColors: [gray, black, red] 19 | goalkeeperColors: [red, gray, black] 20 | 21 | - name: Berlin United 22 | number: 4 23 | fieldPlayerColors: [blue, purple] 24 | goalkeeperColors: [white, blue, purple] 25 | 26 | - name: B-Human 27 | number: 5 28 | fieldPlayerColors: [black, red] 29 | goalkeeperColors: [purple, blue, black, red] 30 | 31 | - name: Cerberus 32 | number: 6 33 | fieldPlayerColors: [blue, red] 34 | goalkeeperColors: [blue, red] 35 | 36 | - name: DAInamite 37 | number: 7 38 | fieldPlayerColors: [red, blue] 39 | goalkeeperColors: [red, blue] 40 | 41 | - name: Dutch Nao Team 42 | number: 8 43 | fieldPlayerColors: [orange, blue] 44 | goalkeeperColors: [blue, red, orange] 45 | 46 | - name: Edinferno 47 | number: 9 48 | fieldPlayerColors: [blue, red] 49 | goalkeeperColors: [blue, red] 50 | 51 | - name: Kouretes 52 | number: 10 53 | fieldPlayerColors: [blue, red] 54 | goalkeeperColors: [blue, red] 55 | 56 | - name: MiPal 57 | number: 11 58 | fieldPlayerColors: [red, blue] 59 | goalkeeperColors: [red, blue] 60 | 61 | - name: Nao Devils 62 | number: 12 63 | fieldPlayerColors: [yellow, black] 64 | goalkeeperColors: [red, yellow, black] 65 | 66 | - name: HTWK Robots 67 | number: 13 68 | fieldPlayerColors: [blue, yellow] 69 | goalkeeperColors: [green, blue, yellow] 70 | 71 | - name: Northern Bites 72 | number: 14 73 | fieldPlayerColors: [black, white] 74 | goalkeeperColors: [black, white] 75 | 76 | - name: NTU RoboPAL 77 | number: 15 78 | fieldPlayerColors: [red, yellow] 79 | goalkeeperColors: [red, yellow] 80 | 81 | - name: RoboCanes 82 | number: 16 83 | fieldPlayerColors: [blue, red] 84 | goalkeeperColors: [blue, red] 85 | 86 | - name: RoboEireann 87 | number: 17 88 | fieldPlayerColors: [green, white] 89 | goalkeeperColors: [white, green, red, blue] 90 | 91 | - name: rUNSWift 92 | number: 18 93 | fieldPlayerColors: [yellow, blue] 94 | goalkeeperColors: [green, red, yellow, blue] 95 | 96 | - name: SPQR Team 97 | number: 19 98 | fieldPlayerColors: [red, blue] 99 | goalkeeperColors: [black, red, blue] 100 | 101 | - name: TJArk 102 | number: 20 103 | fieldPlayerColors: [gray, red] 104 | goalkeeperColors: [gray, red] 105 | 106 | - name: UChile Robotics Team 107 | number: 21 108 | fieldPlayerColors: [blue, red] 109 | goalkeeperColors: [blue, red] 110 | 111 | - name: UPennalizers 112 | number: 22 113 | fieldPlayerColors: [blue, red] 114 | goalkeeperColors: [blue, red] 115 | 116 | - name: Crude Scientists 117 | number: 23 118 | fieldPlayerColors: [blue, red] 119 | goalkeeperColors: [blue, red] 120 | 121 | - name: HULKs 122 | number: 24 123 | fieldPlayerColors: [gray, red, green] 124 | goalkeeperColors: [gray, red, green] 125 | 126 | - name: MRL-SPL 127 | number: 26 128 | fieldPlayerColors: [blue, red] 129 | goalkeeperColors: [blue, red] 130 | 131 | - name: Philosopher 132 | number: 27 133 | fieldPlayerColors: [white, black] 134 | goalkeeperColors: [white, black] 135 | 136 | - name: Rimal Team 137 | number: 28 138 | fieldPlayerColors: [blue, red] 139 | goalkeeperColors: [blue, red] 140 | 141 | - name: SpelBots 142 | number: 29 143 | fieldPlayerColors: [blue, red] 144 | goalkeeperColors: [blue, red] 145 | 146 | - name: Team-NUST 147 | number: 30 148 | fieldPlayerColors: [blue, yellow] 149 | goalkeeperColors: [blue, yellow] 150 | 151 | - name: UnBeatables 152 | number: 31 153 | fieldPlayerColors: [yellow, blue] 154 | goalkeeperColors: [yellow, blue] 155 | 156 | - name: UTH-CAR 157 | number: 32 158 | fieldPlayerColors: [blue, red] 159 | goalkeeperColors: [blue, red] 160 | 161 | - name: NomadZ 162 | number: 33 163 | fieldPlayerColors: [red, blue] 164 | goalkeeperColors: [orange, black, red, blue] 165 | 166 | - name: SPURT 167 | number: 34 168 | fieldPlayerColors: [blue, red] 169 | goalkeeperColors: [blue, red] 170 | 171 | - name: Blue Spider 172 | number: 35 173 | fieldPlayerColors: [blue, red] 174 | goalkeeperColors: [blue, red] 175 | 176 | - name: Camellia Dragons 177 | number: 36 178 | fieldPlayerColors: [purple, yellow] 179 | goalkeeperColors: [purple, yellow] 180 | 181 | - name: JoiTech-SPL 182 | number: 37 183 | fieldPlayerColors: [blue, red] 184 | goalkeeperColors: [blue, red] 185 | 186 | - name: Linköping Humanoids 187 | number: 38 188 | fieldPlayerColors: [blue, orange] 189 | goalkeeperColors: [blue, orange] 190 | 191 | - name: WrightOcean 192 | number: 39 193 | fieldPlayerColors: [blue, red] 194 | goalkeeperColors: [blue, red] 195 | 196 | - name: Mars 197 | number: 40 198 | fieldPlayerColors: [blue, red] 199 | goalkeeperColors: [blue, red] 200 | 201 | - name: Aztlan 202 | number: 41 203 | fieldPlayerColors: [blue, red] 204 | goalkeeperColors: [blue, red] 205 | 206 | - name: CMSingle 207 | number: 42 208 | fieldPlayerColors: [blue, red] 209 | goalkeeperColors: [blue, red] 210 | 211 | - name: TeamSP 212 | number: 43 213 | fieldPlayerColors: [blue, red] 214 | goalkeeperColors: [blue, red] 215 | 216 | - name: Luxembourg United 217 | number: 44 218 | fieldPlayerColors: [blue, red] 219 | goalkeeperColors: [blue, red] 220 | 221 | - name: Naova 222 | number: 45 223 | fieldPlayerColors: [black, red] 224 | goalkeeperColors: [orange, black, red, green] 225 | 226 | - name: Recife Soccer 227 | number: 46 228 | fieldPlayerColors: [blue, red] 229 | goalkeeperColors: [blue, red] 230 | 231 | - name: Rinobot-Jaguar 232 | number: 47 233 | fieldPlayerColors: [black, red] 234 | goalkeeperColors: [yellow, blue] 235 | 236 | - name: Starkit 237 | number: 48 238 | fieldPlayerColors: [red, blue] 239 | goalkeeperColors: [red, blue] 240 | 241 | - name: SABANA Herons 242 | number: 49 243 | fieldPlayerColors: [blue, red] 244 | goalkeeperColors: [red, black, green] 245 | 246 | - name: R-ZWEI KICKERS 247 | number: 50 248 | fieldPlayerColors: [red, blue] 249 | goalkeeperColors: [black, red, blue] 250 | 251 | - name: RedBackBots 252 | number: 51 253 | fieldPlayerColors: [black, red] 254 | goalkeeperColors: [red, black, blue] 255 | 256 | - name: BadgerBots 257 | number: 52 258 | fieldPlayerColors: [white, red] 259 | goalkeeperColors: [black, white, red] 260 | 261 | - name: RoboIME 262 | number: 53 263 | fieldPlayerColors: [blue, red] 264 | goalkeeperColors: [blue, red] 265 | 266 | - name: WisTex United 267 | number: 54 268 | fieldPlayerColors: [red, orange] 269 | goalkeeperColors: [black, white, red, orange] 270 | 271 | - name: AI Rams 272 | number: 55 273 | fieldPlayerColors: [blue, red] 274 | goalkeeperColors: [blue, red] 275 | 276 | - name: B-Team 277 | number: 70 278 | fieldPlayerColors: [red, blue, yellow, black, white, green, orange, purple, brown, gray] 279 | goalkeeperColors: [blue, yellow, black, white, green, orange, purple, brown, gray, red] 280 | -------------------------------------------------------------------------------- /dist/.gitignore: -------------------------------------------------------------------------------- 1 | /game_controller-* 2 | /GameController-* 3 | -------------------------------------------------------------------------------- /dist/mkdist-linux: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | version="$(sed -e "s/^v\(.*\)/\1/" <<<${1})" 6 | target=${2:-"$(rustc -vV | sed -n "s/^host: \(.*\)/\1/p")"} 7 | profile=release-dist 8 | 9 | if [ -z ${version} ]; then 10 | >&2 echo "usage: ${0} []" 11 | exit 1 12 | fi 13 | 14 | basedir="$(cd "$(dirname "$(which "${0}")")" && pwd)/.." 15 | archivedir="${basedir}/dist/game_controller-${version}-${target}" 16 | archive="${basedir}/dist/game_controller-${version}-${target}.tar.bz2" 17 | 18 | rm -rf "${archivedir}" 19 | 20 | pushd "${basedir}/frontend" 21 | npm ci 22 | npm run build 23 | popd 24 | 25 | pushd "${basedir}" 26 | cargo build --target "${target}" --profile "${profile}" --package game_controller_app 27 | popd 28 | 29 | mkdir -p "${archivedir}/target/release" 30 | cp "${basedir}/LICENSE" "${archivedir}" 31 | cp "${basedir}/README.md" "${archivedir}" 32 | cp -r "${basedir}/config" "${archivedir}" 33 | cp "${basedir}/target/${target}/${profile}/game_controller_app" "${archivedir}/target/release" 34 | cat >"${archivedir}/game_controller" <&2 echo "usage: ${0} []" 11 | exit 1 12 | fi 13 | 14 | basedir="$(cd "$(dirname "$(which "${0}")")" && pwd)/.." 15 | if [ -z ${target} ]; then 16 | universal=true 17 | templatedir="${basedir}/dist/GameController-${version}" 18 | archpref=" 19 | LSArchitecturePriority 20 | 21 | arm64 22 | x86_64 23 | " 24 | else 25 | universal=false 26 | templatedir="${basedir}/dist/GameController-${version}-${target}" 27 | archpref= 28 | fi 29 | appdir="${templatedir}/GameController.app" 30 | dmg="${templatedir}.dmg" 31 | 32 | rm -rf "${appdir}" 33 | rm -f "${dmg}" 34 | 35 | pushd "${basedir}/frontend" 36 | npm ci 37 | npm run build 38 | popd 39 | 40 | pushd "${basedir}" 41 | if ${universal}; then 42 | cargo build --target aarch64-apple-darwin --profile "${profile}" --package game_controller_app 43 | cargo build --target x86_64-apple-darwin --profile "${profile}" --package game_controller_app 44 | else 45 | cargo build --target "${target}" --profile "${profile}" --package game_controller_app 46 | fi 47 | popd 48 | 49 | mkdir -p "${appdir}/Contents" 50 | 51 | cp "${basedir}/LICENSE" "${appdir}/Contents" 52 | cp "${basedir}/README.md" "${appdir}/Contents" 53 | cp -r "${basedir}/config" "${appdir}/Contents" 54 | ln -s /tmp "${appdir}/Contents/logs" 55 | mkdir -p "${appdir}/Contents/target/release" 56 | if ${universal}; then 57 | lipo -create -output "${appdir}/Contents/target/release/game_controller_app" "${basedir}/target/aarch64-apple-darwin/${profile}/game_controller_app" "${basedir}/target/x86_64-apple-darwin/${profile}/game_controller_app" 58 | else 59 | cp "${basedir}/target/${target}/${profile}/game_controller_app" "${appdir}/Contents/target/release" 60 | fi 61 | 62 | mkdir "${appdir}/Contents/MacOS" 63 | cat >"${appdir}/Contents/MacOS/GameController" <"${appdir}/Contents/Info.plist" < 77 | 78 | 79 | 80 | CFBundleExecutable 81 | GameController 82 | CFBundleIconFile 83 | GameController.icns 84 | CFBundleIdentifier 85 | org.RoboCup.GameController 86 | CFBundlePackageType 87 | APPL 88 | CFBundleSignature 89 | RGC3 90 | CFBundleSupportedPlatforms 91 | 92 | MacOSX 93 | 94 | LSMinimumSystemVersion 95 | 12.6${archpref} 96 | NSHighResolutionCapable 97 | True 98 | NSPrincipleClass 99 | NSApplication 100 | 101 | 102 | EOF 103 | echo -n APPLRGC3 >"${appdir}/Contents/PkgInfo" 104 | 105 | hdiutil create "${dmg}" -srcfolder "${templatedir}" -format UDZO -volname "GameController-${version}" 106 | -------------------------------------------------------------------------------- /dist/mkdist-windows.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | $Version = $args[0] -replace "^v", "" 4 | if (!$Version) { 5 | throw "usage: $PSCommandPath []" 6 | } 7 | $Target = $args[1] 8 | if (!$Target) { 9 | $Target = $(rustc.exe -vV) -match "^host: (.*)" -replace "^host: ", "" 10 | } 11 | $BuildProfile = "release-dist" 12 | 13 | $BaseDirectory = Split-Path $PSScriptRoot -Parent 14 | $ArchiveDirectory = Join-Path $PSScriptRoot "GameController-$Version-$Target" 15 | $Archive = Join-Path $PSScriptRoot "GameController-$Version-$Target.zip" 16 | 17 | if (Test-Path $ArchiveDirectory) { 18 | Remove-Item -Recurse -Force $ArchiveDirectory 19 | } 20 | 21 | Push-Location $(Join-Path $BaseDirectory "frontend") 22 | npm ci 23 | npm run build 24 | Pop-Location 25 | 26 | Push-Location $BaseDirectory 27 | cargo build --target $Target --profile $BuildProfile --package game_controller_app 28 | Pop-Location 29 | 30 | New-Item -ItemType Directory -Path $(Join-Path $ArchiveDirectory "target\release") 31 | Copy-Item $(Join-Path $BaseDirectory "LICENSE") $ArchiveDirectory 32 | Copy-Item $(Join-Path $BaseDirectory "README.md") $ArchiveDirectory 33 | Copy-Item $(Join-Path $BaseDirectory "config") $ArchiveDirectory -Recurse 34 | Copy-Item $(Join-Path $BaseDirectory "target\$Target\$BuildProfile\game_controller_app.exe") $(Join-Path $ArchiveDirectory "target\release") 35 | New-Item -ItemType File -Path $(Join-Path $ArchiveDirectory "GameController.bat") -Value @" 36 | @echo off 37 | start %~dp0\target\release\game_controller_app.exe %* 38 | "@ 39 | Compress-Archive $ArchiveDirectory $Archive -Force 40 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | }, 5 | extends: ["eslint:recommended", "plugin:react/recommended", "prettier"], 6 | parserOptions: { 7 | ecmaFeatures: ["jsx"], 8 | ecmaVersion: "latest", 9 | sourceType: "module", 10 | }, 11 | plugins: ["react"], 12 | rules: { 13 | "react/prop-types": 0, 14 | "react/react-in-jsx-scope": 0, 15 | }, 16 | settings: { 17 | react: { 18 | version: "detect", 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /node_modules 3 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | /build 2 | /node_modules 3 | /package-lock.json 4 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "webpack --mode production", 5 | "dev": "webpack serve --mode development", 6 | "format": "prettier --write .", 7 | "lint": "eslint --ext .js,.jsx ./src" 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.21.0", 11 | "@babel/preset-react": "^7.18.6", 12 | "autoprefixer": "^10.4.13", 13 | "babel-loader": "^9.1.2", 14 | "css-loader": "^6.7.3", 15 | "cssnano": "^6.0.0", 16 | "eslint": "^8.37.0", 17 | "eslint-config-prettier": "^8.8.0", 18 | "eslint-plugin-react": "^7.32.2", 19 | "html-webpack-plugin": "^5.5.0", 20 | "mini-css-extract-plugin": "^2.7.2", 21 | "postcss": "^8.4.21", 22 | "postcss-loader": "^7.0.2", 23 | "prettier": "2.8.7", 24 | "style-loader": "^3.3.1", 25 | "tailwindcss": "^3.2.7", 26 | "webpack": "^5.94.0", 27 | "webpack-cli": "^5.0.1", 28 | "webpack-dev-server": "^5.2.2" 29 | }, 30 | "dependencies": { 31 | "@tauri-apps/api": "^1.2.0", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/actions.js: -------------------------------------------------------------------------------- 1 | export const PENALTIES = [ 2 | ["Pushing", "pushing"], 3 | ["Foul", "foul"], 4 | ["Fallen / Inactive", "fallenInactive"], 5 | ["Leaving the Field", "leavingTheField"], 6 | ["Motion in Set", "motionInSet"], 7 | ["Illegal Position", "illegalPosition"], 8 | ["Ball Holding", "ballHolding"], 9 | ["Penalty Kick", "penaltyKick"], 10 | ["Local Game Stuck", "localGameStuck"], 11 | ["Pick-Up", "requestForPickUp"], 12 | ["Player Stance", "playerStance"], 13 | ["Arms / Hands", "playingWithArmsHands"], 14 | ]; 15 | 16 | const NUM_OF_PLAYERS = 20; 17 | const NUM_OF_TEAMS = 2; 18 | 19 | const TEAM_ACTION_BASE = 0; 20 | 21 | export const TIMEOUT = 0; 22 | export const GOAL = 1; 23 | export const GOAL_KICK = 2; 24 | export const KICK_IN = 3; 25 | export const CORNER_KICK = 4; 26 | export const MOTION_IN_STANDBY = 5; 27 | export const SWITCH_TEAM_MODE = 6; 28 | 29 | const NUM_OF_TEAM_ACTIONS = 7; 30 | 31 | const GAME_ACTION_BASE = TEAM_ACTION_BASE + NUM_OF_TEAMS * NUM_OF_TEAM_ACTIONS; 32 | 33 | export const SWITCH_HALF = 0; 34 | export const WAIT_FOR_READY = 1; 35 | export const START_PENALTY_SHOOTOUT_LEFT = 2; 36 | export const START_PENALTY_SHOOTOUT_RIGHT = 3; 37 | export const WAIT_FOR_PENALTY_SHOT = 4; 38 | export const WAIT_FOR_SET_PLAY = 5; 39 | export const FREE_PENALTY_SHOT = 6; 40 | export const FINISH_SET_PLAY = 7; 41 | export const FREE_SET_PLAY = 8; 42 | export const FINISH_PENALTY_SHOT = 9; 43 | export const FINISH_HALF = 10; 44 | // These are game actions because they are part of the center panel. 45 | export const START_KICK_OFF_NONE = 11; 46 | export const START_KICK_OFF_HOME = 12; 47 | export const START_KICK_OFF_AWAY = 13; 48 | export const ADD_EXTRA_TIME = 14; 49 | export const REFEREE_TIMEOUT = 15; 50 | export const GLOBAL_GAME_STUCK = 16; 51 | 52 | const NUM_OF_GAME_ACTIONS = 17; 53 | 54 | const PENALTY_ACTION_BASE = GAME_ACTION_BASE + NUM_OF_GAME_ACTIONS; 55 | 56 | const NUM_OF_PENALTY_ACTIONS = NUM_OF_TEAMS * NUM_OF_PLAYERS * (PENALTIES.length + 1); // The + 1 is the unpenalize action. 57 | 58 | const UNDO_ACTION_BASE = PENALTY_ACTION_BASE + NUM_OF_PENALTY_ACTIONS; 59 | 60 | const NUM_OF_UNDO_ACTIONS = 5; 61 | 62 | export const NUM_OF_ACTIONS = 63 | NUM_OF_TEAMS * NUM_OF_TEAM_ACTIONS + 64 | NUM_OF_GAME_ACTIONS + 65 | NUM_OF_PENALTY_ACTIONS + 66 | NUM_OF_UNDO_ACTIONS; 67 | 68 | export const getActions = () => { 69 | var actions = []; 70 | for (const side of ["home", "away"]) { 71 | actions.push( 72 | { type: "timeout", args: { side: side } }, 73 | { type: "goal", args: { side: side } }, 74 | { type: "startSetPlay", args: { side: side, setPlay: "goalKick" } }, 75 | { type: "startSetPlay", args: { side: side, setPlay: "kickIn" } }, 76 | { type: "startSetPlay", args: { side: side, setPlay: "cornerKick" } }, 77 | { type: "penalize", args: { side: side, player: null, call: "motionInStandby" } }, 78 | { type: "switchTeamMode", args: { side: side } } 79 | ); 80 | } 81 | actions.push({ type: "switchHalf", args: null }); 82 | actions.push({ type: "waitForReady", args: null }); 83 | actions.push({ type: "startPenaltyShootout", args: { sides: "homeDefendsLeftGoal" } }); 84 | actions.push({ type: "startPenaltyShootout", args: { sides: "homeDefendsRightGoal" } }); 85 | actions.push({ type: "waitForPenaltyShot", args: null }); 86 | actions.push({ type: "waitForSetPlay", args: null }); 87 | actions.push({ type: "freePenaltyShot", args: null }); 88 | actions.push({ type: "finishSetPlay", args: null }); 89 | actions.push({ type: "freeSetPlay", args: null }); 90 | actions.push({ type: "finishPenaltyShot", args: null }); 91 | actions.push({ type: "finishHalf", args: null }); 92 | actions.push({ type: "startSetPlay", args: { side: null, setPlay: "kickOff" } }); 93 | actions.push({ type: "startSetPlay", args: { side: "home", setPlay: "kickOff" } }); 94 | actions.push({ type: "startSetPlay", args: { side: "away", setPlay: "kickOff" } }); 95 | actions.push({ type: "addExtraTime", args: null }); 96 | actions.push({ type: "timeout", args: { side: null } }); 97 | actions.push({ type: "globalGameStuck", args: null }); 98 | for (const penalty of PENALTIES) { 99 | for (const side of ["home", "away"]) { 100 | for (let number = 1; number <= NUM_OF_PLAYERS; ++number) { 101 | actions.push({ type: "penalize", args: { side: side, player: number, call: penalty[1] } }); 102 | } 103 | } 104 | } 105 | for (const side of ["home", "away"]) { 106 | for (let number = 1; number <= NUM_OF_PLAYERS; ++number) { 107 | actions.push({ type: "unpenalize", args: { side: side, player: number } }); 108 | } 109 | } 110 | for (let states = 1; states <= NUM_OF_UNDO_ACTIONS; ++states) { 111 | actions.push({ type: "undo", args: { states: states } }); 112 | } 113 | return actions; 114 | }; 115 | 116 | export const extractTeamActions = (legalActions, side) => { 117 | return side === "home" 118 | ? legalActions.slice(TEAM_ACTION_BASE, TEAM_ACTION_BASE + NUM_OF_TEAM_ACTIONS) 119 | : legalActions.slice( 120 | TEAM_ACTION_BASE + NUM_OF_TEAM_ACTIONS, 121 | TEAM_ACTION_BASE + NUM_OF_TEAMS * NUM_OF_TEAM_ACTIONS 122 | ); 123 | }; 124 | 125 | export const extractGameActions = (legalActions) => { 126 | return legalActions.slice(GAME_ACTION_BASE, GAME_ACTION_BASE + NUM_OF_GAME_ACTIONS); 127 | }; 128 | 129 | export const extractPenaltyActions = (legalActions) => { 130 | return legalActions.slice(PENALTY_ACTION_BASE, PENALTY_ACTION_BASE + NUM_OF_PENALTY_ACTIONS); 131 | }; 132 | 133 | export const extractUndoActions = (legalActions) => { 134 | return legalActions.slice(UNDO_ACTION_BASE, UNDO_ACTION_BASE + NUM_OF_UNDO_ACTIONS); 135 | }; 136 | 137 | export const isPenaltyCallLegal = (legalPenaltyActions, callIndex) => { 138 | return legalPenaltyActions 139 | .slice( 140 | callIndex * NUM_OF_TEAMS * NUM_OF_PLAYERS, 141 | (callIndex + 1) * NUM_OF_TEAMS * NUM_OF_PLAYERS 142 | ) 143 | .some((element) => element != 0); 144 | }; 145 | 146 | export const isPenaltyCallLegalForPlayer = (legalPenaltyActions, side, player, callIndex) => { 147 | return ( 148 | legalPenaltyActions[ 149 | (callIndex === null ? PENALTIES.length : callIndex) * NUM_OF_TEAMS * NUM_OF_PLAYERS + 150 | (side === "home" ? 0 : NUM_OF_PLAYERS) + 151 | (player - 1) 152 | ] != 0 153 | ); 154 | }; 155 | -------------------------------------------------------------------------------- /frontend/src/api.js: -------------------------------------------------------------------------------- 1 | import { invoke } from "@tauri-apps/api/tauri"; 2 | import { appWindow } from "@tauri-apps/api/window"; 3 | import { NUM_OF_ACTIONS } from "./actions.js"; 4 | 5 | export const getLaunchData = async () => { 6 | if (window.__TAURI_METADATA__) { 7 | return await invoke("get_launch_data"); 8 | } else { 9 | return { 10 | competitions: [ 11 | { id: "champions_cup", name: "Champions Cup", teams: [0, 1, 2] }, 12 | { id: "challenge_shield", name: "Challenge Shield", teams: [0, 3, 4, 5] }, 13 | ], 14 | teams: [ 15 | { 16 | number: 0, 17 | name: "Invisibles", 18 | fieldPlayerColors: ["blue", "red"], 19 | goalkeeperColors: ["yellow", "black"], 20 | }, 21 | { 22 | number: 1, 23 | name: "UT Austin Villa", 24 | fieldPlayerColors: ["white", "orange"], 25 | goalkeeperColors: ["white", "orange"], 26 | }, 27 | { 28 | number: 2, 29 | name: "Austrian Kangaroos", 30 | fieldPlayerColors: ["blue", "red"], 31 | goalkeeperColors: ["blue", "red"], 32 | }, 33 | { 34 | number: 3, 35 | name: "Bembelbots", 36 | fieldPlayerColors: ["gray", "blue"], 37 | goalkeeperColors: ["gray", "blue"], 38 | }, 39 | { 40 | number: 4, 41 | name: "Berlin United", 42 | fieldPlayerColors: ["blue", "red"], 43 | goalkeeperColors: ["blue", "red"], 44 | }, 45 | { 46 | number: 5, 47 | name: "B-Human", 48 | fieldPlayerColors: ["black", "red"], 49 | goalkeeperColors: ["black", "red"], 50 | }, 51 | ], 52 | networkInterfaces: [ 53 | { id: "en0", address: "10.0.0.1", broadcast: "10.0.255.255" }, 54 | { id: "lo0", address: "127.0.0.1", broadcast: "127.0.0.1" }, 55 | ], 56 | defaultSettings: { 57 | competition: { id: "champions_cup" }, 58 | game: { 59 | teams: { 60 | home: { number: 0, fieldPlayerColor: "blue", goalkeeperColor: "yellow" }, 61 | away: { number: 0, fieldPlayerColor: "red", goalkeeperColor: "black" }, 62 | }, 63 | long: false, 64 | kickOffSide: "home", 65 | sideMapping: "homeDefendsLeftGoal", 66 | test: { 67 | noDelay: false, 68 | penaltyShootout: false, 69 | unpenalize: false, 70 | }, 71 | }, 72 | window: { fullscreen: false }, 73 | network: { interface: "en0", broadcast: false, multicast: false }, 74 | }, 75 | }; 76 | } 77 | }; 78 | 79 | export const launch = async (settings) => { 80 | if (window.__TAURI_METADATA__) { 81 | await invoke("launch", { settings: settings }); 82 | } else { 83 | console.log(settings); 84 | } 85 | }; 86 | 87 | export const listenForState = async (handler) => { 88 | if (window.__TAURI_METADATA__) { 89 | return await appWindow.listen("state", (event) => { 90 | handler(event.payload); 91 | }); 92 | } else { 93 | handler({ 94 | game: { 95 | sides: "homeDefendsLeftGoal", 96 | phase: "firstHalf", 97 | state: "initial", 98 | setPlay: "noSetPlay", 99 | kickingSide: "home", 100 | primaryTimer: { 101 | started: { remaining: [600, 0], run_condition: "playing", behavior_at_zero: "overflow" }, 102 | }, 103 | secondaryTimer: { stopped: null }, 104 | timeoutRewindTimer: { stopped: null }, 105 | teams: { 106 | home: { 107 | goalkeeper: 1, 108 | score: 0, 109 | penaltyCounter: 0, 110 | timeoutBudget: 1, 111 | messageBudget: 1200, 112 | illegalCommunication: false, 113 | penaltyShot: 0, 114 | penaltyShotMask: 0, 115 | players: [ 116 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 117 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 118 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 119 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 120 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 121 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 122 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 123 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 124 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 125 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 126 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 127 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 128 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 129 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 130 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 131 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 132 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 133 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 134 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 135 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 136 | ], 137 | }, 138 | away: { 139 | goalkeeper: 1, 140 | score: 0, 141 | penaltyCounter: 0, 142 | timeoutBudget: 1, 143 | messageBudget: 1200, 144 | illegalCommunication: false, 145 | penaltyShot: 0, 146 | penaltyShotMask: 0, 147 | players: [ 148 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 149 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 150 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 151 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 152 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 153 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 154 | { penalty: "noPenalty", penaltyTimer: { stopped: null } }, 155 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 156 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 157 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 158 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 159 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 160 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 161 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 162 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 163 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 164 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 165 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 166 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 167 | { penalty: "substitute", penaltyTimer: { stopped: null } }, 168 | ], 169 | }, 170 | }, 171 | }, 172 | legalActions: new Array(NUM_OF_ACTIONS).fill(0), 173 | connectionStatus: { 174 | home: [1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0], 175 | away: [1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0, 1, 2, 1, 0], 176 | }, 177 | undoActions: [], 178 | }); 179 | return () => {}; 180 | } 181 | }; 182 | 183 | export const syncWithBackend = async () => { 184 | if (window.__TAURI_METADATA__) { 185 | return await invoke("sync_with_backend"); 186 | } else { 187 | return { 188 | competition: {}, 189 | game: { 190 | teams: { 191 | home: { 192 | number: 0, 193 | fieldPlayerColor: "blue", 194 | goalkeeperColor: "yellow", 195 | }, 196 | away: { 197 | number: 0, 198 | fieldPlayerColor: "red", 199 | goalkeeperColor: "black", 200 | }, 201 | }, 202 | long: false, 203 | }, 204 | }; 205 | } 206 | }; 207 | 208 | export const applyAction = (action) => { 209 | if (window.__TAURI_METADATA__) { 210 | invoke("apply_action", { action: action }); 211 | } else { 212 | console.log(action); 213 | } 214 | }; 215 | 216 | export const declareActions = (actions) => { 217 | if (window.__TAURI_METADATA__) { 218 | invoke("declare_actions", { actions: actions }); 219 | } else { 220 | console.log(actions); 221 | } 222 | }; 223 | -------------------------------------------------------------------------------- /frontend/src/components/Index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Launcher from "./Launcher"; 3 | import Main from "./Main"; 4 | 5 | const Index = () => { 6 | const [launched, setLaunched] = useState(false); 7 | 8 | if (launched) { 9 | return
; 10 | } else { 11 | return ; 12 | } 13 | }; 14 | 15 | export default Index; 16 | -------------------------------------------------------------------------------- /frontend/src/components/Launcher.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import CompetitionSettings from "./launcher/CompetitionSettings"; 3 | import GameSettings from "./launcher/GameSettings"; 4 | import NetworkSettings from "./launcher/NetworkSettings"; 5 | import WindowSettings from "./launcher/WindowSettings"; 6 | import { getLaunchData, launch } from "../api"; 7 | 8 | const Launcher = ({ setLaunched }) => { 9 | const [competitions, setCompetitions] = useState(null); 10 | const [launchSettings, setLaunchSettings] = useState(null); 11 | const [networkInterfaces, setNetworkInterfaces] = useState(null); 12 | const [teams, setTeams] = useState(null); 13 | 14 | const launchSettingsAreLegal = 15 | launchSettings != null && 16 | launchSettings.game.teams.home.number != launchSettings.game.teams.away.number && 17 | launchSettings.game.teams.home.fieldPlayerColor != 18 | launchSettings.game.teams.away.fieldPlayerColor && 19 | launchSettings.game.teams.home.fieldPlayerColor != 20 | launchSettings.game.teams.home.goalkeeperColor && 21 | launchSettings.game.teams.home.fieldPlayerColor != 22 | launchSettings.game.teams.away.goalkeeperColor && 23 | launchSettings.game.teams.away.fieldPlayerColor != 24 | launchSettings.game.teams.away.goalkeeperColor && 25 | launchSettings.game.teams.away.fieldPlayerColor != 26 | launchSettings.game.teams.home.goalkeeperColor; 27 | 28 | useEffect(() => { 29 | getLaunchData().then((data) => { 30 | setCompetitions(data.competitions); 31 | setLaunchSettings(data.defaultSettings); 32 | setNetworkInterfaces(data.networkInterfaces); 33 | setTeams(data.teams); 34 | }); 35 | }, []); 36 | 37 | if ( 38 | competitions != null && 39 | launchSettings != null && 40 | networkInterfaces != null && 41 | teams != null 42 | ) { 43 | const setCompetition = (competition) => { 44 | const INVISIBLES_NUMBER = 0; 45 | const defaultTeam = teams.find((team) => team.number === INVISIBLES_NUMBER); // Assuming that the Invisibles are part of every competition. 46 | setLaunchSettings({ 47 | ...launchSettings, 48 | competition: competition, 49 | game: { 50 | ...launchSettings.game, 51 | teams: { 52 | home: { 53 | number: defaultTeam.number, 54 | fieldPlayerColor: defaultTeam.fieldPlayerColors[0], 55 | goalkeeperColor: defaultTeam.goalkeeperColors[0], 56 | }, 57 | away: { 58 | number: defaultTeam.number, 59 | fieldPlayerColor: defaultTeam.fieldPlayerColors[1], 60 | goalkeeperColor: defaultTeam.goalkeeperColors[1], 61 | }, 62 | }, 63 | }, 64 | }); 65 | }; 66 | const thisCompetition = competitions.find( 67 | (competition) => competition.id === launchSettings.competition.id 68 | ); 69 | const teamsInThisCompetition = teams.filter((team) => 70 | thisCompetition.teams.includes(team.number) 71 | ); 72 | return ( 73 |
74 | 79 | setLaunchSettings({ ...launchSettings, game: game })} 83 | /> 84 | setLaunchSettings({ ...launchSettings, window: window })} 87 | /> 88 | setLaunchSettings({ ...launchSettings, network: network })} 92 | /> 93 | 100 |
101 | ); 102 | } else { 103 | return <>; 104 | } 105 | }; 106 | 107 | export default Launcher; 108 | -------------------------------------------------------------------------------- /frontend/src/components/Main.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import CenterPanel from "./main/CenterPanel"; 3 | import TeamPanel from "./main/TeamPanel"; 4 | import UndoPanel from "./main/UndoPanel"; 5 | import { 6 | getActions, 7 | extractGameActions, 8 | extractPenaltyActions, 9 | extractTeamActions, 10 | extractUndoActions, 11 | isPenaltyCallLegal, 12 | NUM_OF_ACTIONS, 13 | } from "../actions.js"; 14 | import { getLaunchData, declareActions, listenForState, syncWithBackend } from "../api.js"; 15 | 16 | const Main = () => { 17 | const [connectionStatus, setConnectionStatus] = useState(null); 18 | const [game, setGame] = useState(null); 19 | const [legalActions, setLegalActions] = useState(null); 20 | const [params, setParams] = useState(null); 21 | const [selectedPenaltyCall, setSelectedPenaltyCall] = useState(null); 22 | const [teamNames, setTeamNames] = useState(null); 23 | const [undoActions, setUndoActions] = useState(null); 24 | 25 | useEffect(() => { 26 | if ( 27 | legalActions != null && 28 | selectedPenaltyCall != null && 29 | !isPenaltyCallLegal(extractPenaltyActions(legalActions), selectedPenaltyCall) 30 | ) { 31 | setSelectedPenaltyCall(null); 32 | } 33 | }, [legalActions]); 34 | 35 | useEffect(() => { 36 | const thePromise = (async () => { 37 | const unlisten = await listenForState((state) => { 38 | setConnectionStatus(state.connectionStatus); 39 | setGame(state.game); 40 | setLegalActions(state.legalActions); 41 | setUndoActions(state.undoActions); 42 | }); 43 | // listen must have completed before starting the next call because the core may send a state 44 | // event once syncWithBackend is called that must not be missed. 45 | const params = await syncWithBackend(); 46 | setParams(params); 47 | const teams = (await getLaunchData()).teams; 48 | setTeamNames( 49 | Object.fromEntries( 50 | Object.entries(params.game.teams).map(([side, teamParams]) => [ 51 | side, 52 | teams.find((team) => team.number === teamParams.number).name, 53 | ]) 54 | ) 55 | ); 56 | // syncWithBackend must have completed before the next call because declareActions fails if 57 | // certain things have not been initialized that are only guaranteed to be initialized after 58 | // syncWithBackend. 59 | declareActions(getActions()); 60 | return unlisten; 61 | })(); 62 | return () => { 63 | thePromise.then((unlisten) => unlisten()); 64 | }; 65 | }, []); 66 | 67 | if ( 68 | connectionStatus != null && 69 | game != null && 70 | legalActions != null && 71 | legalActions.length == NUM_OF_ACTIONS && 72 | params != null && 73 | teamNames != null && 74 | undoActions != null 75 | ) { 76 | const mirror = game.sides === "homeDefendsRightGoal"; 77 | return ( 78 |
79 |
84 | 96 | 104 | {params.competition.challengeMode == null ? ( 105 | 117 | ) : ( 118 | <> 119 | )} 120 |
121 | 122 |
123 | ); 124 | } else { 125 | return <>; 126 | } 127 | }; 128 | 129 | export default Main; 130 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/CompetitionSettings.jsx: -------------------------------------------------------------------------------- 1 | const CompetitionSettings = ({ competitions, competition, setCompetition }) => { 2 | return ( 3 |
4 | 5 | 16 |
17 | ); 18 | }; 19 | 20 | export default CompetitionSettings; 21 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/GameSettings.jsx: -------------------------------------------------------------------------------- 1 | import TeamSettings from "./TeamSettings"; 2 | import TestSettings from "./TestSettings"; 3 | 4 | const GameSettings = ({ teams, game, setGame }) => { 5 | return ( 6 |
7 |
8 | 9 | setGame({ ...game, long: e.target.checked })} 14 | /> 15 |
16 |
21 | {["home", "away"].map((side) => { 22 | return ( 23 |
24 |
25 | 26 | { 32 | setGame({ ...game, kickOffSide: e.target.value }); 33 | }} 34 | /> 35 |
36 | 40 | setGame({ 41 | ...game, 42 | teams: { ...game.teams, [side]: team }, 43 | }) 44 | } 45 | isTeamLegal={game.teams.home.number != game.teams.away.number} 46 | isFieldPlayerColorLegal={ 47 | game.teams[side].fieldPlayerColor != game.teams.home.goalkeeperColor && 48 | game.teams[side].fieldPlayerColor != game.teams.away.goalkeeperColor && 49 | game.teams.home.fieldPlayerColor != game.teams.away.fieldPlayerColor 50 | } 51 | isGoalkeeperColorLegal={ 52 | game.teams[side].goalkeeperColor != game.teams.home.fieldPlayerColor && 53 | game.teams[side].goalkeeperColor != game.teams.away.fieldPlayerColor 54 | } 55 | /> 56 |
57 | ); 58 | })} 59 |
60 |
61 | 62 | 67 | setGame({ 68 | ...game, 69 | sideMapping: e.target.checked ? "homeDefendsRightGoal" : "homeDefendsLeftGoal", 70 | }) 71 | } 72 | /> 73 |
74 | setGame({ ...game, test: test })} /> 75 |
76 | ); 77 | }; 78 | 79 | export default GameSettings; 80 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/NetworkSettings.jsx: -------------------------------------------------------------------------------- 1 | const NetworkSettings = ({ interfaces, network, setNetwork }) => { 2 | return ( 3 |
4 |
5 | 6 | 17 |
18 |
19 | Casting (advanced option) 20 |
21 | 22 | setNetwork({ ...network, broadcast: e.target.checked })} 27 | /> 28 |
29 |
30 | 31 | setNetwork({ ...network, multicast: e.target.checked })} 36 | /> 37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default NetworkSettings; 44 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/TeamColorSelector.jsx: -------------------------------------------------------------------------------- 1 | const TeamColorSelector = ({ colors, color, setColor, isColorLegal }) => { 2 | return ( 3 | 14 | ); 15 | }; 16 | 17 | export default TeamColorSelector; 18 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/TeamSelector.jsx: -------------------------------------------------------------------------------- 1 | const TeamSelector = ({ teams, number, setNumber, isTeamLegal }) => { 2 | return ( 3 | 16 | ); 17 | }; 18 | 19 | export default TeamSelector; 20 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/TeamSettings.jsx: -------------------------------------------------------------------------------- 1 | import TeamColorSelector from "./TeamColorSelector"; 2 | import TeamSelector from "./TeamSelector"; 3 | 4 | const TeamSettings = ({ 5 | teams, 6 | team, 7 | setTeam, 8 | isTeamLegal, 9 | isFieldPlayerColorLegal, 10 | isGoalkeeperColorLegal, 11 | }) => { 12 | const setNumber = (number) => { 13 | const teamOptions = teams.find((t) => t.number === number); 14 | setTeam({ 15 | ...team, 16 | number: number, 17 | fieldPlayerColor: teamOptions.fieldPlayerColors[0], 18 | goalkeeperColor: teamOptions.goalkeeperColors[0], 19 | }); 20 | }; 21 | const teamOptions = teams.find((t) => t.number === team.number); 22 | return ( 23 |
24 | 30 |
31 |
32 | 33 | setTeam({ ...team, fieldPlayerColor: color })} 37 | isColorLegal={isFieldPlayerColorLegal} 38 | /> 39 |
40 |
41 | 42 | setTeam({ ...team, goalkeeperColor: color })} 46 | isColorLegal={isGoalkeeperColorLegal} 47 | /> 48 |
49 |
50 |
51 | ); 52 | }; 53 | 54 | export default TeamSettings; 55 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/TestSettings.jsx: -------------------------------------------------------------------------------- 1 | const TestSettings = ({ test, setTest }) => { 2 | return ( 3 |
4 |
5 | Testing 6 |
7 | 8 | setTest({ ...test, noDelay: e.target.checked })} 13 | /> 14 |
15 |
16 | 17 | setTest({ ...test, penaltyShootout: e.target.checked })} 22 | /> 23 |
24 |
25 | 26 | setTest({ ...test, unpenalize: e.target.checked })} 31 | /> 32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | export default TestSettings; 39 | -------------------------------------------------------------------------------- /frontend/src/components/launcher/WindowSettings.jsx: -------------------------------------------------------------------------------- 1 | const WindowSettings = ({ window, setWindow }) => { 2 | return ( 3 |
4 | 5 | setWindow({ ...window, fullscreen: e.target.checked })} 10 | /> 11 |
12 | ); 13 | }; 14 | 15 | export default WindowSettings; 16 | -------------------------------------------------------------------------------- /frontend/src/components/main/ActionButton.jsx: -------------------------------------------------------------------------------- 1 | import { applyAction } from "../../api.js"; 2 | 3 | const ActionButton = ({ action, active, label, legal }) => { 4 | return ( 5 | 16 | ); 17 | }; 18 | 19 | export default ActionButton; 20 | -------------------------------------------------------------------------------- /frontend/src/components/main/CenterPanel.jsx: -------------------------------------------------------------------------------- 1 | import ClockPanel from "./ClockPanel"; 2 | import PenaltyPanel from "./PenaltyPanel"; 3 | import StatePanel from "./StatePanel"; 4 | 5 | const CenterPanel = ({ 6 | game, 7 | legalGameActions, 8 | legalPenaltyActions, 9 | params, 10 | selectedPenaltyCall, 11 | setSelectedPenaltyCall, 12 | }) => { 13 | return ( 14 |
15 | 16 | 17 | 22 |
23 | ); 24 | }; 25 | 26 | export default CenterPanel; 27 | -------------------------------------------------------------------------------- /frontend/src/components/main/ClockPanel.jsx: -------------------------------------------------------------------------------- 1 | import ActionButton from "./ActionButton"; 2 | import * as actions from "../../actions.js"; 3 | import { formatMMSS } from "../../utils.js"; 4 | 5 | const getPhaseDescription = (game) => { 6 | switch (game.phase) { 7 | case "firstHalf": 8 | return game.state === "finished" ? "Half-Time Break" : "First Half"; 9 | case "secondHalf": 10 | return game.state === "initial" || game.state === "standby" 11 | ? "Half-Time Break" 12 | : "Second Half"; 13 | case "penaltyShootout": 14 | return "Penalty Shoot-out"; 15 | } 16 | return ""; 17 | }; 18 | 19 | const getStateDescription = (game) => { 20 | switch (game.state) { 21 | case "timeout": 22 | return "Timeout"; 23 | case "initial": 24 | return "Initial"; 25 | case "finished": 26 | return "Finished"; 27 | case "standby": 28 | return "Standby"; 29 | } 30 | let prefix = { 31 | noSetPlay: "", 32 | kickOff: "Kick-off", 33 | kickIn: "Kick-in", 34 | goalKick: "Goal Kick", 35 | cornerKick: "Corner Kick", 36 | pushingFreeKick: "Pushing Free Kick", 37 | penaltyKick: "Penalty Kick", 38 | }[game.setPlay]; 39 | let state = ""; 40 | if (game.state === "ready") { 41 | state = " Ready"; 42 | } else if (game.state === "set") { 43 | state = " Set"; 44 | } else if (prefix === "") { 45 | state = "Playing"; 46 | } 47 | return prefix + state; 48 | }; 49 | 50 | const ClockPanel = ({ game, legalGameActions }) => { 51 | return ( 52 |
53 |

{getPhaseDescription(game)}

54 |
55 |

64 | {formatMMSS(game.primaryTimer)} 65 |

66 |
71 | 76 |
77 |
78 |

{getStateDescription(game)}

79 |

80 | {formatMMSS(game.secondaryTimer)} 81 |

82 |
83 | ); 84 | }; 85 | 86 | export default ClockPanel; 87 | -------------------------------------------------------------------------------- /frontend/src/components/main/PenaltyButton.jsx: -------------------------------------------------------------------------------- 1 | const PenaltyButton = ({ label, legal, onClick, selected }) => { 2 | return ( 3 | 12 | ); 13 | }; 14 | 15 | export default PenaltyButton; 16 | -------------------------------------------------------------------------------- /frontend/src/components/main/PenaltyPanel.jsx: -------------------------------------------------------------------------------- 1 | import PenaltyButton from "./PenaltyButton"; 2 | import { isPenaltyCallLegal, PENALTIES } from "../../actions.js"; 3 | 4 | const PenaltyPanel = ({ legalPenaltyActions, selectedPenaltyCall, setSelectedPenaltyCall }) => { 5 | return ( 6 |
7 | {PENALTIES.map((penalty, index) => ( 8 | setSelectedPenaltyCall(selectedPenaltyCall === index ? null : index)} 13 | selected={selectedPenaltyCall === index} 14 | /> 15 | ))} 16 |
17 | ); 18 | }; 19 | 20 | export default PenaltyPanel; 21 | -------------------------------------------------------------------------------- /frontend/src/components/main/PlayerButton.jsx: -------------------------------------------------------------------------------- 1 | import { formatMMSS } from "../../utils.js"; 2 | 3 | const bgClasses = { 4 | red: "bg-red-100", 5 | blue: "bg-blue-100", 6 | yellow: "bg-yellow-100", 7 | black: "bg-white", 8 | white: "bg-white", 9 | green: "bg-green-100", 10 | orange: "bg-orange-100", 11 | purple: "bg-purple-100", 12 | brown: "bg-amber-100", 13 | gray: "bg-gray-200", 14 | }; 15 | 16 | const penaltyDescriptions = { 17 | noPenalty: "No Penalty", 18 | substitute: "Substitute", 19 | pickedUp: "Picked Up", 20 | illegalPositionInSet: "Illegal Position", 21 | illegalPosition: "Illegal Position", 22 | motionInSet: "Motion in Set", 23 | fallenInactive: "Fallen / Inactive", 24 | localGameStuck: "Local Game Stuck", 25 | ballHolding: "Ball Holding", 26 | playerStance: "Player Stance", 27 | playerPushing: "Pushing", 28 | playingWithArmsHands: "Arms / Hands", 29 | leavingTheField: "Leaving the Field", 30 | }; 31 | 32 | const PlayerButton = ({ color, legal, sign, onClick, player }) => { 33 | const shouldFlash = 34 | player && 35 | player.penalty != "noPenalty" && 36 | player.penalty != "substitute" && 37 | player.penalty != "motionInSet" && 38 | (player.penaltyTimer.started 39 | ? player.penaltyTimer.started.remaining[0] < 10 40 | : player.penalty != "pickedUp"); 41 | return ( 42 | 93 | ); 94 | }; 95 | 96 | export default PlayerButton; 97 | -------------------------------------------------------------------------------- /frontend/src/components/main/StatePanel.jsx: -------------------------------------------------------------------------------- 1 | import ActionButton from "./ActionButton"; 2 | import * as actions from "../../actions.js"; 3 | 4 | const StatePanel = ({ game, params, legalGameActions }) => { 5 | const inHalfTimeBreak = 6 | (game.phase === "firstHalf" && game.state === "finished") || 7 | (game.phase === "secondHalf" && game.state === "initial"); 8 | 9 | const hasStandbyState = 10 | params.competition.delayAfterReady.secs > 0 || params.competition.delayAfterReady.nanos > 0; 11 | 12 | let standbyButton = 13 | hasStandbyState && 14 | game.phase != "penaltyShootout" && 15 | (game.state === "initial" || 16 | game.state == "timeout" || 17 | (game.phase === "firstHalf" && game.state === "finished")) ? ( 18 |
19 | 24 |
25 | ) : ( 26 | <> 27 | ); 28 | 29 | let readySideMap = { 30 | null: actions.START_KICK_OFF_NONE, 31 | home: actions.START_KICK_OFF_HOME, 32 | away: actions.START_KICK_OFF_AWAY, 33 | }; 34 | let readyButton = 35 | game.phase != "penaltyShootout" && 36 | (hasStandbyState 37 | ? game.state === "standby" 38 | : game.state === "initial" || 39 | game.state === "timeout" || 40 | (game.phase === "firstHalf" && game.state === "finished")) ? ( 41 |
42 | 47 |
48 | ) : ( 49 | <> 50 | ); 51 | 52 | let globalGameStuckButton = 53 | game.phase != "penaltyShootout" && game.state === "playing" ? ( 54 | 59 | ) : ( 60 | <> 61 | ); 62 | 63 | let setButton = 64 | game.phase === "penaltyShootout" || game.state === "ready" || game.state === "set" ? ( 65 | 80 | ) : ( 81 | <> 82 | ); 83 | 84 | let playingButton = 85 | game.phase === "penaltyShootout" || 86 | game.state === "ready" || 87 | game.state === "set" || 88 | game.state === "playing" ? ( 89 |
90 | 103 |
104 | ) : ( 105 | <> 106 | ); 107 | 108 | let ballFreeButton = 109 | game.phase != "penaltyShootout" && 110 | (game.state === "ready" || game.state === "set" || game.state === "playing") ? ( 111 | 116 | ) : ( 117 | <> 118 | ); 119 | 120 | let finishButton = 121 | game.phase === "penaltyShootout" || 122 | game.state === "ready" || 123 | game.state === "set" || 124 | game.state === "playing" ? ( 125 | 138 | ) : ( 139 | <> 140 | ); 141 | 142 | // This button is still displayed when we are already in the Initial state of the second half. 143 | // This is because the state can switch automatically to the second half and it would be bad if 144 | // the operator clicked the button exactly at that time, but the button switches its meaning to 145 | // Ready/Standby before the button is actually clicked. Therefore, both buttons (Ready/Standby and 146 | // Second Half) are displayed during the entire half-time break, even though only one of them can 147 | // be legal. 148 | let secondHalfButton = inHalfTimeBreak ? ( 149 | 154 | ) : ( 155 | <> 156 | ); 157 | 158 | let penaltyShootoutButtons = 159 | game.phase === "secondHalf" && game.state === "finished" ? ( 160 | <> 161 |
162 | 167 |
168 |
169 | 174 |
175 | 176 | ) : ( 177 | <> 178 | ); 179 | 180 | let refereeTimeoutButton = ( 181 | 186 | ); 187 | 188 | return ( 189 |
190 | {secondHalfButton} 191 | {standbyButton} 192 | {penaltyShootoutButtons} 193 | {readyButton} 194 | {globalGameStuckButton} 195 | {setButton} 196 | {playingButton} 197 | {ballFreeButton} 198 | {finishButton} 199 | {refereeTimeoutButton} 200 |
201 | ); 202 | }; 203 | 204 | export default StatePanel; 205 | -------------------------------------------------------------------------------- /frontend/src/components/main/UndoPanel.jsx: -------------------------------------------------------------------------------- 1 | import ActionButton from "./ActionButton"; 2 | import * as actions from "../../actions.js"; 3 | 4 | const getActionName = (action) => { 5 | switch (action.type) { 6 | case "addExtraTime": 7 | return "Add Extra Time"; 8 | case "finishHalf": 9 | case "finishPenaltyShot": 10 | return "Finish"; 11 | case "finishSetPlay": 12 | return "Set Play Complete"; 13 | case "freePenaltyShot": 14 | case "freeSetPlay": 15 | return "Playing"; 16 | case "globalGameStuck": 17 | return "Global Game Stuck"; 18 | case "goal": 19 | return "Goal"; 20 | case "penalize": { 21 | const penalty = actions.PENALTIES.find((penalty) => penalty[1] === action.args.call); 22 | if (penalty) { 23 | return penalty[0]; 24 | } 25 | return "Penalize"; 26 | } 27 | case "selectPenaltyShotPlayer": 28 | return "Select"; 29 | case "startPenaltyShootout": 30 | return "Penalty Shoot-out"; 31 | case "startSetPlay": 32 | switch (action.args.setPlay) { 33 | case "kickOff": 34 | return "Ready"; 35 | case "kickIn": 36 | return "Kick-in"; 37 | case "goalKick": 38 | return "Goal Kick"; 39 | case "cornerKick": 40 | return "Corner Kick"; 41 | } 42 | break; 43 | case "substitute": 44 | return "Substitute"; 45 | case "switchHalf": 46 | return "Second Half"; 47 | case "switchTeamMode": 48 | return "Switch Mode"; 49 | case "timeout": 50 | return action.args.side ? "Timeout" : "Referee Timeout"; 51 | case "unpenalize": 52 | return "Unpenalize"; 53 | case "waitForPenaltyShot": 54 | case "waitForSetPlay": 55 | return "Set"; 56 | case "waitForReady": 57 | return "Standby"; 58 | } 59 | return action.type; 60 | }; 61 | 62 | const UndoPanel = ({ undoActions, legalUndoActions }) => { 63 | return ( 64 |
65 | {legalUndoActions.map((legal, index) => ( 66 | 72 | ))} 73 |
74 | ); 75 | }; 76 | 77 | export default UndoPanel; 78 | -------------------------------------------------------------------------------- /frontend/src/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./style.css"; 4 | import Index from "./components/Index"; 5 | 6 | document.addEventListener("contextmenu", (event) => event.preventDefault()); 7 | const container = document.createElement("div"); 8 | document.body.appendChild(container); 9 | const root = createRoot(container); 10 | 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | -------------------------------------------------------------------------------- /frontend/src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | overflow: hidden; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/utils.js: -------------------------------------------------------------------------------- 1 | export const formatMMSS = (timer) => { 2 | const getDuration = (duration) => { 3 | return duration[0] + (duration[1] > 0 ? 1 : 0); 4 | }; 5 | const rawSeconds = timer.started ? getDuration(timer.started.remaining) : 0; 6 | const sign = rawSeconds < 0 ? "-" : ""; 7 | var seconds = Math.abs(rawSeconds); 8 | var minutes = Math.floor(seconds / 60); 9 | seconds -= minutes * 60; 10 | if (minutes < 10) { 11 | minutes = "0" + minutes; 12 | } 13 | if (seconds < 10) { 14 | seconds = "0" + seconds; 15 | } 16 | return sign + minutes + ":" + seconds; 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | 5 | module.exports = (_, { mode }) => ({ 6 | devServer: { 7 | port: 3000, 8 | static: false, 9 | }, 10 | entry: path.resolve(__dirname, "src", "index.jsx"), 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.jsx$/, 15 | exclude: /node_modules/, 16 | use: { 17 | loader: "babel-loader", 18 | options: { 19 | presets: [ 20 | [ 21 | "@babel/preset-react", 22 | { 23 | runtime: "automatic", 24 | }, 25 | ], 26 | ], 27 | }, 28 | }, 29 | }, 30 | { 31 | test: /\.css$/, 32 | exclude: /node_modules/, 33 | use: [ 34 | mode === "production" ? MiniCssExtractPlugin.loader : "style-loader", 35 | { 36 | loader: "css-loader", 37 | options: { 38 | importLoaders: 1, 39 | }, 40 | }, 41 | { 42 | loader: "postcss-loader", 43 | options: { 44 | postcssOptions: { 45 | plugins: [ 46 | [ 47 | "tailwindcss", 48 | { 49 | content: [path.resolve(__dirname, "src/**/*.jsx")], 50 | theme: { 51 | extend: { 52 | animation: { 53 | "flash-text": "flash-text 1s step-start 0s infinite", 54 | "flash-bg": "flash-bg 1s step-start 0s infinite", 55 | }, 56 | keyframes: { 57 | "flash-text": { 58 | "50%": { color: "#facc15" }, 59 | }, 60 | "flash-bg": { 61 | "50%": { "background-color": "#facc15" }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | ], 68 | "autoprefixer", 69 | ].concat(mode === "production" ? ["cssnano"] : []), 70 | }, 71 | }, 72 | }, 73 | ], 74 | }, 75 | ], 76 | }, 77 | output: { 78 | filename: "index.js", 79 | path: path.resolve(__dirname, "build"), 80 | clean: true, 81 | }, 82 | plugins: [ 83 | new HtmlWebpackPlugin({ 84 | filename: "index.html", 85 | title: "GameController", 86 | }), 87 | ].concat(mode === "production" ? [new MiniCssExtractPlugin()] : []), 88 | resolve: { 89 | extensions: ["...", ".jsx"], 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /game_controller_app/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | description = "The GUI application of the RoboCup Standard Platform League GameController" 4 | edition = { workspace = true } 5 | license = { workspace = true } 6 | name = "game_controller_app" 7 | repository = { workspace = true } 8 | rust-version = { workspace = true } 9 | version = { workspace = true } 10 | 11 | [build-dependencies] 12 | tauri-build = { workspace = true } 13 | 14 | [dependencies] 15 | anyhow = { workspace = true } 16 | clap = { workspace = true } 17 | game_controller_core = { workspace = true } 18 | game_controller_runtime = { workspace = true } 19 | tauri = { workspace = true } 20 | tokio = { workspace = true } 21 | 22 | [features] 23 | # by default Tauri runs in production mode 24 | # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL 25 | default = ["custom-protocol"] 26 | # this feature is used for production builds where `devPath` points to the filesystem 27 | # DO NOT remove this 28 | custom-protocol = ["tauri/custom-protocol"] 29 | -------------------------------------------------------------------------------- /game_controller_app/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /game_controller_app/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/128x128.png -------------------------------------------------------------------------------- /game_controller_app/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/128x128@2x.png -------------------------------------------------------------------------------- /game_controller_app/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/32x32.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /game_controller_app/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/StoreLogo.png -------------------------------------------------------------------------------- /game_controller_app/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/icon.icns -------------------------------------------------------------------------------- /game_controller_app/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/icon.ico -------------------------------------------------------------------------------- /game_controller_app/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoboCup-SPL/GameController3/92718d45569f151e69c40dd58f7381179a6bc5ce/game_controller_app/icons/icon.png -------------------------------------------------------------------------------- /game_controller_app/src/handlers.rs: -------------------------------------------------------------------------------- 1 | //! This module defines handlers that can be called from JavaScript. 2 | 3 | use std::sync::Arc; 4 | 5 | use anyhow::{anyhow, Context}; 6 | use tauri::{ 7 | command, generate_handler, AppHandle, InvokeHandler, LogicalSize, Manager, State, Window, Wry, 8 | }; 9 | use tokio::sync::Notify; 10 | 11 | use game_controller_core::{action::VAction, types::Params}; 12 | use game_controller_runtime::{ 13 | launch::{LaunchData, LaunchSettings}, 14 | start_runtime, RuntimeState, 15 | }; 16 | 17 | /// This struct is used as state so that the [launch] function can communicate to 18 | /// [sync_with_backend] that the full [RuntimeState] is managed now. 19 | struct SyncState(Arc); 20 | 21 | /// This function is called by the launcher to obtain its data. The data is read from a state 22 | /// variable that is created by [game_controller_runtime::launch::make_launch_data] and put there by 23 | /// [crate::main]. 24 | #[command] 25 | fn get_launch_data(launch_data: State) -> LaunchData { 26 | launch_data.inner().clone() 27 | } 28 | 29 | /// This function is called when the user finishes the launcher dialog. It creates a game state and 30 | /// network services, and spawns tasks to handle events. 31 | #[command] 32 | async fn launch(settings: LaunchSettings, window: Window, app: AppHandle) { 33 | // The notify object must be managed before the window is created. 34 | let runtime_notify = Arc::new(Notify::new()); 35 | app.manage(SyncState(runtime_notify.clone())); 36 | 37 | // Unfortunately we cannot use the number of players per team here. 38 | let size = LogicalSize::::new(1024.0, 820.0); 39 | let _ = window.set_min_size(Some(size)); 40 | #[cfg(target_os = "windows")] 41 | let _ = window.set_size(size); 42 | let _ = window.set_fullscreen(settings.window.fullscreen); 43 | let _ = window.set_resizable(true); 44 | let _ = window.center(); 45 | 46 | let send_ui_state = move |ui_state| { 47 | if let Err(error) = window.emit("state", ui_state) { 48 | Err(anyhow!(error)) 49 | } else { 50 | Ok(()) 51 | } 52 | }; 53 | 54 | let launch_data = app.state::(); 55 | match start_runtime( 56 | // TODO: This will probably not work in production. 57 | &app.path_resolver() 58 | .resource_dir() 59 | .unwrap() 60 | .join("..") 61 | .join("..") 62 | .join("config"), 63 | &app.path_resolver() 64 | .resource_dir() 65 | .unwrap() 66 | .join("..") 67 | .join("..") 68 | .join("logs"), 69 | &settings, 70 | &launch_data.teams, 71 | &launch_data.network_interfaces, 72 | Box::new(send_ui_state), 73 | ) 74 | .await 75 | .context("could not start runtime") 76 | { 77 | Ok(runtime_state) => { 78 | app.manage(runtime_state); 79 | } 80 | Err(error) => { 81 | eprintln!("{error:?}"); 82 | app.exit(1); 83 | } 84 | } 85 | 86 | // Now that the RuntimeState is managed, we can tell the UI that it can proceed. 87 | runtime_notify.notify_one(); 88 | } 89 | 90 | /// This function should be called once by the UI after it listens to UI events, but before it 91 | /// calls [apply_action] or [declare_actions]. The caller gets the combined parameters of the game 92 | /// and competition. It is wrapped in a [Result] as a tauri workaround. 93 | #[command] 94 | async fn sync_with_backend(app: AppHandle, state: State<'_, SyncState>) -> Result { 95 | // Wait until manage has been called. 96 | state.0.notified().await; 97 | // Now we can obtain a handle to the RuntimeState to notify the runtime thread that it can 98 | // start sending UI events. 99 | let runtime_state = app.state::(); 100 | runtime_state.ui_notify.notify_one(); 101 | Ok(runtime_state.params.clone()) 102 | } 103 | 104 | /// This function enqueues an action to be applied to the game. 105 | #[command] 106 | fn apply_action(action: VAction, state: State) { 107 | let _ = state.action_sender.send(action); 108 | } 109 | 110 | /// This function lets the UI declare actions for which it wants to know whether they are legal. 111 | #[command] 112 | fn declare_actions(actions: Vec, state: State) { 113 | let _ = state.subscribed_actions_sender.send(actions); 114 | } 115 | 116 | /// This function returns a handler that can be passed to [tauri::Builder::invoke_handler]. 117 | /// It must be boxed because otherwise its size is unknown at compile time. 118 | pub fn get_invoke_handler() -> Box> { 119 | Box::new(generate_handler![ 120 | apply_action, 121 | declare_actions, 122 | get_launch_data, 123 | launch, 124 | sync_with_backend, 125 | ]) 126 | } 127 | -------------------------------------------------------------------------------- /game_controller_app/src/main.rs: -------------------------------------------------------------------------------- 1 | //! This crate defines the main program of the GameController application. 2 | 3 | #![cfg_attr( 4 | all(not(debug_assertions), target_os = "windows"), 5 | windows_subsystem = "windows" 6 | )] 7 | 8 | use clap::Parser; 9 | use tauri::{async_runtime, generate_context, Manager, RunEvent, WindowBuilder, WindowUrl}; 10 | 11 | use game_controller_runtime::{ 12 | cli::Args, launch::make_launch_data, shutdown_runtime, RuntimeState, 13 | }; 14 | 15 | mod handlers; 16 | 17 | use handlers::get_invoke_handler; 18 | 19 | /// This function runs the tauri app. It first parses command line arguments and displays a 20 | /// launcher in which the user can configure the settings for the game. When the user is done with 21 | /// that, the main window and network services are started and shut down when the app is quit. 22 | fn main() { 23 | // Parse the command line arguments first. This includes handling the version and help commands 24 | // and wrong arguments. 25 | let args = Args::parse(); 26 | 27 | // We want to manage an external tokio runtime, mainly to keep dependencies to tauri minimal, 28 | // but also because I don't know how to do the shutdown correctly otherwise. 29 | let runtime = tokio::runtime::Builder::new_multi_thread() 30 | .enable_all() 31 | .build() 32 | .unwrap(); 33 | async_runtime::set(runtime.handle().clone()); 34 | 35 | let app = tauri::Builder::default() 36 | .setup(|app| { 37 | // TODO: This will probably not work in production. 38 | let config_directory = app 39 | .path_resolver() 40 | .resource_dir() 41 | .unwrap() 42 | .join("..") 43 | .join("..") 44 | .join("config"); 45 | match make_launch_data(&config_directory, args) { 46 | Ok(launch_data) => { 47 | app.manage(launch_data); 48 | } 49 | Err(error) => { 50 | eprintln!("{error:?}"); 51 | app.handle().exit(1); 52 | } 53 | }; 54 | 55 | let _window = WindowBuilder::new(app, "main", WindowUrl::App("index.html".into())) 56 | .center() 57 | .inner_size(640.0, 480.0) 58 | .resizable(false) 59 | .title("GameController") 60 | .build()?; 61 | Ok(()) 62 | }) 63 | .invoke_handler(get_invoke_handler()) 64 | .build(generate_context!()) 65 | .expect("error while running tauri application"); 66 | 67 | app.run(move |handle, event| { 68 | if let RunEvent::Exit = event { 69 | if let Some(runtime_state) = handle.try_state::() { 70 | runtime.block_on(shutdown_runtime(&runtime_state)); 71 | } 72 | } 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /game_controller_app/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "beforeBuildCommand": "npm run build", 4 | "beforeDevCommand": "cd frontend && npm run dev", 5 | "devPath": "http://localhost:3000", 6 | "distDir": "../frontend/build" 7 | }, 8 | "package": { 9 | "productName": "GameController", 10 | "version": "4.0.0-rc.1" 11 | }, 12 | "tauri": { 13 | "bundle": { 14 | "active": true, 15 | "category": "Utility", 16 | "copyright": "", 17 | "deb": { 18 | "depends": [] 19 | }, 20 | "externalBin": [], 21 | "icon": [], 22 | "identifier": "org.robocup.spl.game-controller", 23 | "longDescription": "", 24 | "macOS": { 25 | "entitlements": null, 26 | "exceptionDomain": "", 27 | "frameworks": [], 28 | "providerShortName": null, 29 | "signingIdentity": null 30 | }, 31 | "resources": [], 32 | "shortDescription": "The GameController for the RoboCup Standard Platform League", 33 | "targets": "all", 34 | "windows": { 35 | "certificateThumbprint": null, 36 | "digestAlgorithm": "sha256", 37 | "timestampUrl": "" 38 | } 39 | }, 40 | "security": { 41 | "csp": "default-src 'self'" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /game_controller_core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | edition = { workspace = true } 4 | license = { workspace = true } 5 | name = "game_controller_core" 6 | repository = { workspace = true } 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | 10 | [dependencies] 11 | enum-map = { workspace = true } 12 | serde = { workspace = true } 13 | serde_with = { workspace = true } 14 | time = { workspace = true } 15 | trait_enum = { workspace = true } 16 | -------------------------------------------------------------------------------- /game_controller_core/src/action.rs: -------------------------------------------------------------------------------- 1 | //! This module defines common types for actions. Actions can be checked for legality against game 2 | //! states and can be applied to game states. The action variant [VAction] implements the [Action] 3 | //! trait and dispatches calls to the inner actions, which must implement the [Action] trait, too. 4 | 5 | use std::time::Duration; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | use trait_enum::trait_enum; 9 | 10 | use crate::{ 11 | actions::*, 12 | timer::{BehaviorAtZero, RunCondition, Timer}, 13 | types::{Game, Params}, 14 | DelayHandler, 15 | }; 16 | 17 | /// This trait must be implemented by all actions in the [crate::actions] module. 18 | pub trait Action { 19 | /// This function applies the action to the game state. 20 | fn execute(&self, c: &mut ActionContext); 21 | 22 | /// This function returns whether the action is legal in the given game state. 23 | fn is_legal(&self, c: &ActionContext) -> bool; 24 | } 25 | 26 | trait_enum! { 27 | /// This is a "variant" of all actions. 28 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 29 | #[serde(tag = "type", content = "args", rename_all = "camelCase")] 30 | pub enum VAction: Action { 31 | AddExtraTime, 32 | FinishHalf, 33 | FinishPenaltyShot, 34 | FinishSetPlay, 35 | FreePenaltyShot, 36 | FreeSetPlay, 37 | GlobalGameStuck, 38 | Goal, 39 | Penalize, 40 | SelectPenaltyShotPlayer, 41 | StartPenaltyShootout, 42 | StartSetPlay, 43 | Substitute, 44 | SwitchHalf, 45 | SwitchTeamMode, 46 | TeamMessage, 47 | Timeout, 48 | Undo, 49 | Unpenalize, 50 | WaitForPenaltyShot, 51 | WaitForReady, 52 | WaitForSetPlay, 53 | } 54 | } 55 | 56 | /// This struct defines a context in which an action is evaluated. 57 | pub struct ActionContext<'a> { 58 | /// The game state on which the action operates. 59 | pub game: &'a mut Game, 60 | /// The parameters which the action uses. 61 | pub params: &'a Params, 62 | delay: Option<&'a mut Option>, 63 | history: Option<&'a mut Vec<(Game, VAction)>>, 64 | } 65 | 66 | impl ActionContext<'_> { 67 | /// This function creates a new action context. 68 | pub fn new<'a>( 69 | game: &'a mut Game, 70 | params: &'a Params, 71 | delay: Option<&'a mut Option>, 72 | history: Option<&'a mut Vec<(Game, VAction)>>, 73 | ) -> ActionContext<'a> { 74 | ActionContext { 75 | game, 76 | params, 77 | delay, 78 | history, 79 | } 80 | } 81 | 82 | /// This function "forks" the game state. If the context is in a delayed game, it does nothing 83 | /// and returns [false]. If the context is in the true game, it forks a delayed game state that 84 | /// does not include the following effects of the current action and returns [true]. The 85 | /// delayed game state will be canceled after a given duration, or if any future action would 86 | /// be illegal in the delayed game state, unless it is accepted by the given ignore function. 87 | pub fn fork( 88 | &mut self, 89 | duration: Duration, 90 | ignore: impl Fn(&VAction) -> bool + Send + 'static, 91 | ) -> bool { 92 | if let Some(delay) = self.delay.as_mut() { 93 | **delay = Some(DelayHandler { 94 | game: self.game.clone(), 95 | timer: Timer::Started { 96 | remaining: duration.try_into().unwrap(), 97 | run_condition: RunCondition::Always, 98 | behavior_at_zero: BehaviorAtZero::Expire(vec![]), 99 | }, 100 | ignore: Box::new(ignore), 101 | }); 102 | true 103 | } else { 104 | false 105 | } 106 | } 107 | 108 | /// This function adds the current game state to the (undo) history, together with the action 109 | /// that will be applied now. 110 | pub fn add_to_history(&mut self, action: VAction) { 111 | if let Some(history) = self.history.as_mut() { 112 | history.push((self.game.clone(), action)); 113 | } 114 | } 115 | 116 | /// This function checks if a given number of previous actions can be undone. 117 | pub fn is_undo_available(&self, back: u32) -> bool { 118 | self.history 119 | .as_ref() 120 | .is_some_and(|history| history.len() >= (back as usize)) 121 | } 122 | 123 | /// This function reverts the game state to the state before a given number of actions. 124 | pub fn undo(&mut self, back: u32) { 125 | if let Some(history) = self.history.as_mut() { 126 | // If you think that there is an off-by-one error here, consider that when this 127 | // function is called, the state immediately before the undo action has been added to 128 | // the history as well (which was not there when is_undo_available was called). 129 | for _i in 0..back { 130 | history.pop(); 131 | } 132 | if let Some(entry) = history.pop() { 133 | *self.game = entry.0; 134 | } 135 | } 136 | } 137 | 138 | /// This function returns the delayed game state if there is some, or [None]. 139 | pub fn delayed_game(&self) -> Option<&Game> { 140 | self.delay 141 | .as_ref() 142 | .and_then(|delay| delay.as_ref().map(|delay| &delay.game)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/add_extra_time.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::action::{Action, ActionContext}; 6 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 7 | use crate::types::{Phase, State}; 8 | 9 | /// This struct defines an action that adds a minute of extra time. 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | pub struct AddExtraTime; 12 | 13 | impl AddExtraTime { 14 | const MINUTE: Duration = Duration::from_secs(60); 15 | } 16 | 17 | impl Action for AddExtraTime { 18 | fn execute(&self, c: &mut ActionContext) { 19 | c.game.primary_timer = Timer::Started { 20 | remaining: c.game.primary_timer.get_remaining() + Self::MINUTE, 21 | run_condition: RunCondition::MainTimer, 22 | behavior_at_zero: BehaviorAtZero::Overflow, 23 | }; 24 | c.game.teams.values_mut().for_each(|team| { 25 | if !team.illegal_communication { 26 | team.message_budget += c.params.competition.messages_per_team_per_extra_minute; 27 | } 28 | }); 29 | } 30 | 31 | fn is_legal(&self, c: &ActionContext) -> bool { 32 | c.game.phase != Phase::PenaltyShootout 33 | && c.game.state != State::Playing 34 | && matches!(c.game.primary_timer, Timer::Started { .. }) 35 | && c.game.primary_timer.get_remaining() + Self::MINUTE 36 | < c.params.competition.half_duration 37 | && c.params.competition.challenge_mode.is_none() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/finish_half.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext, VAction}; 4 | use crate::actions::SwitchHalf; 5 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 6 | use crate::types::{Phase, SetPlay, State}; 7 | 8 | /// This struct defines an action which corresponds to the referee call "Finish" (or rather 9 | /// two/three successive whistles). 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | pub struct FinishHalf; 12 | 13 | impl Action for FinishHalf { 14 | fn execute(&self, c: &mut ActionContext) { 15 | // Cancel all penalty timers. 16 | c.game.teams.values_mut().for_each(|team| { 17 | team.players.iter_mut().for_each(|player| { 18 | player.penalty_timer = Timer::Stopped; 19 | }) 20 | }); 21 | 22 | c.game.secondary_timer = Timer::Stopped; 23 | c.game.timeout_rewind_timer = Timer::Stopped; 24 | c.game.set_play = SetPlay::NoSetPlay; 25 | c.game.kicking_side = None; 26 | c.game.state = State::Finished; 27 | 28 | // After the first half, a timer counts down the half-time break. 29 | if c.game.phase == Phase::FirstHalf && c.params.competition.challenge_mode.is_none() { 30 | c.game.secondary_timer = Timer::Started { 31 | remaining: c 32 | .params 33 | .competition 34 | .half_time_break_duration 35 | .try_into() 36 | .unwrap(), 37 | run_condition: RunCondition::Always, 38 | behavior_at_zero: BehaviorAtZero::Overflow, 39 | }; 40 | c.game.switch_half_timer = Timer::Started { 41 | remaining: (c.params.competition.half_time_break_duration / 2) 42 | .try_into() 43 | .unwrap(), 44 | run_condition: RunCondition::Always, 45 | behavior_at_zero: BehaviorAtZero::Expire(vec![VAction::SwitchHalf(SwitchHalf)]), 46 | }; 47 | } 48 | } 49 | 50 | fn is_legal(&self, c: &ActionContext) -> bool { 51 | c.game.phase != Phase::PenaltyShootout 52 | && (c.game.state == State::Playing 53 | || c.game.state == State::Ready 54 | || c.game.state == State::Set) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/finish_penalty_shot.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{Phase, State}; 6 | 7 | /// This struct defines an action which corresponds to the referee call "Finish" in a penalty 8 | /// shoot-out. 9 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 10 | pub struct FinishPenaltyShot; 11 | 12 | impl Action for FinishPenaltyShot { 13 | fn execute(&self, c: &mut ActionContext) { 14 | // Cancel all penalty timers (only for consistency with FinishHalf). 15 | c.game.teams.values_mut().for_each(|team| { 16 | team.players.iter_mut().for_each(|player| { 17 | player.penalty_timer = Timer::Stopped; 18 | }) 19 | }); 20 | 21 | c.game.state = State::Finished; 22 | } 23 | 24 | fn is_legal(&self, c: &ActionContext) -> bool { 25 | c.game.phase == Phase::PenaltyShootout && c.game.state == State::Playing 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/finish_set_play.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{SetPlay, State}; 6 | 7 | /// This struct defines an action which corresponds to the referee call "Ball Free". It is the last 8 | /// part of a set play (i.e. fourth part of "complex" set plays with Ready and Set state and second 9 | /// part of "simple" set plays). 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | pub struct FinishSetPlay; 12 | 13 | impl Action for FinishSetPlay { 14 | fn execute(&self, c: &mut ActionContext) { 15 | c.game.secondary_timer = Timer::Stopped; 16 | c.game.set_play = SetPlay::NoSetPlay; 17 | c.game.kicking_side = None; 18 | } 19 | 20 | fn is_legal(&self, c: &ActionContext) -> bool { 21 | c.game.state == State::Playing && c.game.set_play != SetPlay::NoSetPlay 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/free_penalty_shot.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::types::{Penalty, Phase, State}; 5 | 6 | /// This struct defines an action which corresponds to the referee call "Playing" in a penalty 7 | /// shoot-out. 8 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 9 | pub struct FreePenaltyShot; 10 | 11 | impl Action for FreePenaltyShot { 12 | fn execute(&self, c: &mut ActionContext) { 13 | if !c.params.game.test.no_delay 14 | && !c.fork(c.params.competition.delay_after_playing, |_| false) 15 | { 16 | return; 17 | } 18 | 19 | c.game.state = State::Playing; 20 | } 21 | 22 | fn is_legal(&self, c: &ActionContext) -> bool { 23 | c.game.phase == Phase::PenaltyShootout 24 | && c.game.state == State::Set 25 | && c.game.teams.values().all(|team| { 26 | team.players 27 | .iter() 28 | .filter(|player| player.penalty != Penalty::Substitute) 29 | .count() 30 | == 1 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/free_set_play.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext, VAction}; 4 | use crate::actions::FinishSetPlay; 5 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 6 | use crate::types::{SetPlay, State}; 7 | 8 | /// This struct defines an action which corresponds to the referee call "Playing". It is the third 9 | /// part of "complex" set plays which have a Ready and Set state. 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | pub struct FreeSetPlay; 12 | 13 | impl Action for FreeSetPlay { 14 | fn execute(&self, c: &mut ActionContext) { 15 | // FinishSetPlay is not a reason to cancel the delayed state because that would mean that 16 | // e.g. a kick-off is delayed for only 10 seconds instead of the desired 15 seconds. 17 | if !c.params.game.test.no_delay 18 | && !c.fork(c.params.competition.delay_after_playing, |action| { 19 | matches!(action, VAction::FinishSetPlay(_)) 20 | }) 21 | { 22 | return; 23 | } 24 | 25 | if !c.params.competition.set_plays[c.game.set_play] 26 | .duration 27 | .is_zero() 28 | && c.game.kicking_side.is_some() 29 | { 30 | c.game.secondary_timer = Timer::Started { 31 | remaining: c.params.competition.set_plays[c.game.set_play] 32 | .duration 33 | .try_into() 34 | .unwrap(), 35 | run_condition: RunCondition::Always, 36 | behavior_at_zero: BehaviorAtZero::Expire(vec![VAction::FinishSetPlay( 37 | FinishSetPlay, 38 | )]), 39 | }; 40 | } else { 41 | c.game.secondary_timer = Timer::Stopped; 42 | c.game.set_play = SetPlay::NoSetPlay; 43 | } 44 | c.game.timeout_rewind_timer = Timer::Stopped; 45 | c.game.state = State::Playing; 46 | } 47 | 48 | fn is_legal(&self, c: &ActionContext) -> bool { 49 | c.game.state == State::Set && c.game.set_play != SetPlay::NoSetPlay 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/global_game_stuck.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::actions::StartSetPlay; 5 | use crate::types::{Phase, SetPlay, State}; 6 | 7 | /// This struct defines an action which corresponds to the referee call "Global Game Stuck". 8 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 9 | pub struct GlobalGameStuck; 10 | 11 | impl Action for GlobalGameStuck { 12 | fn execute(&self, c: &mut ActionContext) { 13 | StartSetPlay { 14 | side: None, 15 | set_play: SetPlay::KickOff, 16 | } 17 | .execute(c); 18 | } 19 | 20 | fn is_legal(&self, c: &ActionContext) -> bool { 21 | c.game.phase != Phase::PenaltyShootout && c.game.state == State::Playing 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/goal.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::actions::{FinishHalf, StartSetPlay}; 5 | use crate::timer::Timer; 6 | use crate::types::{Phase, SetPlay, Side, State}; 7 | 8 | /// This struct defines an action for when a goal has been scored. 9 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct Goal { 12 | /// The side which has scored a goal. 13 | pub side: Side, 14 | } 15 | 16 | impl Action for Goal { 17 | fn execute(&self, c: &mut ActionContext) { 18 | // Mercy rule: At a certain goal difference, the game is finished. 19 | let mercy_rule = c.game.phase != Phase::PenaltyShootout 20 | && !c.game.teams[self.side].illegal_communication 21 | && (c.game.teams[self.side].score + 1) 22 | >= c.game.teams[-self.side].score 23 | + c.params.competition.mercy_rule_score_difference; 24 | if !c.params.game.test.no_delay 25 | && c.game.phase != Phase::PenaltyShootout 26 | && c.params.competition.challenge_mode.is_none() 27 | && !mercy_rule 28 | && !c.fork(c.params.competition.delay_after_goal, |_| false) 29 | { 30 | return; 31 | } 32 | 33 | if !c.game.teams[self.side].illegal_communication { 34 | c.game.teams[self.side].score += 1; 35 | } 36 | if mercy_rule || c.params.competition.challenge_mode.is_some() { 37 | c.game.teams.values_mut().for_each(|team| { 38 | team.players.iter_mut().for_each(|player| { 39 | player.penalty_timer = Timer::Stopped; 40 | }) 41 | }); 42 | if mercy_rule { 43 | c.game.phase = Phase::SecondHalf; 44 | } 45 | FinishHalf.execute(c); 46 | } else if c.game.phase != Phase::PenaltyShootout { 47 | // A kick-off for the other team. 48 | StartSetPlay { 49 | side: Some(-self.side), 50 | set_play: SetPlay::KickOff, 51 | } 52 | .execute(c); 53 | } else { 54 | c.game.teams[self.side].penalty_shot_mask |= 55 | 1u16 << (c.game.teams[self.side].penalty_shot - 1); 56 | c.game.state = State::Finished; 57 | } 58 | } 59 | 60 | fn is_legal(&self, c: &ActionContext) -> bool { 61 | c.game.state == State::Playing 62 | && (c.game.phase != Phase::PenaltyShootout 63 | || c.game.kicking_side.is_none_or(|side| side == self.side)) 64 | && (c.params.competition.challenge_mode.is_none() 65 | || self.side 66 | == (if c.game.phase == Phase::FirstHalf { 67 | c.params.game.kick_off_side 68 | } else { 69 | -c.params.game.kick_off_side 70 | })) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module contains all actions. 2 | 3 | mod add_extra_time; 4 | mod finish_half; 5 | mod finish_penalty_shot; 6 | mod finish_set_play; 7 | mod free_penalty_shot; 8 | mod free_set_play; 9 | mod global_game_stuck; 10 | mod goal; 11 | mod penalize; 12 | mod select_penalty_shot_player; 13 | mod start_penalty_shootout; 14 | mod start_set_play; 15 | mod substitute; 16 | mod switch_half; 17 | mod switch_team_mode; 18 | mod team_message; 19 | mod timeout; 20 | mod undo; 21 | mod unpenalize; 22 | mod wait_for_penalty_shot; 23 | mod wait_for_ready; 24 | mod wait_for_set_play; 25 | 26 | pub use add_extra_time::AddExtraTime; 27 | pub use finish_half::FinishHalf; 28 | pub use finish_penalty_shot::FinishPenaltyShot; 29 | pub use finish_set_play::FinishSetPlay; 30 | pub use free_penalty_shot::FreePenaltyShot; 31 | pub use free_set_play::FreeSetPlay; 32 | pub use global_game_stuck::GlobalGameStuck; 33 | pub use goal::Goal; 34 | pub use penalize::Penalize; 35 | pub use select_penalty_shot_player::SelectPenaltyShotPlayer; 36 | pub use start_penalty_shootout::StartPenaltyShootout; 37 | pub use start_set_play::StartSetPlay; 38 | pub use substitute::Substitute; 39 | pub use switch_half::SwitchHalf; 40 | pub use switch_team_mode::SwitchTeamMode; 41 | pub use team_message::TeamMessage; 42 | pub use timeout::Timeout; 43 | pub use undo::Undo; 44 | pub use unpenalize::Unpenalize; 45 | pub use wait_for_penalty_shot::WaitForPenaltyShot; 46 | pub use wait_for_ready::WaitForReady; 47 | pub use wait_for_set_play::WaitForSetPlay; 48 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/select_penalty_shot_player.rs: -------------------------------------------------------------------------------- 1 | use std::mem::replace; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::action::{Action, ActionContext}; 6 | use crate::timer::Timer; 7 | use crate::types::{Penalty, Phase, Player, PlayerNumber, Side}; 8 | 9 | /// This struct defines an action to select the player in a penalty shoot-out. 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct SelectPenaltyShotPlayer { 13 | /// The side which selects a player. 14 | pub side: Side, 15 | /// The player who is selected. 16 | pub player: PlayerNumber, 17 | /// Whether the player is a goalkeeper (i.e. wearing a goalkeeper jersey). 18 | pub goalkeeper: bool, 19 | } 20 | 21 | impl Action for SelectPenaltyShotPlayer { 22 | fn execute(&self, c: &mut ActionContext) { 23 | // Penalize all players while transferring the penalty of the previously selected player to 24 | // the new player. If no player was previously selected, it has no penalty. 25 | c.game.teams[self.side][self.player] = c.game.teams[self.side] 26 | .players 27 | .iter_mut() 28 | .map(|player| { 29 | replace( 30 | player, 31 | Player { 32 | penalty: Penalty::Substitute, 33 | penalty_timer: Timer::Stopped, 34 | }, 35 | ) 36 | }) 37 | .find(|player| player.penalty != Penalty::Substitute) 38 | .unwrap_or(Player { 39 | penalty: Penalty::NoPenalty, 40 | penalty_timer: Timer::Stopped, 41 | }); 42 | 43 | c.game.teams[self.side].goalkeeper = if self.goalkeeper { 44 | Some(self.player) 45 | } else { 46 | None 47 | }; 48 | } 49 | 50 | fn is_legal(&self, c: &ActionContext) -> bool { 51 | c.game.phase == Phase::PenaltyShootout 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/start_penalty_shootout.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{Penalty, Phase, SetPlay, Side, SideMapping, State}; 6 | 7 | /// This struct defines an action which starts a penalty (kick) shoot-out. To disambiguate this 8 | /// from penalty kicks as set plays within the game, penalty kicks in a penalty (kick) shoot-out 9 | /// are mostly referred to as "penalty shots". 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct StartPenaltyShootout { 13 | /// This defines the goal on which all penalty shots are taken. Since the home team always has 14 | /// the first kick, [SideMapping::HomeDefendsLeftGoal] means that the right goal is used. 15 | pub sides: SideMapping, 16 | } 17 | 18 | impl Action for StartPenaltyShootout { 19 | fn execute(&self, c: &mut ActionContext) { 20 | // Make all players substitutes. 21 | c.game.teams.values_mut().for_each(|team| { 22 | team.goalkeeper = None; 23 | team.fallback_mode = false; 24 | team.penalty_shot = 0; 25 | team.penalty_shot_mask = 0; 26 | team.players.iter_mut().for_each(|player| { 27 | player.penalty = Penalty::Substitute; 28 | player.penalty_timer = Timer::Stopped; 29 | }); 30 | }); 31 | 32 | c.game.sides = self.sides; 33 | c.game.phase = Phase::PenaltyShootout; 34 | c.game.state = State::Initial; 35 | c.game.set_play = SetPlay::NoSetPlay; 36 | // "The first (left) team in the GameController will have the striker robot for the first 37 | // penalty kick." - 2023 rule book section 3.16 38 | c.game.kicking_side = Some(Side::Home); 39 | c.game.primary_timer = Timer::Stopped; 40 | c.game.secondary_timer = Timer::Stopped; 41 | } 42 | 43 | fn is_legal(&self, c: &ActionContext) -> bool { 44 | c.game.phase == Phase::SecondHalf 45 | && c.game.state == State::Finished 46 | && (c.game.teams[Side::Home].score == c.game.teams[Side::Away].score 47 | || c.params.game.test.penalty_shootout) 48 | && c.params.competition.challenge_mode.is_none() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/start_set_play.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext, VAction}; 4 | use crate::actions::{FinishSetPlay, WaitForSetPlay}; 5 | use crate::timer::{BehaviorAtZero, RunCondition, SignedDuration, Timer}; 6 | use crate::types::{Phase, SetPlay, Side, State}; 7 | 8 | /// This struct defines an action to start a set play. Depending on the set play type, this means 9 | /// switching to the Ready state or just setting a flag for the current set play within the Playing 10 | /// state. 11 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 12 | #[serde(rename_all = "camelCase")] 13 | pub struct StartSetPlay { 14 | /// The side which can execute the set play. 15 | pub side: Option, 16 | /// The type of set play to start. 17 | pub set_play: SetPlay, 18 | } 19 | 20 | impl Action for StartSetPlay { 21 | fn execute(&self, c: &mut ActionContext) { 22 | if !c.params.game.test.no_delay 23 | && self.set_play == SetPlay::KickOff 24 | && c.game.state == State::Standby 25 | && !c.fork(c.params.competition.delay_after_ready, |_| false) 26 | { 27 | return; 28 | } 29 | 30 | if !c.params.competition.set_plays[self.set_play] 31 | .ready_duration 32 | .is_zero() 33 | { 34 | c.game.secondary_timer = Timer::Started { 35 | remaining: c.params.competition.set_plays[self.set_play] 36 | .ready_duration 37 | .try_into() 38 | .unwrap(), 39 | run_condition: RunCondition::Always, 40 | // Automatically transition to the Set state when the timer expires. 41 | behavior_at_zero: BehaviorAtZero::Expire(vec![VAction::WaitForSetPlay( 42 | WaitForSetPlay, 43 | )]), 44 | }; 45 | // This timer counts the time during the Ready and Set states (negatively) so it can be 46 | // added back to the primary timer when taking a timeout. It uses the same run 47 | // condition as the primary timer, so if the primary counter doesn't count down, the 48 | // time won't be added back to it. 49 | c.game.timeout_rewind_timer = Timer::Started { 50 | remaining: SignedDuration::ZERO, 51 | run_condition: RunCondition::MainTimer, 52 | behavior_at_zero: BehaviorAtZero::Overflow, 53 | }; 54 | c.game.state = State::Ready; 55 | } else { 56 | c.game.secondary_timer = Timer::Started { 57 | remaining: c.params.competition.set_plays[self.set_play] 58 | .duration 59 | .try_into() 60 | .unwrap(), 61 | run_condition: RunCondition::Always, 62 | // Automatically deactivate the set play when the timer expires. 63 | behavior_at_zero: BehaviorAtZero::Expire(vec![VAction::FinishSetPlay( 64 | FinishSetPlay, 65 | )]), 66 | }; 67 | } 68 | c.game.set_play = self.set_play; 69 | c.game.kicking_side = self.side; 70 | } 71 | 72 | fn is_legal(&self, c: &ActionContext) -> bool { 73 | let has_standby_state = !c.params.competition.delay_after_ready.is_zero(); 74 | self.set_play != SetPlay::NoSetPlay 75 | && c.game.phase != Phase::PenaltyShootout 76 | && (if self.set_play == SetPlay::KickOff { 77 | // For kick-offs, the kicking side is pre-filled so that only that team can take 78 | // the kick-off. 79 | (if has_standby_state { 80 | c.game.state == State::Standby 81 | } else { 82 | c.game.state == State::Initial || c.game.state == State::Timeout 83 | }) && c.game.kicking_side == self.side 84 | } else { 85 | // All set plays other than kick-off must be "for" some team. 86 | self.side.is_some() 87 | // It must be Playing, and we can only start set play during other set plays if 88 | // they are for the other team (this is a shortcut, because FinishSetPlay should 89 | // have been clicked before). 90 | && c.game.state == State::Playing 91 | && (c.game.set_play == SetPlay::NoSetPlay || c.game.kicking_side != self.side) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/substitute.rs: -------------------------------------------------------------------------------- 1 | use std::mem::replace; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::action::{Action, ActionContext}; 6 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 7 | use crate::types::{Penalty, Phase, PlayerNumber, Side, State}; 8 | 9 | /// This struct defines an action to substitute players. 10 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct Substitute { 13 | /// The side which does the substitution. 14 | pub side: Side, 15 | /// The player who comes in (currently a substitute). 16 | pub player_in: PlayerNumber, 17 | /// The player who comes off (will become a substitute). 18 | pub player_out: PlayerNumber, 19 | } 20 | 21 | impl Action for Substitute { 22 | fn execute(&self, c: &mut ActionContext) { 23 | if c.game.teams[self.side][self.player_out].penalty == Penalty::NoPenalty 24 | && matches!(c.game.state, State::Ready | State::Set | State::Playing) 25 | { 26 | // Players that are substituted while not being penalized must still wait as if they 27 | // were picked-up. 28 | assert!(!c.params.competition.penalties[Penalty::PickedUp].incremental); 29 | c.game.teams[self.side][self.player_in].penalty = Penalty::PickedUp; 30 | c.game.teams[self.side][self.player_in].penalty_timer = Timer::Started { 31 | remaining: c.params.competition.penalties[Penalty::PickedUp] 32 | .duration 33 | .try_into() 34 | .unwrap(), 35 | run_condition: RunCondition::ReadyOrPlaying, 36 | behavior_at_zero: BehaviorAtZero::Clip, 37 | }; 38 | c.game.teams[self.side][self.player_out].penalty_timer = Timer::Stopped; 39 | } else { 40 | // Inherit the penalty and the timer. 41 | c.game.teams[self.side][self.player_in].penalty = 42 | c.game.teams[self.side][self.player_out].penalty; 43 | c.game.teams[self.side][self.player_in].penalty_timer = replace( 44 | &mut c.game.teams[self.side][self.player_out].penalty_timer, 45 | Timer::Stopped, 46 | ); 47 | } 48 | c.game.teams[self.side][self.player_out].penalty = Penalty::Substitute; 49 | if c.game.teams[self.side].goalkeeper == Some(self.player_out) { 50 | c.game.teams[self.side].goalkeeper = Some(self.player_in); 51 | } 52 | } 53 | 54 | fn is_legal(&self, c: &ActionContext) -> bool { 55 | c.game.phase != Phase::PenaltyShootout 56 | && self.player_in != self.player_out 57 | && c.game.teams[self.side][self.player_in].penalty == Penalty::Substitute 58 | && c.game.teams[self.side][self.player_out].penalty != Penalty::Substitute 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/switch_half.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 5 | use crate::types::{Penalty, Phase, State}; 6 | 7 | /// This struct defines an action that switches from the end of the first half to the beginning of 8 | /// the second half, including the switch of sides. 9 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 10 | pub struct SwitchHalf; 11 | 12 | impl Action for SwitchHalf { 13 | fn execute(&self, c: &mut ActionContext) { 14 | // Unpenalize all players that are not substitutes. Maybe picked up players should stay 15 | // picked up, but the old GameController unpenalized them, too. 16 | c.game.teams.values_mut().for_each(|team| { 17 | team.players 18 | .iter_mut() 19 | .filter(|player| player.penalty != Penalty::Substitute) 20 | .for_each(|player| { 21 | player.penalty = Penalty::NoPenalty; 22 | player.penalty_timer = Timer::Stopped; 23 | }) 24 | }); 25 | 26 | c.game.sides = -c.params.game.side_mapping; 27 | c.game.phase = Phase::SecondHalf; 28 | c.game.state = State::Initial; 29 | c.game.kicking_side = Some(-c.params.game.kick_off_side); 30 | 31 | c.game.primary_timer = Timer::Started { 32 | remaining: c.params.competition.half_duration.try_into().unwrap(), 33 | run_condition: RunCondition::MainTimer, 34 | behavior_at_zero: BehaviorAtZero::Overflow, 35 | }; 36 | c.game.switch_half_timer = Timer::Stopped; 37 | } 38 | 39 | fn is_legal(&self, c: &ActionContext) -> bool { 40 | c.game.phase == Phase::FirstHalf 41 | && c.game.state == State::Finished 42 | && c.params.competition.challenge_mode.is_none() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/switch_team_mode.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{Penalty, Phase, Player, PlayerNumber, Side, State}; 6 | 7 | /// This struct defines an action that switches between "normal mode" and "fallback mode". 8 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 9 | pub struct SwitchTeamMode { 10 | /// The side which switches its mode. 11 | pub side: Side, 12 | } 13 | 14 | impl Action for SwitchTeamMode { 15 | fn execute(&self, c: &mut ActionContext) { 16 | if let Some(players_per_team_fallback_mode) = 17 | c.params.competition.players_per_team_fallback_mode 18 | { 19 | if players_per_team_fallback_mode < c.params.competition.players_per_team { 20 | type SwitchParameters<'a> = ( 21 | Box bool + 'a>, 22 | Penalty, 23 | u8, 24 | ); 25 | let goalkeeper_index = c.game.teams[self.side] 26 | .goalkeeper 27 | .map(|goalkeeper| u8::from(goalkeeper) - PlayerNumber::MIN); 28 | let (predicate, penalty, target_players): SwitchParameters = 29 | if !c.game.teams[self.side].fallback_mode { 30 | ( 31 | Box::new(|(index, player)| { 32 | player.penalty != Penalty::Substitute 33 | && goalkeeper_index 34 | .is_none_or(|goalkeeper| goalkeeper != (*index as u8)) 35 | }), 36 | Penalty::Substitute, 37 | players_per_team_fallback_mode, 38 | ) 39 | } else { 40 | ( 41 | Box::new(|(_, player)| player.penalty == Penalty::Substitute), 42 | Penalty::NoPenalty, 43 | c.params.competition.players_per_team, 44 | ) 45 | }; 46 | c.game.teams[self.side] 47 | .players 48 | .iter_mut() 49 | .enumerate() 50 | .filter(predicate) 51 | .map(|(_, player)| player) 52 | .take( 53 | (c.params.competition.players_per_team - players_per_team_fallback_mode) 54 | as usize, 55 | ) 56 | .for_each(|player| { 57 | player.penalty = penalty; 58 | player.penalty_timer = Timer::Stopped; 59 | }); 60 | assert!( 61 | c.game.teams[self.side] 62 | .players 63 | .iter() 64 | .filter(|player| player.penalty != Penalty::Substitute) 65 | .count() 66 | == target_players.into() 67 | ); 68 | } 69 | c.game.teams[self.side].fallback_mode = !c.game.teams[self.side].fallback_mode; 70 | } 71 | } 72 | 73 | fn is_legal(&self, c: &ActionContext) -> bool { 74 | c.params 75 | .competition 76 | .players_per_team_fallback_mode 77 | .is_some() 78 | && c.game.phase != Phase::PenaltyShootout 79 | && (c.game.state == State::Initial 80 | || (c.game.state == State::Timeout && !c.game.teams[self.side].fallback_mode)) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/team_message.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::types::{Phase, Side, State}; 5 | 6 | /// This struct defines an action that is triggered when a team message is received. 7 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct TeamMessage { 10 | /// The side which sent the team message. 11 | pub side: Side, 12 | /// Whether the message itself has an illegal format. 13 | pub illegal: bool, 14 | } 15 | 16 | impl Action for TeamMessage { 17 | fn execute(&self, c: &mut ActionContext) { 18 | if c.game.teams[self.side].message_budget == 0 || self.illegal { 19 | c.game.teams[self.side].illegal_communication = true; 20 | c.game.teams[self.side].score = 0; 21 | } 22 | if c.game.teams[self.side].message_budget > 0 { 23 | c.game.teams[self.side].message_budget -= 1; 24 | } 25 | } 26 | 27 | fn is_legal(&self, c: &ActionContext) -> bool { 28 | // Team messages are only counted during those states. 29 | c.game.phase != Phase::PenaltyShootout 30 | && (c.game.state == State::Standby 31 | || c.game.state == State::Ready 32 | || c.game.state == State::Set 33 | || c.game.state == State::Playing) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/timeout.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 5 | use crate::types::{Phase, SetPlay, Side, State}; 6 | 7 | /// This struct defines an action for when a team or the referee takes a timeout. 8 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct Timeout { 11 | /// The side which takes the timeout or [None] for a referee timeout. 12 | pub side: Option, 13 | } 14 | 15 | impl Action for Timeout { 16 | fn execute(&self, c: &mut ActionContext) { 17 | // Cancel all penalty timers. 18 | c.game.teams.values_mut().for_each(|team| { 19 | team.players.iter_mut().for_each(|player| { 20 | player.penalty_timer = Timer::Stopped; 21 | }) 22 | }); 23 | 24 | if c.game.phase != Phase::PenaltyShootout { 25 | // If this is not a referee timeout, the next kick-off is for the other team. 26 | // Otherwise, the kicking side is kept unless we're not in or before a kick-off, in 27 | // which case there is a dropped ball. 28 | if let Some(side) = self.side { 29 | c.game.kicking_side = Some(-side); 30 | } else if c.game.phase != Phase::PenaltyShootout 31 | && c.game.state != State::Initial 32 | && c.game.state != State::Standby 33 | && c.game.state != State::Timeout 34 | && c.game.set_play != SetPlay::KickOff 35 | { 36 | c.game.kicking_side = None; 37 | } 38 | // The primary timer is rewound to the time when the stoppage of play has started. 39 | c.game.primary_timer = Timer::Started { 40 | remaining: c.game.primary_timer.get_remaining() 41 | - c.game.timeout_rewind_timer.get_remaining(), 42 | run_condition: RunCondition::MainTimer, 43 | behavior_at_zero: BehaviorAtZero::Overflow, 44 | }; 45 | c.game.timeout_rewind_timer = Timer::Stopped; 46 | } 47 | let duration = if self.side.is_some() { 48 | c.params.competition.timeout_duration 49 | } else { 50 | c.params.competition.referee_timeout_duration 51 | }; 52 | c.game.secondary_timer = Timer::Started { 53 | // In some cases, an existing timer is modified to avoid situations like "We are going 54 | // to take a timeout once their timeout is over". However, we don't want that in the 55 | // half-time break if the timer is already negative because this happens in interleaved 56 | // games. 57 | remaining: if c.game.state == State::Timeout 58 | || ((c.game.state == State::Initial || c.game.state == State::Standby) 59 | && c.game.phase == Phase::SecondHalf 60 | && c.game.secondary_timer.get_remaining().is_positive()) 61 | { 62 | c.game.secondary_timer.get_remaining() + duration 63 | } else { 64 | duration.try_into().unwrap() 65 | }, 66 | run_condition: RunCondition::Always, 67 | behavior_at_zero: BehaviorAtZero::Overflow, 68 | }; 69 | c.game.state = State::Timeout; 70 | c.game.set_play = SetPlay::NoSetPlay; 71 | if let Some(side) = self.side { 72 | c.game.teams[side].timeout_budget -= 1; 73 | } 74 | } 75 | 76 | fn is_legal(&self, c: &ActionContext) -> bool { 77 | (c.game.phase != Phase::PenaltyShootout 78 | || c.game.state == State::Initial 79 | || c.game.state == State::Timeout) 80 | && c.game.state != State::Finished 81 | && self.side.is_none_or(|side| { 82 | // These are additional conditions when it is a team taking a timeout and not the 83 | // referee. 84 | c.game.state != State::Playing 85 | // This check is so you can't take timeouts during a penalty kick Ready/Set. 86 | // The rules don't explicitly rule this out (I think), but it would be 87 | // ridiculous if it was legal. 88 | && (c.game.set_play == SetPlay::NoSetPlay 89 | || c.game.set_play == SetPlay::KickOff) 90 | && c.game.teams[side].timeout_budget > 0 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/undo.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | 5 | /// This struct defines an action which reverts the game to a previous state. 6 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 7 | #[serde(rename_all = "camelCase")] 8 | pub struct Undo { 9 | /// The number of previous user actions to be reverted. 10 | pub states: u32, 11 | } 12 | 13 | impl Action for Undo { 14 | fn execute(&self, c: &mut ActionContext) { 15 | c.undo(self.states); 16 | } 17 | 18 | fn is_legal(&self, c: &ActionContext) -> bool { 19 | c.is_undo_available(self.states) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/unpenalize.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{Penalty, PlayerNumber, Side, State}; 6 | 7 | /// This struct defines an action to unpenalize players. 8 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 9 | #[serde(rename_all = "camelCase")] 10 | pub struct Unpenalize { 11 | /// The side whose player is unpenalized. 12 | pub side: Side, 13 | /// The number of the player who is unpenalized. 14 | pub player: PlayerNumber, 15 | } 16 | 17 | impl Action for Unpenalize { 18 | fn execute(&self, c: &mut ActionContext) { 19 | c.game.teams[self.side][self.player].penalty_timer = Timer::Stopped; 20 | c.game.teams[self.side][self.player].penalty = Penalty::NoPenalty; 21 | } 22 | 23 | fn is_legal(&self, c: &ActionContext) -> bool { 24 | c.game.teams[self.side][self.player].penalty != Penalty::NoPenalty 25 | && c.game.teams[self.side][self.player].penalty != Penalty::Substitute 26 | && (c.game.teams[self.side][self.player] 27 | .penalty_timer 28 | .get_remaining() 29 | .is_zero() 30 | // We allow motion in Set penalties to be revoked while still in Set. 31 | || (c.game.teams[self.side][self.player].penalty == Penalty::MotionInSet 32 | && c.game.state == State::Set) 33 | || c.params.game.test.unpenalize) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/wait_for_penalty_shot.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::{BehaviorAtZero, RunCondition, Timer}; 5 | use crate::types::{Penalty, Phase, Side, State}; 6 | 7 | /// This struct defines an action which corresponds to the referee call "Set" in a penalty 8 | /// shoot-out. 9 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 10 | pub struct WaitForPenaltyShot; 11 | 12 | impl Action for WaitForPenaltyShot { 13 | fn execute(&self, c: &mut ActionContext) { 14 | // If we come from a previous shot, all players are reset to be substitutes and the sides 15 | // are switched. 16 | if c.game.state == State::Finished { 17 | c.game.teams.values_mut().for_each(|team| { 18 | team.goalkeeper = None; 19 | team.players.iter_mut().for_each(|player| { 20 | player.penalty = Penalty::Substitute; 21 | player.penalty_timer = Timer::Stopped; 22 | }); 23 | }); 24 | 25 | c.game.sides = -c.game.sides; 26 | c.game.kicking_side = c.game.kicking_side.map(|side| -side); 27 | } 28 | 29 | c.game.state = State::Set; 30 | c.game.primary_timer = Timer::Started { 31 | remaining: c 32 | .params 33 | .competition 34 | .penalty_shot_duration 35 | .try_into() 36 | .unwrap(), 37 | run_condition: RunCondition::MainTimer, 38 | behavior_at_zero: BehaviorAtZero::Overflow, 39 | }; 40 | c.game.secondary_timer = Timer::Stopped; // This can be set from a previous timeout. 41 | if let Some(side) = c.game.kicking_side { 42 | c.game.teams[side].penalty_shot += 1; 43 | } 44 | } 45 | 46 | fn is_legal(&self, c: &ActionContext) -> bool { 47 | c.game.phase == Phase::PenaltyShootout 48 | && (c.game.state == State::Initial 49 | || c.game.state == State::Timeout 50 | || (c.game.state == State::Finished 51 | && c.game.kicking_side.is_some_and(|side| { 52 | ({ 53 | // At this point, side is the team that just finished its shot, so 54 | // -side is the team that would have the next shot. The following 55 | // should answer the question: Should that team still have a shot or 56 | // not? 57 | let score_difference = (c.game.teams[side].score as i16) 58 | - (c.game.teams[-side].score as i16); 59 | if c.game.teams[-side].penalty_shot < c.params.competition.penalty_shots 60 | { 61 | // We are still in the regular penalty shoot-out. The following 62 | // should answer if still both teams can win. 63 | 64 | // How many shots does the next team still have in the regular 65 | // shoot-out? (is at least 1) 66 | let remaining_for_next = c.params.competition.penalty_shots 67 | - c.game.teams[-side].penalty_shot; 68 | 69 | // How many shots does the last team still have? (can be 0) 70 | let remaining_for_last = c.params.competition.penalty_shots 71 | - c.game.teams[side].penalty_shot; 72 | 73 | // Can the next team still equalize? 74 | score_difference <= remaining_for_next.into() 75 | // Can the last team still equalize? 76 | && -score_difference <= remaining_for_last.into() 77 | } else if c.game.teams[-side].penalty_shot 78 | < c.params.competition.penalty_shots 79 | + c.params.competition.sudden_death_penalty_shots 80 | { 81 | // This means that the next shot will/would be part of the sudden 82 | // death penalty shoot-out. The away team always gets another shot, 83 | // but the home team only continues if the score is still even. At 84 | // this point, there are other criteria to finish the game even if 85 | // neither team scored, but that must be sorted out by the referee. 86 | side == Side::Home || score_difference == 0 87 | } else { 88 | false 89 | } 90 | } || c.params.game.test.penalty_shootout) 91 | }))) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/wait_for_ready.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::types::{Phase, State}; 5 | 6 | /// This struct defines an action which corresponds to the referee call "Standby". 7 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 8 | pub struct WaitForReady; 9 | 10 | impl Action for WaitForReady { 11 | fn execute(&self, c: &mut ActionContext) { 12 | c.game.state = State::Standby; 13 | } 14 | 15 | fn is_legal(&self, c: &ActionContext) -> bool { 16 | !c.params.competition.delay_after_ready.is_zero() 17 | && c.game.phase != Phase::PenaltyShootout 18 | && (c.game.state == State::Initial || c.game.state == State::Timeout) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /game_controller_core/src/actions/wait_for_set_play.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::action::{Action, ActionContext}; 4 | use crate::timer::Timer; 5 | use crate::types::{Penalty, SetPlay, State}; 6 | 7 | /// This struct defines an action which corresponds to the referee call "Set". It is the second 8 | /// part of "complex" set plays which have a Ready and Set state. 9 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 10 | pub struct WaitForSetPlay; 11 | 12 | impl Action for WaitForSetPlay { 13 | fn execute(&self, c: &mut ActionContext) { 14 | c.game.teams.values_mut().for_each(|team| { 15 | team.players 16 | .iter_mut() 17 | .filter(|player| player.penalty == Penalty::MotionInStandby) 18 | .for_each(|player| { 19 | player.penalty = Penalty::NoPenalty; 20 | player.penalty_timer = Timer::Stopped; 21 | }) 22 | }); 23 | 24 | c.game.secondary_timer = Timer::Stopped; 25 | c.game.state = State::Set; 26 | } 27 | 28 | fn is_legal(&self, c: &ActionContext) -> bool { 29 | c.game.state == State::Ready && c.game.set_play != SetPlay::NoSetPlay 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /game_controller_core/src/log.rs: -------------------------------------------------------------------------------- 1 | //! This module defines structures that can be logged, a trait for loggers and an implementation 2 | //! that just saves entries in memory. 3 | 4 | use std::{net::IpAddr, time::Duration}; 5 | 6 | use serde::{Deserialize, Serialize}; 7 | use serde_with::{base64::Base64, serde_as}; 8 | use time::{format_description::well_known::Rfc3339, OffsetDateTime}; 9 | 10 | use crate::action::VAction; 11 | use crate::types::{ActionSource, Game, Params}; 12 | 13 | /// This struct defines an entry type that should appear once at the beginning of a log file. 14 | #[serde_as] 15 | #[derive(Deserialize, Serialize)] 16 | #[serde(rename_all = "camelCase")] 17 | pub struct LoggedMetadata { 18 | /// The name of the program that created this log. 19 | pub creator: String, 20 | /// The version of the program that created this log. 21 | pub version: u32, 22 | /// The "real" time when this log was created. 23 | #[serde_as(as = "Rfc3339")] 24 | pub timestamp: OffsetDateTime, 25 | /// The combined parameters. 26 | pub params: Box, 27 | } 28 | 29 | /// This struct defines an entry type that represents an action that is applied to the game. 30 | #[derive(Deserialize, Serialize)] 31 | #[serde(rename_all = "camelCase")] 32 | pub struct LoggedAction { 33 | /// The type of event which triggered the action. 34 | pub source: ActionSource, 35 | /// The action itself. 36 | pub action: VAction, 37 | } 38 | 39 | /// This struct defines an entry type with the complete description of the dynamic game state. This 40 | /// is stored on the heap because it is much larger than the other log entries. 41 | pub type LoggedGameState = Box; 42 | 43 | /// This struct defines an entry type for a received monitor request. 44 | #[serde_as] 45 | #[derive(Deserialize, Serialize)] 46 | #[serde(rename_all = "camelCase")] 47 | pub struct LoggedMonitorRequest { 48 | /// The host which sent the monitor request. 49 | pub host: IpAddr, 50 | /// The binary data of the monitor request. 51 | #[serde_as(as = "Base64")] 52 | pub data: Vec, 53 | } 54 | 55 | /// This struct defines an entry type for a received status message. 56 | #[serde_as] 57 | #[derive(Deserialize, Serialize)] 58 | #[serde(rename_all = "camelCase")] 59 | pub struct LoggedStatusMessage { 60 | /// The host which sent the status message. 61 | pub host: IpAddr, 62 | /// The binary data of the status message. 63 | #[serde_as(as = "Base64")] 64 | pub data: Vec, 65 | } 66 | 67 | /// This struct defines an entry type for a received team message. 68 | #[serde_as] 69 | #[derive(Deserialize, Serialize)] 70 | #[serde(rename_all = "camelCase")] 71 | pub struct LoggedTeamMessage { 72 | /// The team number of the team which sent the team message. 73 | pub team: u8, 74 | /// The host which sent the team message. 75 | pub host: IpAddr, 76 | /// The binary data of the team message. 77 | #[serde_as(as = "Base64")] 78 | pub data: Vec, 79 | } 80 | 81 | #[derive(Deserialize, Serialize)] 82 | #[serde(rename_all = "camelCase")] 83 | pub enum LogEntry { 84 | Metadata(LoggedMetadata), 85 | Action(LoggedAction), 86 | GameState(LoggedGameState), 87 | MonitorRequest(LoggedMonitorRequest), 88 | StatusMessage(LoggedStatusMessage), 89 | TeamMessage(LoggedTeamMessage), 90 | /// This is a marker that is the last entry in intact log files and allows to reconstruct the 91 | /// final state of timers. 92 | End, 93 | } 94 | 95 | /// This struct wraps a log entry together with a timestamp. 96 | #[derive(Deserialize, Serialize)] 97 | #[serde(rename_all = "camelCase")] 98 | pub struct TimestampedLogEntry { 99 | /// The timestamp of the entry as its duration since the start of the game. 100 | pub timestamp: Duration, 101 | /// The log entry itself. 102 | pub entry: LogEntry, 103 | } 104 | 105 | /// This trait must be implmented by logging methods. 106 | pub trait Logger { 107 | /// This function appends an entry to the log. 108 | fn append(&mut self, entry: TimestampedLogEntry); 109 | } 110 | 111 | /// This struct defines a logger that does nothing. 112 | pub struct NullLogger; 113 | 114 | impl Logger for NullLogger { 115 | fn append(&mut self, _entry: TimestampedLogEntry) {} 116 | } 117 | -------------------------------------------------------------------------------- /game_controller_core/src/timer.rs: -------------------------------------------------------------------------------- 1 | //! This module defines types for timers that count down during a game. It is a bit awkward to use 2 | //! both [std::time::Duration] and [time::Duration] (the latter aliased as [SignedDuration]). The 3 | //! reason is that there are some timestamps that can be negative and others that can't. 4 | 5 | use std::{cmp::min, mem::take, ops::Index, time::Duration}; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | pub use time::Duration as SignedDuration; 9 | 10 | use crate::action::VAction; 11 | use crate::types::{Game, Params, Phase, State}; 12 | 13 | /// This enumerates conditions which restrict in which states a timer actually counts down. 14 | #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub enum RunCondition { 17 | /// The timer counts down in any state. 18 | Always, 19 | /// The timer counts down during Playing, but also during Ready and Set if the game is not a 20 | /// "long" game. 21 | MainTimer, 22 | /// The timer counts during the Ready and Playing states. 23 | ReadyOrPlaying, 24 | /// The timer counts down only during the Playing state. 25 | Playing, 26 | } 27 | 28 | /// This struct can be queried for values of each run condition. It mainly exists because there are 29 | /// technical reasons that the conditions can not be evaluated directly in [Timer::seek] or 30 | /// [Timer::is_running]. 31 | pub struct EvaluatedRunConditions { 32 | main_timer: bool, 33 | ready_or_playing: bool, 34 | playing: bool, 35 | } 36 | 37 | impl EvaluatedRunConditions { 38 | /// This function evaluates the run conditions in a given game state so they can be queried 39 | /// later. 40 | pub fn new(game: &Game, params: &Params) -> Self { 41 | Self { 42 | main_timer: game.state == State::Playing 43 | || ((game.state == State::Ready || game.state == State::Set) 44 | && game.phase != Phase::PenaltyShootout 45 | && !params.game.long 46 | && game.primary_timer.get_remaining() 47 | != TryInto::::try_into(params.competition.half_duration) 48 | .unwrap()), 49 | ready_or_playing: game.state == State::Ready || game.state == State::Playing, 50 | playing: game.state == State::Playing, 51 | } 52 | } 53 | } 54 | 55 | impl Index for EvaluatedRunConditions { 56 | type Output = bool; 57 | 58 | fn index(&self, index: RunCondition) -> &Self::Output { 59 | match index { 60 | RunCondition::Always => &true, 61 | RunCondition::MainTimer => &self.main_timer, 62 | RunCondition::ReadyOrPlaying => &self.ready_or_playing, 63 | RunCondition::Playing => &self.playing, 64 | } 65 | } 66 | } 67 | 68 | /// This enumerates the possible behaviors of a timer when 0 is reached. 69 | #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] 70 | #[serde(rename_all = "camelCase")] 71 | pub enum BehaviorAtZero { 72 | /// When the timer reaches 0, it stops itself and potentially releases some actions to be 73 | /// executed. 74 | Expire(Vec), 75 | /// The timer is clipped at 0 but stays "active". 76 | Clip, 77 | /// The timer continues to run into negative durations. 78 | Overflow, 79 | } 80 | 81 | /// This struct describes the state of a timer. A timer can be either started or stopped. 82 | #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] 83 | #[serde(rename_all = "camelCase")] 84 | pub enum Timer { 85 | /// The timer is active with some parameters. It may still be paused. 86 | #[serde(rename_all = "camelCase")] 87 | Started { 88 | /// The remaining time until the timer reaches 0. 89 | remaining: SignedDuration, 90 | /// The condition under which the timer actually counts down. 91 | run_condition: RunCondition, 92 | /// What happens when the timer reaches 0. 93 | behavior_at_zero: BehaviorAtZero, 94 | }, 95 | /// The timer is currently not in use. 96 | #[default] 97 | Stopped, 98 | } 99 | 100 | impl Timer { 101 | /// This function lets time progress. The caller must supply the current state of the run 102 | /// conditions. The caller must also ensure that if the timer's behavior is set to 103 | /// [BehaviorAtZero::Expire], the requested duration can be at most the remaining time. When 104 | /// such a timer reaches 0, this function releases the stored actions as result. 105 | pub fn seek( 106 | &mut self, 107 | dt: Duration, 108 | run_conditions: &EvaluatedRunConditions, 109 | ) -> Option> { 110 | match self { 111 | Self::Started { 112 | remaining, 113 | run_condition, 114 | behavior_at_zero, 115 | } => { 116 | if run_conditions[*run_condition] { 117 | match behavior_at_zero { 118 | BehaviorAtZero::Expire(actions) => { 119 | if dt > *remaining { 120 | panic!("timers that expire can't be sought beyond their expiration ({:?}, {:?})", dt, *remaining); 121 | } 122 | *remaining -= dt; 123 | if remaining.is_zero() { 124 | let result = take(actions); 125 | *self = Self::Stopped; 126 | Some(result) 127 | } else { 128 | None 129 | } 130 | } 131 | BehaviorAtZero::Clip => { 132 | *remaining -= min(*remaining, dt.try_into().unwrap()); 133 | None 134 | } 135 | BehaviorAtZero::Overflow => { 136 | *remaining -= dt; 137 | None 138 | } 139 | } 140 | } else { 141 | None 142 | } 143 | } 144 | _ => None, 145 | } 146 | } 147 | 148 | /// This function returns if the timer will count down if [Timer::seek] is called. The caller 149 | /// must supply the current state of the run conditions. 150 | pub fn is_running(&self, run_conditions: &EvaluatedRunConditions) -> bool { 151 | match self { 152 | Self::Started { 153 | remaining, 154 | run_condition, 155 | behavior_at_zero, 156 | } => { 157 | run_conditions[*run_condition] 158 | && !(remaining.is_zero() && matches!(behavior_at_zero, BehaviorAtZero::Clip)) 159 | } 160 | _ => false, 161 | } 162 | } 163 | 164 | /// This function returns if the timer will expire at some point in the future. 165 | pub fn will_expire(&self) -> bool { 166 | matches!( 167 | self, 168 | Self::Started { 169 | behavior_at_zero: BehaviorAtZero::Expire(_), 170 | .. 171 | } 172 | ) 173 | } 174 | 175 | /// This function returns the time remaining until the timer reaches 0 (can be negative). 176 | pub fn get_remaining(&self) -> SignedDuration { 177 | match self { 178 | Self::Started { remaining, .. } => *remaining, 179 | _ => SignedDuration::ZERO, 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /game_controller_logs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | edition = { workspace = true } 4 | license = { workspace = true } 5 | name = "game_controller_logs" 6 | repository = { workspace = true } 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | 10 | [dependencies] 11 | anyhow = { workspace = true } 12 | bytes = { workspace = true } 13 | clap = { workspace = true } 14 | enum-map = { workspace = true } 15 | game_controller_core = { workspace = true } 16 | game_controller_msgs = { workspace = true } 17 | serde_yaml = { workspace = true } 18 | -------------------------------------------------------------------------------- /game_controller_logs/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate contains functions to process log files. 2 | 3 | pub mod statistics; 4 | pub mod team_communication; 5 | -------------------------------------------------------------------------------- /game_controller_logs/src/main.rs: -------------------------------------------------------------------------------- 1 | //! This crate defines the main program to analyze GameController log files. 2 | 3 | use std::{fs::File, path::PathBuf}; 4 | 5 | use anyhow::{Context, Result}; 6 | use clap::{Parser, Subcommand}; 7 | 8 | use game_controller_core::log::TimestampedLogEntry; 9 | 10 | use game_controller_logs::{statistics, team_communication}; 11 | 12 | /// This struct defines the parser for the command line arguments. 13 | #[derive(Parser)] 14 | #[command(about, author, version)] 15 | struct Args { 16 | /// The path of the log file to analyze. 17 | #[arg(long, short)] 18 | pub path: Option, 19 | /// The kind of thing that should be done with that log file. 20 | #[command(subcommand)] 21 | pub command: Commands, 22 | } 23 | 24 | /// This struct defines the command line subcommands. 25 | #[derive(Subcommand)] 26 | enum Commands { 27 | /// Extract statistics about general game events. 28 | Statistics, 29 | /// Extract statistics about the bandwidth usage of team communication. 30 | TeamCommunication, 31 | } 32 | 33 | /// This function applies a subcommand to one log file. 34 | fn process_file(f: File, command: &Commands) -> Result<()> { 35 | let entries: Vec = 36 | serde_yaml::from_reader(f).context("could not parse log file")?; 37 | match command { 38 | Commands::Statistics => { 39 | statistics::evaluate(entries).context("could not create statistics from log file")?; 40 | } 41 | Commands::TeamCommunication => { 42 | team_communication::evaluate(entries) 43 | .context("could not evaluate team communication")?; 44 | } 45 | } 46 | Ok(()) 47 | } 48 | 49 | fn main() -> Result<()> { 50 | let args = Args::parse(); 51 | 52 | if let Some(path) = args.path { 53 | let f = File::open(path).context("could not open log file")?; 54 | process_file(f, &args.command)?; 55 | } 56 | 57 | Ok(()) 58 | } 59 | -------------------------------------------------------------------------------- /game_controller_logs/src/team_communication.rs: -------------------------------------------------------------------------------- 1 | //! This module implements functions to evaluate the bandwidth usage of team communication. 2 | 3 | use std::{collections::HashMap, time::Duration}; 4 | 5 | use anyhow::{bail, Result}; 6 | use bytes::Bytes; 7 | use enum_map::enum_map; 8 | 9 | use game_controller_core::{ 10 | log::{LogEntry, LoggedMetadata, TimestampedLogEntry}, 11 | types::{Game, Params, Penalty, Phase, PlayerNumber, Side, State}, 12 | }; 13 | use game_controller_msgs::StatusMessage; 14 | 15 | /// This function checks if the given game is in a state where team messages are counted for this 16 | /// statistic. 17 | fn is_valid_state(game: &Game) -> bool { 18 | game.phase != Phase::PenaltyShootout 19 | && matches!(game.state, State::Ready | State::Playing | State::Set) 20 | } 21 | 22 | /// This function evaluates the bandwidth usage of team communication on a single game. For each 23 | /// team, a line is written to the standard output with three comma separated values: the team 24 | /// number, the number of payload bytes that the team sent during the game, and the overall uptime 25 | /// of the team during the game in milliseconds. 26 | pub fn evaluate(entries: Vec) -> Result<()> { 27 | let mut iter = entries.iter(); 28 | let metadata: &LoggedMetadata = 29 | if let LogEntry::Metadata(metadata) = &iter.next().unwrap().entry { 30 | metadata 31 | } else { 32 | bail!("first log entry must be metadata"); 33 | }; 34 | let params: &Params = &metadata.params; 35 | let mut last_aliveness = HashMap::<(Side, PlayerNumber), Duration>::new(); 36 | let mut stats = enum_map! { 37 | _ => (0usize, Duration::ZERO), 38 | }; 39 | let mut last: Option<(&Game, Duration)> = None; 40 | // Timestamp of the last transition from initial/finished/timeout to ready/set/playing (at 41 | // least if the current state is ready/set/playing). 42 | let mut last_stopped_timestamp = Duration::ZERO; 43 | for entry in iter { 44 | match &entry.entry { 45 | LogEntry::GameState(state) => { 46 | if let Some((last_state, last_timestamp)) = last { 47 | if is_valid_state(last_state) { 48 | let dt = entry.timestamp - last_timestamp; 49 | for side in [Side::Home, Side::Away] { 50 | // A player counts as being alive if it is 51 | // - not penalized AND 52 | // - has sent a status message during this segment of ready/set/playing 53 | // (- 4 seconds because this is the minimum frequency of status 54 | // messages, but the state segment could be shorter than that). 55 | let active_players = last_state.teams[side] 56 | .players 57 | .iter() 58 | .zip(PlayerNumber::MIN..=PlayerNumber::MAX) 59 | .filter(|(player, number)| { 60 | player.penalty == Penalty::NoPenalty 61 | && last_aliveness 62 | .get(&(side, PlayerNumber::new(*number))) 63 | .map_or(false, |t| { 64 | *t + Duration::from_secs(4) 65 | >= last_stopped_timestamp 66 | }) 67 | }) 68 | .count() as u32; 69 | stats[side].1 += dt * active_players; 70 | } 71 | } else { 72 | last_stopped_timestamp = entry.timestamp; 73 | } 74 | } 75 | last = Some((state, entry.timestamp)); 76 | } 77 | LogEntry::StatusMessage(status_message) => { 78 | if let Ok(status_message) = 79 | StatusMessage::try_from(Bytes::from(status_message.data.clone())) 80 | { 81 | if let Some(side) = params.game.get_side(status_message.team_number) { 82 | last_aliveness.insert( 83 | (side, PlayerNumber::new(status_message.player_number)), 84 | entry.timestamp, 85 | ); 86 | } 87 | } 88 | } 89 | LogEntry::TeamMessage(team_message) => { 90 | if let Some((last_state, _)) = last { 91 | if is_valid_state(last_state) { 92 | if let Some(side) = params.game.get_side(team_message.team) { 93 | stats[side].0 += team_message.data.len(); 94 | } 95 | } 96 | } 97 | } 98 | _ => {} 99 | } 100 | } 101 | for side in [Side::Home, Side::Away] { 102 | println!( 103 | "{},{},{}", 104 | params.game.teams[side].number, 105 | stats[side].0, 106 | stats[side].1.as_millis() 107 | ); 108 | } 109 | Ok(()) 110 | } 111 | -------------------------------------------------------------------------------- /game_controller_msgs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | edition = { workspace = true } 4 | license = { workspace = true } 5 | name = "game_controller_msgs" 6 | repository = { workspace = true } 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | 10 | [build-dependencies] 11 | bindgen = { workspace = true } 12 | 13 | [dependencies] 14 | anyhow = { workspace = true } 15 | bytes = { workspace = true } 16 | game_controller_core = { workspace = true } 17 | -------------------------------------------------------------------------------- /game_controller_msgs/build.rs: -------------------------------------------------------------------------------- 1 | use std::{env, path::PathBuf}; 2 | 3 | use bindgen::Builder; 4 | 5 | fn main() { 6 | let bindings = Builder::default() 7 | .header("headers/bindings.h") 8 | .allowlist_file("headers[/\\\\].*.h") 9 | .blocklist_type(".*") 10 | .fit_macro_constants(true) 11 | .layout_tests(false) 12 | .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) 13 | .generate() 14 | .expect("failed to generate bindings"); 15 | 16 | let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); 17 | bindings 18 | .write_to_file(out_path.join("bindings.rs")) 19 | .expect("failed to write bindings"); 20 | } 21 | -------------------------------------------------------------------------------- /game_controller_msgs/headers/RoboCupGameControlData.h: -------------------------------------------------------------------------------- 1 | #ifndef ROBOCUPGAMECONTROLDATA_H 2 | #define ROBOCUPGAMECONTROLDATA_H 3 | 4 | #include 5 | 6 | #define GAMECONTROLLER_DATA_PORT 3838 7 | #define GAMECONTROLLER_RETURN_PORT 3939 8 | 9 | #define GAMECONTROLLER_STRUCT_HEADER "RGme" 10 | #define GAMECONTROLLER_STRUCT_VERSION 18 11 | 12 | #define MAX_NUM_PLAYERS 20 13 | 14 | // SPL 15 | #define TEAM_BLUE 0 // blue, cyan 16 | #define TEAM_RED 1 // red, magenta, pink 17 | #define TEAM_YELLOW 2 // yellow 18 | #define TEAM_BLACK 3 // black, dark gray 19 | #define TEAM_WHITE 4 // white 20 | #define TEAM_GREEN 5 // green 21 | #define TEAM_ORANGE 6 // orange 22 | #define TEAM_PURPLE 7 // purple, violet 23 | #define TEAM_BROWN 8 // brown 24 | #define TEAM_GRAY 9 // lighter gray 25 | 26 | #define COMPETITION_PHASE_ROUNDROBIN 0 27 | #define COMPETITION_PHASE_PLAYOFF 1 28 | 29 | #define COMPETITION_TYPE_NORMAL 0 30 | #define COMPETITION_TYPE_MOST_PASSES 1 31 | 32 | #define GAME_PHASE_NORMAL 0 33 | #define GAME_PHASE_PENALTYSHOOT 1 34 | #define GAME_PHASE_OVERTIME 2 35 | #define GAME_PHASE_TIMEOUT 3 36 | 37 | #define STATE_INITIAL 0 38 | #define STATE_READY 1 39 | #define STATE_SET 2 40 | #define STATE_PLAYING 3 41 | #define STATE_FINISHED 4 42 | #define STATE_STANDBY 5 43 | 44 | #define SET_PLAY_NONE 0 45 | #define SET_PLAY_GOAL_KICK 1 46 | #define SET_PLAY_PUSHING_FREE_KICK 2 47 | #define SET_PLAY_CORNER_KICK 3 48 | #define SET_PLAY_KICK_IN 4 49 | #define SET_PLAY_PENALTY_KICK 5 50 | 51 | #define KICKING_TEAM_NONE 255 52 | 53 | #define PENALTY_NONE 0 54 | // SPL 55 | #define PENALTY_SPL_ILLEGAL_BALL_CONTACT 1 // ball holding / playing with hands 56 | #define PENALTY_SPL_PLAYER_PUSHING 2 57 | #define PENALTY_SPL_ILLEGAL_MOTION_IN_SET 3 // heard whistle too early? 58 | #define PENALTY_SPL_INACTIVE_PLAYER 4 // fallen, inactive 59 | #define PENALTY_SPL_ILLEGAL_POSITION 5 60 | #define PENALTY_SPL_LEAVING_THE_FIELD 6 61 | #define PENALTY_SPL_REQUEST_FOR_PICKUP 7 62 | #define PENALTY_SPL_LOCAL_GAME_STUCK 8 63 | #define PENALTY_SPL_ILLEGAL_POSITION_IN_SET 9 64 | #define PENALTY_SPL_PLAYER_STANCE 10 65 | #define PENALTY_SPL_ILLEGAL_MOTION_IN_STANDBY 11 66 | 67 | #define PENALTY_SUBSTITUTE 14 68 | #define PENALTY_MANUAL 15 69 | 70 | struct RobotInfo 71 | { 72 | uint8_t penalty; // penalty state of the player 73 | uint8_t secsTillUnpenalised; // estimate of time till unpenalised 74 | }; 75 | 76 | struct TeamInfo 77 | { 78 | uint8_t teamNumber; // unique team number 79 | uint8_t fieldPlayerColour; // colour of the field players 80 | uint8_t goalkeeperColour; // colour of the goalkeeper 81 | uint8_t goalkeeper; // player number of the goalkeeper (1-MAX_NUM_PLAYERS) 82 | uint8_t score; // team's score 83 | uint8_t penaltyShot; // penalty shot counter, or fallback mode flag (if not in GAME_PHASE_PENALTYSHOOT) 84 | uint16_t singleShots; // bits represent penalty shot success 85 | uint16_t messageBudget; // number of team messages the team is allowed to send for the remainder of the game 86 | struct RobotInfo players[MAX_NUM_PLAYERS]; // the team's players 87 | }; 88 | 89 | struct RoboCupGameControlData 90 | { 91 | char header[4]; // header to identify the structure 92 | uint8_t version; // version of the data structure 93 | uint8_t packetNumber; // number incremented with each packet sent (with wraparound) 94 | uint8_t playersPerTeam; // the number of players on a team 95 | uint8_t competitionPhase; // phase of the competition (COMPETITION_PHASE_ROUNDROBIN, COMPETITION_PHASE_PLAYOFF) 96 | uint8_t competitionType; // type of the competition (COMPETITION_TYPE_NORMAL, COMPETITION_TYPE_MOST_PASSES) 97 | uint8_t gamePhase; // phase of the game (GAME_PHASE_NORMAL, GAME_PHASE_PENALTYSHOOT, etc) 98 | uint8_t state; // state of the game (STATE_READY, STATE_PLAYING, etc) 99 | uint8_t setPlay; // active set play (SET_PLAY_NONE, SET_PLAY_GOAL_KICK, etc) 100 | uint8_t firstHalf; // 1 = game in first half, 0 otherwise 101 | uint8_t kickingTeam; // the team number of the next team to kick off, free kick etc, or KICKING_TEAM_NONE 102 | int16_t secsRemaining; // estimate of number of seconds remaining in the half 103 | int16_t secondaryTime; // number of seconds shown as secondary time (remaining ready, until free ball, etc) 104 | struct TeamInfo teams[2]; 105 | }; 106 | 107 | // data structure header 108 | #define GAMECONTROLLER_RETURN_STRUCT_HEADER "RGrt" 109 | #define GAMECONTROLLER_RETURN_STRUCT_VERSION 4 110 | 111 | struct RoboCupGameControlReturnData 112 | { 113 | char header[4]; // "RGrt" 114 | uint8_t version; // has to be set to GAMECONTROLLER_RETURN_STRUCT_VERSION 115 | uint8_t playerNum; // player number starts with 1 116 | uint8_t teamNum; // team number 117 | uint8_t fallen; // 1 means that the robot is fallen, 0 means that the robot can play 118 | 119 | // position and orientation of robot 120 | // coordinates in millimeters 121 | // 0,0 is in center of field 122 | // +ve x-axis points towards the goal we are attempting to score on 123 | // +ve y-axis is 90 degrees counter clockwise from the +ve x-axis 124 | // angle in radians, 0 along the +x axis, increasing counter clockwise 125 | float pose[3]; // x,y,theta 126 | 127 | // ball information 128 | float ballAge; // seconds since this robot last saw the ball. -1.f if we haven't seen it 129 | 130 | // position of ball relative to the robot 131 | // coordinates in millimeters 132 | // 0,0 is in center of the robot 133 | // +ve x-axis points forward from the robot 134 | // +ve y-axis is 90 degrees counter clockwise from the +ve x-axis 135 | float ball[2]; 136 | 137 | #ifdef __cplusplus 138 | // constructor 139 | RoboCupGameControlReturnData() : 140 | version(GAMECONTROLLER_RETURN_STRUCT_VERSION), 141 | playerNum(0), 142 | teamNum(0), 143 | fallen(255), 144 | ballAge(-1.f) 145 | { 146 | const char* init = GAMECONTROLLER_RETURN_STRUCT_HEADER; 147 | for(unsigned int i = 0; i < sizeof(header); ++i) 148 | header[i] = init[i]; 149 | pose[0] = 0.f; 150 | pose[1] = 0.f; 151 | pose[2] = 0.f; 152 | ball[0] = 0.f; 153 | ball[1] = 0.f; 154 | } 155 | #endif 156 | }; 157 | 158 | #endif // ROBOCUPGAMECONTROLDATA_H 159 | -------------------------------------------------------------------------------- /game_controller_msgs/headers/bindings.h: -------------------------------------------------------------------------------- 1 | /* 2 | * This wrapper is necessary because the way that bindgen 3 | * handles multiple headers (passing every header but the last 4 | * via -include) does not work with Apple's libclang. 5 | */ 6 | 7 | #include 8 | 9 | #include "RoboCupGameControlData.h" 10 | 11 | static const size_t GAMECONTROLLER_STRUCT_SIZE = sizeof(struct RoboCupGameControlData); 12 | static const size_t GAMECONTROLLER_RETURN_STRUCT_SIZE = sizeof(struct RoboCupGameControlReturnData); 13 | -------------------------------------------------------------------------------- /game_controller_msgs/src/bindings.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(non_snake_case)] 3 | 4 | include!(concat!(env!("OUT_DIR"), "/bindings.rs")); 5 | -------------------------------------------------------------------------------- /game_controller_msgs/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate defines the messages that the GameController and associated tools exchange via the 2 | //! network and their binary representations. The structs are deliberately not generated by 3 | //! bindgen, but they are still rather raw and do not use types from 4 | //! [mod@game_controller_core::types]. 5 | 6 | mod bindings; 7 | mod control_message; 8 | mod monitor_request; 9 | mod status_message; 10 | 11 | use bindings::{ 12 | GAMECONTROLLER_DATA_PORT, GAMECONTROLLER_RETURN_PORT, GAMECONTROLLER_RETURN_STRUCT_SIZE, 13 | GAMECONTROLLER_STRUCT_SIZE, 14 | }; 15 | 16 | /// The binary size of a control message. 17 | pub const CONTROL_MESSAGE_SIZE: usize = GAMECONTROLLER_STRUCT_SIZE; 18 | /// The binary size of a monitor request. 19 | pub const MONITOR_REQUEST_SIZE: usize = 5; 20 | /// The binary size of a status message. 21 | pub const STATUS_MESSAGE_SIZE: usize = GAMECONTROLLER_RETURN_STRUCT_SIZE; 22 | /// The maximal binary size of a team message. 23 | pub const TEAM_MESSAGE_MAX_SIZE: usize = 128; 24 | 25 | /// The UDP port on which control messages are sent. 26 | pub const CONTROL_MESSAGE_PORT: u16 = GAMECONTROLLER_DATA_PORT; 27 | /// The UDP port on which monitor requests are received. 28 | pub const MONITOR_REQUEST_PORT: u16 = 3636; 29 | /// The UDP port on which status messages are received. 30 | pub const STATUS_MESSAGE_PORT: u16 = GAMECONTROLLER_RETURN_PORT; 31 | /// The UDP port on which status messages are forwarded. 32 | pub const STATUS_MESSAGE_FORWARD_PORT: u16 = STATUS_MESSAGE_PORT + 1; 33 | /// The number to which the team number is added to obtain the UDP port for that team's 34 | /// communication. 35 | pub const TEAM_MESSAGE_PORT_BASE: u16 = 10000; 36 | 37 | pub use control_message::ControlMessage; 38 | pub use monitor_request::MonitorRequest; 39 | pub use status_message::StatusMessage; 40 | -------------------------------------------------------------------------------- /game_controller_msgs/src/monitor_request.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Error}; 2 | use bytes::{Buf, Bytes}; 3 | 4 | /// This struct represents a request from a monitor application for "true" data. 5 | pub struct MonitorRequest(); 6 | 7 | impl TryFrom for MonitorRequest { 8 | type Error = Error; 9 | 10 | fn try_from(mut bytes: Bytes) -> Result { 11 | if bytes.len() != 5 { 12 | bail!("wrong length"); 13 | } 14 | let header = bytes.copy_to_bytes(4); 15 | if header != b"RGTr"[..4] { 16 | bail!("wrong header"); 17 | } 18 | let version = bytes.get_u8(); 19 | if version != 0 { 20 | bail!("wrong version"); 21 | } 22 | assert!(!bytes.has_remaining()); 23 | Ok(MonitorRequest()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /game_controller_msgs/src/status_message.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Error}; 2 | use bytes::{Buf, Bytes}; 3 | 4 | use crate::bindings::{ 5 | GAMECONTROLLER_RETURN_STRUCT_HEADER, GAMECONTROLLER_RETURN_STRUCT_SIZE, 6 | GAMECONTROLLER_RETURN_STRUCT_VERSION, MAX_NUM_PLAYERS, 7 | }; 8 | 9 | /// This struct corresponds to `RoboCupGameControlReturnData`. 10 | /// `RoboCupGameControlReturnData::header` and `RoboCupGameControlReturnData::version` are 11 | /// implicitly added/removed when converting to/from the binary format. 12 | pub struct StatusMessage { 13 | /// This field corresponds to `RoboCupGameControlReturnData::playerNum`. 14 | pub player_number: u8, 15 | /// This field corresponds to `RoboCupGameControlReturnData::teamNum`. 16 | pub team_number: u8, 17 | /// This field corresponds to `RoboCupGameControlReturnData::fallen`. 18 | pub fallen: bool, 19 | /// This field corresponds to `RoboCupGameControlReturnData::pose`. 20 | pub pose: [f32; 3], 21 | /// This field corresponds to `RoboCupGameControlReturnData::ballAge`. 22 | pub ball_age: f32, 23 | /// This field corresponds to `RoboCupGameControlReturnData::ball`. 24 | pub ball: [f32; 2], 25 | } 26 | 27 | impl TryFrom for StatusMessage { 28 | type Error = Error; 29 | 30 | fn try_from(mut bytes: Bytes) -> Result { 31 | if bytes.len() != GAMECONTROLLER_RETURN_STRUCT_SIZE { 32 | bail!("wrong length"); 33 | } 34 | let header = bytes.copy_to_bytes(4); 35 | if header != GAMECONTROLLER_RETURN_STRUCT_HEADER[..4] { 36 | bail!("wrong header"); 37 | } 38 | let version = bytes.get_u8(); 39 | if version != GAMECONTROLLER_RETURN_STRUCT_VERSION { 40 | bail!("wrong version"); 41 | } 42 | let player_number = bytes.get_u8(); 43 | if !(1..=MAX_NUM_PLAYERS).contains(&player_number) { 44 | bail!("invalid player number"); 45 | } 46 | let team_number = bytes.get_u8(); 47 | let fallen = bytes.get_u8(); 48 | if fallen > 1 { 49 | bail!("invalid fallen"); 50 | } 51 | let pose = [bytes.get_f32_le(), bytes.get_f32_le(), bytes.get_f32_le()]; 52 | if pose.iter().any(|component| component.is_nan()) { 53 | bail!("invalid pose"); 54 | } 55 | let ball_age = bytes.get_f32_le(); 56 | if ball_age.is_nan() { 57 | bail!("invalid ball age"); 58 | } 59 | let ball = [bytes.get_f32_le(), bytes.get_f32_le()]; 60 | if ball.iter().any(|component| component.is_nan()) { 61 | bail!("invalid ball"); 62 | } 63 | assert!(!bytes.has_remaining()); 64 | Ok(StatusMessage { 65 | player_number, 66 | team_number, 67 | fallen: fallen == 1, 68 | pose, 69 | ball_age, 70 | ball, 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /game_controller_net/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | edition = { workspace = true } 4 | license = { workspace = true } 5 | name = "game_controller_net" 6 | repository = { workspace = true } 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | 10 | [dependencies] 11 | anyhow = { workspace = true } 12 | bytes = { workspace = true } 13 | game_controller_core = { workspace = true } 14 | game_controller_msgs = { workspace = true } 15 | socket2 = { workspace = true } 16 | tokio = { workspace = true } 17 | -------------------------------------------------------------------------------- /game_controller_net/src/control_message_sender.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | net::{IpAddr, Ipv4Addr, Ipv6Addr}, 3 | time::Duration, 4 | }; 5 | 6 | use anyhow::Result; 7 | use bytes::Bytes; 8 | use tokio::{net::UdpSocket, sync::watch, time::interval}; 9 | 10 | use game_controller_core::types::{Game, Params}; 11 | use game_controller_msgs::{ControlMessage, CONTROL_MESSAGE_PORT}; 12 | 13 | /// This struct represents a sender for control messages. The messages are sent as UDP packets to 14 | /// the given destination address. The states to be sent are obtained from a [tokio::sync::watch] 15 | /// channel. This struct works both for sending to players and sending to monitor applications. The 16 | /// caller can select what is desired by supplying an appropriate destination address and the flag. 17 | pub struct ControlMessageSender { 18 | socket: UdpSocket, 19 | params: Params, 20 | game_receiver: watch::Receiver, 21 | to_monitor: bool, 22 | } 23 | 24 | impl ControlMessageSender { 25 | /// The period at which control messages are sent. 26 | const SEND_INTERVAL: Duration = Duration::from_millis(500); 27 | 28 | /// This function creates a new sender for control messages. 29 | pub async fn new( 30 | address: IpAddr, 31 | params: Params, 32 | game_receiver: watch::Receiver, 33 | to_monitor: bool, 34 | ) -> Result { 35 | Ok(Self { 36 | socket: { 37 | let socket = UdpSocket::bind(( 38 | match address { 39 | IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), 40 | IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), 41 | }, 42 | 0u16, 43 | )) 44 | .await?; 45 | socket.set_broadcast(true)?; 46 | socket.connect((address, CONTROL_MESSAGE_PORT)).await?; 47 | socket 48 | }, 49 | params, 50 | game_receiver, 51 | to_monitor, 52 | }) 53 | } 54 | 55 | /// This function runs the sender indefinitely. 56 | pub async fn run(&self) { 57 | let mut interval = interval(Self::SEND_INTERVAL); 58 | let mut packet_number: u8 = 0; 59 | loop { 60 | interval.tick().await; 61 | // It would be better if the timers were updated to the current time before 62 | // serialization. At the moment, this is not necessary because the main thread updates 63 | // the state each time a (rounded) timer crosses a second boundary. However, this tight 64 | // coupling between the fact that timers are sent as seconds and the main logic is 65 | // undesirable. 66 | let buffer: Bytes = ControlMessage::new( 67 | &self.game_receiver.borrow(), 68 | &self.params, 69 | packet_number, 70 | self.to_monitor, 71 | ) 72 | .into(); 73 | let _ = self.socket.send(&buffer).await; 74 | packet_number = packet_number.wrapping_add(1); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /game_controller_net/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate contains network services for the GameController. 2 | 3 | use std::net::IpAddr; 4 | 5 | use bytes::Bytes; 6 | 7 | mod control_message_sender; 8 | mod monitor_request_receiver; 9 | mod status_message_forwarder; 10 | mod status_message_receiver; 11 | mod team_message_receiver; 12 | mod workaround; 13 | 14 | pub use control_message_sender::ControlMessageSender; 15 | pub use monitor_request_receiver::MonitorRequestReceiver; 16 | pub use status_message_forwarder::StatusMessageForwarder; 17 | pub use status_message_receiver::StatusMessageReceiver; 18 | pub use team_message_receiver::TeamMessageReceiver; 19 | 20 | /// This enumerates network events. 21 | #[derive(Debug)] 22 | pub enum Event { 23 | /// An incoming monitor request. 24 | MonitorRequest { 25 | /// The host which sent the request. 26 | host: IpAddr, 27 | /// The payload of the request. 28 | data: Bytes, 29 | /// Whether there would have been more bytes in the request. 30 | too_long: bool, 31 | }, 32 | /// An incoming status message (from a player). 33 | StatusMessage { 34 | /// The host which sent the message. 35 | host: IpAddr, 36 | /// The payload of the message. 37 | data: Bytes, 38 | /// Whether there would have been more bytes in the message. 39 | too_long: bool, 40 | }, 41 | /// An incoming team message (from a player). 42 | TeamMessage { 43 | /// The host which sent the message. 44 | host: IpAddr, 45 | /// The team which sent the message. 46 | team: u8, 47 | /// The payload of the message. 48 | data: Bytes, 49 | /// Whether there would have been more bytes in the message. 50 | too_long: bool, 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /game_controller_net/src/monitor_request_receiver.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::min, 3 | net::{IpAddr, Ipv4Addr, Ipv6Addr}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use bytes::Bytes; 8 | use tokio::{net::UdpSocket, sync::mpsc}; 9 | 10 | use game_controller_msgs::{MONITOR_REQUEST_PORT, MONITOR_REQUEST_SIZE}; 11 | 12 | use crate::Event; 13 | 14 | /// This struct represents a receiver for monitor requests. It listens only on the given local 15 | /// address. Received messages are passed to the caller as events in a [tokio::sync::mpsc] channel. 16 | pub struct MonitorRequestReceiver { 17 | socket: UdpSocket, 18 | event_sender: mpsc::UnboundedSender, 19 | } 20 | 21 | impl MonitorRequestReceiver { 22 | /// This function creates a new receiver for monitor requests. 23 | pub async fn new(address: IpAddr, event_sender: mpsc::UnboundedSender) -> Result { 24 | Ok(Self { 25 | socket: UdpSocket::bind(( 26 | match address { 27 | IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), 28 | IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), 29 | }, 30 | MONITOR_REQUEST_PORT, 31 | )) 32 | .await?, 33 | event_sender, 34 | }) 35 | } 36 | 37 | /// This function runs the receiver until an error occurs. 38 | pub async fn run(&self) -> Result<()> { 39 | let mut buffer = vec![0u8; MONITOR_REQUEST_SIZE + 1]; 40 | loop { 41 | let (length, address) = crate::workaround::recv_from(&self.socket, &mut buffer).await?; 42 | self.event_sender.send(Event::MonitorRequest { 43 | host: address.ip(), 44 | data: Bytes::copy_from_slice(&buffer[0..min(length, MONITOR_REQUEST_SIZE)]), 45 | too_long: length > MONITOR_REQUEST_SIZE, 46 | })?; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /game_controller_net/src/status_message_forwarder.rs: -------------------------------------------------------------------------------- 1 | use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; 2 | 3 | use anyhow::Result; 4 | use bytes::{Bytes, BytesMut}; 5 | use tokio::{net::UdpSocket, sync::broadcast}; 6 | 7 | use game_controller_msgs::STATUS_MESSAGE_FORWARD_PORT; 8 | 9 | /// This struct represents a sender that forwards status messages to a monitor application. Each 10 | /// message is prefixed with the IP address of its original sender. Messages arrive in the 11 | /// unassembled form via a [tokio::sync::broadcast] channel. 12 | pub struct StatusMessageForwarder { 13 | socket: UdpSocket, 14 | message_receiver: broadcast::Receiver<(IpAddr, Bytes)>, 15 | } 16 | 17 | impl StatusMessageForwarder { 18 | /// This function creates a new sender that forwards status messages to a monitor application. 19 | pub async fn new( 20 | address: IpAddr, 21 | message_receiver: broadcast::Receiver<(IpAddr, Bytes)>, 22 | ) -> Result { 23 | Ok(Self { 24 | socket: { 25 | let socket = UdpSocket::bind(( 26 | match address { 27 | IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), 28 | IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), 29 | }, 30 | 0u16, 31 | )) 32 | .await?; 33 | socket 34 | .connect((address, STATUS_MESSAGE_FORWARD_PORT)) 35 | .await?; 36 | socket 37 | }, 38 | message_receiver, 39 | }) 40 | } 41 | 42 | /// This function runs the forwarder until an error occurs. Network errors during sending are 43 | /// ignored because the network might change and we shouldn't crash in that case. 44 | pub async fn run(&mut self) -> Result<()> { 45 | loop { 46 | let (source, buffer) = self.message_receiver.recv().await?; 47 | let prefixed_buffer = match source { 48 | IpAddr::V4(ip) => { 49 | let octets = ip.octets(); 50 | assert!(octets.len() == 4); 51 | let mut prefixed_buffer = BytesMut::new(); 52 | prefixed_buffer.extend_from_slice(&octets); 53 | prefixed_buffer.extend(buffer); 54 | prefixed_buffer.freeze() 55 | } 56 | _ => todo!("implement forwarding of IPv6 status messages"), 57 | }; 58 | let _ = self.socket.send(&prefixed_buffer).await; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /game_controller_net/src/status_message_receiver.rs: -------------------------------------------------------------------------------- 1 | use std::{cmp::min, net::IpAddr}; 2 | 3 | use anyhow::Result; 4 | use bytes::Bytes; 5 | use tokio::{net::UdpSocket, sync::mpsc}; 6 | 7 | use game_controller_msgs::{STATUS_MESSAGE_PORT, STATUS_MESSAGE_SIZE}; 8 | 9 | use crate::Event; 10 | 11 | /// This struct represents a receiver for status messages. Status messages are UDP packets with a 12 | /// fixed format, although that format isn't checked here. It listens only on the given local 13 | /// address. Received messages are passed to the caller as events in a [tokio::sync::mpsc] channel. 14 | pub struct StatusMessageReceiver { 15 | socket: UdpSocket, 16 | event_sender: mpsc::UnboundedSender, 17 | } 18 | 19 | impl StatusMessageReceiver { 20 | /// This function creates a new receiver for status messages. 21 | pub async fn new(address: IpAddr, event_sender: mpsc::UnboundedSender) -> Result { 22 | Ok(Self { 23 | socket: UdpSocket::bind((address, STATUS_MESSAGE_PORT)).await?, 24 | event_sender, 25 | }) 26 | } 27 | 28 | /// This function runs the receiver until an error occurs. 29 | pub async fn run(&self) -> Result<()> { 30 | let mut buffer = vec![0u8; STATUS_MESSAGE_SIZE + 1]; 31 | loop { 32 | let (length, address) = crate::workaround::recv_from(&self.socket, &mut buffer).await?; 33 | self.event_sender.send(Event::StatusMessage { 34 | host: address.ip(), 35 | data: Bytes::copy_from_slice(&buffer[0..min(length, STATUS_MESSAGE_SIZE)]), 36 | too_long: length > STATUS_MESSAGE_SIZE, 37 | })?; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /game_controller_net/src/team_message_receiver.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cmp::min, 3 | net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use bytes::Bytes; 8 | use socket2::{Protocol, SockAddr, Socket, Type}; 9 | use tokio::{net::UdpSocket, sync::mpsc}; 10 | 11 | use game_controller_msgs::{TEAM_MESSAGE_MAX_SIZE, TEAM_MESSAGE_PORT_BASE}; 12 | 13 | use crate::Event; 14 | 15 | /// This struct represents a receiver for team messages. The messages are UDP packets of a given 16 | /// maximum length. It listens on any local address, but by specifying a local address, the caller 17 | /// can choose between IPv4 and IPv6. The given team determines the UDP port on which messages are 18 | /// expected. Received messages are passed to the caller as events in a [tokio::sync::mpsc] 19 | /// channel. 20 | pub struct TeamMessageReceiver { 21 | socket: UdpSocket, 22 | team: u8, 23 | event_sender: mpsc::UnboundedSender, 24 | } 25 | 26 | impl TeamMessageReceiver { 27 | /// This function creates a new receiver for team messages. 28 | pub async fn new( 29 | address: IpAddr, 30 | multicast: bool, 31 | team: u8, 32 | event_sender: mpsc::UnboundedSender, 33 | ) -> Result { 34 | Ok(Self { 35 | socket: { 36 | // This might be stuff that should not be done in an async function. 37 | let socket_address: SockAddr = SocketAddr::new( 38 | match address { 39 | IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), 40 | IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), 41 | }, 42 | TEAM_MESSAGE_PORT_BASE + (team as u16), 43 | ) 44 | .into(); 45 | let raw_socket = 46 | Socket::new(socket_address.domain(), Type::DGRAM, Some(Protocol::UDP))?; 47 | #[cfg(target_os = "macos")] 48 | raw_socket.set_reuse_port(true)?; 49 | #[cfg(any(target_os = "linux", target_os = "windows"))] 50 | raw_socket.set_reuse_address(true)?; 51 | // Extend this for other operating systems when it's clear what the right thing is 52 | // on that system. 53 | raw_socket.bind(&socket_address)?; 54 | raw_socket.set_nonblocking(true)?; 55 | let socket = UdpSocket::from_std(raw_socket.into())?; 56 | if multicast { 57 | if let IpAddr::V4(address_v4) = address { 58 | let _ = socket.join_multicast_v4(Ipv4Addr::new(239, 0, 0, 1), address_v4); 59 | } 60 | } 61 | socket 62 | }, 63 | team, 64 | event_sender, 65 | }) 66 | } 67 | 68 | /// This function runs the receiver until an error occurs. 69 | pub async fn run(&self) -> Result<()> { 70 | // Since we want to catch team messages that are too long, we expect one more byte than the 71 | // maximum message length. 72 | let mut buffer = vec![0u8; TEAM_MESSAGE_MAX_SIZE + 1]; 73 | loop { 74 | let (length, address) = crate::workaround::recv_from(&self.socket, &mut buffer).await?; 75 | self.event_sender.send(Event::TeamMessage { 76 | host: address.ip(), 77 | team: self.team, 78 | data: Bytes::copy_from_slice(&buffer[0..min(length, TEAM_MESSAGE_MAX_SIZE)]), 79 | too_long: length > TEAM_MESSAGE_MAX_SIZE, 80 | })?; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /game_controller_net/src/workaround.rs: -------------------------------------------------------------------------------- 1 | use std::{io::Result, net::SocketAddr}; 2 | use tokio::net::UdpSocket; 3 | 4 | /// This function wraps [tokio::net::UdpSocket::recv_from]. It handles the fact that Windows throws 5 | /// an error (`WSAEMSGSIZE`) when a received datagram is larger than the user-supplied buffer. 6 | /// Unfortunately, it is not possible to get the sender address in that case. Instead, the local 7 | /// socket address is returned, so the origin of overlong packets is not reliable on Windows (it is 8 | /// not reliable in general because of the nature of UDP, but that is another topic). 9 | pub async fn recv_from(socket: &UdpSocket, buf: &mut [u8]) -> Result<(usize, SocketAddr)> { 10 | match socket.recv_from(buf).await { 11 | #[cfg(target_os = "windows")] 12 | Err(error) if error.raw_os_error() == Some(10040) => Ok((buf.len(), socket.local_addr()?)), 13 | result => result, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /game_controller_runtime/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = { workspace = true } 3 | edition = { workspace = true } 4 | license = { workspace = true } 5 | name = "game_controller_runtime" 6 | repository = { workspace = true } 7 | rust-version = { workspace = true } 8 | version = { workspace = true } 9 | 10 | [dependencies] 11 | anyhow = { workspace = true } 12 | clap = { workspace = true } 13 | enum-map = { workspace = true } 14 | game_controller_core = { workspace = true } 15 | game_controller_msgs = { workspace = true } 16 | game_controller_net = { workspace = true } 17 | network-interface = { workspace = true } 18 | serde = { workspace = true } 19 | serde_with = { workspace = true } 20 | serde_repr = { workspace = true } 21 | serde_yaml = { workspace = true } 22 | time = { workspace = true } 23 | tokio = { workspace = true } 24 | tokio-util = { workspace = true } 25 | -------------------------------------------------------------------------------- /game_controller_runtime/src/cli.rs: -------------------------------------------------------------------------------- 1 | //! This module defines the command line interface. 2 | 3 | use std::path::PathBuf; 4 | 5 | use clap::Parser; 6 | 7 | /// This struct defines the parser for the command line arguments. 8 | #[derive(Parser)] 9 | #[command(about, author, version)] 10 | pub struct Args { 11 | /// Set the competition type. 12 | #[arg(long, short)] 13 | pub competition: Option, 14 | /// Set whether this is a play-off (long) game. 15 | #[arg(long)] 16 | pub play_off: bool, 17 | /// Set the home team (name or number). 18 | #[arg(long)] 19 | pub home_team: Option, 20 | /// Set the away team (name or number). 21 | #[arg(long)] 22 | pub away_team: Option, 23 | /// Set the no-delay test flag. 24 | #[arg(long)] 25 | pub no_delay: bool, 26 | /// Set the penalty shoot-out test flag. 27 | #[arg(long)] 28 | pub penalty_shootout: bool, 29 | /// Set the unpenalize test flag. 30 | #[arg(long)] 31 | pub unpenalize: bool, 32 | /// Open the main window in fullscreen mode. 33 | #[arg(long, short)] 34 | pub fullscreen: bool, 35 | /// Set the network interface to listen/send on/to. 36 | #[arg(long, short)] 37 | pub interface: Option, 38 | /// Send control messages to 255.255.255.255. 39 | #[arg(long, short)] 40 | pub broadcast: bool, 41 | /// Join multicast groups for simulated team communication. 42 | #[arg(long, short)] 43 | pub multicast: bool, 44 | /// Sync the log file to the storage device after each entry. 45 | #[arg(long)] 46 | pub sync: bool, 47 | /// Specify the path to a log file to replay. 48 | #[arg(long)] 49 | pub replay: Option, 50 | } 51 | -------------------------------------------------------------------------------- /game_controller_runtime/src/connection_status.rs: -------------------------------------------------------------------------------- 1 | //! This module defines utilities to manage the connection status of players. 2 | 3 | use std::{collections::HashMap, time::Duration}; 4 | 5 | use enum_map::{enum_map, EnumMap}; 6 | use serde_repr::Serialize_repr; 7 | use tokio::time::Instant; 8 | 9 | use game_controller_core::types::{PlayerNumber, Side}; 10 | 11 | /// This enumerates the possible values of a player's connection status. 12 | #[derive(Clone, Copy, Serialize_repr)] 13 | #[repr(u8)] 14 | pub enum ConnectionStatus { 15 | /// The player hasn't sent a status message for a long time and is probably not running. 16 | Offline = 0, 17 | /// The player has sent a status message, but it has been a while. 18 | Bad = 1, 19 | /// The player has sent a status message recently. 20 | Good = 2, 21 | } 22 | 23 | /// The upper bound on the time since the last status message for a good (but not yet bad) 24 | /// connection status. 25 | const CONNECTION_STATUS_TIMEOUT_GOOD: Duration = Duration::from_secs(2); 26 | 27 | /// The upper bound on the time since the last status message for a bad (but not yet offline) 28 | /// connection status. 29 | const CONNECTION_STATUS_TIMEOUT_BAD: Duration = Duration::from_secs(4); 30 | 31 | /// This type aliases a "two-dimensional array"-like map from players to connection status values. 32 | pub type ConnectionStatusMap = 33 | EnumMap; 34 | 35 | /// This type aliases a hashmap from players (represented as pairs of a side and a player number) 36 | /// to the timestamp when the last status message was received. 37 | pub type AlivenessTimestampMap = HashMap<(Side, PlayerNumber), Instant>; 38 | 39 | /// This function transforms a map from players to timestamps into a map of connection status 40 | /// values, given the current time. 41 | pub fn get_connection_status_map( 42 | timestamps: &AlivenessTimestampMap, 43 | now: &Instant, 44 | ) -> ConnectionStatusMap { 45 | let mut result = enum_map! { 46 | _ => [ConnectionStatus::Offline; (PlayerNumber::MAX - PlayerNumber::MIN + 1) as usize] 47 | }; 48 | for (key, value) in timestamps { 49 | let time_since_alive = now.duration_since(*value); 50 | let status = if time_since_alive <= CONNECTION_STATUS_TIMEOUT_GOOD { 51 | ConnectionStatus::Good 52 | } else if time_since_alive <= CONNECTION_STATUS_TIMEOUT_BAD { 53 | ConnectionStatus::Bad 54 | } else { 55 | ConnectionStatus::Offline 56 | }; 57 | result[key.0][(u8::from(key.1) - PlayerNumber::MIN) as usize] = status; 58 | } 59 | result 60 | } 61 | 62 | /// This function returns the duration until any player's connection status changes or [None] if 63 | /// that will never happen. 64 | pub fn get_next_connection_status_change( 65 | timestamps: &AlivenessTimestampMap, 66 | now: &Instant, 67 | ) -> Option { 68 | timestamps 69 | .values() 70 | .flat_map(|timestamp| { 71 | if *timestamp + CONNECTION_STATUS_TIMEOUT_GOOD > *now { 72 | Some(*timestamp + CONNECTION_STATUS_TIMEOUT_GOOD - *now) 73 | } else if *timestamp + CONNECTION_STATUS_TIMEOUT_BAD > *now { 74 | Some(*timestamp + CONNECTION_STATUS_TIMEOUT_BAD - *now) 75 | } else { 76 | None 77 | } 78 | }) 79 | .min() 80 | } 81 | -------------------------------------------------------------------------------- /game_controller_runtime/src/logger.rs: -------------------------------------------------------------------------------- 1 | //! This module defines the logging facilites of the GameController application. 2 | 3 | use std::path::Path; 4 | 5 | use anyhow::{Context, Result}; 6 | use tokio::{fs::File, io::AsyncWriteExt, sync::mpsc, task::JoinSet}; 7 | 8 | use game_controller_core::log::{Logger, TimestampedLogEntry}; 9 | 10 | /// This struct defines a log that is backed by a file. The actual writing happens asynchronously 11 | /// in a concurrent task. 12 | pub struct FileLogger { 13 | /// The channel via which the GameController sends entries to the logger task. 14 | entry_sender: mpsc::UnboundedSender, 15 | } 16 | 17 | impl FileLogger { 18 | /// This function creates a new log file at a given path. If requested, the file will be synced 19 | /// to the storage medium after each added entry. The caller must supply a join set in which 20 | /// the worker will be spawned. 21 | pub async fn new>( 22 | path: P, 23 | join_set: &mut JoinSet>, 24 | sync: bool, 25 | ) -> Result { 26 | let mut file = File::create(path) 27 | .await 28 | .context("could not create log file")?; 29 | let (entry_sender, mut entry_receiver) = mpsc::unbounded_channel(); 30 | join_set.spawn(async move { 31 | while let Some(entry) = entry_receiver.recv().await { 32 | file.write_all(serde_yaml::to_string(&vec![&entry])?.as_bytes()) 33 | .await?; 34 | file.flush().await?; 35 | if sync { 36 | let _ = file.sync_data().await; 37 | } 38 | } 39 | if sync { 40 | let _ = file.sync_all().await; 41 | } 42 | Ok(()) 43 | }); 44 | Ok(Self { entry_sender }) 45 | } 46 | } 47 | 48 | impl Logger for FileLogger { 49 | fn append(&mut self, entry: TimestampedLogEntry) { 50 | let _ = self.entry_sender.send(entry); 51 | } 52 | } 53 | --------------------------------------------------------------------------------