diff --git a/.github/workflows/build_application.yml b/.github/workflows/build_application.yml
index 635150033..94f783fd1 100644
--- a/.github/workflows/build_application.yml
+++ b/.github/workflows/build_application.yml
@@ -2,7 +2,7 @@ name: Application build
on:
push:
- branches: [ develop, release/pre, master ]
+ branches: [ develop, release/pre, master, feature/zombie-stats ]
paths:
- Application/**
- WebfrontCore/**
@@ -217,6 +217,9 @@ jobs:
mkdir -p ${{ env.outputFolder }}/wwwroot/js/user
cp ${{ github.workspace }}/WebfrontCore/wwwroot/js/app.min.js ${{ env.outputFolder }}/wwwroot/js/app.min.js
cp ${{ github.workspace }}/WebfrontCore/wwwroot/js/user/user.js ${{ env.outputFolder }}/wwwroot/js/user/user.js
+ # zombie-scrubber.js is loaded standalone (kept out of the main bundle
+ # for cache isolation — UI changes ship without invalidating app.min.js).
+ cp ${{ github.workspace }}/WebfrontCore/wwwroot/js/zombie-scrubber.js ${{ env.outputFolder }}/wwwroot/js/zombie-scrubber.js
mkdir -p ${{ env.outputFolder }}/wwwroot/font
mkdir -p ${{ env.outputFolder }}/wwwroot/font/phosphor
rsync -ar ${{ github.workspace }}/WebfrontCore/wwwroot/font/phosphor/ ${{ env.outputFolder }}/wwwroot/font/phosphor/
@@ -235,7 +238,7 @@ jobs:
build_and_push_docker_dev:
runs-on: ubuntu-latest
needs: [ make_version, build ]
- if: ${{ github.ref == 'refs/heads/develop' }}
+ if: ${{ github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/feature/zombie-stats' }}
permissions:
contents: read
packages: write
@@ -270,10 +273,10 @@ jobs:
with:
images: ghcr.io/${{ github.repository_owner }}/iw4madmin
tags: |
- # push to develop branch: tags as 'develop'
- type=raw,value=develop
- # tag with the build number and -dev suffix (e.g., 2025.8.13.1-dev)
- type=raw,value=${{ env.buildNumber }}-dev
+ # branch name as tag (e.g., 'develop' or 'zombie-stats')
+ type=ref,event=branch
+ # tag with the build number and branch suffix (e.g., 2025.8.13.1-dev)
+ type=raw,value=${{ env.buildNumber }}-${{ github.ref_name == 'develop' && 'dev' || github.ref_name }}
# tag with the short git sha (e.g., f15fbd3)
type=sha,prefix=
labels: |
diff --git a/.gitignore b/.gitignore
index 08216d4eb..b991fe5dc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -250,6 +250,8 @@ Application/WebfrontCore/*
/Data/IW4MAdmin_Migration.db-wal
bundle/
WebfrontCore/Tools/*
+_PRIVATE/
+translation_temp.csv
# LLM folders
.claude/
diff --git a/Application/Application.csproj b/Application/Application.csproj
index 0ce040cfa..f533f8036 100644
--- a/Application/Application.csproj
+++ b/Application/Application.csproj
@@ -4,7 +4,7 @@
Exe
net10.0
RaidMax.IW4MAdmin.Application
- 2020.0.0.0
+ 2024.0.0.0
RaidMax
Forever None
IW4MAdmin
diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs
index 4c58f4af1..148698488 100644
--- a/Application/ApplicationManager.cs
+++ b/Application/ApplicationManager.cs
@@ -142,6 +142,12 @@ public ApplicationManager(ILogger logger, IMiddlewareActionH
public IEnumerable Plugins { get; }
public IInteractionRegistration InteractionRegistration { get; }
+ public IList>, long?, string, bool, Task>> CustomStatsMetrics { get; } =
+ new List>, long?, string, bool, Task>>();
+
+ public IList, long?, string, Task>> CustomTopStatsTransformers { get; } =
+ new List, long?, string, Task>>();
+
public async Task ExecuteEvent(GameEvent newEvent)
{
ProcessingEvents.TryAdd(newEvent.IncrementalId, newEvent);
diff --git a/Application/BuildScripts/PostBuild.sh b/Application/BuildScripts/PostBuild.sh
index d6a96ebaa..bfdad22a5 100644
--- a/Application/BuildScripts/PostBuild.sh
+++ b/Application/BuildScripts/PostBuild.sh
@@ -32,12 +32,15 @@ mv "$PublishDir/DefaultSettings.json" "$PublishDir/Configuration/"
mkdir -p "$PublishDir/Lib"
rm -f "$PublishDir/Microsoft.CodeAnalysis*.dll"
-# Get list of plugin DLLs from BUILD/Plugins (dynamically detected)
+# Get list of plugin DLLs by matching .csproj project names in the Plugins/ source directory.
+# This avoids treating transitive dependencies (e.g. EF Core, Serilog) that land in BUILD/Plugins
+# as plugin assemblies when plugins use ProjectReferences instead of PackageReferences.
pluginDllNames=()
-if [ -d "$SourceDir/BUILD/Plugins" ]; then
- for pluginDll in "$SourceDir/BUILD/Plugins"/*.dll; do
- if [ -f "$pluginDll" ]; then
- pluginDllNames+=("$(basename "$pluginDll")")
+if [ -d "$SourceDir/Plugins" ]; then
+ for csproj in "$SourceDir/Plugins"/*/*.csproj; do
+ if [ -f "$csproj" ]; then
+ projName=$(basename "$csproj" .csproj)
+ pluginDllNames+=("${projName}.dll")
fi
done
fi
diff --git a/Application/Commands/SetLogLevelCommand.cs b/Application/Commands/SetLogLevelCommand.cs
index 250b139fe..720371f4c 100644
--- a/Application/Commands/SetLogLevelCommand.cs
+++ b/Application/Commands/SetLogLevelCommand.cs
@@ -20,7 +20,7 @@ public SetLogLevelCommand(CommandConfiguration config, ITranslationLookup layout
Name = "loglevel";
Alias = "ll";
- Description = "set minimum logging level";
+ Description = layout["COMMANDS_LOGLEVEL_DESC"];
Permission = EFClient.Permission.Owner;
Arguments = new CommandArgument[]
{
@@ -50,7 +50,8 @@ public override async Task ExecuteAsync(GameEvent gameEvent)
{
await gameEvent.Origin.TellAsync(new[]
{
- $"Valid log values: {string.Join(",", Enum.GetValues())}"
+ _translationLookup["COMMANDS_LOGLEVEL_VALID_VALUES"]
+ .FormatExt(string.Join(",", Enum.GetValues()))
});
return;
}
@@ -75,6 +76,9 @@ await gameEvent.Origin.TellAsync(new[]
}
await gameEvent.Origin.TellAsync(new[]
- { $"Set minimum log level to {loggingSwitch.MinimumLevel.ToString()}" });
+ {
+ _translationLookup["COMMANDS_LOGLEVEL_SUCCESS"]
+ .FormatExt(loggingSwitch.MinimumLevel.ToString())
+ });
}
}
diff --git a/Application/CoreEventHandler.cs b/Application/CoreEventHandler.cs
index fbac588b3..133a3727f 100644
--- a/Application/CoreEventHandler.cs
+++ b/Application/CoreEventHandler.cs
@@ -45,15 +45,13 @@ public void QueueEvent(IManager manager, CoreEvent coreEvent)
public void StartProcessing(CancellationToken token)
{
- _cancellationToken = token;
-
- while (!_cancellationToken.IsCancellationRequested)
+ while (!token.IsCancellationRequested)
{
_onEventReady.Reset();
try
{
- _onProcessingEvents.Wait(_cancellationToken);
+ _onProcessingEvents.Wait(token);
if (!_runningEventTasks.TryDequeue(out var coreEvent))
{
@@ -62,7 +60,7 @@ public void StartProcessing(CancellationToken token)
_onProcessingEvents.Release(1);
}
- _onEventReady.Wait(_cancellationToken);
+ _onEventReady.Wait(token);
continue;
}
diff --git a/Application/DefaultSettings.json b/Application/DefaultSettings.json
index 641caba7d..650f9aa39 100644
--- a/Application/DefaultSettings.json
+++ b/Application/DefaultSettings.json
@@ -3011,6 +3011,7 @@
"left_foot": "Left Foot",
"left_arm_upper": "Upper Left Arm",
"left_arm_lower": "Lower Left Arm",
+ "head": "Head",
"gl": "Rifle Grenade",
"bigammo": "Round Drum",
"scoped": "Sniper Scope",
@@ -3018,38 +3019,465 @@
"aperture": "Aperture Sight",
"flash": "Flash Hider",
"silenced": "Silencer",
- "molotov": "Molotov Cocktail",
"sticky": "N° 74 ST",
"m2": "M2 Flamethrower",
"artillery": "Artillery Strike",
"dog": "Attack Dogs",
"colt": "Colt M1911",
- "357magnum": ".357 Magnum",
+ "sw_357": ".357 Magnum",
"walther": "Walther P38",
- "tokarev": "Tokarev TT-33",
- "shotgun": "M1897 Trench Gun",
"doublebarreledshotgun": "Double-Barreled Shotgun",
+ "doublebarrel": "Double-Barreled Shotgun",
+ "30cal": "Browning M1919",
+ "bar": "BAR",
+ "fg42": "FG42",
+ "m1garand": "M1 Garand",
"mp40": "MP40",
- "type100smg": "Type 100",
"ppsh": "PPSh-41",
- "svt40": "SVT-40",
"gewehr43": "Gewehr 43",
- "m1garand": "M1 Garand",
+ "svt40": "SVT-40",
+ "nambu": "Nambu",
+ "m1garand_bayonet": "M1 Garand Bayonet",
+ "m1a1carbine_bayonet": "M1A1 Carbine Bayonet",
+ "kar98k_bayonet": "Kar98k Bayonet",
+ "mosin_rifle_bayonet": "Mosin-Nagant Bayonet",
+ "mg42": "MG42",
+ "colt45": "Colt M1911",
+ "sten_silenced": "Silenced Sten",
+ "type99_lmg": "Type 99",
+ "dp28": "DP-28",
+ "mine_shoebox": "PMD-6",
+ "mine_bouncing_betty": "Bouncing Betty",
+ "357magnum": ".357 Magnum",
+ "ptrs41": "PTRS-41",
+ "remingtonmodel11": "Remington Model 11",
+ "tabun_grenade": "Tabun Gas",
+ "signal_flare": "Signal Flare",
+ "shotgun_double_barreled": "Double-Barreled Shotgun",
+ "m7_launcher": "M7 Grenade Launcher",
+ "fg42_telescopic": "Telescopic Sight",
+ "springfield_scoped": "Scoped Springfield",
+ "m1garand_gl": "M1 Garand w/ Launcher",
+ "sticky_grenade": "N\u00ba 74 ST",
+ "tokarev_tt30": "Tokarev TT-33",
+ "mg42_bipod": "Deployable MG42",
+ "dp28_bipod": "Deployable DP-28",
+ "fg42_bipod": "Deployable FG42",
+ "bar_bipod": "Deployable BAR",
+ "30cal_bipod": "Deployable Browning M1919",
+ "type99_lmg_bipod": "Deployable Type 99",
+ "type100smg": "Type 100",
"stg44": "STG-44",
"m1carbine": "M1A1 Carbine",
"type99lmg": "Type 99",
- "bar": "BAR",
- "dp28": "DP-28",
- "mg42": "MG42",
- "fg42": "FG42",
- "30cal": "Browning M1919",
- "type99rifle": "Arisaka",
- "mosinrifle": "Mosin-Nagant",
- "ptrs41": "PTRS-41"
+ "syrette": "Syrette",
+ "supportgunner": "Support Gunner",
+ "bren": "Bren LMG",
+ "rifleman": "Rifleman",
+ "lee_enfield": "Lee-Enfield",
+ "kar98k": "Kar98k",
+ "luger": "Luger",
+ "m1a1carbine": "M1A1 Carbine",
+ "mosin_rifle": "Mosin-Nagant",
+ "mosin_rifle_scoped": "Scoped Mosin-Nagant",
+ "sniper": "Sniper",
+ "submachinegunner": "Submachine Gunner",
+ "mp44": "MP44",
+ "springfield": "Springfield",
+ "mosinnagantammo": "Mosin-Nagant Ammo",
+ "sten": "Sten",
+ "armyengineer": "Army Engineer",
+ "thompson": "Thompson",
+ "fastauto": "Fast-Auto",
+ "slowauto": "Slow-Auto",
+ "fullauto": "Full-Auto",
+ "semiauto": "Semi-Auto",
+ "m2fraggrenade": "M2 Frag Grenade",
+ "mk1_frag_grenade": "MK1 Frag Grenade",
+ "russiangrenade": "RGD-33 Stick Grenade",
+ "germangrenade": "Stielhandgranate",
+ "panzerschrek": "Panzerschrek",
+ "panzerfaust": "Panzerfaust 60",
+ "scopedkar98k": "Scoped Kar98k",
+ "holdpin": "Hold-Pin",
+ "cookoff": "Cook-Off",
+ "medicplaceholder": "Medic",
+ "fraggrenade": "Frag",
+ "m8_white_smoke": "Smoke",
+ "shotgun": "M1897 Trench Gun",
+ "greasegun": "Grease Gun",
+ "pps42": "PPS42",
+ "webley": "Webley",
+ "scopedg43": "Scoped Gewehr 43",
+ "defaultweapon": "Default Weapon",
+ "satchel": "Satchel Charge",
+ "anm8_smoke_grenade": "AN-M8 Smoke Grenade",
+ "no77_wp_smoke_grenade": "No.77 WP Smoke Grenade",
+ "nebelhandgranate": "Nebelhandgranate",
+ "rgd1_smoke_grenade": "RGD-1 Smoke Grenade",
+ "potato": "Potato",
+ "no_ammo": "No Ammo",
+ "no_frag_grenade": "No Primary Grenades Remaining",
+ "no_smoke_grenade": "No Smoke Grenades Remaining",
+ "no_flash_grenade": "No Flashbang Grenades Remaining",
+ "noecial_grenade": "No Special Grenades Remaining",
+ "location_selector": "Select a location",
+ "smoke_grenade": "Smoke Grenade",
+ "flash_grenade": "Flash Grenade",
+ "concussion_grenade": "Stun Grenade",
+ "smgs": "Submachine Guns",
+ "assaultrifles": "Assault Rifles",
+ "shotguns": "Shotguns",
+ "sniperrifles": "Sniper Rifles",
+ "target_too_close": "Too Close to Target",
+ "lockon_required": "Lock-On Required",
+ "target_not_enough_clearance": "Not Enough Room To Fire",
+ "no_attachment": "No Attachment",
+ "silencer": "Suppressor",
+ "grenade_launcher": "Grenade Launcher",
+ "no_camo": "No Camo",
+ "golden_camo": "Golden",
+ "prestige_camo": "Prestige",
+ "binoculars": "Binoculars",
+ "grip": "Grip",
+ "m16a4_grenadier": "M16A4 Grenadier",
+ "panzershrek": "Panzershrek",
+ "bazooka": "M9A1 Bazooka",
+ "bazooka_man": "Bazooka",
+ "tokarev": "Tokarev TT-33",
+ "russian_flag": "Red Army Banner",
+ "stg-44": "STG-44",
+ "mortar_round": "Mortar Round",
+ "molotov": "Molotov Cocktail",
+ "fireblob": "Napalm Blob (Fire on Ground)",
+ "m2_flamethrower": "M2 Flamethrower",
+ "flamethrower_gunner": "Flamethrower",
+ "kar98k_scoped": "Scoped Kar98k",
+ "lee_enfield_scoped": "Scoped Lee-Enfield",
+ "type100_smg": "Type 100",
+ "type99_rifle": "Arisaka",
+ "type99_rifle_bayonet": "Arisaka Bayonet",
+ "type99_rifle_scoped": "Scoped Arisaka",
+ "walther_p38": "Walther P38",
+ "shotgunner": "Shotgunner",
+ "doublebarrel_sawed_grip": "Sawed-Off Double-Barreled Shotgun w/ Grip",
+ "antitank_gunner": "Anti-Tank Gunner",
+ "springfield_no_attachment": "No Attachment",
+ "springfield_bayonet": "Springfield Bayonet",
+ "springfield_rifle_grenade": "Rifle Grenade",
+ "type99_rifle_rifle_grenade": "Rifle Grenade",
+ "type99_rifle_no_attachment": "No Attachment",
+ "kar98k_no_attachment": "No Attachment",
+ "kar98k_rifle_grenade": "Rifle Grenade",
+ "mosin_rifle_no_attachment": "No Attachment",
+ "mosin_rifle_rifle_grenade": "Rifle Grenade",
+ "svt40_no_attachment": "No Attachment",
+ "svt40_flash": "Flash Hider",
+ "svt40_aperture": "Aperture Sight",
+ "svt40_telescopic": "Telescopic Sight",
+ "svt40_select_fire": "Select Fire",
+ "gewehr43_no_attachment": "No Attachment",
+ "gewehr43_silenced": "Suppressor",
+ "gewehr43_aperture": "Aperture Sight",
+ "gewehr43_telescopic": "Telescopic Sight",
+ "gewehr43_rifle_grenade": "Rifle Grenade",
+ "m1garand_no_attachment": "No Attachment",
+ "m1garand_rifle_grenade": "Rifle Grenade",
+ "m1garand_scoped": "Sniper Scope",
+ "m1garand_flash": "Flash Hider",
+ "m1a1carbine_no_attachment": "No Attachment",
+ "m1a1carbine_flash": "Flash Hider",
+ "m1a1carbine_bigammo": "Box Magazine",
+ "m1a1carbine_aperture": "Aperture Sight",
+ "stg-44_no_attachment": "No Attachment",
+ "stg-44_flash": "Flash Hider",
+ "stg-44_aperture": "Aperture Sight",
+ "stg-44_telescopic": "Telescopic Sight",
+ "stg-44_select_fire": "Select Fire",
+ "thompson_no_attachment": "No Attachment",
+ "thompson_silenced": "Suppressor",
+ "thompson_aperture": "Aperture Sight",
+ "thompson_bigammo": "Round Drum",
+ "type100_smg_no_attachment": "No Attachment",
+ "type100_smg_silenced": "Suppressor",
+ "type100_smg_bigammo": "Box Magazine",
+ "type100_smg_aperture": "Aperture Sight",
+ "mp40_no_attachment": "No Attachment",
+ "mp40_silenced": "Suppressor",
+ "mp40_aperture": "Aperture Sight",
+ "mp40_bigammo": "Dual Magazines",
+ "ppsh_no_attachment": "No Attachment",
+ "ppsh_aperture": "Aperture Sight",
+ "ppsh_bigammo": "Round Drum",
+ "shotgun_no_attachment": "No Attachment",
+ "shotgun_grip": "Grip",
+ "shotgun_bayonet": "Bayonet",
+ "shotgun_double_barreled_no_attachment": "No Attachment",
+ "shotgun_double_barreled_grip": "Grip",
+ "doublebarrel_sawed": "Sawed-Off Shotgun",
+ "sailor": "Crewman",
+ "fg42_scoped": "Scoped FG42",
+ "mosin_launcher": "Mosin-Nagant w/ Launcher",
+ "zombie_melee": "BRAAAINS...",
+ "ray_gun": "Ray Gun",
+ "nomad": "Nomad",
+ "tesla_gun": "Wunderwaffe DG-2",
+ "30cal_upgraded": "B115 accelerator",
+ "bar_upgraded": "The Widow Maker",
+ "colt_upgraded": "C-3000 b1at-ch35",
+ "shotgun_double_barreled_sawed_grip_upgraded": "The Snuff Box",
+ "shotgun_double_barreled_upgraded": "24 Bore long range",
+ "fg42_upgraded": "420 Impeller",
+ "gewehr43_upgraded": "G115 Compressor",
+ "m1a1carbine_upgraded": "Widdershins RC-1",
+ "m1garand_upgraded": "M1000",
+ "m1garand_gl_upgraded": "The Imploder",
+ "mg42_upgraded": "Barracuda FU-A11",
+ "mp40_upgraded": "The Afterburner",
+ "ppsh_upgraded": "The Reaper",
+ "shotgun_upgraded": "Gut Shot",
+ "stg-44_upgraded": "Spatz-447 +",
+ "sw_357_upgraded": ".357 Plus 1 K1L-u",
+ "thompson_upgraded": "Gibs-o-matic",
+ "type100_smg_upgraded": "1001 Samurais",
+ "type99_rifle_upgraded": "The Eviscerator",
+ "panzerschrek_upgraded": "Longinus",
+ "ray_gun_upgraded": "Porter's X2 Ray Gun",
+ "tesla_gun_upgraded": "Wunderwaffe DG-3 JZ",
+ "m2_flamethrower_upgraded": "FIW Nitrogen cooled",
+ "ptrs41_upgraded": "The Penetrator",
+ "m7_launcher_upgraded": "The Imploder",
+ "nazi_zombies_cap": "NAZI ZOMBIES",
+ "kar98k_upgraded": "Armageddon",
+ "m7_launcher_upgraded_nonade": "The Imploder",
+ "zombie_knuckle_crack": "Pack A Punch Knuckle Crack",
+ "cymbal_monkey": "Cymbal Monkey",
+
+ "zombie_perk_bottle_jugg": "Juggernog",
+ "zombie_perk_bottle_revive": "Quick Revive",
+ "zombie_perk_bottle_sleight": "Speed Cola",
+ "zombie_perk_bottle_doubletap": "Double Tap",
+ "specialty_armorvest": "Juggernog",
+ "specialty_quickrevive": "Quick Revive",
+ "specialty_fastreload": "Speed Cola",
+ "specialty_rof": "Double Tap",
+ "specialty_juggernaut_zombies": "Juggernog",
+ "specialty_quickrevive_zombies": "Quick Revive",
+ "specialty_fastreload_zombies": "Speed Cola",
+ "specialty_doubletap_zombies": "Double Tap",
+
+ "full_ammo": "Max Ammo",
+ "double_points": "Double Points",
+ "insta_kill": "Insta-Kill",
+ "nuke": "Nuke",
+ "carpenter": "Carpenter",
+ "fire_sale": "Fire Sale",
+
+ "zombie_30cal": "Browning M1919",
+ "zombie_30cal_upgraded": "B115 accelerator",
+ "zombie_sw_357": ".357 Magnum",
+ "zombie_sw_357_upgraded": ".357 Plus 1 K1L-u",
+ "zombie_bar": "BAR",
+ "zombie_bar_upgraded": "The Widow Maker",
+ "zombie_colt": "Colt M1911",
+ "zombie_colt_upgraded": "C-3000 b1at-ch35",
+ "zombie_fg42": "FG42",
+ "zombie_fg42_upgraded": "420 Impeller",
+ "zombie_gewehr43": "Gewehr 43",
+ "zombie_gewehr43_upgraded": "G115 Compressor",
+ "zombie_kar98k": "Kar98k",
+ "zombie_kar98k_upgraded": "Armageddon",
+ "zombie_m1carbine": "M1A1 Carbine",
+ "zombie_m1carbine_upgraded": "Widdershins RC-1",
+ "zombie_m1garand": "M1 Garand",
+ "zombie_m1garand_upgraded": "M1000",
+ "m2_flamethrower_zombie": "M2 Flamethrower",
+ "m2_flamethrower_zombie_upgraded": "FIW Nitrogen cooled",
+ "m7_launcher_zombie": "M7 Grenade Launcher",
+ "m7_launcher_zombie_upgraded": "The Imploder",
+ "zombie_mg42": "MG42",
+ "zombie_mg42_upgraded": "Barracuda FU-A11",
+ "zombie_mp40": "MP40",
+ "zombie_mp40_upgraded": "The Afterburner",
+ "panzerschrek_zombie": "Panzerschrek",
+ "panzerschrek_zombie_upgraded": "Longinus",
+ "zombie_ppsh": "PPSh-41",
+ "zombie_ppsh_upgraded": "The Reaper",
+ "ptrs41_zombie": "PTRS-41",
+ "ptrs41_zombie_upgraded": "The Penetrator",
+ "zombie_shotgun": "M1897 Trench Gun",
+ "zombie_shotgun_upgraded": "Gut Shot",
+ "zombie_doublebarrel": "Double-Barreled Shotgun",
+ "zombie_doublebarrel_upgraded": "24 Bore long range",
+ "zombie_doublebarrel_sawed": "Sawed-Off Double-Barreled Shotgun w/ Grip",
+ "zombie_doublebarrel_sawed_upgraded": "The Snuff Box",
+ "zombie_stg44": "STG-44",
+ "zombie_stg44_upgraded": "Spatz-447 +",
+ "zombie_thompson": "Thompson",
+ "zombie_thompson_upgraded": "Gibs-o-matic",
+ "zombie_type100_smg": "Type 100",
+ "zombie_type100_smg_upgraded": "1001 Samurais",
+ "zombie_type99_rifle": "Arisaka",
+ "zombie_type99_rifle_upgraded": "The Eviscerator"
},
-
- "T6" : {
-
+
+ "T5": {
+ "specialty_armorvest": "Juggernog",
+ "specialty_quickrevive": "Quick Revive",
+ "specialty_fastreload": "Speed Cola",
+ "specialty_rof": "Double Tap",
+ "specialty_deadshot": "Deadshot Daiquiri",
+ "specialty_longersprint": "Stamin-Up",
+ "specialty_flakjacket": "PhD Flopper",
+ "specialty_additionalprimaryweapon": "Mule Kick",
+
+ "full_ammo": "Max Ammo",
+ "double_points": "Double Points",
+ "insta_kill": "Insta-Kill",
+ "nuke": "Nuke",
+ "carpenter": "Carpenter",
+ "fire_sale": "Fire Sale",
+ "bonfire_sale": "Bonfire Sale",
+ "all_revive": "All Revive",
+ "minigun": "Death Machine",
+ "free_perk": "Random Perk",
+ "tesla": "Wunderwaffe",
+ "random_weapon": "Random Weapon",
+ "monkey_swarm": "Monkey Swarm",
+
+ "zombie_30cal": "Browning M1919",
+ "zombie_30cal_upgraded": "B115 accelerator",
+ "zombie_sw_357": ".357 Magnum",
+ "zombie_sw_357_upgraded": ".357 Plus 1 K1L-u",
+ "ak74u_zm": "AK74u",
+ "ak74u_upgraded_zm": "AK74fu2",
+ "aug_acog_zm": "AUG",
+ "aug_acog_mk_upgraded_zm": "AUG-50M3",
+ "aug_acog_gl_zm": "AUG Grenade Launcher",
+ "aug_acog_gl_upgraded_zm": "AUG Grenade Launcher Upgraded",
+ "zombie_bar": "BAR",
+ "zombie_bar_upgraded": "The Widow Maker",
+ "china_lake_zm": "China Lake",
+ "china_lake_upgraded_zm": "China Beach",
+ "zombie_colt": "Colt M1911",
+ "zombie_colt_upgraded": "C-3000 b1at-ch35",
+ "commando_zm": "Commando",
+ "commando_upgraded_zm": "Predator",
+ "crossbow_explosive_zm": "Crossbow",
+ "crossbow_explosive_upgraded_zm": "Awful Lawton",
+ "cz75_zm": "CZ75",
+ "cz75_upgraded_zm": "Calamity",
+ "cz75dw_zm": "CZ75 Dual Wield",
+ "cz75dw_upgraded_zm": "Calamity & Jane",
+ "dragunov_zm": "Dragunov",
+ "dragunov_upgraded_zm": "D115 Disassembler",
+ "enfield_zm": "Enfield",
+ "enfield_upgraded_zm": "Enfield Upgraded",
+ "famas_zm": "Famas",
+ "famas_upgraded_zm": "G16-GL35",
+ "zombie_fg42": "FG42",
+ "zombie_fg42_upgraded": "420 Impeller",
+ "fnfal_zm": "FN FAL",
+ "fnfal_upgraded_zm": "EPC WN",
+ "freezegun_zm": "Winter's Howl",
+ "freezegun_upgraded_zm": "Winter's Fury",
+ "g11_lps_zm": "G11",
+ "g11_lps_upgraded_zm": "G115 Generator",
+ "galil_zm": "Galil",
+ "galil_upgraded_zm": "Lamentation",
+ "zombie_gewehr43": "Gewehr 43",
+ "zombie_gewehr43_upgraded": "G115 Compressor",
+ "hk21_zm": "HK21",
+ "hk21_upgraded_zm": "H115 Oscillator",
+ "hs10_zm": "HS10",
+ "hs10_upgraded_zm": "Typhoid & Mary",
+ "ithaca_zm": "Stakeout",
+ "ithaca_upgraded_zm": "Raid",
+ "zombie_kar98k": "Kar98k",
+ "zombie_kar98k_upgraded": "Armageddon",
+ "knife_ballistic_zm": "Ballistic Knife",
+ "knife_ballistic_upgraded_zm": "The Krauss Refibrillator",
+ "l96a1_zm": "L96A1",
+ "l96a1_upgraded_zm": "L115 Isolator",
+ "m14_zm": "M14",
+ "m14_upgraded_zm": "Mnesia",
+ "m16_zm": "M16",
+ "m16_gl_upgraded_zm": "Skullcrusher",
+ "m1911_zm": "M1911",
+ "m1911_upgraded_zm": "Mustang & Sally",
+ "zombie_m1carbine": "M1 Carbine",
+ "zombie_m1carbine_upgraded": "Widdershins RC-1",
+ "zombie_m1garand": "M1 Garand",
+ "zombie_m1garand_upgraded": "M1000",
+ "m202_flash_zm": "Grim Reaper",
+ "m202_flash_upgraded_zm": "Grim Reaper Upgraded",
+ "m2_flamethrower_zombie": "M2 Flamethrower",
+ "m2_flamethrower_zombie_upgraded": "FIW Nitrogen cooled",
+ "m72_law_zm": "M72 LAW",
+ "m72_law_upgraded_zm": "M72 Anarchy",
+ "mac11_zm": "MAC11",
+ "mac11_upgraded_zm": "MAC11 Upgraded",
+ "zombie_mg42": "MG42",
+ "zombie_mg42_upgraded": "Barracuda FU-A11",
+ "mp40_zm": "MP40",
+ "mp40_upgraded_zm": "The Afterburner",
+ "mp5k_zm": "MP5K",
+ "mp5k_upgraded_zm": "MP115 Kollider",
+ "mpl_zm": "MPL",
+ "mpl_upgraded_zm": "MPL-LF",
+ "panzerschrek_zombie": "Panzerschrek",
+ "panzerschrek_zombie_upgraded": "Longinus",
+ "pm63_zm": "PM63",
+ "pm63_upgraded_zm": "Tokyo & Rose",
+ "zombie_ppsh": "PPSh-41",
+ "zombie_ppsh_upgraded": "The Reaper",
+ "ptrs41_zombie": "PTRS-41",
+ "ptrs41_zombie_upgraded": "The Penetrator",
+ "python_zm": "Python",
+ "python_upgraded_zm": "Cobra",
+ "ray_gun_zm": "Ray Gun",
+ "ray_gun_upgraded_zm": "Porter's X2 Ray Gun",
+ "rottweil72_zm": "Olympia",
+ "rottweil72_upgraded_zm": "Hades",
+ "rpk_zm": "RPK",
+ "rpk_upgraded_zm": "R115 Resonator",
+ "zombie_shotgun": "M1897 Trench Gun",
+ "zombie_shotgun_upgraded": "Gut Shot",
+ "zombie_doublebarrel": "Double-Barreled Shotgun",
+ "zombie_doublebarrel_upgraded": "24 Bore long range",
+ "zombie_doublebarrel_sawed": "Sawed-Off Double-Barreled Shotgun w/ Grip",
+ "zombie_doublebarrel_sawed_upgraded": "The Snuff Box",
+ "spas_zm": "SPAS-12",
+ "spas_upgraded_zm": "SPAZ-24",
+ "spectre_zm": "Spectre",
+ "spectre_upgraded_zm": "Phantom",
+ "zombie_stg44": "STG-44",
+ "zombie_stg44_upgraded": "Spatz-447 +",
+ "blow_gun_zm": "Blow Gun",
+ "blow_gun_upgraded_zm": "Super Blow Gun",
+ "tesla_gun_zm": "Wunderwaffe DG-2",
+ "tesla_gun_upgraded_zm": "Wunderwaffe DG-3 JZ",
+ "zombie_thompson": "Thompson",
+ "zombie_thompson_upgraded": "Gibs-o-matic",
+ "thundergun_zm": "Thundergun",
+ "thundergun_upgraded_zm": "ZeusCannon",
+ "zombie_type100_smg": "Type 100",
+ "zombie_type100_smg_upgraded": "1001 Samurais",
+ "zombie_type99_rifle": "Arisaka",
+ "zombie_type99_rifle_upgraded": "The Eviscerator",
+ "humangun_zm": "V-R11",
+ "humangun_upgraded_zm": "V-R11 Lazarus",
+ "microwavegun_zm": "Wave Gun",
+ "microwavegun_upgraded_zm": "Max Wave Gun",
+ "microwavegundw_zm": "Zap Gun Dual Wield",
+ "microwavegundw_upgraded_zm": "Porter's X2 Zap Gun Dual Wield",
+ "shrink_ray_zm": "31-79 JGb215",
+ "shrink_ray_upgraded_zm": "The Fractalizer",
+ "sniper_explosive_zm": "Scavenger",
+ "sniper_explosive_upgraded_zm": "Hyena Infra-dead"
+ },
+
+ "T6" : {
"mp7": "MP7",
"pdw57": "PDW-57",
"vector": "Vector K10",
@@ -3163,8 +3591,213 @@
"remote_mortar": "Lodestar",
"player_gunner": "VTOL Warship",
"dogs": "K9 Unit",
- "missile_swarm": "Swarm"
-
+ "missile_swarm": "Swarm",
+
+ "specialty_armorvest": "Juggernog",
+ "specialty_quickrevive": "Quick Revive",
+ "specialty_fastreload": "Speed Cola",
+ "specialty_rof": "Double Tap",
+ "specialty_deadshot": "Deadshot Daiquiri",
+ "specialty_longersprint": "Stamin-Up",
+ "specialty_additionalprimaryweapon": "Mule Kick",
+ "specialty_scavenger": "Tombstone Soda",
+ "specialty_finalstand": "Who's Who",
+ "specialty_grenadepulldeath": "Electric Cherry",
+ "specialty_nomotionsensor": "Vulture Aid",
+ "specialty_weapupgrade": "Pack-a-Punch",
+
+ "full_ammo": "Max Ammo",
+ "double_points": "Double Points",
+ "insta_kill": "Insta-Kill",
+ "insta_kill_ug": "Insta-Kill",
+ "nuke": "Nuke",
+ "carpenter": "Carpenter",
+ "fire_sale": "Fire Sale",
+ "bonfire_sale": "Bonfire Sale",
+ "all_revive": "All Revive",
+ "free_perk": "Random Perk",
+ "random_weapon": "Random Weapon",
+ "bonus_points_player": "Bonus Points",
+ "bonus_points_team": "Team Bonus Points",
+ "lose_points_team": "Lose Points",
+ "lose_perk": "Lose Perk",
+ "empty_clip": "Empty Clip",
+ "zombie_blood": "Zombie Blood",
+ "the_cure": "The Cure",
+ "teller_withdrawl": "Bank Withdrawal",
+ "blue_monkey": "Blue Monkey",
+ "meat_stink": "Meat",
+
+ "870mcs_dualclip_zm": "R870 MCS Fast Mag",
+ "870mcs_dualclip_upgraded_zm": "Rettified-870 Mechanical Cranium Sequencer",
+ "870mcs_zm": "Remington 870 MCS",
+ "870mcs_upgraded_zm": "Refitted-870 Mechanical Cranium Sequencer",
+ "acidgat_zm": "Acid Gat",
+ "acidgat_upgraded_zm": "Vitriolic Withering",
+ "ak47_zm": "AK-47",
+ "ak47_upgraded_zm": "Reznov's Revenge",
+ "an94_zm": "AN-94",
+ "an94_upgraded_zm": "Actuated Neutralizer 94000",
+ "ballista_zm": "Ballista",
+ "ballista_upgraded_zm": "Infused Arbalest",
+ "barretm82_zm": "Barret M82A1",
+ "barretm82_upgraded_zm": "Macro Annihilator",
+ "beretta93r_zm": "B23R",
+ "beretta93r_upgraded_zm": "B34R",
+ "blundergat_zm": "Blundergat",
+ "blundergat_upgraded_zm": "The Sweeper",
+ "c96_zm": "Mauser C96",
+ "c96_upgraded_zm": "BoomHilda",
+ "dsr50_zm": "DSR-50",
+ "dsr50_upgraded_zm": "Dead Specimen Reactor 5000",
+ "evoskorpion_zm": "Skorpion",
+ "evoskorpion_upgraded_zm": "Evolved Death Stalker",
+ "fivesevendw_zm": "Five-seven Dual Wield",
+ "fivesevendw_upgraded_zm": "Ultra & Violet",
+ "fiveseven_zm": "Five-seven",
+ "fiveseven_upgraded_zm": "Ultra",
+ "hamr_zm": "HAMR",
+ "hamr_upgraded_zm": "SLDG HAMR",
+ "hk416_zm": "M27",
+ "hk416_upgraded_zm": "Mystifier",
+ "judge_zm": "Raging Judge",
+ "judge_upgraded_zm": "Voice of Justice",
+ "kard_zm": "KAP",
+ "kard_upgraded_zm": "Karmic Atom Perforator-4000",
+ "ksg_zm": "KSG",
+ "ksg_upgraded_zm": "Mist Maker",
+ "lsat_zm": "LSAT",
+ "lsat_upgraded_zm": "FSIRT",
+ "m32_zm": "M32 Grenade Launcher",
+ "m32_upgraded_zm": "Dystopic Demolisher",
+ "mg08_zm": "MG08/15",
+ "mg08_upgraded_zm": "Magna Collider",
+ "minigun_zm": "Minigun",
+ "minigun_upgraded_zm": "Meat Grinder",
+ "mp44_zm": "STG-44",
+ "mp44_upgraded_zm": "Spatz-447 +",
+ "slowgun_zm": "Paralyzer",
+ "slowgun_upgraded_zm": "Petrifier",
+ "pdw57_zm": "PDW-57",
+ "pdw57_upgraded_zm": "Predictive Death Wish 57000",
+ "qcw05_zm": "QCW-05",
+ "qcw05_upgraded_zm": "Chicom Cataclysmic Quadruple Burst",
+ "raygun_mark2_zm": "Ray Gun Mark II",
+ "raygun_mark2_upgraded_zm": "Porter's Mark II Ray Gun",
+ "rnma_zm": "Remington New Model Army",
+ "rnma_upgraded_zm": "Sassafras",
+ "rpd_zm": "RPD",
+ "rpd_upgraded_zm": "Relativistic Punishment Device",
+ "saiga12_zm": "Saiga-12",
+ "saiga12_upgraded_zm": "Synthetic Dozen",
+ "saritch_zm": "TOZ Saritch",
+ "saritch_upgraded_zm": "SM1L3R",
+ "scar_zm": "SCAR-H",
+ "scar_upgraded_zm": "Agarthan Reaper",
+ "slipgun_zm": "Sliquifier",
+ "slipgun_upgraded_zm": "Sl1qu1f13r",
+ "srm1216_zm": "M1216",
+ "srm1216_upgraded_zm": "Mesmerizer",
+ "staff_air_zm": "Staff of Wind",
+ "staff_air_upgraded_zm": "Boreas' Fury",
+ "staff_fire_zm": "Staff of Fire",
+ "staff_fire_upgraded_zm": "Kagutsuchi's Blood",
+ "staff_lightning_zm": "Staff of Lightning",
+ "staff_lightning_upgraded_zm": "Kimat's Bite",
+ "staff_water_zm": "Staff of Ice",
+ "staff_water_upgraded_zm": "Ull's Arrow",
+ "svu_zm": "SVU-AS",
+ "svu_upgraded_zm": "Shadowy Veil Utilizer",
+ "tar21_zm": "X95L",
+ "tar21_upgraded_zm": "Malevolent Taxonomic Anodized Redeemer",
+ "thompson_zm": "M1927",
+ "thompson_upgraded_zm": "Speakeasy",
+ "tomahawk_zm": "Hell's Retriever",
+ "tomahawk_upgraded_zm": "Hell's Redeemer",
+ "type95_zm": "Type 25",
+ "type95_upgraded_zm": "Strain 25",
+ "usrpg_zm": "RPG",
+ "usrpg_upgraded_zm": "Rocket Propelled Grievance",
+ "uzi_zm": "Uzi",
+ "uzi_upgraded_zm": "Uncle Gal",
+ "xm8_zm": "M8A1",
+ "xm8_upgraded_zm": "Micro Aerator",
+ "ak74u_zm": "AK74u",
+ "ak74u_upgraded_zm": "AK74fu2",
+ "aug_acog_gl_zm": "AUG w/ Grenade Launcher",
+ "aug_acog_gl_upgraded_zm": "AUG Grenade Launcher Upgraded",
+ "aug_acog_zm": "AUG",
+ "aug_acog_mk_upgraded_zm": "AUG-50M3",
+ "china_lake_zm": "China Lake",
+ "china_lake_upgraded_zm": "China Beach",
+ "commando_zm": "Commando",
+ "commando_upgraded_zm": "Predator",
+ "crossbow_explosive_zm": "Crossbow",
+ "crossbow_explosive_upgraded_zm": "Awful Lawton",
+ "cz75dw_zm": "CZ75 Dual Wield",
+ "cz75dw_upgraded_zm": "Calamity & Jane",
+ "cz75_zm": "CZ75",
+ "cz75_upgraded_zm": "Calamity",
+ "dragunov_zm": "Dragunov",
+ "dragunov_upgraded_zm": "D115 Disassembler",
+ "enfield_zm": "Enfield",
+ "enfield_upgraded_zm": "Enfield Upgraded",
+ "famas_zm": "FAMAS",
+ "famas_upgraded_zm": "G16-GL35",
+ "fnfal_zm": "FAL",
+ "fnfal_upgraded_zm": "WN",
+ "freezegun_zm": "Winter's Howl",
+ "freezegun_upgraded_zm": "Winter's Fury",
+ "g11_lps_zm": "G11",
+ "g11_lps_upgraded_zm": "G115 Generator",
+ "galil_zm": "Galil",
+ "galil_upgraded_zm": "Lamentation",
+ "hk21_zm": "HK21",
+ "hk21_upgraded_zm": "H115 Oscillator",
+ "hs10_zm": "HS10",
+ "hs10_upgraded_zm": "Typhoid & Mary",
+ "ithaca_zm": "Stakeout",
+ "ithaca_upgraded_zm": "Raid",
+ "knife_ballistic_zm": "Ballistic Knife",
+ "knife_ballistic_upgraded_zm": "The Krauss Refibrillator",
+ "l96a1_zm": "L96A1",
+ "l96a1_upgraded_zm": "L115 Isolator",
+ "m14_zm": "M14",
+ "m14_upgraded_zm": "Mnesia",
+ "m16_zm": "Colt M16A1",
+ "m16_gl_upgraded_zm": "Skullcrusher",
+ "m1911_zm": "M1911",
+ "m1911_upgraded_zm": "Mustang & Sally",
+ "m202_flash_zm": "M202 Flash",
+ "m202_flash_upgraded_zm": "Grim Reaper Upgraded",
+ "m72_law_zm": "M72 LAW",
+ "m72_law_upgraded_zm": "M72 Anarchy",
+ "mac11_zm": "MAC-11",
+ "mac11_upgraded_zm": "MAC11 Upgraded",
+ "mp40_zm": "MP40",
+ "mp40_upgraded_zm": "The Afterburner",
+ "mp5k_zm": "MP5",
+ "mp5k_upgraded_zm": "MP115 Kollider",
+ "mpl_zm": "MPL",
+ "mpl_upgraded_zm": "MPL-LF",
+ "pm63_zm": "PM63",
+ "pm63_upgraded_zm": "Tokyo & Rose",
+ "python_zm": "Python",
+ "python_upgraded_zm": "Cobra",
+ "ray_gun_zm": "Ray Gun",
+ "ray_gun_upgraded_zm": "Porter's X2 Ray Gun",
+ "rottweil72_zm": "Olympia",
+ "rottweil72_upgraded_zm": "Hades",
+ "rpk_zm": "RPK",
+ "rpk_upgraded_zm": "R115 Resonator",
+ "spas_zm": "SPAS-12",
+ "spas_upgraded_zm": "SPAZ-24",
+ "spectre_zm": "Spectre",
+ "spectre_upgraded_zm": "Phantom",
+ "tesla_gun_zm": "Wunderwaffe DG-2",
+ "tesla_gun_upgraded_zm": "Wunderwaffe DG-3 JZ",
+ "thundergun_zm": "Thunder Gun",
+ "thundergun_upgraded_zm": "ZeusCannon"
}
}
}
diff --git a/Application/Extensions/StartupExtensions.cs b/Application/Extensions/StartupExtensions.cs
index be6acabe1..cfe723f17 100644
--- a/Application/Extensions/StartupExtensions.cs
+++ b/Application/Extensions/StartupExtensions.cs
@@ -48,8 +48,8 @@ public static IServiceCollection AddBaseLogger(this IServiceCollection services,
loggerConfig = loggerConfig.WriteTo.Console(
outputTemplate:
"[{Timestamp:HH:mm:ss} {Server} {Level:u3}] {Message:lj}{NewLine}{Exception}")
- .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
- .MinimumLevel.Debug();
+ .MinimumLevel.Override("Microsoft", LogEventLevel.Information)
+ .MinimumLevel.Debug();
}
_defaultLogger = loggerConfig.CreateLogger();
@@ -83,10 +83,10 @@ public static IServiceCollection AddDatabaseContextOptions(this IServiceCollecti
: currentPath;
var connectionStringBuilder = new SqliteConnectionStringBuilder
- {DataSource = Path.Join(currentPath, "Database", "Database.db")};
+ { DataSource = Path.Join(currentPath, "Database", "Database.db") };
var connectionString = connectionStringBuilder.ToString();
- services.AddSingleton(sp => (DbContextOptions) new DbContextOptionsBuilder()
+ services.AddSingleton(sp => (DbContextOptions)new DbContextOptionsBuilder()
.UseSqlite(connectionString)
.UseLoggerFactory(sp.GetRequiredService())
.EnableSensitiveDataLogging().Options);
@@ -100,7 +100,7 @@ public static IServiceCollection AddDatabaseContextOptions(this IServiceCollecti
StringComparison.InvariantCultureIgnoreCase);
var connectionString =
appConfig.ConnectionString + (appendTimeout ? ";default command timeout=0" : "");
- services.AddSingleton(sp => (DbContextOptions) new DbContextOptionsBuilder()
+ services.AddSingleton(sp => (DbContextOptions)new DbContextOptionsBuilder()
.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString),
mysqlOptions => mysqlOptions.EnableRetryOnFailure())
.UseLoggerFactory(sp.GetRequiredService()).Options);
@@ -109,7 +109,7 @@ public static IServiceCollection AddDatabaseContextOptions(this IServiceCollecti
appendTimeout = !appConfig.ConnectionString.Contains("Command Timeout",
StringComparison.InvariantCultureIgnoreCase);
services.AddSingleton(sp =>
- (DbContextOptions) new DbContextOptionsBuilder()
+ (DbContextOptions)new DbContextOptionsBuilder()
.UseNpgsql(appConfig.ConnectionString + (appendTimeout ? ";Command Timeout=0" : ""),
postgresqlOptions =>
{
diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs
index 6ee9be854..4f30f6498 100644
--- a/Application/IW4MServer.cs
+++ b/Application/IW4MServer.cs
@@ -109,10 +109,12 @@ public override async Task OnClientConnected(EFClient clientFromLog)
ServerLogger.LogDebug("Client slot #{clientNumber} now reserved", clientFromLog.ClientNumber);
var client = await Manager.GetClientService().GetUnique(clientFromLog.NetworkId, GameName);
+ var foundClient = true;
// first time client is connecting to server
if (client == null)
{
+ foundClient = false;
ServerLogger.LogDebug("Client {client} first time connecting", clientFromLog.ToString());
clientFromLog.CurrentServer = this;
client = await Manager.GetClientService().Create(clientFromLog);
@@ -120,12 +122,16 @@ public override async Task OnClientConnected(EFClient clientFromLog)
client.CopyAdditionalProperties(clientFromLog);
- // this is only a temporary version until the IPAddress is transmitted
- client.CurrentAlias = new EFAlias()
+ if (foundClient)
{
- Name = clientFromLog.Name,
- IPAddress = clientFromLog.IPAddress
- };
+ client.CurrentAlias = new EFAlias
+ {
+ AliasId = client.CurrentAliasId,
+ LinkId = client.AliasLinkId,
+ Name = clientFromLog.Name,
+ IPAddress = clientFromLog.IPAddress
+ };
+ }
// Do the player specific stuff
client.ClientNumber = clientFromLog.ClientNumber;
@@ -426,10 +432,7 @@ protected override async Task ProcessEvent(GameEvent E)
{
if (E.Origin.State != ClientState.Connected)
{
- E.Origin.State = ClientState.Connected;
- E.Origin.Connections += 1;
-
- ChatHistory.Add(new ChatInfo()
+ ChatHistory.Add(new ChatInfo
{
ClientId = E.Origin.ClientId,
Name = E.Origin.Name,
@@ -444,6 +447,10 @@ protected override async Task ProcessEvent(GameEvent E)
{
E.Origin.Tag = clientTag.Value;
}
+
+ await E.Origin.OnJoin(E.Origin.IPAddress, Manager.GetApplicationSettings().Configuration().EnableImplicitAccountLinking);
+ E.Origin.State = ClientState.Connected;
+ E.Origin.Connections += 1;
try
{
@@ -463,8 +470,6 @@ protected override async Task ProcessEvent(GameEvent E)
ServerLogger.LogError(ex, "Could not get offline message count for {Client}", E.Origin.ToString());
throw;
}
-
- await E.Origin.OnJoin(E.Origin.IPAddress, Manager.GetApplicationSettings().Configuration().EnableImplicitAccountLinking);
}
}
@@ -923,6 +928,23 @@ public async Task EnsureServerAdded()
context.Entry(gameServer).Property(property => property.HostName).IsModified = true;
}
+ var normalizedPerformanceCode = PerformanceCode?.ToLowerInvariant();
+ if (gameServer.PerformanceBucket?.Code != normalizedPerformanceCode && !string.IsNullOrEmpty(normalizedPerformanceCode))
+ {
+ var bucket = await context.Set()
+ .FirstOrDefaultAsync(b => b.Code == normalizedPerformanceCode);
+
+ if (bucket == null)
+ {
+ bucket = new Data.Models.Client.Stats.EFPerformanceBucket { Code = normalizedPerformanceCode };
+ context.Add(bucket);
+ await context.SaveChangesAsync();
+ }
+
+ gameServer.PerformanceBucketId = bucket.PerformanceBucketId;
+ context.Entry(gameServer).Property(p => p.PerformanceBucketId).IsModified = true;
+ }
+
if (gameServer.IsPasswordProtected != !string.IsNullOrEmpty(GamePassword))
{
gameServer.IsPasswordProtected = !string.IsNullOrEmpty(GamePassword);
@@ -1488,7 +1510,7 @@ await this.SetDvarAsync("sv_sayname", CustomSayName,
MaxClients = maxplayers;
FSGame = game.Value;
Gametype = gametype;
- IP = ip.Value is "localhost" or "0.0.0.0" ? ServerConfig.IPAddress : ip.Value ?? ServerConfig.IPAddress;
+ IP = ServerConfig.IPAddress;
GamePassword = gamePassword.Value;
PrivateClientSlots = privateClients.Value;
diff --git a/Application/Main.cs b/Application/Main.cs
index 555b28c93..9e653fc25 100644
--- a/Application/Main.cs
+++ b/Application/Main.cs
@@ -517,7 +517,7 @@ private static void ConfigureServices(IServiceCollection serviceCollection)
var httpClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true })
{
BaseAddress = masterUri,
- Timeout = TimeSpan.FromSeconds(15)
+ Timeout = Utilities.IsDevelopment ? TimeSpan.FromMilliseconds(500) : TimeSpan.FromSeconds(15)
};
var masterRestClient = RestService.For(httpClient);
var translationLookup = Configure.Initialize(Utilities.DefaultLogger, masterRestClient, appConfig);
diff --git a/Application/Misc/ServerDataCollector.cs b/Application/Misc/ServerDataCollector.cs
index 0bbc2d3ef..3adc3339a 100644
--- a/Application/Misc/ServerDataCollector.cs
+++ b/Application/Misc/ServerDataCollector.cs
@@ -30,6 +30,12 @@ public class ServerDataCollector : IServerDataCollector
private bool _inProgress;
private TimeSpan _period;
+ // Serializes GetOrCreateMap across all servers in a single collection tick so that
+ // two servers discovering the same new map cannot both insert an EFMaps row for it.
+ // Contention is negligible — collection runs on a long interval, serving ~1 call
+ // per server per tick, and EFMaps reads/writes are cheap.
+ private readonly SemaphoreSlim _getOrCreateMapLock = new(1, 1);
+
public ServerDataCollector(ILogger logger, ApplicationConfiguration appConfig,
IManager manager, IDatabaseContextFactory contextFactory)
{
@@ -97,25 +103,32 @@ private async Task> BuildCollectionData(Cancellati
private async Task GetOrCreateMap(string mapName, Reference.Game game, CancellationToken token)
{
- await using var context = _contextFactory.CreateContext();
- var existingMap =
- await context.Maps.FirstOrDefaultAsync(map => map.Name == mapName && map.Game == game, token);
-
- if (existingMap != null)
+ await _getOrCreateMapLock.WaitAsync(token);
+ try
{
- return existingMap.MapId;
- }
+ await using var context = _contextFactory.CreateContext();
+ var existingMap = await context.Maps
+ .FirstOrDefaultAsync(map => map.Name == mapName && map.Game == game, token);
- var newMap = new EFMap
- {
- Name = mapName,
- Game = game
- };
+ if (existingMap != null)
+ {
+ return existingMap.MapId;
+ }
- context.Maps.Add(newMap);
- await context.SaveChangesAsync(token);
+ var newMap = new EFMap
+ {
+ Name = mapName,
+ Game = game
+ };
- return newMap.MapId;
+ context.Maps.Add(newMap);
+ await context.SaveChangesAsync(token);
+ return newMap.MapId;
+ }
+ finally
+ {
+ _getOrCreateMapLock.Release();
+ }
}
private async Task SaveData(IEnumerable snapshots, CancellationToken token)
diff --git a/Application/Misc/ServerDataViewer.cs b/Application/Misc/ServerDataViewer.cs
index aad6a3af3..3e2b3befd 100644
--- a/Application/Misc/ServerDataViewer.cs
+++ b/Application/Misc/ServerDataViewer.cs
@@ -8,6 +8,7 @@
using Data.Models.Client;
using Data.Models.Client.Stats;
using Data.Models.Server;
+using IW4MAdmin.Plugins.Stats.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
@@ -25,31 +26,35 @@ public class ServerDataViewer : IServerDataViewer
private readonly IDataValueCache _serverStatsCache;
private readonly IDataValueCache> _clientHistoryCache;
private readonly IDataValueCache _rankedClientsCache;
+ private readonly StatManager _statManager;
private readonly TimeSpan? _cacheTimeSpan =
- Utilities.IsDevelopment ? TimeSpan.FromSeconds(30) : (TimeSpan?) TimeSpan.FromMinutes(10);
+ Utilities.IsDevelopment ? TimeSpan.FromSeconds(30) : (TimeSpan?)TimeSpan.FromMinutes(10);
public ServerDataViewer(ILogger logger, IDataValueCache snapshotCache,
IDataValueCache serverStatsCache,
- IDataValueCache> clientHistoryCache, IDataValueCache rankedClientsCache)
+ IDataValueCache> clientHistoryCache,
+ IDataValueCache rankedClientsCache, StatManager statManager)
{
_logger = logger;
_snapshotCache = snapshotCache;
_serverStatsCache = serverStatsCache;
_clientHistoryCache = clientHistoryCache;
_rankedClientsCache = rankedClientsCache;
+ _statManager = statManager;
}
- public async Task<(int?, DateTime?)>
+ public async Task<(int?, DateTime?)>
MaxConcurrentClientsAsync(long? serverId = null, Reference.Game? gameCode = null, TimeSpan? overPeriod = null,
- CancellationToken token = default)
+ CancellationToken token = default)
{
- _snapshotCache.SetCacheItem(async (snapshots, ids, cancellationToken) =>
+ _snapshotCache.SetCacheItem(async (snapshots, idsList, cancellationToken) =>
{
Reference.Game? game = null;
long? id = null;
- if (ids.Any())
+ var ids = idsList.ToList();
+ if (ids.Count is not 0)
{
game = (Reference.Game?)ids.First();
id = (long?)ids.Last();
@@ -99,12 +104,11 @@ public ServerDataViewer(ILogger logger, IDataValueCache logger, IDataValueCache ClientCountsAsync(TimeSpan? overPeriod = null, Reference.Game? gameCode = null, CancellationToken token = default)
+ public async Task<(int, int)> ClientCountsAsync(TimeSpan? overPeriod = null, Reference.Game? gameCode = null,
+ CancellationToken token = default)
{
_serverStatsCache.SetCacheItem(async (set, ids, cancellationToken) =>
{
Reference.Game? game = null;
-
+
if (ids.Any())
{
game = (Reference.Game?)ids.First();
}
-
+
var count = await set.CountAsync(item => game == null || item.GameName == game,
cancellationToken);
var startOfPeriod =
DateTime.UtcNow.AddHours(-overPeriod?.TotalHours ?? -24);
- var recentCount = await set.CountAsync(client => (game == null || client.GameName == game) && client.LastConnection >= startOfPeriod,
+ var recentCount = await set.CountAsync(
+ client => (game == null || client.GameName == game) && client.LastConnection >= startOfPeriod,
cancellationToken);
return (count, recentCount);
@@ -170,7 +176,10 @@ public async Task> ClientHistoryAsync(TimeSpan? o
{
ServerId = byServer.Key,
ClientCounts = byServer.Select(snapshot => new ClientCountSnapshot
- { Time = snapshot.CapturedAt, ClientCount = snapshot.ClientCount, ConnectionInterrupted = snapshot.ConnectionInterrupted ?? false, Map = snapshot.MapName}).ToList()
+ {
+ Time = snapshot.CapturedAt, ClientCount = snapshot.ClientCount,
+ ConnectionInterrupted = snapshot.ConnectionInterrupted ?? false, Map = snapshot.MapName
+ }).ToList()
}).ToList();
}, nameof(_clientHistoryCache), TimeSpan.MaxValue);
@@ -181,34 +190,38 @@ public async Task> ClientHistoryAsync(TimeSpan? o
catch (Exception ex)
{
_logger.LogError(ex, "Could not retrieve data for {Name}", nameof(ClientHistoryAsync));
- return Enumerable.Empty();
+ return [];
}
}
- public async Task RankedClientsCountAsync(long? serverId = null, CancellationToken token = default)
+ public async Task RankedClientsCountAsync(long? serverId = null, string performanceBucketCode = null,
+ CancellationToken token = default)
{
- _rankedClientsCache.SetCacheItem((set, ids, cancellationToken) =>
+ _rankedClientsCache.SetCacheItem(async (set, idsList, cancellationToken) =>
{
long? id = null;
-
- if (ids.Any())
+ string bucket = null;
+
+ var ids = idsList.ToList();
+ if (ids.Count is not 0)
{
id = (long?)ids.First();
}
- var fifteenDaysAgo = Plugins.Stats.Extensions.FifteenDaysAgo();
- return set
- .Where(rating => rating.Newest)
- .Where(rating => rating.ServerId == id)
- .Where(rating => rating.CreatedDateTime >= fifteenDaysAgo)
- .Where(rating => rating.Client.Level != EFClient.Permission.Banned)
- .Where(rating => rating.Ranking != null)
- .CountAsync(cancellationToken);
- }, nameof(_rankedClientsCache), new object[] { serverId }, _cacheTimeSpan);
-
+ if (ids.Count is 2)
+ {
+ bucket = (string)ids.Last();
+ }
+
+ // GetTotalRankedPlayers resolves its own bucket config — no need to pre-fetch.
+ // The prior .ContinueWith(...).Result pattern blocked sync-over-async and
+ // orphaned the DbContext inside GetBucketConfig, leaking Npgsql connections.
+ return await _statManager.GetTotalRankedPlayers(id, bucket);
+ }, nameof(_rankedClientsCache), [serverId, performanceBucketCode], _cacheTimeSpan);
+
try
{
- return await _rankedClientsCache.GetCacheItem(nameof(_rankedClientsCache), new object[] { serverId }, token);
+ return await _rankedClientsCache.GetCacheItem(nameof(_rankedClientsCache), [serverId, performanceBucketCode], token);
}
catch (Exception ex)
{
diff --git a/Application/RConParsers/BaseRConParser.cs b/Application/RConParsers/BaseRConParser.cs
index ba238e6e8..f21d898d5 100644
--- a/Application/RConParsers/BaseRConParser.cs
+++ b/Application/RConParsers/BaseRConParser.cs
@@ -218,13 +218,13 @@ private T GetValueFromStatus(IEnumerable response, ParserRegex.GroupT
return (T)Convert.ChangeType(value, typeof(T));
}
- public async Task SetDvarAsync(IRConConnection connection, string dvarName, object dvarValue, CancellationToken token = default)
+ public async Task SetDvarAsync(IRConConnection connection, string dvarName, object dvarValue, CancellationToken token = default, Action onPacketSent = null)
{
var dvarString = (dvarValue is string str)
? $"{dvarName} \"{str}\""
: $"{dvarName} {dvarValue}";
- return (await connection.SendQueryAsync(StaticHelpers.QueryType.SET_DVAR, dvarString, token)).Length > 0;
+ return (await connection.SendQueryAsync(StaticHelpers.QueryType.SET_DVAR, dvarString, token, onPacketSent)).Length > 0;
}
public void BeginSetDvar(IRConConnection connection, string dvarName, object dvarValue, AsyncCallback callback,
diff --git a/Application/Services/ServerLatencyMonitoringService.cs b/Application/Services/ServerLatencyMonitoringService.cs
index abd91d619..bb954d3b4 100644
--- a/Application/Services/ServerLatencyMonitoringService.cs
+++ b/Application/Services/ServerLatencyMonitoringService.cs
@@ -22,8 +22,10 @@ public class ServerLatencyMonitoringService(Server server, ApplicationConfigurat
private const string DvarProbe = "sv_iw4madmin_probe";
private const string DvarLatencyProbe = "sv_iw4madmin_latencyprobe";
private static readonly TimeSpan StaleProbeThreshold = TimeSpan.FromSeconds(30);
+ private const double ProbeJitterFraction = 0.2;
private readonly ConcurrentDictionary _pendingProbes = new();
+ private readonly Random _jitterRng = new();
private Timer _probeTimer;
private bool _gscDetected;
@@ -33,33 +35,33 @@ public class ServerLatencyMonitoringService(Server server, ApplicationConfigurat
public void Start(CancellationToken cancellationToken)
{
IGameServerEventSubscriptions.ServerStatusReceived += OnServerStatusReceived;
- IGameEventSubscriptions.ScriptEventTriggered += OnScriptEventTriggered;
- if (appConfig.LatencyProbeIntervalMs > 0 && server.IsLegacyGameIntegrationEnabled)
+ if (appConfig.LatencyProbeIntervalMs > 0)
{
+ // GSC presence is determined by DetectGscCompanionAsync probing
+ // sv_iw4madmin_latencyprobe directly — no pre-gate required, and
+ // any pre-gate based on legacy flags (e.g. sv_customcallbacks)
+ // produces false negatives on games that don't use them.
+ IGameEventSubscriptions.ScriptEventTriggered += OnScriptEventTriggered;
IGameEventSubscriptions.MatchStarted += OnMatchStarted;
// Initial detection attempt after a short delay to allow GSC to initialize
_ = new Timer(OnInitialDetection, null, appConfig.LatencyProbeIntervalMs, Timeout.Infinite);
-
- cancellationToken.Register(() =>
- {
- _probeTimer?.Dispose();
- IGameServerEventSubscriptions.ServerStatusReceived -= OnServerStatusReceived;
- IGameEventSubscriptions.ScriptEventTriggered -= OnScriptEventTriggered;
- IGameEventSubscriptions.MatchStarted -= OnMatchStarted;
- _pendingProbes.Clear();
- });
}
- else
+
+ cancellationToken.Register(() =>
{
- cancellationToken.Register(() =>
+ _probeTimer?.Dispose();
+ IGameServerEventSubscriptions.ServerStatusReceived -= OnServerStatusReceived;
+
+ if (appConfig.LatencyProbeIntervalMs > 0)
{
- IGameServerEventSubscriptions.ServerStatusReceived -= OnServerStatusReceived;
IGameEventSubscriptions.ScriptEventTriggered -= OnScriptEventTriggered;
- _pendingProbes.Clear();
- });
- }
+ IGameEventSubscriptions.MatchStarted -= OnMatchStarted;
+ }
+
+ _pendingProbes.Clear();
+ });
}
private Task OnServerStatusReceived(ServerStatusReceiveEvent statusEvent, CancellationToken token)
@@ -86,12 +88,31 @@ private Task OnScriptEventTriggered(GameScriptEvent scriptEvent, CancellationTok
if (!string.IsNullOrEmpty(probe.ProbeId) && _pendingProbes.TryRemove(probe.ProbeId, out var sendTime))
{
- var latencyMs = (DateTime.UtcNow - sendTime).TotalMilliseconds;
- LatencyMetrics.RecordLogProbeLatency(latencyMs);
+ var totalMs = (DateTime.UtcNow - sendTime).TotalMilliseconds;
+
+ // GameLogIngestMs is the path a natural log line takes — game-write →
+ // GLS poll → IW4MAdmin parse — and explicitly does NOT include the RCon
+ // out-leg the probe used to start the clock. Subtract one-way RCon delivery
+ // (rtt/2) from the measured total. If RCon RTT isn't established yet, skip
+ // this sample rather than record an inflated number; EMA recovers on the
+ // next probe once RTT samples accumulate.
+ var rtt = LatencyMetrics.RconRoundTripMs;
+ if (rtt is null)
+ {
+ using (LogContext.PushProperty("Server", server.Id))
+ {
+ logger.LogDebug("Log probe {ProbeId} dropped: RCon RTT not yet stable", probe.ProbeId);
+ }
+ return Task.CompletedTask;
+ }
+
+ var pipelineMs = Math.Max(0, totalMs - rtt.Value / 2.0);
+ LatencyMetrics.RecordLogProbeLatency(pipelineMs);
using (LogContext.PushProperty("Server", server.Id))
{
- logger.LogDebug("Log probe {ProbeId} latency: {LatencyMs:F1}ms", probe.ProbeId, latencyMs);
+ logger.LogDebug("Log probe {ProbeId} pipeline: {PipelineMs:F1}ms (total {TotalMs:F1}ms - rtt/2 {HalfRtt:F1}ms)",
+ probe.ProbeId, pipelineMs, totalMs, rtt.Value / 2.0);
}
}
@@ -134,13 +155,14 @@ private async Task TryDetectAndStartProbing()
if (_gscDetected && _probeTimer is null)
{
- _probeTimer = new Timer(OnProbeTimerElapsed, null, appConfig.LatencyProbeIntervalMs,
- appConfig.LatencyProbeIntervalMs);
+ // One-shot timer; OnProbeTimerElapsed re-arms with a jittered delay each
+ // cycle so probes don't phase-lock to the GameLogReader poll cycle.
+ _probeTimer = new Timer(OnProbeTimerElapsed, null, NextJitteredDelayMs(), Timeout.Infinite);
using (LogContext.PushProperty("Server", server.Id))
{
- logger.LogInformation("Latency probe started (interval: {Interval}ms)",
- appConfig.LatencyProbeIntervalMs);
+ logger.LogInformation("Latency probe started (interval: {Interval}ms ±{JitterPct:P0})",
+ appConfig.LatencyProbeIntervalMs, ProbeJitterFraction);
}
}
}
@@ -159,16 +181,33 @@ private async void OnProbeTimerElapsed(object state)
logger.LogWarning(ex, "Error in latency probe timer");
}
}
+ finally
+ {
+ _probeTimer?.Change(NextJitteredDelayMs(), Timeout.Infinite);
+ }
+ }
+
+ private int NextJitteredDelayMs()
+ {
+ var basePeriod = appConfig.LatencyProbeIntervalMs;
+ var jitterRange = (int)(basePeriod * ProbeJitterFraction);
+ // uniform offset in [-jitterRange, +jitterRange]
+ var offset = _jitterRng.Next(-jitterRange, jitterRange + 1);
+ return Math.Max(1, basePeriod + offset);
}
private async Task SendProbeAsync()
{
var probeId = Guid.NewGuid().ToString("N")[..8];
- _pendingProbes[probeId] = DateTime.UtcNow;
try
{
- await server.SetDvarAsync(DvarProbe, probeId, server.Manager.CancellationToken);
+ // T1 anchored to the actual UDP send moment via onPacketSent callback —
+ // strips C#-side queue/flood-protect/retry pollution from the measurement.
+ // TryAdd ensures retries (which re-fire the callback) don't overwrite the
+ // first send time; GSC echoes on whichever attempt arrives first.
+ await server.SetDvarAsync(DvarProbe, probeId, server.Manager.CancellationToken,
+ onPacketSent: sentAt => _pendingProbes.TryAdd(probeId, sentAt));
using (LogContext.PushProperty("Server", server.Id))
{
diff --git a/CLAUDE.md b/CLAUDE.md
index 67724525c..e009b2443 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,6 +54,32 @@ EF Core 9.0 with three supported providers (each has its own migration context i
Migrations live in `Data/Migrations/{Sqlite,Postgresql,MySql}/`. When adding a migration, you must add it for each provider using the corresponding context class.
+### Generating Migrations
+
+The `MigrationContext` classes require `ASPNETCORE_ENVIRONMENT=Migration` to enable their parameterless constructors. Use the `Data` project as both the project and startup project (not `Application`, which has a PreBuild script that fails under `dotnet ef`).
+
+```bash
+# SQLite
+ASPNETCORE_ENVIRONMENT=Migration dotnet ef migrations add \
+ -p Data/Data.csproj -s Data/Data.csproj \
+ -c Data.MigrationContext.SqliteDatabaseContext \
+ -o Migrations/Sqlite --configuration Release
+
+# PostgreSQL
+ASPNETCORE_ENVIRONMENT=Migration dotnet ef migrations add \
+ -p Data/Data.csproj -s Data/Data.csproj \
+ -c Data.MigrationContext.PostgresqlDatabaseContext \
+ -o Migrations/Postgresql --configuration Release
+
+# MySQL (requires a live MySQL server for ServerVersion.AutoDetect —
+# if unavailable, temporarily replace AutoDetect in MySqlDatabaseContext.cs
+# with: new MySqlServerVersion(new Version(8, 0, 35)), then revert after)
+ASPNETCORE_ENVIRONMENT=Migration dotnet ef migrations add \
+ -p Data/Data.csproj -s Data/Data.csproj \
+ -c Data.MigrationContext.MySqlDatabaseContext \
+ -o Migrations/MySql --configuration Release
+```
+
## Architecture
### Project Dependency Graph
diff --git a/Data/Context/DatabaseContext.cs b/Data/Context/DatabaseContext.cs
index 0d9b98d0f..6de8862f8 100644
--- a/Data/Context/DatabaseContext.cs
+++ b/Data/Context/DatabaseContext.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -9,6 +10,7 @@
using Data.Models.Client.Stats.Reference;
using Data.Models.Misc;
using Data.Models.Server;
+using Data.Models.Zombie;
namespace Data.Context
{
@@ -24,6 +26,7 @@ public abstract class DatabaseContext : DbContext
#region STATS
+ public DbSet PerformanceBuckets { get; set; }
public DbSet Vector3s { get; set; }
public DbSet SnapshotVector3s { get; set; }
public DbSet ACSnapshots { get; set; }
@@ -37,6 +40,9 @@ public abstract class DatabaseContext : DbContext
public DbSet HitStatistics { get; set; }
public DbSet Weapons { get; set; }
public DbSet WeaponAttachments { get; set; }
+
+ public DbSet ClientStatTags { get; set; }
+ public DbSet ClientStatTagValues { get; set; }
public DbSet Maps { get; set; }
#endregion
@@ -48,6 +54,18 @@ public abstract class DatabaseContext : DbContext
public DbSet ServerSnapshots { get;set; }
public DbSet ConnectionHistory { get; set; }
+ #endregion
+
+ #region Zombie
+
+ public DbSet ZombieMatches { get; set; }
+ public DbSet ZombieMatchClientStats { get; set; }
+ public DbSet ZombieRoundClientStats { get; set; }
+ public DbSet ZombieClientStatAggregates { get; set; }
+ public DbSet ZombieClientStatRecords { get; set; }
+ public DbSet ZombieEvents { get; set; }
+ public DbSet ZombieRoundDurationEmas { get; set; }
+
#endregion
private void SetAuditColumns()
@@ -63,10 +81,6 @@ public DatabaseContext()
}
}
- public DatabaseContext(DbContextOptions options) : base(options)
- {
- }
-
protected DatabaseContext(DbContextOptions options) : base(options)
{
}
@@ -84,6 +98,33 @@ public override int SaveChanges()
return base.SaveChanges();
}
+ ///
+ /// SQLite has no native type and the EF Core
+ /// SQLite provider can't translate ORDER BY on DateTimeOffset columns
+ /// to SQL (throws NotSupportedException at query compile time). The
+ /// canonical fix is a value converter that stores DateTimeOffset as a long
+ /// (binary tick representation) — comparable, ORDER BY-able, and round-trips
+ /// the offset losslessly via .
+ ///
+ /// Postgres + MySQL handle DateTimeOffset natively, so the converter is only
+ /// applied when running on SQLite (detected via ).
+ /// Apply via ConfigureConventions so it covers every DateTimeOffset
+ /// property in every entity globally — no risk of forgetting one as new
+ /// models are added.
+ ///
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ base.ConfigureConventions(configurationBuilder);
+
+ if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite")
+ {
+ configurationBuilder.Properties()
+ .HaveConversion();
+ configurationBuilder.Properties()
+ .HaveConversion();
+ }
+ }
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// make network id unique
@@ -175,7 +216,69 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity().ToTable("EFPenaltyIdentifiers");
modelBuilder.Entity().ToTable(nameof(EFServerSnapshot));
modelBuilder.Entity().ToTable(nameof(EFClientConnectionHistory));
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieMatches)}");
+ // Index supports the GSC stitching lookup: TrackClient checks
+ // for an open match on this server with the same GameMatchId.
+ ent.HasIndex(m => new { m.ServerId, m.GameMatchId, m.MatchEndDate });
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieClientStat)}s");
+ ent.HasOne(prop => prop.Client)
+ .WithMany(prop => prop.ZombieClientStats)
+ .HasForeignKey(prop => prop.ClientId);
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieMatchClientStats)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieRoundClientStats)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieClientStatAggregates)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieEvents)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieClientStatRecords)}");
+ });
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ZombieRoundDurationEmas)}");
+ ent.HasKey(e => new { e.MapId, e.RoundNumber, e.PlayerCount });
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(PerformanceBuckets)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ClientStatTags)}");
+ });
+
+ modelBuilder.Entity(ent =>
+ {
+ ent.ToTable($"EF{nameof(ClientStatTagValues)}");
+ });
+
Models.Configuration.StatsModelConfiguration.Configure(modelBuilder);
base.OnModelCreating(modelBuilder);
diff --git a/Data/MigrationContext/MySqlDatabaseContext.cs b/Data/MigrationContext/MySqlDatabaseContext.cs
index cddffacd1..60e7a7d09 100644
--- a/Data/MigrationContext/MySqlDatabaseContext.cs
+++ b/Data/MigrationContext/MySqlDatabaseContext.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using Data.Context;
using Data.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -24,6 +24,9 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
if (MigrationExtensions.IsMigration)
{
+ // Note: ServerVersion.AutoDetect requires a live MySQL server connection.
+ // To generate migrations without a running server, temporarily replace with:
+ // new MySqlServerVersion(new Version(8, 0, 35)) then revert after generation.
var connectionString = "Server=127.0.0.1;Database=IW4MAdmin_Migration;Uid=root;Pwd=password;";
optionsBuilder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString))
.EnableDetailedErrors()
diff --git a/Data/Migrations/MySql/20260411135005_AddZombieStats.Designer.cs b/Data/Migrations/MySql/20260411135005_AddZombieStats.Designer.cs
new file mode 100644
index 000000000..8fc399002
--- /dev/null
+++ b/Data/Migrations/MySql/20260411135005_AddZombieStats.Designer.cs
@@ -0,0 +1,2281 @@
+//
+using System;
+using Data.MigrationContext;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Data.Migrations.MySql
+{
+ [DbContext(typeof(MySqlDatabaseContext))]
+ [Migration("20260411135005_AddZombieStats")]
+ partial class AddZombieStats
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("Data.Models.Client.EFACSnapshotVector3", b =>
+ {
+ b.Property("ACSnapshotVector3Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ACSnapshotVector3Id"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("SnapshotId")
+ .HasColumnType("int");
+
+ b.Property("Vector3Id")
+ .HasColumnType("int");
+
+ b.HasKey("ACSnapshotVector3Id");
+
+ b.HasIndex("SnapshotId");
+
+ b.HasIndex("Vector3Id");
+
+ b.ToTable("EFACSnapshotVector3", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.EFClient", b =>
+ {
+ b.Property("ClientId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ClientId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AliasLinkId")
+ .HasColumnType("int");
+
+ b.Property("Connections")
+ .HasColumnType("int");
+
+ b.Property("CurrentAliasId")
+ .HasColumnType("int");
+
+ b.Property("FirstConnection")
+ .HasColumnType("datetime(6)");
+
+ b.Property("GameName")
+ .HasColumnType("int");
+
+ b.Property("LastConnection")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Level")
+ .HasColumnType("int");
+
+ b.Property("Masked")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("NetworkId")
+ .HasColumnType("bigint");
+
+ b.Property("Password")
+ .HasColumnType("longtext");
+
+ b.Property("PasswordSalt")
+ .HasColumnType("longtext");
+
+ b.Property("TotalConnectionTime")
+ .HasColumnType("int");
+
+ b.Property("TwoFactorBackupCodes")
+ .HasColumnType("longtext");
+
+ b.Property("TwoFactorSecret")
+ .HasColumnType("longtext");
+
+ b.HasKey("ClientId");
+
+ b.HasAlternateKey("NetworkId", "GameName");
+
+ b.HasIndex("AliasLinkId");
+
+ b.HasIndex("CurrentAliasId");
+
+ b.HasIndex("LastConnection");
+
+ b.HasIndex("NetworkId");
+
+ b.ToTable("EFClients", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.EFClientConnectionHistory", b =>
+ {
+ b.Property("ClientConnectionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ClientConnectionId"));
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("ConnectionType")
+ .HasColumnType("int");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("ClientConnectionId");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("CreatedDateTime");
+
+ b.HasIndex("ServerId");
+
+ b.ToTable("EFClientConnectionHistory", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.EFClientKill", b =>
+ {
+ b.Property("KillId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("KillId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AttackerId")
+ .HasColumnType("int");
+
+ b.Property("Damage")
+ .HasColumnType("int");
+
+ b.Property("DeathOriginVector3Id")
+ .HasColumnType("int");
+
+ b.Property("DeathType")
+ .HasColumnType("int");
+
+ b.Property("Fraction")
+ .HasColumnType("double");
+
+ b.Property("HitLoc")
+ .HasColumnType("int");
+
+ b.Property("IsKill")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("KillOriginVector3Id")
+ .HasColumnType("int");
+
+ b.Property("Map")
+ .HasColumnType("int");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("VictimId")
+ .HasColumnType("int");
+
+ b.Property("ViewAnglesVector3Id")
+ .HasColumnType("int");
+
+ b.Property("VisibilityPercentage")
+ .HasColumnType("double");
+
+ b.Property("Weapon")
+ .HasColumnType("int");
+
+ b.Property("WeaponReference")
+ .HasColumnType("longtext");
+
+ b.Property("When")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("KillId");
+
+ b.HasIndex("AttackerId");
+
+ b.HasIndex("DeathOriginVector3Id");
+
+ b.HasIndex("KillOriginVector3Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("VictimId");
+
+ b.HasIndex("ViewAnglesVector3Id");
+
+ b.ToTable("EFClientKills", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.EFClientMessage", b =>
+ {
+ b.Property("MessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("MessageId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("Message")
+ .HasColumnType("longtext");
+
+ b.Property("SentIngame")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("TimeSent")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("MessageId");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("TimeSent");
+
+ b.ToTable("EFClientMessages", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFACSnapshot", b =>
+ {
+ b.Property("SnapshotId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("SnapshotId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("CurrentSessionLength")
+ .HasColumnType("int");
+
+ b.Property("CurrentStrain")
+ .HasColumnType("double");
+
+ b.Property("CurrentViewAngleId")
+ .HasColumnType("int");
+
+ b.Property("Deaths")
+ .HasColumnType("int");
+
+ b.Property("Distance")
+ .HasColumnType("double");
+
+ b.Property("EloRating")
+ .HasColumnType("double");
+
+ b.Property("HitDestinationId")
+ .HasColumnType("int");
+
+ b.Property("HitLocation")
+ .HasColumnType("int");
+
+ b.Property("HitLocationReference")
+ .HasColumnType("longtext");
+
+ b.Property("HitOriginId")
+ .HasColumnType("int");
+
+ b.Property("HitType")
+ .HasColumnType("int");
+
+ b.Property("Hits")
+ .HasColumnType("int");
+
+ b.Property("Kills")
+ .HasColumnType("int");
+
+ b.Property("LastStrainAngleId")
+ .HasColumnType("int");
+
+ b.Property("RecoilOffset")
+ .HasColumnType("double");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("SessionAngleOffset")
+ .HasColumnType("double");
+
+ b.Property("SessionAverageSnapValue")
+ .HasColumnType("double");
+
+ b.Property("SessionSPM")
+ .HasColumnType("double");
+
+ b.Property("SessionScore")
+ .HasColumnType("int");
+
+ b.Property("SessionSnapHits")
+ .HasColumnType("int");
+
+ b.Property("StrainAngleBetween")
+ .HasColumnType("double");
+
+ b.Property("TimeSinceLastEvent")
+ .HasColumnType("int");
+
+ b.Property("WeaponId")
+ .HasColumnType("int");
+
+ b.Property("WeaponReference")
+ .HasColumnType("longtext");
+
+ b.Property("When")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("SnapshotId");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("CurrentViewAngleId");
+
+ b.HasIndex("HitDestinationId");
+
+ b.HasIndex("HitOriginId");
+
+ b.HasIndex("LastStrainAngleId");
+
+ b.HasIndex("ServerId");
+
+ b.ToTable("EFACSnapshot", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientHitStatistic", b =>
+ {
+ b.Property("ClientHitStatisticId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ClientHitStatisticId"));
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DamageInflicted")
+ .HasColumnType("int");
+
+ b.Property("DamageReceived")
+ .HasColumnType("int");
+
+ b.Property("DeathCount")
+ .HasColumnType("int");
+
+ b.Property("HitCount")
+ .HasColumnType("int");
+
+ b.Property("HitLocationId")
+ .HasColumnType("int");
+
+ b.Property("KillCount")
+ .HasColumnType("int");
+
+ b.Property("MeansOfDeathId")
+ .HasColumnType("int");
+
+ b.Property("PerformanceBucketId")
+ .HasColumnType("int");
+
+ b.Property("ReceivedHitCount")
+ .HasColumnType("int");
+
+ b.Property("Score")
+ .HasColumnType("int");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("SuicideCount")
+ .HasColumnType("int");
+
+ b.Property("UpdatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UsageSeconds")
+ .HasColumnType("int");
+
+ b.Property("WeaponAttachmentComboId")
+ .HasColumnType("int");
+
+ b.Property("WeaponId")
+ .HasColumnType("int");
+
+ b.HasKey("ClientHitStatisticId");
+
+ b.HasIndex("HitLocationId");
+
+ b.HasIndex("MeansOfDeathId");
+
+ b.HasIndex("PerformanceBucketId");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("WeaponAttachmentComboId");
+
+ b.HasIndex("WeaponId");
+
+ b.HasIndex("ClientId", "ServerId");
+
+ b.ToTable("EFClientHitStatistics", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientRankingHistory", b =>
+ {
+ b.Property("ClientRankingHistoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ClientRankingHistoryId"));
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Newest")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("PerformanceBucketId")
+ .HasColumnType("int");
+
+ b.Property("PerformanceMetric")
+ .HasColumnType("double");
+
+ b.Property("Ranking")
+ .HasColumnType("int");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ZScore")
+ .HasColumnType("double");
+
+ b.HasKey("ClientRankingHistoryId");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("CreatedDateTime");
+
+ b.HasIndex("PerformanceBucketId");
+
+ b.HasIndex("Ranking");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("UpdatedDateTime");
+
+ b.HasIndex("ZScore");
+
+ b.ToTable("EFClientRankingHistory", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientRatingHistory", b =>
+ {
+ b.Property("RatingHistoryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RatingHistoryId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.HasKey("RatingHistoryId");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("EFClientRatingHistory", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientStatTag", b =>
+ {
+ b.Property("ZombieStatTagId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ZombieStatTagId"));
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TagName")
+ .HasMaxLength(128)
+ .HasColumnType("varchar(128)");
+
+ b.Property("UpdatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("ZombieStatTagId");
+
+ b.ToTable("EFClientStatTags", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientStatTagValue", b =>
+ {
+ b.Property("ZombieClientStatTagValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("ZombieClientStatTagValueId"));
+
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("CreatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.Property("StatTagId")
+ .HasColumnType("int");
+
+ b.Property("StatValue")
+ .HasColumnType("int");
+
+ b.Property("UpdatedDateTime")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("ZombieClientStatTagValueId");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("StatTagId");
+
+ b.ToTable("EFClientStatTagValues", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFClientStatistics", b =>
+ {
+ b.Property("ClientId")
+ .HasColumnType("int");
+
+ b.Property("ServerId")
+ .HasColumnType("bigint");
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("AverageSnapValue")
+ .HasColumnType("double");
+
+ b.Property("Deaths")
+ .HasColumnType("int");
+
+ b.Property("EloRating")
+ .HasColumnType("double");
+
+ b.Property("Kills")
+ .HasColumnType("int");
+
+ b.Property("MaxStrain")
+ .HasColumnType("double");
+
+ b.Property("RollingWeightedKDR")
+ .HasColumnType("double");
+
+ b.Property("SPM")
+ .HasColumnType("double");
+
+ b.Property("Skill")
+ .HasColumnType("double");
+
+ b.Property("SnapHitCount")
+ .HasColumnType("int");
+
+ b.Property("TimePlayed")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ZScore")
+ .HasColumnType("double");
+
+ b.HasKey("ClientId", "ServerId");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("ZScore");
+
+ b.HasIndex("ClientId", "TimePlayed", "ZScore");
+
+ b.ToTable("EFClientStatistics", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFHitLocationCount", b =>
+ {
+ b.Property("HitLocationCountId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("HitLocationCountId"));
+
+ b.Property("Active")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("EFClientStatisticsClientId")
+ .HasColumnType("int")
+ .HasColumnName("EFClientStatisticsClientId");
+
+ b.Property("EFClientStatisticsServerId")
+ .HasColumnType("bigint")
+ .HasColumnName("EFClientStatisticsServerId");
+
+ b.Property("HitCount")
+ .HasColumnType("int");
+
+ b.Property("HitOffsetAverage")
+ .HasColumnType("float");
+
+ b.Property("Location")
+ .HasColumnType("int");
+
+ b.Property("MaxAngleDistance")
+ .HasColumnType("float");
+
+ b.HasKey("HitLocationCountId");
+
+ b.HasIndex("EFClientStatisticsServerId");
+
+ b.HasIndex("EFClientStatisticsClientId", "EFClientStatisticsServerId");
+
+ b.ToTable("EFHitLocationCounts", (string)null);
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFPerformanceBucket", b =>
+ {
+ b.Property("PerformanceBucketId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("PerformanceBucketId"));
+
+ b.Property("Code")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.HasKey("PerformanceBucketId");
+
+ b.ToTable("PerformanceBuckets");
+ });
+
+ modelBuilder.Entity("Data.Models.Client.Stats.EFRating", b =>
+ {
+ b.Property("RatingId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property