diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..62528a4
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+ko_fi: cs2surf
\ No newline at end of file
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..bd7d6ed
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,28 @@
+# This workflow will build a .NET project
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
+
+name: .NET
+
+on:
+ push:
+ branches: [ "main", "dev" ]
+ pull_request:
+ branches: [ "main", "dev" ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: 7.0.x
+ - name: Restore dependencies
+ run: dotnet restore
+ - name: Build
+ run: dotnet build --no-restore
+ - name: Test
+ run: dotnet test --no-build --verbosity normal
diff --git a/.gitignore b/.gitignore
index 59bfa40..3485604 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,8 @@
*.vs
+*.vscode/*
+!.vscode/tasks.json
+*.idea
src/bin/Debug/*
+src/bin/Release/*
src/obj/*
src/SurfTimer.csproj
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..68c000f
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,27 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-debug",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/SurfTimer.csproj",
+ "/property:Configuration=Debug"
+ ],
+ "problemMatcher": "$msCompile"
+ },
+ {
+ "label": "build-release",
+ "command": "dotnet",
+ "type": "process",
+ "args": [
+ "build",
+ "${workspaceFolder}/src/SurfTimer.csproj",
+ "/property:Configuration=Release"
+ ],
+ "problemMatcher": "$msCompile"
+ }
+ ]
+}
diff --git a/CS2SurfTimer.sln b/CS2SurfTimer.sln
new file mode 100644
index 0000000..ab56b21
--- /dev/null
+++ b/CS2SurfTimer.sln
@@ -0,0 +1,30 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.002.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A9962DB7-AE8A-4370-B381-19529A91B7EC}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SurfTimer", "src\SurfTimer.csproj", "{98841535-B479-49B7-8D35-03786D4C31B9}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {98841535-B479-49B7-8D35-03786D4C31B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {98841535-B479-49B7-8D35-03786D4C31B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {98841535-B479-49B7-8D35-03786D4C31B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {98841535-B479-49B7-8D35-03786D4C31B9}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {98841535-B479-49B7-8D35-03786D4C31B9} = {A9962DB7-AE8A-4370-B381-19529A91B7EC}
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {2AE70C13-97FA-4E60-93C0-94275277887E}
+ EndGlobalSection
+EndGlobal
diff --git a/LICENSE b/LICENSE
index f288702..0ad25db 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,5 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
+our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
+software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
- "This License" refers to version 3 of the GNU General Public License.
+ "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
- 13. Use with the GNU Affero General Public License.
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
+under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
+Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
+GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
+versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
+ GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License
+ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
+For more information on this, and how to apply and follow the GNU AGPL, see
.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/README.md b/README.md
index 92ed0b9..4663617 100644
--- a/README.md
+++ b/README.md
@@ -1,26 +1,67 @@
+# PLEASE DO NOT USE THIS, IT IS NOT COMPLETE AND IS AN ACTIVE WORK-IN-PROGRESS. ISSUES HAVE BEEN DISABLED FOR THIS REASON.
+## Please join the Discord: https://discord.cs.surf
+
# Timer
Core plugin for CS2 Surf Servers. This project is aimed to be fully open-source with the goal of uniting all of CS2 surf towards building the game mode.
# Goals
*Note: This is not definitive/complete and simply serves as a reference for what we should try to achieve. Subject to change.*
+Bold & Italics = being worked on.
-- [ ] Data storage
+- [ ] Database
- [ ] MySQL database schema ([W.I.P Design Diagram](https://dbdiagram.io/d/CS2Surf-Timer-DB-Schema-6560b76b3be1495787ace4d2))
- - [ ] Plugin auto-create tables for easier install?
-- [ ] Zoning
- - [ ] Hook zones from map triggers
- - [ ] Support for stages/checkpoints
- - [ ] Support for bonuses
- - [ ] Load zone information for official maps from CS2 Surf upstream? (Probably make this optional)
- - [ ] Support for custom zoning (Draw in-game similar to CSGO Surftimer?)
+ - [ ] Plugin auto-create tables for easier setup?
+ - [X] Base database class implementation
+- [ ] Maps
+ - [X] Implement map info object (DB)
+ - [ ] Zoning
+ - [X] Hook zones from map triggers
+ - [X] Map start/end zones
+ - [X] Stage zones
+ - [X] Checkpoint zones (this is each stage for a Staged map)
+ - [X] Bonus zones
+ - [X] Support for stages/checkpoints
+ - [X] Hook to their start/end zones
+ - [X] Save/Compare checkpoint times
+ - [ ] Save Stage times
+ - [X] Support for bonuses
+ - [X] Hook to their start/end zones
+ - [ ] Save Bonus times
+ - [X] Start/End trigger touch hooks
+ - [X] Load zone information automatically from standardised triggers: https://github.com/CS2Surf/Timer/wiki/CS2-Surf-Mapping
+ - [X] _**Support for stages (`/rs`, teleporting with `/s`)**_
+ - [ ] _**Support for bonuses (`/rs`, teleporting with `/b #`)**_
+ - [ ] _**Start/End touch hooks implemented for all zones**_
+- [ ] Surf configs
+ - [X] Server settings configuration
+ - [ ] Plugin configuration
+ - [X] Database configuration
- [ ] Timing
- - [ ] Implement timer HUD (similar to WST)
- - [ ] Save/load times from the database
- - [ ] Practice Mode
+ - [X] Base timer class implementation
+ - [X] Base timer HUD implementation
+ - [X] Prespeed measurement and display
+ - [ ] Save/load times
+ - [x] Map times
+ - [x] Checkpoint times
+ - [ ] Stage times
+ - [ ] Bonus times
+ - [X] Practice Mode implementation
- [ ] Announce records to Discord
+ - [ ] Stretch goal: sub-tick timing
- [ ] Player Data
- - [ ] Profiles
- - [ ] Points/Skill Groups
-- [ ] Replays
-- [ ] Angle style implementation
-- [ ] Paint
+ - [X] Base player class
+ - [ ] **_Player stat classes_**
+ - [ ] Profile implementation (DB)
+ - [ ] Points/Skill Groups (DB)
+ - [ ] Player settings (DB)
+- [x] Replays - Not tracking Stage/Bonus times but Replay functionality for them is there
+ - [x] Personal Best
+ - [x] Map Record
+ - [ ] Stage Record
+ - [ ] Bonus Record
+ - [x] World Record
+ - [X] Map Record
+ - [ ] Stage Record
+ - [ ] Bonus Record
+- [ ] Style implementation (SW, HSW, BW)
+- [ ] Paint (?)
diff --git a/cfg/SurfTimer/database.json b/cfg/SurfTimer/database.json
new file mode 100644
index 0000000..01d0f8d
--- /dev/null
+++ b/cfg/SurfTimer/database.json
@@ -0,0 +1,8 @@
+{
+ "host": "DATABASE_HOST_OR_IP",
+ "database": "DATABASE_NAME",
+ "user": "DATABASE_USERNAME",
+ "password": "DATABASE_PASSWORD",
+ "port": 3306,
+ "timeout": 0
+}
\ No newline at end of file
diff --git a/cfg/SurfTimer/server_settings.cfg b/cfg/SurfTimer/server_settings.cfg
new file mode 100644
index 0000000..b67b24f
--- /dev/null
+++ b/cfg/SurfTimer/server_settings.cfg
@@ -0,0 +1,80 @@
+// These are settings you would like to enforce on the server. The plugin
+// tries to enforce this on the server when the map starts.
+
+sv_cheats 1
+
+// Timelimit
+mp_timelimit "30"
+mp_roundtime "30"
+mp_roundtime_defuse "30"
+mp_roundtime_hostage "30"
+mp_roundtime_deployment "0"
+mp_freezetime 3
+
+// Comms Settings
+sv_alltalk 1
+sv_deadtalk 1
+sv_full_alltalk 1
+
+// Movement Settings
+sv_airaccelerate 150
+// sv_airaccelerate 2000
+sv_gravity 800
+sv_friction 5.2
+sv_maxspeed 350
+sv_accelerate 10
+sv_enablebunnyhopping 1
+sv_autobunnyhopping 1
+sv_staminajumpcost 0
+sv_staminalandcost 0
+sv_timebetweenducks 0
+
+// Some replay bot shit (took so fucking long to debug)
+// bot_quota 1 No need for this, because the server handles it
+bot_quota_mode "normal"
+bot_join_after_player 1
+bot_join_team CT
+bot_zombie 1
+bot_stop 1
+bot_freeze 1
+
+// Player Settings
+mp_spectators_max 64
+mp_humanteam ct
+mp_disconnect_kills_players 1
+mp_solid_teammates 0
+mp_respawn_on_death_ct 1
+mp_respawn_on_death_t 1
+mp_ct_default_secondary weapon_usp_silencer
+mp_t_default_secondary weapon_usp_silencer
+mp_limitteams 0
+mp_autoteambalance 0
+mp_playercashawards 0
+mp_teamcashawards 0
+mp_death_drop_c4 1
+mp_death_drop_defuser 1
+mp_death_drop_grenade 1
+mp_death_drop_gun 1
+mp_drop_knife_enable 1
+mp_weapons_allow_map_placed 1
+sv_falldamage_scale 0
+mp_damage_scale_t_body 0
+mp_damage_scale_t_head 0
+mp_damage_scale_ct_body 0
+mp_damage_scale_ct_head 0
+
+// Server Settings
+sv_allow_votes 0
+mp_suicide_penalty 0
+mp_friendlyfire 0
+mp_ignore_round_win_conditions 0
+mp_round_restart_delay 0
+mp_warmuptime_all_players_connected 0
+mp_freezetime 0
+mp_team_intro_time 0
+mp_warmup_end
+mp_warmuptime 0
+sv_holiday_mode 0
+sv_party_mode 0
+
+sv_cheats 0
\ No newline at end of file
diff --git a/cfg/SurfTimer/timer_settings.json b/cfg/SurfTimer/timer_settings.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/cfg/SurfTimer/timer_settings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/data/GeoIP/GeoLite2-Country.mmdb b/data/GeoIP/GeoLite2-Country.mmdb
new file mode 100644
index 0000000..b170f4b
Binary files /dev/null and b/data/GeoIP/GeoLite2-Country.mmdb differ
diff --git a/lang/en.json b/lang/en.json
new file mode 100644
index 0000000..544b7b4
--- /dev/null
+++ b/lang/en.json
@@ -0,0 +1,3 @@
+{
+
+}
\ No newline at end of file
diff --git a/src/ST-Commands/MapCommands.cs b/src/ST-Commands/MapCommands.cs
new file mode 100644
index 0000000..4bebc09
--- /dev/null
+++ b/src/ST-Commands/MapCommands.cs
@@ -0,0 +1,75 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Core.Attributes.Registration;
+using CounterStrikeSharp.API.Modules.Commands;
+using CounterStrikeSharp.API.Modules.Utils;
+using CounterStrikeSharp.API.Modules.Admin;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ // All map-related commands here
+ [ConsoleCommand("css_tier", "Display the current map tier.")]
+ [ConsoleCommand("css_mapinfo", "Display the current map tier.")]
+ [ConsoleCommand("css_mi", "Display the current map tier.")]
+ [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
+ public void MapTier(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null)
+ return;
+
+ if (CurrentMap.Stages > 1)
+ player.PrintToChat($"{PluginPrefix} {CurrentMap.Name} - {ChatColors.Green}Tier {CurrentMap.Tier}{ChatColors.Default} - Staged {ChatColors.Yellow}{CurrentMap.Stages} Stages{ChatColors.Default}");
+ else
+ player.PrintToChat($"{PluginPrefix} {CurrentMap.Name} - {ChatColors.Green}Tier {CurrentMap.Tier}{ChatColors.Default} - Linear {ChatColors.Yellow}{CurrentMap.Checkpoints} Checkpoints{ChatColors.Default}");
+ return;
+ }
+
+ [ConsoleCommand("css_triggers", "List all valid zone triggers in the map.")]
+ [RequiresPermissions("@css/root")]
+ [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
+ public void Triggers(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null)
+ return;
+
+ IEnumerable triggers = Utilities.FindAllEntitiesByDesignerName("trigger_multiple");
+ player.PrintToChat($"Count of triggers: {triggers.Count()}");
+ foreach (CBaseTrigger trigger in triggers)
+ {
+ if (trigger.Entity!.Name != null)
+ {
+ player.PrintToChat($"Trigger -> Origin: {trigger.AbsOrigin}, Radius: {trigger.Collision.BoundingRadius}, Name: {trigger.Entity!.Name}");
+ }
+ }
+
+ player.PrintToChat($"Hooked Trigger -> Start -> {CurrentMap.StartZone} -> Angles {CurrentMap.StartZoneAngles}");
+ player.PrintToChat($"Hooked Trigger -> End -> {CurrentMap.EndZone}");
+ int i = 1;
+ foreach (Vector stage in CurrentMap.StageStartZone)
+ {
+ if (stage.X == 0 && stage.Y == 0 && stage.Z == 0)
+ continue;
+ else
+ {
+ player.PrintToChat($"Hooked Trigger -> Stage {i} -> {stage} -> Angles {CurrentMap.StageStartZoneAngles[i]}");
+ i++;
+ }
+ }
+
+ i = 1;
+ foreach (Vector bonus in CurrentMap.BonusStartZone)
+ {
+ if (bonus.X == 0 && bonus.Y == 0 && bonus.Z == 0)
+ continue;
+ else
+ {
+ player.PrintToChat($"Hooked Trigger -> Bonus {i} -> {bonus} -> Angles {CurrentMap.BonusStartZoneAngles[i]}");
+ i++;
+ }
+ }
+
+ return;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Commands/PlayerCommands.cs b/src/ST-Commands/PlayerCommands.cs
new file mode 100644
index 0000000..3e51527
--- /dev/null
+++ b/src/ST-Commands/PlayerCommands.cs
@@ -0,0 +1,302 @@
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Core.Attributes.Registration;
+using CounterStrikeSharp.API.Modules.Commands;
+using CounterStrikeSharp.API.Modules.Admin;
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ [ConsoleCommand("css_r", "Reset back to the start of the map.")]
+ [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
+ public void PlayerReset(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null)
+ return;
+
+ // To-do: players[userid].Timer.Reset() -> teleport player
+ playerList[player.UserId ?? 0].Timer.Reset();
+ if (CurrentMap.StartZone != new Vector(0, 0, 0))
+ Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0, 0, 0), new Vector(0, 0, 0)));
+ return;
+ }
+
+ [ConsoleCommand("css_rs", "Reset back to the start of the stage or bonus you're in.")]
+ [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
+ public void PlayerResetStage(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null)
+ return;
+
+ // To-do: players[userid].Timer.Reset() -> teleport player
+ Player SurfPlayer = playerList[player.UserId ?? 0];
+ if (SurfPlayer.Timer.Stage != 0 && CurrentMap.StageStartZone[SurfPlayer.Timer.Stage] != new Vector(0, 0, 0))
+ Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StageStartZone[SurfPlayer.Timer.Stage], CurrentMap.StageStartZoneAngles[SurfPlayer.Timer.Stage], new Vector(0, 0, 0)));
+ else // Reset back to map start
+ Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0, 0, 0), new Vector(0, 0, 0)));
+ return;
+ }
+
+ [ConsoleCommand("css_s", "Teleport to a stage")]
+ [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)]
+ public void PlayerGoToStage(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null)
+ return;
+
+ int stage = Int32.Parse(command.ArgByIndex(1)) - 1;
+ if (stage > CurrentMap.Stages - 1 && CurrentMap.Stages > 0)
+ stage = CurrentMap.Stages - 1;
+
+ // Must be 1 argument
+ if (command.ArgCount < 2 || stage < 0)
+ {
+ #if DEBUG
+ player.PrintToChat($"CS2 Surf DEBUG >> css_s >> Arg#: {command.ArgCount} >> Args: {Int32.Parse(command.ArgByIndex(1))}");
+ #endif
+
+ player.PrintToChat($"{PluginPrefix} {ChatColors.Red}Invalid arguments. Usage: {ChatColors.Green}!s ");
+ return;
+ }
+ else if (CurrentMap.Stages <= 0)
+ {
+ player.PrintToChat($"{PluginPrefix} {ChatColors.Red}This map has no stages.");
+ return;
+ }
+
+ if (CurrentMap.StageStartZone[stage] != new Vector(0, 0, 0))
+ {
+ if (stage == 0)
+ Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, CurrentMap.StartZoneAngles, new Vector(0, 0, 0)));
+ else
+ Server.NextFrame(() => player.PlayerPawn.Value!.Teleport(CurrentMap.StageStartZone[stage], CurrentMap.StageStartZoneAngles[stage], new Vector(0, 0, 0)));
+
+ playerList[player.UserId ?? 0].Timer.Reset();
+ playerList[player.UserId ?? 0].Timer.IsStageMode = true;
+
+ // To-do: If you run this while you're in the start zone, endtouch for the start zone runs after you've teleported
+ // causing the timer to start. This needs to be fixed.
+ }
+
+ else
+ player.PrintToChat($"{PluginPrefix} {ChatColors.Red}Invalid stage provided. Usage: {ChatColors.Green}!s ");
+ }
+
+ [ConsoleCommand("css_spec", "Moves a player automaticlly into spectator mode")]
+ public void MovePlayerToSpectator(CCSPlayerController? player, CommandInfo command)
+ {
+ if (player == null || player.Team == CsTeam.Spectator)
+ return;
+
+ player.ChangeTeam(CsTeam.Spectator);
+ }
+
+ /*
+ #########################
+ Reaplay Commands
+ #########################
+ */
+ [ConsoleCommand("css_replaybotpause", "Pause the replay bot playback")]
+ [ConsoleCommand("css_rbpause", "Pause the replay bot playback")]
+ public void PauseReplay(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || player.Team != CsTeam.Spectator)
+ return;
+
+ foreach(ReplayPlayer rb in CurrentMap.ReplayBots)
+ {
+ if(!rb.IsPlayable || !rb.IsPlaying || !playerList[player.UserId ?? 0].IsSpectating(rb.Controller!))
+ continue;
+
+ rb.Pause();
+ }
+ }
+
+ [ConsoleCommand("css_replaybotflip", "Flips the replay bot between Forward/Backward playback")]
+ [ConsoleCommand("css_rbflip", "Flips the replay bot between Forward/Backward playback")]
+ public void ReverseReplay(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || player.Team != CsTeam.Spectator)
+ return;
+
+ foreach(ReplayPlayer rb in CurrentMap.ReplayBots)
+ {
+ if(!rb.IsPlayable || !rb.IsPlaying || !playerList[player.UserId ?? 0].IsSpectating(rb.Controller!))
+ continue;
+
+ rb.FrameTickIncrement *= -1;
+ }
+ }
+
+ [ConsoleCommand("css_pbreplay", "Allows for replay of player's PB")]
+ public void PbReplay(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null)
+ return;
+
+ int maptime_id = playerList[player!.UserId ?? 0].Stats.PB[playerList[player.UserId ?? 0].Timer.Style].ID;
+ if (command.ArgCount > 1)
+ {
+ try
+ {
+ maptime_id = int.Parse(command.ArgByIndex(1));
+ }
+ catch {}
+ }
+
+ if(maptime_id == -1 || !CurrentMap.ConnectedMapTimes.Contains(maptime_id))
+ {
+ player.PrintToChat($"{PluginPrefix} {ChatColors.Red}No time was found");
+ return;
+ }
+
+ for(int i = 0; i < CurrentMap.ReplayBots.Count; i++)
+ {
+ if(CurrentMap.ReplayBots[i].Stat_MapTimeID == maptime_id)
+ {
+ player.PrintToChat($"{PluginPrefix} {ChatColors.Red}A bot of this run already playing");
+ return;
+ }
+ }
+
+ CurrentMap.ReplayBots = CurrentMap.ReplayBots.Prepend(new ReplayPlayer() {
+ Stat_MapTimeID = maptime_id,
+ Stat_Prefix = "PB"
+ }).ToList();
+
+ Server.NextFrame(() => {
+ Server.ExecuteCommand($"bot_quota {CurrentMap.ReplayBots.Count}");
+ });
+ }
+
+ /*
+ ########################
+ Saveloc Commands
+ ########################
+ */
+ [ConsoleCommand("css_saveloc", "Save current player location to be practiced")]
+ public void SavePlayerLocation(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0))
+ return;
+
+ Player p = playerList[player.UserId ?? 0];
+ if (!p.Timer.IsRunning)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Cannot save location while not in run");
+ return;
+ }
+
+ var player_pos = p.Controller.Pawn.Value!.AbsOrigin!;
+ var player_angle = p.Controller.PlayerPawn.Value!.EyeAngles;
+ var player_velocity = p.Controller.PlayerPawn.Value!.AbsVelocity;
+
+ p.SavedLocations.Add(new SavelocFrame {
+ Pos = new Vector(player_pos.X, player_pos.Y, player_pos.Z),
+ Ang = new QAngle(player_angle.X, player_angle.Y, player_angle.Z),
+ Vel = new Vector(player_velocity.X, player_velocity.Y, player_velocity.Z),
+ Tick = p.Timer.Ticks
+ });
+ p.CurrentSavedLocation = p.SavedLocations.Count-1;
+
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Green}Saved location! {ChatColors.Default} use !tele {p.SavedLocations.Count-1} to teleport to this location");
+ }
+
+ [ConsoleCommand("css_tele", "Teleport player to current saved location")]
+ public void TeleportPlayerLocation(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0))
+ return;
+
+ Player p = playerList[player.UserId ?? 0];
+
+ if(p.SavedLocations.Count == 0)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations");
+ return;
+ }
+
+ if(!p.Timer.IsRunning)
+ p.Timer.Start();
+
+ if (!p.Timer.IsPracticeMode)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Timer now on practice");
+ p.Timer.IsPracticeMode = true;
+ }
+
+ if(command.ArgCount > 1)
+ try
+ {
+ int tele_n = int.Parse(command.ArgByIndex(1));
+ if (tele_n < p.SavedLocations.Count)
+ p.CurrentSavedLocation = tele_n;
+ }
+ catch { }
+ SavelocFrame location = p.SavedLocations[p.CurrentSavedLocation];
+ Server.NextFrame(() => {
+ p.Controller.PlayerPawn.Value!.Teleport(location.Pos, location.Ang, location.Vel);
+ p.Timer.Ticks = location.Tick;
+ });
+
+ p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}");
+ }
+
+ [ConsoleCommand("css_teleprev", "Teleport player to previous saved location")]
+ public void TeleportPlayerLocationPrev(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0))
+ return;
+
+ Player p = playerList[player.UserId ?? 0];
+
+ if(p.SavedLocations.Count == 0)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations");
+ return;
+ }
+
+ if(p.CurrentSavedLocation == 0)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Already at first location");
+ }
+ else
+ {
+ p.CurrentSavedLocation--;
+ }
+
+ TeleportPlayerLocation(player, command);
+
+ p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}");
+ }
+
+ [ConsoleCommand("css_telenext", "Teleport player to next saved location")]
+ public void TeleportPlayerLocationNext(CCSPlayerController? player, CommandInfo command)
+ {
+ if(player == null || !player.PawnIsAlive || !playerList.ContainsKey(player.UserId ?? 0))
+ return;
+
+ Player p = playerList[player.UserId ?? 0];
+
+ if(p.SavedLocations.Count == 0)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}No saved locations");
+ return;
+ }
+
+ if(p.CurrentSavedLocation == p.SavedLocations.Count-1)
+ {
+ p.Controller.PrintToChat($"{PluginPrefix} {ChatColors.Red}Already at last location");
+ }
+ else
+ {
+ p.CurrentSavedLocation++;
+ }
+
+ TeleportPlayerLocation(player, command);
+
+ p.Controller.PrintToChat($"{PluginPrefix} Teleported #{p.CurrentSavedLocation}");
+ }
+}
\ No newline at end of file
diff --git a/src/ST-DB/DB.cs b/src/ST-DB/DB.cs
new file mode 100644
index 0000000..78ce507
--- /dev/null
+++ b/src/ST-DB/DB.cs
@@ -0,0 +1,79 @@
+namespace SurfTimer;
+
+using System.Runtime.CompilerServices;
+using CounterStrikeSharp.API;
+using MySqlConnector; // https://dev.mysql.com/doc/connector-net/en/connector-net-connections-string.html
+
+// This will have functions for DB access and query sending
+internal class TimerDatabase
+{
+ private readonly MySqlConnection? _db;
+ private readonly string _connString = string.Empty;
+
+ public TimerDatabase()
+ {
+ // Null'd
+ }
+
+
+ public TimerDatabase(string host, string database, string user, string password, int port, int timeout)
+ {
+ this._connString = $"server={host};user={user};password={password};database={database};port={port};connect timeout={timeout};";
+ this._db = new MySqlConnection(this._connString);
+ this._db.Open();
+ }
+
+ public void Close()
+ {
+ if (this._db != null)
+ this._db!.Close();
+ }
+
+ public async Task Query(string query)
+ {
+ return await Task.Run(async () =>
+ {
+ try
+ {
+ if (this._db == null)
+ {
+ throw new InvalidOperationException("Database connection is not open.");
+ }
+
+ MySqlCommand cmd = new(query, this._db);
+ MySqlDataReader reader = await cmd.ExecuteReaderAsync();
+
+ return reader;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error executing query: {ex.Message}");
+ throw;
+ }
+ });
+ }
+
+ public async Task Write(string query)
+ {
+ return await Task.Run(async () =>
+ {
+ try
+ {
+ if (this._db == null)
+ {
+ throw new InvalidOperationException("Database connection is not open.");
+ }
+
+ MySqlCommand cmd = new(query, this._db);
+ int rowsAffected = await cmd.ExecuteNonQueryAsync();
+
+ return rowsAffected;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error executing write operation: {ex.Message}");
+ throw;
+ }
+ });
+ }
+}
diff --git a/src/ST-Events/Players.cs b/src/ST-Events/Players.cs
new file mode 100644
index 0000000..a2162a2
--- /dev/null
+++ b/src/ST-Events/Players.cs
@@ -0,0 +1,207 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Core.Attributes.Registration;
+using CounterStrikeSharp.API.Modules.Utils;
+using MySqlConnector;
+using MaxMind.GeoIP2;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ [GameEventHandler]
+ public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
+ {
+ var controller = @event.Userid;
+ if(!controller.IsValid || !controller.IsBot)
+ return HookResult.Continue;
+
+ for (int i = 0; i < CurrentMap.ReplayBots.Count; i++)
+ {
+ if(CurrentMap.ReplayBots[i].IsPlayable)
+ continue;
+
+ int repeats = -1;
+ if(CurrentMap.ReplayBots[i].Stat_Prefix == "PB")
+ repeats = 3;
+
+ CurrentMap.ReplayBots[i].SetController(controller, repeats);
+ Server.PrintToChatAll($"{ChatColors.Lime} Loading replay data...");
+ AddTimer(2f, () => {
+ if(!CurrentMap.ReplayBots[i].IsPlayable)
+ return;
+
+ CurrentMap.ReplayBots[i].Controller!.RemoveWeapons();
+
+ CurrentMap.ReplayBots[i].LoadReplayData(DB!);
+
+ CurrentMap.ReplayBots[i].Start();
+ });
+
+ return HookResult.Continue;
+ }
+
+ return HookResult.Continue;
+ }
+
+ [GameEventHandler]
+ public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
+ {
+ var player = @event.Userid;
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> {player.PlayerName} / {player.UserId} / {player.SteamID}");
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> {player.PlayerName} / {player.UserId} / Bot Diff: {player.PawnBotDifficulty}");
+ #endif
+
+ if (player.IsBot || !player.IsValid) // IsBot might be broken so we can check for PawnBotDifficulty which is `-1` for real players
+ {
+ return HookResult.Continue;
+ }
+ else
+ {
+ int dbID, joinDate, lastSeen, connections;
+ string name, country;
+
+ // GeoIP
+ DatabaseReader geoipDB = new DatabaseReader(PluginPath + "data/GeoIP/GeoLite2-Country.mmdb");
+ if (geoipDB.Country(player.IpAddress!.Split(":")[0]).Country.IsoCode is not null)
+ {
+ country = geoipDB.Country(player.IpAddress!.Split(":")[0]).Country.IsoCode!;
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> GeoIP -> {player.PlayerName} -> {player.IpAddress!.Split(":")[0]} -> {country}");
+ #endif
+ }
+ else
+ country = "XX";
+ geoipDB.Dispose();
+
+ if (DB == null)
+ throw new Exception("CS2 Surf ERROR >> OnPlayerConnect -> DB object is null, this shouldnt happen.");
+
+ // Load player profile data from database (or create an entry if first time connecting)
+ Task dbTask = DB.Query($"SELECT * FROM `Player` WHERE `steam_id` = {player.SteamID} LIMIT 1;");
+ MySqlDataReader playerData = dbTask.Result;
+ if (playerData.HasRows && playerData.Read())
+ {
+ // Player exists in database
+ dbID = playerData.GetInt32("id");
+ name = playerData.GetString("name");
+ if (country == "XX" && playerData.GetString("country") != "XX")
+ country = playerData.GetString("country");
+ joinDate = playerData.GetInt32("join_date");
+ lastSeen = playerData.GetInt32("last_seen");
+ connections = playerData.GetInt32("connections");
+ playerData.Close();
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> Returning player {name} ({player.SteamID}) loaded from database with ID {dbID}");
+ #endif
+ }
+ else
+ {
+ playerData.Close();
+ // Player does not exist in database
+ name = player.PlayerName;
+ joinDate = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ lastSeen = joinDate;
+ connections = 1;
+
+ // Write new player to database
+ Task newPlayerTask = DB.Write($@"
+ INSERT INTO `Player` (`name`, `steam_id`, `country`, `join_date`, `last_seen`, `connections`)
+ VALUES ('{MySqlHelper.EscapeString(name)}', {player.SteamID}, '{country}', {joinDate}, {lastSeen}, {connections});
+ ");
+ int newPlayerTaskRows = newPlayerTask.Result;
+ if (newPlayerTaskRows != 1)
+ throw new Exception($"CS2 Surf ERROR >> OnPlayerConnect -> Failed to write new player to database, this shouldnt happen. Player: {name} ({player.SteamID})");
+ newPlayerTask.Dispose();
+
+ // Get new player's database ID
+ Task newPlayerDataTask = DB.Query($"SELECT `id` FROM `Player` WHERE `steam_id` = {player.SteamID} LIMIT 1;");
+ MySqlDataReader newPlayerData = newPlayerDataTask.Result;
+ if (newPlayerData.HasRows && newPlayerData.Read())
+ {
+ #if DEBUG
+ // Iterate through data:
+ for (int i = 0; i < newPlayerData.FieldCount; i++)
+ {
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> newPlayerData[{i}] = {newPlayerData.GetValue(i)}");
+ }
+ #endif
+ dbID = newPlayerData.GetInt32("id");
+ }
+ else
+ throw new Exception($"CS2 Surf ERROR >> OnPlayerConnect -> Failed to get new player's database ID after writing, this shouldnt happen. Player: {name} ({player.SteamID})");
+ newPlayerData.Close();
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> OnPlayerConnect -> New player {name} ({player.SteamID}) added to database with ID {dbID}");
+ #endif
+ }
+ dbTask.Dispose();
+
+ // Create Player object and add to playerList
+ PlayerProfile Profile = new PlayerProfile(dbID, name, player.SteamID, country, joinDate, lastSeen, connections);
+ playerList[player.UserId ?? 0] = new Player(player,
+ new CCSPlayer_MovementServices(player.PlayerPawn.Value!.MovementServices!.Handle),
+ Profile, CurrentMap);
+
+ #if DEBUG
+ Console.WriteLine($"=================================== SELECT * FROM `MapTimes` WHERE `player_id` = {playerList[player.UserId ?? 0].Profile.ID} AND `map_id` = {CurrentMap.ID};");
+ #endif
+
+ // To-do: hardcoded Style value
+ // Load MapTimes for the player's PB and their Checkpoints
+ playerList[player.UserId ?? 0].Stats.LoadMapTimesData(playerList[player.UserId ?? 0], DB); // Will reload PB and Checkpoints for the player for all styles
+ playerList[player.UserId ?? 0].Stats.LoadCheckpointsData(DB); // To-do: This really should go inside `LoadMapTimesData` imo cuz here we hardcoding load for Style 0
+
+ // Print join messages
+ Server.PrintToChatAll($"{PluginPrefix} {ChatColors.Green}{player.PlayerName}{ChatColors.Default} has connected from {ChatColors.Lime}{playerList[player.UserId ?? 0].Profile.Country}{ChatColors.Default}.");
+ Console.WriteLine($"[CS2 Surf] {player.PlayerName} has connected from {playerList[player.UserId ?? 0].Profile.Country}.");
+ return HookResult.Continue;
+ }
+ }
+
+ [GameEventHandler] // Player Disconnect Event
+ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
+ {
+ var player = @event.Userid;
+
+ for (int i = 0; i < CurrentMap.ReplayBots.Count; i++)
+ if (CurrentMap.ReplayBots[i].IsPlayable && CurrentMap.ReplayBots[i].Controller!.Equals(player))
+ CurrentMap.ReplayBots[i].Reset();
+
+ if (player.IsBot || !player.IsValid)
+ {
+ return HookResult.Continue;
+ }
+
+ else
+ {
+ if (DB == null)
+ throw new Exception("CS2 Surf ERROR >> OnPlayerDisconnect -> DB object is null, this shouldnt happen.");
+
+ if (!playerList.ContainsKey(player.UserId ?? 0))
+ {
+ Console.WriteLine($"CS2 Surf ERROR >> OnPlayerDisconnect -> Player playerList does NOT contain player.UserId, this shouldn't happen. Player: {player.PlayerName} ({player.UserId})");
+ }
+ else
+ {
+ // Update data in Player DB table
+ Task updatePlayerTask = DB.Write($@"
+ UPDATE `Player` SET country = '{playerList[player.UserId ?? 0].Profile.Country}',
+ `last_seen` = {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, `connections` = `connections` + 1
+ WHERE `id` = {playerList[player.UserId ?? 0].Profile.ID} LIMIT 1;
+ ");
+ if (updatePlayerTask.Result != 1)
+ throw new Exception($"CS2 Surf ERROR >> OnPlayerDisconnect -> Failed to update player data in database. Player: {player.PlayerName} ({player.SteamID})");
+ // Player disconnection to-do
+ updatePlayerTask.Dispose();
+
+ // Remove player data from playerList
+ playerList.Remove(player.UserId ?? 0);
+ }
+ return HookResult.Continue;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Events/Tick.cs b/src/ST-Events/Tick.cs
new file mode 100644
index 0000000..37703e4
--- /dev/null
+++ b/src/ST-Events/Tick.cs
@@ -0,0 +1,37 @@
+using CounterStrikeSharp.API.Modules.Cvars;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ public void OnTick()
+ {
+ foreach (var player in playerList.Values)
+ {
+ player.Timer.Tick();
+ player.ReplayRecorder.Tick(player);
+ player.HUD.Display();
+ }
+
+ if (CurrentMap == null)
+ return;
+
+ // Need to disable maps from executing their cfgs. Currently idk how (But seriusly it a security issue)
+ ConVar? bot_quota = ConVar.Find("bot_quota");
+ if (bot_quota != null)
+ {
+ int cbq = bot_quota.GetPrimitiveValue();
+ if(cbq != CurrentMap.ReplayBots.Count)
+ {
+ bot_quota.SetValue(CurrentMap.ReplayBots.Count);
+ }
+ }
+
+ for(int i = 0; i < CurrentMap!.ReplayBots.Count; i++)
+ {
+ CurrentMap.ReplayBots[i].Tick();
+ if (CurrentMap.ReplayBots[i].RepeatCount == 0)
+ CurrentMap.KickReplayBot(i);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Events/TriggerEndTouch.cs b/src/ST-Events/TriggerEndTouch.cs
new file mode 100644
index 0000000..b98ed13
--- /dev/null
+++ b/src/ST-Events/TriggerEndTouch.cs
@@ -0,0 +1,155 @@
+using System.Text.RegularExpressions;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Utils;
+using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ // Trigger end touch handler - CBaseTrigger_EndTouchFunc
+ internal HookResult OnTriggerEndTouch(DynamicHook handler)
+ {
+ CBaseTrigger trigger = handler.GetParam(0);
+ CBaseEntity entity = handler.GetParam(1);
+ CCSPlayerController client = new CCSPlayerController(new CCSPlayerPawn(entity.Handle).Controller.Value!.Handle);
+ if (!client.IsValid || client.UserId == -1 || !client.PawnIsAlive || !playerList.ContainsKey((int)client.UserId!)) // `client.IsBot` throws error in server console when going to spectator? + !playerList.ContainsKey((int)client.UserId!) make sure to not check for user_id that doesnt exists
+ {
+ return HookResult.Continue;
+ }
+
+ else
+ {
+ // Implement Trigger End Touch Here
+ Player player = playerList[client.UserId ?? 0];
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_EndTouchFunc -> {trigger.DesignerName} -> {trigger.Entity!.Name}");
+ #endif
+
+ if (trigger.Entity!.Name != null)
+ {
+ // Get velocities for DB queries
+ // Get the velocity of the player - we will be using this values to compare and write to DB
+ float velocity_x = player.Controller.PlayerPawn.Value!.AbsVelocity.X;
+ float velocity_y = player.Controller.PlayerPawn.Value!.AbsVelocity.Y;
+ float velocity_z = player.Controller.PlayerPawn.Value!.AbsVelocity.Z;
+ float velocity = (float)Math.Sqrt(velocity_x * velocity_x + velocity_y * velocity_y + velocity_z + velocity_z);
+
+ // Map start zones -- hook into map_start, (s)tage1_start
+ if (trigger.Entity.Name.Contains("map_start") ||
+ trigger.Entity.Name.Contains("s1_start") ||
+ trigger.Entity.Name.Contains("stage1_start"))
+ {
+ // Replay
+ if(player.ReplayRecorder.IsRecording)
+ {
+ // Saveing 2 seconds before leaving the start zone
+ player.ReplayRecorder.Frames.RemoveRange(0, Math.Max(0, player.ReplayRecorder.Frames.Count - (64*2))); // Todo make a plugin convar for the time saved before start of run
+ }
+
+ // MAP START ZONE
+ player.Timer.Start();
+ player.ReplayRecorder.CurrentSituation = ReplayFrameSituation.START_RUN;
+
+ /* Revisit
+ // Wonky Prespeed check
+ // To-do: make the teleportation a bit more elegant (method in a class or something)
+ if (velocity > 666.0)
+ {
+ player.Controller.PrintToChat(
+ $"{PluginPrefix} {ChatColors.Red}You are going too fast! ({velocity.ToString("0")} u/s)");
+ player.Timer.Reset();
+ if (CurrentMap.StartZone != new Vector(0,0,0))
+ Server.NextFrame(() => player.Controller.PlayerPawn.Value!.Teleport(CurrentMap.StartZone, new QAngle(0,0,0), new Vector(0,0,0)));
+ }
+ */
+
+ // Prespeed display
+ player.Controller.PrintToCenter($"Prespeed: {velocity.ToString("0")} u/s");
+ player.Stats.ThisRun.StartVelX = velocity_x; // Start pre speed for the run
+ player.Stats.ThisRun.StartVelY = velocity_y; // Start pre speed for the run
+ player.Stats.ThisRun.StartVelZ = velocity_z; // Start pre speed for the run
+
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone");
+ #endif
+ }
+
+ // Stage start zones -- hook into (s)tage#_start
+ else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success)
+ {
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone");
+ Console.WriteLine($"===================== player.Timer.Checkpoint {player.Timer.Checkpoint} - player.Stats.ThisRun.Checkpoint.Count {player.Stats.ThisRun.Checkpoint.Count}");
+ #endif
+
+ // This will populate the End velocities for the given Checkpoint zone (Stage = Checkpoint when in a Map Run)
+ if (player.Timer.Checkpoint != 0 && player.Timer.Checkpoint <= player.Stats.ThisRun.Checkpoint.Count)
+ {
+ var currentCheckpoint = player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint];
+ #if DEBUG
+ Console.WriteLine($"currentCheckpoint.EndVelX {currentCheckpoint.EndVelX} - velocity_x {velocity_x}");
+ Console.WriteLine($"currentCheckpoint.EndVelY {currentCheckpoint.EndVelY} - velocity_y {velocity_y}");
+ Console.WriteLine($"currentCheckpoint.EndVelZ {currentCheckpoint.EndVelZ} - velocity_z {velocity_z}");
+ Console.WriteLine($"currentCheckpoint.Attempts {currentCheckpoint.Attempts}");
+ #endif
+
+ // Update the values
+ currentCheckpoint.EndVelX = velocity_x;
+ currentCheckpoint.EndVelY = velocity_y;
+ currentCheckpoint.EndVelZ = velocity_z;
+ currentCheckpoint.EndTouch = player.Timer.Ticks; // To-do: what type of value we store in DB ?
+ currentCheckpoint.Attempts += 1;
+ // Assign the updated currentCheckpoint back to the list as `currentCheckpoint` is supposedly a copy of the original object
+ player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint] = currentCheckpoint;
+
+ // Show Prespeed for stages - will be enabled/disabled by the user?
+ player.Controller.PrintToCenter($"Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} - Prespeed: {velocity.ToString("0")} u/s");
+ }
+ else
+ {
+ // Handle the case where the index is out of bounds
+ }
+ }
+
+ // Checkpoint zones -- hook into "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$" map_c(heck)p(oint)
+ else if (Regex.Match(trigger.Entity.Name, "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$").Success)
+ {
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.LightRed}EndTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone");
+ Console.WriteLine($"===================== player.Timer.Checkpoint {player.Timer.Checkpoint} - player.Stats.ThisRun.Checkpoint.Count {player.Stats.ThisRun.Checkpoint.Count}");
+ #endif
+
+ // This will populate the End velocities for the given Checkpoint zone (Stage = Checkpoint when in a Map Run)
+ if (player.Timer.Checkpoint != 0 && player.Timer.Checkpoint <= player.Stats.ThisRun.Checkpoint.Count)
+ {
+ var currentCheckpoint = player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint];
+ #if DEBUG
+ Console.WriteLine($"currentCheckpoint.EndVelX {currentCheckpoint.EndVelX} - velocity_x {velocity_x}");
+ Console.WriteLine($"currentCheckpoint.EndVelY {currentCheckpoint.EndVelY} - velocity_y {velocity_y}");
+ Console.WriteLine($"currentCheckpoint.EndVelZ {currentCheckpoint.EndVelZ} - velocity_z {velocity_z}");
+ #endif
+
+ // Update the values
+ currentCheckpoint.EndVelX = velocity_x;
+ currentCheckpoint.EndVelY = velocity_y;
+ currentCheckpoint.EndVelZ = velocity_z;
+ currentCheckpoint.EndTouch = player.Timer.Ticks; // To-do: what type of value we store in DB ?
+ currentCheckpoint.Attempts += 1;
+ // Assign the updated currentCheckpoint back to the list as `currentCheckpoint` is supposedly a copy of the original object
+ player.Stats.ThisRun.Checkpoint[player.Timer.Checkpoint] = currentCheckpoint;
+
+ // Show Prespeed for stages - will be enabled/disabled by the user?
+ player.Controller.PrintToCenter($"Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} - Prespeed: {velocity.ToString("0")} u/s");
+ }
+ else
+ {
+ // Handle the case where the index is out of bounds
+ }
+ }
+ }
+
+ return HookResult.Continue;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Events/TriggerStartTouch.cs b/src/ST-Events/TriggerStartTouch.cs
new file mode 100644
index 0000000..3059d4e
--- /dev/null
+++ b/src/ST-Events/TriggerStartTouch.cs
@@ -0,0 +1,226 @@
+using System.Text.RegularExpressions;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Utils;
+using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
+using CounterStrikeSharp.API;
+
+namespace SurfTimer;
+
+public partial class SurfTimer
+{
+ // Trigger start touch handler - CBaseTrigger_StartTouchFunc
+ internal HookResult OnTriggerStartTouch(DynamicHook handler)
+ {
+ CBaseTrigger trigger = handler.GetParam(0);
+ CBaseEntity entity = handler.GetParam(1);
+ CCSPlayerController client = new CCSPlayerController(new CCSPlayerPawn(entity.Handle).Controller.Value!.Handle);
+ if (!client.IsValid || !client.PawnIsAlive || !playerList.ContainsKey((int)client.UserId!)) // !playerList.ContainsKey((int)client.UserId!) make sure to not check for user_id that doesnt exists
+ {
+ return HookResult.Continue;
+ }
+ else
+ {
+ // To-do: Sometimes this triggers before `OnPlayerConnect` and `playerList` does not contain the player how is this possible :thonk:
+ if (!playerList.ContainsKey(client.UserId ?? 0))
+ {
+ Console.WriteLine($"CS2 Surf ERROR >> OnTriggerStartTouch -> Init -> Player playerList does NOT contain client.UserId, this shouldn't happen. Player: {client.PlayerName} ({client.UserId})");
+ throw new Exception($"CS2 Surf ERROR >> OnTriggerStartTouch -> Init -> Player playerList does NOT contain client.UserId, this shouldn't happen. Player: {client.PlayerName} ({client.UserId})");
+ // return HookResult.Continue;
+ }
+ // Implement Trigger Start Touch Here
+ Player player = playerList[client.UserId ?? 0];
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc -> {trigger.DesignerName} -> {trigger.Entity!.Name}");
+ #endif
+
+ if (trigger.Entity!.Name != null)
+ {
+ // Get velocities for DB queries
+ // Get the velocity of the player - we will be using this values to compare and write to DB
+ float velocity_x = player.Controller.PlayerPawn.Value!.AbsVelocity.X;
+ float velocity_y = player.Controller.PlayerPawn.Value!.AbsVelocity.Y;
+ float velocity_z = player.Controller.PlayerPawn.Value!.AbsVelocity.Z;
+ float velocity = (float)Math.Sqrt(velocity_x * velocity_x + velocity_y * velocity_y + velocity_z + velocity_z);
+ int style = player.Timer.Style;
+
+ // Map end zones -- hook into map_end
+ if (trigger.Entity.Name == "map_end")
+ {
+ player.Controller.PrintToCenter($"Map End");
+ // MAP END ZONE
+ if (player.Timer.IsRunning)
+ {
+ player.Timer.Stop();
+ player.ReplayRecorder.CurrentSituation = ReplayFrameSituation.END_RUN;
+
+ player.Stats.ThisRun.Ticks = player.Timer.Ticks; // End time for the run
+ player.Stats.ThisRun.EndVelX = velocity_x; // End pre speed for the run
+ player.Stats.ThisRun.EndVelY = velocity_y; // End pre speed for the run
+ player.Stats.ThisRun.EndVelZ = velocity_z; // End pre speed for the run
+
+ string PracticeString = "";
+ if (player.Timer.IsPracticeMode)
+ PracticeString = $"({ChatColors.Grey}Practice{ChatColors.Default}) ";
+
+ // To-do: make Style (currently 0) be dynamic
+ if (player.Stats.PB[style].Ticks <= 0) // Player first ever PersonalBest for the map
+ {
+ Server.PrintToChatAll($"{PluginPrefix} {PracticeString}{player.Controller.PlayerName} finished the map in {ChatColors.Gold}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default} ({player.Timer.Ticks})!");
+ }
+ else if (player.Timer.Ticks < player.Stats.PB[style].Ticks) // Player beating their existing PersonalBest for the map
+ {
+ Server.PrintToChatAll($"{PluginPrefix} {PracticeString}{ChatColors.Lime}{player.Profile.Name}{ChatColors.Default} beat their PB in {ChatColors.Gold}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default} (Old: {ChatColors.BlueGrey}{PlayerHUD.FormatTime(player.Stats.PB[style].Ticks)}{ChatColors.Default})!");
+ }
+ else // Player did not beat their existing PersonalBest for the map
+ {
+ player.Controller.PrintToChat($"{PluginPrefix} {PracticeString}You finished the map in {ChatColors.Yellow}{PlayerHUD.FormatTime(player.Timer.Ticks)}{ChatColors.Default}!");
+ return HookResult.Continue; // Exit here so we don't write to DB
+ }
+
+ if (DB == null)
+ throw new Exception("CS2 Surf ERROR >> OnTriggerStartTouch (Map end zone) -> DB object is null, this shouldn't happen.");
+
+
+ player.Stats.PB[style].Ticks = player.Timer.Ticks; // Reload the run_time for the HUD and also assign for the DB query
+
+ #if DEBUG
+ Console.WriteLine($@"CS2 Surf DEBUG >> OnTriggerStartTouch (Map end zone) ->
+ ============== INSERT INTO `MapTimes`
+ (`player_id`, `map_id`, `style`, `type`, `stage`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, `end_vel_x`, `end_vel_y`, `end_vel_z`, `run_date`)
+ VALUES ({player.Profile.ID}, {CurrentMap.ID}, {style}, 0, 0, {player.Stats.ThisRun.Ticks},
+ {player.Stats.ThisRun.StartVelX}, {player.Stats.ThisRun.StartVelY}, {player.Stats.ThisRun.StartVelZ}, {velocity_x}, {velocity_y}, {velocity_z}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()})
+ ON DUPLICATE KEY UPDATE run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y),
+ start_vel_z=VALUES(start_vel_z), end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), run_date=VALUES(run_date);
+ ");
+ #endif
+
+ // Add entry in DB for the run
+ if(!player.Timer.IsPracticeMode) {
+ AddTimer(1.5f, () => {
+ player.Stats.ThisRun.SaveMapTime(player, DB); // Save the MapTime PB data
+ player.Stats.LoadMapTimesData(player, DB); // Load the MapTime PB data again (will refresh the MapTime ID for the Checkpoints query)
+ player.Stats.ThisRun.SaveCurrentRunCheckpoints(player, DB); // Save this run's checkpoints
+ player.Stats.LoadCheckpointsData(DB); // Reload checkpoints for the run - we should really have this in `SaveMapTime` as well but we don't re-load PB data inside there so we need to do it here
+ CurrentMap.GetMapRecordAndTotals(DB); // Reload the Map record and totals for the HUD
+ });
+
+ // This section checks if the PB is better than WR
+ if(player.Timer.Ticks < CurrentMap.WR[player.Timer.Style].Ticks || CurrentMap.WR[player.Timer.Style].ID == -1)
+ {
+ int WrIndex = CurrentMap.ReplayBots.Count-1; // As the ReplaysBot is set, WR Index will always be at the end of the List
+ AddTimer(2f, () => {
+ CurrentMap.ReplayBots[WrIndex].Stat_MapTimeID = CurrentMap.WR[player.Timer.Style].ID;
+ CurrentMap.ReplayBots[WrIndex].LoadReplayData(DB!);
+ CurrentMap.ReplayBots[WrIndex].ResetReplay();
+ });
+ }
+ }
+ }
+
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Red}Map Stop Zone");
+ #endif
+ }
+
+ // Map start zones -- hook into map_start, (s)tage1_start
+ else if (trigger.Entity.Name.Contains("map_start") ||
+ trigger.Entity.Name.Contains("s1_start") ||
+ trigger.Entity.Name.Contains("stage1_start"))
+ {
+ player.ReplayRecorder.Start(); // Start replay recording
+
+ player.Timer.Reset();
+ player.Stats.ThisRun.Checkpoint.Clear(); // I have the suspicion that the `Timer.Reset()` does not properly reset this object :thonk:
+ player.Controller.PrintToCenter($"Map Start ({trigger.Entity.Name})");
+
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Green}Map Start Zone");
+ // player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc -> KeyValues: {trigger.Entity.KeyValues3}");
+ #endif
+ }
+
+ // Stage start zones -- hook into (s)tage#_start
+ else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success)
+ {
+ int stage = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1;
+ player.Timer.Stage = stage;
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Timer.IsRunning: {player.Timer.IsRunning}");
+ Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> !player.Timer.IsStageMode: {!player.Timer.IsStageMode}");
+ Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Stats.ThisRun.Checkpoint.Count <= stage: {player.Stats.ThisRun.Checkpoint.Count <= stage}");
+ #endif
+
+ // This should patch up re-triggering *player.Stats.ThisRun.Checkpoint.Count < stage*
+ if (player.Timer.IsRunning && !player.Timer.IsStageMode && player.Stats.ThisRun.Checkpoint.Count < stage)
+ {
+ player.Timer.Checkpoint = stage; // Stage = Checkpoint when in a run on a Staged map
+
+ #if DEBUG
+ Console.WriteLine($"============== Initial entity value: {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} | Assigned to `stage`: {Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1}");
+ Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Stage start zones) -> player.Stats.PB[{style}].Checkpoint.Count = {player.Stats.PB[style].Checkpoint.Count}");
+ #endif
+
+ // Print checkpoint message
+ player.HUD.DisplayCheckpointMessages(PluginPrefix);
+
+ // store the checkpoint in the player's current run checkpoints used for Checkpoint functionality
+ Checkpoint cp2 = new Checkpoint(stage,
+ player.Timer.Ticks,
+ velocity_x,
+ velocity_y,
+ velocity_z,
+ -1.0f,
+ -1.0f,
+ -1.0f,
+ -1.0f,
+ 0);
+ player.Stats.ThisRun.Checkpoint[stage] = cp2;
+ }
+
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.Yellow}Stage {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Start Zone");
+ #endif
+ }
+
+ // Map checkpoint zones -- hook into map_(c)heck(p)oint#
+ else if (Regex.Match(trigger.Entity.Name, "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$").Success)
+ {
+ int checkpoint = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value);
+ player.Timer.Checkpoint = checkpoint;
+
+ // This should patch up re-triggering *player.Stats.ThisRun.Checkpoint.Count < checkpoint*
+ if (player.Timer.IsRunning && !player.Timer.IsStageMode && player.Stats.ThisRun.Checkpoint.Count < checkpoint)
+ {
+ #if DEBUG
+ Console.WriteLine($"============== Initial entity value: {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} | Assigned to `checkpoint`: {Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1}");
+ Console.WriteLine($"CS2 Surf DEBUG >> CBaseTrigger_StartTouchFunc (Checkpoint zones) -> player.Stats.PB[{style}].Checkpoint.Count = {player.Stats.PB[style].Checkpoint.Count}");
+ #endif
+
+ // Print checkpoint message
+ player.HUD.DisplayCheckpointMessages(PluginPrefix);
+
+ // store the checkpoint in the player's current run checkpoints used for Checkpoint functionality
+ Checkpoint cp2 = new Checkpoint(checkpoint,
+ player.Timer.Ticks,
+ velocity_x,
+ velocity_y,
+ velocity_z,
+ -1.0f,
+ -1.0f,
+ -1.0f,
+ -1.0f,
+ 0);
+ player.Stats.ThisRun.Checkpoint[checkpoint] = cp2;
+ }
+
+ #if DEBUG
+ player.Controller.PrintToChat($"CS2 Surf DEBUG >> CBaseTrigger_{ChatColors.Lime}StartTouchFunc{ChatColors.Default} -> {ChatColors.LightBlue}Checkpoint {Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value} Zone");
+ #endif
+ }
+ }
+
+ return HookResult.Continue;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Map/Map.cs b/src/ST-Map/Map.cs
new file mode 100644
index 0000000..c1fb166
--- /dev/null
+++ b/src/ST-Map/Map.cs
@@ -0,0 +1,329 @@
+using System.Text.RegularExpressions;
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Utils;
+using MySqlConnector;
+
+namespace SurfTimer;
+
+internal class Map
+{
+ // Map information
+ public int ID {get; set;} = -1; // Can we use this to re-trigger retrieving map information from the database?? (all db IDs are auto-incremented)
+ public string Name {get; set;} = "";
+ public string Author {get; set;} = "";
+ public int Tier {get; set;} = 0;
+ public int Stages {get; set;} = 0;
+ public int Checkpoints {get; set;} = 0;
+ public int Bonuses {get; set;} = 0;
+ public bool Ranked {get; set;} = false;
+ public int DateAdded {get; set;} = 0;
+ public int LastPlayed {get; set;} = 0;
+ public int TotalCompletions {get; set;} = 0;
+ public Dictionary WR { get; set; } = new Dictionary();
+ public List ConnectedMapTimes { get; set; } = new List();
+ public List ReplayBots { get; set; } = new List { new ReplayPlayer() };
+
+ // Zone Origin Information
+ // Map start/end zones
+ public Vector StartZone {get;} = new Vector(0,0,0);
+ public QAngle StartZoneAngles {get;} = new QAngle(0,0,0);
+ public Vector EndZone {get;} = new Vector(0,0,0);
+ // Map stage zones
+ public Vector[] StageStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray();
+ public QAngle[] StageStartZoneAngles {get;} = Enumerable.Repeat(0, 99).Select(x => new QAngle(0,0,0)).ToArray();
+ // Map bonus zones
+ public Vector[] BonusStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); // To-do: Implement bonuses
+ public QAngle[] BonusStartZoneAngles {get;} = Enumerable.Repeat(0, 99).Select(x => new QAngle(0,0,0)).ToArray(); // To-do: Implement bonuses
+ public Vector[] BonusEndZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray(); // To-do: Implement bonuses
+ // Map checkpoint zones
+ public Vector[] CheckpointStartZone {get;} = Enumerable.Repeat(0, 99).Select(x => new Vector(0,0,0)).ToArray();
+
+ // Constructor
+ internal Map(string Name, TimerDatabase DB)
+ {
+ // Set map name
+ this.Name = Name;
+ this.WR[0] = new PersonalBest(); // To-do: Implement styles
+ // Gathering zones from the map
+ IEnumerable triggers = Utilities.FindAllEntitiesByDesignerName("trigger_multiple");
+ // Gathering info_teleport_destinations from the map
+ IEnumerable teleports = Utilities.FindAllEntitiesByDesignerName("info_teleport_destination");
+ foreach (CBaseTrigger trigger in triggers)
+ {
+ if (trigger.Entity!.Name != null)
+ {
+ // Map start zone
+ if (trigger.Entity!.Name.Contains("map_start") ||
+ trigger.Entity!.Name.Contains("stage1_start") ||
+ trigger.Entity!.Name.Contains("s1_start"))
+ {
+ bool foundPlayerSpawn = false; // Track whether a player spawn is found
+ foreach (CBaseEntity teleport in teleports)
+ {
+ if (teleport.Entity!.Name != null &&
+ (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) ||
+ teleport.Entity!.Name.Contains("spawn_map_start") ||
+ teleport.Entity!.Name.Contains("spawn_stage1_start") ||
+ teleport.Entity!.Name.Contains("spawn_s1_start")))
+ {
+ this.StartZone = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z);
+ this.StartZoneAngles = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z);
+ foundPlayerSpawn = true;
+ break;
+ }
+ }
+
+ if (!foundPlayerSpawn)
+ {
+ this.StartZone = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ }
+ }
+
+ // Map end zone
+ else if (trigger.Entity!.Name.Contains("map_end"))
+ {
+ this.EndZone = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ }
+
+ // Stage start zones
+ else if (Regex.Match(trigger.Entity.Name, "^s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success)
+ {
+ int stage = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value);
+
+ // Find an info_destination_teleport inside this zone to grab angles from
+ bool foundPlayerSpawn = false; // Track whether a player spawn is found
+ foreach (CBaseEntity teleport in teleports)
+ {
+ if (teleport.Entity!.Name != null &&
+ (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) || (Regex.Match(teleport.Entity.Name, "^spawn_s([1-9][0-9]?|tage[1-9][0-9]?)_start$").Success && Int32.Parse(Regex.Match(teleport.Entity.Name, "[0-9][0-9]?").Value) == stage)))
+ {
+ this.StageStartZone[stage - 1] = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z);
+ this.StageStartZoneAngles[stage - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z);
+ this.Stages++; // Count stage zones for the map to populate DB
+ foundPlayerSpawn = true;
+ break;
+ }
+ }
+
+ if (!foundPlayerSpawn)
+ {
+ this.StageStartZone[stage - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ }
+ }
+
+ // Checkpoint start zones (linear maps)
+ else if (Regex.Match(trigger.Entity.Name, "^map_c(p[1-9][0-9]?|heckpoint[1-9][0-9]?)$").Success)
+ {
+ this.CheckpointStartZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ this.Checkpoints++; // Might be useful to have this in DB entry
+ }
+
+ // Bonus start zones
+ else if (Regex.Match(trigger.Entity.Name, "^b([1-9][0-9]?|onus[1-9][0-9]?)_start$").Success)
+ {
+ int bonus = Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value);
+
+ // Find an info_destination_teleport inside this zone to grab angles from
+ bool foundPlayerSpawn = false; // Track whether a player spawn is found
+ foreach (CBaseEntity teleport in teleports)
+ {
+ if (teleport.Entity!.Name != null &&
+ (IsInZone(trigger.AbsOrigin!, trigger.Collision.BoundingRadius, teleport.AbsOrigin!) || (Regex.Match(teleport.Entity.Name, "^spawn_b([1-9][0-9]?|onus[1-9][0-9]?)_start$").Success && Int32.Parse(Regex.Match(teleport.Entity.Name, "[0-9][0-9]?").Value) == bonus)))
+ {
+ this.BonusStartZone[bonus - 1] = new Vector(teleport.AbsOrigin!.X, teleport.AbsOrigin!.Y, teleport.AbsOrigin!.Z);
+ this.BonusStartZoneAngles[bonus - 1] = new QAngle(teleport.AbsRotation!.X, teleport.AbsRotation!.Y, teleport.AbsRotation!.Z);
+ this.Bonuses++; // Count bonus zones for the map to populate DB
+ foundPlayerSpawn = true;
+ break;
+ }
+ }
+
+ if (!foundPlayerSpawn)
+ {
+ this.BonusStartZone[bonus - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ }
+ }
+
+ else if (Regex.Match(trigger.Entity.Name, "^b([1-9][0-9]?|onus[1-9][0-9]?)_end$").Success)
+ {
+ this.BonusEndZone[Int32.Parse(Regex.Match(trigger.Entity.Name, "[0-9][0-9]?").Value) - 1] = new Vector(trigger.AbsOrigin!.X, trigger.AbsOrigin!.Y, trigger.AbsOrigin!.Z);
+ }
+ }
+ }
+ if (this.Stages > 0) this.Stages++; // You did not count the stages right :(
+ Console.WriteLine($"[CS2 Surf] Identifying start zone: {this.StartZone.X},{this.StartZone.Y},{this.StartZone.Z}\nIdentifying end zone: {this.EndZone.X},{this.EndZone.Y},{this.EndZone.Z}");
+
+ // Gather map information OR create entry
+ Task reader = DB.Query($"SELECT * FROM Maps WHERE name='{MySqlHelper.EscapeString(Name)}'");
+ MySqlDataReader mapData = reader.Result;
+ bool updateData = false;
+ if (mapData.HasRows && mapData.Read()) // In here we can check whether MapData in DB is the same as the newly extracted data, if not, update it (as hookzones may have changed on map updates)
+ {
+ this.ID = mapData.GetInt32("id");
+ this.Author = mapData.GetString("author") ?? "Unknown";
+ this.Tier = mapData.GetInt32("tier");
+ if (this.Stages != mapData.GetInt32("stages") || this.Bonuses != mapData.GetInt32("bonuses"))
+ updateData = true;
+ // this.Stages = mapData.GetInt32("stages"); // this should now be populated accordingly when looping through hookzones for the map
+ // this.Bonuses = mapData.GetInt32("bonuses"); // this should now be populated accordingly when looping through hookzones for the map
+ this.Ranked = mapData.GetBoolean("ranked");
+ this.DateAdded = mapData.GetInt32("date_added");
+ this.LastPlayed = mapData.GetInt32("last_played");
+ updateData = true;
+ mapData.Close();
+ }
+
+ else
+ {
+ mapData.Close();
+ Task writer = DB.Write($"INSERT INTO Maps (name, author, tier, stages, ranked, date_added, last_played) VALUES ('{MySqlHelper.EscapeString(Name)}', 'Unknown', {this.Stages}, {this.Bonuses}, 0, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()})");
+ int writerRows = writer.Result;
+ if (writerRows != 1)
+ throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> new Map() -> Failed to write new map to database, this shouldn't happen. Map: {Name}");
+ writer.Dispose();
+
+ Task postWriteReader = DB.Query($"SELECT * FROM Maps WHERE name='{MySqlHelper.EscapeString(Name)}'");
+ MySqlDataReader postWriteMapData = postWriteReader.Result;
+ if (postWriteMapData.HasRows && postWriteMapData.Read())
+ {
+ this.ID = postWriteMapData.GetInt32("id");
+ this.Author = postWriteMapData.GetString("author");
+ this.Tier = postWriteMapData.GetInt32("tier");
+ // this.Stages = -1; // this should now be populated accordingly when looping through hookzones for the map
+ // this.Bonuses = -1; // this should now be populated accordingly when looping through hookzones for the map
+ this.Ranked = postWriteMapData.GetBoolean("ranked");
+ this.DateAdded = postWriteMapData.GetInt32("date_added");
+ this.LastPlayed = this.DateAdded;
+ }
+ postWriteMapData.Close();
+
+ return;
+ }
+
+ // Update the map's last played data in the DB
+ // Update last_played data or update last_played, stages, and bonuses data
+ string query = $"UPDATE Maps SET last_played={(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()} WHERE id={this.ID}";
+ if (updateData) query = $"UPDATE Maps SET last_played={(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, stages={this.Stages}, bonuses={this.Bonuses} WHERE id={this.ID}";
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf ERROR >> OnRoundStart -> update Map() -> Update MapData: {query}");
+ #endif
+
+ Task updater = DB.Write(query);
+ int lastPlayedUpdateRows = updater.Result;
+ if (lastPlayedUpdateRows != 1)
+ throw new Exception($"CS2 Surf ERROR >> OnRoundStart -> update Map() -> Failed to update map in database, this shouldnt happen. Map: {Name} | was it 'big' update? {updateData}");
+ updater.Dispose();
+
+ // Initiates getting the World Records for the map
+ GetMapRecordAndTotals(DB); // To-do: Implement styles
+
+ this.ReplayBots[0].Stat_MapTimeID = this.WR[0].ID; // Sets WrIndex to WR maptime_id
+ if(this.Stages > 0) // If stages map adds bot
+ this.ReplayBots = this.ReplayBots.Prepend(new ReplayPlayer()).ToList();
+
+ if(this.Bonuses > 0) // If has bonuses adds bot
+ this.ReplayBots = this.ReplayBots.Prepend(new ReplayPlayer()).ToList();
+ }
+
+ public void KickReplayBot(int index)
+ {
+ if (!this.ReplayBots[index].IsPlayable)
+ return;
+
+ int? id_to_kick = this.ReplayBots[index].Controller!.UserId;
+ if(id_to_kick == null)
+ return;
+
+ this.ReplayBots.RemoveAt(index);
+ Server.ExecuteCommand($"kickid {id_to_kick}; bot_quota {this.ReplayBots.Count}");
+ }
+
+ public bool IsInZone(Vector zoneOrigin, float zoneCollisionRadius, Vector spawnOrigin)
+ {
+ if (spawnOrigin.X >= zoneOrigin.X - zoneCollisionRadius && spawnOrigin.X <= zoneOrigin.X + zoneCollisionRadius &&
+ spawnOrigin.Y >= zoneOrigin.Y - zoneCollisionRadius && spawnOrigin.Y <= zoneOrigin.Y + zoneCollisionRadius &&
+ spawnOrigin.Z >= zoneOrigin.Z - zoneCollisionRadius && spawnOrigin.Z <= zoneOrigin.Z + zoneCollisionRadius)
+ return true;
+ else
+ return false;
+ }
+
+ // Leaving this outside of the constructor for `Map` so we can call it to ONLY update the data when a new world record is set
+ internal void GetMapRecordAndTotals(TimerDatabase DB, int style = 0 ) // To-do: Implement styles
+ {
+ // Get map world records
+ Task reader = DB.Query($@"
+ SELECT MapTimes.*, Player.name
+ FROM MapTimes
+ JOIN Player ON MapTimes.player_id = Player.id
+ WHERE MapTimes.map_id = {this.ID} AND MapTimes.style = {style}
+ ORDER BY MapTimes.run_time ASC;
+ ");
+ MySqlDataReader mapWrData = reader.Result;
+ int totalRows = 0;
+
+ if (mapWrData.HasRows)
+ {
+ // To-do: Implement bonuses WR
+ // To-do: Implement stages WR
+ this.ConnectedMapTimes.Clear();
+ while (mapWrData.Read())
+ {
+ if (totalRows == 0) // We are sorting by `run_time ASC` so the first row is always the fastest run for the map and style combo :)
+ {
+ this.WR[style].ID = mapWrData.GetInt32("id"); // WR ID for the Map and Style combo
+ this.WR[style].Ticks = mapWrData.GetInt32("run_time"); // Fastest run time (WR) for the Map and Style combo
+ this.WR[style].StartVelX = mapWrData.GetFloat("start_vel_x"); // Fastest run start velocity X for the Map and Style combo
+ this.WR[style].StartVelY = mapWrData.GetFloat("start_vel_y"); // Fastest run start velocity Y for the Map and Style combo
+ this.WR[style].StartVelZ = mapWrData.GetFloat("start_vel_z"); // Fastest run start velocity Z for the Map and Style combo
+ this.WR[style].EndVelX = mapWrData.GetFloat("end_vel_x"); // Fastest run end velocity X for the Map and Style combo
+ this.WR[style].EndVelY = mapWrData.GetFloat("end_vel_y"); // Fastest run end velocity Y for the Map and Style combo
+ this.WR[style].EndVelZ = mapWrData.GetFloat("end_vel_z"); // Fastest run end velocity Z for the Map and Style combo
+ this.WR[style].RunDate = mapWrData.GetInt32("run_date"); // Fastest run date for the Map and Style combo
+ this.WR[style].Name = mapWrData.GetString("name"); // Fastest run player name for the Map and Style combo
+ }
+ this.ConnectedMapTimes.Add(mapWrData.GetInt32("id"));
+ totalRows++;
+ }
+ }
+ mapWrData.Close();
+ this.TotalCompletions = totalRows; // Total completions for the map and style - this should maybe be added to PersonalBest class
+
+ // Get map world record checkpoints
+ if (totalRows != 0)
+ {
+ Task cpReader = DB.Query($"SELECT * FROM `Checkpoints` WHERE `maptime_id` = {this.WR[style].ID};");
+ MySqlDataReader cpWrData = cpReader.Result;
+ while (cpWrData.Read())
+ {
+ #if DEBUG
+ Console.WriteLine($"cp {cpWrData.GetInt32("cp")} ");
+ Console.WriteLine($"run_time {cpWrData.GetFloat("run_time")} ");
+ Console.WriteLine($"sVelX {cpWrData.GetFloat("start_vel_x")} ");
+ Console.WriteLine($"sVelY {cpWrData.GetFloat("start_vel_y")} ");
+ #endif
+
+ Checkpoint cp = new(cpWrData.GetInt32("cp"),
+ cpWrData.GetInt32("run_time"), // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int???
+ cpWrData.GetFloat("start_vel_x"),
+ cpWrData.GetFloat("start_vel_y"),
+ cpWrData.GetFloat("start_vel_z"),
+ cpWrData.GetFloat("end_vel_x"),
+ cpWrData.GetFloat("end_vel_y"),
+ cpWrData.GetFloat("end_vel_z"),
+ cpWrData.GetFloat("end_touch"),
+ cpWrData.GetInt32("attempts"));
+ cp.ID = cpWrData.GetInt32("cp");
+ // To-do: cp.ID = calculate Rank # from DB
+
+ this.WR[style].Checkpoint[cp.CP] = cp;
+
+ #if DEBUG
+ Console.WriteLine($"======= CS2 Surf DEBUG >> internal void GetMapRecordAndTotals : Map -> Loaded WR CP {cp.CP} with RunTime {cp.Ticks} for MapTimeID {WR[0].ID} (MapId = {this.ID}).");
+ #endif
+ }
+ cpWrData.Close();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/Player.cs b/src/ST-Player/Player.cs
new file mode 100644
index 0000000..2bcfb83
--- /dev/null
+++ b/src/ST-Player/Player.cs
@@ -0,0 +1,52 @@
+namespace SurfTimer;
+using CounterStrikeSharp.API.Core;
+
+internal class Player
+{
+ // CCS requirements
+ public CCSPlayerController Controller {get;}
+ public CCSPlayer_MovementServices MovementServices {get;} // Can be used later for any movement modification (eg: styles)
+
+ // Timer-related properties
+ public PlayerTimer Timer {get; set;}
+ public PlayerStats Stats {get; set;}
+ public PlayerHUD HUD {get; set;}
+ public ReplayRecorder ReplayRecorder { get; set; }
+ public List SavedLocations { get; set; }
+ public int CurrentSavedLocation { get; set; }
+
+ // Player information
+ public PlayerProfile Profile {get; set;}
+
+ // Map information
+ public Map CurrMap = null!;
+
+ // Constructor
+ public Player(CCSPlayerController Controller, CCSPlayer_MovementServices MovementServices, PlayerProfile Profile, Map CurrMap)
+ {
+ this.Controller = Controller;
+ this.MovementServices = MovementServices;
+
+ this.Profile = Profile;
+
+ this.Timer = new PlayerTimer();
+ this.Stats = new PlayerStats();
+ this.ReplayRecorder = new ReplayRecorder();
+ this.SavedLocations = new List();
+ CurrentSavedLocation = 0;
+
+ this.HUD = new PlayerHUD(this);
+ this.CurrMap = CurrMap;
+ }
+
+ ///
+ /// Checks if current player is spcetating player
+ ///
+ public bool IsSpectating(CCSPlayerController p)
+ {
+ if(p == null || this.Controller == null || this.Controller.Team != CounterStrikeSharp.API.Modules.Utils.CsTeam.Spectator)
+ return false;
+
+ return p.Pawn.SerialNum == this.Controller.ObserverPawn.Value!.ObserverServices!.ObserverTarget.SerialNum;
+ }
+}
diff --git a/src/ST-Player/PlayerHUD.cs b/src/ST-Player/PlayerHUD.cs
new file mode 100644
index 0000000..4ed6c2b
--- /dev/null
+++ b/src/ST-Player/PlayerHUD.cs
@@ -0,0 +1,258 @@
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+internal class PlayerHUD
+{
+ private Player _player;
+
+ public PlayerHUD(Player Player)
+ {
+ _player = Player;
+ }
+
+ private string FormatHUDElementHTML(string title, string body, string color, string size = "m")
+ {
+ if (title != "")
+ {
+ if (size == "m")
+ return $"{title}: {body}";
+ else
+ return $"{title}: {body}";
+ }
+
+ else
+ {
+ if (size == "m")
+ return $"{body}";
+ else
+ return $"{body}";
+ }
+ }
+
+ ///
+ /// Formats the given time in ticks into a readable time string.
+ /// Unless specified differently, the default formatting will be `Compact`.
+ /// Check for all formatting types.
+ ///
+ public static string FormatTime(int ticks, PlayerTimer.TimeFormatStyle style = PlayerTimer.TimeFormatStyle.Compact)
+ {
+ TimeSpan time = TimeSpan.FromSeconds(ticks / 64.0);
+ int millis = (int)(ticks % 64 * (1000.0 / 64.0));
+
+ switch (style)
+ {
+ case PlayerTimer.TimeFormatStyle.Compact:
+ return time.TotalMinutes < 1
+ ? $"{time.Seconds:D1}:{millis:D3}"
+ : $"{time.Minutes:D1}:{time.Seconds:D1}.{millis:D3}";
+ case PlayerTimer.TimeFormatStyle.Full:
+ return $"{time.Hours:D2}:{time.Minutes:D2}:{time.Seconds:D2}.{millis:D3}";
+ case PlayerTimer.TimeFormatStyle.Verbose:
+ return $"{time.Hours}h {time.Minutes}m {time.Seconds}s {millis}ms";
+ default:
+ throw new ArgumentException("Invalid time format style");
+ }
+ }
+
+ public void Display()
+ {
+ if(!_player.Controller.IsValid)
+ return;
+
+ if (_player.Controller.PawnIsAlive)
+ {
+ int style = _player.Timer.Style;
+ // Timer Module
+ string timerColor = "#79d1ed";
+
+ if (_player.Timer.IsRunning)
+ {
+ if (_player.Timer.IsPracticeMode)
+ timerColor = "#F2C94C";
+ else
+ timerColor = "#2E9F65";
+ }
+ string timerModule = FormatHUDElementHTML("", FormatTime(_player.Timer.Ticks), timerColor);
+
+ // Velocity Module - To-do: Make velocity module configurable (XY or XYZ velocity)
+ float velocity = (float)Math.Sqrt(_player.Controller.PlayerPawn.Value!.AbsVelocity.X * _player.Controller.PlayerPawn.Value!.AbsVelocity.X
+ + _player.Controller.PlayerPawn.Value!.AbsVelocity.Y * _player.Controller.PlayerPawn.Value!.AbsVelocity.Y
+ + _player.Controller.PlayerPawn.Value!.AbsVelocity.Z * _player.Controller.PlayerPawn.Value!.AbsVelocity.Z);
+ string velocityModule = FormatHUDElementHTML("Speed", velocity.ToString("0"), "#79d1ed") + " u/s";
+ // Rank Module
+ string rankModule = FormatHUDElementHTML("Rank", $"N/A", "#7882dd");
+ if (_player.Stats.PB[style].ID != -1 && _player.CurrMap.WR[style].ID != -1)
+ {
+ rankModule = FormatHUDElementHTML("Rank", $"{_player.Stats.PB[style].Rank}/{_player.CurrMap.TotalCompletions}", "#7882dd");
+ }
+ else if (_player.CurrMap.WR[style].ID != -1)
+ {
+ rankModule = FormatHUDElementHTML("Rank", $"-/{_player.CurrMap.TotalCompletions}", "#7882dd");
+ }
+ // PB & WR Modules
+ string pbModule = FormatHUDElementHTML("PB", _player.Stats.PB[style].Ticks > 0 ? FormatTime(_player.Stats.PB[style].Ticks) : "N/A", "#7882dd"); // IMPLEMENT IN PlayerStats // To-do: make Style (currently 0) be dynamic
+ string wrModule = FormatHUDElementHTML("WR", _player.CurrMap.WR[style].Ticks > 0 ? FormatTime(_player.CurrMap.WR[style].Ticks) : "N/A", "#ffc61a"); // IMPLEMENT IN PlayerStats - This should be part of CurrentMap, not PlayerStats?
+
+ // Build HUD
+ string hud = $"{timerModule}
{velocityModule}
{pbModule} | {rankModule}
{wrModule}";
+
+ // Display HUD
+ _player.Controller.PrintToCenterHtml(hud);
+ }
+ else if (_player.Controller.Team == CsTeam.Spectator)
+ {
+ for (int i = 0; i < _player.CurrMap.ReplayBots.Count; i++)
+ {
+ if(!_player.CurrMap.ReplayBots[i].IsPlayable || !_player.IsSpectating(_player.CurrMap.ReplayBots[i].Controller!))
+ continue;
+
+ string replayModule = $"{FormatHUDElementHTML("", "REPLAY", "red", "large")}";
+
+ string nameModule = FormatHUDElementHTML($"{_player.CurrMap.ReplayBots[i].Stat_PlayerName}", $"{FormatTime(_player.CurrMap.ReplayBots[i].Stat_RunTime)}", "#ffd500");
+
+ string elapsed_time = FormatHUDElementHTML("Time", $"{PlayerHUD.FormatTime(_player.CurrMap.ReplayBots[i].Stat_RunTick)}", "#7882dd");
+ string hud = $"{replayModule}
{elapsed_time}
{nameModule}";
+
+ _player.Controller.PrintToCenterHtml(hud);
+ }
+ }
+ }
+
+ ///
+ /// Only calculates if the player has a PB, otherwise it will display N/A
+ ///
+ ///
+ public void DisplayCheckpointMessages(string PluginPrefix) // To-do: PluginPrefix should be accessible in here without passing it as a parameter
+ {
+ int pbTime;
+ int wrTime = -1;
+ float pbSpeed;
+ float wrSpeed = -1.0f;
+ int style = _player.Timer.Style;
+
+ int currentTime = _player.Timer.Ticks;
+ float currentSpeed = (float)Math.Sqrt(_player.Controller.PlayerPawn.Value!.AbsVelocity.X * _player.Controller.PlayerPawn.Value!.AbsVelocity.X
+ + _player.Controller.PlayerPawn.Value!.AbsVelocity.Y * _player.Controller.PlayerPawn.Value!.AbsVelocity.Y
+ + _player.Controller.PlayerPawn.Value!.AbsVelocity.Z * _player.Controller.PlayerPawn.Value!.AbsVelocity.Z);
+
+ // Default values for the PB and WR differences in case no calculations can be made
+ string strPbDifference = $"{ChatColors.Grey}N/A{ChatColors.Default} ({ChatColors.Grey}N/A{ChatColors.Default})";
+ string strWrDifference = $"{ChatColors.Grey}N/A{ChatColors.Default} ({ChatColors.Grey}N/A{ChatColors.Default})";
+
+ // We need to try/catch this because the player might not have a PB for this stage in this case but they will not have for the map as well
+ // Can check checkpoints count instead of try/catch
+ try
+ {
+ pbTime = _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].Ticks;
+ pbSpeed = (float)Math.Sqrt(_player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelX * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelX
+ + _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelY * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelY
+ + _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ * _player.Stats.PB[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ);
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] Got pbTime from _player.Stats.PB[{style}].Checkpoint[{_player.Timer.Checkpoint} = {pbTime}]");
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] Got pbSpeed from _player.Stats.PB[{style}].Checkpoint[{_player.Timer.Checkpoint}] = {pbSpeed}");
+ #endif
+ }
+ #if DEBUG
+ catch (System.Exception ex)
+ #else
+ catch (System.Exception)
+ #endif
+ {
+ // Handle the exception gracefully without stopping the application
+ // We assign default values to pbTime and pbSpeed
+ pbTime = -1; // This determines if we will calculate differences or not!!!
+ pbSpeed = 0.0f;
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf CAUGHT EXCEPTION >> DisplayCheckpointMessages -> An error occurred: {ex.Message}");
+ Console.WriteLine($"CS2 Surf CAUGHT EXCEPTION >> DisplayCheckpointMessages -> An error occurred Player has no PB and therefore no Checkpoints | _player.Stats.PB[{style}].Checkpoint.Count = {_player.Stats.PB[style].Checkpoint.Count}");
+ #endif
+ }
+
+ // Calculate differences in PB (PB - Current)
+ if (pbTime != -1)
+ {
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> Starting PB difference calculation... (pbTime != -1)");
+ #endif
+ // Reset the string
+ strPbDifference = "";
+
+ // Calculate the time difference
+ if (pbTime - currentTime < 0.0)
+ {
+ strPbDifference += ChatColors.Red + "+" + FormatTime((pbTime - currentTime) * -1); // We multiply by -1 to get the positive value
+ }
+ else if (pbTime - currentTime >= 0.0)
+ {
+ strPbDifference += ChatColors.Green + "-" + FormatTime(pbTime - currentTime);
+ }
+ strPbDifference += ChatColors.Default + " ";
+
+ // Calculate the speed difference
+ if (pbSpeed - currentSpeed <= 0.0)
+ {
+ strPbDifference += "(" + ChatColors.Green + "+" + ((pbSpeed - currentSpeed) * -1).ToString("0"); // We multiply by -1 to get the positive value
+ }
+ else if (pbSpeed - currentSpeed > 0.0)
+ {
+ strPbDifference += "(" + ChatColors.Red + "-" + (pbSpeed - currentSpeed).ToString("0");
+ }
+ strPbDifference += ChatColors.Default + ")";
+ }
+
+ if (_player.CurrMap.WR[style].Ticks > 0)
+ {
+ // Calculate differences in WR (WR - Current)
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> Starting WR difference calculation... (_player.CurrMap.WR[{style}].Ticks > 0)");
+ #endif
+
+ wrTime = _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].Ticks;
+ wrSpeed = (float)Math.Sqrt(_player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelX * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelX
+ + _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelY * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelY
+ + _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ * _player.CurrMap.WR[style].Checkpoint[_player.Timer.Checkpoint].StartVelZ);
+ // Reset the string
+ strWrDifference = "";
+
+ // Calculate the WR time difference
+ if (wrTime - currentTime < 0.0)
+ {
+ strWrDifference += ChatColors.Red + "+" + FormatTime((wrTime - currentTime) * -1); // We multiply by -1 to get the positive value
+ }
+ else if (wrTime - currentTime >= 0.0)
+ {
+ strWrDifference += ChatColors.Green + "-" + FormatTime(wrTime - currentTime);
+ }
+ strWrDifference += ChatColors.Default + " ";
+
+ // Calculate the WR speed difference
+ if (wrSpeed - currentSpeed <= 0.0)
+ {
+ strWrDifference += "(" + ChatColors.Green + "+" + ((wrSpeed - currentSpeed) * -1).ToString("0"); // We multiply by -1 to get the positive value
+ }
+ else if (wrSpeed - currentSpeed > 0.0)
+ {
+ strWrDifference += "(" + ChatColors.Red + "-" + (wrSpeed - currentSpeed).ToString("0");
+ }
+ strWrDifference += ChatColors.Default + ")";
+ }
+
+ // Print checkpoint message
+ _player.Controller.PrintToChat(
+ $"{PluginPrefix} CP [{ChatColors.Yellow}{_player.Timer.Checkpoint}{ChatColors.Default}]: " +
+ $"{ChatColors.Yellow}{FormatTime(_player.Timer.Ticks)}{ChatColors.Default} " +
+ $"{ChatColors.Yellow}({currentSpeed.ToString("0")}){ChatColors.Default} " +
+ $"[PB: {strPbDifference} | " +
+ $"WR: {strWrDifference}]");
+
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] PB: {pbTime} - CURR: {currentTime} = pbTime: {pbTime - currentTime}");
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] PB: {pbSpeed} - CURR: {currentSpeed} = difference: {pbSpeed - currentSpeed}");
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [TIME] WR: {wrTime} - CURR: {currentTime} = difference: {wrTime - currentTime}");
+ Console.WriteLine($"CS2 Surf DEBUG >> DisplayCheckpointMessages -> [SPEED] WR: {wrSpeed} - CURR: {currentSpeed} = difference: {wrSpeed - currentSpeed}");
+ #endif
+ }
+}
diff --git a/src/ST-Player/PlayerProfile.cs b/src/ST-Player/PlayerProfile.cs
new file mode 100644
index 0000000..3904e35
--- /dev/null
+++ b/src/ST-Player/PlayerProfile.cs
@@ -0,0 +1,23 @@
+namespace SurfTimer;
+
+internal class PlayerProfile
+{
+ public int ID {get; set;} = 0;
+ public string Name {get; set;} = "";
+ public ulong SteamID {get; set;} = 0;
+ public string Country {get; set;} = "";
+ public int JoinDate {get; set;} = 0;
+ public int LastSeen {get; set;} = 0;
+ public int Connections {get; set;} = 0;
+
+ public PlayerProfile(int ID, string Name, ulong SteamID, string Country, int JoinDate, int LastSeen, int Connections)
+ {
+ this.ID = ID;
+ this.Name = Name;
+ this.SteamID = SteamID;
+ this.Country = Country;
+ this.JoinDate = JoinDate;
+ this.LastSeen = LastSeen;
+ this.Connections = Connections;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/PlayerStats/Checkpoint.cs b/src/ST-Player/PlayerStats/Checkpoint.cs
new file mode 100644
index 0000000..bf51165
--- /dev/null
+++ b/src/ST-Player/PlayerStats/Checkpoint.cs
@@ -0,0 +1,22 @@
+namespace SurfTimer;
+
+internal class Checkpoint : PersonalBest
+{
+ public int CP { get; set; }
+ public float EndTouch { get; set; }
+ public int Attempts { get; set; }
+
+ public Checkpoint(int cp, int ticks, float startVelX, float startVelY, float startVelZ, float endVelX, float endVelY, float endVelZ, float endTouch, int attempts)
+ {
+ CP = cp;
+ Ticks = ticks; // To-do: this was supposed to be the ticks but that is used for run_time for HUD????
+ StartVelX = startVelX;
+ StartVelY = startVelY;
+ StartVelZ = startVelZ;
+ EndVelX = endVelX;
+ EndVelY = endVelY;
+ EndVelZ = endVelZ;
+ EndTouch = endTouch;
+ Attempts = attempts;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/PlayerStats/CurrentRun.cs b/src/ST-Player/PlayerStats/CurrentRun.cs
new file mode 100644
index 0000000..4609dd1
--- /dev/null
+++ b/src/ST-Player/PlayerStats/CurrentRun.cs
@@ -0,0 +1,130 @@
+namespace SurfTimer;
+
+///
+/// This class stores data for the current run.
+///
+internal class CurrentRun
+{
+ public Dictionary Checkpoint { get; set; } // Current RUN checkpoints tracker
+ public int Ticks { get; set; } // To-do: will be the last (any) zone end touch time
+ public float StartVelX { get; set; } // This will store MAP START VELOCITY X
+ public float StartVelY { get; set; } // This will store MAP START VELOCITY Y
+ public float StartVelZ { get; set; } // This will store MAP START VELOCITY Z
+ public float EndVelX { get; set; } // This will store MAP END VELOCITY X
+ public float EndVelY { get; set; } // This will store MAP END VELOCITY Y
+ public float EndVelZ { get; set; } // This will store MAP END VELOCITY Z
+ public int RunDate { get; set; }
+ // Add other properties as needed
+
+ // Constructor
+ public CurrentRun()
+ {
+ Checkpoint = new Dictionary();
+ Ticks = 0;
+ StartVelX = 0.0f;
+ StartVelY = 0.0f;
+ StartVelZ = 0.0f;
+ EndVelX = 0.0f;
+ EndVelY = 0.0f;
+ EndVelZ = 0.0f;
+ RunDate = 0;
+ }
+
+ public void Reset()
+ {
+ Checkpoint.Clear();
+ Ticks = 0;
+ StartVelX = 0.0f;
+ StartVelY = 0.0f;
+ StartVelZ = 0.0f;
+ EndVelX = 0.0f;
+ EndVelY = 0.0f;
+ EndVelZ = 0.0f;
+ RunDate = 0;
+ // Reset other properties as needed
+ }
+
+ ///
+ /// Saves the player's run to the database and reloads the data for the player.
+ /// NOTE: Not re-loading any data at this point as we need `LoadMapTimesData` to be called from here as well, otherwise we may not have the `this.ID` populated
+ ///
+ public void SaveMapTime(Player player, TimerDatabase DB)
+ {
+ // Add entry in DB for the run
+ // To-do: add `type`
+ int style = player.Timer.Style;
+ string replay_frames = player.ReplayRecorder.SerializeReplay();
+ Task updatePlayerRunTask = DB.Write($@"
+ INSERT INTO `MapTimes`
+ (`player_id`, `map_id`, `style`, `type`, `stage`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`, `end_vel_x`, `end_vel_y`, `end_vel_z`, `run_date`, `replay_frames`)
+ VALUES ({player.Profile.ID}, {player.CurrMap.ID}, {style}, 0, 0, {player.Stats.ThisRun.Ticks},
+ {player.Stats.ThisRun.StartVelX}, {player.Stats.ThisRun.StartVelY}, {player.Stats.ThisRun.StartVelZ}, {player.Stats.ThisRun.EndVelX}, {player.Stats.ThisRun.EndVelY}, {player.Stats.ThisRun.EndVelZ}, {(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()}, '{replay_frames}')
+ ON DUPLICATE KEY UPDATE run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y),
+ start_vel_z=VALUES(start_vel_z), end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), run_date=VALUES(run_date), replay_frames=VALUES(replay_frames);
+ ");
+ if (updatePlayerRunTask.Result <= 0)
+ throw new Exception($"CS2 Surf ERROR >> internal class PersonalBest -> SaveMapTime -> Failed to insert/update player run in database. Player: {player.Profile.Name} ({player.Profile.SteamID})");
+ updatePlayerRunTask.Dispose();
+
+ // Will have to LoadMapTimesData right here as well to get the ID of the run we just inserted
+ // this.SaveCurrentRunCheckpoints(player, DB); // Save checkpoints for this run
+ // this.LoadCheckpointsForRun(DB); // Re-Load checkpoints for this run
+ }
+
+ ///
+ /// Saves the `CurrentRunCheckpoints` dictionary to the database
+ /// We need the correct `this.ID` to be populated before calling this method otherwise Query will fail
+ ///
+ public void SaveCurrentRunCheckpoints(Player player, TimerDatabase DB) // To-do: Transactions? Player sometimes rubberbands for a bit here
+ {
+ int style = player.Timer.Style;
+ // Loop through the checkpoints and insert/update them in the database for the run
+ foreach (var item in player.Stats.ThisRun.Checkpoint)
+ {
+ int cp = item.Key;
+ int ticks = item.Value!.Ticks;
+ int runTime = item.Value!.Ticks / 64; // Runtime in decimal
+ double startVelX = item.Value!.StartVelX;
+ double startVelY = item.Value!.StartVelY;
+ double startVelZ = item.Value!.StartVelZ;
+ double endVelX = item.Value!.EndVelX;
+ double endVelY = item.Value!.EndVelY;
+ double endVelZ = item.Value!.EndVelZ;
+ int attempts = item.Value!.Attempts;
+
+ #if DEBUG
+ Console.WriteLine($"CP: {cp} | MapTime ID: {player.Stats.PB[style].ID} | Time: {runTime} | Ticks: {ticks} | startVelX: {startVelX} | startVelY: {startVelY} | startVelZ: {startVelZ} | endVelX: {endVelX} | endVelY: {endVelY} | endVelZ: {endVelZ}");
+ Console.WriteLine($@"CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> SaveCurrentRunCheckpoints ->
+ INSERT INTO `Checkpoints`
+ (`maptime_id`, `cp`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`,
+ `end_vel_x`, `end_vel_y`, `end_vel_z`, `attempts`, `end_touch`)
+ VALUES ({player.Stats.PB[style].ID}, {cp}, {runTime}, {startVelX}, {startVelY}, {startVelZ}, {endVelX}, {endVelY}, {endVelZ}, {attempts}, {ticks}) ON DUPLICATE KEY UPDATE
+ run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), start_vel_z=VALUES(start_vel_z),
+ end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), attempts=VALUES(attempts), end_touch=VALUES(end_touch);
+ ");
+ #endif
+
+ // Insert/Update CPs to database
+ // To-do: Transactions?
+ // Check if the player has PB object initialized and if the player's character is currently active in the game
+ if (item.Value != null && player.Controller.PlayerPawn.Value != null)
+ {
+ Task newPbTask = DB.Write($@"
+ INSERT INTO `Checkpoints`
+ (`maptime_id`, `cp`, `run_time`, `start_vel_x`, `start_vel_y`, `start_vel_z`,
+ `end_vel_x`, `end_vel_y`, `end_vel_z`, `attempts`, `end_touch`)
+ VALUES ({player.Stats.PB[style].ID}, {cp}, {runTime}, {startVelX}, {startVelY}, {startVelZ}, {endVelX}, {endVelY}, {endVelZ}, {attempts}, {ticks})
+ ON DUPLICATE KEY UPDATE
+ run_time=VALUES(run_time), start_vel_x=VALUES(start_vel_x), start_vel_y=VALUES(start_vel_y), start_vel_z=VALUES(start_vel_z),
+ end_vel_x=VALUES(end_vel_x), end_vel_y=VALUES(end_vel_y), end_vel_z=VALUES(end_vel_z), attempts=VALUES(attempts), end_touch=VALUES(end_touch);
+ ");
+ if (newPbTask.Result <= 0)
+ throw new Exception($"CS2 Surf ERROR >> internal class Checkpoint : PersonalBest -> SaveCurrentRunCheckpoints -> Inserting Checkpoints. CP: {cp} | Name: {player.Profile.Name}");
+
+ newPbTask.Dispose();
+ }
+ }
+
+ player.Stats.ThisRun.Checkpoint.Clear();
+ }
+}
diff --git a/src/ST-Player/PlayerStats/PersonalBest.cs b/src/ST-Player/PlayerStats/PersonalBest.cs
new file mode 100644
index 0000000..2ee50bc
--- /dev/null
+++ b/src/ST-Player/PlayerStats/PersonalBest.cs
@@ -0,0 +1,36 @@
+namespace SurfTimer;
+
+// To-do: make Style (currently 0) be dynamic
+// To-do: add `Type`
+internal class PersonalBest
+{
+ public int ID { get; set; } = -1; // Exclude from constructor, retrieve from Database when loading/saving
+ public int Ticks { get; set; }
+ public int Rank { get; set; } = -1; // Exclude from constructor, retrieve from Database when loading/saving
+ public Dictionary Checkpoint { get; set; }
+ // public int Type { get; set; }
+ public float StartVelX { get; set; }
+ public float StartVelY { get; set; }
+ public float StartVelZ { get; set; }
+ public float EndVelX { get; set; }
+ public float EndVelY { get; set; }
+ public float EndVelZ { get; set; }
+ public int RunDate { get; set; }
+ public string Name { get; set; } = ""; // This is used only for WRs
+ // Add other properties as needed
+
+ // Constructor
+ public PersonalBest()
+ {
+ Ticks = -1; // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int???
+ Checkpoint = new Dictionary();
+ // Type = type;
+ StartVelX = -1.0f;
+ StartVelY = -1.0f;
+ StartVelZ = -1.0f;
+ EndVelX = -1.0f;
+ EndVelY = -1.0f;
+ EndVelZ = -1.0f;
+ RunDate = 0;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/PlayerStats/PlayerStats.cs b/src/ST-Player/PlayerStats/PlayerStats.cs
new file mode 100644
index 0000000..48eed71
--- /dev/null
+++ b/src/ST-Player/PlayerStats/PlayerStats.cs
@@ -0,0 +1,150 @@
+using MySqlConnector;
+
+namespace SurfTimer;
+
+internal class PlayerStats
+{
+ // To-Do: Each stat should be a class of its own, with its own methods and properties - easier to work with.
+ // Temporarily, we store ticks + basic info so we can experiment
+ // These account for future style support and a relevant index.
+ public int[,] StagePB { get; set; } = { { 0, 0 } }; // First dimension: style (0 = normal), second dimension: stage index
+ public int[,] StageRank { get; set; } = { { 0, 0 } }; // First dimension: style (0 = normal), second dimension: stage index
+ //
+
+ public Dictionary PB { get; set; } = new Dictionary();
+ public CurrentRun ThisRun { get; set; } = new CurrentRun(); // This is a CurrenntRun object that tracks the data for the Player's current run
+ // Initialize PersonalBest for each `style` (e.g., 0 for normal) - this is a temporary solution
+ // Here we can loop through all available styles at some point and initialize them
+ public PlayerStats()
+ {
+ PB[0] = new PersonalBest();
+ // Add more styles as needed
+ }
+
+ ///
+ /// Loads the player's MapTimes data from the database along with `Rank` for the run.
+ /// `Checkpoints` are loaded separately because inside the while loop we cannot run queries.
+ /// This can populate all the `style` stats the player has for the map - currently only 1 style is supported
+ ///
+ public void LoadMapTimesData(Player player, TimerDatabase DB, int playerId = 0, int mapId = 0)
+ {
+ Task dbTask2 = DB.Query($@"
+ SELECT mainquery.*, (SELECT COUNT(*) FROM `MapTimes` AS subquery
+ WHERE subquery.`map_id` = mainquery.`map_id` AND subquery.`style` = mainquery.`style`
+ AND subquery.`run_time` <= mainquery.`run_time`) AS `rank` FROM `MapTimes` AS mainquery
+ WHERE mainquery.`player_id` = {player.Profile.ID} AND mainquery.`map_id` = {player.CurrMap.ID};
+ ");
+ MySqlDataReader playerStats = dbTask2.Result;
+ int style = 0; // To-do: implement styles
+ if (!playerStats.HasRows)
+ {
+ Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadMapTimesData -> No MapTimes data found for Player.");
+ }
+ else
+ {
+ while (playerStats.Read())
+ {
+ // Load data into PersonalBest object
+ // style = playerStats.GetInt32("style"); // Uncomment when style is implemented
+ PB[style].ID = playerStats.GetInt32("id");
+ PB[style].StartVelX = (float)playerStats.GetDouble("start_vel_x");
+ PB[style].StartVelY = (float)playerStats.GetDouble("start_vel_y");
+ PB[style].StartVelZ = (float)playerStats.GetDouble("start_vel_z");
+ PB[style].EndVelX = (float)playerStats.GetDouble("end_vel_x");
+ PB[style].EndVelY = (float)playerStats.GetDouble("end_vel_y");
+ PB[style].EndVelZ = (float)playerStats.GetDouble("end_vel_z");
+ PB[style].Ticks = playerStats.GetInt32("run_time");
+ PB[style].RunDate = playerStats.GetInt32("run_date");
+ PB[style].Rank = playerStats.GetInt32("rank");
+
+ Console.WriteLine($"============== CS2 Surf DEBUG >> LoadMapTimesData -> PlayerID: {player.Profile.ID} | Rank: {PB[style].Rank} | ID: {PB[style].ID} | RunTime: {PB[style].Ticks} | SVX: {PB[style].StartVelX} | SVY: {PB[style].StartVelY} | SVZ: {PB[style].StartVelZ} | EVX: {PB[style].EndVelX} | EVY: {PB[style].EndVelY} | EVZ: {PB[style].EndVelZ} | Run Date (UNIX): {PB[style].RunDate}");
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadMapTimesData -> PlayerStats.PB (ID {PB[style].ID}) loaded from DB.");
+ #endif
+ }
+ }
+ playerStats.Close();
+ }
+
+ ///
+ /// Executes the DB query to get all the checkpoints and store them in the Checkpoint dictionary
+ ///
+ public void LoadCheckpointsData(TimerDatabase DB)
+ {
+ Task dbTask = DB.Query($"SELECT * FROM `Checkpoints` WHERE `maptime_id` = {PB[0].ID};");
+ MySqlDataReader results = dbTask.Result;
+ if (PB[0] == null)
+ {
+ #if DEBUG
+ Console.WriteLine("CS2 Surf ERROR >> internal class PlayerStats -> LoadCheckpointsData -> PersonalBest object is null.");
+ #endif
+
+ results.Close();
+ return;
+ }
+
+ if (PB[0].Checkpoint == null)
+ {
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerStats -> LoadCheckpointsData -> PB Checkpoints list is not initialized.");
+ #endif
+
+ PB[0].Checkpoint = new Dictionary(); // Initialize if null
+ }
+
+ #if DEBUG
+ Console.WriteLine($"this.Checkpoint.Count {PB[0].Checkpoint.Count} ");
+ Console.WriteLine($"this.ID {PB[0].ID} ");
+ Console.WriteLine($"this.Ticks {PB[0].Ticks} ");
+ Console.WriteLine($"this.RunDate {PB[0].RunDate} ");
+ #endif
+
+ if (!results.HasRows)
+ {
+ #if DEBUG
+ Console.WriteLine($"CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> No checkpoints found for this mapTimeId {PB[0].ID}.");
+ #endif
+
+ results.Close();
+ return;
+ }
+
+ #if DEBUG
+ Console.WriteLine($"======== CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Checkpoints found for this mapTimeId");
+ #endif
+
+ while (results.Read())
+ {
+ #if DEBUG
+ Console.WriteLine($"cp {results.GetInt32("cp")} ");
+ Console.WriteLine($"run_time {results.GetFloat("run_time")} ");
+ Console.WriteLine($"sVelX {results.GetFloat("start_vel_x")} ");
+ Console.WriteLine($"sVelY {results.GetFloat("start_vel_y")} ");
+ #endif
+
+ Checkpoint cp = new(results.GetInt32("cp"),
+ results.GetInt32("run_time"), // To-do: what type of value we use here? DB uses DECIMAL but `.Tick` is int???
+ results.GetFloat("start_vel_x"),
+ results.GetFloat("start_vel_y"),
+ results.GetFloat("start_vel_z"),
+ results.GetFloat("end_vel_x"),
+ results.GetFloat("end_vel_y"),
+ results.GetFloat("end_vel_z"),
+ results.GetFloat("end_touch"),
+ results.GetInt32("attempts"));
+ cp.ID = results.GetInt32("cp");
+ // To-do: cp.ID = calculate Rank # from DB
+
+ PB[0].Checkpoint[cp.CP] = cp;
+
+ #if DEBUG
+ Console.WriteLine($"======= CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Loaded CP {cp.CP} with RunTime {cp.Ticks}.");
+ #endif
+ }
+ results.Close();
+
+ #if DEBUG
+ Console.WriteLine($"======= CS2 Surf DEBUG >> internal class Checkpoint : PersonalBest -> LoadCheckpointsData -> Checkpoints loaded from DB. Count: {PB[0].Checkpoint.Count}");
+ #endif
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/PlayerTimer.cs b/src/ST-Player/PlayerTimer.cs
new file mode 100644
index 0000000..4d7da97
--- /dev/null
+++ b/src/ST-Player/PlayerTimer.cs
@@ -0,0 +1,71 @@
+namespace SurfTimer;
+
+internal class PlayerTimer
+{
+ // Status
+ public bool IsEnabled { get; set; } = true; // Enable toggle for entire timer
+ public bool IsPaused { get; set; } = false; // Pause toggle for timer
+ public bool IsRunning { get; set; } = false; // Is the timer currently running?
+
+ // Modes
+ public bool IsPracticeMode { get; set; } = false; // Practice mode toggle
+ public bool IsStageMode { get; set; } = false; // Stage mode toggle
+
+ // Tracking
+ public int Stage { get; set; } = 0; // Current stage tracker
+ public int Checkpoint {get; set;} = 0; // Current checkpoint tracker
+ public CurrentRun CurrentRunData { get; set; } = new CurrentRun(); // Current RUN data tracker
+ public int Bonus { get; set; } = 0; // To-do: bonus implementation - Current bonus tracker
+ public int Style { get; set; } = 0; // To-do: functionality for player to change this value and the actual styles implementation - Current style tracker
+
+ // Timing
+ public int Ticks { get; set; } = 0; // To-do: sub-tick counting? This currently goes on OnTick, which is not sub-tick I believe? Needs investigating
+
+ // Time Formatting
+ public enum TimeFormatStyle
+ {
+ Compact,
+ Full,
+ Verbose
+ }
+
+ // Methods
+ public void Reset()
+ {
+ this.Stop();
+ this.Ticks = 0;
+ this.Stage = 0;
+ this.Checkpoint = 0;
+ this.IsPaused = false;
+ this.IsPracticeMode = false;
+ this.CurrentRunData.Reset();
+ }
+
+ public void Pause()
+ {
+ this.IsPaused = true;
+ }
+
+ public void Start()
+ {
+ // Timer Start method - notes: OnStartTimerPress
+ if (this.IsEnabled)
+ this.IsRunning = true;
+ }
+
+ public void Stop()
+ {
+ // Timer Stop method - notes: OnStopTimerPress
+ this.IsRunning = false;
+ }
+
+ public void Tick()
+ {
+ // Tick the timer - this checks for any restrictions, so can be conveniently called from anywhere
+ // without worry for any timing restrictions (eg: Paused, Enabled, etc)
+ if (this.IsPaused || !this.IsEnabled || !this.IsRunning)
+ return;
+
+ this.Ticks++;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/Replay/ReplayFrame.cs b/src/ST-Player/Replay/ReplayFrame.cs
new file mode 100644
index 0000000..f67de67
--- /dev/null
+++ b/src/ST-Player/Replay/ReplayFrame.cs
@@ -0,0 +1,24 @@
+namespace SurfTimer;
+using CounterStrikeSharp.API.Modules.Utils;
+using CounterStrikeSharp.API.Core;
+
+internal enum ReplayFrameSituation
+{
+ NONE,
+ START_RUN,
+ END_RUN,
+ TOUCH_CHECKPOINT,
+ START_STAGE,
+ END_STAGE
+}
+
+[Serializable]
+internal class ReplayFrame
+{
+ public Vector Pos { get; set; } = new Vector(0, 0, 0);
+ public QAngle Ang { get; set; } = new QAngle(0, 0, 0);
+ public uint Situation { get; set; } = (uint)ReplayFrameSituation.NONE;
+ public ulong Button { get; set; }
+ public uint Flags { get; set; }
+ public MoveType_t MoveType { get; set; }
+}
diff --git a/src/ST-Player/Replay/ReplayPlayer.cs b/src/ST-Player/Replay/ReplayPlayer.cs
new file mode 100644
index 0000000..98a44c4
--- /dev/null
+++ b/src/ST-Player/Replay/ReplayPlayer.cs
@@ -0,0 +1,200 @@
+using System.Text;
+using System.Text.Json;
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Modules.Utils;
+using MySqlConnector;
+
+namespace SurfTimer;
+
+internal class ReplayPlayer
+{
+ public bool IsPlaying { get; set; } = false;
+ public bool IsPaused { get; set; } = false;
+ public bool IsPlayable { get; set; } = false;
+
+ // Tracking for replay counting
+ public int RepeatCount { get; set; } = -1;
+
+ // Stats for replay displaying
+ public string Stat_Prefix { get; set; } = "WR";
+ public string Stat_PlayerName { get; set; } = "N/A";
+ public int Stat_MapTimeID { get; set; } = -1;
+ public int Stat_RunTime { get; set; } = 0;
+ public bool Stat_IsRunning { get; set; } = false;
+ public int Stat_RunTick { get; set; } = 0;
+
+ // Tracking
+ public List Frames { get; set; } = new List();
+
+ // Playing
+ public int CurrentFrameTick { get; set; } = 0;
+ public int FrameTickIncrement { get; set; } = 1;
+
+ public CCSPlayerController? Controller { get; set; }
+
+ public void ResetReplay()
+ {
+ this.CurrentFrameTick = 0;
+ this.FrameTickIncrement = 1;
+ if(this.RepeatCount > 0)
+ this.RepeatCount--;
+
+ this.Stat_IsRunning = false;
+ this.Stat_RunTick = 0;
+ }
+
+ public void Reset()
+ {
+ this.IsPlaying = false;
+ this.IsPaused = false;
+ this.IsPlayable = false;
+ this.RepeatCount = -1;
+
+ this.Frames.Clear();
+
+ this.ResetReplay();
+
+ this.Controller = null;
+ }
+
+ public void SetController(CCSPlayerController c, int repeat_count = -1)
+ {
+ this.Controller = c;
+ this.RepeatCount = repeat_count;
+ this.IsPlayable = true;
+ }
+
+ public void Start()
+ {
+ if (!this.IsPlayable)
+ return;
+
+ this.IsPlaying = true;
+ }
+
+ public void Stop()
+ {
+ this.IsPlaying = false;
+ }
+
+ public void Pause()
+ {
+ if (!this.IsPlaying)
+ return;
+
+ this.IsPaused = !this.IsPaused;
+ this.Stat_IsRunning = !this.Stat_IsRunning;
+ }
+
+ public void Tick()
+ {
+ if (!this.IsPlaying || !this.IsPlayable || this.Frames.Count == 0)
+ return;
+
+ ReplayFrame current_frame = this.Frames[this.CurrentFrameTick];
+
+ // SOME BLASHPEMY FOR YOU
+ if (this.FrameTickIncrement >= 0)
+ {
+ if (current_frame.Situation == (uint)ReplayFrameSituation.START_RUN)
+ {
+ this.Stat_IsRunning = true;
+ this.Stat_RunTick = 0;
+ }
+ else if (current_frame.Situation == (uint)ReplayFrameSituation.END_RUN)
+ {
+ this.Stat_IsRunning = false;
+ }
+ }
+ else
+ {
+ if (current_frame.Situation == (uint)ReplayFrameSituation.START_RUN)
+ {
+ this.Stat_IsRunning = false;
+ }
+ else if (current_frame.Situation == (uint)ReplayFrameSituation.END_RUN)
+ {
+ this.Stat_IsRunning = true;
+ this.Stat_RunTick = this.CurrentFrameTick - (64*2); // (64*2) counts for the 2 seconds before run actually starts
+ }
+ }
+ // END OF BLASPHEMY
+
+ var current_pos = this.Controller!.PlayerPawn.Value!.AbsOrigin!;
+
+ bool is_on_ground = (current_frame.Flags & (uint)PlayerFlags.FL_ONGROUND) != 0;
+
+ Vector velocity = (current_frame.Pos - current_pos) * 64;
+
+ if (is_on_ground)
+ this.Controller.PlayerPawn.Value.MoveType = MoveType_t.MOVETYPE_WALK;
+ else
+ this.Controller.PlayerPawn.Value.MoveType = MoveType_t.MOVETYPE_NOCLIP;
+
+ if ((current_pos - current_frame.Pos).Length() > 200)
+ this.Controller.PlayerPawn.Value.Teleport(current_frame.Pos, current_frame.Ang, new Vector(nint.Zero));
+ else
+ this.Controller.PlayerPawn.Value.Teleport(new Vector(nint.Zero), current_frame.Ang, velocity);
+
+
+ if (!this.IsPaused)
+ {
+ this.CurrentFrameTick = Math.Max(0, this.CurrentFrameTick + this.FrameTickIncrement);
+ if (this.Stat_IsRunning)
+ this.Stat_RunTick = Math.Max(0, this.Stat_RunTick + this.FrameTickIncrement);
+ }
+
+ if(this.CurrentFrameTick >= this.Frames.Count)
+ this.ResetReplay();
+ }
+
+ public void LoadReplayData(TimerDatabase DB)
+ {
+ if (!this.IsPlayable)
+ return;
+
+ Task dbTask = DB.Query($@"
+ SELECT MapTimes.replay_frames, MapTimes.run_time, Player.name
+ FROM MapTimes
+ JOIN Player ON MapTimes.player_id = Player.id
+ WHERE MapTimes.id={this.Stat_MapTimeID}
+ ");
+
+ MySqlDataReader mapTimeReplay = dbTask.Result;
+ if(!mapTimeReplay.HasRows)
+ {
+ Console.WriteLine($"CS2 Surf DEBUG >> internal class PlayerReplay -> Load -> No replay data found for Player.");
+ }
+ else
+ {
+ JsonSerializerOptions options = new JsonSerializerOptions {WriteIndented = false, Converters = { new VectorConverter(), new QAngleConverter() }};
+ while(mapTimeReplay.Read())
+ {
+ string json = Compressor.Decompress(Encoding.UTF8.GetString((byte[])mapTimeReplay[0]));
+ this.Frames = JsonSerializer.Deserialize>(json, options)!;
+
+ this.Stat_RunTime = mapTimeReplay.GetInt32("run_time");
+ this.Stat_PlayerName = mapTimeReplay.GetString("name");
+ }
+ FormatBotName();
+ }
+ mapTimeReplay.Close();
+ dbTask.Dispose();
+ }
+
+ private void FormatBotName()
+ {
+ if (!this.IsPlayable)
+ return;
+
+ SchemaString bot_name = new SchemaString(this.Controller!, "m_iszPlayerName");
+
+ string replay_name = $"[{this.Stat_Prefix}] {this.Stat_PlayerName} | {PlayerHUD.FormatTime(this.Stat_RunTime)}";
+ if(this.Stat_RunTime <= 0)
+ replay_name = $"[{this.Stat_Prefix}] {this.Stat_PlayerName}";
+
+ bot_name.Set(replay_name);
+ Utilities.SetStateChanged(this.Controller!, "CBasePlayerController", "m_iszPlayerName");
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/Replay/ReplayRecorder.cs b/src/ST-Player/Replay/ReplayRecorder.cs
new file mode 100644
index 0000000..fd20ed5
--- /dev/null
+++ b/src/ST-Player/Replay/ReplayRecorder.cs
@@ -0,0 +1,69 @@
+using System.Text.Json;
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+internal class ReplayRecorder
+{
+ public bool IsRecording { get; set; } = false;
+ public ReplayFrameSituation CurrentSituation { get; set; } = ReplayFrameSituation.NONE;
+ public List Frames { get; set; } = new List();
+
+ public void Reset()
+ {
+ this.IsRecording = false;
+ this.Frames.Clear();
+ }
+
+ public void Start()
+ {
+ this.IsRecording = true;
+ }
+
+ public void Stop()
+ {
+ this.IsRecording = false;
+ }
+
+ public void Tick(Player player)
+ {
+ if (!this.IsRecording || player == null)
+ return;
+
+ // Disabeling Recording if timer disabled
+ if (!player.Timer.IsEnabled)
+ {
+ this.Stop();
+ this.Reset();
+ return;
+ }
+
+ var player_pos = player.Controller.Pawn.Value!.AbsOrigin!;
+ var player_angle = player.Controller.PlayerPawn.Value!.EyeAngles;
+ var player_button = player.Controller.Pawn.Value.MovementServices!.Buttons.ButtonStates[0];
+ var player_flags = player.Controller.Pawn.Value.Flags;
+ var player_move_type = player.Controller.Pawn.Value.MoveType;
+
+ var frame = new ReplayFrame
+ {
+ Pos = new Vector(player_pos.X, player_pos.Y, player_pos.Z),
+ Ang = new QAngle(player_angle.X, player_angle.Y, player_angle.Z),
+ Situation = (uint)this.CurrentSituation,
+ Button = player_button,
+ Flags = player_flags,
+ MoveType = player_move_type,
+ };
+
+ this.Frames.Add(frame);
+
+ // Every Situation should last for at most, 1 tick
+ this.CurrentSituation = ReplayFrameSituation.NONE;
+ }
+
+ public string SerializeReplay()
+ {
+ JsonSerializerOptions options = new JsonSerializerOptions {WriteIndented = false, Converters = { new VectorConverter(), new QAngleConverter() }};
+ string replay_frames = JsonSerializer.Serialize(Frames, options);
+ return Compressor.Compress(replay_frames);
+ }
+}
\ No newline at end of file
diff --git a/src/ST-Player/Saveloc/SavelocFrame.cs b/src/ST-Player/Saveloc/SavelocFrame.cs
new file mode 100644
index 0000000..a6bcd4c
--- /dev/null
+++ b/src/ST-Player/Saveloc/SavelocFrame.cs
@@ -0,0 +1,11 @@
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+internal class SavelocFrame
+{
+ public Vector Pos { get; set; } = new Vector(0, 0, 0);
+ public QAngle Ang { get; set; } = new QAngle(0, 0, 0);
+ public Vector Vel { get; set; } = new Vector(0, 0, 0);
+ public int Tick { get; set; } = 0;
+}
diff --git a/src/ST-UTILS/Compression.cs b/src/ST-UTILS/Compression.cs
new file mode 100644
index 0000000..25ff2b2
--- /dev/null
+++ b/src/ST-UTILS/Compression.cs
@@ -0,0 +1,161 @@
+using System.IO.Compression;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+internal class VectorConverter : JsonConverter
+{
+ public override Vector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ // Ensure that the reader is positioned at the start of an object
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException("Expected start of object.");
+
+ float x = 0, y = 0, z = 0;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType == JsonTokenType.PropertyName)
+ {
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ switch (propertyName)
+ {
+ case "X":
+ x = (float)reader.GetDouble();
+ break;
+ case "Y":
+ y = (float)reader.GetDouble();
+ break;
+ case "Z":
+ z = (float)reader.GetDouble();
+ break;
+ }
+ }
+ }
+
+ return new Vector { X = x, Y = y, Z = z };
+ }
+
+ public override void Write(Utf8JsonWriter writer, Vector value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("X", value.X);
+ writer.WriteNumber("Y", value.Y);
+ writer.WriteNumber("Z", value.Z);
+ writer.WriteEndObject();
+ }
+}
+
+internal class QAngleConverter : JsonConverter
+{
+ public override QAngle Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ // Ensure that the reader is positioned at the start of an object
+ if (reader.TokenType != JsonTokenType.StartObject)
+ throw new JsonException("Expected start of object.");
+
+ float X = 0, Y = 0, Z = 0;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ break;
+
+ if (reader.TokenType == JsonTokenType.PropertyName)
+ {
+ string propertyName = reader.GetString()!;
+ reader.Read();
+
+ switch (propertyName)
+ {
+ case "X":
+ X = (float)reader.GetDouble();
+ break;
+ case "Y":
+ Y = (float)reader.GetDouble();
+ break;
+ case "Z":
+ Z = (float)reader.GetDouble();
+ break;
+ }
+ }
+ }
+
+ return new QAngle { X = X, Y = Y, Z = Z };
+ }
+
+ public override void Write(Utf8JsonWriter writer, QAngle value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("X", value.X);
+ writer.WriteNumber("Y", value.Y);
+ writer.WriteNumber("Z", value.Z);
+ writer.WriteEndObject();
+ }
+}
+
+internal class Compressor
+{
+ public static string Decompress(string input)
+ {
+ byte[] compressed = Convert.FromBase64String(input);
+ byte[] decompressed = Decompress(compressed);
+ return Encoding.UTF8.GetString(decompressed);
+ }
+
+ public static string Compress(string input)
+ {
+ byte[] encoded = Encoding.UTF8.GetBytes(input);
+ byte[] compressed = Compress(encoded);
+ return Convert.ToBase64String(compressed);
+ }
+
+ public static byte[] Decompress(byte[] input)
+ {
+ using (var source = new MemoryStream(input))
+ {
+ byte[] lengthBytes = new byte[4];
+ source.Read(lengthBytes, 0, 4);
+
+ var length = BitConverter.ToInt32(lengthBytes, 0);
+ using (var decompressionStream = new GZipStream(source,
+ CompressionMode.Decompress))
+ {
+ var result = new byte[length];
+ int totalRead = 0, bytesRead;
+ while ((bytesRead = decompressionStream.Read(result, totalRead, length - totalRead)) > 0)
+ {
+ totalRead += bytesRead;
+ }
+
+ return result;
+ }
+ }
+ }
+
+ public static byte[] Compress(byte[] input)
+ {
+ using (var result = new MemoryStream())
+ {
+ var lengthBytes = BitConverter.GetBytes(input.Length);
+ result.Write(lengthBytes, 0, 4);
+
+ using (var compressionStream = new GZipStream(result,
+ CompressionMode.Compress))
+ {
+ compressionStream.Write(input, 0, input.Length);
+ compressionStream.Flush();
+
+ }
+ return result.ToArray();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ST-UTILS/ConVar.cs b/src/ST-UTILS/ConVar.cs
new file mode 100644
index 0000000..55206ba
--- /dev/null
+++ b/src/ST-UTILS/ConVar.cs
@@ -0,0 +1,15 @@
+using CounterStrikeSharp.API.Modules.Cvars;
+
+namespace SurfTimer;
+
+internal class ConVarHelper
+{
+ public static void RemoveCheatFlagFromConVar(string cv_name)
+ {
+ ConVar? cv = ConVar.Find(cv_name);
+ if (cv == null || (cv.Flags & CounterStrikeSharp.API.ConVarFlags.FCVAR_CHEAT) == 0)
+ return;
+
+ cv.Flags &= ~CounterStrikeSharp.API.ConVarFlags.FCVAR_CHEAT;
+ }
+}
\ No newline at end of file
diff --git a/src/ST-UTILS/Schema.cs b/src/ST-UTILS/Schema.cs
new file mode 100644
index 0000000..ba324df
--- /dev/null
+++ b/src/ST-UTILS/Schema.cs
@@ -0,0 +1,30 @@
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Modules.Memory;
+
+using System.Runtime.CompilerServices;
+using System.Text;
+
+namespace SurfTimer;
+public class SchemaString : NativeObject where SchemaClass : NativeObject
+{
+ public SchemaString(SchemaClass instance, string member)
+ : base(Schema.GetSchemaValue(instance.Handle, typeof(SchemaClass).Name!, member))
+ { }
+
+ public unsafe void Set(string str)
+ {
+ byte[] bytes = this.GetStringBytes(str);
+
+ for (int i = 0; i < bytes.Length; i++)
+ {
+ Unsafe.Write((void*)(this.Handle.ToInt64() + i), bytes[i]);
+ }
+
+ Unsafe.Write((void*)(this.Handle.ToInt64() + bytes.Length), 0);
+ }
+
+ private byte[] GetStringBytes(string str)
+ {
+ return Encoding.UTF8.GetBytes(str);
+ }
+}
\ No newline at end of file
diff --git a/src/SurfTimer.cs b/src/SurfTimer.cs
new file mode 100644
index 0000000..846a225
--- /dev/null
+++ b/src/SurfTimer.cs
@@ -0,0 +1,130 @@
+/*
+ ___ _____ _________ ___
+ ___ / _/ |/ / __/ _ \/ _ |
+ ___ _/ // / _// , _/ __ |
+ ___ /___/_/|_/_/ /_/|_/_/ |_|
+
+ Official Timer plugin for the CS2 Surf Initiative.
+ Copyright (C) 2024 Liam C. (Infra)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+ Source: https://github.com/CS2Surf/Timer
+*/
+
+#define DEBUG
+
+using System.Text.Json;
+using CounterStrikeSharp.API;
+using CounterStrikeSharp.API.Core;
+using CounterStrikeSharp.API.Core.Attributes;
+using CounterStrikeSharp.API.Core.Attributes.Registration;
+using CounterStrikeSharp.API.Modules.Memory;
+using CounterStrikeSharp.API.Modules.Utils;
+
+namespace SurfTimer;
+
+// Gameplan: https://github.com/CS2Surf/Timer/tree/dev/README.md
+[MinimumApiVersion(120)]
+public partial class SurfTimer : BasePlugin
+{
+ // Metadata
+ public override string ModuleName => "CS2 SurfTimer";
+ public override string ModuleVersion => "DEV-1";
+ public override string ModuleDescription => "Official Surf Timer by the CS2 Surf Initiative.";
+ public override string ModuleAuthor => "The CS2 Surf Initiative - github.com/cs2surf";
+ public string PluginPrefix => $"[{ChatColors.DarkBlue}CS2 Surf{ChatColors.Default}]"; // To-do: make configurable
+
+ // Globals
+ private Dictionary playerList = new Dictionary(); // This can probably be done way better, revisit
+ internal TimerDatabase? DB = new TimerDatabase();
+ public string PluginPath = Server.GameDirectory + "/csgo/addons/counterstrikesharp/plugins/SurfTimer/";
+ internal Map CurrentMap = null!;
+
+ /* ========== MAP START HOOKS ========== */
+ public void OnMapStart(string mapName)
+ {
+ // Initialise Map Object
+ // To-do: It seems like players connect very quickly and sometimes `CurrentMap` is null when it shouldn't be, lowered the timer ot 1.0 seconds for now
+ if ((CurrentMap == null || CurrentMap.Name != mapName) && mapName.Contains("surf_"))
+ {
+ AddTimer(1.0f, () => CurrentMap = new Map(mapName, DB!)); // Was 3 seconds, now 1 second
+ }
+ }
+
+ public void OnMapEnd()
+ {
+ // Clear/reset stuff here
+ CurrentMap = null!;
+ playerList.Clear();
+ }
+
+ [GameEventHandler]
+ public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info)
+ {
+ // Load cvars/other configs here
+ // Execute server_settings.cfg
+
+ ConVarHelper.RemoveCheatFlagFromConVar("bot_stop");
+ ConVarHelper.RemoveCheatFlagFromConVar("bot_freeze");
+ ConVarHelper.RemoveCheatFlagFromConVar("bot_zombie");
+
+ Server.ExecuteCommand("execifexists SurfTimer/server_settings.cfg");
+ Console.WriteLine("[CS2 Surf] Executed configuration: server_settings.cfg");
+ return HookResult.Continue;
+ }
+
+ /* ========== PLUGIN LOAD ========== */
+ public override void Load(bool hotReload)
+ {
+ // Load database config & spawn database object
+ try
+ {
+ JsonElement dbConfig = JsonDocument.Parse(File.ReadAllText(Server.GameDirectory + "/csgo/cfg/SurfTimer/database.json")).RootElement;
+ DB = new TimerDatabase(dbConfig.GetProperty("host").GetString()!,
+ dbConfig.GetProperty("database").GetString()!,
+ dbConfig.GetProperty("user").GetString()!,
+ dbConfig.GetProperty("password").GetString()!,
+ dbConfig.GetProperty("port").GetInt32(),
+ dbConfig.GetProperty("timeout").GetInt32());
+ Console.WriteLine("[CS2 Surf] Database connection established.");
+ }
+
+ catch (Exception e)
+ {
+ Console.WriteLine($"[CS2 Surf] Error loading database config: {e.Message}");
+ // To-do: Abort plugin loading
+ }
+
+ Console.WriteLine(String.Format(" ____________ ____ ___\n"
+ + " / ___/ __/_ | / __/_ ______/ _/\n"
+ + "/ /___\\ \\/ __/ _\\ \\/ // / __/ _/ \n"
+ + "\\___/___/____/ /___/\\_,_/_/ /_/\n"
+ + $"[CS2 Surf] SurfTimer plugin loaded. Version: {ModuleVersion}"
+ + $"[CS2 Surf] This plugin is licensed under the GNU Affero General Public License v3.0. See LICENSE for more information. Source code: https://github.com/CS2Surf/Timer\n"
+ ));
+
+ // Map Start Hook
+ RegisterListener(OnMapStart);
+ // Map End Hook
+ RegisterListener(OnMapEnd);
+ // Tick listener
+ RegisterListener(OnTick);
+
+ // StartTouch Hook
+ VirtualFunctions.CBaseTrigger_StartTouchFunc.Hook(OnTriggerStartTouch, HookMode.Post);
+ // EndTouch Hook
+ VirtualFunctions.CBaseTrigger_EndTouchFunc.Hook(OnTriggerEndTouch, HookMode.Post);
+ }
+}
diff --git a/src/SurfTimer.csproj b/src/SurfTimer.csproj
index 40ec78f..173f3e3 100644
--- a/src/SurfTimer.csproj
+++ b/src/SurfTimer.csproj
@@ -4,12 +4,18 @@
net7.0
enable
enable
+ true
+
+
+
+
+ DEBUG
-
- INSERT_CSSHARP_DLL_PATH_HERE
-
+
+
+