diff --git a/rcon/api_commands.py b/rcon/api_commands.py
index 8179a2c22..ecaf970ae 100644
--- a/rcon/api_commands.py
+++ b/rcon/api_commands.py
@@ -689,7 +689,9 @@ def set_votemap_config(
reset_to_default: bool = False,
**kwargs,
) -> bool:
- return self._validate_user_config(
+ old_config = VoteMapUserConfig.load_from_db()
+
+ res = self._validate_user_config(
by=by,
command_name=inspect.currentframe().f_code.co_name, # type: ignore
model=VoteMapUserConfig,
@@ -698,6 +700,14 @@ def set_votemap_config(
reset_to_default=reset_to_default,
)
+ new_config = VoteMapUserConfig.load_from_db()
+
+ # on -> off or off -> on
+ if old_config.enabled != new_config:
+ self.reset_votemap_state()
+
+ return True
+
def get_auto_broadcasts_config(self) -> AutoBroadcastUserConfig:
return AutoBroadcastUserConfig.load_from_db()
@@ -1736,3 +1746,6 @@ def get_date_scoreboard(self, start: int, end: int):
result = {}
return result
+
+ def get_objective_row(self, row: int):
+ return super().get_objective_row(int(row))
diff --git a/rcon/commands.py b/rcon/commands.py
index d3d390133..c7cb0a934 100644
--- a/rcon/commands.py
+++ b/rcon/commands.py
@@ -4,7 +4,7 @@
import time
from contextlib import contextmanager, nullcontext
from functools import wraps
-from typing import Generator, List
+from typing import Generator, List, Sequence
from rcon.connection import HLLConnection
from rcon.types import ServerInfoType, VipId
@@ -366,12 +366,30 @@ def _read_list(self, raw: bytes, conn: HLLConnection) -> list[str]:
@_auto_retry
def _get_list(
- self, item: str, can_fail=True, conn: HLLConnection | None = None
+ self,
+ item: str,
+ can_fail=True,
+ conn: HLLConnection | None = None,
+ fail_msgs: Sequence[str] = [],
) -> list[str]:
if conn is None:
raise ValueError("conn parameter should never be None")
res = self._bytes_request(item, can_fail=can_fail, conn=conn)
+
+ # Some commands do not return "FAIL" when they fail. Normally we can handle them in the command implementation,
+ # but not with lists, where we need to do it here.
+ try:
+ decoded_res = res.decode()
+ except UnicodeDecodeError:
+ pass
+ else:
+ if decoded_res in fail_msgs:
+ if can_fail:
+ raise CommandFailedError(decoded_res)
+ else:
+ raise HLLServerError(f"Got FAIL for {item}: {decoded_res}")
+
return self._read_list(res, conn)
def get_profanities(self) -> list[str]:
@@ -719,6 +737,23 @@ def get_gamestate(self) -> list[str]:
return self._str_request("get gamestate", can_fail=False).split("\n")
+ def get_objective_row(self, row: int):
+ if not (0 <= row <= 4):
+ raise ValueError('Row must be between 0 and 4')
+
+ return self._get_list(
+ f'get objectiverow_{row}',
+ fail_msgs="Cannot execute command for this gamemode."
+ )
+
+ def set_game_layout(self, objectives: Sequence[str]):
+ if len(objectives) != 5:
+ raise ValueError("5 objectives must be provided")
+ self._str_request(
+ f'gamelayout "{objectives[0]}" "{objectives[1]}" "{objectives[2]}" "{objectives[3]}" "{objectives[4]}"', log_info=True,
+ can_fail=False
+ )
+ return list(objectives)
if __name__ == "__main__":
from rcon.settings import SERVER_INFO
diff --git a/rcon/maps.py b/rcon/maps.py
index 0380e2faf..b9b73c059 100644
--- a/rcon/maps.py
+++ b/rcon/maps.py
@@ -32,6 +32,7 @@ class MapType(typing_extensions.TypedDict):
shortname: str
allies: "Faction"
axis: "Faction"
+ orientation: str
class LayerType(typing_extensions.TypedDict):
@@ -57,6 +58,10 @@ class FactionType(typing_extensions.TypedDict):
# interacts with the CRCON API and enables much easier parsing of results
# for example where result is a plain python dictionary containing a serialized Layer:
# Layer.model_validate(result)
+class Orientation(str, Enum):
+ HORIZONTAL = "horizontal"
+ VERTICAL = "vertical"
+
class GameMode(str, Enum):
WARFARE = "warfare"
OFFENSIVE = "offensive"
@@ -121,6 +126,7 @@ class Map(pydantic.BaseModel):
shortname: str
allies: "Faction"
axis: "Faction"
+ orientation: Orientation
def __str__(self) -> str:
return self.id
@@ -226,6 +232,7 @@ def image_name(self) -> str:
shortname=UNKNOWN_MAP_NAME,
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="stmereeglise",
@@ -235,6 +242,7 @@ def image_name(self) -> str:
shortname="SME",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="stmariedumont",
@@ -244,6 +252,7 @@ def image_name(self) -> str:
shortname="SMDM",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="utahbeach",
@@ -253,6 +262,7 @@ def image_name(self) -> str:
shortname="Utah",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="omahabeach",
@@ -262,6 +272,7 @@ def image_name(self) -> str:
shortname="Omaha",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="purpleheartlane",
@@ -271,6 +282,7 @@ def image_name(self) -> str:
shortname="PHL",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="carentan",
@@ -280,6 +292,7 @@ def image_name(self) -> str:
shortname="Carentan",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="hurtgenforest",
@@ -289,6 +302,7 @@ def image_name(self) -> str:
shortname="Hurtgen",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="hill400",
@@ -298,6 +312,7 @@ def image_name(self) -> str:
shortname="Hill 400",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="foy",
@@ -307,6 +322,7 @@ def image_name(self) -> str:
shortname="Foy",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="kursk",
@@ -316,6 +332,7 @@ def image_name(self) -> str:
shortname="Kursk",
allies=Faction(name=FactionName.RUS.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="stalingrad",
@@ -325,6 +342,7 @@ def image_name(self) -> str:
shortname="Stalingrad",
allies=Faction(name=FactionName.RUS.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="remagen",
@@ -334,6 +352,7 @@ def image_name(self) -> str:
shortname="Remagen",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="kharkov",
@@ -343,6 +362,7 @@ def image_name(self) -> str:
shortname="Kharkov",
allies=Faction(name=FactionName.RUS.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="driel",
@@ -352,6 +372,7 @@ def image_name(self) -> str:
shortname="Driel",
allies=Faction(name=FactionName.GB.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
),
Map(
id="elalamein",
@@ -361,6 +382,7 @@ def image_name(self) -> str:
shortname="Alamein",
allies=Faction(name=FactionName.GB.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
Map(
id="mortain",
@@ -370,6 +392,7 @@ def image_name(self) -> str:
shortname="MOR",
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.HORIZONTAL,
),
)
}
@@ -920,6 +943,7 @@ def parse_layer(layer_name: str | Layer) -> Layer:
shortname=tag,
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
)
try:
@@ -967,6 +991,7 @@ def _parse_legacy_layer(layer_name: str):
shortname=name.capitalize(),
allies=Faction(name=FactionName.US.value, team=Team.ALLIES),
axis=Faction(name=FactionName.GER.value, team=Team.AXIS),
+ orientation=Orientation.VERTICAL,
)
result = Layer(id=layer_name, map=map_, game_mode=GameMode.WARFARE)
diff --git a/rcon/rcon.py b/rcon/rcon.py
index 36723ea30..65d6fc54e 100644
--- a/rcon/rcon.py
+++ b/rcon/rcon.py
@@ -1,12 +1,13 @@
import logging
import os
+import random
import re
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from functools import cached_property
from itertools import chain
from time import sleep
-from typing import Any, Iterable, Literal, Optional, overload
+from typing import Any, Iterable, List, Literal, Optional, Sequence, overload
from dateutil import parser
@@ -19,6 +20,7 @@
from rcon.settings import SERVER_INFO
from rcon.types import (
AdminType,
+ GameLayoutRandomConstraints,
GameServerBanType,
GameState,
GetDetailedPlayer,
@@ -1309,6 +1311,96 @@ def set_maprotation(self, map_names: list[str]) -> list[Layer]:
super().remove_map_from_rotation(current[0], 1)
return self.get_map_rotation()
+
+ @ttl_cache(ttl=10)
+ def get_objective_row(self, row: int):
+ return super().get_objective_row(row)
+
+ def get_objective_rows(self) -> List[List[str]]:
+ return [
+ self.get_objective_row(row)
+ for row in range(5)
+ ]
+
+ def set_game_layout(self, objectives: Sequence[str | int | None], random_constraints: GameLayoutRandomConstraints = 0):
+ if len(objectives) != 5:
+ raise ValueError("5 objectives must be provided")
+
+ obj_rows = self.get_objective_rows()
+ parsed_objs: list[str] = []
+ for row, (obj, obj_row) in enumerate(zip(objectives, obj_rows)):
+ if isinstance(obj, str):
+ # Verify whether the objective exists, or if it can be logically determined
+ if obj in obj_row:
+ parsed_objs.append(obj)
+ elif obj in ("left", "top"):
+ parsed_objs.append(obj_row[0])
+ elif obj in ("center", "mid"):
+ parsed_objs.append(obj_row[1])
+ elif obj in ("right", "bottom"):
+ parsed_objs.append(obj_row[2])
+ else:
+ raise ValueError("Objective %s does not exist in row %s" % (obj, row))
+
+ elif isinstance(obj, int):
+ # Use index of the objective
+ if not (0 <= obj <= 2):
+ raise ValueError("Objective index %s is out of range 0-2 in row %s" % (obj, row + 1))
+ parsed_objs.append(obj_row[obj])
+
+ elif obj is None:
+ # Choose randomly
+ if random_constraints:
+ # Sort later
+ parsed_objs.append(None)
+ else:
+ # No constraints, no need for a special algorithm
+ parsed_objs.append(random.choice(obj_row))
+
+ # Special algorithm to apply randomness with constraints. Prioritizes rows with already
+ # determined neighbors to avoid conflicts in situations with two neighbors.
+ while None in parsed_objs:
+ # If no rows are predetermined, determine the middle row first
+ if all(obj is None for obj in parsed_objs):
+ parsed_objs[2] = random.choice(obj_rows[2])
+
+ for row, obj in enumerate(parsed_objs):
+ # Skip if row is already determined
+ if obj is not None:
+ continue
+
+ # Get the indices of the objectives of neighboring rows, if they are already determined
+ neighbors = []
+ if row > 0 and parsed_objs[row - 1] is not None:
+ neighbors.append(obj_rows[row - 1].index(parsed_objs[row - 1]))
+ if row < 4 and parsed_objs[row + 1] is not None:
+ neighbors.append(obj_rows[row + 1].index(parsed_objs[row + 1]))
+
+ # Skip this row for now if neither of its neighbors had their objective determined yet
+ if not neighbors:
+ continue
+
+ # Create a list of available objectives
+ obj_row = obj_rows[row]
+ obj_choices = obj_row.copy()
+ for neighbor_idx in neighbors:
+ # Apply constraints
+ if random_constraints & GameLayoutRandomConstraints.ALWAYS_ADJACENT:
+ # Cannot have two objectives follow each other up on opposite sides of the map
+ if neighbor_idx == 0:
+ obj_choices[2] = None
+ elif neighbor_idx == 2:
+ obj_choices[0] = None
+ if random_constraints & GameLayoutRandomConstraints.ALWAYS_DIAGONAL:
+ # Cannot have two objectives in a straight row
+ obj_choices[neighbor_idx] = None
+
+ # Pick an objective. If none are viable, discard constraints.
+ parsed_objs[row] = random.choice(
+ [c for c in obj_choices if c is not None] or obj_row
+ )
+
+ return super().set_game_layout(parsed_objs)
@staticmethod
def parse_log_line(raw_line: str) -> StructuredLogLineType:
diff --git a/rcon/types.py b/rcon/types.py
index ec25b80cc..f84ceeba9 100644
--- a/rcon/types.py
+++ b/rcon/types.py
@@ -770,3 +770,8 @@ class DjangoUserPermissions(TypedDict):
permissions: list[DjangoPermission]
groups: list[DjangoGroup]
is_superuser: bool
+
+
+class GameLayoutRandomConstraints(enum.IntFlag):
+ ALWAYS_ADJACENT = enum.auto()
+ ALWAYS_DIAGONAL = enum.auto()
diff --git a/rcon/vote_map.py b/rcon/vote_map.py
index f6d3be036..8771000e2 100644
--- a/rcon/vote_map.py
+++ b/rcon/vote_map.py
@@ -127,9 +127,15 @@ def _suggest_next_maps(
last_n_maps = set(m for m in maps_history[:exclude_last_n])
else:
last_n_maps: set[maps.Layer] = set()
- logger.info("Excluding last %s player maps: %s", exclude_last_n, last_n_maps)
+ logger.info(
+ "Excluding last %s player maps: %s",
+ exclude_last_n,
+ [m.pretty_name for m in last_n_maps],
+ )
remaining_maps = [maps.parse_layer(m) for m in allowed_maps - last_n_maps]
- logger.info("Remaining maps to suggest from: %s", remaining_maps)
+ logger.info(
+ "Remaining maps to suggest from: %s", [m.pretty_name for m in remaining_maps]
+ )
current_side = current_map.attackers
@@ -142,7 +148,10 @@ def _suggest_next_maps(
remaining_maps = [
maps.parse_layer(m) for m in remaining_maps if m.map not in map_ids
]
- logger.info("Remaining maps to suggest from: %s", remaining_maps)
+ logger.info(
+ "Remaining maps to suggest from: %s",
+ [m.pretty_name for m in remaining_maps],
+ )
if not allow_consecutive_offensives_of_opposite_side and current_side:
# TODO: make sure this is correct
@@ -155,7 +164,10 @@ def _suggest_next_maps(
"Not allowing consecutive offensive with opposite side: %s",
maps.get_opposite_side(current_side),
)
- logger.info("Remaining maps to suggest from: %s", remaining_maps)
+ logger.info(
+ "Remaining maps to suggest from: %s",
+ [m.pretty_name for m in remaining_maps],
+ )
# Handle case if all maps got excluded
categorized_maps = categorize_maps(remaining_maps)
@@ -197,7 +209,7 @@ def _suggest_next_maps(
logger.error("No maps can be suggested with the given parameters.")
raise RestrictiveFilterError("Unable to suggest map")
- logger.info("Suggestion %s", selection)
+ logger.info("Suggestion %s", [m.pretty_name for m in selection])
return selection
@@ -684,7 +696,7 @@ def gen_selection(self):
)
self.set_selection(selection=selection)
- logger.info("Saved new selection: %s", selection)
+ logger.info("Saved new selection: %s", [m.pretty_name for m in selection])
def _get_selection(self) -> list[str]:
"""Return the current map suggestions"""
diff --git a/rcongui/package-lock.json b/rcongui/package-lock.json
index 7f2b0beda..803664daf 100644
--- a/rcongui/package-lock.json
+++ b/rcongui/package-lock.json
@@ -23,6 +23,7 @@
"@testing-library/user-event": "^7.1.2",
"chart.js": "^3.9.1",
"chartjs-adapter-moment": "^1.0.0",
+ "clsx": "^2.1.1",
"country-list": "^2.2.0",
"emoji-mart": "^3.0.0",
"faker": "^4.1.0",
@@ -2565,6 +2566,14 @@
}
}
},
+ "node_modules/@material-ui/core/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@material-ui/icons": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz",
@@ -2615,6 +2624,14 @@
}
}
},
+ "node_modules/@material-ui/lab/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@material-ui/pickers": {
"version": "3.2.10",
"resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.10.tgz",
@@ -2635,6 +2652,14 @@
"react-dom": "^16.8.4"
}
},
+ "node_modules/@material-ui/pickers/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@material-ui/styles": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.2.tgz",
@@ -2676,6 +2701,14 @@
}
}
},
+ "node_modules/@material-ui/styles/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@material-ui/system": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.11.2.tgz",
@@ -3678,9 +3711,9 @@
"integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q=="
},
"node_modules/clsx": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
- "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==",
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
@@ -5041,6 +5074,14 @@
"react-dom": "^16.8.0"
}
},
+ "node_modules/mui-datatables/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
@@ -5637,6 +5678,14 @@
"react-dom": "^15.3.0 || ^16.0.0-alpha"
}
},
+ "node_modules/react-virtualized/node_modules/clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
diff --git a/rcongui/package.json b/rcongui/package.json
index 1f907b972..00d8ac29b 100644
--- a/rcongui/package.json
+++ b/rcongui/package.json
@@ -18,6 +18,7 @@
"@testing-library/user-event": "^7.1.2",
"chart.js": "^3.9.1",
"chartjs-adapter-moment": "^1.0.0",
+ "clsx": "^2.1.1",
"country-list": "^2.2.0",
"emoji-mart": "^3.0.0",
"faker": "^4.1.0",
diff --git a/rcongui/public/tac-maps/carentan.webp b/rcongui/public/tac-maps/carentan.webp
new file mode 100644
index 000000000..f4c309622
Binary files /dev/null and b/rcongui/public/tac-maps/carentan.webp differ
diff --git a/rcongui/public/tac-maps/driel.webp b/rcongui/public/tac-maps/driel.webp
new file mode 100644
index 000000000..e0bc11449
Binary files /dev/null and b/rcongui/public/tac-maps/driel.webp differ
diff --git a/rcongui/public/tac-maps/elalamein.webp b/rcongui/public/tac-maps/elalamein.webp
new file mode 100644
index 000000000..5a78b7e5f
Binary files /dev/null and b/rcongui/public/tac-maps/elalamein.webp differ
diff --git a/rcongui/public/tac-maps/foy.webp b/rcongui/public/tac-maps/foy.webp
new file mode 100644
index 000000000..a512ecbb5
Binary files /dev/null and b/rcongui/public/tac-maps/foy.webp differ
diff --git a/rcongui/public/tac-maps/hill400.webp b/rcongui/public/tac-maps/hill400.webp
new file mode 100644
index 000000000..6e95b2fc2
Binary files /dev/null and b/rcongui/public/tac-maps/hill400.webp differ
diff --git a/rcongui/public/tac-maps/hurtgenforrest.webp b/rcongui/public/tac-maps/hurtgenforrest.webp
new file mode 100644
index 000000000..fcdf30c7b
Binary files /dev/null and b/rcongui/public/tac-maps/hurtgenforrest.webp differ
diff --git a/rcongui/public/tac-maps/kharkov.webp b/rcongui/public/tac-maps/kharkov.webp
new file mode 100644
index 000000000..06f61364c
Binary files /dev/null and b/rcongui/public/tac-maps/kharkov.webp differ
diff --git a/rcongui/public/tac-maps/kursk.webp b/rcongui/public/tac-maps/kursk.webp
new file mode 100644
index 000000000..85a7f26d6
Binary files /dev/null and b/rcongui/public/tac-maps/kursk.webp differ
diff --git a/rcongui/public/tac-maps/mortain.webp b/rcongui/public/tac-maps/mortain.webp
new file mode 100644
index 000000000..323cdefcd
Binary files /dev/null and b/rcongui/public/tac-maps/mortain.webp differ
diff --git a/rcongui/public/tac-maps/omahabeach.webp b/rcongui/public/tac-maps/omahabeach.webp
new file mode 100644
index 000000000..436f0239a
Binary files /dev/null and b/rcongui/public/tac-maps/omahabeach.webp differ
diff --git a/rcongui/public/tac-maps/purpleheartlane.webp b/rcongui/public/tac-maps/purpleheartlane.webp
new file mode 100644
index 000000000..c3a3103ef
Binary files /dev/null and b/rcongui/public/tac-maps/purpleheartlane.webp differ
diff --git a/rcongui/public/tac-maps/remagen.webp b/rcongui/public/tac-maps/remagen.webp
new file mode 100644
index 000000000..279dd548b
Binary files /dev/null and b/rcongui/public/tac-maps/remagen.webp differ
diff --git a/rcongui/public/tac-maps/stalingrad.webp b/rcongui/public/tac-maps/stalingrad.webp
new file mode 100644
index 000000000..03fde2c91
Binary files /dev/null and b/rcongui/public/tac-maps/stalingrad.webp differ
diff --git a/rcongui/public/tac-maps/stmariedumont.webp b/rcongui/public/tac-maps/stmariedumont.webp
new file mode 100644
index 000000000..5b6ccd654
Binary files /dev/null and b/rcongui/public/tac-maps/stmariedumont.webp differ
diff --git a/rcongui/public/tac-maps/stmereeglise.webp b/rcongui/public/tac-maps/stmereeglise.webp
new file mode 100644
index 000000000..44012c066
Binary files /dev/null and b/rcongui/public/tac-maps/stmereeglise.webp differ
diff --git a/rcongui/public/tac-maps/utahbeach.webp b/rcongui/public/tac-maps/utahbeach.webp
new file mode 100644
index 000000000..45a814052
Binary files /dev/null and b/rcongui/public/tac-maps/utahbeach.webp differ
diff --git a/rcongui/src/App.css b/rcongui/src/App.css
index 74b5e0534..ad1a806f0 100644
--- a/rcongui/src/App.css
+++ b/rcongui/src/App.css
@@ -1,5 +1,7 @@
.App {
- text-align: center;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
}
.App-logo {
diff --git a/rcongui/src/App.js b/rcongui/src/App.js
index 65754e888..3a212e54f 100644
--- a/rcongui/src/App.js
+++ b/rcongui/src/App.js
@@ -57,6 +57,7 @@ import {
} from "./components/UserSettings/miscellaneous";
import BlacklistRecords from "./components/Blacklist/BlacklistRecords";
import BlacklistLists from "./components/Blacklist/BlacklistLists";
+import { MapManager } from "./components/MapManager/map-manager";
const Live = ({ classes }) => {
const [mdSize, setMdSize] = React.useState(6);
@@ -399,7 +400,7 @@ function App() {
const Router = isEmbed ? BrowserRouter : HashRouter;
return (
-
+
{isEmbed ? "" : }
@@ -412,56 +413,56 @@ function App() {
)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!process.env.REACT_APP_PUBLIC_BUILD ? (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!process.env.REACT_APP_PUBLIC_BUILD ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
-
+
@@ -476,6 +477,7 @@ function App() {
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ Players
+
+
+
+
+
+ Historical Logs
+
+
+
-
-
-
-
-
-
- Players
-
-
-
-
-
- Historical Logs
-
-
-
-
-
-
- ) : (
- ""
- )}
-
+
+
+ ) : (
+ ""
+ )}
+
+
{isEmbed ? "" : }
diff --git a/rcongui/src/components/Blacklist/BlacklistRecords.js b/rcongui/src/components/Blacklist/BlacklistRecords.js
index b75745a77..1a3f80e6c 100644
--- a/rcongui/src/components/Blacklist/BlacklistRecords.js
+++ b/rcongui/src/components/Blacklist/BlacklistRecords.js
@@ -1,13 +1,15 @@
-import {
- Box,
- Button,
- Grid,
- LinearProgress,
-} from "@material-ui/core";
+import { Box, Button, Grid, LinearProgress } from "@material-ui/core";
import useStyles from "../useStyles";
import BlacklistRecordsSearch from "./BlacklistRecordsSearch";
import React from "react";
-import { addPlayerToBlacklist, get, getBlacklists, handle_http_errors, postData, showResponse } from "../../utils/fetchUtils";
+import {
+ addPlayerToBlacklist,
+ get,
+ getBlacklists,
+ handle_http_errors,
+ postData,
+ showResponse,
+} from "../../utils/fetchUtils";
import Pagination from "@material-ui/lab/Pagination";
import BlacklistRecordGrid from "./BlacklistRecordGrid";
import { List, fromJS } from "immutable";
@@ -51,75 +53,103 @@ const BlacklistRecords = ({ classes: globalClasses }) => {
reason: undefined,
blacklist_id: undefined,
exclude_expired: false,
- page_size: 50
+ page_size: 50,
});
- React.useEffect(
- () => {
- if (!blacklists.length) {
- loadBlacklists();
- }
- loadRecords();
- }, [searchQuery, page]
- );
+ React.useEffect(() => {
+ if (!blacklists.length) {
+ loadBlacklists();
+ }
+ loadRecords();
+ }, [searchQuery, page]);
async function loadBlacklists() {
- setBlacklists(await getBlacklists())
+ setBlacklists(await getBlacklists());
}
async function loadRecords() {
- setIsFetching(true)
+ setIsFetching(true);
try {
- const data = await getBlacklistRecords({ ...searchQuery, page })
+ const data = await getBlacklistRecords({ ...searchQuery, page });
const records = data.result;
if (records) {
- setRecords(fromJS(records.records))
- setTotalRecords(records.total)
+ setRecords(fromJS(records.records));
+ setTotalRecords(records.total);
}
- setIsFetching(false)
+ setIsFetching(false);
// delay UI, this can be removed along with skeletons
- await new Promise((res) => setTimeout(res, 500))
- setIsLoading(false)
+ await new Promise((res) => setTimeout(res, 500));
+ setIsLoading(false);
} catch (error) {
- handle_http_errors(error)
+ handle_http_errors(error);
}
}
async function createRecord(recordDetails) {
- await addPlayerToBlacklist(recordDetails)
- loadRecords()
+ await addPlayerToBlacklist(recordDetails);
+ loadRecords();
}
// If you don't like the loading skeletons, just return `null`
if (isLoading) {
return (
-
+
-
+
-
+
-
+
-
+
- )
+ );
}
return (
-
+
{
Create New Record
+ >
+ Create New Record
+
- )
-}
+ );
+};
-export default BlacklistRecords
\ No newline at end of file
+export default BlacklistRecords;
diff --git a/rcongui/src/components/GameView/index.js b/rcongui/src/components/GameView/index.js
index 3b2f34491..ce73064ef 100644
--- a/rcongui/src/components/GameView/index.js
+++ b/rcongui/src/components/GameView/index.js
@@ -40,7 +40,7 @@ import {
} from "../PlayerView/playerActions";
import { toast } from "react-toastify";
import { FlagDialog } from "../PlayersHistory";
-import Padlock from "../SettingsView/padlock";
+import Padlock from "../shared/padlock";
import BlacklistRecordCreateDialog from "../Blacklist/BlacklistRecordCreateDialog";
const useStyles = makeStyles((theme) => ({
@@ -314,8 +314,8 @@ const GameView = ({ classes: globalClasses }) => {
const [selectedPlayers, setSelectedPlayers] = React.useState(
new OrderedSet()
);
- const [resfreshFreqSecs, setResfreshFreqSecs] = React.useState(5);
- const [intervalHandle, setIntervalHandle] = React.useState(null);
+ const [refreshFreqSecs, setResfreshFreqSecs] = React.useState(5);
+ const intervalHandleRef = React.useRef(null);
const [flag, setFlag] = React.useState(false);
const [blacklistDialogOpen, setBlacklistDialogOpen] = React.useState(false);
const [blacklists, setBlacklists] = React.useState([]);
@@ -476,30 +476,23 @@ const GameView = ({ classes: globalClasses }) => {
.catch(handle_http_errors);
};
- const myInterval = React.useMemo(
- () => (func, ms) => {
- const handle = setTimeout(async () => {
- try {
- await func();
- } catch (e) {
- console.warn("Error in periodic refresh", e);
- }
- myInterval(func, ms);
- }, ms);
- setIntervalHandle(handle);
- },
- []
- );
-
React.useEffect(() => {
loadData();
}, []);
React.useEffect(() => {
- clearTimeout(intervalHandle);
- myInterval(loadData, resfreshFreqSecs * 1000);
- return () => clearInterval(intervalHandle);
- }, [resfreshFreqSecs, myInterval]);
+ // Set up the interval
+ intervalHandleRef.current = setInterval(() => {
+ loadData().catch(e => console.warn("Error in periodic refresh", e));
+ }, refreshFreqSecs * 1000);
+
+ // Clear the interval on component unmount or when refreshFreqSecs changes
+ return () => {
+ if (intervalHandleRef.current) {
+ clearInterval(intervalHandleRef.current);
+ }
+ };
+ }, [refreshFreqSecs]);
const isMessageLessAction = (actionType) =>
actionType.startsWith("switch_") || actionType.startsWith("unwatch_");
@@ -568,9 +561,12 @@ const GameView = ({ classes: globalClasses }) => {
};
function blacklistManyPlayers(payload) {
- const filteredPlayers = playerNamesToPlayerId?.filter(p => p !== undefined).toJS();
+ const playersToBlacklist = selectedPlayers
+ .toJS()
+ .map(playerName => playerNamesToPlayerId.get(playerName))
+ .filter(p => p !== undefined)
- Promise.allSettled(Object.entries(filteredPlayers).map(([_, playerId]) => (
+ Promise.allSettled(playersToBlacklist.map((playerId) => (
addPlayerToBlacklist({
...payload,
playerId,
@@ -586,12 +582,13 @@ const GameView = ({ classes: globalClasses }) => {
}
function selectedPlayersToRows() {
- const filteredPlayers = playerNamesToPlayerId?.filter(p => p !== undefined).toJS();
- const selected = []
- for (const [player, id] of Object.entries(filteredPlayers)) {
- selected.push(`${player} -> ${id}`)
- }
- return selected.join(",\n")
+ return selectedPlayers
+ .toJS()
+ .filter(p => playerNamesToPlayerId.get(p) !== undefined)
+ .map(player => {
+ const id = playerNamesToPlayerId.get(player)
+ return `${player} -> ${id}`
+ }).join(",\n")
}
return (
@@ -729,7 +726,7 @@ const GameView = ({ classes: globalClasses }) => {
inputProps={{ min: 2, max: 6000 }}
label="Refresh seconds"
helperText=""
- value={resfreshFreqSecs}
+ value={refreshFreqSecs}
onChange={(e) => setResfreshFreqSecs(e.target.value)}
/>
diff --git a/rcongui/src/components/Header/header.js b/rcongui/src/components/Header/header.js
index 0214ab3df..fe35722b8 100644
--- a/rcongui/src/components/Header/header.js
+++ b/rcongui/src/components/Header/header.js
@@ -1,571 +1,107 @@
import React from "react";
-import "react-toastify/dist/ReactToastify.css";
import Grid from "@material-ui/core/Grid";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import Link from "@material-ui/core/Link";
import { Link as RouterLink } from "react-router-dom";
-import TextField from "@material-ui/core/TextField";
import Button from "@material-ui/core/Button";
-import ServerStatus from "./serverStatus";
-import {
- get,
- handle_http_errors,
- postData,
- showResponse,
-} from "../../utils/fetchUtils";
-import Dialog from "@material-ui/core/Dialog";
-import DialogActions from "@material-ui/core/DialogActions";
-import DialogContent from "@material-ui/core/DialogContent";
-import DialogTitle from "@material-ui/core/DialogTitle";
-import { throttle } from "lodash/function";
+import ServerStatus from "./server-status";
import Menu from "@material-ui/core/Menu";
import MenuItem from "@material-ui/core/MenuItem";
-
-const LoginModal = ({
- open,
- password,
- username,
- handleClose,
- setPassword,
- setUsername,
- login,
-}) => (
-
-);
-
-class LoginBox extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- username: "",
- password: "",
- open: false,
- isLoggedIn: false,
- interval: null,
- };
-
- this.login = this.login.bind(this);
- this.logout = this.logout.bind(this);
- this.isLoggedIn = this.isLoggedIn.bind(this);
- }
-
- componentDidMount() {
- const f = throttle(this.isLoggedIn, 1000 * 55);
- this.isLoggedIn();
- this.setState({
- interval: setInterval(f, 1000 * 60),
- });
- }
-
- componentWillUnmount() {
- clearInterval(this.state.interval);
- }
-
- isLoggedIn() {
- return get("is_logged_in")
- .then((response) => response.json())
- .then((res) => this.setState({ isLoggedIn: res.result.authenticated }))
- .catch(handle_http_errors);
- }
-
- login() {
- return postData(`${process.env.REACT_APP_API_URL}login`, {
- username: this.state.username,
- password: this.state.password,
- })
- .then((res) => showResponse(res, `login ${this.state.username}`, true))
- .then((data) => {
- if (data.failed === false) {
- this.setState({ isLoggedIn: true, open: false, password: "" });
- }
- })
- .catch(handle_http_errors);
- }
-
- logout() {
- return get("logout")
- .then(this.setState({ isLoggedIn: false }))
- .catch(handle_http_errors);
- }
-
- render() {
- const { open, username, password, isLoggedIn } = this.state;
- const { classes } = this.props;
-
- return (
-
-
- isLoggedIn === true ? this.logout() : this.setState({ open: true })
- }
- to="#"
- >
- {isLoggedIn === true ? "Logout" : "Login"}
-
- this.setState({ open: false })}
- login={this.login}
- password={password}
- setPassword={(password) => this.setState({ password: password })}
- username={username}
- setUsername={(username) => this.setState({ username: username })}
- />
-
- );
- }
-}
+import { navMenus } from "./nav-data";
+import { LoginBox } from "./login";
+import { Box, createStyles, makeStyles } from "@material-ui/core";
+
+const useStyles = makeStyles((theme) => createStyles({
+ root: {
+ display: "flex",
+ flexGrow: 1,
+ flexDirection: "column",
+ justifyContent: "center",
+ alignItems: "start",
+ padding: theme.spacing(0.25),
+ minHeight: 0,
+ gap: theme.spacing(0.25),
+ [theme.breakpoints.up("md")]: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: theme.spacing(2),
+ padding: theme.spacing(0.5),
+ }
+ },
+ nav: {
+ display: "flex",
+ flexDirection: "row",
+ flexGrow: 1,
+ justifyContent: "space-between",
+ },
+}))
+
+const initialMenuState = navMenus.reduce((state, menu) => {
+ state[menu.name] = false;
+ return state;
+}, {});
// TODO: Make this reactive, it's causing the view on mobile to be bigger then it should
const Header = ({ classes }) => {
+ const [openedMenu, setOpenedMenu] = React.useState(initialMenuState);
const [anchorEl, setAnchorEl] = React.useState(null);
- const [anchorElScores, setAnchorElScores] = React.useState(null);
- const [anchorElSettings, setAnchorElSettings] = React.useState(null);
- const [anchorElLive, setAnchorElLive] = React.useState(null);
- const handleClick = (event) => {
- console.log(event.currentTarget);
+ const localClasses = useStyles()
+
+ const handleOpenMenu = (name) => (event) => {
+ setOpenedMenu({
+ ...openedMenu,
+ [name]: true,
+ });
setAnchorEl(event.currentTarget);
};
- const handleClose = () => {
+ const handleCloseMenu = (name) => () => {
+ setOpenedMenu({
+ ...openedMenu,
+ [name]: false,
+ });
setAnchorEl(null);
};
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/rcongui/src/components/Header/login.jsx b/rcongui/src/components/Header/login.jsx
new file mode 100644
index 000000000..aee49226f
--- /dev/null
+++ b/rcongui/src/components/Header/login.jsx
@@ -0,0 +1,133 @@
+import React from "react";
+import "react-toastify/dist/ReactToastify.css";
+import Grid from "@material-ui/core/Grid";
+import Link from "@material-ui/core/Link";
+import { Link as RouterLink } from "react-router-dom";
+import TextField from "@material-ui/core/TextField";
+import Button from "@material-ui/core/Button";
+import {
+ get,
+ handle_http_errors,
+ postData,
+ showResponse,
+} from "../../utils/fetchUtils";
+import Dialog from "@material-ui/core/Dialog";
+import DialogActions from "@material-ui/core/DialogActions";
+import DialogContent from "@material-ui/core/DialogContent";
+import DialogTitle from "@material-ui/core/DialogTitle";
+import { throttle } from "lodash/function";
+
+export class LoginBox extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ username: "",
+ password: "",
+ open: false,
+ isLoggedIn: false,
+ interval: null,
+ };
+
+ this.login = this.login.bind(this);
+ this.logout = this.logout.bind(this);
+ this.isLoggedIn = this.isLoggedIn.bind(this);
+ }
+
+ componentDidMount() {
+ const f = throttle(this.isLoggedIn, 1000 * 55);
+ this.isLoggedIn();
+ this.setState({
+ interval: setInterval(f, 1000 * 60),
+ });
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.state.interval);
+ }
+
+ isLoggedIn() {
+ return get("is_logged_in")
+ .then((response) => response.json())
+ .then((res) => this.setState({ isLoggedIn: res.result.authenticated }))
+ .catch(handle_http_errors);
+ }
+
+ login() {
+ return postData(`${process.env.REACT_APP_API_URL}login`, {
+ username: this.state.username,
+ password: this.state.password,
+ })
+ .then((res) => showResponse(res, `login ${this.state.username}`, true))
+ .then((data) => {
+ if (data.failed === false) {
+ this.setState({ isLoggedIn: true, open: false, password: "" });
+ }
+ })
+ .catch(handle_http_errors);
+ }
+
+ logout() {
+ return get("logout")
+ .then(this.setState({ isLoggedIn: false }))
+ .catch(handle_http_errors);
+ }
+
+ render() {
+ const { open, username, password, isLoggedIn } = this.state;
+
+ return (
+
+
+
+
+ );
+ }
+}
diff --git a/rcongui/src/components/Header/nav-data.js b/rcongui/src/components/Header/nav-data.js
new file mode 100644
index 000000000..15e6fb3ff
--- /dev/null
+++ b/rcongui/src/components/Header/nav-data.js
@@ -0,0 +1,154 @@
+export const navMenus = [
+ {
+ name: "views",
+ links: [
+ {
+ name: "Live",
+ to: "/",
+ },
+ {
+ name: "Game",
+ to: "/gameview",
+ },
+ ],
+ },
+ {
+ name: "records",
+ links: [
+ {
+ name: "Players",
+ to: "/history",
+ },
+ {
+ name: "Blacklist",
+ to: "/blacklists",
+ },
+ {
+ name: "Logs",
+ to: "/logs",
+ },
+ {
+ name: "Audit Logs",
+ to: "/auditlogs",
+ },
+ {
+ name: "Combined",
+ to: "/combined_history",
+ },
+ ],
+ },
+ {
+ name: "settings",
+ links: [
+ {
+ name: "Settings",
+ to: "/settings",
+ },
+ {
+ name: "Map Manager",
+ to: "/settings/maps/change"
+ },
+ {
+ name: "Audit Webhooks",
+ to: "/settings/audit-webhooks",
+ },
+ {
+ name: "Admin Ping Webhooks",
+ to: "/settings/admin-webhooks",
+ },
+ {
+ name: "Watchlist Webhooks",
+ to: "/settings/watchlist-webhooks",
+ },
+ {
+ name: "Camera Webhooks",
+ to: "/settings/camera-webhooks",
+ },
+ {
+ name: "Kill/TK Webhooks",
+ to: "/settings/kill-webhooks",
+ },
+ {
+ name: "Level Auto Mod",
+ to: "/settings/automod-level",
+ },
+ {
+ name: "No Leader Auto Mod",
+ to: "/settings/automod-no-leader",
+ },
+ {
+ name: "Seeding Auto Mod",
+ to: "/settings/automod-seeding",
+ },
+ {
+ name: "No Solo Tank Auto Mod",
+ to: "/settings/chat-webhooks",
+ },
+ {
+ name: "RCON Game Server Connection",
+ to: "/settings/rcon-gameserver",
+ },
+ {
+ name: "CRCON Settings",
+ to: "/settings/rcon-server",
+ },
+ {
+ name: "Chat Commands",
+ to: "/settings/chat-commands",
+ },
+ {
+ name: "Scorebot",
+ to: "/settings/scorebot",
+ },
+ {
+ name: "Steam API",
+ to: "/settings/steam",
+ },
+ {
+ name: "VAC/Game Bans",
+ to: "/settings/vac-gamebans",
+ },
+ {
+ name: "TK Ban On Connect",
+ to: "/settings/tk-ban",
+ },
+ {
+ name: "Name Kicks",
+ to: "/settings/name-kicks",
+ },
+ {
+ name: "Log Line Webhooks",
+ to: "/settings/log-lines",
+ },
+ {
+ name: "Expired VIP",
+ to: "/settings/expired-vip",
+ },
+ {
+ name: "GTX Server Name Change",
+ to: "/settings/gtx-server-name-change",
+ },
+ {
+ name: "Log Stream",
+ to: "/settings/log-stream",
+ },
+ ],
+ },
+ {
+ name: "stats",
+ links: [
+ {
+ name: "Live Sessions",
+ to: "/livescore",
+ },
+ {
+ name: "Live Game",
+ to: "/livegamescore",
+ },
+ {
+ name: "Games",
+ to: "/gamescoreboard",
+ },
+ ],
+ },
+];
diff --git a/rcongui/src/components/Header/server-status.jsx b/rcongui/src/components/Header/server-status.jsx
new file mode 100644
index 000000000..96155f4f7
--- /dev/null
+++ b/rcongui/src/components/Header/server-status.jsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect } from "react";
+import "react-toastify/dist/ReactToastify.css";
+import { get, handle_http_errors, showResponse } from "../../utils/fetchUtils";
+import { createStyles, makeStyles } from "@material-ui/core/styles";
+import Menu from "@material-ui/core/Menu";
+import MenuItem from "@material-ui/core/MenuItem";
+import Link from "@material-ui/core/Link";
+import { fromJS, List } from "immutable";
+import { Box, IconButton, Typography } from "@material-ui/core";
+import SwapVertIcon from '@material-ui/icons/SwapVert';
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ paddingLeft: theme.spacing(1),
+ },
+ menu: {
+ display: "flex",
+ gap: theme.spacing(1),
+ },
+ })
+);
+
+const ServerStatus = () => {
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [name, setName] = useState("");
+ const [numCurrentPlayers, setNumCurrentPlayers] = useState(0);
+ const [maxPlayers, setMaxPlayers] = useState(0);
+ const [map, setMap] = useState(null);
+ const [serverList, setServerList] = useState(List());
+ const [timeRemaining, setTimeRemaining] = useState("0:00:00");
+ const [balance, setBalance] = useState("0vs0");
+ const [score, setScore] = useState("0:0");
+ const classes = useStyles();
+
+ const refreshIntervalSec = 10;
+ const listRefreshIntervalSec = 30;
+
+ const handleClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const load = async () => {
+ return get(`get_status`)
+ .then((response) => showResponse(response, "get_status", false))
+ .then((data) => {
+ setName(data?.result.name);
+ setMap(data?.result.map);
+ setNumCurrentPlayers(data.result.current_players);
+ setMaxPlayers(data.result.max_players);
+ document.title = `(${data?.result.current_players}) ${data?.result.short_name}`;
+ })
+ .catch(handle_http_errors);
+ };
+
+ const loadInfo = async () => {
+ return get(`get_gamestate`)
+ .then((response) => showResponse(response, "get_gamestate", false))
+ .then((data) => {
+ setBalance(
+ `${data.result.num_allied_players}vs${data.result.num_axis_players}`
+ );
+ setScore(`${data.result.allied_score}:${data.result.axis_score}`);
+ setTimeRemaining(data.result.raw_time_remaining);
+ })
+ .catch(handle_http_errors);
+ };
+
+ const loadServerList = async () => {
+ return get(`get_server_list`)
+ .then((response) => showResponse(response, "get_server_list", false))
+ .then((data) => {
+ setServerList(fromJS(data.result || []));
+ })
+ .catch(handle_http_errors);
+ };
+
+ useEffect(() => {
+ load();
+ const interval = setInterval(load, refreshIntervalSec * 1000);
+ const intervalLoadList = setInterval(() => {
+ loadServerList();
+ loadInfo();
+ }, listRefreshIntervalSec * 1000);
+
+ loadServerList();
+ loadInfo();
+
+ return () => {
+ clearInterval(interval);
+ clearInterval(intervalLoadList);
+ };
+ }, []);
+
+ return (
+
+
+
+ {name}
+
+ {!!serverList.size && (
+
+
+
+ )}
+
+
+
+ {numCurrentPlayers}/{maxPlayers} ({balance}) -{" "}
+ {map?.pretty_name ?? "Unknown Map"} - {timeRemaining} - {score}
+
+
+ );
+};
+
+export default ServerStatus;
diff --git a/rcongui/src/components/Header/serverStatus.js b/rcongui/src/components/Header/serverStatus.js
deleted file mode 100644
index b35ad79a1..000000000
--- a/rcongui/src/components/Header/serverStatus.js
+++ /dev/null
@@ -1,222 +0,0 @@
-import React from "react";
-import "react-toastify/dist/ReactToastify.css";
-import Grid from "@material-ui/core/Grid";
-import { get, handle_http_errors, showResponse } from "../../utils/fetchUtils";
-
-import debounce from "lodash/debounce";
-import { useTheme } from "@material-ui/core/styles";
-import useMediaQuery from "@material-ui/core/useMediaQuery";
-import Menu from "@material-ui/core/Menu";
-import MenuItem from "@material-ui/core/MenuItem";
-import Link from "@material-ui/core/Link";
-import { fromJS, List } from "immutable";
-
-const Status = ({
- classes,
- name,
- numCurrentPlayers,
- maxPlayers,
- map,
- serverList,
- timeRemaining,
- balance,
- score,
-}) => {
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("sm"));
- const [anchorEl, setAnchorEl] = React.useState(null);
-
- const handleClick = (event) => {
- setAnchorEl(event.currentTarget);
- };
-
- const handleClose = () => {
- setAnchorEl(null);
- };
-
- return (
-
-
-
-
-
- {isSmall ? `${name.substring(0, 40)}...` : name}
-
-
-
-
- {numCurrentPlayers}/{maxPlayers} ({balance}) - {map.pretty_name} -{" "}
- {timeRemaining} - {score}
-
-
-
-
- );
-};
-
-class ServerStatus extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- name: "",
- numCurrentPlayers: 0,
- maxPlayers: 0,
- map: new Map(),
- serverList: List(),
- refreshIntervalSec: 10,
- listRefreshIntervalSec: 30,
- interval: null,
- intervalLoadList: null,
- numAlliedPlayers: 0,
- numAxisPlayers: 0,
- alliedScore: 0,
- axisScore: 0,
- timeRemaining: "0:00:00",
- rawTimeRemaining: "0:00:00",
- currentMap: "",
- nextMap: "",
- };
-
- this.debouncedLoad = debounce(
- this.load.bind(this),
- this.state.refreshIntervalSec
- );
- this.debouncedLoadList = debounce(
- this.loadServerList.bind(this),
- this.state.listRefreshIntervalSec
- );
- this.debouncedLoadInfo = debounce(
- this.loadInfo.bind(this),
- this.state.listRefreshIntervalSec
- );
- }
-
- componentDidMount() {
- this.load();
- this.setState({
- interval: setInterval(
- this.debouncedLoad,
- this.state.refreshIntervalSec * 1000
- ),
- });
- this.setState({
- intervalLoadList: setInterval(() => {
- this.debouncedLoadList();
- this.debouncedLoadInfo();
- }, this.state.listRefreshIntervalSec * 1000),
- });
- this.loadServerList();
- this.loadInfo();
- }
-
- componentWillUnmount() {
- clearInterval(this.state.interval);
- clearInterval(this.state.intervalLoadList);
- }
-
- async load() {
- return get(`get_status`)
- .then((response) => showResponse(response, "get_status", false))
- .then((data) => {
- this.setState({
- name: data?.result.name,
- map: data?.result.map,
- numCurrentPlayers: data.result.current_players,
- maxPlayers: data.result.max_players,
- });
- document.title = `(${data?.result.current_players}) ${data?.result.short_name}`;
- })
- .catch(handle_http_errors);
- }
-
- async loadInfo() {
- return get(`get_gamestate`)
- .then((response) => showResponse(response, "get_gamestate", false))
- .then((data) => {
- this.setState({
- numAlliedPlayers: data.result.num_allied_players,
- numAxisPlayers: data.result.num_axis_players,
- alliedScore: data.result.allied_score,
- axisScore: data.result.axis_score,
- timeRemaining: data.result.time_remaining,
- rawTimeRemaining: data.result.raw_time_remaining,
- currentMap: data.result.current_map,
- nextMap: data.result.next_map,
- });
- })
- .catch(handle_http_errors);
- }
-
- async loadServerList() {
- return get(`get_server_list`)
- .then((response) => showResponse(response, "get_server_list", false))
- .then((data) => {
- this.setState({
- serverList: fromJS(data.result || []),
- });
- })
- .catch(handle_http_errors);
- }
-
- render() {
- const {
- map,
- name,
- numCurrentPlayers,
- maxPlayers,
- serverList,
- rawTimeRemaining,
- axisScore,
- alliedScore,
- numAxisPlayers,
- numAlliedPlayers,
- } = this.state;
- const { classes } = this.props;
-
- return (
-
- );
- }
-}
-
-export default ServerStatus;
diff --git a/rcongui/src/components/MapManager/DraggableList.js b/rcongui/src/components/MapManager/DraggableList.js
index df394cc8f..0b6ecb77f 100644
--- a/rcongui/src/components/MapManager/DraggableList.js
+++ b/rcongui/src/components/MapManager/DraggableList.js
@@ -4,16 +4,17 @@ import {
DragDropContext,
Droppable,
} from "react-beautiful-dnd";
+import { List } from "@material-ui/core";
const DraggableList = React.memo(({ items, onDragEnd, onRemove }) => {
return (
{(provided) => (
-
{items.map((item, index) => (
{
/>
))}
{provided.placeholder}
-
+
)}
diff --git a/rcongui/src/components/MapManager/DraggableListItem.js b/rcongui/src/components/MapManager/DraggableListItem.js
index a72e6ce1c..1905b146b 100644
--- a/rcongui/src/components/MapManager/DraggableListItem.js
+++ b/rcongui/src/components/MapManager/DraggableListItem.js
@@ -8,69 +8,24 @@ import ListItemText from "@material-ui/core/ListItemText";
import {
ListItemSecondaryAction,
IconButton,
- Chip,
- Typography,
+ createStyles,
} from "@material-ui/core";
import Avatar from "@material-ui/core/Avatar";
import DeleteIcon from "@material-ui/icons/Delete";
+import { MapDetails } from "./map-details";
-const useStyles = makeStyles({
+const useStyles = makeStyles((theme) => createStyles({
draggingListItem: {
- background: "rgb(235,235,235)",
+ boxShadow: "rgba(6, 24, 44, 0.4) 0px 0px 0px 2px, rgba(6, 24, 44, 0.65) 0px 4px 6px -1px, rgba(255, 255, 255, 0.08) 0px 1px 0px 0px inset"
},
- noDot: {
- listStyleType: "none",
+ base: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
},
-});
+}));
const DraggableListItem = ({ item, index, onRemove }) => {
- const getLabels = (layer) => {
- const labels = [];
-
- if (layer.game_mode === "offensive") {
- labels.push("offensive");
- if (
- layer.attackers == "allies"
- ) {
- labels.push("allies");
- } else {
- labels.push("axis");
- }
- } else if (
- layer.game_mode === "control" ||
- layer.game_mode === "phased" ||
- layer.game_mode === "majority"
- ) {
- labels.push("skirmish");
- } else {
- labels.push("warfare");
- }
-
- if (layer.environment !== "day") {
- labels.push("night");
- }
- return labels;
- };
-
- const labelsColors = {
- offensive: "primary",
- night: "secondary",
- warfare: "default",
- allies: "primary",
- axis: "secondary",
- skirmish: "secondary",
- };
-
- const labelsVariant = {
- offensive: "default",
- night: "default",
- warfare: "default",
- axis: "outlined",
- allies: "outlined",
- skirmish: "default",
- };
-
const classes = useStyles();
+
return (
{(provided, snapshot) => (
@@ -79,31 +34,15 @@ const DraggableListItem = ({ item, index, onRemove }) => {
{...provided.draggableProps}
{...provided.dragHandleProps}
className={
- snapshot.isDragging
- ? classes.draggingListItem + " " + classes.noDot
- : classes.noDot
+ snapshot.isDragging ? classes.draggingListItem : classes.base
}
>
-
- {item.pretty_name}{" "}
- {getLabels(item).map((e) => (
-
- ))}
-
- >
- }
- secondary={<>{item.id}>}
+ primary={item.map.pretty_name}
+ secondary={}
/>
//secondary: faker.company.catchPhrase()
//}
);
+
+export const unifiedGamemodeName = (modeName) => {
+ switch (modeName) {
+ case "control":
+ case "phased":
+ case "majority":
+ return "skirmish";
+ default:
+ return modeName;
+ }
+}
+
+export const generateInitialState = (orientation, defaultState = false) => {
+ const gridSize = 5;
+ const blocked = null;
+
+ const blockedRowTemplate = Array(gridSize).fill(blocked);
+ const verticalRowTemplate = [
+ blocked,
+ defaultState,
+ defaultState,
+ defaultState,
+ blocked,
+ ];
+ const horizontalRowTemplate = Array(gridSize).fill(defaultState);
+
+ return Array(gridSize)
+ .fill(null)
+ .map((_, row) => {
+ if (orientation === "vertical") {
+ return verticalRowTemplate;
+ }
+ if (row === 0 || row === gridSize - 1) {
+ return blockedRowTemplate;
+ }
+ return horizontalRowTemplate;
+ });
+};
+
+export const getMapLayerImageSrc = (mapLayer) => `maps/${mapLayer.image_name}`;
+export const getTacMapImageSrc = (mapLayer) => `tac-maps/${mapLayer.map.id}.webp`;
diff --git a/rcongui/src/components/MapManager/map-change/map-change.jsx b/rcongui/src/components/MapManager/map-change/map-change.jsx
new file mode 100644
index 000000000..09bb08ec1
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-change/map-change.jsx
@@ -0,0 +1,180 @@
+import {
+ Box,
+ Button,
+ createStyles,
+ IconButton,
+ List,
+ makeStyles,
+} from "@material-ui/core";
+import React from "react";
+import { changeMap, get, getServerStatus } from "../../../utils/fetchUtils";
+import MapSearch from "./map-search";
+import { MapListItem } from "../map-list-item";
+import CheckCircleOutlineIcon from "@material-ui/icons/CheckCircleOutline";
+import ReplayIcon from "@material-ui/icons/Replay";
+import LockIcon from "@material-ui/icons/Lock";
+import Skeleton from "@material-ui/lab/Skeleton";
+import { unifiedGamemodeName } from "../helpers";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ main: {
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(1),
+ },
+ panel: {
+ display: "flex",
+ flexDirection: "row",
+ gap: theme.spacing(1),
+ alignItems: "center",
+ },
+ maps: {
+ maxWidth: theme.breakpoints.values.sm,
+ position: "relative",
+ overflow: "auto",
+ minHeight: 500,
+ maxHeight: "70vh",
+ },
+ })
+);
+
+const UPDATE_INTERVAL = 60 * 1000;
+
+function MapChange() {
+ const [currentMap, setCurrentMap] = React.useState(null);
+ const [maps, setMaps] = React.useState([]);
+ const [nameFilter, setNameFilter] = React.useState("");
+ const [modeFilters, setModeFilters] = React.useState({
+ warfare: true,
+ offensive: false,
+ skirmish: false,
+ });
+ const [selected, setSelected] = React.useState("");
+ const statusIntervalRef = React.useRef(null);
+ const classes = useStyles();
+ const filteredMaps = maps.filter(
+ (map) =>
+ modeFilters[unifiedGamemodeName(map.game_mode)] &&
+ map.pretty_name.toLowerCase().includes(nameFilter.toLowerCase())
+ );
+
+ const updateServerStatus = async () => {
+ const status = await getServerStatus();
+ if (status) {
+ setCurrentMap(status.map);
+ }
+ };
+
+ const getMaps = async () => {
+ const response = await get("get_maps");
+ const data = await response.json();
+ const mapLayers = data.result;
+ if (mapLayers) {
+ setMaps(mapLayers);
+ }
+ };
+
+ const handleOnInputChange = (e) => {
+ setNameFilter(e.target.value);
+ };
+
+ const handleConfirmMap = (mapLayer) => {
+ changeMap(mapLayer.id);
+ setSelected("");
+ };
+
+ const handleResetMap = () => {
+ changeMap(currentMap.id);
+ };
+
+ const handleModeFilterClick = (filter) => {
+ setModeFilters((prevFilters) => ({
+ ...prevFilters,
+ [filter]: !prevFilters[filter],
+ }));
+ };
+
+ React.useEffect(() => {
+ updateServerStatus();
+ getMaps();
+ statusIntervalRef.current = setInterval(
+ updateServerStatus,
+ UPDATE_INTERVAL
+ );
+ return () => clearInterval(statusIntervalRef.current);
+ }, []);
+
+ return (
+
+
+ }
+ color="secondary"
+ onClick={handleResetMap}
+ variant="outlined"
+ size="small"
+ >
+ Map Reset
+
+ }
+ disabled
+ variant="outlined"
+ size="small"
+ >
+ Switch Allies
+
+ }
+ disabled
+ variant="outlined"
+ size="small"
+ >
+ Switch Axis
+
+
+ {currentMap ? (
+ >> ${currentMap.pretty_name} <<<`}
+ component={Box}
+ />
+ ) : (
+
+ )}
+
+
+ {filteredMaps.map((mapLayer, index) => (
+ {
+ setSelected(mapLayer.id);
+ }}
+ key={`${index}#${mapLayer.id}`}
+ mapLayer={mapLayer}
+ renderAction={(mapLayer) =>
+ selected === mapLayer.id && (
+ handleConfirmMap(mapLayer)}
+ >
+
+
+ )
+ }
+ />
+ ))}
+
+
+ );
+}
+
+export default MapChange;
diff --git a/rcongui/src/components/MapManager/map-change/map-search.jsx b/rcongui/src/components/MapManager/map-change/map-search.jsx
new file mode 100644
index 000000000..159a518ad
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-change/map-search.jsx
@@ -0,0 +1,97 @@
+import React from "react";
+import { makeStyles } from "@material-ui/core/styles";
+import Paper from "@material-ui/core/Paper";
+import InputBase from "@material-ui/core/InputBase";
+import Divider from "@material-ui/core/Divider";
+import IconButton from "@material-ui/core/IconButton";
+import SearchIcon from "@material-ui/icons/Search";
+import { Box, Chip } from "@material-ui/core";
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ padding: "2px 4px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ maxWidth: theme.breakpoints.values.sm,
+ [theme.breakpoints.up("sm")]: {
+ flexDirection: "row",
+ },
+ },
+ search: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "start",
+ width: "100%",
+ },
+ input: {
+ marginLeft: theme.spacing(1),
+ flex: 1,
+ },
+ iconButton: {
+ padding: 10,
+ },
+ divider: {
+ height: 28,
+ margin: 4,
+ },
+ smDivider: {
+ width: "100%",
+ [theme.breakpoints.up("sm")]: {
+ display: "none",
+ },
+ },
+ chips: {
+ display: "flex",
+ width: "100%",
+ gap: 4,
+ paddingTop: 8,
+ paddingBottom: 8,
+ justifyContent: "start",
+ alignItems: "start",
+ [theme.breakpoints.up("sm")]: {
+ flexDirection: "row",
+ width: "auto",
+ paddingBottom: 0,
+ paddingTop: 0,
+ },
+ },
+}));
+
+export default function MapSearch({ onChange, onSearch, onFilter, filters }) {
+ const classes = useStyles();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {Object.entries(filters).map(([filter, isApplied]) => (
+ onFilter(filter)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/rcongui/src/components/MapManager/map-details.jsx b/rcongui/src/components/MapManager/map-details.jsx
new file mode 100644
index 000000000..d4f7bc5b8
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-details.jsx
@@ -0,0 +1,46 @@
+import {
+ Avatar,
+ Box,
+ createStyles,
+ Divider,
+ makeStyles,
+ Typography,
+} from "@material-ui/core";
+import { getMapLayerImageSrc, unifiedGamemodeName } from "./helpers";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: theme.spacing(0.5),
+ textTransform: "capitalize",
+ },
+ })
+);
+
+export function MapDetails({ mapLayer }) {
+ const classes = useStyles();
+
+ const gameMode = unifiedGamemodeName(mapLayer.game_mode)
+
+ return (
+
+ {gameMode}
+ {gameMode === "offensive" && (
+ <>
+
+ {mapLayer.attackers}
+ >
+ )}
+
+ {mapLayer.environment}
+
+ );
+}
+
+export function MapAvatar({ mapLayer }) {
+ return (
+
+ )
+}
diff --git a/rcongui/src/components/MapManager/map-list-item.jsx b/rcongui/src/components/MapManager/map-list-item.jsx
new file mode 100644
index 000000000..092ddc17d
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-list-item.jsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+
+import makeStyles from "@material-ui/core/styles/makeStyles";
+import ListItem from "@material-ui/core/ListItem";
+import ListItemAvatar from "@material-ui/core/ListItemAvatar";
+import ListItemText from "@material-ui/core/ListItemText";
+import { ListItemSecondaryAction, createStyles } from "@material-ui/core";
+import { MapAvatar, MapDetails } from "./map-details";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ root: {
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ },
+ })
+);
+
+export function MapListItem({ mapLayer, primary, secondary, renderAction, ...props }) {
+ const classes = useStyles();
+
+ return (
+
+
+
+
+ }
+ />
+ {renderAction && (
+
+ {renderAction(mapLayer)}
+
+ )}
+
+ );
+}
diff --git a/rcongui/src/components/MapManager/map-list.jsx b/rcongui/src/components/MapManager/map-list.jsx
new file mode 100644
index 000000000..9bf1cbde4
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-list.jsx
@@ -0,0 +1,13 @@
+import * as React from "react";
+import { List } from "@material-ui/core";
+import { MapListItem } from "./map-list-item";
+
+export function MapList({ mapLayers, ...props }) {
+ return (
+
+ {mapLayers.map((mapLayer, index) => (
+
+ ))}
+
+ );
+}
diff --git a/rcongui/src/components/MapManager/map-manager.jsx b/rcongui/src/components/MapManager/map-manager.jsx
new file mode 100644
index 000000000..cc32fb8ef
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-manager.jsx
@@ -0,0 +1,91 @@
+import React from "react";
+import {
+ Grid,
+ Tab,
+ Tabs,
+ useTheme,
+ useMediaQuery,
+ Container,
+} from "@material-ui/core";
+import { Link, Switch, Route } from "react-router-dom";
+import VoteMapConfig from "./votemap/votemap";
+import MapRotationConfig from "./map-rotation/map-rotation";
+import MapChange from "./map-change/map-change";
+import MapObjectives from "./objectives/objectives";
+
+const tabs = {
+ change: 0,
+ rotation: 1,
+ objectives: 2,
+ votemap: 3,
+};
+
+export function MapManager({ match }) {
+ const theme = useTheme();
+ const isMdScreen = useMediaQuery(theme.breakpoints.up("md"));
+ let location = match.params.path;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function LinkTab(props) {
+ return ;
+}
+
+function TabPanel(props) {
+ const { children, path, index, ...other } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+function a11yProps(index) {
+ return {
+ id: `nav-tab-${index}`,
+ "aria-controls": `nav-tabpanel-${index}`,
+ };
+}
diff --git a/rcongui/src/components/MapManager/settings.js b/rcongui/src/components/MapManager/map-rotation/map-rotation-config.jsx
similarity index 93%
rename from rcongui/src/components/MapManager/settings.js
rename to rcongui/src/components/MapManager/map-rotation/map-rotation-config.jsx
index 4455f55ef..78faf6902 100644
--- a/rcongui/src/components/MapManager/settings.js
+++ b/rcongui/src/components/MapManager/map-rotation/map-rotation-config.jsx
@@ -1,9 +1,9 @@
import * as React from "react";
-import {get, handle_http_errors, postData, showResponse,} from "../../utils/fetchUtils";
+import {get, handle_http_errors, postData, showResponse,} from "../../../utils/fetchUtils";
import {Grid, Typography} from "@material-ui/core";
-import Padlock from "../SettingsView/padlock";
+import Padlock from "../../shared/padlock";
-const MapRotationSettings = ({classes}) => {
+const MapRotationSettings = () => {
const [shuffleEnabled, setShuffleEnabled] = React.useState(false);
const loadToState = (command, showSuccess, stateSetter) => {
diff --git a/rcongui/src/components/MapManager/index.js b/rcongui/src/components/MapManager/map-rotation/map-rotation-list.jsx
similarity index 61%
rename from rcongui/src/components/MapManager/index.js
rename to rcongui/src/components/MapManager/map-rotation/map-rotation-list.jsx
index 5f0f6e496..3189a09d1 100644
--- a/rcongui/src/components/MapManager/index.js
+++ b/rcongui/src/components/MapManager/map-rotation/map-rotation-list.jsx
@@ -1,21 +1,18 @@
import * as React from "react";
-import makeStyles from "@material-ui/core/styles/makeStyles";
-import Paper from "@material-ui/core/Paper";
-import DraggableList from "./DraggableList";
-import { getItems, reorder } from "./helpers";
+import DraggableList from "../DraggableList";
+import { reorder } from "../helpers";
import {
get,
handle_http_errors,
postData,
- sendAction,
showResponse,
-} from "../../utils/fetchUtils";
-import { Button, CircularProgress, Grid, Typography } from "@material-ui/core";
-import Chip from "@material-ui/core/Chip";
+} from "../../../utils/fetchUtils";
+import { Box, Button, CircularProgress, Grid } from "@material-ui/core";
import Autocomplete from "@material-ui/lab/Autocomplete";
import TextField from "@material-ui/core/TextField";
+import { Alert } from "@material-ui/lab";
-const MapRotation = ({ classes }) => {
+const MapRotation = () => {
const [maps, setMaps] = React.useState([]);
const [currentRotation, setCurrentRotation] = React.useState([]);
const [rotation, setRotation] = React.useState([]);
@@ -100,71 +97,62 @@ const MapRotation = ({ classes }) => {
);
return (
-
+
- Drag and drop to reorder
-
-
-
-
-
-
-
-
-
-
- m.pretty_name}
- isOptionEqualToValue={(option, value) => option.id == value.id}
- onChange={(e, v) => setMapsToAdd(v)}
- renderInput={(params) => (
-
- )}
- />
-
-
+ {voteMapConfig.enabled && You can't change the rotation while votemap is on}
+
+
+
-
-
+
+
+
+ m.pretty_name}
+ isOptionEqualToValue={(option, value) => option.id == value.id}
+ onChange={(e, v) => setMapsToAdd(v)}
+ renderInput={(params) => (
+
+ )}
+ />
-
+
);
diff --git a/rcongui/src/components/MapManager/map-rotation/map-rotation.jsx b/rcongui/src/components/MapManager/map-rotation/map-rotation.jsx
new file mode 100644
index 000000000..247a8bb90
--- /dev/null
+++ b/rcongui/src/components/MapManager/map-rotation/map-rotation.jsx
@@ -0,0 +1,30 @@
+import { createStyles, makeStyles, Typography } from "@material-ui/core";
+import MapRotationSettings from "./map-rotation-config";
+import MapRotation from "./map-rotation-list";
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ text: {
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ borderBottom: "1px solid",
+ borderColor: theme.palette.divider,
+ },
+ })
+);
+
+function MapRotationConfig() {
+ const classes = useStyles();
+
+ return (
+ <>
+
+
+ Other settings
+
+
+ >
+ );
+}
+
+export default MapRotationConfig;
diff --git a/rcongui/src/components/MapManager/objectives/objectives.jsx b/rcongui/src/components/MapManager/objectives/objectives.jsx
new file mode 100644
index 000000000..20cb4ffe2
--- /dev/null
+++ b/rcongui/src/components/MapManager/objectives/objectives.jsx
@@ -0,0 +1,327 @@
+import {
+ Box,
+ Button,
+ createStyles,
+ FormControl,
+ FormControlLabel,
+ FormLabel,
+ makeStyles,
+ Radio,
+ RadioGroup,
+ Typography,
+} from "@material-ui/core";
+import React from "react";
+import { changeGameLayout, getServerStatus } from "../../../utils/fetchUtils";
+import {
+ generateInitialState,
+ getTacMapImageSrc,
+ unifiedGamemodeName,
+} from "../helpers";
+import { Alert, AlertTitle, Skeleton } from "@material-ui/lab";
+import clsx from "clsx";
+
+const UPDATE_INTERVAL = 5 * 1000;
+
+const flip = (o) =>
+ o.map((row, x) => {
+ const arr = Array(row.lenght);
+ o.forEach((r, y) => {
+ arr[y] = r[x];
+ });
+ return arr;
+ });
+
+const reduceToInts = (arr) =>
+ arr.reduce((acc, row) => {
+ const i = row.indexOf(true);
+ return acc.concat(i === -1 ? null : i - 1);
+ }, []);
+
+const useStyles = makeStyles((theme) =>
+ createStyles({
+ main: {
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(1),
+ },
+ panel: {
+ display: "flex",
+ flexDirection: "row",
+ gap: theme.spacing(1),
+ alignItems: "center",
+ },
+ map: {
+ position: "absolute",
+ top: 0,
+ left: 0,
+ width: "100%",
+ height: "100%",
+ },
+ mapImg: {
+ width: "100%",
+ height: "100%",
+ touchAction: "none",
+ },
+ grid: {
+ position: "relative",
+ display: "grid",
+ gridTemplateColumns: "repeat(5, 1fr)",
+ gridTemplateRows: "repeat(5, 1fr)",
+ width: "100%",
+ height: "100%",
+ },
+ controlGrid: {
+ position: "relative",
+ display: "grid",
+ gridTemplateColumns: "repeat(5, 1fr)",
+ gridTemplateRows: "repeat(5, 1fr)",
+ gap: theme.spacing(0.5),
+ width: "100%",
+ height: "100%",
+ },
+ gridContainer: {
+ position: "relative",
+ maxWidth: 650,
+ minWidth: 280,
+ aspectRatio: "1 / 1",
+ },
+ controlContainer: {
+ width: "fit-content",
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(2),
+ },
+ controlBtn: {
+ border: "4px ridge black",
+ minWidth: 0,
+ padding: 0,
+ borderRadius: 0,
+ opacity: 0.35,
+ "&:hover": {
+ borderStyle: "inset",
+ },
+ },
+ controlBtnSelected: {
+ background: theme.palette.success.main,
+ borderStyle: "inset",
+ "&:hover": {
+ background: theme.palette.success.dark,
+ },
+ },
+ selected: {
+ opacity: 0.25,
+ background: theme.palette.success.main,
+ border: "2px solid",
+ borderColor: "2px solid white",
+ },
+ unavailable: {
+ backgroundImage:
+ "repeating-linear-gradient(45deg, #ff7700 0, #ff7700 2px, transparent 0, transparent 50%);",
+ backgroundSize: "10px 10px",
+ },
+ disabled: {
+ backgroundImage:
+ "repeating-linear-gradient(45deg, #000 0, #000 2px, transparent 0, transparent 50%);",
+ backgroundSize: "20px 20px",
+ },
+ })
+);
+
+function MapObjectives() {
+ const [currentMap, setCurrentMap] = React.useState(null);
+ const [randomConstraint, setRandomConstraint] = React.useState("0");
+ const [objectives, setObjectives] = React.useState(null);
+ const statusIntervalRef = React.useRef(null);
+ const classes = useStyles();
+
+ const updateServerStatus = async () => {
+ const status = await getServerStatus();
+ if (status) {
+ if (unifiedGamemodeName(status.map.game_mode) === "skirmish") {
+ setObjectives(null);
+ }
+ if (status.map.id !== currentMap?.id) {
+ setCurrentMap(status.map);
+ }
+ }
+ };
+
+ const isButtonUnavailable = (state) => {
+ return state === null;
+ };
+
+ const isButtonDisabled = (state, index) => {
+ const size = 5;
+ const targetIndex = index % size;
+ const targetRow = Math.floor(index / size);
+
+ // Is unavailable
+ if (isButtonUnavailable(state)) {
+ return true;
+ }
+
+ // Is selected
+ if (state === true) return false;
+
+ // Is unselected
+ if (currentMap.map.orientation === "vertical") {
+ // but there is another selected in the row
+ if (objectives[targetRow].includes(true)) return true;
+ } else {
+ // but there is another selected in the column
+ if (objectives.some((row) => row[targetIndex] === true)) return true;
+ }
+
+ return false;
+ };
+
+ const handleSelectClick = (index) => {
+ const targetIndex = index % 5;
+ const targetRow = Math.floor(index / 5);
+ setObjectives((prevObjectives) =>
+ prevObjectives.map((row, rowIndex) =>
+ row.map((item, itemIndex) =>
+ rowIndex === targetRow && itemIndex === targetIndex ? !item : item
+ )
+ )
+ );
+ };
+
+ const handleChangeLayoutClick = async () => {
+ const values = reduceToInts(
+ objectives[1][0] !== null ? flip(objectives) : objectives
+ );
+ await changeGameLayout({
+ objectives: values,
+ random_constraints: Number(randomConstraint),
+ });
+ };
+
+ const handleConstraintChange = (event) => {
+ setRandomConstraint(event.target.value);
+ };
+
+ React.useEffect(() => {
+ updateServerStatus();
+ statusIntervalRef.current = setInterval(
+ updateServerStatus,
+ UPDATE_INTERVAL
+ );
+ return () => clearInterval(statusIntervalRef.current);
+ }, [currentMap]);
+
+ React.useEffect(() => {
+ if (currentMap) {
+ setObjectives(generateInitialState(currentMap.map.orientation));
+ }
+ }, [currentMap]);
+
+ if (currentMap && unifiedGamemodeName(currentMap.game_mode) === "skirmish") {
+ return (
+
+
+ Error
+ {currentMap.pretty_name} - Skirmish mode cannot have the game layout changed!
+
+
+
+
+ );
+ }
+
+ if (!objectives) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {currentMap ? (
+
+
+
+
+
+ {objectives.flat().map((state, index) => {
+ return (
+
+ );
+ })}
+
+
+ ) : (
+
+ )}
+
+
+ When you omit any objective selection, the objectives will be chosen
+ by the following constraints.
+
+
+ Random contraints
+
+ }
+ label={"No constraints"}
+ />
+ }
+ label="Points must be adjacent"
+ />
+ }
+ label="No straight line"
+ />
+ }
+ label="Adjacent and No line combined"
+ />
+
+
+
+
+ );
+}
+
+export default MapObjectives;
diff --git a/rcongui/src/components/MapManager/votemap/configs-data.js b/rcongui/src/components/MapManager/votemap/configs-data.js
new file mode 100644
index 000000000..0e86cd5c7
--- /dev/null
+++ b/rcongui/src/components/MapManager/votemap/configs-data.js
@@ -0,0 +1,61 @@
+export const padlockConfigs = [
+ { name: "allow_opt_out", label: "Allow user to opt-out of vote map reminders by typing !votemap never" },
+ { name: "consider_offensive_same_map", label: "Consider offensive maps as being the same when excluding:" },
+ { name: "consider_skirmishes_as_same_map", label: "Consider skirmish maps as being the same when excluding:" },
+ { name: "allow_consecutive_offensives", label: "Allow consecutive offensive map" },
+ { name: "allow_consecutive_offensives_opposite_sides", label: "Allow consecutive offensive where a team would play defense twice in a row. E.g off_ger followed by off_us" },
+ { name: "allow_consecutive_skirmishes", label: "Allow consecutive skirmish map" },
+ { name: "allow_default_to_offensive", label: "Allow default map to be an offensive" },
+ { name: "allow_default_to_skirmish", label: "Allow default map to be a skirmish" },
+];
+
+export const messageFieldConfigs = [
+ {
+ name: "instruction_text",
+ label: "Reminder text sent to player to vote:",
+ helperText: "Make sure you add {map_selection} in your text",
+ rows: 10,
+ },
+ {
+ name: "thank_you_text",
+ label: "Thank you for voting message:",
+ helperText: "The reply to player after they voted. You can use {player_name} and {map_name} in the text. Leave blank if you don't want the confirmation message",
+ rows: 10,
+ },
+ {
+ name: "help_text",
+ label: "Help text:",
+ helperText: "This text will show to the player in case of a bad !votemap command, or if the user types !votemap help",
+ rows: 10,
+ },
+];
+
+export const textFieldConfigs = [
+ {
+ name: "reminder_frequency_minutes",
+ label: "Reminder frequency minutes:",
+ helperText: "Will remind players who haven't voted with a PM. Set to 0 to disable (will only show once on map end).",
+ inputProps: { min: 0, max: 90 },
+ },
+ {
+ name: "num_warfare_options",
+ label: "Warfare",
+ helperText: "Number of warfare maps to offer",
+ },
+ {
+ name: "num_offensive_options",
+ label: "Offensive",
+ helperText: "Number of offensive maps to offer",
+ },
+ {
+ name: "num_skirmish_control_options",
+ label: "Control Skirmish",
+ helperText: "Number of control skirmish maps to offer",
+ },
+ {
+ name: "number_last_played_to_exclude",
+ label: "Number of recently played maps excluded:",
+ helperText: "Exclude the last N played maps from the selection. The current map is always excluded.",
+ inputProps: { min: 0, max: 6, step: 1 },
+ },
+];
diff --git a/rcongui/src/components/MapManager/votemap/vote-status.jsx b/rcongui/src/components/MapManager/votemap/vote-status.jsx
new file mode 100644
index 000000000..c8a253add
--- /dev/null
+++ b/rcongui/src/components/MapManager/votemap/vote-status.jsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+import { List, Typography } from "@material-ui/core";
+import { MapListItem } from "../map-list-item";
+
+export function VoteStatus({ voteStatus, ...props }) {
+ return voteStatus.length ? (
+
+ {voteStatus.map((mapStatus, index) => {
+ const { map, voters } = mapStatus;
+ return (
+
+ );
+ })}
+
+ ) : (
+ No VoteMap History
+ );
+}
diff --git a/rcongui/src/components/MapManager/votemap/votemap.jsx b/rcongui/src/components/MapManager/votemap/votemap.jsx
new file mode 100644
index 000000000..788c77522
--- /dev/null
+++ b/rcongui/src/components/MapManager/votemap/votemap.jsx
@@ -0,0 +1,244 @@
+import {
+ Box,
+ Button,
+ FormControl,
+ InputLabel,
+ NativeSelect,
+ Paper,
+ TextField,
+ Typography,
+ Snackbar,
+ makeStyles,
+ createStyles,
+} from "@material-ui/core";
+import React from "react";
+import {
+ getVotemapConfig,
+ getVotemapStatus,
+ resetVotemapState,
+ updateVotemapConfig,
+} from "../../../utils/fetchUtils";
+import Padlock from "../../shared/padlock";
+import { VoteStatus } from "./vote-status";
+import { messageFieldConfigs, padlockConfigs, textFieldConfigs } from "./configs-data";
+import { isEmpty, isEqual } from "lodash";
+import { Alert } from "@material-ui/lab";
+
+const useStyles = makeStyles((theme) => createStyles({
+ spacing: {
+ marginTop: theme.spacing(1),
+ marginBottom: theme.spacing(1),
+ },
+ messages: {
+ maxWidth: theme.breakpoints.values.md,
+ },
+ container: {
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(2),
+ },
+ numberFields: {
+ display: "flex",
+ flexDirection: "column",
+ gap: theme.spacing(1),
+ maxWidth: theme.breakpoints.values.sm,
+ },
+}))
+
+const UPDATE_INTERVAL = 15 * 1000
+
+const VoteMapConfig = () => {
+ const [_config, setConfig] = React.useState({});
+ const [configChanges, setConfigChanges] = React.useState({})
+ const [incomingChanges, setIncomingChanges] = React.useState(null)
+ const [status, setStatus] = React.useState([]);
+ const statusIntervalRef = React.useRef(null)
+ const configIntervalRef = React.useRef(null)
+
+ const classes = useStyles();
+
+ const config = {
+ ..._config,
+ ...configChanges,
+ }
+
+ async function updateConfig() {
+ await updateVotemapConfig(config);
+ await getConfig();
+ setConfigChanges({});
+ setIncomingChanges(null);
+ }
+
+ async function getConfig() {
+ const config = await getVotemapConfig();
+ if (config) {
+ setConfig(config);
+ }
+ }
+
+ async function getStatus() {
+ const status = await getVotemapStatus()
+ if (status) {
+ setStatus(status)
+ }
+ }
+
+ async function resetState() {
+ const newStatus = await resetVotemapState();
+ if (newStatus) {
+ setStatus(newStatus)
+ }
+ }
+
+ const handleConfigChange = (propName) => (value) => {
+ // When Event object is being passed in as a value
+ if (typeof value === "object" && "target" in value) {
+ value = value.target.value;
+ }
+ setConfigChanges((prevConfig) => ({
+ ...prevConfig,
+ [propName]: value,
+ }));
+ }
+
+ const acceptIncomingConfigChanges = () => {
+ setConfig(incomingChanges);
+ setConfigChanges({});
+ setIncomingChanges(null);
+ }
+
+ React.useEffect(() => {
+ getConfig();
+ getStatus();
+ }, [])
+
+ React.useEffect(() => {
+ statusIntervalRef.current = setInterval(getStatus, UPDATE_INTERVAL);
+ return () => clearInterval(statusIntervalRef.current);
+ }, []);
+
+ React.useEffect(() => {
+ configIntervalRef.current = setInterval(async () => {
+ const freshConfig = await getVotemapConfig();
+ if (!isEqual(_config, freshConfig)) {
+ setIncomingChanges(freshConfig);
+ }
+ }, UPDATE_INTERVAL);
+ return () => clearInterval(configIntervalRef.current);
+ }, [_config]);
+
+ React.useEffect(() => {
+ if (incomingChanges !== null && isEmpty(configChanges)) {
+ acceptIncomingConfigChanges()
+ }
+ }, [incomingChanges, configChanges])
+
+ return (
+ <>
+
+
+ Accept changes
+
+ }>The config has changed!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ In-Game Texts
+
+ {messageFieldConfigs.map((configItem) => (
+
+ ))}
+
+
+ Other settings
+
+
+ Default map method (when no votes)
+
+
+
+
+
+
+
+
+ {padlockConfigs.map(({ name, label }) => (
+
+ ))}
+
+
+ {textFieldConfigs.map((configItem) => (
+
+ ))}
+
+
+ >
+ );
+};
+
+export default VoteMapConfig;
diff --git a/rcongui/src/components/RconSettings/rconSettings.js b/rcongui/src/components/RconSettings/rconSettings.js
index 64ff8b354..9eddd974f 100644
--- a/rcongui/src/components/RconSettings/rconSettings.js
+++ b/rcongui/src/components/RconSettings/rconSettings.js
@@ -19,7 +19,7 @@ import {
import Blacklist from "./blacklist";
import { toast } from "react-toastify";
import _ from "lodash";
-import Padlock from "../../components/SettingsView/padlock";
+import Padlock from "../shared/padlock";
import TextHistoryManager, { SelectNameSpace } from "./textHistoryManager";
import TextHistory from "../textHistory";
import ServicesList from "../Services";
diff --git a/rcongui/src/components/SettingsView/changeMap.js b/rcongui/src/components/SettingsView/changeMap.js
index 9d35ff723..69b80c575 100644
--- a/rcongui/src/components/SettingsView/changeMap.js
+++ b/rcongui/src/components/SettingsView/changeMap.js
@@ -1,7 +1,7 @@
import React from "react";
import { Grid, Button, Menu, MenuItem } from "@material-ui/core";
-const ChangeMap = ({ classes, availableMaps, changeMap }) => {
+const ChangeMap = ({ availableMaps, changeMap }) => {
const [anchorEl, setAnchorEl] = React.useState(null);
const handleClick = (event) => {
diff --git a/rcongui/src/components/SettingsView/hllSettings.js b/rcongui/src/components/SettingsView/hllSettings.js
index d3d5e78ba..383890c8e 100644
--- a/rcongui/src/components/SettingsView/hllSettings.js
+++ b/rcongui/src/components/SettingsView/hllSettings.js
@@ -2,10 +2,7 @@ import React from "react";
import {
Button,
Grid,
- Link,
TextField,
- Typography,
- Tooltip,
} from "@material-ui/core";
import { range } from "lodash/util";
import {
@@ -20,14 +17,9 @@ import AdminsEditableList from "./admins";
import CollapseCard from "../collapseCard";
import ServerMessage from "./serverMessage";
import NumSlider from "./numSlider";
-import ChangeMap from "./changeMap";
-import Padlock from "./padlock";
+import Padlock from "../shared/padlock";
import AutoRefreshLine from "../autoRefreshLine";
import { ForwardCheckBox, WordList } from "../commonComponent";
-import VoteMapConfig from "./voteMapConfig";
-import HelpIcon from "@material-ui/icons/Help";
-import MapRotation from "../MapManager";
-import MapRotationSettings from "../MapManager/settings";
const ProfanityFiler = ({
words,
@@ -343,20 +335,6 @@ class HLLSettings extends React.Component {
classes={classes}
/>
-
-
-
-
-
-
-
-
- Vote Map config{" "}
-
-
-
-
-
-
-
-
-
-
-
- Map rotation
-
-
- Map rotation settings
-
-
-
-
{
- const [config, setConfig] = React.useState(new Map());
- const [status, setStatus] = React.useState(new Map());
-
- const saveConfig = (kv) => {
- let mapAsObject = {
- ...Object.fromEntries(config.entries()),
- ...kv,
- };
- /* TODO: This is kind of dumb but I don't know a better way to do it */
- delete mapAsObject.size;
- delete mapAsObject._root;
- delete mapAsObject.__altered;
- return postData(
- `${process.env.REACT_APP_API_URL}set_votemap_config`,
- mapAsObject
- )
- .then((res) => showResponse(res, "set_votemap_config", true))
- .then(resetVotes)
- .then(loadData)
- .catch(handle_http_errors);
- };
-
- const loadData = () => {
- get("get_votemap_status")
- .then((res) => showResponse(res, "get_votemap_status", false))
- .then((data) => (data.failed ? "" : setStatus(fromJS(data.result))))
- .catch(handle_http_errors);
- get("get_votemap_config")
- .then((res) => showResponse(res, "get_votemap_config", false))
- .then((data) => (data.failed ? "" : setConfig(fromJS(data.result))))
- .catch(handle_http_errors);
- };
-
- const resetVotes = () =>
- postData(`${process.env.REACT_APP_API_URL}reset_votemap_state`)
- .then((res) => showResponse(res, "reset_votemap_state", true))
- .then((res) => (res.failed ? "" : setStatus(fromJS(res.result))))
- .catch(handle_http_errors);
-
- React.useEffect(() => {
- loadData();
- const handle = setInterval(loadData, 60000);
- return () => clearInterval(handle);
- }, []);
-
- return (
-
-
- saveConfig({ enabled: v })}
- />
-
-
-
-
- setConfig(config.set("instruction_text", e.target.value))
- }
- />
-
-
-
- setConfig(config.set("thank_you_text", e.target.value))
- }
- />
-
-
- setConfig(config.set("help_text", e.target.value))}
- />
-
-
-
-
-
-
-
- saveConfig({ reminder_frequency_minutes: e.target.value })
- }
- />
-
-
- saveConfig({ allow_opt_out: v })}
- />
-
-
-
-
-
- saveConfig({ num_warfare_options: e.target.value })}
- />
-
-
-
- saveConfig({ num_offensive_options: e.target.value })
- }
- />
-
-
-
- saveConfig({ num_skirmish_control_options: e.target.value })
- }
- />
-
-
- {" "}
-
- saveConfig({
- number_last_played_to_exclude: e.target.value,
- })
- }
- />
-
-
- saveConfig({ consider_offensive_same_map: v })}
- />
-
-
-
- saveConfig({ consider_skirmishes_as_same_map: v })
- }
- />
-
-
-
- saveConfig({ allow_consecutive_offensives: v })}
- />
-
-
-
-
- saveConfig({
- allow_consecutive_offensives_opposite_sides: v,
- })
- }
- />
-
-
-
- saveConfig({ allow_consecutive_skirmishes: v })}
- />
-
-
-
-
- Default map method (when no votes)
- saveConfig({ default_method: e.target.value })}
- >
-
-
-
-
-
-
-
-
- saveConfig({ allow_default_to_offensive: v })}
- />
-
-
- saveConfig({ allow_default_to_skirmish: v })}
- />
-
-
-
-
- Current vote status
-
-
- Votes:
-
- {status.map((o) => {
- return o.get("voters").map((vote) => {
- return `${vote}: ${o
- .get("map", new Map())
- .get("pretty_name")}\n`;
- });
- })}
-
-
-
- Map selection:
-
- {status
- .map((v) => `${v.get('map', new Map()).get("pretty_name")}\n`)}
-
-
-
- Results:
-
- {status
- .filter((o) => {
- // console.log(`o=${JSON.stringify(o)}`);
- return o.get("voters").size > 0;
- })
- .map(
- (o) =>
- `${o.get("map", new Map()).get("pretty_name")}: ${o.get("voters", new List()).size
- }\n`
- )}
-
-
-
-
-
-
-
-
- );
-};
-
-export default VoteMapConfig;
diff --git a/rcongui/src/components/SettingsView/padlock.js b/rcongui/src/components/shared/padlock.js
similarity index 100%
rename from rcongui/src/components/SettingsView/padlock.js
rename to rcongui/src/components/shared/padlock.js
diff --git a/rcongui/src/utils/fetchUtils.js b/rcongui/src/utils/fetchUtils.js
index f44e6d7fb..ac350cdb7 100644
--- a/rcongui/src/utils/fetchUtils.js
+++ b/rcongui/src/utils/fetchUtils.js
@@ -264,6 +264,78 @@ async function removePlayerVip(player) {
}
}
+async function resetVotemapState() {
+ try {
+ const response = await execute("reset_votemap_state");
+ const data = showResponse(response, "reset_votemap_state", true)
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
+async function updateVotemapConfig(config) {
+ try {
+ const response = await execute("set_votemap_config", config);
+ const data = showResponse(response, "set_votemap_config", true)
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
+async function getVotemapStatus() {
+ try {
+ const response = await get("get_votemap_status")
+ const data = await response.json()
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
+async function getVotemapConfig() {
+ try {
+ const response = await get("get_votemap_config")
+ const data = await response.json()
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
+async function changeMap(mapId) {
+ try {
+ const response = await execute("set_map", { map_name: mapId });
+ const data = showResponse(response, `Map changed to ${mapId}`, true)
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
+async function changeGameLayout(payload) {
+ try {
+ const response = await execute("set_game_layout", payload);
+ const data = showResponse(response, "set_game_layout", true)
+ if (data.result) {
+ return data.result;
+ }
+ } catch (error) {
+ handle_http_errors(error)
+ }
+}
+
export {
postData,
showResponse,
@@ -280,4 +352,10 @@ export {
addPlayerVip,
removePlayerVip,
getVips,
+ resetVotemapState,
+ getVotemapStatus,
+ getVotemapConfig,
+ updateVotemapConfig,
+ changeMap,
+ changeGameLayout,
};
\ No newline at end of file
diff --git a/rconweb/api/migrations/0003_create_default_groups.py b/rconweb/api/migrations/0003_create_default_groups.py
index 6921bc982..cf7e4d824 100644
--- a/rconweb/api/migrations/0003_create_default_groups.py
+++ b/rconweb/api/migrations/0003_create_default_groups.py
@@ -184,6 +184,14 @@
"can_change_chat_commands_config",
"can_view_log_stream_config",
"can_change_log_stream_config",
+ "can_view_blacklists",
+ "can_add_blacklist_records",
+ "can_change_blacklist_records",
+ "can_delete_blacklist_records",
+ "can_create_blacklists",
+ "can_change_blacklists",
+ "can_delete_blacklists",
+ "can_change_game_layout",
),
),
(
@@ -358,6 +366,14 @@
"can_change_chat_commands_config",
"can_view_log_stream_config",
"can_change_log_stream_config",
+ "can_view_blacklists",
+ "can_add_blacklist_records",
+ "can_change_blacklist_records",
+ "can_delete_blacklist_records",
+ "can_create_blacklists",
+ "can_change_blacklists",
+ "can_delete_blacklists",
+ "can_change_game_layout",
),
),
(
diff --git a/rconweb/api/migrations/0014_alter_rconuser_options.py b/rconweb/api/migrations/0014_alter_rconuser_options.py
new file mode 100644
index 000000000..43b68e773
--- /dev/null
+++ b/rconweb/api/migrations/0014_alter_rconuser_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.2.10 on 2024-08-27 19:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0013_delete_deprecated_permissions'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='rconuser',
+ options={'default_permissions': (), 'permissions': (('can_add_admin_roles', 'Can add HLL game server admin roles to players'), ('can_add_map_to_rotation', 'Can add a map to the rotation'), ('can_add_map_to_whitelist', 'Can add a map to the votemap whitelist'), ('can_add_maps_to_rotation', 'Can add maps to the rotation'), ('can_add_maps_to_whitelist', 'Can add multiple maps to the votemap whitelist'), ('can_add_player_comments', 'Can add comments to a players profile'), ('can_add_player_watch', 'Can add a watch to players'), ('can_add_vip', 'Can add VIP status to players'), ('can_ban_profanities', 'Can ban profanities (censored game chat)'), ('can_change_auto_broadcast_config', 'Can change the automated broadcast settings'), ('can_change_auto_settings', 'Can change auto settings'), ('can_change_autobalance_enabled', 'Can enable/disable autobalance'), ('can_change_autobalance_threshold', 'Can change the autobalance threshold'), ('can_change_broadcast_message', 'Can change the broadcast message'), ('can_change_camera_config', 'Can change camera notification settings'), ('can_change_current_map', 'Can change the current map'), ('can_change_discord_webhooks', 'Can change configured webhooks on the settings page'), ('can_change_idle_autokick_time', 'Can change the idle autokick time'), ('can_change_max_ping_autokick', 'Can change the max ping autokick'), ('can_change_profanities', 'Can add/remove profanities (censored game chat)'), ('can_change_queue_length', 'Can change the server queue size'), ('can_change_real_vip_config', 'Can change the real VIP settings'), ('can_change_server_name', 'Can change the server name'), ('can_change_shared_standard_messages', 'Can change the shared standard messages'), ('can_change_team_switch_cooldown', 'Can change the team switch cooldown'), ('can_change_vip_slots', 'Can change the number of reserved VIP slots'), ('can_change_votekick_autotoggle_config', 'Can change votekick settings'), ('can_change_votekick_enabled', 'Can enable/disable vote kicks'), ('can_change_votekick_threshold', 'Can change vote kick thresholds'), ('can_change_votemap_config', 'Can change the votemap settings'), ('can_change_welcome_message', 'Can change the welcome (rules) message'), ('can_clear_crcon_cache', 'Can clear the CRCON Redis cache'), ('can_download_vip_list', 'Can download the VIP list'), ('can_flag_player', 'Can add flags to players'), ('can_kick_players', 'Can kick players'), ('can_message_players', 'Can message players'), ('can_perma_ban_players', 'Can permanently ban players'), ('can_punish_players', 'Can punish players'), ('can_remove_admin_roles', 'Can remove HLL game server admin roles from players'), ('can_remove_all_vips', 'Can remove all VIPs'), ('can_remove_map_from_rotation', 'Can remove a map from the rotation'), ('can_remove_map_from_whitelist', 'Can remove a map from the votemap whitelist'), ('can_remove_maps_from_rotation', 'Can remove maps from the rotation'), ('can_remove_maps_from_whitelist', 'Can remove multiple maps from the votemap whitelist'), ('can_remove_perma_bans', 'Can remove permanent bans from players'), ('can_remove_player_watch', 'Can remove a watch from players'), ('can_remove_temp_bans', 'Can remove temporary bans from players'), ('can_remove_vip', 'Can remove VIP status from players'), ('can_reset_map_whitelist', 'Can reset the votemap whitelist'), ('can_reset_votekick_threshold', 'Can reset votekick thresholds'), ('can_reset_votemap_state', 'Can reset votemap selection & votes'), ('can_run_raw_commands', 'Can send raw commands to the HLL game server'), ('can_set_map_whitelist', 'Can set the votemap whitelist'), ('can_switch_players_immediately', 'Can immediately switch players'), ('can_switch_players_on_death', 'Can switch players on death'), ('can_temp_ban_players', 'Can temporarily ban players'), ('can_toggle_services', 'Can enable/disable services (automod, etc)'), ('can_unban_profanities', 'Can unban profanities (censored game chat)'), ('can_unflag_player', 'Can remove flags from players'), ('can_upload_vip_list', 'Can upload a VIP list'), ('can_view_admin_groups', 'Can view available admin roles'), ('can_view_admin_ids', 'Can view the name/steam IDs/role of everyone with a HLL games erver admin role'), ('can_view_admins', 'Can view users with HLL game server admin roles'), ('can_view_all_maps', 'Can view all possible maps'), ('can_view_audit_logs', 'Can view the can_view_audit_logs endpoint'), ('can_view_audit_logs_autocomplete', 'Can view the get_audit_logs_autocomplete endpoint'), ('can_view_auto_broadcast_config', 'Can view the automated broadcast settings'), ('can_view_auto_settings', 'Can view auto settings'), ('can_view_autobalance_enabled', 'Can view if autobalance is enabled'), ('can_view_autobalance_threshold', 'Can view the autobalance threshold'), ('can_view_available_services', 'Can view services (automod, etc)'), ('can_view_broadcast_message', 'Can view the current broadcast message'), ('can_view_camera_config', 'Can view camera notification settings'), ('can_view_connection_info', "Can view CRCON's connection info"), ('can_view_current_map', 'Can view the currently playing map'), ('can_view_date_scoreboard', 'Can view the date_scoreboard endpoint'), ('can_view_detailed_player_info', 'Can view detailed player info (name, steam ID, loadout, squad, etc.)'), ('can_view_discord_webhooks', 'Can view configured webhooks on the settings page'), ('can_view_game_logs', 'Can view the get_logs endpoint (returns unparsed game logs)'), ('can_view_gamestate', 'Can view the current gamestate'), ('can_view_get_players', 'Can view get_players endpoint (name, steam ID, VIP status and sessions) for all connected players'), ('can_view_get_status', 'Can view the get_status endpoint (server name, current map, player count)'), ('can_view_historical_logs', 'Can view historical logs'), ('can_view_idle_autokick_time', 'Can view the idle autokick time'), ('can_view_ingame_admins', 'Can view admins connected to the game server'), ('can_view_map_rotation', 'Can view the current map rotation'), ('can_view_map_whitelist', 'Can view the votemap whitelist'), ('can_view_max_ping_autokick', 'Can view the max autokick ping'), ('can_view_next_map', 'Can view the next map in the rotation'), ('can_view_online_admins', 'Can view admins connected to CRCON'), ('can_view_online_console_admins', 'Can view the player name of all connected players with a HLL game server admin role'), ('can_view_other_crcon_servers', 'Can view other servers hosted in the same CRCON (forward to all servers)'), ('can_view_perma_bans', 'Can view permanently banned players'), ('can_view_player_bans', 'Can view all bans (temp/permanent) for a specific player'), ('can_view_player_comments', 'Can view comments added to a players profile'), ('can_view_player_history', 'Can view History > Players'), ('can_view_player_info', 'Can view the get_player_info endpoint (Name, steam ID, country and steam bans)'), ('can_view_player_messages', 'Can view messages sent to players'), ('can_view_player_profile', 'View the detailed player profile page'), ('can_view_player_slots', 'Can view the current/max players on the server'), ('can_view_playerids', 'Can view the get_playerids endpoint (name and steam IDs of connected players)'), ('can_view_players', 'Can view get_players endpoint for all connected players '), ('can_view_profanities', 'Can view profanities (censored game chat)'), ('can_view_queue_length', 'Can view the maximum size of the server queue'), ('can_view_real_vip_config', 'Can view the real VIP settings'), ('can_view_recent_logs', 'Can view recent logs (Live view)'), ('can_view_round_time_remaining', 'Can view the amount of time left in the round'), ('can_view_server_name', 'Can view the server name'), ('can_view_shared_standard_messages', 'Can view the shared standard messages'), ('can_view_structured_logs', 'Can view the get_structured_logs endpoint'), ('can_view_team_objective_scores', 'Can view the number of objectives held by each team'), ('can_view_team_switch_cooldown', 'Can view the team switch cooldown value'), ('can_view_detailed_players', 'Can view get_detailed_players endpoint'), ('can_view_team_view', 'Can view get_team_view endpoint (detailed player info by team for all connected players)'), ('can_view_temp_bans', 'Can view temporary banned players'), ('can_view_vip_count', 'Can view the number of connected VIPs'), ('can_view_vip_ids', 'Can view all players with VIP and their expiration timestamps'), ('can_view_vip_slots', 'Can view the number of reserved VIP slots'), ('can_view_votekick_autotoggle_config', 'Can view votekick settings'), ('can_view_votekick_enabled', 'Can view if vote kick is enabled'), ('can_view_votekick_threshold', 'Can view the vote kick thresholds'), ('can_view_votemap_config', 'Can view the votemap settings'), ('can_view_votemap_status', 'Can view the current votemap status (votes, results, etc)'), ('can_view_current_map_sequence', 'Can view the current map shuffle sequence'), ('can_view_map_shuffle_enabled', 'Can view if map shuffle is enabled'), ('can_change_map_shuffle_enabled', 'Can enable/disable map shuffle'), ('can_view_welcome_message', 'Can view the server welcome message'), ('can_view_auto_mod_level_config', 'Can view Auto Mod Level enforcement config'), ('can_change_auto_mod_level_config', 'Can change Auto Mod Level enforcement config'), ('can_view_auto_mod_no_leader_config', 'Can view Auto Mod No Leader enforcement config'), ('can_change_auto_mod_no_leader_config', 'Can change Auto Mod No Leader enforcement config'), ('can_view_auto_mod_seeding_config', 'Can view Auto Mod No Seeding enforcement config'), ('can_change_auto_mod_seeding_config', 'Can change Auto Mod No Seeding enforcement config'), ('can_view_auto_mod_solo_tank_config', 'Can view Auto Mod No Solo Tank enforcement config'), ('can_change_auto_mod_solo_tank_config', 'Can change Auto Mod No Solo Tank enforcement config'), ('can_view_tk_ban_on_connect_config', 'Can view team kill ban on connect config'), ('can_change_tk_ban_on_connect_config', 'Can change team kill ban on connect config'), ('can_view_expired_vip_config', 'Can view Expired VIP config'), ('can_change_expired_vip_config', 'Can change Expired VIP config'), ('can_view_server_name_change_config', 'Can view server name change (GSP credentials!) config'), ('can_change_server_name_change_config', 'Can change server name change (GSP credentials!) config'), ('can_view_log_line_discord_webhook_config', 'Can view log webhook (messages for log events) config'), ('can_change_log_line_discord_webhook_config', 'Can change log webhook (messages for log events) config'), ('can_view_name_kick_config', 'Can view kick players for names config'), ('can_change_name_kick_config', 'Can change kick players for names config'), ('can_view_rcon_connection_settings_config', 'Can view game server connection settings config'), ('can_change_rcon_connection_settings_config', 'Can change game server connection settings config'), ('can_view_rcon_server_settings_config', 'Can view general CRCON server settings'), ('can_change_rcon_server_settings_config', 'Can change general CRCON server settings'), ('can_view_scorebot_config', 'Can view scorebot config'), ('can_change_scorebot_config', 'Can change scorebot config'), ('can_view_standard_broadcast_messages', 'Can view shared broadcast messages'), ('can_change_standard_broadcast_messages', 'Can change shared broadcast messages'), ('can_view_standard_punishment_messages', 'Can view shared punishment messages'), ('can_change_standard_punishment_messages', 'Can change shared punishment messages'), ('can_view_standard_welcome_messages', 'Can view shared welcome messages'), ('can_change_standard_welcome_messages', 'Can change shared welcome messages'), ('can_view_steam_config', 'Can view steam API config'), ('can_change_steam_config', 'Can change steam API config'), ('can_view_vac_game_bans_config', 'Can view VAC/Gameban ban on connect config'), ('can_change_vac_game_bans_config', 'Can change VAC/Gameban ban on connect config'), ('can_view_admin_pings_discord_webhooks_config', 'Can view Discord admin ping config'), ('can_change_admin_pings_discord_webhooks_config', 'Can change Discord admin ping config'), ('can_view_audit_discord_webhooks_config', 'Can view Discord audit config'), ('can_change_audit_discord_webhooks_config', 'Can change Discord audit config'), ('can_view_camera_discord_webhooks_config', 'Can view Discord admin cam notification config'), ('can_change_camera_discord_webhooks_config', 'Can change Discord admin cam notification config'), ('can_view_chat_discord_webhooks_config', 'Can view Discord chat notification config'), ('can_change_chat_discord_webhooks_config', 'Can change Discord chat notification config'), ('can_view_kills_discord_webhooks_config', 'Can view Discord team/teamkill notification config'), ('can_change_kills_discord_webhooks_config', 'Can change Discord team/teamkill notification config'), ('can_view_watchlist_discord_webhooks_config', 'Can view Discord player watchlist notification config'), ('can_change_watchlist_discord_webhooks_config', 'Can change Discord player watchlist notification config'), ('can_restart_webserver', 'Can restart the webserver (Not a complete Docker restart)'), ('can_view_chat_commands_config', 'Can view the chat commands config'), ('can_change_chat_commands_config', 'Can change the chat commands config'), ('can_view_log_stream_config', 'Can view the Log Stream config'), ('can_change_log_stream_config', 'Can change the Log Stream config'), ('can_view_blacklists', 'Can view available blacklists'), ('can_add_blacklist_records', 'Can add players to blacklists'), ('can_change_blacklist_records', 'Can unblacklist players and edit blacklist records'), ('can_delete_blacklist_records', 'Can delete blacklist records'), ('can_create_blacklists', 'Can create blacklists'), ('can_change_blacklists', 'Can change blacklists'), ('can_delete_blacklists', 'Can delete blacklists'), ('can_change_game_layout', 'Can change game layout'))},
+ ),
+ ]
diff --git a/rconweb/api/models.py b/rconweb/api/models.py
index 755230b68..197553409 100644
--- a/rconweb/api/models.py
+++ b/rconweb/api/models.py
@@ -437,4 +437,5 @@ class Meta:
("can_create_blacklists", "Can create blacklists"),
("can_change_blacklists", "Can change blacklists"),
("can_delete_blacklists", "Can delete blacklists"),
+ ("can_change_game_layout", "Can change game layout"),
)
diff --git a/rconweb/api/views.py b/rconweb/api/views.py
index e7a1ec5b1..bf8fdbef9 100644
--- a/rconweb/api/views.py
+++ b/rconweb/api/views.py
@@ -583,6 +583,9 @@ def run_raw_command(request):
"api.can_remove_temp_bans",
"api.can_remove_perma_bans",
},
+ rcon_api.get_objective_row: "api.can_view_current_map",
+ rcon_api.get_objective_rows: "api.can_view_current_map",
+ rcon_api.set_game_layout: "api.can_change_game_layout"
}
PREFIXES_TO_EXPOSE = [
@@ -635,6 +638,8 @@ def run_raw_command(request):
rcon_api.get_detailed_players: ["GET"],
rcon_api.get_expired_vip_config: ["GET"],
rcon_api.get_gamestate: ["GET"],
+ rcon_api.get_objective_row: ["GET"],
+ rcon_api.get_objective_rows: ["GET"],
rcon_api.get_historical_logs: ["GET", "POST"],
rcon_api.get_idle_autokick_time: ["GET"],
rcon_api.get_ingame_mods: ["GET"],
@@ -725,6 +730,7 @@ def run_raw_command(request):
rcon_api.set_chat_commands_config: ["POST"],
rcon_api.set_chat_discord_webhooks_config: ["POST"],
rcon_api.set_expired_vip_config: ["POST"],
+ rcon_api.set_game_layout: ["POST"],
rcon_api.set_idle_autokick_time: ["POST"],
rcon_api.set_kills_discord_webhooks_config: ["POST"],
rcon_api.set_log_line_webhook_config: ["POST"],