101 | );
102 | } else {
103 | return <>>;
104 | }
105 | };
106 |
107 | export default Launcher;
108 |
--------------------------------------------------------------------------------
/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/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 plays 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 | && c.params.competition.challenge_mode.is_none()
93 | })
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/game_controller_app/src/handlers.rs:
--------------------------------------------------------------------------------
1 | //! This module defines handlers that can be called from JavaScript.
2 |
3 | use std::{env::current_exe, sync::Arc};
4 |
5 | use anyhow::{anyhow, Context};
6 | use tauri::{
7 | command, generate_handler, ipc::InvokeHandler, AppHandle, Emitter, LogicalSize, Manager, State,
8 | WebviewWindow, Wry,
9 | };
10 | use tokio::sync::Notify;
11 |
12 | use game_controller_core::{action::VAction, types::Params};
13 | use game_controller_runtime::{
14 | launch::{LaunchData, LaunchSettings},
15 | start_runtime, RuntimeState,
16 | };
17 |
18 | /// This struct is used as state so that the [launch] function can communicate to
19 | /// [sync_with_backend] that the full [RuntimeState] is managed now.
20 | struct SyncState(Arc);
21 |
22 | /// This function is called by the launcher to obtain its data. The data is read from a state
23 | /// variable that is created by [game_controller_runtime::launch::make_launch_data] and put there by
24 | /// [crate::main].
25 | #[command]
26 | fn get_launch_data(launch_data: State) -> LaunchData {
27 | launch_data.inner().clone()
28 | }
29 |
30 | /// This function is called when the user finishes the launcher dialog. It creates a game state and
31 | /// network services, and spawns tasks to handle events.
32 | #[command]
33 | async fn launch(settings: LaunchSettings, window: WebviewWindow, app: AppHandle) {
34 | // The notify object must be managed before the window is created.
35 | let runtime_notify = Arc::new(Notify::new());
36 | app.manage(SyncState(runtime_notify.clone()));
37 |
38 | // Unfortunately we cannot use the number of players per team here.
39 | let size = LogicalSize::::new(1024.0, 820.0);
40 | let _ = window.set_min_size(Some(size));
41 | #[cfg(target_os = "windows")]
42 | let _ = window.set_size(size);
43 | let _ = window.set_fullscreen(settings.window.fullscreen);
44 | let _ = window.set_resizable(true);
45 | let _ = window.center();
46 |
47 | let send_ui_state = move |ui_state| {
48 | if let Err(error) = window.emit("state", ui_state) {
49 | Err(anyhow!(error))
50 | } else {
51 | Ok(())
52 | }
53 | };
54 |
55 | let launch_data = app.state::();
56 | match start_runtime(
57 | ¤t_exe()
58 | .unwrap()
59 | .parent()
60 | .unwrap()
61 | .join("..")
62 | .join("..")
63 | .join("config"),
64 | ¤t_exe()
65 | .unwrap()
66 | .parent()
67 | .unwrap()
68 | .join("..")
69 | .join("..")
70 | .join("logs"),
71 | &settings,
72 | &launch_data.teams,
73 | &launch_data.network_interfaces,
74 | Box::new(send_ui_state),
75 | )
76 | .await
77 | .context("could not start runtime")
78 | {
79 | Ok(runtime_state) => {
80 | app.manage(runtime_state);
81 | }
82 | Err(error) => {
83 | eprintln!("{error:?}");
84 | app.exit(1);
85 | }
86 | }
87 |
88 | // Now that the RuntimeState is managed, we can tell the UI that it can proceed.
89 | runtime_notify.notify_one();
90 | }
91 |
92 | /// This function should be called once by the UI after it listens to UI events, but before it
93 | /// calls [apply_action] or [declare_actions]. The caller gets the combined parameters of the game
94 | /// and competition. It is wrapped in a [Result] as a tauri workaround.
95 | #[command]
96 | async fn sync_with_backend(app: AppHandle, state: State<'_, SyncState>) -> Result {
97 | // Wait until manage has been called.
98 | state.0.notified().await;
99 | // Now we can obtain a handle to the RuntimeState to notify the runtime thread that it can
100 | // start sending UI events.
101 | let runtime_state = app.state::();
102 | runtime_state.ui_notify.notify_one();
103 | Ok(runtime_state.params.clone())
104 | }
105 |
106 | /// This function enqueues an action to be applied to the game.
107 | #[command]
108 | fn apply_action(action: VAction, state: State) {
109 | let _ = state.action_sender.send(action);
110 | }
111 |
112 | /// This function lets the UI declare actions for which it wants to know whether they are legal.
113 | #[command]
114 | fn declare_actions(actions: Vec, state: State) {
115 | let _ = state.subscribed_actions_sender.send(actions);
116 | }
117 |
118 | /// This function returns a handler that can be passed to [tauri::Builder::invoke_handler].
119 | /// It must be boxed because otherwise its size is unknown at compile time.
120 | pub fn get_invoke_handler() -> Box> {
121 | Box::new(generate_handler![
122 | apply_action,
123 | declare_actions,
124 | get_launch_data,
125 | launch,
126 | sync_with_backend,
127 | ])
128 | }
129 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |