diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b4958b48 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +.git/ +.github/ +CustomNugets/ diff --git a/.editorconfig b/.editorconfig index 1878d48f..64fe0359 100644 --- a/.editorconfig +++ b/.editorconfig @@ -703,6 +703,9 @@ dotnet_diagnostic.IDE0280.severity = suggestion # Disagree about clarity of primary constructor dotnet_diagnostic.IDE0290.severity = none +# .ToList() is just fine +dotnet_diagnostic.IDE0305.severity = none + ## ## StyleCop.Analyzers ## diff --git a/.gitattributes b/.gitattributes index a47ecf23..3cb0752b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,5 @@ * text auto +*.sh text eol=lf *.cdr -text *.exe -text *.dll -text diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..8c14e2dc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build and test + +# Minimal CI: builds the self-contained server solution and runs the test project. +# The BlazorServer client is intentionally excluded because it consumes the Api/Common +# packages from the local CustomNugets feed produced by the build pipeline, which is not +# available on a clean CI runner. + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore BtDamageResolver/BTDamageResolver.slnx + + - name: Build + run: dotnet build BtDamageResolver/BTDamageResolver.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test BtDamageResolver/tests/Tests/Tests.csproj --configuration Release --no-build diff --git a/.gitignore b/.gitignore index d4fe3086..b4859aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.userosscache *.sln.docstates *.ruleset +**dpkeys # Build results [Dd]ebug/ @@ -42,7 +43,6 @@ dlldata.c project.lock.json project.fragment.lock.json artifacts/ -**/Properties/launchSettings.json # StyleCop StyleCopReport.xml @@ -161,8 +161,6 @@ __pycache__/ # Docker-Compose environment infra/.env -# Possible sensitive resource files -infra/dpkeys/ - # Debug settings containing password data *.Debug.json +*.Development.json diff --git a/BtDamageResolver/data/ammo/Ammo_AC.json b/BtDamageResolver/data/ammo/Ammo_AC.json index 5cda3b8b..772b163e 100644 --- a/BtDamageResolver/data/ammo/Ammo_AC.json +++ b/BtDamageResolver/data/ammo/Ammo_AC.json @@ -1,6 +1,7 @@ [ { "Name": "AC-AP2", + "AmmoProxyName": "AP", "HitModifier": 1, "SpecialFeatures": [ { "Data": "-4", "Type": "ArmorPiercing" }, @@ -10,6 +11,7 @@ }, { "Name": "AC-AP5", + "AmmoProxyName": "AP", "HitModifier": 1, "SpecialFeatures": [ { "Data": "-3", "Type": "ArmorPiercing" }, @@ -19,6 +21,7 @@ }, { "Name": "AC-AP10", + "AmmoProxyName": "AP", "HitModifier": 1, "SpecialFeatures": [ { "Data": "-2", "Type": "ArmorPiercing" }, @@ -28,6 +31,7 @@ }, { "Name": "AC-AP20", + "AmmoProxyName": "AP", "HitModifier": 1, "SpecialFeatures": [ { "Data": "-1", "Type": "ArmorPiercing" }, @@ -35,8 +39,17 @@ { "Type": "TargetingComputerValidAimed" } ] }, + { + "Name": "AC-Slug", + "AmmoProxyName": "Slug", + "SpecialFeatures": [ + { "Type": "TargetingComputerValid" }, + { "Type": "TargetingComputerValidAimed" } + ] + }, { "Name": "AC-LBXCluster", + "AmmoProxyName": "Cluster", "HitModifier": -1, "SpecialFeatures": [ { "Type": "Cluster" }, @@ -47,6 +60,7 @@ }, { "Name": "AC-Single", + "AmmoProxyName": "Normal", "SpecialFeatures": [ { "Type": "TargetingComputerValid" }, { "Type": "TargetingComputerValidAimed" } diff --git a/BtDamageResolver/data/ammo/Ammo_ATM.json b/BtDamageResolver/data/ammo/Ammo_ATM.json index c455cc08..2df21a11 100644 --- a/BtDamageResolver/data/ammo/Ammo_ATM.json +++ b/BtDamageResolver/data/ammo/Ammo_ATM.json @@ -1,6 +1,7 @@ [ { "Name": "ATM3-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 9 }, @@ -19,6 +20,7 @@ }, { "Name": "ATM3-MRM", + "AmmoProxyName": "MRM", "Damage": { "Short": 6 }, @@ -38,6 +40,7 @@ }, { "Name": "ATM3-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 3 }, @@ -57,6 +60,7 @@ }, { "Name": "ATM6-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 18 }, @@ -75,6 +79,7 @@ }, { "Name": "ATM6-MRM", + "AmmoProxyName": "MRM", "Damage": { "Short": 12 }, @@ -94,6 +99,7 @@ }, { "Name": "ATM6-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 6 }, @@ -113,6 +119,7 @@ }, { "Name": "ATM9-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 27 }, @@ -131,6 +138,7 @@ }, { "Name": "ATM9-MRM", + "AmmoProxyName": "MRM", "Damage": { "Short": 18 }, @@ -150,6 +158,7 @@ }, { "Name": "ATM9-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 9 }, @@ -169,6 +178,7 @@ }, { "Name": "ATM12-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 36 }, @@ -187,6 +197,7 @@ }, { "Name": "ATM12-MRM", + "AmmoProxyName": "MRM", "Damage": { "Short": 24 }, @@ -206,6 +217,7 @@ }, { "Name": "ATM12-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 12 }, @@ -223,4 +235,4 @@ "RangeMinimum": 4, "SpecialFeatures": [ { "Type": "Cluster" } ] } -] \ No newline at end of file +] diff --git a/BtDamageResolver/data/ammo/Ammo_Generic.json b/BtDamageResolver/data/ammo/Ammo_Generic.json index 64ddec29..66e0fbd2 100644 --- a/BtDamageResolver/data/ammo/Ammo_Generic.json +++ b/BtDamageResolver/data/ammo/Ammo_Generic.json @@ -6,7 +6,7 @@ "Name": "Missile", "SpecialFeatures": [ { "Type": "Cluster" } ] }, - { + { "Name": "Missile+Artemis", "SpecialFeatures": [ { "Type": "Cluster" } ], "ClusterBonus": { @@ -17,6 +17,20 @@ "Name": "MissileHoming", "SpecialFeatures": [ { "Type": "Cluster" }, { "Type": "Homing" } ] }, + { + "Name": "MissileHoming+Inferno", + "Damage": { + "Short": 0 + }, + "DamageAerospace": { + "Short": 0 + }, + "SpecialDamage": [ + { "Data": "3", "Type": "Burst" }, + { "Data": "2", "Type": "Heat" } + ], + "SpecialFeatures": [ { "Type": "Cluster" }, { "Type": "Homing" } ] + }, { "Name": "MissileHoming+Artemis", "SpecialFeatures": [ { "Type": "Cluster" }, { "Type": "Homing" } ], diff --git a/BtDamageResolver/data/ammo/Ammo_LRM.json b/BtDamageResolver/data/ammo/Ammo_LRM.json index 7e523638..c450f989 100644 --- a/BtDamageResolver/data/ammo/Ammo_LRM.json +++ b/BtDamageResolver/data/ammo/Ammo_LRM.json @@ -135,4 +135,4 @@ "HitModifier": 1, "SpecialFeatures": [ { "Type": "Cluster" }, { "Type": "Homing" }, { "Type": "IndirectFire" } ] } -] \ No newline at end of file +] diff --git a/BtDamageResolver/data/ammo/Ammo_MML.json b/BtDamageResolver/data/ammo/Ammo_MML.json index a97be2b8..0626501b 100644 --- a/BtDamageResolver/data/ammo/Ammo_MML.json +++ b/BtDamageResolver/data/ammo/Ammo_MML.json @@ -1,6 +1,7 @@ [ { "Name": "MML3-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 3 }, @@ -21,6 +22,7 @@ }, { "Name": "MML3-SRM+Artemis", + "AmmoProxyName": "SRM", "Damage": { "Short": 3 }, @@ -44,6 +46,7 @@ }, { "Name": "MML3-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 3 }, @@ -65,6 +68,7 @@ }, { "Name": "MML3-LRM+Artemis", + "AmmoProxyName": "LRM", "Damage": { "Short": 3 }, @@ -89,6 +93,7 @@ }, { "Name": "MML3-LRM Indirect", + "AmmoProxyName": "LRM", "Damage": { "Short": 3 }, @@ -111,6 +116,7 @@ }, { "Name": "MML5-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 5 }, @@ -131,6 +137,7 @@ }, { "Name": "MML5-SRM+Artemis", + "AmmoProxyName": "SRM", "Damage": { "Short": 5 }, @@ -154,6 +161,7 @@ }, { "Name": "MML5-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 5 }, @@ -175,6 +183,7 @@ }, { "Name": "MML5-LRM+Artemis", + "AmmoProxyName": "LRM", "Damage": { "Short": 5 }, @@ -199,6 +208,7 @@ }, { "Name": "MML5-LRM Indirect", + "AmmoProxyName": "LRM", "Damage": { "Short": 5 }, @@ -221,6 +231,7 @@ }, { "Name": "MML7-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 7 }, @@ -241,6 +252,7 @@ }, { "Name": "MML7-SRM+Artemis", + "AmmoProxyName": "SRM", "Damage": { "Short": 7 }, @@ -264,6 +276,7 @@ }, { "Name": "MML7-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 7 }, @@ -285,6 +298,7 @@ }, { "Name": "MML7-LRM+Artemis", + "AmmoProxyName": "LRM", "Damage": { "Short": 7 }, @@ -309,6 +323,7 @@ }, { "Name": "MML7-LRM Indirect", + "AmmoProxyName": "LRM", "Damage": { "Short": 7 }, @@ -331,6 +346,7 @@ }, { "Name": "MML9-SRM", + "AmmoProxyName": "SRM", "Damage": { "Short": 9 }, @@ -351,6 +367,7 @@ }, { "Name": "MML9-SRM+Artemis", + "AmmoProxyName": "SRM", "Damage": { "Short": 9 }, @@ -374,6 +391,7 @@ }, { "Name": "MML9-LRM", + "AmmoProxyName": "LRM", "Damage": { "Short": 9 }, @@ -395,6 +413,7 @@ }, { "Name": "MML9-LRM+Artemis", + "AmmoProxyName": "LRM", "Damage": { "Short": 9 }, @@ -419,6 +438,7 @@ }, { "Name": "MML9-LRM Indirect", + "AmmoProxyName": "LRM", "Damage": { "Short": 9 }, @@ -439,4 +459,4 @@ "ClusterSize": 5, "ClusterDamage": 1 } -] \ No newline at end of file +] diff --git a/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechQuad_Critical_Structure.json b/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechQuad_Critical_Structure.json new file mode 100644 index 00000000..d9ac5ed9 --- /dev/null +++ b/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechQuad_Critical_Structure.json @@ -0,0 +1,18 @@ +{ + "Type": "Critical", + "Location": "Structure", + "UnitType": "MechQuad", + "Mapping": { + "2": [ "None" ], + "3": [ "None" ], + "4": [ "None" ], + "5": [ "None" ], + "6": [ "None" ], + "7": [ "None" ], + "8": [ "Critical" ], + "9": [ "Critical" ], + "10": [ "Critical", "Critical" ], + "11": [ "Critical", "Critical" ], + "12": [ "Critical", "Critical", "Critical" ] + } +} diff --git a/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechTripod_Critical_Structure.json b/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechTripod_Critical_Structure.json new file mode 100644 index 00000000..b6a140e6 --- /dev/null +++ b/BtDamageResolver/data/criticaldamagetable/CriticalDamageTable_MechTripod_Critical_Structure.json @@ -0,0 +1,18 @@ +{ + "Type": "Critical", + "Location": "Structure", + "UnitType": "MechTripod", + "Mapping": { + "2": [ "None" ], + "3": [ "None" ], + "4": [ "None" ], + "5": [ "None" ], + "6": [ "None" ], + "7": [ "None" ], + "8": [ "Critical" ], + "9": [ "Critical" ], + "10": [ "Critical", "Critical" ], + "11": [ "Critical", "Critical" ], + "12": [ "Critical", "Critical", "Critical" ] + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Front.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Front.json new file mode 100644 index 00000000..195de750 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Front.json @@ -0,0 +1,13 @@ +{ + "Type": "MechQuad", + "AttackType": "Kick", + "Direction": "Front", + "LocationMapping": { + "1": [ "FrontRightLeg" ], + "2": [ "FrontLeftLeg" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Left.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Left.json new file mode 100644 index 00000000..ae251e22 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Left.json @@ -0,0 +1,13 @@ +{ + "Type": "MechQuad", + "AttackType": "Kick", + "Direction": "Left", + "LocationMapping": { + "1": [ "FrontLeftLeg" ], + "2": [ "RearLeftLeg" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Rear.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Rear.json new file mode 100644 index 00000000..5492347f --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Rear.json @@ -0,0 +1,13 @@ +{ + "Type": "MechQuad", + "AttackType": "Kick", + "Direction": "Rear", + "LocationMapping": { + "1": [ "RearRightLeg" ], + "2": [ "RearLeftLeg" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Right.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Right.json new file mode 100644 index 00000000..c1576329 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Kick_Right.json @@ -0,0 +1,13 @@ +{ + "Type": "MechQuad", + "AttackType": "Kick", + "Direction": "Right", + "LocationMapping": { + "1": [ "FrontRightLeg" ], + "2": [ "RearRightLeg" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom.json new file mode 100644 index 00000000..1e165e31 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Bottom", + "LocationMapping": { + "2": [ "CenterTorso", "RearCenterTorso" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso", "RearRightTorso" ], + "7": [ "CenterTorso", "RearCenterTorso" ], + "8": [ "LeftTorso", "RearLeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom_Floating.json new file mode 100644 index 00000000..34f12b56 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Bottom_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Bottom", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso", "RearRightTorso" ], + "7": [ "CenterTorso", "RearCenterTorso" ], + "8": [ "LeftTorso", "RearLeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front.json new file mode 100644 index 00000000..9e23f524 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Front", + "LocationMapping": { + "2": [ "CenterTorso" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso" ], + "7": [ "CenterTorso" ], + "8": [ "LeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front_Floating.json new file mode 100644 index 00000000..7973e4be --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Front_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Front", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso" ], + "7": [ "CenterTorso" ], + "8": [ "LeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left.json new file mode 100644 index 00000000..b3d1f3d3 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Left", + "LocationMapping": { + "2": [ "LeftTorso" ], + "3": [ "RearLeftLeg" ], + "4": [ "FrontLeftLeg" ], + "5": [ "FrontLeftLeg" ], + "6": [ "RearLeftLeg" ], + "7": [ "LeftTorso" ], + "8": [ "CenterTorso" ], + "9": [ "RightTorso" ], + "10": [ "FrontRightLeg" ], + "11": [ "RearRightLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left_Floating.json new file mode 100644 index 00000000..4c3f3184 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Left_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Left", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "RearLeftLeg" ], + "4": [ "FrontLeftLeg" ], + "5": [ "FrontLeftLeg" ], + "6": [ "RearLeftLeg" ], + "7": [ "LeftTorso" ], + "8": [ "CenterTorso" ], + "9": [ "RightTorso" ], + "10": [ "FrontRightLeg" ], + "11": [ "RearRightLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear.json new file mode 100644 index 00000000..4aa32b96 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Rear", + "LocationMapping": { + "2": [ "RearCenterTorso" ], + "3": [ "RearRightLeg" ], + "4": [ "RearRightLeg" ], + "5": [ "FrontRightLeg" ], + "6": [ "RearRightTorso" ], + "7": [ "RearCenterTorso" ], + "8": [ "RearLeftTorso" ], + "9": [ "FrontLeftLeg" ], + "10": [ "RearLeftLeg" ], + "11": [ "RearLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear_Floating.json new file mode 100644 index 00000000..55ace60b --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Rear_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Rear", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "RearRightLeg" ], + "4": [ "RearRightLeg" ], + "5": [ "FrontRightLeg" ], + "6": [ "RearRightTorso" ], + "7": [ "RearCenterTorso" ], + "8": [ "RearLeftTorso" ], + "9": [ "FrontLeftLeg" ], + "10": [ "RearLeftLeg" ], + "11": [ "RearLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right.json new file mode 100644 index 00000000..a29be393 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Right", + "LocationMapping": { + "2": [ "RightTorso" ], + "3": [ "RearRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "FrontRightLeg" ], + "6": [ "RearRightLeg" ], + "7": [ "RightTorso" ], + "8": [ "CenterTorso" ], + "9": [ "LeftTorso" ], + "10": [ "FrontLeftLeg" ], + "11": [ "RearLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right_Floating.json new file mode 100644 index 00000000..fd03ae61 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Right_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Right", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "RearRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "FrontRightLeg" ], + "6": [ "RearRightLeg" ], + "7": [ "RightTorso" ], + "8": [ "CenterTorso" ], + "9": [ "LeftTorso" ], + "10": [ "FrontLeftLeg" ], + "11": [ "RearLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top.json new file mode 100644 index 00000000..d544569a --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top.json @@ -0,0 +1,31 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Top", + "LocationMapping": { + "2": [ "CenterTorso", "RearCenterTorso" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso", "RearRightTorso" ], + "7": [ "CenterTorso", "RearCenterTorso" ], + "8": [ "LeftTorso", "RearLeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top_Floating.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top_Floating.json new file mode 100644 index 00000000..a76ca746 --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Normal_Top_Floating.json @@ -0,0 +1,32 @@ +{ + "Type": "MechQuad", + "AttackType": "Normal", + "Direction": "Top", + "Rules": [ "FloatingCritical" ], + "LocationMapping": { + "2": [ "Reroll" ], + "3": [ "FrontRightLeg" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "RightTorso", "RearRightTorso" ], + "7": [ "CenterTorso", "RearCenterTorso" ], + "8": [ "LeftTorso", "RearLeftTorso" ], + "9": [ "RearLeftLeg" ], + "10": [ "FrontLeftLeg" ], + "11": [ "FrontLeftLeg" ], + "12": [ "Head" ] + }, + "CriticalDamageMapping": { + "2": "Critical", + "3": "None", + "4": "None", + "5": "None", + "6": "None", + "7": "None", + "8": "None", + "9": "None", + "10": "None", + "11": "None", + "12": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Front.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Front.json new file mode 100644 index 00000000..3493563d --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Front.json @@ -0,0 +1,21 @@ +{ + "Type": "MechQuad", + "AttackType": "Punch", + "Direction": "Front", + "LocationMapping": { + "1": [ "FrontLeftLeg" ], + "2": [ "LeftTorso" ], + "3": [ "CenterTorso" ], + "4": [ "RightTorso" ], + "5": [ "FrontRightLeg" ], + "6": [ "Head" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None", + "3": "None", + "4": "None", + "5": "None", + "6": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Left.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Left.json new file mode 100644 index 00000000..3f1ec0df --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Left.json @@ -0,0 +1,21 @@ +{ + "Type": "MechQuad", + "AttackType": "Punch", + "Direction": "Left", + "LocationMapping": { + "1": [ "LeftTorso" ], + "2": [ "LeftTorso" ], + "3": [ "CenterTorso" ], + "4": [ "FrontLeftLeg" ], + "5": [ "RearLeftLeg" ], + "6": [ "Head" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None", + "3": "None", + "4": "None", + "5": "None", + "6": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Rear.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Rear.json new file mode 100644 index 00000000..9ec4f06d --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Rear.json @@ -0,0 +1,21 @@ +{ + "Type": "MechQuad", + "AttackType": "Punch", + "Direction": "Rear", + "LocationMapping": { + "1": [ "RearLeftLeg" ], + "2": [ "RearLeftTorso" ], + "3": [ "RearCenterTorso" ], + "4": [ "RearRightTorso" ], + "5": [ "RearRightLeg" ], + "6": [ "Head" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None", + "3": "None", + "4": "None", + "5": "None", + "6": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Right.json b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Right.json new file mode 100644 index 00000000..a536290e --- /dev/null +++ b/BtDamageResolver/data/paperdoll/PaperDoll_MechQuad_Punch_Right.json @@ -0,0 +1,21 @@ +{ + "Type": "MechQuad", + "AttackType": "Punch", + "Direction": "Right", + "LocationMapping": { + "1": [ "RightTorso" ], + "2": [ "RightTorso" ], + "3": [ "CenterTorso" ], + "4": [ "FrontRightLeg" ], + "5": [ "RearRightLeg" ], + "6": [ "Head" ] + }, + "CriticalDamageMapping": { + "1": "None", + "2": "None", + "3": "None", + "4": "None", + "5": "None", + "6": "None" + } +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Front.json b/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Front.json index 3ac35eac..97d6b9cb 100644 --- a/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Front.json +++ b/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Front.json @@ -3,11 +3,11 @@ "AttackType": "Kick", "Direction": "Front", "LocationMapping": { - "1": [ "LeftLeg" ], - "2": [ "RightLeg" ] + "1": [ "RightLeg" ], + "2": [ "LeftLeg" ] }, "CriticalDamageMapping": { "1": "None", "2": "None" } -} \ No newline at end of file +} diff --git a/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Rear.json b/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Rear.json index f3149363..0d8c524e 100644 --- a/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Rear.json +++ b/BtDamageResolver/data/paperdoll/PaperDoll_Mech_Kick_Rear.json @@ -10,4 +10,4 @@ "1": "None", "2": "None" } -} \ No newline at end of file +} diff --git a/BtDamageResolver/data/paperdoll/images/Mech.cdr b/BtDamageResolver/data/paperdoll/images/Mech.cdr index 02b5e1f4..aac65008 100644 Binary files a/BtDamageResolver/data/paperdoll/images/Mech.cdr and b/BtDamageResolver/data/paperdoll/images/Mech.cdr differ diff --git a/BtDamageResolver/data/unit/Unit_AerospaceFighter_Samurai_OMIL-2.json b/BtDamageResolver/data/unit/Unit_AerospaceFighter_Samurai_OMIL-2.json index 13ad8fe2..815d07be 100644 --- a/BtDamageResolver/data/unit/Unit_AerospaceFighter_Samurai_OMIL-2.json +++ b/BtDamageResolver/data/unit/Unit_AerospaceFighter_Samurai_OMIL-2.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_BattleArmor_BA_Knight_Serpent.json b/BtDamageResolver/data/unit/Unit_BattleArmor_BA_Knight_Serpent.json index 1fc5df6f..39880ea6 100644 --- a/BtDamageResolver/data/unit/Unit_BattleArmor_BA_Knight_Serpent.json +++ b/BtDamageResolver/data/unit/Unit_BattleArmor_BA_Knight_Serpent.json @@ -8,6 +8,7 @@ "Troopers": 6, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Archangel_C-ANG-HG.json b/BtDamageResolver/data/unit/Unit_Mech_Archangel_C-ANG-HG.json index 456b1a73..209ea58d 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Archangel_C-ANG-HG.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Archangel_C-ANG-HG.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-2R.json b/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-2R.json new file mode 100644 index 00000000..632f3027 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-2R.json @@ -0,0 +1,54 @@ +{ + "Features": [ + "BattleFists", + "CommandUnit", + "Stable" + ], + "JumpJets": 0, + "Sinks": 10, + "Speed": 4, + "Type": "Mech", + "Tonnage": 70, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-20" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-20" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Archer ARC-2R" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-5S.json b/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-5S.json index f3bd5efd..572a178e 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-5S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Archer_ARC-5S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Atlas_AS7-S.json b/BtDamageResolver/data/unit/Unit_Mech_Atlas_AS7-S.json index b2e147a7..6276b93c 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Atlas_AS7-S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Atlas_AS7-S.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-8Q.json b/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-8Q.json index 9c7c785d..a483826c 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-8Q.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-8Q.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-9M.json b/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-9M.json index c8c7a810..9e0b29c4 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-9M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Awesome_AWS-9M.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Banshee_BNC-SB1.json b/BtDamageResolver/data/unit/Unit_Mech_Banshee_BNC-SB1.json index 07d740d8..15a15a5c 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Banshee_BNC-SB1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Banshee_BNC-SB1.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3M-DC.json b/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3M-DC.json new file mode 100644 index 00000000..01326a9e --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3M-DC.json @@ -0,0 +1,65 @@ +{ + "Features": [ + "CommandUnit", + "ImprovedCommunications", + "JettisonableWeapon", + "WeakHeadArmor" + ], + "JumpJets": 0, + "Sinks": 34, + "Speed": 4, + "Type": "Mech", + "Tonnage": 85, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER PPC" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Kick" + } + ], + "Name": "Default" + } + ], + "Name": "Battlemaster BLR-3M-DC" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3S.json b/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3S.json index 3a06713a..56f841da 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Battlemaster_BLR-3S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Black_Knight_BL-TDHI-1-KNT.json b/BtDamageResolver/data/unit/Unit_Mech_Black_Knight_BL-TDHI-1-KNT.json index a9ef8fe4..a7eccb42 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Black_Knight_BL-TDHI-1-KNT.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Black_Knight_BL-TDHI-1-KNT.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Blackjack_BJ-3.json b/BtDamageResolver/data/unit/Unit_Mech_Blackjack_BJ-3.json index 047ce488..69c4f040 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Blackjack_BJ-3.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Blackjack_BJ-3.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-BHI1.json b/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-BHI1.json index c5d796bf..0edffd52 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-BHI1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-BHI1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Artemis", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-C4C.json b/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-C4C.json index be90a84d..687a9200 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-C4C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Catapult_CPLT-C4C.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Cauldron_Born_D.json b/BtDamageResolver/data/unit/Unit_Mech_Cauldron_Born_D.json new file mode 100644 index 00000000..f124683c --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Cauldron_Born_D.json @@ -0,0 +1,47 @@ +{ + "Features": [ + "NarrowLowProfile" + ], + "JumpJets": 0, + "Sinks": 26, + "Speed": 5, + "Type": "Mech", + "Tonnage": 65, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Rapid", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Ultra AC/10" + }, + { + "Ammo": "Rapid", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Ultra AC/10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Large Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Medium Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Cauldron Born D" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Centurion_CN9-D.json b/BtDamageResolver/data/unit/Unit_Mech_Centurion_CN9-D.json index b318b0ec..2d733734 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Centurion_CN9-D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Centurion_CN9-D.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Cicada_CDA-3M.json b/BtDamageResolver/data/unit/Unit_Mech_Cicada_CDA-3M.json index 7f765285..217a9465 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Cicada_CDA-3M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Cicada_CDA-3M.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Rapid", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Commando_CMD-2D.json b/BtDamageResolver/data/unit/Unit_Mech_Commando_CMD-2D.json index f23ec733..d2039cfe 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Commando_CMD-2D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Commando_CMD-2D.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-1B.json b/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-1B.json index cbda649d..b40515e6 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-1B.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-1B.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-5S.json b/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-5S.json index 9c497332..862174df 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-5S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Commando_COM-5S.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Crab_CRB-BHI1.json b/BtDamageResolver/data/unit/Unit_Mech_Crab_CRB-BHI1.json index 3c98a1a6..796684b6 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Crab_CRB-BHI1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Crab_CRB-BHI1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Crusader_CDR-5S.json b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CDR-5S.json index 3640d24d..dd94f068 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Crusader_CDR-5S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CDR-5S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-4K.json b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-4K.json new file mode 100644 index 00000000..26b8152e --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-4K.json @@ -0,0 +1,56 @@ +{ + "Features": [ + "EasyToMaintain", + "Rugged", + "StabilizedWeapons" + ], + "JumpJets": 0, + "Sinks": 14, + "Speed": 4, + "Type": "Mech", + "Tonnage": 65, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-10" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + } + ], + "Name": "Default" + } + ], + "Name": "Crusader CRD-4K" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-FIN1.json b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-FIN1.json index 09bf617e..b9f03274 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-FIN1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Crusader_CRD-FIN1.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Artemis", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Emperor_EMP-CP1.json b/BtDamageResolver/data/unit/Unit_Mech_Emperor_EMP-CP1.json index 5855c217..b7e4a817 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Emperor_EMP-CP1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Emperor_EMP-CP1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Slug", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-5D.json b/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-5D.json index 0f0c3144..af46941c 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-5D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-5D.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Slug", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-CP1.json b/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-CP1.json index a6cc9fc1..463133f6 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-CP1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Enforcer_ENF-CP1.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Slug", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Executioner_EXE-CEO1.json b/BtDamageResolver/data/unit/Unit_Mech_Executioner_EXE-CEO1.json index de83a930..8c42d5c1 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Executioner_EXE-CEO1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Executioner_EXE-CEO1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Fenris_D.json b/BtDamageResolver/data/unit/Unit_Mech_Fenris_D.json index 3ce6a3fe..f96feb87 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Fenris_D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Fenris_D.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Fenris_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Fenris_Prime.json index 7892a294..21c87e9b 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Fenris_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Fenris_Prime.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Gargoyle_A.json b/BtDamageResolver/data/unit/Unit_Mech_Gargoyle_A.json index cd04e3d1..edabbda7 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Gargoyle_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Gargoyle_A.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Grasshopper_GHR-5H.json b/BtDamageResolver/data/unit/Unit_Mech_Grasshopper_GHR-5H.json index 0f0041bc..4bd2df4f 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Grasshopper_GHR-5H.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Grasshopper_GHR-5H.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Griffin_GRF-1DS.json b/BtDamageResolver/data/unit/Unit_Mech_Griffin_GRF-1DS.json index 6d6450f4..8b5bc947 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Griffin_GRF-1DS.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Griffin_GRF-1DS.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Hatchetman_HCT-3F.json b/BtDamageResolver/data/unit/Unit_Mech_Hatchetman_HCT-3F.json new file mode 100644 index 00000000..5afe15aa --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Hatchetman_HCT-3F.json @@ -0,0 +1,41 @@ +{ + "Features": [ + "TargetingAntiAir" + ], + "JumpJets": 0, + "Sinks": 11, + "Speed": 4, + "Type": "Mech", + "Tonnage": 45, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "AC/10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Hatchet" + } + ], + "Name": "Default" + } + ], + "Name": "Hatchetman HCT-3F" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Hellhound.json b/BtDamageResolver/data/unit/Unit_Mech_Hellhound.json index 25159145..dda0a049 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Hellhound.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Hellhound.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Hellspawn_HSN-CP1.json b/BtDamageResolver/data/unit/Unit_Mech_Hellspawn_HSN-CP1.json index c78ff21e..a5f774dd 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Hellspawn_HSN-CP1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Hellspawn_HSN-CP1.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Artemis", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Huncchback_HBK-4G.json b/BtDamageResolver/data/unit/Unit_Mech_Huncchback_HBK-4G.json index 188e1e4d..f6c81ac5 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Huncchback_HBK-4G.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Huncchback_HBK-4G.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Hunchback_2C.json b/BtDamageResolver/data/unit/Unit_Mech_Hunchback_2C.json index 0d938a09..e4243e7b 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Hunchback_2C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Hunchback_2C.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Hunchback_HBK-4G.json b/BtDamageResolver/data/unit/Unit_Mech_Hunchback_HBK-4G.json index 273ff664..4f11ada8 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Hunchback_HBK-4G.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Hunchback_HBK-4G.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Huntsman_B.json b/BtDamageResolver/data/unit/Unit_Mech_Huntsman_B.json new file mode 100644 index 00000000..d1aa2a97 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Huntsman_B.json @@ -0,0 +1,52 @@ +{ + "Features": [ + "Stable" + ], + "JumpJets": 5, + "Sinks": 20, + "Speed": 5, + "Type": "Mech", + "Tonnage": 50, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER PPC" + }, + { + "Ammo": "Slug", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan LBX AC/10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + } + ], + "Name": "Default" + } + ], + "Name": "Huntsman B" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Incubus_2.json b/BtDamageResolver/data/unit/Unit_Mech_Incubus_2.json index ac288529..88adcaa6 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Incubus_2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Incubus_2.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_JagerMech_JM6-DD.json b/BtDamageResolver/data/unit/Unit_Mech_JagerMech_JM6-DD.json index 4e3336b7..55c7e8a5 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_JagerMech_JM6-DD.json +++ b/BtDamageResolver/data/unit/Unit_Mech_JagerMech_JM6-DD.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Rapid", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Javelin_JVN-10P.json b/BtDamageResolver/data/unit/Unit_Mech_Javelin_JVN-10P.json index 4b6a901c..c0159213 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Javelin_JVN-10P.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Javelin_JVN-10P.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Jenner_2C.json b/BtDamageResolver/data/unit/Unit_Mech_Jenner_2C.json index 09d08e02..23b2b164 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Jenner_2C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Jenner_2C.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Jenner_JR7-D.json b/BtDamageResolver/data/unit/Unit_Mech_Jenner_JR7-D.json index 9f0e037e..863828df 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Jenner_JR7-D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Jenner_JR7-D.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Jiira_IIC.json b/BtDamageResolver/data/unit/Unit_Mech_Jiira_IIC.json index adad06e9..3dccbd06 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Jiira_IIC.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Jiira_IIC.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_A.json b/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_A.json index 39c2b2c4..1bdde792 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_A.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_Prime.json index b1d19e01..e3989cf3 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Kit_Fox_Prime.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Slug", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Kraken_2.json b/BtDamageResolver/data/unit/Unit_Mech_Kraken_2.json index 49680577..0534ca52 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Kraken_2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Kraken_2.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Locust_LCT-3S.json b/BtDamageResolver/data/unit/Unit_Mech_Locust_LCT-3S.json index b0114cfe..0bf471cc 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Locust_LCT-3S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Locust_LCT-3S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Loki_A.json b/BtDamageResolver/data/unit/Unit_Mech_Loki_A.json index c01cb087..4a8a52cf 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Loki_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Loki_A.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Loki_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Loki_Prime.json index a0606e10..d76e36fc 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Loki_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Loki_Prime.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Marauder_2C.json b/BtDamageResolver/data/unit/Unit_Mech_Marauder_2C.json index 83e54afb..9c7f3886 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Marauder_2C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Marauder_2C.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D-DC.json b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D-DC.json new file mode 100644 index 00000000..c31b2864 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D-DC.json @@ -0,0 +1,57 @@ +{ + "Features": [ + "CommandUnit", + "DirectionalTorsoMount", + "ExposedWeaponLinkage", + "HyperExtendingActuators", + "ImprovedCommunications", + "NarrowLowProfile" + ], + "JumpJets": 4, + "Sinks": 30, + "Speed": 4, + "Type": "Mech", + "Tonnage": 75, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER PPC" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER PPC" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Large Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Small Pulse Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Marauder MAD-5D-DC" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D.json b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D.json index 6996807d..4ebf3f43 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-5D.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-BN2A.json b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-BN2A.json index 5297641b..432dbfe4 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-BN2A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Marauder_MAD-BN2A.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Cluster", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Mauler_MAL-TDHI2.json b/BtDamageResolver/data/unit/Unit_Mech_Mauler_MAL-TDHI2.json index a0f18d32..c01f93ff 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Mauler_MAL-TDHI2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Mauler_MAL-TDHI2.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Mist_Lynx_B.json b/BtDamageResolver/data/unit/Unit_Mech_Mist_Lynx_B.json index 7093ac67..77ff5b3a 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Mist_Lynx_B.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Mist_Lynx_B.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Nova_A.json b/BtDamageResolver/data/unit/Unit_Mech_Nova_A.json index 10f8cfb2..e7c76164 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Nova_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Nova_A.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Nova_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Nova_Prime.json index 51c0d335..4a17fbd5 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Nova_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Nova_Prime.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Orion_ON1-K.json b/BtDamageResolver/data/unit/Unit_Mech_Orion_ON1-K.json new file mode 100644 index 00000000..a303c481 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Orion_ON1-K.json @@ -0,0 +1,60 @@ +{ + "Features": [ + "EasyToMaintain", + "Rugged", + "TargetingAntiAir" + ], + "JumpJets": 4, + "Sinks": 10, + "Speed": 4, + "Type": "Mech", + "Tonnage": 75, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "AC/10" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-15" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-4" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Punch" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Kick" + } + ], + "Name": "Default" + } + ], + "Name": "Orion ON1-K" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Ostroc_OSR-2D.json b/BtDamageResolver/data/unit/Unit_Mech_Ostroc_OSR-2D.json index 60126bbc..7246b8bb 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Ostroc_OSR-2D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Ostroc_OSR-2D.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Ostscout_OTT-7J.json b/BtDamageResolver/data/unit/Unit_Mech_Ostscout_OTT-7J.json index 84084328..544d45b1 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Ostscout_OTT-7J.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Ostscout_OTT-7J.json @@ -13,6 +13,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Ostsol_OTL-4D.json b/BtDamageResolver/data/unit/Unit_Mech_Ostsol_OTL-4D.json index 715a95a6..1d412e3e 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Ostsol_OTL-4D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Ostsol_OTL-4D.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Phoenix_Hawk_PXH-3S.json b/BtDamageResolver/data/unit/Unit_Mech_Phoenix_Hawk_PXH-3S.json index 90c583d9..3a773b8d 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Phoenix_Hawk_PXH-3S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Phoenix_Hawk_PXH-3S.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Phoenix_hawk_PXH-3K.json b/BtDamageResolver/data/unit/Unit_Mech_Phoenix_hawk_PXH-3K.json new file mode 100644 index 00000000..7457b214 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Phoenix_hawk_PXH-3K.json @@ -0,0 +1,44 @@ +{ + "Features": [ + "Ams", + "CommandUnit", + "ImprovedCommunications", + "JettisonableWeapon", + "Masc" + ], + "JumpJets": 6, + "Sinks": 24, + "Speed": 6, + "Type": "Mech", + "Tonnage": 45, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER Large Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER Large Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Phoenix hawk PXH-3K" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN1.json b/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN1.json old mode 100755 new mode 100644 index 84e8b2f1..972e1565 --- a/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN2.json b/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN2.json old mode 100755 new mode 100644 index 136990c8..6f0fc7e3 --- a/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Preta_PRT-BN2.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Puma_C.json b/BtDamageResolver/data/unit/Unit_Mech_Puma_C.json index ecde4bf7..b7a14d12 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Puma_C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Puma_C.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Rifleman_2C.json b/BtDamageResolver/data/unit/Unit_Mech_Rifleman_2C.json index a138043b..1557a111 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Rifleman_2C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Rifleman_2C.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Rifleman_RFL-5D.json b/BtDamageResolver/data/unit/Unit_Mech_Rifleman_RFL-5D.json index 70dbd2bf..18d7ddbc 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Rifleman_RFL-5D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Rifleman_RFL-5D.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-2K.json b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-2K.json new file mode 100644 index 00000000..3e9f304b --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-2K.json @@ -0,0 +1,33 @@ +{ + "Features": [ + "BattleFists", + "Rugged" + ], + "JumpJets": 5, + "Sinks": 17, + "Speed": 5, + "Type": "Mech", + "Tonnage": 55, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "PPC" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-15" + } + ], + "Name": "Default" + } + ], + "Name": "Shadow Hawk SHD-2K" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-5M.json b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-5M.json index 1546c14b..7fd8b655 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-5M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-5M.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-BN1.json b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-BN1.json index b6cb4602..5dff9ff9 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-BN1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Shadow_Hawk_SHD-BN1.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-5S.json b/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-5S.json index 6136c5f0..de0135ab 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-5S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-5S.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-MK2.json b/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-MK2.json index a9a29481..ec5c754d 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-MK2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Stalker_STK-MK2.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Artemis", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-3G.json b/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-3G.json index e2d3d4fd..6583e923 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-3G.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-3G.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-5M.json b/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-5M.json index ffdc8910..89fb741d 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-5M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Stinger_STG-5M.json @@ -13,6 +13,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_A.json b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_A.json index ac2b9117..83a01953 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_A.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_C.json b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_C.json new file mode 100644 index 00000000..2787516f --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_C.json @@ -0,0 +1,41 @@ +{ + "Features": [ + "Stable" + ], + "JumpJets": 0, + "Sinks": 20, + "Speed": 6, + "Type": "Mech", + "Tonnage": 55, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Slug", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan LBX AC/10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Large Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Stormcrow C" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_Prime.json new file mode 100644 index 00000000..9569aa71 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Stormcrow_Prime.json @@ -0,0 +1,45 @@ +{ + "Features": [ + "Stable" + ], + "JumpJets": 0, + "Sinks": 44, + "Speed": 6, + "Type": "Mech", + "Tonnage": 55, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Large Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Large Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER Medium Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Stormcrow Prime" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thor_A.json b/BtDamageResolver/data/unit/Unit_Mech_Thor_A.json index e5232133..e54385dd 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Thor_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Thor_A.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thor_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Thor_Prime.json index b549c998..1fe5a997 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Thor_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Thor_Prime.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thug_THG-ROT1.json b/BtDamageResolver/data/unit/Unit_Mech_Thug_THG-ROT1.json index 8786160b..61e067b9 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Thug_THG-ROT1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Thug_THG-ROT1.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-5Sh.json b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-5Sh.json new file mode 100644 index 00000000..155e10a0 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-5Sh.json @@ -0,0 +1,63 @@ +{ + "Features": [ + "MultiTarget", + "Rugged" + ], + "JumpJets": 0, + "Sinks": 15, + "Speed": 4, + "Type": "Mech", + "Tonnage": 65, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-15" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-2" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Hatchet" + } + ], + "Name": "Default" + } + ], + "Name": "Thunderbolt TDR-5Sh" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-7M.json b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-7M.json index c866d918..7353c288 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-7M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-7M.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-9S.json b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-9S.json index 0efac7aa..159efee5 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-9S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Thunderbolt_TDR-9S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_A.json b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_A.json new file mode 100644 index 00000000..52f41706 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_A.json @@ -0,0 +1,58 @@ +{ + "Features": [ + "TargetingMediumRange", + "WeakHeadArmor" + ], + "JumpJets": 0, + "Sinks": 40, + "Speed": 5, + "Type": "Mech", + "Tonnage": 75, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER PPC" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan ER PPC" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Streak SRM-6" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER Small Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Timber Wolf A" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_B.json b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_B.json new file mode 100644 index 00000000..5282b37f --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_B.json @@ -0,0 +1,48 @@ +{ + "Features": [ + "TargetingMediumRange", + "WeakHeadArmor" + ], + "JumpJets": 0, + "Sinks": 30, + "Speed": 5, + "Type": "Mech", + "Tonnage": 75, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Gauss Rifle" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Large Pulse Laser" + }, + { + "Ammo": "Artemis", + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan LRM-10" + }, + { + "Ammo": "Artemis", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-4" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Clan Small Pulse Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Timber Wolf B" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_D.json b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_D.json index 42fc8938..a8779990 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Timber_Wolf_D.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Trebuchet_TBT-7M.json b/BtDamageResolver/data/unit/Unit_Mech_Trebuchet_TBT-7M.json index 1cd143ae..5fc2bd76 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Trebuchet_TBT-7M.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Trebuchet_TBT-7M.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Uziel__UZL-MK1.json b/BtDamageResolver/data/unit/Unit_Mech_Uziel_UZL-MK1.json similarity index 93% rename from BtDamageResolver/data/unit/Unit_Mech_Uziel__UZL-MK1.json rename to BtDamageResolver/data/unit/Unit_Mech_Uziel_UZL-MK1.json index 2db3aa8a..b6dca2cd 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Uziel__UZL-MK1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Uziel_UZL-MK1.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", @@ -34,5 +35,5 @@ "Name": "Default" } ], - "Name": "Uziel UZL-MK1" + "Name": "Uziel UZL-MK1" } \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Valkyrie_VLK-QD.json b/BtDamageResolver/data/unit/Unit_Mech_Valkyrie_VLK-QD.json index 5c36a49c..27718240 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Valkyrie_VLK-QD.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Valkyrie_VLK-QD.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-9B.json b/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-9B.json index 7d6eaaa7..0278a875 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-9B.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-9B.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-SB2.json b/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-SB2.json index 2cfd1dd3..db89088e 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-SB2.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Victor_VTR-SB2.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Vulture_A.json b/BtDamageResolver/data/unit/Unit_Mech_Vulture_A.json index cae7c134..e3941fb2 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Vulture_A.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Vulture_A.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Vulture_B.json b/BtDamageResolver/data/unit/Unit_Mech_Vulture_B.json index 8b2879f6..7f9534e7 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Vulture_B.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Vulture_B.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Vulture_MDD-BN1.json b/BtDamageResolver/data/unit/Unit_Mech_Vulture_MDD-BN1.json index a46218c1..764c71b0 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Vulture_MDD-BN1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Vulture_MDD-BN1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Vulture_Prime.json b/BtDamageResolver/data/unit/Unit_Mech_Vulture_Prime.json index 03829cba..b4c969a7 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Vulture_Prime.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Vulture_Prime.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Warhammer_2C.json b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_2C.json index 581fb4a2..cccc1684 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Warhammer_2C.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_2C.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7M-DC.json b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7M-DC.json new file mode 100644 index 00000000..dce84d44 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7M-DC.json @@ -0,0 +1,51 @@ +{ + "Features": [ + "ImprovedCommunications", + "Rugged", + "Searchlight", + "Stable" + ], + "JumpJets": 0, + "Sinks": 36, + "Speed": 4, + "Type": "Mech", + "Tonnage": 70, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER PPC" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "ER PPC" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Machine Gun" + } + ], + "Name": "Default" + } + ], + "Name": "Warhammer WHM-7M-DC" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7S.json b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7S.json index a2c2d1d2..5c699953 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Warhammer_WHM-7S.json @@ -12,6 +12,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Warhawk_B.json b/BtDamageResolver/data/unit/Unit_Mech_Warhawk_B.json index a161fa03..30feb4b3 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Warhawk_B.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Warhawk_B.json @@ -11,6 +11,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Whitworth_WTH-1.json b/BtDamageResolver/data/unit/Unit_Mech_Whitworth_WTH-1.json index eed246eb..315760c8 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Whitworth_WTH-1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Whitworth_WTH-1.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolf_Trap_(Tora)_WFT-1.json b/BtDamageResolver/data/unit/Unit_Mech_Wolf_Trap_(Tora)_WFT-1.json new file mode 100644 index 00000000..e3a79e2b --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolf_Trap_(Tora)_WFT-1.json @@ -0,0 +1,40 @@ +{ + "Features": [], + "JumpJets": 0, + "Sinks": 10, + "Speed": 6, + "Type": "Mech", + "Tonnage": 45, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Ammo": "Slug", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LBX AC/10" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "LRM-10" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Laser" + } + ], + "Name": "Default" + } + ], + "Name": "Wolf Trap (Tora) WFT-1" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7D.json b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7D.json index e0e1a7f0..39a0e1a3 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7D.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7D.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Single", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7K.json b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7K.json new file mode 100644 index 00000000..bd215d2b --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7K.json @@ -0,0 +1,51 @@ +{ + "Features": [ + "CommandUnit", + "ExtendedTorsoTwist", + "ImprovedCommunications", + "JettisonableWeapon", + "ProtectedActuators" + ], + "JumpJets": 5, + "Sinks": 26, + "Speed": 5, + "Type": "Mech", + "Tonnage": 55, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Large Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Small Pulse Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + } + ], + "Name": "Default" + } + ], + "Name": "Wolverine WVR-7K" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7Kh.json b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7Kh.json new file mode 100644 index 00000000..69a62320 --- /dev/null +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-7Kh.json @@ -0,0 +1,50 @@ +{ + "Features": [ + "CommandUnit", + "ExtendedTorsoTwist", + "ImprovedCommunications", + "JettisonableWeapon", + "ProtectedActuators" + ], + "JumpJets": 5, + "Sinks": 26, + "Speed": 5, + "Type": "Mech", + "Tonnage": 55, + "Troopers": 1, + "WeaponBays": [ + { + "Arc": "Default", + "Weapons": [ + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Large Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Medium Pulse Laser" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Small Pulse Laser" + }, + { + "Ammo": "Normal", + "Amount": 1, + "Modifier": 0, + "WeaponName": "SRM-6" + }, + { + "Amount": 1, + "Modifier": 0, + "WeaponName": "Melee Hatchet" + } + ], + "Name": "Default" + } + ], + "Name": "Wolverine WVR-7Kh" +} \ No newline at end of file diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-FIN1.json b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-FIN1.json index 11d83106..aaffd7d0 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-FIN1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-FIN1.json @@ -14,6 +14,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-TDHI1.json b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-TDHI1.json index 90367490..5adbf336 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-TDHI1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Wolverine_WVR-TDHI1.json @@ -15,6 +15,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-9S.json b/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-9S.json index 15531036..bbb6044a 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-9S.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-9S.json @@ -10,6 +10,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-SB1.json b/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-SB1.json index 0526a6e1..7b2a4498 100644 --- a/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-SB1.json +++ b/BtDamageResolver/data/unit/Unit_Mech_Zeus_ZEU-SB1.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Artemis", diff --git a/BtDamageResolver/data/unit/Unit_VehicleHover_Magrider_Hovertank.json b/BtDamageResolver/data/unit/Unit_VehicleHover_Magrider_Hovertank.json index 6283c679..5e33e4a5 100644 --- a/BtDamageResolver/data/unit/Unit_VehicleHover_Magrider_Hovertank.json +++ b/BtDamageResolver/data/unit/Unit_VehicleHover_Magrider_Hovertank.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_VehicleHover_Maxim_Heavy_Hover_Transport.json b/BtDamageResolver/data/unit/Unit_VehicleHover_Maxim_Heavy_Hover_Transport.json index cce1569c..f9f3e784 100644 --- a/BtDamageResolver/data/unit/Unit_VehicleHover_Maxim_Heavy_Hover_Transport.json +++ b/BtDamageResolver/data/unit/Unit_VehicleHover_Maxim_Heavy_Hover_Transport.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Amount": 1, diff --git a/BtDamageResolver/data/unit/Unit_VehicleTracked_Schreck_PPC_Carrier.json b/BtDamageResolver/data/unit/Unit_VehicleTracked_Schreck_PPC_Carrier.json index c78e7b2b..791e7346 100644 --- a/BtDamageResolver/data/unit/Unit_VehicleTracked_Schreck_PPC_Carrier.json +++ b/BtDamageResolver/data/unit/Unit_VehicleTracked_Schreck_PPC_Carrier.json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/unit/Unit_VehicleWheeled_Striker_Light_Tank_(SRM).json b/BtDamageResolver/data/unit/Unit_VehicleWheeled_Striker_Light_Tank_(SRM).json index 431f8ce9..a01893f8 100644 --- a/BtDamageResolver/data/unit/Unit_VehicleWheeled_Striker_Light_Tank_(SRM).json +++ b/BtDamageResolver/data/unit/Unit_VehicleWheeled_Striker_Light_Tank_(SRM).json @@ -8,6 +8,7 @@ "Troopers": 1, "WeaponBays": [ { + "Arc": "Default", "Weapons": [ { "Ammo": "Normal", diff --git a/BtDamageResolver/data/weapon/Weapons_AC-LBX.json b/BtDamageResolver/data/weapon/Weapons_AC-LBX.json index 3e38ca80..667640fc 100644 --- a/BtDamageResolver/data/weapon/Weapons_AC-LBX.json +++ b/BtDamageResolver/data/weapon/Weapons_AC-LBX.json @@ -3,10 +3,11 @@ "Name": "LBX AC/2", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 4 @@ -32,10 +33,11 @@ "Name": "LBX AC/5", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 8 @@ -61,10 +63,11 @@ "Name": "LBX AC/10", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 11 @@ -90,10 +93,11 @@ "Name": "LBX AC/20", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 20 @@ -119,10 +123,11 @@ "Name": "Clan LBX AC/2", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 4 @@ -148,10 +153,11 @@ "Name": "Clan LBX AC/5", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 8 @@ -177,10 +183,11 @@ "Name": "Clan LBX AC/10", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 11 @@ -206,10 +213,11 @@ "Name": "Clan LBX AC/20", "Type": "Ballistic", "Ammo": { - "Slug": "AC-Single", + "Slug": "AC-Slug", "Cluster": "AC-LBXCluster" }, "AmmoDefault": "Slug", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 20 diff --git a/BtDamageResolver/data/weapon/Weapons_AC-Normal.json b/BtDamageResolver/data/weapon/Weapons_AC-Normal.json index af8fd086..d3b34425 100644 --- a/BtDamageResolver/data/weapon/Weapons_AC-Normal.json +++ b/BtDamageResolver/data/weapon/Weapons_AC-Normal.json @@ -7,6 +7,7 @@ "AP": "AC-AP2" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 4 @@ -36,6 +37,7 @@ "AP": "AC-AP5" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 8 @@ -65,6 +67,7 @@ "AP": "AC-AP10" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 11 @@ -94,6 +97,7 @@ "AP": "AC-AP20" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 20 diff --git a/BtDamageResolver/data/weapon/Weapons_ATM.json b/BtDamageResolver/data/weapon/Weapons_ATM.json index b2a2fe80..38991e52 100644 --- a/BtDamageResolver/data/weapon/Weapons_ATM.json +++ b/BtDamageResolver/data/weapon/Weapons_ATM.json @@ -8,6 +8,7 @@ "LRM": "ATM3-LRM" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 2 @@ -48,6 +49,7 @@ "LRM": "ATM9-LRM" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 6 @@ -68,6 +70,7 @@ "LRM": "ATM12-LRM" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 8 diff --git a/BtDamageResolver/data/weapon/Weapons_BattleArmor-SRM.json b/BtDamageResolver/data/weapon/Weapons_BattleArmor-SRM.json index 8d8f215c..76d7dbca 100644 --- a/BtDamageResolver/data/weapon/Weapons_BattleArmor-SRM.json +++ b/BtDamageResolver/data/weapon/Weapons_BattleArmor-SRM.json @@ -2,6 +2,12 @@ { "Name": "BA SRM-1", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 1 @@ -23,12 +29,17 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 }, { "Name": "BA SRM-2", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 2 @@ -50,12 +61,17 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 }, { "Name": "BA SRM-3", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 3 @@ -77,12 +93,17 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 }, { "Name": "BA SRM-4", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 4 @@ -104,12 +125,17 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 }, { "Name": "BA SRM-5", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 5 @@ -131,12 +157,17 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 }, { "Name": "BA SRM-6", "Type": "Missile", + "Ammo": { + "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno" + }, + "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 6 @@ -158,7 +189,6 @@ }, "UsesAmmo": true, "ClusterSize": 2, - "ClusterDamage": 2, - "SpecialFeatures": [ { "Type": "Cluster" } ] + "ClusterDamage": 2 } ] diff --git a/BtDamageResolver/data/weapon/Weapons_MML.json b/BtDamageResolver/data/weapon/Weapons_MML.json index a0029601..c067e3ab 100644 --- a/BtDamageResolver/data/weapon/Weapons_MML.json +++ b/BtDamageResolver/data/weapon/Weapons_MML.json @@ -10,6 +10,7 @@ "LRM Indirect": "MML3-LRM Indirect" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 2 @@ -27,6 +28,7 @@ "LRM Indirect": "MML5-LRM Indirect" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 3 @@ -44,6 +46,7 @@ "LRM Indirect": "MML7-LRM Indirect" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 2 @@ -61,6 +64,7 @@ "LRM Indirect": "MML9-LRM Indirect" }, "AmmoDefault": "SRM", + "MultiAmmo": true, "AttackType": "Normal", "Heat": { "Short": 3 diff --git a/BtDamageResolver/data/weapon/Weapons_SRM.json b/BtDamageResolver/data/weapon/Weapons_SRM.json index 6e6d1d40..0a2248b0 100644 --- a/BtDamageResolver/data/weapon/Weapons_SRM.json +++ b/BtDamageResolver/data/weapon/Weapons_SRM.json @@ -4,9 +4,11 @@ "Type": "Missile", "Ammo": { "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno", "Artemis": "MissileHoming+Artemis" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 2 @@ -34,9 +36,11 @@ "Type": "Missile", "Ammo": { "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno", "Artemis": "MissileHoming+Artemis" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 4 @@ -64,9 +68,11 @@ "Type": "Missile", "Ammo": { "Normal": "MissileHoming", + "Inferno": "MissileHoming+Inferno", "Artemis": "MissileHoming+Artemis" }, "AmmoDefault": "Normal", + "MultiAmmo": true, "AttackType": "Normal", "Damage": { "Short": 6 diff --git a/BtDamageResolver/docs/superpowers/plans/2026-06-02-native-appsettings-config-refactor.md b/BtDamageResolver/docs/superpowers/plans/2026-06-02-native-appsettings-config-refactor.md new file mode 100644 index 00000000..b3a354f9 --- /dev/null +++ b/BtDamageResolver/docs/superpowers/plans/2026-06-02-native-appsettings-config-refactor.md @@ -0,0 +1,830 @@ +# Native appsettings + ConnectionStrings Refactor — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the custom `ConfigurationUtilities.GetConfiguration` scheme with the native .NET host config chain, move Redis and Postgres connection strings into native `ConnectionStrings`, delete `CommunicationOptions`, remove `RESOLVER_ENVIRONMENT`, and rename the `orleansdb` container to `resolverpostgres`. + +**Architecture:** The silo uses `Host.CreateDefaultBuilder()` (already default-loads `appsettings.json` → `appsettings.{DOTNET_ENVIRONMENT}.json` → env vars). The client's `Startup.ConfigureServices` becomes an instance method binding off the injected `Configuration`. The tools use `Host.CreateApplicationBuilder(args).Configuration`. Connection strings are read via `IConfiguration.GetConnectionString(...)`. `FaemiyahClusterOptions` is kept intact (published in the `Common` NuGet package) and has its `ConnectionString` hydrated via `PostConfigure`. + +**Tech Stack:** .NET 10, Microsoft.Orleans 10.1, Microsoft.Extensions.Hosting/Configuration, NUnit 4 + AwesomeAssertions, Docker Compose. + +**Repos touched (commit in the repo that contains each path):** +- **Code repo:** `C:\Work\src\BtDamageResolver\BtDamageResolver` (Silo, Common, Services, tools, Tests) — this is the cwd/git repo. +- **Infra:** `C:\Work\src\BtDamageResolver\infra` + `C:\Work\src\BtDamageResolver\refresh.sh|.bat` — run `git status` to confirm which repo tracks these; commit there. +- **Client repo:** `C:\Work\src\BtDamageResolver\BtDamageResolverClient` (BlazorServer). Builds against `Common`/`Api` **NuGet** packages, so it is independent of the `Common` source edits below. + +**Spec:** `docs/superpowers/specs/2026-06-02-native-appsettings-config-refactor-design.md` + +--- + +## Task 1: Config precedence regression test + +Locks in the two behaviors the original custom loader existed to guarantee: `GetConnectionString("Redis")` resolves the JSON value, and a `ConnectionStrings__Redis` environment variable overrides it. This is a characterization test of the native chain (it passes immediately); it guards against future regressions in key naming/precedence. + +**Files:** +- Create: `tests/Tests/ConfigurationPrecedenceTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System; +using System.IO; +using System.Text; +using AwesomeAssertions; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; + +namespace Faemiyah.BtDamageResolver.Tests; + +/// +/// Verifies the native configuration chain resolves connection strings from the +/// ConnectionStrings block and that environment variables override JSON values, +/// matching the source order appsettings.json -> environment variables. +/// +[TestFixture] +internal class ConfigurationPrecedenceTests +{ + private const string RedisEnvVariable = "ConnectionStrings__Redis"; + private const string JsonConnectionString = "redis-from-json:6379"; + + private const string SampleJson = """ + { + "ConnectionStrings": { + "Redis": "redis-from-json:6379" + } + } + """; + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(RedisEnvVariable, null); + } + + [Test] + public void GetConnectionString_ResolvesRedisFromJson() + { + // Arrange + var configuration = BuildConfiguration(); + + // Act + var connectionString = configuration.GetConnectionString("Redis"); + + // Assert + connectionString.Should().Be(JsonConnectionString); + } + + [Test] + public void EnvironmentVariable_OverridesJsonConnectionString() + { + // Arrange + const string overrideValue = "redis-from-env:6379"; + Environment.SetEnvironmentVariable(RedisEnvVariable, overrideValue); + var configuration = BuildConfiguration(); + + // Act + var connectionString = configuration.GetConnectionString("Redis"); + + // Assert + connectionString.Should().Be(overrideValue); + } + + private static IConfiguration BuildConfiguration() + { + return new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(SampleJson))) + .AddEnvironmentVariables() + .Build(); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `dotnet test tests/Tests/Tests.csproj --filter "FullyQualifiedName~ConfigurationPrecedenceTests" --nologo` +Expected: PASS (2 passed). This is a characterization test, so it passes on first run. + +- [ ] **Step 3: Commit** + +```bash +git add tests/Tests/ConfigurationPrecedenceTests.cs +git commit -m "test: lock in native ConnectionStrings precedence behavior" +``` + +--- + +## Task 2: Silo — native config + ConnectionStrings + cluster hydration + +**Files:** +- Modify: `src/Silo/Program.cs` (`CreateSilo` ~112-222, `GetRedisEntityRepository` ~224-231) +- Rename: `src/Silo/SiloSettings.json` → `src/Silo/appsettings.json`; `src/Silo/SiloSettings.Release.json` → `src/Silo/appsettings.Release.json` (and `SiloSettings.Debug.json` → `appsettings.Debug.json` if a source copy exists) +- Modify: `src/Silo/Silo.csproj` + +- [ ] **Step 1: Rename the settings files (preserve git history)** + +```bash +git mv src/Silo/SiloSettings.json src/Silo/appsettings.json +git mv src/Silo/SiloSettings.Release.json src/Silo/appsettings.Release.json +# Only if a source Debug file exists (it may live only under bin/): +git mv src/Silo/SiloSettings.Debug.json src/Silo/appsettings.Debug.json 2>/dev/null || true +``` + +- [ ] **Step 2: Rewrite `src/Silo/appsettings.json` to use ConnectionStrings** + +```json +{ + "LoggingOptions": { + "LogToConsole": true, + "LogToDatabase": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning", + "LoggingIntervalMilliseconds": 15000, + "SendDetailedErrorsToClient": true + }, + "ClusterOptions": { + "Invariant": "Npgsql" + }, + "ConnectionStrings": { + "Postgres": "User ID=USERNAME;Password=PASSWORD;Host=resolverpostgres;Port=5432;Database=BtDamageResolver;SSL Mode=Disable;", + "Redis": "redis:6379,password=PASSWORD" + }, + "CompressionOptions": { + "Provider": "Brotli", + "Quality": 4 + } +} +``` + +- [ ] **Step 3: Update `src/Silo/Silo.csproj` copy directives** + +Replace the two `` blocks that reference `SiloSettings.*` with: + +```xml + + + Always + + + Always + + + + + + Always + + +``` + +- [ ] **Step 4: Rewrite `CreateSilo()` to read native config inside `UseOrleans((context, siloBuilder) => ...)`** + +In `src/Silo/Program.cs`, replace the start of `CreateSilo` (the three local variables + the `Host.CreateDefaultBuilder()...ConfigureAppConfiguration(...).UseOrleans(siloBuilder =>` opening) with the version below. Keep the entire body of the Orleans delegate identical EXCEPT the four changes marked, and the closing stays the same. + +```csharp + private static IHost CreateSilo() + { + var (clientPort, siloPort) = GetSiloPortConfigurationFromEnvironment(); + + var siloHostBuilder = Host.CreateDefaultBuilder() + .UseOrleans((context, siloBuilder) => + { + var configuration = context.Configuration; + var clusterOptions = new FaemiyahClusterOptions + { + Invariant = configuration[$"{Settings.ClusterOptionsBlockName}:Invariant"], + ConnectionString = configuration.GetConnectionString("Postgres") + }; + + siloBuilder + .Services.AddSerializer(serializerBuilder => serializerBuilder.AddJsonSerializer( + isSupported: type => type.Namespace != null && type.Namespace.StartsWith("Faemiyah.BtDamageResolver"), + jsonSerializerOptions: CreateJsonSerializerOptions())); + siloBuilder + .Configure(options => + { + options.ClusterId = "faemiyah"; + options.ServiceId = "Resolver"; + }) + .Configure(options => options.CollectionAge = TimeSpan.FromDays(1)) + .Configure(options => + { + options.DefunctSiloExpiration = TimeSpan.FromHours(1); + options.NumMissedProbesLimit = 2; + options.NumMissedTableIAmAliveLimit = 1; + options.NumVotesForDeathDeclaration = 1; + }) + .Configure(options => + { + options.MaxRequestProcessingTime = TimeSpan.FromSeconds(15); + options.SystemResponseTimeout = TimeSpan.FromSeconds(15); + }) + .Configure(options => + { + options.DropExpiredMessages = true; + options.ResponseTimeout = TimeSpan.FromSeconds(15); + options.ResponseTimeoutWithDebugger = TimeSpan.FromMinutes(15); + }) + .UseAdoNetClustering(options => + { + options.Invariant = clusterOptions.Invariant; + options.ConnectionString = clusterOptions.ConnectionString; + }) + .AddGrainStorage(Settings.ActorStateStoreName, clusterOptions) + .AddGrainStorage(Settings.SessionStateStoreName, clusterOptions) + .Configure(options => + { + options.AdvertisedIPAddress = GetHostIp(); + options.GatewayListeningEndpoint = new IPEndPoint(IPAddress.Any, clientPort); + options.SiloListeningEndpoint = new IPEndPoint(IPAddress.Any, siloPort); + }) + .AddGrainService() + .AddGrainService() + .ConfigureServices(services => + { + services.ConfigureJsonSerializerOptions(); + services.Configure(configuration.GetSection(Settings.CompressionOptionsBlockName)); + services.Configure(configuration.GetSection(Settings.ClusterOptionsBlockName)); + services.PostConfigure(options => options.ConnectionString = configuration.GetConnectionString("Postgres")); + services.Configure(configuration.GetSection(Settings.LoggingOptionsBlockName)); + services.AddLogging(conf => + { + conf.AddFaemiyahLogging(); + conf.AddFilter("DeploymentLoadPublisher", LogLevel.Warning); + }); + services.Configure(opt => opt.SuppressStatusMessages = true); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton>(GetRedisEntityRepository); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton, CachedEntityRepository>(); + services.AddSingleton(serviceProvider => + { + return new RepositoryProvider( + serviceProvider.GetService>(), + serviceProvider.GetService>(), + serviceProvider.GetService>(), + serviceProvider.GetService>(), + serviceProvider.GetService>(), + serviceProvider.GetService>(), + serviceProvider.GetService>() + ); + }); + services.AddSingleton(); + }); + }); + + return siloHostBuilder.Build(); + } +``` + +The four changes vs. the original: (1) no top-level `GetConfiguration`/`clusterOptions`; (2) `.ConfigureAppConfiguration(...)` removed; (3) `UseOrleans((context, siloBuilder) => ...)` with `clusterOptions` built from `context.Configuration` + `GetConnectionString("Postgres")`; (4) the `services.Configure(...)` line is gone and a `FaemiyahClusterOptions` `PostConfigure` is added. + +- [ ] **Step 5: Update `GetRedisEntityRepository` to read the Redis connection string from config** + +Replace the method body: + +```csharp + private static RedisEntityRepository GetRedisEntityRepository(IServiceProvider serviceProvider) + where TType : class, IEntity + { + var connectionString = serviceProvider.GetRequiredService().GetConnectionString("Redis"); + return !string.IsNullOrEmpty(connectionString) + ? new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), connectionString) + : throw new InvalidOperationException($"No 'Redis' connection string configured for entity repository of type {typeof(TType)}."); + } +``` + +- [ ] **Step 6: Build the silo** + +Run: `dotnet build src/Silo/Silo.csproj --nologo` +Expected: Build succeeded, 0 errors. (`CommunicationOptions` still exists in `Common`; it is simply no longer referenced by the silo.) + +- [ ] **Step 7: Commit** + +```bash +git add src/Silo/ +git commit -m "refactor: silo uses native appsettings + ConnectionStrings" +``` + +--- + +## Task 3: Tools — native config via Host.CreateApplicationBuilder + +Both tools switch to `Host.CreateApplicationBuilder(args).Configuration`, read Redis from `GetConnectionString("Redis")`, and read their own `appsettings.json`. This also fixes the exporter's cross-file read (it currently loads `DataImporterSettings.json`). + +**Files:** +- Modify: `tools/DataImporter/Program.cs`, `tools/DataImporter/DataImporter.cs`, `tools/DataImporter/DataImporter.csproj` +- Modify: `tools/DataExporter/Program.cs`, `tools/DataExporter/DataExporter.cs`, `tools/DataExporter/DataExporter.csproj` +- Rename: `DataImporterSettings.json`/`.Release.json` → `appsettings.json`/`appsettings.Release.json`; same for DataExporter + +- [ ] **Step 1: Rename + rewrite the importer settings file** + +```bash +git mv tools/DataImporter/DataImporterSettings.json tools/DataImporter/appsettings.json +git mv tools/DataImporter/DataImporterSettings.Release.json tools/DataImporter/appsettings.Release.json +git mv tools/DataImporter/DataImporterSettings.Debug.json tools/DataImporter/appsettings.Debug.json 2>/dev/null || true +``` + +Rewrite `tools/DataImporter/appsettings.json`: + +```json +{ + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning" + }, + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + } +} +``` + +- [ ] **Step 2: Rename + rewrite the exporter settings file (drop the dead Postgres block)** + +```bash +git mv tools/DataExporter/DataExporterSettings.json tools/DataExporter/appsettings.json +git mv tools/DataExporter/DataExporterSettings.Release.json tools/DataExporter/appsettings.Release.json +git mv tools/DataExporter/DataExporterSettings.Debug.json tools/DataExporter/appsettings.Debug.json 2>/dev/null || true +``` + +Rewrite `tools/DataExporter/appsettings.json` (identical shape to the importer): + +```json +{ + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning" + }, + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + } +} +``` + +- [ ] **Step 3: Update both `.csproj` copy directives** + +In `tools/DataImporter/DataImporter.csproj` and `tools/DataExporter/DataExporter.csproj`, replace the `SiloSettings`/`DataImporterSettings`/`DataExporterSettings` `None Update` entries with: + +```xml + + + Always + + + Always + + + + + + Always + + +``` + +- [ ] **Step 4: Update `tools/DataImporter/Program.cs`** + +Replace the private `GetConfiguration` and its call. Change the top of `Main`: + +```csharp + var configuration = Host.CreateApplicationBuilder(args).Configuration; + var section = configuration.GetSection(Settings.LoggingOptionsBlockName); +``` + +Delete the entire `private static IConfiguration GetConfiguration(string settingsFile) { ... }` method. Add `using Microsoft.Extensions.Hosting;` and remove `using System.IO;` if no longer used. Keep `using Microsoft.Extensions.Configuration;` (needed for `GetSection`). + +- [ ] **Step 5: Update `tools/DataImporter/DataImporter.cs`** + +The `DataImporter` constructor takes `ILoggerFactory loggerFactory`. Add an `IConfiguration` parameter so it shares the host configuration, and read Redis from it: + +Change the constructor signature and the connection-string lines: + +```csharp + public DataImporter(ILoggerFactory loggerFactory, IConfiguration configuration) + { + _logger = loggerFactory.CreateLogger(); + _jsonSerializerOptions = GetJsonSerializerOptions(); + + // Repository provider setup + var redisConnectionString = configuration.GetConnectionString("Redis"); + _repositoryProvider = new RepositoryProvider( + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString)); + } +``` + +KEEP `using static Faemiyah.BtDamageResolver.Common.ConfigurationUtilities;` (still used by `GetJsonSerializerOptions()`). Remove `using Faemiyah.BtDamageResolver.Common.Options;` (no longer references `CommunicationOptions`) and `using Faemiyah.BtDamageResolver.Common.Constants;` (the only `Settings.*` use — `CommunicationOptionsBlockName` — is gone). Keep `using Microsoft.Extensions.Configuration;`. + +- [ ] **Step 6: Thread configuration through the importer's `RunDataImport`** + +In `tools/DataImporter/Program.cs`, the `RunDataImport` helper constructs `new DataImporter(loggerFactory)`. Pass configuration through. Update `Main` to capture `configuration` (already done in Step 4) and change `RunDataImport`: + +```csharp + var result = Parser.Default.ParseArguments(args) + .MapResult( + initOptions => RunDataImport(loggerFactory, configuration, initOptions).Result, + errs => 1); +``` + +```csharp + private static async Task RunDataImport(ILoggerFactory loggerFactory, IConfiguration configuration, DataImportOptions options) + { + try + { + var dataImporter = new DataImporter(loggerFactory, configuration); + await dataImporter.Work(options); + } + catch (Exception e) + { + Console.WriteLine(e); + return 1; + } + + return 0; + } +``` + +- [ ] **Step 7: Apply the identical changes to the exporter** + +Repeat Steps 4-6 for `tools/DataExporter/Program.cs` and `tools/DataExporter/DataExporter.cs`, substituting `DataExporter`/`DataExportOptions`/`RunDataExport` and the logger category `"DataExporter"`. The two `Program.cs` files are structurally identical apart from those names. Apply the same `using` cleanup to `DataExporter.cs` (remove `Common.Options` and `Common.Constants`, keep the `ConfigurationUtilities` static using). The exporter's `DataExporter` constructor (currently reading `DataImporterSettings.json`) becomes: + +```csharp + public DataExporter(ILoggerFactory loggerFactory, IConfiguration configuration) + { + _logger = loggerFactory.CreateLogger(); + _jsonSerializerOptions = GetJsonSerializerOptions(); + + // Pretty printing options + _jsonSerializerOptions.IndentCharacter = ' '; + _jsonSerializerOptions.IndentSize = 4; + _jsonSerializerOptions.WriteIndented = true; + + // Repository provider setup + var redisConnectionString = configuration.GetConnectionString("Redis"); + _repositoryProvider = new RepositoryProvider( + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString)); + } +``` + +- [ ] **Step 8: Build both tools** + +Run: `dotnet build tools/DataImporter/DataImporter.csproj --nologo && dotnet build tools/DataExporter/DataExporter.csproj --nologo` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 9: Commit** + +```bash +git add tools/ +git commit -m "refactor: tools use native config + ConnectionStrings; fix exporter cross-file read" +``` + +--- + +## Task 4: Services — CommunicationService reads Redis from IConfiguration + +**Files:** +- Modify: `src/Services/CommunicationService.cs` + +- [ ] **Step 1: Swap the options dependency for `IConfiguration`** + +Change the constructor parameter `IOptions communicationOptions` to `IConfiguration configuration`, and line 44's `communicationOptions.Value.ConnectionString` to `configuration.GetConnectionString("Redis")`: + +```csharp + public CommunicationService( + ILogger logger, + IConfiguration configuration, + IOptions jsonSerializerOptions, + DataHelper dataHelper, + IGrainFactory grainFactory, + GrainId grainId, + Silo silo, + ILoggerFactory loggerFactory) : base(grainId, silo, loggerFactory) + { + _logger = logger; + _serverToClientCommunicator = new ServerToClientCommunicator(loggerFactory.CreateLogger(), jsonSerializerOptions, configuration.GetConnectionString("Redis"), dataHelper, grainFactory); + } +``` + +Update the `` doc line to `The application configuration.`. Add `using Microsoft.Extensions.Configuration;`. Remove `using Faemiyah.BtDamageResolver.Common.Options;` (keep `using Microsoft.Extensions.Options;` — still used by `IOptions`). + +- [ ] **Step 2: Build Services** + +Run: `dotnet build src/Services/Services.csproj --nologo` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Services/CommunicationService.cs +git commit -m "refactor: CommunicationService reads Redis from IConfiguration" +``` + +--- + +## Task 5: Delete CommunicationOptions, GetConfiguration, RESOLVER_ENVIRONMENT; fix Settings docs + +At this point nothing in the code repo references `CommunicationOptions` or `GetConfiguration`. Remove them. + +**Files:** +- Delete: `src/Common/Options/CommunicationOptions.cs` +- Modify: `src/Common/Constants/Settings.cs` +- Modify: `src/Common/ConfigurationUtilities.cs` + +- [ ] **Step 1: Delete the options class** + +```bash +git rm src/Common/Options/CommunicationOptions.cs +``` + +- [ ] **Step 2: Update `src/Common/Constants/Settings.cs`** + +Remove the `CommunicationOptionsBlockName` constant and its doc comment. Fix the two wrong "RabbitMQ" doc comments: + +```csharp + /// + /// The name of the options block which contains Orleans clustering settings (the ADO.NET invariant). + /// + public const string ClusterOptionsBlockName = "ClusterOptions"; + + /// + /// The name of the options block which configures application logging. + /// + public const string LoggingOptionsBlockName = "LoggingOptions"; +``` + +(Leave `CompressionOptionsBlockName`, `ActorStateStoreName`, `SessionStateStoreName`, `MaximumGameEntryAgeHours` unchanged.) + +- [ ] **Step 3: Trim `src/Common/ConfigurationUtilities.cs`** + +Delete the `GetConfiguration` method (lines ~80-118), the `GetEnvironmentName` method (lines ~64-78), and the `EnvironmentName` / `DefaultDebugEnvironmentName` / `DefaultEnvironmentName` constants. Simplify `GetSiloPortConfigurationFromEnvironment` to drop the `portPad` parameter and the Debug-padding block: + +```csharp + public static (int ClientPort, int SiloPort) GetSiloPortConfigurationFromEnvironment() + { + int clientPort = DefaultOrleansClientPort, siloPort = DefaultOrleansSiloPort; + + try + { + var envClientPort = Environment.GetEnvironmentVariable(EnvironmentOrleansClientPort); + var envSiloPort = Environment.GetEnvironmentVariable(EnvironmentOrleansSiloPort); + + if (envClientPort != null && envSiloPort != null) + { + clientPort = int.Parse(envClientPort, NumberStyles.Integer, CultureInfo.InvariantCulture); + siloPort = int.Parse(envSiloPort, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + } + catch (Exception ex) + { + throw new InvalidOperationException("Unable to resolve silo port configuration from environment variables.", ex); + } + + return (clientPort, siloPort); + } +``` + +Remove the now-unused `using System.IO;` and `using Microsoft.Extensions.Configuration;` directives. Keep `System`, `System.Globalization`, `System.Net`, `System.Net.Sockets`, `System.Text.Json`, `System.Text.Json.Serialization`, `Microsoft.Extensions.DependencyInjection`. (`Faemiyah...Common.Options` is no longer needed either — remove it.) + +- [ ] **Step 4: Full code-repo build + tests** + +Run: `dotnet build --nologo` +Expected: Build succeeded, 0 errors across all projects (Common, Api, Services, Actors, Silo, tools, Tests). + +Run: `dotnet test tests/Tests/Tests.csproj --nologo` +Expected: All tests pass (including `ConfigurationPrecedenceTests`). + +- [ ] **Step 5: Verify RESOLVER_ENVIRONMENT is gone from code** + +Run: `git grep -n "RESOLVER_ENVIRONMENT\|CommunicationOptions\|GetConfiguration\|GetEnvironmentName"` +Expected: no matches in `src/`, `tools/`, or `tests/` (matches only inside `docs/superpowers/`). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor: remove CommunicationOptions, custom config loader, RESOLVER_ENVIRONMENT" +``` + +--- + +## Task 6: Infra — rename orleansdb → resolverpostgres + ConnectionStrings env vars + +**Files (in the infra repo — `git status` to confirm which repo tracks these):** +- Modify: `infra/docker-compose.yml` +- Modify: `infra/grafana/datasources/datasource.yml` +- Modify: `refresh.sh`, `refresh.bat` + +- [ ] **Step 1: Update `infra/docker-compose.yml`** + +Make these edits: +- `resolver.environment`: `RESOLVER_ENVIRONMENT: Release` → `DOTNET_ENVIRONMENT: Release`; rename `ClusterOptions__ConnectionString` → `ConnectionStrings__Postgres` and change `Host=orleansdb` → `Host=resolverpostgres`; rename `CommunicationOptions__ConnectionString` → `ConnectionStrings__Redis`. +- `resolver.depends_on`: `- orleansdb` → `- resolverpostgres`. +- `resolverclient.environment`: remove the `RESOLVER_ENVIRONMENT: Release` line; rename `CommunicationOptions__ConnectionString` → `ConnectionStrings__Redis` (keep `ASPNETCORE_ENVIRONMENT: Release`). +- `resolverdataimporter.environment`: `RESOLVER_ENVIRONMENT: Release` → `DOTNET_ENVIRONMENT: Release`; rename `CommunicationOptions__ConnectionString` → `ConnectionStrings__Redis`. +- `grafana.depends_on`: `- orleansdb` → `- resolverpostgres`. +- The `orleansdb:` service block → rename the key to `resolverpostgres:`; `container_name: orleansdb` → `resolverpostgres`; `hostname: orleansdb` → `resolverpostgres`; `image: orleansdb:latest` → `resolverpostgres:latest`; volume mount `orleansdb-data:/var/lib/postgresql/18/docker` → `resolverpostgres-data:/var/lib/postgresql/18/docker`. +- Top-level `volumes:` block: `orleansdb-data:` → `resolverpostgres-data:`. + +Resulting `resolver` env block for reference: + +```yaml + environment: + DOTNET_ENVIRONMENT: Release + ConnectionStrings__Postgres: "User ID=${RESOLVER_USER:?err};Password=${RESOLVER_PASSWORD:?err};Host=resolverpostgres;Port=5432;Database=BtDamageResolver;SSL Mode=Disable;" + ConnectionStrings__Redis: "redis:6379,password=${RESOLVER_PASSWORD:?err}" +``` + +- [ ] **Step 2: Update `infra/grafana/datasources/datasource.yml`** + +Change `url: orleansdb:5432` → `url: resolverpostgres:5432`. Leave `name: Orleansdb` unchanged (Grafana dashboards may reference the datasource by name; renaming the display name is out of scope). + +- [ ] **Step 3: Update build scripts** + +In `refresh.sh` and `refresh.bat`, change `docker build --tag orleansdb -f ./postgresql/Dockerfile ./postgresql/` → `docker build --tag resolverpostgres -f ./postgresql/Dockerfile ./postgresql/`. + +- [ ] **Step 4: Verify no stray references** + +Run: `git grep -n "orleansdb"` (in the infra repo) +Expected: no matches (the volume, hostname, image, datasource url, and build tag are all renamed). + +- [ ] **Step 5: Commit (in the infra repo)** + +```bash +git add infra/docker-compose.yml infra/grafana/datasources/datasource.yml refresh.sh refresh.bat +git commit -m "chore: rename orleansdb container to resolverpostgres; native ConnectionStrings env vars" +``` + +--- + +## Task 7: Client — native config + ConnectionStrings (separate repo) + +**Files (in `C:\Work\src\BtDamageResolver\BtDamageResolverClient`):** +- Modify: `src/BlazorServer/Startup.cs` +- Modify: `src/BlazorServer/Communication/ResolverCommunicator.cs` +- Modify: `src/BlazorServer/appsettings.json` +- Create: `src/BlazorServer/appsettings.Release.json` +- Delete: `src/BlazorServer/CommunicationSettings.json`, `CommunicationSettings.Release.json`, `CommunicationSettings.Debug.json` +- Modify: `src/BlazorServer/BlazorServer.csproj` + +- [ ] **Step 1: Merge config into `appsettings.json`** + +Rewrite `src/BlazorServer/appsettings.json` to fold in the contents of `CommunicationSettings.json`: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "AllowedHosts": "*", + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning" + }, + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + }, + "CompressionOptions": { + "Provider": "Brotli", + "Quality": 4 + } +} +``` + +- [ ] **Step 2: Move the Release override and delete the old files** + +```bash +git mv src/BlazorServer/CommunicationSettings.Release.json src/BlazorServer/appsettings.Release.json +git rm src/BlazorServer/CommunicationSettings.json src/BlazorServer/CommunicationSettings.Debug.json +``` + +If `appsettings.Release.json` is empty `{}`, leave it as `{}`. + +- [ ] **Step 3: Update `BlazorServer.csproj`** + +Remove the two `` blocks that reference `CommunicationSettings.*`. Add an explicit copy for the Release override (the Web SDK auto-includes `appsettings.json`/`appsettings.Development.json` but not `appsettings.Release.json`): + +```xml + + + PreserveNewest + + +``` + +- [ ] **Step 4: Make `ConfigureServices` an instance method binding off `Configuration`** + +In `src/BlazorServer/Startup.cs`, change the signature from `public static void ConfigureServices(IServiceCollection services)` to `public void ConfigureServices(IServiceCollection services)`, delete the line `var configuration = GetConfiguration("CommunicationSettings.json");`, and replace every `configuration.` reference in the method with `Configuration.` (the injected property). Specifically: + +```csharp + public void ConfigureServices(IServiceCollection services) + { + services.Configure(Configuration.GetSection(Settings.CompressionOptionsBlockName)); + services.Configure(Configuration.GetSection(Settings.LoggingOptionsBlockName)); +``` + +Update the data-protection line that read `configuration[...]`: + +```csharp + services.AddDataProtection().SetApplicationName("BtDamageResolverClient").PersistKeysToFileSystem(new DirectoryInfo(Configuration["DataProtectionKeysPath"] ?? "/app/dpkeys/")); +``` + +Delete the now-removed `services.Configure(...)` line. Remove `using static Faemiyah.BtDamageResolver.Common.ConfigurationUtilities;` ONLY if `ConfigureJsonSerializerOptions()` (line ~89) is the sole remaining use — it is an extension method on `IServiceCollection` from that static class, so KEEP the static using. Leave the rest of the method unchanged. + +- [ ] **Step 5: Update `GetRedisEntityRepository` in `Startup.cs`** + +```csharp + private RedisEntityRepository GetRedisEntityRepository(IServiceProvider serviceProvider) + where TType : class, IEntity + { + var connectionString = Configuration.GetConnectionString("Redis"); + + if (!string.IsNullOrEmpty(connectionString)) + { + return new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), connectionString); + } + + throw new InvalidOperationException($"No 'Redis' connection string configured for entity repository of type {typeof(TType)}."); + } +``` + +(`GetRedisEntityRepository` becomes a non-static instance method so it can read `Configuration`; it is referenced as a method-group `GetRedisEntityRepository` in the registrations, which still resolves on the instance.) + +- [ ] **Step 6: Update `ResolverCommunicator.cs`** + +Replace the `IOptions` dependency with `IConfiguration`: + +```csharp + private readonly string _redisConnectionString; +``` + +(replacing `private readonly CommunicationOptions _communicationOptions;`) + +```csharp + public ResolverCommunicator(ILogger logger, IConfiguration configuration, IOptions jsonSerializerOptions, DataHelper dataHelper, HubConnection hubConnection) + { + _logger = logger; + _jsonSerializerOptions = jsonSerializerOptions; + _redisConnectionString = configuration.GetConnectionString("Redis"); + _dataHelper = dataHelper; + _hubConnection = hubConnection; + } +``` + +At line ~368, change `_communicationOptions.ConnectionString` → `_redisConnectionString`. Update the `` doc to `The application configuration.`. Add `using Microsoft.Extensions.Configuration;`. Remove `using Faemiyah.BtDamageResolver.Common.Options;` (keep `Microsoft.Extensions.Options` — still used for `IOptions`). + +- [ ] **Step 7: Build the client** + +Run (from the client repo root): `dotnet build src/BlazorServer/BlazorServer.csproj --nologo` +Expected: Build succeeded, 0 errors. (The client compiles against `Common` NuGet 0.0.447, which still contains `CommunicationOptions`; the client simply no longer references it.) + +- [ ] **Step 8: Commit (in the client repo)** + +```bash +git add -A +git commit -m "refactor: client uses native appsettings + ConnectionStrings" +``` + +--- + +## Final verification (manual gate — owner) + +- [ ] Code repo: `dotnet build --nologo` and `dotnet test tests/Tests/Tests.csproj --nologo` both green. +- [ ] Client repo: `dotnet build src/BlazorServer/BlazorServer.csproj --nologo` green. +- [ ] `git grep -n "orleansdb"` clean in code + infra repos. +- [ ] Build images (`./refresh.sh` or `refresh.bat`) and `docker compose -f infra/docker-compose.yml up`. Confirm: silo connects to Redis + Postgres, client connects, DB logging writes. Note the Postgres volume is reset (empty DB; schema recreated by the custom image on first init). +``` diff --git a/BtDamageResolver/docs/superpowers/specs/2026-06-02-native-appsettings-config-refactor-design.md b/BtDamageResolver/docs/superpowers/specs/2026-06-02-native-appsettings-config-refactor-design.md new file mode 100644 index 00000000..349b1774 --- /dev/null +++ b/BtDamageResolver/docs/superpowers/specs/2026-06-02-native-appsettings-config-refactor-design.md @@ -0,0 +1,217 @@ +# Native appsettings + ConnectionStrings refactor + +**Date:** 2026-06-02 +**Status:** Approved (design) +**Scope:** Silo, BlazorServer client, DataImporter, DataExporter, Common, Services, infra + +## Goal + +Replace the custom configuration-loading scheme (`ConfigurationUtilities.GetConfiguration`) +with the native .NET host configuration chain; replace the `CommunicationOptions` wrapper class +with the native `ConnectionStrings` block; move the Postgres connection string into +`ConnectionStrings` as well; remove the now-dead `RESOLVER_ENVIRONMENT` / Orleans port-padding +code; and rename the misleadingly-named `orleansdb` container to `resolverpostgres`. + +The custom loader was originally written to guarantee the source order +`config file -> environment-override file -> environment variables`. This is **already** the +default order produced by `Host.CreateDefaultBuilder()` / `Host.CreateApplicationBuilder()`, with +environment variables taking precedence over JSON values. The custom loader is therefore redundant. + +## Background / current state + +- `ConfigurationUtilities.GetConfiguration(settingsFile)` (`src/Common/ConfigurationUtilities.cs:88-118`) + builds: base JSON -> `{file}.{RESOLVER_ENVIRONMENT}.json` (mandatory if the env var is set; throws + otherwise) -> `AddEnvironmentVariables()`. +- **Silo** (`src/Silo/Program.cs`): `Host.CreateDefaultBuilder()` with custom config injected via + `.ConfigureAppConfiguration((_, c) => c.AddConfiguration(GetConfiguration("SiloSettings.json")))`. +- **Client** (`BtDamageResolverClient/src/BlazorServer/Startup.cs`): `ConfigureServices` is `static`, + ignores the injected `Configuration`, and calls `GetConfiguration("CommunicationSettings.json")`. +- **Tools** (`tools/DataImporter`, `tools/DataExporter`): each has its own private `GetConfiguration` + that calls only `AddJsonFile(...)` -- **no** `AddEnvironmentVariables()`. +- `CommunicationOptions` holds a Redis `ConnectionString`. `FaemiyahClusterOptions` holds `Invariant` + + a Postgres `ConnectionString`. + +### Package topology (important) + +- The **client** (`BlazorServer.csproj`) consumes `Common`/`Api` via **NuGet** `PackageReference` + (v0.0.447), NOT project references. The **silo, services, tools** use project references. +- Consequence: editing/deleting types in `Common` source does not break the client build until a new + package is published (owner's separate NuGet workflow). The client must only stop *referencing* + removed types in its own code. + +### Known issues surfaced during analysis + +1. **Tool env-var override is dead today.** The tools never call `AddEnvironmentVariables()`, so the + `CommunicationOptions__ConnectionString` env var for `resolverdataimporter` (`docker-compose.yml:45`) + is silently ignored; the literal `password=PASSWORD` placeholder is used. This refactor fixes it. +2. **DataExporter reads the wrong file.** `DataExporter.cs:43` calls `GetConfiguration("DataImporterSettings.json")` + (the importer's file) for its Redis connection, while `Program.cs:32` reads `DataExporterSettings.json` + for logging. The exporter's `ClusterOptions`/Postgres block is therefore dead. Going native (each tool + reads its own `appsettings.json`) fixes the cross-file read; the dead Postgres block is dropped. +3. **Dockerfiles do not reference settings filenames**; renaming settings files needs no Dockerfile changes. + +## Design + +### 1. Configuration loading + +Delete `ConfigurationUtilities.GetConfiguration`. + +- **Silo** (`src/Silo/Program.cs`): remove the `.ConfigureAppConfiguration(... AddConfiguration ...)` line + and the top-level `GetConfiguration` call. Read config from `context.Configuration` inside a + `.UseOrleans((context, siloBuilder) => ...)` delegate. Default chain: + `appsettings.json -> appsettings.{DOTNET_ENVIRONMENT}.json -> env vars`. +- **Client** (`Startup.cs`): make `ConfigureServices` an **instance** method binding off the injected + `Configuration`. Remove the `GetConfiguration("CommunicationSettings.json")` call. +- **Tools**: replace each private `GetConfiguration` with `Host.CreateApplicationBuilder(args).Configuration` + (projects already reference `Microsoft.Extensions.Hosting`). Native chain, no custom code, and each tool + reads its own `appsettings.json` (fixes finding #2). + +### 2. Remove RESOLVER_ENVIRONMENT and the dead port-padding path + +In `ConfigurationUtilities`: +- Delete `GetEnvironmentName`, the `EnvironmentName` constant, and `DefaultDebugEnvironmentName`/`DefaultEnvironmentName`. +- Simplify `GetSiloPortConfigurationFromEnvironment`: drop the `portPad` parameter and the Debug-padding block; + keep the `ORLEANS_CLIENTPORT`/`ORLEANS_SILOPORT` reads and defaults. +- Remove now-unused `using` directives (`System.IO`, `Microsoft.Extensions.Configuration`). + +`GetSiloPortConfigurationFromEnvironment()` is already called with no argument (`Program.cs:114`). +After this, `RESOLVER_ENVIRONMENT` no longer appears anywhere. Remaining in `ConfigurationUtilities`: +`GetSiloPortConfigurationFromEnvironment`, `GetHostIp`, and the JSON-serializer helpers. + +### 3. CommunicationOptions -> ConnectionStrings:Redis + +- Delete `src/Common/Options/CommunicationOptions.cs` and `Settings.CommunicationOptionsBlockName`. +- JSON: `"CommunicationOptions": { "ConnectionString": "redis:..." }` -> `"ConnectionStrings": { "Redis": "redis:..." }`. +- Consumers switch from `IOptions` to `IConfiguration.GetConnectionString("Redis")`: + - `src/Silo/Program.cs:227` `GetRedisEntityRepository` -- resolve `IConfiguration` from `serviceProvider`. + - `src/Services/CommunicationService.cs:35,44` -- inject `IConfiguration`. + - `BtDamageResolverClient/src/BlazorServer/Communication/ResolverCommunicator.cs:40,368` -- inject `IConfiguration`. + - `tools/DataImporter/DataImporter.cs:41`, `tools/DataExporter/DataExporter.cs:44` -- read via `GetConnectionString`. +- Remove the now-unused `services.Configure(...)` registrations and `using` directives. + +### 4. Postgres connection string -> ConnectionStrings:Postgres + +- JSON: move the Postgres string from `ClusterOptions:ConnectionString` to `ConnectionStrings:Postgres`. + `ClusterOptions` retains only `Invariant`. +- **Keep `FaemiyahClusterOptions` unchanged** (still `Invariant` + `ConnectionString`) to avoid changing the + published `Common` package contract and to leave `Services` (`LoggingService`/`LoggingRepository`) untouched. + In the Silo DI, hydrate the options object: + ```csharp + services.Configure(configuration.GetSection(Settings.ClusterOptionsBlockName)); // Invariant + services.PostConfigure(o => o.ConnectionString = configuration.GetConnectionString("Postgres")); + ``` +- Silo clustering/grain storage read `Invariant` from the section and the connection string from + `GetConnectionString("Postgres")`. `LoggingService`/`LoggingRepository` are unchanged (still receive a + fully-populated `FaemiyahClusterOptions` via `IOptions<>`). +- Connection-string key name: **`Postgres`**. + +### 5. Settings.cs documentation fixes + +`ClusterOptionsBlockName` and `LoggingOptionsBlockName` XML docs both wrongly say "RabbitMQ"; correct them. +(`CommunicationOptionsBlockName` is deleted in section 3.) + +### 6. Rename orleansdb -> resolverpostgres (infra) + +The Postgres container is misnamed (it backs Orleans clustering, grain storage, **and** DB logging). +Rename everywhere: +- `infra/docker-compose.yml`: service key, `container_name`, `hostname`, `image: orleansdb:latest` + -> `resolverpostgres:latest`, both `depends_on: orleansdb` entries, the volume mount and the volume + declaration `orleansdb-data` -> `resolverpostgres-data`, and `Host=orleansdb` -> `Host=resolverpostgres` + in the Postgres connection string env var. +- `infra/grafana/datasources/datasource.yml`: `url: orleansdb:5432` -> `resolverpostgres:5432`. The datasource + `name: Orleansdb` is left unchanged unless verified safe to rename (Grafana dashboards may reference the + datasource by name). +- `refresh.sh` and `refresh.bat`: `docker build --tag orleansdb ...` -> `--tag resolverpostgres ...`. +- `Host=orleansdb` -> `Host=resolverpostgres` in the Silo `appsettings.json` default. + +**Volume:** renamed to `resolverpostgres-data` (accepted). Next `docker compose up` starts with an empty +Postgres DB; the custom Postgres image (`infra/postgresql/Dockerfile`) recreates the Orleans schema on first +init. Persisted Orleans grain/session state and logs are lost; Redis reference data is unaffected. + +### 7. File renames + environment mapping + +| Old | New | +|---|---| +| `src/Silo/SiloSettings.json` / `.Debug` / `.Release` | `appsettings.json` / `.Debug` / `.Release` | +| `tools/DataImporter/DataImporterSettings.*` | `appsettings.*` | +| `tools/DataExporter/DataExporterSettings.*` | `appsettings.*` (dead Postgres block dropped) | +| Client `CommunicationSettings.json` | merged into existing `appsettings.json` | +| Client `CommunicationSettings.Release.json` | becomes `appsettings.Release.json` | +| Client `CommunicationSettings.Debug.json` | deleted (client uses `ASPNETCORE_ENVIRONMENT`) | + +Environment variable per process: +- **Silo + tools:** `RESOLVER_ENVIRONMENT` -> `DOTNET_ENVIRONMENT`. +- **Client:** already `ASPNETCORE_ENVIRONMENT`; drop the redundant `RESOLVER_ENVIRONMENT`. + +`.csproj` copy directives: +- `Silo.csproj`, `tools/*/*.csproj` (console SDK): rename `None Update` entries to `appsettings*.json`. +- `BlazorServer.csproj` (Web SDK): `appsettings.json` auto-copies; remove the `CommunicationSettings.*` + entries; add an explicit copy for `appsettings.Release.json`. + +### 8. docker-compose.yml env vars + +- `resolver`: `RESOLVER_ENVIRONMENT` -> `DOTNET_ENVIRONMENT`; `CommunicationOptions__ConnectionString` + -> `ConnectionStrings__Redis`; `ClusterOptions__ConnectionString` -> `ConnectionStrings__Postgres` + (with `Host=resolverpostgres`). +- `resolverclient`: remove `RESOLVER_ENVIRONMENT`; `CommunicationOptions__ConnectionString` -> `ConnectionStrings__Redis`. +- `resolverdataimporter`: `RESOLVER_ENVIRONMENT` -> `DOTNET_ENVIRONMENT`; `CommunicationOptions__ConnectionString` -> `ConnectionStrings__Redis`. + +### Resulting JSON shapes + +Silo `appsettings.json`: +```json +{ + "LoggingOptions": { "LogToConsole": true, "LogToDatabase": true, "LogLevel": "Information", "LogLevelOrleans": "Warning", "LoggingIntervalMilliseconds": 15000, "SendDetailedErrorsToClient": true }, + "ClusterOptions": { "Invariant": "Npgsql" }, + "ConnectionStrings": { + "Postgres": "User ID=USERNAME;Password=PASSWORD;Host=resolverpostgres;Port=5432;Database=BtDamageResolver;SSL Mode=Disable;", + "Redis": "redis:6379,password=PASSWORD" + }, + "CompressionOptions": { "Provider": "Brotli", "Quality": 4 } +} +``` + +Client `appsettings.json` (merge of existing + `CommunicationSettings.json`): +```json +{ + "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning", "Microsoft.Hosting.Lifetime": "Information" } }, + "AllowedHosts": "*", + "LoggingOptions": { "LogToConsole": true, "LogLevel": "Information", "LogLevelOrleans": "Warning" }, + "ConnectionStrings": { "Redis": "redis:6379,password=PASSWORD" }, + "CompressionOptions": { "Provider": "Brotli", "Quality": 4 } +} +``` + +Tools `appsettings.json` (both importer and exporter): +```json +{ + "LoggingOptions": { "LogToConsole": true, "LogLevel": "Information", "LogLevelOrleans": "Warning" }, + "ConnectionStrings": { "Redis": "redis:6379,password=PASSWORD" } +} +``` + +## Testing + +- **New test** (`tests/Tests`): build a `ConfigurationBuilder` over a sample `appsettings.json` fixture and + assert (a) `GetConnectionString("Redis")` resolves the JSON value, and (b) a `ConnectionStrings__Redis` + environment variable **overrides** the JSON value (locks in the precedence behavior that motivated the + original loader). +- **Build** Silo, BlazorServer, DataImporter, DataExporter, Common, Services, Tests. +- **Manual gate (owner):** `docker compose up` smoke test for real Redis/Postgres connectivity (note the + Postgres volume reset). + +## Risks + +- Missing a compose env-var rename -> Redis/Postgres silently uses the `PASSWORD` placeholder. Mitigated by + the rename tables (sections 7-8) and the precedence test. +- A missed `orleansdb` reference -> resolver/grafana cannot reach Postgres. Mitigated by the section 6 file list. +- Web SDK not copying `appsettings.Release.json` -> empty override at runtime (harmless); mitigated by the + explicit copy directive. +- `FaemiyahClusterOptions` connection-string source becomes indirect (hydrated via `PostConfigure`); mitigated + by an explanatory comment at the registration site. + +## Out of scope + +- Splitting the single Postgres DB into separate clustering/storage/logging databases. +- Renaming `ConfigurationUtilities` (now purely Orleans-port/IP + JSON helpers). +- Renaming the Grafana datasource display name `Orleansdb` (cosmetic; deferred to avoid dashboard breakage). diff --git a/BtDamageResolver/exportdata.sh b/BtDamageResolver/exportdata.sh deleted file mode 100644 index c3b20b97..00000000 --- a/BtDamageResolver/exportdata.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -dotnet DataExporter.dll export -f ./data/ diff --git a/BtDamageResolver/importdata.bat b/BtDamageResolver/importdata.bat deleted file mode 100644 index 9a53aca4..00000000 --- a/BtDamageResolver/importdata.bat +++ /dev/null @@ -1,3 +0,0 @@ -cd tools\DataImporter\bin\Debug\net10.0 -DataImporter.exe import -s *.json -f ..\..\..\..\..\data -cd ..\..\..\..\.. diff --git a/BtDamageResolver/importdata.sh b/BtDamageResolver/importdata.sh deleted file mode 100644 index 20ad1726..00000000 --- a/BtDamageResolver/importdata.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -dotnet DataImporter.dll import -s '*.json' -f ./data/ diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IAmmoRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IAmmoRepository.cs deleted file mode 100644 index 4be6da0c..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IAmmoRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for an Ammo Repository Actor. -/// -public interface IAmmoRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IArcDiagramRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IArcDiagramRepository.cs deleted file mode 100644 index 21550001..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IArcDiagramRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for an Ammo Repository Actor. -/// -public interface IArcDiagramRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IClusterTableRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IClusterTableRepository.cs deleted file mode 100644 index a49c1886..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IClusterTableRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for a ClusterTable Repository Actor. -/// -public interface IClusterTableRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/ICriticalDamageTableRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/ICriticalDamageTableRepository.cs deleted file mode 100644 index a5d218ec..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/ICriticalDamageTableRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for a CriticalDamageTable Repository Actor. -/// -public interface ICriticalDamageTableRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IPaperDollRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IPaperDollRepository.cs deleted file mode 100644 index 8b0cdf37..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IPaperDollRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for a PaperDoll Repository Actor. -/// -public interface IPaperDollRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IUnitRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IUnitRepository.cs deleted file mode 100644 index 4dfc95af..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IUnitRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for an Unit Repository Actor. -/// -public interface IUnitRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/ActorInterfaces/Repositories/IWeaponRepository.cs b/BtDamageResolver/src/ActorInterfaces/Repositories/IWeaponRepository.cs deleted file mode 100644 index fc2571fe..00000000 --- a/BtDamageResolver/src/ActorInterfaces/Repositories/IWeaponRepository.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Faemiyah.BtDamageResolver.ActorInterfaces.Repositories.Prototypes; -using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; -using Orleans; - -namespace Faemiyah.BtDamageResolver.ActorInterfaces.Repositories; - -/// -/// Interface for a Weapon Repository Actor. -/// -public interface IWeaponRepository : IGrainWithIntegerKey, IExternalRepositoryActorBase -{ -} \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Cryptography/FaemiyahPasswordHasher.cs b/BtDamageResolver/src/Actors/Cryptography/FaemiyahPasswordHasher.cs index 6396cf7b..9a7f5880 100644 --- a/BtDamageResolver/src/Actors/Cryptography/FaemiyahPasswordHasher.cs +++ b/BtDamageResolver/src/Actors/Cryptography/FaemiyahPasswordHasher.cs @@ -1,37 +1,37 @@ using System.Security.Cryptography; -using System.Text; namespace Faemiyah.BtDamageResolver.Actors.Cryptography; /// -/// SHA-512 password hasher with a random salt. -/// Uses the static method, which is stateless and safe for concurrent -/// use without any locking — each call gets its own internal hash context from the runtime. +/// PBKDF2 (Rfc2898) password hasher with a random salt. +/// Uses the static +/// method, which is stateless and safe for concurrent use without any locking. /// Uses for constant-time comparison /// to prevent timing-oracle attacks. -/// Note: consider upgrading to PBKDF2 or Argon2 for stronger brute-force resistance in the future. /// +/// +/// The stored hash format is not self-describing (no embedded algorithm/iteration tag), so hashes +/// produced by a different algorithm or iteration count will simply fail to verify. +/// public class FaemiyahPasswordHasher : IHasher { private const int SaltLength = 32; + private const int HashLength = 32; + private const int Iterations = 100000; + private static readonly HashAlgorithmName HashAlgorithm = HashAlgorithmName.SHA256; /// public (byte[] Hash, byte[] Salt) Hash(string password) { var salt = RandomNumberGenerator.GetBytes(SaltLength); - var hash = SHA512.HashData(GetSaltedBytes(password, salt)); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithm, HashLength); return (hash, salt); } /// public bool Verify(string password, byte[] salt, byte[] referenceHash) { - var hash = SHA512.HashData(GetSaltedBytes(password, salt)); + var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, HashAlgorithm, HashLength); return CryptographicOperations.FixedTimeEquals(hash, referenceHash); } - - private static byte[] GetSaltedBytes(string password, byte[] salt) - { - return [.. Encoding.UTF8.GetBytes(password), .. salt]; - } } \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Extensions/TaskExtensions.cs b/BtDamageResolver/src/Actors/Extensions/TaskExtensions.cs new file mode 100644 index 00000000..0981d2fe --- /dev/null +++ b/BtDamageResolver/src/Actors/Extensions/TaskExtensions.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Faemiyah.BtDamageResolver.Actors.Extensions; + +/// +/// Extensions for . +/// +public static class TaskExtensions +{ + /// + /// Fire-and-forget a task, logging any exception. Use as a safer alternative to + /// Task.Ignore() in cases where a grain call must not be awaited (e.g. to avoid + /// deadlock) but failures should still be surfaced in the log. + /// + /// The task to observe. + /// The logger to write failures to. + /// The structured logging message template. + /// The arguments to fill the message template. + public static void LogAndForget(this Task task, ILogger logger, string messageTemplate, params object[] args) + { + _ = ObserveAsync(task, logger, messageTemplate, args); + } + + private static async Task ObserveAsync(Task task, ILogger logger, string messageTemplate, object[] args) + { + try + { + await task; + } + catch (Exception ex) + { + logger.LogWarning(ex, messageTemplate, args); + } + } +} diff --git a/BtDamageResolver/src/Actors/GameActor.Tools.cs b/BtDamageResolver/src/Actors/GameActor.Tools.cs index b45f8499..f7ef8446 100644 --- a/BtDamageResolver/src/Actors/GameActor.Tools.cs +++ b/BtDamageResolver/src/Actors/GameActor.Tools.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading.Tasks; using Faemiyah.BtDamageResolver.ActorInterfaces; +using Faemiyah.BtDamageResolver.Actors.Extensions; using Microsoft.Extensions.Logging; using Orleans; @@ -36,8 +37,10 @@ public async Task KickPlayer(string askingPlayerId, string playerId) // Must NOT be awaited: the player actor may currently be waiting on this grain, // which would cause a deadlock. When it eventually processes LeaveGame it will // call back into GameActor.LeaveGame(), which is idempotent and handles the - // already-removed case gracefully. - GrainFactory.GetGrain(playerId).LeaveGame().Ignore(); + // already-removed case gracefully. Use LogAndForget so failures surface in the log + // instead of being silently swallowed by .Ignore(). + GrainFactory.GetGrain(playerId).LeaveGame() + .LogAndForget(_logger, "Failed to notify kicked player {PlayerId} in Game {GameId}.", playerId, this.GetPrimaryKeyString()); _logger.LogInformation("In Game {GameId}, Player {PlayerId} successfully kicked player {PlayerToKickId}.", this.GetPrimaryKeyString(), askingPlayerId, playerId); diff --git a/BtDamageResolver/src/Actors/GameActor.TurnLogic.cs b/BtDamageResolver/src/Actors/GameActor.TurnLogic.cs index 0bdde447..8eaf249e 100644 --- a/BtDamageResolver/src/Actors/GameActor.TurnLogic.cs +++ b/BtDamageResolver/src/Actors/GameActor.TurnLogic.cs @@ -94,7 +94,7 @@ private static void ProcessUnitHeat(List damageReports, UnitEntry } } - private async Task CheckGameStateUpdateEvents(List updatedUnits = null) + private async Task CheckGameStateUpdateEvents(List updatedUnits = null, bool refreshGameEntry = false) { try { @@ -126,21 +126,26 @@ private async Task CheckGameStateUpdateEvents(List updatedUnits = null) // Log update to permanent store await _loggingServiceClient.LogGameAction(DateTime.UtcNow, this.GetPrimaryKeyString(), GameActionType.Update, 1); - // Remark game existence - await GrainFactory.GetGameEntryRepository().AddOrUpdate( - new GameEntry - { - Name = this.GetPrimaryKeyString(), - PasswordProtected = _gameActorState.State.PasswordHash != null, - Players = _gameActorState.State.PlayerStates.Count, - TimeStamp = DateTime.UtcNow - }); + // Remark game existence only on events that change lobby-relevant state (join/leave/turn). + // Per-keystroke player-state edits do not need to refresh the lobby entry, which would otherwise + // trigger a broadcast storm via GameEntryRepository.AddOrUpdate -> Distribute(). + if (refreshGameEntry || fireEventHappened) + { + await GrainFactory.GetGameEntryRepository().AddOrUpdate( + new GameEntry + { + Name = this.GetPrimaryKeyString(), + PasswordProtected = _gameActorState.State.PasswordHash != null, + Players = _gameActorState.State.PlayerStates.Count, + TimeStamp = DateTime.UtcNow + }); + } } catch (Exception ex) { - // I really want to clearly log any error in turn logic so I can fix it. _logger.LogError(ex, "Game {GameId} experienced a critical failure while running game state update events.", this.GetPrimaryKeyString()); - throw new InvalidOperationException($"Error while running game state events for game {this.GetPrimaryKeyString()}", ex); + + throw new InvalidOperationException($"Game {this.GetPrimaryKeyString()} experienced a critical failure while running game state update events.", ex); } } @@ -302,10 +307,10 @@ private async Task ProcessUnitTargetNumbers(UnitEntry unitEn var attackLog = new AttackLog(); var isPrimaryTarget = weaponBay.FiringSolution.Target == primaryTarget; - var (targetNumber, rangeBracket) = await logicUnitAttacker.ResolveHitModifier(attackLog, logicUnitDefender, primaryTargetArc, isPrimaryTarget, weaponBay, weaponEntry); + var (targetNumber, rangeBracket) = logicUnitAttacker.ResolveHitModifier(attackLog, logicUnitDefender, primaryTargetArc, isPrimaryTarget, weaponBay, weaponEntry); - var (ammoEstimate, ammoMax) = await logicUnitAttacker.ProjectAmmo(targetNumber, rangeBracket, weaponEntry); - var (heatEstimate, heatMax) = await logicUnitAttacker.ProjectHeat(targetNumber, rangeBracket, weaponEntry); + var (ammoEstimate, ammoMax) = logicUnitAttacker.ProjectAmmo(targetNumber, rangeBracket, weaponEntry); + var (heatEstimate, heatMax) = logicUnitAttacker.ProjectHeat(targetNumber, rangeBracket, weaponEntry); var weaponAmmoCombinedString = string.IsNullOrEmpty(weaponEntry.Ammo) ? $"{weaponEntry.WeaponName}" : $"{weaponEntry.WeaponName} {weaponEntry.Ammo}"; targetNumberUpdate.AmmoEstimate.AddIfNotZero(weaponAmmoCombinedString, ammoEstimate); diff --git a/BtDamageResolver/src/Actors/GameActor.cs b/BtDamageResolver/src/Actors/GameActor.cs index 6659cbd1..8fc10506 100644 --- a/BtDamageResolver/src/Actors/GameActor.cs +++ b/BtDamageResolver/src/Actors/GameActor.cs @@ -64,6 +64,15 @@ public Task IsUnitInGame(Guid unitId) } /// + /// + /// Stores the incoming by reference. This is safe because + /// Orleans serialises Faemiyah types via the JSON serializer registered in + /// Silo/Program.cs, so every cross-grain call (including this one) produces a fresh + /// deep copy at the boundary. If that serializer registration is ever changed to a by-reference + /// path for intra-silo calls, this method (and GetGameState, which mutates timestamps + /// on stored states) would need to copy defensively to avoid aliasing + /// state. + /// public async Task SendPlayerState(string sendingPlayerId, PlayerState playerState, List unitIds) { // Do not accept player states from players who are not in the game. @@ -167,7 +176,7 @@ public async Task JoinGame(string playerId, string password) _logger.LogInformation("In Game {GameId}, Player {PlayerId} successfully connected to the game.", this.GetPrimaryKeyString(), playerId); - await CheckGameStateUpdateEvents(); + await CheckGameStateUpdateEvents(refreshGameEntry: true); // Log logins to permanent store await _loggingServiceClient.LogGameAction(DateTime.UtcNow, this.GetPrimaryKeyString(), GameActionType.Login, 0); @@ -187,7 +196,7 @@ public async Task LeaveGame(string playerId) if (_gameActorState.State.PlayerStates.Remove(playerId) || _gameActorState.State.PlayerIds.Remove(playerId)) { _gameActorState.State.TimeStamp = DateTime.UtcNow; - await CheckGameStateUpdateEvents(); + await CheckGameStateUpdateEvents(refreshGameEntry: true); _logger.LogInformation("In Game {GameId}, Player {PlayerId} successfully disconnected.", this.GetPrimaryKeyString(), playerId); @@ -226,26 +235,17 @@ private void CheckForPlayerCountEvents() private GameState GetGameState(bool markStateAsNew) { - var timeStampNow = DateTime.UtcNow; - - var gameState = new GameState + // markStateAsNew only affects the top-level GameState.TimeStamp. Per-player TimeStamps + // are already updated to TurnTimeStamp by CheckForFireEvent when a fire event happens, + // so this method does not mutate actor state. + return new GameState { AdminId = _gameActorState.State.AdminId, GameId = this.GetPrimaryKeyString(), Players = _gameActorState.State.PlayerStates, - TimeStamp = markStateAsNew ? timeStampNow : _gameActorState.State.TimeStamp, + TimeStamp = markStateAsNew ? DateTime.UtcNow : _gameActorState.State.TimeStamp, Turn = _gameActorState.State.Turn, TurnTimeStamp = _gameActorState.State.TurnTimeStamp }; - - if (markStateAsNew) - { - foreach (var player in gameState.Players) - { - player.Value.TimeStamp = timeStampNow; - } - } - - return gameState; } } \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/Expression.cs b/BtDamageResolver/src/Actors/Logic/ExpressionSolver/Expression.cs index 71d2b9fc..b8175bab 100644 --- a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/Expression.cs +++ b/BtDamageResolver/src/Actors/Logic/ExpressionSolver/Expression.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Faemiyah.BtDamageResolver.Api; @@ -10,6 +11,8 @@ namespace Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; /// public class Expression { + private static readonly HashSet _tokenChars = [.. Enum.GetValues().Select(x => (char)x)]; + private static readonly string[] _functionNames = Enum.GetNames(); private readonly List _expressions; private readonly ExpressionFunction _outerFunction; private readonly IResolverRandom _random; @@ -41,6 +44,16 @@ public Expression(decimal input) _value = input; } + /// + /// Is the given character a token. + /// + /// The character to test. + /// True if the character is a token, false otherwise. + public static bool IsToken(char input) + { + return _tokenChars.Contains(input); + } + /// /// Parses the expression. /// @@ -133,7 +146,7 @@ private static (string SubExpression, ExpressionFunction FunctionType, string Re var foundFunction = ExpressionFunction.None; string functionScope = null; - var functionName = Enum.GetNames().SingleOrDefault(input.StartsWith); + var functionName = Array.Find(_functionNames, input.StartsWith); if (functionName != null) { @@ -176,7 +189,7 @@ private void CollapseOneStage(int position) for (var diceIndex = 0; diceIndex < numDice; diceIndex++) { - value += _random.NextPlusOne(diceSize); + value += _random.DX(diceSize); } break; @@ -207,7 +220,7 @@ private void CollapseOneStage(int position) private void Construct(string input) { // If we start with a token, such as "-", put 0 before this so that it can be resolved properly - if (input[0].IsToken()) + if (Expression.IsToken(input[0])) { _expressions.Add(new Expression(_random, "0")); _tokens.Add((Token)input[0]); @@ -220,13 +233,13 @@ private void Construct(string input) for (var ii = 0; ii < input.Length; ii++) { - if (input[ii].IsToken()) + if (Expression.IsToken(input[ii])) { _tokens.Add((Token)input[ii]); if (ii != 0) { - _expressions.Add(new Expression(decimal.Parse(input[..ii]))); + _expressions.Add(new Expression(decimal.Parse(input[..ii], CultureInfo.InvariantCulture))); } input = input[(ii + 1)..]; @@ -255,7 +268,7 @@ private void Construct(string input) if (nothingFound) { - _expressions.Add(new Expression(decimal.Parse(input))); + _expressions.Add(new Expression(decimal.Parse(input, CultureInfo.InvariantCulture))); input = string.Empty; } } diff --git a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/ExpressionExtensions.cs b/BtDamageResolver/src/Actors/Logic/ExpressionSolver/ExpressionExtensions.cs deleted file mode 100644 index 5d8d75a2..00000000 --- a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/ExpressionExtensions.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using System.Linq; - -namespace Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; - -/// -/// Expression extensions. -/// -public static class ExpressionExtensions -{ - /// - /// Is the given character a token. - /// - /// The character to test. - /// True if the character is a token, false otherwise. - public static bool IsToken(this char input) - { - if (Enum.GetValues().Any(t => input.Equals((char)t))) - { - return true; - } - - return false; - } -} \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/MathExpression.cs b/BtDamageResolver/src/Actors/Logic/ExpressionSolver/MathExpression.cs index 41d0cb8e..b6d55394 100644 --- a/BtDamageResolver/src/Actors/Logic/ExpressionSolver/MathExpression.cs +++ b/BtDamageResolver/src/Actors/Logic/ExpressionSolver/MathExpression.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Concurrent; using Faemiyah.BtDamageResolver.Api; namespace Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; @@ -9,6 +10,7 @@ namespace Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; public class MathExpression : IMathExpression { private readonly IResolverRandom _random; + private readonly ConcurrentDictionary _resultCache = new(); /// /// Initializes a new instance of the class. @@ -27,8 +29,19 @@ public int Parse(string expression) return 0; } + // Only deterministic expressions (no dice token) are safe to memoize. + if (!expression.Contains((char)Token.Dice)) + { + return _resultCache.GetOrAdd(expression, Evaluate); + } + + return Evaluate(expression); + } + + private int Evaluate(string expression) + { var expressionObject = new Expression(_random, expression); return decimal.ToInt32(Math.Round(expressionObject.Parse(), MidpointRounding.AwayFromZero)); } -} \ No newline at end of file +} diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospace.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospace.cs index a5f90af3..c3ebfbea 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospace.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospace.cs @@ -12,7 +12,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; @@ -26,12 +25,11 @@ public abstract class LogicUnitAerospace : LogicUnit /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitAerospace(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) + protected LogicUnitAerospace(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -97,7 +95,7 @@ protected override async Task ResolveCriticalHit(DamageReport damageReport, Guid if (criticalThreatRoll > 7) { var aerospaceCriticalHitRoll = ResolverRandom.D26(); - damageReport.DamagePaperDoll.RecordCriticalDamage(location, damageOwnerId, inducingDamage, CriticalThreatType.DamageThreshold, criticalDamageTable.Mapping[aerospaceCriticalHitRoll]); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, DamageEntry.CreateList(inducingDamage, location, CriticalThreatType.DamageThreshold, criticalDamageTable.Mapping[aerospaceCriticalHitRoll])); damageReport.Log(new AttackLogEntry(AttackLogEntryType.DiceRoll, damageOwnerId, "Aerospace critical hit roll", aerospaceCriticalHitRoll)); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Critical, damageOwnerId, string.Join(", ", criticalDamageTable.Mapping[aerospaceCriticalHitRoll].Select(c => c.ToString())), transformedDamage, location)); } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospaceLarge.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospaceLarge.cs index 9ef7a910..659490e4 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospaceLarge.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitAerospaceLarge.cs @@ -11,7 +11,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; @@ -25,12 +24,11 @@ public abstract class LogicUnitAerospaceLarge : LogicUnitAerospace /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitAerospaceLarge(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + protected LogicUnitAerospaceLarge(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -73,12 +71,12 @@ protected override async Task ResolveAmmo(DamageReport hitCalculationDamageRepor { if (!combatAction.ActionHappened) { - hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Information, Unit.Id, $"Combat action was canceled for {combatAction.Weapon.Name} and no ammo will be expended")); + hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Information, Unit.Id, $"Combat action was cancelled for {combatAction.Weapon.Name} and no ammo will be expended")); return; } // All units may expend ammo when firing, so an unit type check is skipped - // Bail out if ammo is not used by this weapon or the combat action was canceled + // Bail out if ammo is not used by this weapon or the combat action was cancelled if (!combatAction.Weapon.UsesAmmo) { return; @@ -88,7 +86,7 @@ protected override async Task ResolveAmmo(DamageReport hitCalculationDamageRepor // I.e. when applying ammo usage, calculate it for all weapons in the bay. foreach (var weaponEntry in combatAction.WeaponBay.Weapons) { - var weapon = await FormWeapon(weaponEntry); + var weapon = FormWeapon(weaponEntry); SpendAmmo(hitCalculationDamageReport, weapon); } } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitMechBase.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitMechBase.cs index b6bc67ff..8a996025 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitMechBase.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitMechBase.cs @@ -4,11 +4,12 @@ using Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; using Faemiyah.BtDamageResolver.Api; using Faemiyah.BtDamageResolver.Api.ClientInterface.Repositories.Providers; +using Faemiyah.BtDamageResolver.Api.Constants; using Faemiyah.BtDamageResolver.Api.Entities; +using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; @@ -22,12 +23,11 @@ public abstract class LogicUnitMechBase : LogicUnit /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitMechBase(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) + protected LogicUnitMechBase(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -59,12 +59,18 @@ public override PaperDollType GetPaperDollType() } /// - public override int GetStanceModifier(int distance) + public override int GetStanceModifier(Weapon weapon, int distance) { switch (Unit.Stance) { // Prone mechs are easier to hit at point-blank range and harder to hit at any other range case Stance.Prone: + // Prone mechs cannot be punched in melee + if (!weapon.HasFeature(WeaponFeature.MeleeDfa, out var _) && weapon.AttackType is AttackType.Punch) + { + return LogicConstants.InvalidTargetNumber; + } + if (distance <= 1) { return -1; @@ -81,6 +87,7 @@ public override bool IsBlockedByCover(Cover cover, Location location) { switch (cover) { + case Cover.HullDown: case Cover.Lower: switch (location) { @@ -172,15 +179,22 @@ protected override async Task ResolveCriticalHit(DamageReport damageReport, Guid // Simulate arms and legs being able to be blown off if (criticalThreatRoll == 12 && - (location == Location.LeftArm || location == Location.LeftLeg || - location == Location.RightArm || location == Location.RightLeg)) + (location == Location.Head || + location == Location.LeftArm || + location == Location.LeftLeg || + location == Location.RightArm || + location == Location.RightLeg || + location == Location.FrontLeftLeg || + location == Location.FrontRightLeg || + location == Location.RearLeftLeg || + location == Location.RearRightLeg)) { - damageReport.DamagePaperDoll.RecordCriticalDamage(location, damageOwnerId, inducingDamage, CriticalThreatType.Normal, CriticalDamageType.BlownOff); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, new DamageEntry(inducingDamage, location, CriticalThreatType.Normal, CriticalDamageType.BlownOff)); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Critical, damageOwnerId, string.Join(", ", criticalDamageTable.Mapping[criticalThreatRoll].Select(c => c.ToString())), transformedDamage, location)); } else if (criticalDamageTable.Mapping[criticalThreatRoll].Exists(c => c != CriticalDamageType.None)) { - damageReport.DamagePaperDoll.RecordCriticalDamage(location, damageOwnerId, inducingDamage, CriticalThreatType.Normal, criticalDamageTable.Mapping[criticalThreatRoll]); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, DamageEntry.CreateList(inducingDamage, location, CriticalThreatType.Normal, criticalDamageTable.Mapping[criticalThreatRoll])); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Critical, damageOwnerId, transformedDamage, location)); } else @@ -188,4 +202,10 @@ protected override async Task ResolveCriticalHit(DamageReport damageReport, Guid damageReport.Log(new AttackLogEntry(AttackLogEntryType.Information, damageOwnerId, "Threat roll does not result in a critical hit")); } } + + /// + protected override Location TransformLocation(Location location) + { + return Location.Structure; + } } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitTrooper.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitTrooper.cs index 8ef739ab..770a8261 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitTrooper.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitTrooper.cs @@ -6,7 +6,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; @@ -20,12 +19,11 @@ public abstract class LogicUnitTrooper : LogicUnit /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitTrooper(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, random, unit) + protected LogicUnitTrooper(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, random, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicle.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicle.cs index dee1d16a..6df3fd04 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicle.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicle.cs @@ -9,12 +9,11 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; /// -/// Abstract logic class for all ground vechicles and VTOLs. +/// Abstract logic class for all ground vehicles and VTOLs. /// public abstract class LogicUnitVehicle : LogicUnit { @@ -23,12 +22,11 @@ public abstract class LogicUnitVehicle : LogicUnit /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitVehicle(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) + protected LogicUnitVehicle(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -38,6 +36,18 @@ public override bool CanTakeMotiveHits() return true; } + /// + public override int GetCoverModifier(Cover cover) + { + switch (cover) + { + case Cover.HullDown: + return 2; + default: + return 0; + } + } + /// public override Task TransformDamageBasedOnUnitType(DamageReport damageReport, Guid damageOwnerId, CombatAction combatAction, int damage) { @@ -85,7 +95,7 @@ protected override async Task ResolveCriticalHit(DamageReport damageReport, Guid if (criticalDamageTable.Mapping[criticalThreatRoll].Exists(c => c != CriticalDamageType.None)) { - damageReport.DamagePaperDoll.RecordCriticalDamage(location, damageOwnerId, inducingDamage, CriticalThreatType.Normal, criticalDamageTable.Mapping[criticalThreatRoll]); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, DamageEntry.CreateList(inducingDamage, location, CriticalThreatType.Normal, criticalDamageTable.Mapping[criticalThreatRoll])); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Critical, damageOwnerId, string.Join(", ", criticalDamageTable.Mapping[criticalThreatRoll].Select(c => c.ToString())), transformedDamage, location)); } else diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicleGround.cs b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicleGround.cs index cd0eb3cf..762f4c5f 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicleGround.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/LogicUnitVehicleGround.cs @@ -7,12 +7,11 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations; /// -/// Abstract logic class for all ground vechicles and VTOLs. +/// Abstract logic class for all ground vehicles and VTOLs. /// public abstract class LogicUnitVehicleGround : LogicUnitVehicle { @@ -21,12 +20,11 @@ public abstract class LogicUnitVehicleGround : LogicUnitVehicle /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - protected LogicUnitVehicleGround(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, random, unit) + protected LogicUnitVehicleGround(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, random, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceCapital.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceCapital.cs index 8b50d787..13472444 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceCapital.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceCapital.cs @@ -6,7 +6,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -20,12 +19,11 @@ public class LogicUnitAerospaceCapital : LogicUnitAerospaceLarge /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitAerospaceCapital(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitAerospaceCapital(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipAerodyne.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipAerodyne.cs index 49a8953c..ccd3cd59 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipAerodyne.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipAerodyne.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitAerospaceDropshipAerodyne : LogicUnitAerospaceLarge /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitAerospaceDropshipAerodyne(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitAerospaceDropshipAerodyne(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipSpheroid.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipSpheroid.cs index df1abd9d..5a0acb90 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipSpheroid.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceDropshipSpheroid.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitAerospaceDropshipSpheroid : LogicUnitAerospaceLarge /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitAerospaceDropshipSpheroid(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitAerospaceDropshipSpheroid(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceFighter.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceFighter.cs index be340aa9..9aaa6d21 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceFighter.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitAerospaceFighter.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitAerospaceFighter : LogicUnitAerospace /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitAerospaceFighter(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitAerospaceFighter(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBattleArmor.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBattleArmor.cs index 537a353b..c0818bf3 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBattleArmor.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBattleArmor.cs @@ -7,10 +7,10 @@ using Faemiyah.BtDamageResolver.Api; using Faemiyah.BtDamageResolver.Api.ClientInterface.Repositories.Providers; using Faemiyah.BtDamageResolver.Api.Entities; +using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -24,12 +24,11 @@ public class LogicUnitBattleArmor : LogicUnitTrooper /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitBattleArmor(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitBattleArmor(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -40,7 +39,7 @@ public override PaperDollType GetPaperDollType() } /// - public override int GetStanceModifier(int distance) + public override int GetStanceModifier(Weapon weapon, int distance) { switch (Unit.Stance) { @@ -89,7 +88,7 @@ protected override List ResolveDamagePackets(DamageReport damageRe /// protected override async Task ResolveTotalOutgoingDamage(DamageReport damageReport, ILogicUnit target, CombatAction combatAction) { - return await RapidFireWrapper(damageReport, target, combatAction, ResolveTotalOutgoingDamageInternalBattleArmor(damageReport, target, combatAction)); + return await RapidFireWrapper(damageReport, target, combatAction, () => ResolveTotalOutgoingDamageInternalBattleArmor(damageReport, target, combatAction)); } private async Task ResolveTotalOutgoingDamageInternalBattleArmor(DamageReport damageReport, ILogicUnit target, CombatAction combatAction) diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBuilding.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBuilding.cs index 21be1f81..11c872dd 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBuilding.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitBuilding.cs @@ -8,7 +8,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -22,12 +21,11 @@ public class LogicUnitBuilding : LogicUnit /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitBuilding(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitBuilding(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitInfantry.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitInfantry.cs index b7004b9f..95194d6c 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitInfantry.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitInfantry.cs @@ -7,10 +7,10 @@ using Faemiyah.BtDamageResolver.Api; using Faemiyah.BtDamageResolver.Api.ClientInterface.Repositories.Providers; using Faemiyah.BtDamageResolver.Api.Entities; +using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -24,12 +24,11 @@ public class LogicUnitInfantry : LogicUnitTrooper /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitInfantry(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, random, unit) + public LogicUnitInfantry(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, random, unit) { } @@ -40,7 +39,7 @@ public override PaperDollType GetPaperDollType() } /// - public override int GetStanceModifier(int distance) + public override int GetStanceModifier(Weapon weapon, int distance) { switch (Unit.Stance) { @@ -149,7 +148,7 @@ protected override List ResolveDamagePackets(DamageReport damageRe /// protected override async Task ResolveTotalOutgoingDamage(DamageReport damageReport, ILogicUnit target, CombatAction combatAction) { - return await RapidFireWrapper(damageReport, target, combatAction, ResolveTotalOutgoingDamageInternalInfantry(damageReport, target, combatAction)); + return await RapidFireWrapper(damageReport, target, combatAction, () => ResolveTotalOutgoingDamageInternalInfantry(damageReport, target, combatAction)); } private async Task ResolveTotalOutgoingDamageInternalInfantry(DamageReport damageReport, ILogicUnit target, CombatAction combatAction) diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMech.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMech.cs index 88740dfd..5155cfda 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMech.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMech.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitMech : LogicUnitMechBase /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitMech(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitMech(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } @@ -33,10 +31,4 @@ public override PaperDollType GetPaperDollType() { return PaperDollType.Mech; } - - /// - protected override Location TransformLocation(Location location) - { - return Location.Structure; - } } \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechQuad.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechQuad.cs index e13472c7..ea9bc97e 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechQuad.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechQuad.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,15 +18,31 @@ public class LogicUnitMechQuad : LogicUnitMechBase /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitMechQuad(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitMechQuad(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } + /// + public override int GetCoverModifier(Cover cover) + { + switch (cover) + { + case Cover.HullDown: + return 3; + case Cover.Lower: + case Cover.Left: + case Cover.Right: + case Cover.Upper: + return 1; + default: + return 0; + } + } + /// public override PaperDollType GetPaperDollType() { diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechTripod.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechTripod.cs index f38a7ab9..6b58fe26 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechTripod.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitMechTripod.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitMechTripod : LogicUnitMechBase /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitMechTripod(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitMechTripod(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleHover.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleHover.cs index a11d84a0..b9457afc 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleHover.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleHover.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitVehicleHover : LogicUnitVehicleGround /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitVehicleHover(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitVehicleHover(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleTracked.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleTracked.cs index 9a13c53b..99c4759f 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleTracked.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleTracked.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -24,7 +23,7 @@ public class LogicUnitVehicleTracked : LogicUnitVehicleGround /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitVehicleTracked(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitVehicleTracked(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleVtol.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleVtol.cs index 58208936..e4c9e905 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleVtol.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleVtol.cs @@ -7,7 +7,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -21,15 +20,20 @@ public class LogicUnitVehicleVtol : LogicUnitVehicle /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitVehicleVtol(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitVehicleVtol(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } + /// + public override int GetCoverModifier(Cover cover) + { + return 0; + } + /// public override int GetFeatureModifier(Weapon weapon) { @@ -65,9 +69,9 @@ protected override int TransformDamageAmountBasedOnLocation(DamageReport damageR // Only one case for now if (location == Location.Propulsion) { - var returndedDamage = decimal.ToInt32(Math.Ceiling(damage / 10m)); - damageReport.Log(new AttackLogEntry(AttackLogEntryType.Calculation, damageOwnerId, "Damage after transformation into VTOL propulsion damage", returndedDamage)); - return returndedDamage; + var returnedDamage = decimal.ToInt32(Math.Ceiling(damage / 10m)); + damageReport.Log(new AttackLogEntry(AttackLogEntryType.Calculation, damageOwnerId, "Damage after transformation into VTOL propulsion damage", returnedDamage)); + return returnedDamage; } return damage; diff --git a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleWheeled.cs b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleWheeled.cs index 4ec209cf..0f7af500 100644 --- a/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleWheeled.cs +++ b/BtDamageResolver/src/Actors/Logic/Implementations/NonAbstract/LogicUnitVehicleWheeled.cs @@ -5,7 +5,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic.Implementations.NonAbstract; @@ -19,12 +18,11 @@ public class LogicUnitVehicleWheeled : LogicUnitVehicleGround /// /// The logging interface. /// The game options. - /// The grain factory. /// The math expression parser. /// The repository provider. /// The random number generator. /// The unit. - public LogicUnitVehicleWheeled(ILogger logger, GameOptions gameOptions, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, grainFactory, mathExpression, repositoryProvider, resolverRandom, unit) + public LogicUnitVehicleWheeled(ILogger logger, GameOptions gameOptions, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom resolverRandom, UnitEntry unit) : base(logger, gameOptions, mathExpression, repositoryProvider, resolverRandom, unit) { } diff --git a/BtDamageResolver/src/Actors/Logic/Interfaces/ILogicUnit.cs b/BtDamageResolver/src/Actors/Logic/Interfaces/ILogicUnit.cs index d6652141..a5c938d4 100644 --- a/BtDamageResolver/src/Actors/Logic/Interfaces/ILogicUnit.cs +++ b/BtDamageResolver/src/Actors/Logic/Interfaces/ILogicUnit.cs @@ -102,9 +102,10 @@ public interface ILogicUnit /// /// Gets the modifier to hit resolution from unit stance. /// + /// The weapon being fired. /// The distance to target. /// The modifier to hit resolution from unit stance. - int GetStanceModifier(int distance); + int GetStanceModifier(Weapon weapon, int distance); /// /// Gets the modifier to hit resolution from unit type. @@ -140,7 +141,7 @@ public interface ILogicUnit /// The range bracket to calculate the projection for. /// The weapon entry to calculate projection for. /// A tuple containing the estimated and maximum ammo usage. - Task<(decimal Estimate, int Max)> ProjectAmmo(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry); + (decimal Estimate, int Max) ProjectAmmo(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry); /// /// Calculates projected heat generation for weapon. @@ -149,7 +150,7 @@ public interface ILogicUnit /// The range bracket to calculate the projection for. /// The weapon entry to calculate the projection for. /// A tuple containing the estimated and maximum heat generation. - Task<(decimal Estimate, int Max)> ProjectHeat(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry); + (decimal Estimate, int Max) ProjectHeat(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry); /// /// Transforms a cluster roll based on unit type and possible other properties. @@ -243,10 +244,10 @@ public interface ILogicUnit /// The weapon bay the weapon is in. /// The weapon entry used. /// A tuple with the hit modifier and the range bracket. - Task<(int TargetNumber, RangeBracket RangeBracket)> ResolveHitModifier(AttackLog attackLog, ILogicUnit target, Arc primaryTargetArc, bool isPrimaryTarget, WeaponBay weaponBay, WeaponEntry weaponEntry); + (int TargetNumber, RangeBracket RangeBracket) ResolveHitModifier(AttackLog attackLog, ILogicUnit target, Arc primaryTargetArc, bool isPrimaryTarget, WeaponBay weaponBay, WeaponEntry weaponEntry); /// - /// Calculates all heat buildup not related to weapon fire. + /// Calculates all heat build-up not related to weapon fire. /// /// A damage report with non-weapon heat information. Task ResolveNonWeaponHeat(); diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.Ammo.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.Ammo.cs index 76650d6e..012a88dd 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.Ammo.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.Ammo.cs @@ -12,7 +12,7 @@ namespace Faemiyah.BtDamageResolver.Actors.Logic; public partial class LogicUnit { /// - public async Task<(decimal Estimate, int Max)> ProjectAmmo(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry) + public (decimal Estimate, int Max) ProjectAmmo(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry) { var hitChance = GetHitChanceForTargetNumber(targetNumber); @@ -20,7 +20,7 @@ public partial class LogicUnit // This can only be a single weapon or a multiplied weapon. // No need to loop through different ammo usages in the bay. - var weapon = await FormWeapon(weaponEntry); + var weapon = FormWeapon(weaponEntry); if (!weapon.UsesAmmo || hitChance == 0m) { @@ -75,9 +75,18 @@ protected virtual Task ResolveAmmo(DamageReport hitCalculationDamageReport, Comb /// The weapon to apply ammo usage from. protected void SpendAmmo(DamageReport damageReport, Weapon weapon) { - var ammoName = $"{weapon.Name} {weapon.AppliedAmmo}"; + string ammoName; var ammoUsed = 1; + if (!weapon.MultiAmmo) + { + ammoName = weapon.Name; + } + else + { + ammoName = weapon.AmmoProxyName == null ? $"{weapon.Name} {weapon.AppliedAmmo}" : $"{weapon.Name} {weapon.AmmoProxyName}"; + } + if (weapon.Instances > 1) { ammoUsed *= weapon.Instances; diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.Damage.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.Damage.cs index d38c78bf..0ce237d0 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.Damage.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.Damage.cs @@ -7,7 +7,6 @@ using Faemiyah.BtDamageResolver.Actors.Logic.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities; using Faemiyah.BtDamageResolver.Api.Enums; -using Microsoft.CodeAnalysis; namespace Faemiyah.BtDamageResolver.Actors.Logic; @@ -63,7 +62,7 @@ public virtual Task TransformDamageBasedOnUnitType(DamageReport damageRepor /// The combat action. /// A task that calculates the damage of a single fire instance. /// The total damage by rapid fire action. - protected async Task RapidFireWrapper(DamageReport damageReport, ILogicUnit target, CombatAction combatAction, Task singleFireDamageCalculation) + protected async Task RapidFireWrapper(DamageReport damageReport, ILogicUnit target, CombatAction combatAction, Func> singleFireDamageCalculation) { if (combatAction.Weapon.HasFeature(WeaponFeature.Rapid, out var rapidFeatureEntry)) { @@ -75,7 +74,7 @@ protected async Task RapidFireWrapper(DamageReport damageReport, ILogicUnit var damage = 0; for (var ii = 0; ii < hits; ii++) { - damage += await singleFireDamageCalculation; + damage += await singleFireDamageCalculation(); } damageReport.Log(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Total damage after calculating all hits", damage)); @@ -83,7 +82,7 @@ protected async Task RapidFireWrapper(DamageReport damageReport, ILogicUnit return damage; } - return await singleFireDamageCalculation; + return await singleFireDamageCalculation(); } /// @@ -314,11 +313,11 @@ protected virtual async Task ResolveTotalOutgoingDamage(DamageReport damage // Most units can charge, and mech units can make other melee attacks. Resolve their damage here. if (combatAction.Weapon.Type == WeaponType.Melee) { - return await RapidFireWrapper(damageReport, target, combatAction, ResolveTotalOutgoingDamageMelee(damageReport, combatAction)); + return await RapidFireWrapper(damageReport, target, combatAction, () => ResolveTotalOutgoingDamageMelee(damageReport, combatAction)); } else { - return await RapidFireWrapper(damageReport, target, combatAction, ResolveTotalOutgoingDamageInternal(damageReport, target, combatAction)); + return await RapidFireWrapper(damageReport, target, combatAction, () => ResolveTotalOutgoingDamageInternal(damageReport, target, combatAction)); } } diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.DamageResolution.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.DamageResolution.cs index 3ea6fed1..585c00c3 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.DamageResolution.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.DamageResolution.cs @@ -30,7 +30,7 @@ public async Task ApplyDamagePackets(DamageReport damageReport, Guid damageOwner // Unfortunately we still have to do transformations at this point, as certain locations and armor types receive damage differently var transformedDamage = TransformDamageAmountBasedOnLocation(damageReport, damageOwnerId, location, damagePacket.Damage); - damageReport.DamagePaperDoll.RecordDamage(location, damageOwnerId, transformedDamage); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, new DamageEntry(transformedDamage, location)); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Damage, damageOwnerId, transformedDamage, location)); foreach (var specialDamageEntry in damagePacket.SpecialDamageEntries) @@ -64,7 +64,7 @@ public async Task ApplyDamagePackets(DamageReport damageReport, Guid damageOwner // Critical damage threats coming from special damage (AP Ammo, Retractable Blade) never do multiple critical effects. Take the first. // Direct critical damage threats should always succeed, regardless of damage threshold. Give a very high damage amount. var criticalDamageType = criticalDamageTable.Mapping[specialDamageEntryCriticalThreatRoll][0]; - damageReport.DamagePaperDoll.RecordCriticalDamage(location, damageOwnerId, damagePacket.Damage, CriticalThreatType.Normal, criticalDamageType); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, new DamageEntry(damagePacket.Damage, location, CriticalThreatType.Normal, criticalDamageType)); damageReport.Log(new AttackLogEntry(AttackLogEntryType.Critical, damageOwnerId, criticalDamageType.ToString(), 0, location)); } else @@ -74,7 +74,7 @@ public async Task ApplyDamagePackets(DamageReport damageReport, Guid damageOwner break; default: - damageReport.DamagePaperDoll.RecordSpecialDamage(location, damageOwnerId, specialDamageEntry); + damageReport.DamagePaperDoll.RecordDamage(damageOwnerId, new DamageEntry(int.Parse(specialDamageEntry.Data), location, specialDamageEntry.Type)); damageReport.Log(new AttackLogEntry(AttackLogEntryType.SpecialDamage, damageOwnerId, specialDamageEntry.Type.ToString(), int.Parse(specialDamageEntry.Data), location)); break; } @@ -149,12 +149,26 @@ protected virtual int TransformDamageAmountBasedOnLocation(DamageReport damageRe int hitLocation; var ready = false; + if(cover == Cover.HullDown) + { + switch(Unit.Type) + { + // Magic value for hull down cover hits - always hit turret + case UnitType.VehicleHover: + case UnitType.VehicleTracked: + case UnitType.VehicleWheeled: + return (10, Location.Turret); + default: + break; + } + } + do { hitLocation = paperDoll.LocationMapping.Keys.Count switch { 11 => ResolverRandom.D26(), - _ => ResolverRandom.NextPlusOne(paperDoll.LocationMapping.Keys.Count) + _ => ResolverRandom.DX(paperDoll.LocationMapping.Keys.Count) }; damageReport.Log(new AttackLogEntry(AttackLogEntryType.DiceRoll, damageOwnerId, "Location", hitLocation)); diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.Fetching.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.Fetching.cs index 37294af3..3938e89a 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.Fetching.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.Fetching.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Actors.Logic.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities; using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; @@ -37,7 +36,7 @@ public DamagePaperDoll GetDamagePaperDoll(ILogicUnit target, AttackType attackTy /// /// The weapon entry to base the applied weapon on. /// The with the chosen ammo in the weapon entry applied, if possible. - protected async Task FormWeapon(WeaponEntry weaponEntry) + protected Weapon FormWeapon(WeaponEntry weaponEntry) { var weapon = RepositoryProvider.WeaponRepository.Get(weaponEntry.WeaponName); @@ -63,19 +62,23 @@ protected async Task FormWeapon(WeaponEntry weaponEntry) /// The transformed attack type. protected virtual AttackType TransformAttackType(ILogicUnit target, AttackType attackType, List weaponFeatures) { - // Melee attacks that are not kicks or punches use normal attack tables - var transformedAttackType = attackType == AttackType.Melee ? AttackType.Normal : attackType; - var targetType = target.Unit.Type; + var transformedAttackType = attackType; // Punch and kick tables only exist for mechs, revert to normal for all other target types + // Special handling for mechs switch (targetType) { case UnitType.Mech: case UnitType.MechTripod: case UnitType.MechQuad: - // Punches and kicks to prone or crouched mechs can hit anywhere - if (target.Unit.Stance is Stance.Prone or Stance.Crouch) + // Melee attacks against crouched mechs hit punch table + if ((attackType == AttackType.Melee || attackType == AttackType.Kick) && target.Unit.Stance is Stance.Crouch) + { + transformedAttackType = AttackType.Punch; + } + + if (weaponFeatures.Contains(WeaponFeature.MeleeDfa) && target.Unit.Stance is Stance.Prone) { transformedAttackType = AttackType.Normal; } @@ -86,6 +89,12 @@ protected virtual AttackType TransformAttackType(ILogicUnit target, AttackType a break; } + // Melee attacks that are not kicks or punches use normal attack tables + if (transformedAttackType == AttackType.Melee) + { + transformedAttackType = AttackType.Normal; + } + return transformedAttackType; } @@ -119,13 +128,57 @@ private string GetPaperDollNameFromAttackParameters(ILogicUnit target, AttackTyp var transformedAttackType = TransformAttackType(target, attackType, weaponFeatures); - var transformedDirection = target.Unit.Type is UnitType.Infantry or UnitType.BattleArmor or UnitType.Building ? Direction.Front : direction; + var transformedDirection = TransformDirection(target, weaponFeatures, direction); + + List transformedRules = TransformRules(target, gameOptions, transformedAttackType); + + return PaperDoll.GetIdFromProperties(transformedTargetType, transformedAttackType, transformedDirection, transformedRules); + } + + /// + /// Transform direction based on target properties and weapon features. + /// + /// The target unit. + /// The weapon features. + /// The direction of the attack. + /// The transformed direction. + private static Direction TransformDirection(ILogicUnit target, List weaponFeatures, Direction direction) + { + switch (target.Unit.Type) + { + case UnitType.BattleArmor: + case UnitType.Building: + case UnitType.Infantry: + direction = Direction.Front; + break; + case UnitType.Mech: + case UnitType.MechTripod: + case UnitType.MechQuad: + // Melee DFA against prone mechs hits the back of the mech + if (weaponFeatures.Contains(WeaponFeature.MeleeDfa) && target.Unit.Stance is Stance.Prone) + { + direction = Direction.Rear; + } + break; + } + + return direction; + } + /// + /// Transform rules based on target properties and attack type. + /// + /// The target unit logic. + /// The game options. + /// The attack type. + /// The transformed rules. + private static List TransformRules(ILogicUnit target, GameOptions gameOptions, AttackType attackType) + { // Get alterative paperdolls based on rules var transformedRules = new List(); // Floating critical may only apply to mechs - if (gameOptions.Rules[Rule.FloatingCritical] && transformedAttackType == AttackType.Normal) + if (gameOptions.Rules[Rule.FloatingCritical] && attackType == AttackType.Normal) { switch (target.Unit.Type) { @@ -151,6 +204,6 @@ private string GetPaperDollNameFromAttackParameters(ILogicUnit target, AttackTyp } } - return PaperDoll.GetIdFromProperties(transformedTargetType, transformedAttackType, transformedDirection, transformedRules); + return transformedRules; } } \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.General.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.General.cs index bd736e1f..b43781e0 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.General.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.General.cs @@ -35,7 +35,7 @@ public virtual async Task> ResolveCombatForBay(ILogicUnit tar continue; } - var hitCalclulationDamageReport = new DamageReport + var hitCalculationDamageReport = new DamageReport { Phase = weapon.UsePhase, DamagePaperDoll = GetDamagePaperDoll(target, AttackType.Normal, weaponBay.FiringSolution.Direction, weapon.SpecialFeatures.Select(w => w.Type).ToList()), @@ -46,9 +46,9 @@ public virtual async Task> ResolveCombatForBay(ILogicUnit tar InitialTroopers = target.Unit.Troopers }; - var combatAction = ResolveHit(hitCalclulationDamageReport, target, primaryTargetArc, isPrimaryTarget, weapon, weaponBay, weaponEntry); + var combatAction = ResolveHit(hitCalculationDamageReport, target, primaryTargetArc, isPrimaryTarget, weapon, weaponBay, weaponEntry); - damageReportCombatActionPairs.Add((hitCalclulationDamageReport, combatAction)); + damageReportCombatActionPairs.Add((hitCalculationDamageReport, combatAction)); } // Add damage resolution to damage reports based on combat actions @@ -99,7 +99,7 @@ public virtual async Task> ResolveCombatForBay(ILogicUnit tar foreach (var weaponEntry in weaponBay.Weapons.Where(w => w.State == WeaponState.Active)) { - weapons.Add((await FormWeapon(weaponEntry), weaponEntry)); + weapons.Add((FormWeapon(weaponEntry), weaponEntry)); } return weapons; diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.Heat.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.Heat.cs index 451bd54c..0b7812ea 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.Heat.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.Heat.cs @@ -13,7 +13,7 @@ namespace Faemiyah.BtDamageResolver.Actors.Logic; public partial class LogicUnit { /// - public async Task<(decimal Estimate, int Max)> ProjectHeat(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry) + public (decimal Estimate, int Max) ProjectHeat(int targetNumber, RangeBracket rangeBracket, WeaponEntry weaponEntry) { var hitChance = GetHitChanceForTargetNumber(targetNumber); @@ -24,7 +24,7 @@ public partial class LogicUnit int heatGenerated; - var weapon = await FormWeapon(weaponEntry); + var weapon = FormWeapon(weaponEntry); heatGenerated = weapon.HasFeature(WeaponFeature.Rapid, out var rapidFeatureEntry) ? MathExpression.Parse(rapidFeatureEntry.Data) * ResolveHeatForSingleHit(weapon, rangeBracket) @@ -101,7 +101,7 @@ protected void ResolveHeat(DamageReport hitCalculationDamageReport, CombatAction { if (!combatAction.ActionHappened) { - hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Information, Unit.Id, $"Combat action was canceled for {combatAction.Weapon.Name} and no heat will be generated")); + hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Information, Unit.Id, $"Combat action was cancelled for {combatAction.Weapon.Name} and no heat will be generated")); return; } @@ -111,18 +111,18 @@ protected void ResolveHeat(DamageReport hitCalculationDamageReport, CombatAction return; } - var calculatedSingleHitheat = ResolveHeatForSingleHit(combatAction.Weapon, combatAction.RangeBracket); + var calculatedSingleHitHeat = ResolveHeatForSingleHit(combatAction.Weapon, combatAction.RangeBracket); int heat; if (combatAction.Weapon.HasFeature(WeaponFeature.Rapid, out var rapidFeatureEntry)) { var multiplier = MathExpression.Parse(rapidFeatureEntry.Data); - heat = calculatedSingleHitheat * multiplier; + heat = calculatedSingleHitHeat * multiplier; hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, $"{combatAction.Weapon.Name} rate of fire multiplier for heat", multiplier)); } else { - heat = calculatedSingleHitheat; + heat = calculatedSingleHitHeat; } hitCalculationDamageReport.Log(new AttackLogEntry(AttackLogEntryType.Heat, Unit.Id, combatAction.Weapon.Name, heat)); @@ -137,10 +137,13 @@ protected void ResolveHeat(DamageReport hitCalculationDamageReport, CombatAction /// Aerospace units may have weapon bays where certain versions of weapons fire for different ranges and produce different amounts of heat. /// /// The weapon to resolve heat for. - /// The range bracket to resolve heat for. + /// The range bracket to resolve heat for. Intentionally ignored in the base implementation; see remarks. /// The heat produced. protected virtual int ResolveHeatForSingleHit(Weapon weapon, RangeBracket rangeBracket) { + // NOTE: rangeBracket is intentionally ignored here. For non-aerospace actions, heat is range-independent. + // Indexing by rangeBracket here would possibly return 0 for Medium/Long/etc. -ranged hits and produce wrong + // heat values. Only LogicUnitAerospace (and capital-ship logic) overrides this and uses rangeBracket. return weapon.Heat[RangeBracket.Short]; } } \ No newline at end of file diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.Hit.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.Hit.cs index e437fcf5..c179be58 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.Hit.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.Hit.cs @@ -35,7 +35,7 @@ public void TransformCombatAction(DamageReport targetDamageReport, CombatAction } else { - var singleMissileDefenseRoll = ResolverRandom.NextPlusOne(6); + var singleMissileDefenseRoll = ResolverRandom.DX(6); targetDamageReport.Log(new AttackLogEntry(AttackLogEntryType.DiceRoll, Unit.Id, "Ams defense roll against single missile", singleMissileDefenseRoll)); if (singleMissileDefenseRoll >= 4) { @@ -59,13 +59,9 @@ public void TransformCombatAction(DamageReport targetDamageReport, CombatAction /// The hit chance. protected static decimal GetHitChanceForTargetNumber(int targetNumber) { - if (targetNumber > 12) - { - return 0m; - } - switch (targetNumber) { + case < 3: return 1m; case 3: return 35m / 36m; case 4: return 33m / 36m; case 5: return 30m / 36m; @@ -76,7 +72,7 @@ protected static decimal GetHitChanceForTargetNumber(int targetNumber) case 10: return 6m / 36m; case 11: return 3m / 36m; case 12: return 1m / 36m; - default: return 1m; + default: return 0m; } } diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnit.HitModifier.cs b/BtDamageResolver/src/Actors/Logic/LogicUnit.HitModifier.cs index 4786af84..07094529 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnit.HitModifier.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnit.HitModifier.cs @@ -1,5 +1,4 @@ using System; -using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Actors.Logic.Interfaces; using Faemiyah.BtDamageResolver.Api.Constants; using Faemiyah.BtDamageResolver.Api.Entities; @@ -16,126 +15,48 @@ public abstract partial class LogicUnit private static readonly int[] MovementModifierArray = [0, 0, 0, 1, 1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 6]; /// - public async Task<(int TargetNumber, RangeBracket RangeBracket)> ResolveHitModifier(AttackLog attackLog, ILogicUnit target, Arc primaryTargetArc, bool isPrimaryTarget, WeaponBay weaponBay, WeaponEntry weaponEntry) + public (int TargetNumber, RangeBracket RangeBracket) ResolveHitModifier(AttackLog attackLog, ILogicUnit target, Arc primaryTargetArc, bool isPrimaryTarget, WeaponBay weaponBay, WeaponEntry weaponEntry) { - return ResolveHitModifier(attackLog, target, primaryTargetArc, isPrimaryTarget, await FormWeapon(weaponEntry), weaponBay, weaponEntry); + return ResolveHitModifier(attackLog, target, primaryTargetArc, isPrimaryTarget, FormWeapon(weaponEntry), weaponBay, weaponEntry); } /// public (int TargetNumber, RangeBracket RangeBracket) ResolveHitModifier(AttackLog attackLog, ILogicUnit target, Arc primaryTargetArc, bool isPrimaryTarget, Weapon weapon, WeaponBay weaponBay, WeaponEntry weaponEntry) { - var modifierBase = weapon.AttackType == AttackType.Normal ? Unit.Gunnery : Unit.Piloting; - - // NOTE: In this method, the damageReport may be null, as the target number calculation does not need a damage log. Everywhere else, it must exist. - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Base hit modifier", modifierBase)); - - var modifierMultiTarget = GetMultiTargetModifier(primaryTargetArc, isPrimaryTarget, weapon, weaponBay); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from multiple targets", modifierMultiTarget)); - - var modifierHeat = Unit.GetHeatAttackPenalty(); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from heat effects", modifierHeat)); - - var modifierPenalty = GetPenaltyModifier(); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from EMP effects", modifierPenalty)); - - var modifierFiringSolution = GetFiringSolutionModifier(weaponBay.FiringSolution); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from firing solution", modifierFiringSolution)); - var rangeBracket = GetRangeBracket(weapon, weaponBay); - var modifierRange = GetRangeModifier(rangeBracket); - modifierRange += GetMinimumRangeModifier(weapon, weaponBay); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from range", modifierRange)); - - var modifierArmor = GetArmorModifier(rangeBracket, target); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from target armor", modifierArmor)); - - var modifierWeapon = GetWeaponModifier(weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from weapon properties", modifierWeapon)); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from weapon entry properties", weaponEntry.Modifier)); - - var modifierTargetingComputer = GetTargetingComputerModifier(weapon); - - attackLog.Append(new AttackLogEntry (AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from unit targeting computer", modifierTargetingComputer)); - - var modifierUnitType = target.GetUnitTypeModifier(); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from unit type", modifierUnitType)); - - var modifierCover = target.GetCoverModifier(weaponBay.FiringSolution.Cover); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from cover", modifierCover)); - - var modifierStance = target.GetStanceModifier(weaponBay.FiringSolution.Distance); - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from target stance", modifierStance)); - - var modifierMovementDirection = target.GetMovementDirectionModifier(weaponBay.FiringSolution.Direction); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from movement direction", modifierMovementDirection)); - - var modifierMovementClass = GetMovementClassModifier(target, weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from target movement class", modifierMovementClass)); - - var modifierMovement = GetMovementModifierBase(target, weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from target movement amount", modifierMovement)); - - var modifierEvasion = GetEvasionModifier(target); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from target evasion", modifierEvasion)); - - var modifierOwnMovement = GetOwnMovementModifier(); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from own movement", modifierOwnMovement)); - - var modifierOwnEvasion = GetOwnEvasionModifier(weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from own evasion", modifierOwnEvasion)); - - var modifierFeatures = target.GetFeatureModifier(weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from weapon features", modifierFeatures)); - - var modifierWeather = GetWeatherModifier(weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from weather effects", modifierWeather)); - - var modifierQuirks = GetQuirkModifier(target, weapon); - - attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Hit modifier from unit quirks", modifierQuirks)); - - var modifierTotal = - modifierBase + - modifierMultiTarget + - modifierHeat + - modifierPenalty + - modifierFiringSolution + - modifierRange + - modifierArmor + - modifierWeapon + - weaponEntry.Modifier + - modifierTargetingComputer + - modifierUnitType + - modifierCover + - modifierStance + - modifierMovementDirection + - modifierMovementClass + - modifierMovement + - modifierEvasion + - modifierOwnMovement + - modifierOwnEvasion + - modifierFeatures + - modifierWeather + - modifierQuirks; + var modifiers = new (string Label, int Value)[] + { + ("Base hit modifier", weapon.AttackType == AttackType.Normal ? Unit.Gunnery : Unit.Piloting), + ("Hit modifier from multiple targets", GetMultiTargetModifier(primaryTargetArc, isPrimaryTarget, weapon, weaponBay)), + ("Hit modifier from heat effects", Unit.GetHeatAttackPenalty()), + ("Hit modifier from EMP effects", GetPenaltyModifier()), + ("Hit modifier from firing solution", GetFiringSolutionModifier(weaponBay.FiringSolution)), + ("Hit modifier from range", GetRangeModifier(rangeBracket) + GetMinimumRangeModifier(weapon, weaponBay)), + ("Hit modifier from target armor", GetArmorModifier(rangeBracket, target)), + ("Hit modifier from weapon properties", GetWeaponModifier(weapon)), + ("Hit modifier from weapon entry properties", weaponEntry.Modifier), + ("Hit modifier from unit targeting computer", GetTargetingComputerModifier(weapon)), + ("Hit modifier from unit type", target.GetUnitTypeModifier()), + ("Hit modifier from cover", target.GetCoverModifier(weaponBay.FiringSolution.Cover)), + ("Hit modifier from target stance", target.GetStanceModifier(weapon, weaponBay.FiringSolution.Distance)), + ("Hit modifier from movement direction", target.GetMovementDirectionModifier(weaponBay.FiringSolution.Direction)), + ("Hit modifier from target movement class", GetMovementClassModifier(target, weapon)), + ("Hit modifier from target movement amount", GetMovementModifierBase(target, weapon)), + ("Hit modifier from target evasion", GetEvasionModifier(target)), + ("Hit modifier from own movement", GetOwnMovementModifier()), + ("Hit modifier from own evasion", GetOwnEvasionModifier(weapon)), + ("Hit modifier from weapon features", target.GetFeatureModifier(weapon)), + ("Hit modifier from weather effects", GetWeatherModifier(weapon)), + ("Hit modifier from unit quirks", GetQuirkModifier(target, weapon)), + }; + + var modifierTotal = 0; + foreach (var (label, value) in modifiers) + { + attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, label, value)); + modifierTotal += value; + } attackLog.Append(new AttackLogEntry(AttackLogEntryType.Calculation, Unit.Id, "Target number", modifierTotal)); @@ -179,7 +100,7 @@ public virtual int GetMovementModifier() } /// - public virtual int GetStanceModifier(int distance) + public virtual int GetStanceModifier(Weapon weapon, int distance) { return 0; } diff --git a/BtDamageResolver/src/Actors/Logic/LogicUnitFactory.cs b/BtDamageResolver/src/Actors/Logic/LogicUnitFactory.cs index d7ac3f19..87548a12 100644 --- a/BtDamageResolver/src/Actors/Logic/LogicUnitFactory.cs +++ b/BtDamageResolver/src/Actors/Logic/LogicUnitFactory.cs @@ -8,7 +8,6 @@ using Faemiyah.BtDamageResolver.Api.Enums; using Faemiyah.BtDamageResolver.Api.Options; using Microsoft.Extensions.Logging; -using Orleans; namespace Faemiyah.BtDamageResolver.Actors.Logic; @@ -18,7 +17,6 @@ namespace Faemiyah.BtDamageResolver.Actors.Logic; public class LogicUnitFactory : ILogicUnitFactory { private readonly ILoggerFactory _loggerFactory; - private readonly IGrainFactory _grainFactory; private readonly IMathExpression _mathExpression; private readonly RepositoryProvider _repositoryProvider; private readonly IResolverRandom _resolverRandom; @@ -27,13 +25,11 @@ public class LogicUnitFactory : ILogicUnitFactory /// Initializes a new instance of the class. /// /// The logger factory. - /// The grain factory. /// The math expression solver. /// The random number generator. - public LogicUnitFactory(ILoggerFactory loggerFactory, IGrainFactory grainFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random) + public LogicUnitFactory(ILoggerFactory loggerFactory, IMathExpression mathExpression, RepositoryProvider repositoryProvider, IResolverRandom random) { _loggerFactory = loggerFactory; - _grainFactory = grainFactory; _mathExpression = mathExpression; _repositoryProvider = repositoryProvider; _resolverRandom = random; @@ -45,33 +41,33 @@ public ILogicUnit CreateFrom(GameOptions gameOptions, UnitEntry unit) switch (unit.Type) { case UnitType.Building: - return new LogicUnitBuilding(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitBuilding(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.AerospaceCapital: - return new LogicUnitAerospaceCapital(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitAerospaceCapital(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.AerospaceDropshipAerodyne: - return new LogicUnitAerospaceDropshipAerodyne(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitAerospaceDropshipAerodyne(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.AerospaceDropshipSpheroid: - return new LogicUnitAerospaceDropshipSpheroid(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitAerospaceDropshipSpheroid(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.AerospaceFighter: - return new LogicUnitAerospaceFighter(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitAerospaceFighter(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.BattleArmor: - return new LogicUnitBattleArmor(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitBattleArmor(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.Infantry: - return new LogicUnitInfantry(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitInfantry(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.Mech: - return new LogicUnitMech(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitMech(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.MechTripod: - return new LogicUnitMechTripod(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitMechTripod(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.MechQuad: - return new LogicUnitMechQuad(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitMechQuad(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.VehicleHover: - return new LogicUnitVehicleHover(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitVehicleHover(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.VehicleTracked: - return new LogicUnitVehicleTracked(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitVehicleTracked(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.VehicleVtol: - return new LogicUnitVehicleVtol(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitVehicleVtol(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); case UnitType.VehicleWheeled: - return new LogicUnitVehicleWheeled(_loggerFactory.CreateLogger(), gameOptions, _grainFactory, _mathExpression, _repositoryProvider, _resolverRandom, unit); + return new LogicUnitVehicleWheeled(_loggerFactory.CreateLogger(), gameOptions, _mathExpression, _repositoryProvider, _resolverRandom, unit); default: throw new NotImplementedException($"Factory for type {unit.Type} is not implemented."); } diff --git a/BtDamageResolver/src/Actors/PlayerActor.Connections.cs b/BtDamageResolver/src/Actors/PlayerActor.Connections.cs index 8332721f..6966e84f 100644 --- a/BtDamageResolver/src/Actors/PlayerActor.Connections.cs +++ b/BtDamageResolver/src/Actors/PlayerActor.Connections.cs @@ -80,11 +80,13 @@ public async Task Disconnect(Guid authenticationToken) await SendErrorMessageToClient($"Inconsistent state. Player {this.GetPrimaryKeyString()} Unable to sign out of the active game. {_playerActorState.State.GameId}"); } + // Persist before responding so the client never sees a "disconnected" response that the + // server later forgets due to a failed write or deactivation. + await _playerActorState.WriteStateAsync(); await SendDataToClient(EventNames.ConnectionResponse, GetConnectionResponse(false)); // Log the logout to permanent store await _loggingServiceClient.LogPlayerAction(DateTime.UtcNow, this.GetPrimaryKeyString(), PlayerActionType.Logout, 0); - await _playerActorState.WriteStateAsync(); return true; } @@ -183,6 +185,12 @@ private async Task MarkDisconnectedStateAndSendToClient() { _playerActorState.State.GameId = null; _playerActorState.State.UpdateTimeStamp = DateTime.UtcNow; + + // Persist the cleared GameId before notifying the client. Otherwise the actor could + // deactivate (or the silo could die) after the client has been told it disconnected, + // leaving stale GameId in persistent storage and resurrecting it on next activation. + await _playerActorState.WriteStateAsync(); + await SendOnlyThisPlayerGameStateToClient(); await SendDataToClient(EventNames.ConnectionResponse, GetConnectionResponse(true)); } diff --git a/BtDamageResolver/src/Actors/PlayerActor.Internal.cs b/BtDamageResolver/src/Actors/PlayerActor.Internal.cs index 7ac15399..f59780a2 100644 --- a/BtDamageResolver/src/Actors/PlayerActor.Internal.cs +++ b/BtDamageResolver/src/Actors/PlayerActor.Internal.cs @@ -40,6 +40,13 @@ private ConnectionResponse GetConnectionResponse(bool isConnected) /// /// Get the state of this player. /// + /// + /// The returned aliases the stored instances + /// via a shallow ToList(). This is safe only because the consumer is always reached via + /// a cross-grain or Redis call, both of which round-trip through the JSON serializer registered + /// in Silo/Program.cs and therefore produce a deep copy at the boundary. Do not call this + /// from same-grain code that mutates the result. + /// /// The object containing the properties of this unit actor. private Task GetPlayerState() { diff --git a/BtDamageResolver/src/Actors/PlayerActor.Sends.cs b/BtDamageResolver/src/Actors/PlayerActor.Sends.cs index 45b10375..22ca3a36 100644 --- a/BtDamageResolver/src/Actors/PlayerActor.Sends.cs +++ b/BtDamageResolver/src/Actors/PlayerActor.Sends.cs @@ -78,7 +78,7 @@ public async Task SendPlayerState(Guid authenticationToken, PlayerState pl if (!validationResult.IsValid) { - errorMessageStringBuilder.AppendLine($"Unit {unit} has the following errors: {validationResult}"); + errorMessageStringBuilder.AppendLine($"Unit {unit.Name} ({unit.Id}) has the following errors: {validationResult}"); unitsWithErrors.Add(unit.Id); _logger.LogWarning("Player {PlayerId} is sending invalid data for unit {UnitId}. Reason: {ValidationResult}", this.GetPrimaryKeyString(), unit.Id, validationResult); } diff --git a/BtDamageResolver/src/Actors/PlayerActor.cs b/BtDamageResolver/src/Actors/PlayerActor.cs index 7ee6b0d7..ae131fde 100644 --- a/BtDamageResolver/src/Actors/PlayerActor.cs +++ b/BtDamageResolver/src/Actors/PlayerActor.cs @@ -4,7 +4,7 @@ using Faemiyah.BtDamageResolver.Actors.Cryptography; using Faemiyah.BtDamageResolver.Actors.States; using Faemiyah.BtDamageResolver.Common.Constants; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Faemiyah.BtDamageResolver.Services.Interfaces; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/BtDamageResolver/src/Actors/Repositories/GameEntryRepositoryActor.cs b/BtDamageResolver/src/Actors/Repositories/GameEntryRepositoryActor.cs index c8c7e3b0..a7715fb9 100644 --- a/BtDamageResolver/src/Actors/Repositories/GameEntryRepositoryActor.cs +++ b/BtDamageResolver/src/Actors/Repositories/GameEntryRepositoryActor.cs @@ -18,8 +18,11 @@ namespace Faemiyah.BtDamageResolver.Actors.Repositories; /// public class GameEntryRepositoryActor : ExternalRepositoryActorBase, IGameEntryRepository { + private static readonly TimeSpan ExpireInterval = TimeSpan.FromMinutes(5); + private readonly ICommunicationServiceClient _communicationServiceClient; private readonly TimeSpan _maxGameAge = TimeSpan.FromHours(Settings.MaximumGameEntryAgeHours); + private DateTime _lastExpire = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -35,15 +38,13 @@ public GameEntryRepositoryActor(ILogger logger, Cached /// public override async Task Get(string key) { - await CleanupOldEntries(); - return await base.Get(key); } /// public override async Task> GetAll() { - await CleanupOldEntries(); + await ExpireOldEntries(); return await base.GetAll(); } @@ -83,9 +84,20 @@ private async Task Distribute() await _communicationServiceClient.SendToAllClients(EventNames.GameEntries, await GetAll()); } - private async Task CleanupOldEntries() + private async Task ExpireOldEntries() { - foreach (var entry in (await base.GetAll()).Where(gameEntry => gameEntry.TimeStamp < DateTime.UtcNow - _maxGameAge)) + // Throttle expiration scans — entries age in hours so checking on every Get/GetAll + // (which the broadcast path also hits) is wasteful. When old entries are actually + // deleted, broadcast once at the end so connected lobby clients see the new list. + var now = DateTime.UtcNow; + if (now - _lastExpire < ExpireInterval) + { + return; + } + + _lastExpire = now; + + foreach (var entry in (await base.GetAll()).Where(gameEntry => gameEntry.TimeStamp < now - _maxGameAge)) { await base.Delete(entry.GetName()); } diff --git a/BtDamageResolver/src/Api/Api.csproj b/BtDamageResolver/src/Api/Api.csproj index 3e82994a..3a63e761 100644 --- a/BtDamageResolver/src/Api/Api.csproj +++ b/BtDamageResolver/src/Api/Api.csproj @@ -5,21 +5,19 @@ Faemiyah.BtDamageResolver.Api Faemiyah.BtDamageResolver.Api true - 0.0.440 - https://github.com/Warmag2/BtDamageResolver - Warma - Faemiyah - BtDamageResolver - Faemiyah 2020 - https://github.com/Warmag2/BtDamageResolver + 0.0.467 API classes and interfaces for BtDamageResolver. Needed to connect to and interface with a cluster. + README.md - - - - + + + + + + + diff --git a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisClientToServerCommunicator.cs b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisClientToServerCommunicator.cs index 64c0d4f4..87691e45 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisClientToServerCommunicator.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisClientToServerCommunicator.cs @@ -96,7 +96,7 @@ protected override async Task RunProcessorMethod(Envelope envelope) protected override void SubscribeAdditional() { var listenedClientQueue = RedisSubscriber.Subscribe(new RedisChannel(ClientStreamAddress, RedisChannel.PatternMode.Literal)); - listenedClientQueue.OnMessage(async channelMessage => await RunProcessorMethod(JsonSerializer.Deserialize(channelMessage.Message.ToString(), JsonSerializerOptions)).ConfigureAwait(false)); + listenedClientQueue.OnMessage(ProcessChannelMessage); base.SubscribeAdditional(); } diff --git a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisCommunicator.cs b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisCommunicator.cs index ac70512a..1a714c51 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisCommunicator.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisCommunicator.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +using System; +using System.Collections.Generic; +using System.Text.Json; using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; using Faemiyah.BtDamageResolver.Api.ClientInterface.Events; @@ -12,7 +14,7 @@ namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Communicators; /// /// Base Redis implementation of a communicator. /// -public abstract class RedisCommunicator +public abstract class RedisCommunicator : IDisposable { /// /// The common client stream address. @@ -107,12 +109,39 @@ protected virtual void SubscribeAdditional() /// /// The target where to send data. /// The data to send. - /// The number of clients the message was delivered to. - protected long SendEnvelope(string target, Envelope data) + /// + /// Messages are published with , so the publish does not + /// wait for, and cannot report, the number of subscribers that received it. + /// + protected void SendEnvelope(string target, Envelope data) + { + PublishToChannel(target, JsonSerializer.Serialize(data, JsonSerializerOptions)); + } + + /// + /// Sends the same envelope to many communication endpoints, serializing it only once. + /// + /// The targets where to send data. + /// The data to send. + /// + /// The envelope is identical for every recipient, so it is serialized a single time and the resulting + /// payload (which base64-encodes the already-compressed data) is reused for each channel, rather than + /// re-serializing it once per target. + /// + protected void SendEnvelopeToMany(IEnumerable targets, Envelope data) + { + var serializedEnvelope = JsonSerializer.Serialize(data, JsonSerializerOptions); + foreach (var target in targets) + { + PublishToChannel(target, serializedEnvelope); + } + } + + private void PublishToChannel(string target, string serializedEnvelope) { var channel = new RedisChannel(target, RedisChannel.PatternMode.Literal); CheckChannelConnection(channel); - return RedisSubscriber.Publish(channel, JsonSerializer.Serialize(data, JsonSerializerOptions), CommandFlags.FireAndForget); + RedisSubscriber.Publish(channel, serializedEnvelope, CommandFlags.FireAndForget); } /// @@ -126,18 +155,13 @@ protected void SendErrorMessage(string target, string errorMessage) } /// - /// Send a message to a single client and display warnings if necessary. + /// Send a message to a single client. /// /// The target where to send data. /// The data to send. protected void SendSingle(string clientName, Envelope envelope) { - var clientCount = SendEnvelope(clientName, envelope); - - if (clientCount != 0) - { - Logger.LogWarning("Number of clients receiving targeted message with target {Target} was {Count} instead of 1 as expected.", clientName, clientCount); - } + SendEnvelope(clientName, envelope); } /// @@ -161,10 +185,33 @@ private void Subscribe() { RedisSubscriber = _redisConnectionMultiplexer.GetSubscriber(); _listenedMessageQueue = RedisSubscriber.Subscribe(_listenTarget); - _listenedMessageQueue.OnMessage(async channelMessage => await RunProcessorMethod(JsonSerializer.Deserialize(channelMessage.Message.ToString(), JsonSerializerOptions)).ConfigureAwait(false)); + _listenedMessageQueue.OnMessage(ProcessChannelMessage); SubscribeAdditional(); } + /// + /// Deserializes and processes a single channel message, logging any unhandled exception. + /// + /// The raw channel message. + /// A task which completes when the message has been processed. + /// + /// observes the returned task only to + /// serialize processing; it does not surface faults. Without this guard any exception thrown while handling a + /// message would be silently swallowed (effectively async void), so we catch and log here. + /// + protected async Task ProcessChannelMessage(ChannelMessage channelMessage) + { + try + { + var envelope = JsonSerializer.Deserialize(channelMessage.Message.ToString(), JsonSerializerOptions); + await RunProcessorMethod(envelope).ConfigureAwait(false); + } + catch (Exception exception) + { + Logger.LogError(exception, "Unhandled exception while processing a message on channel {Channel}.", channelMessage.Channel.ToString()); + } + } + /// /// Unsubscribe from the necessary communication endpoints. /// @@ -175,4 +222,30 @@ private void Unsubscribe() _listenedMessageQueue = null; RedisSubscriber = null; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases the Redis resources held by this communicator. + /// + /// true when called from . + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + _listenedMessageQueue?.Unsubscribe(); + _listenedMessageQueue = null; + RedisSubscriber?.UnsubscribeAll(); + RedisSubscriber = null; + _redisConnectionMultiplexer?.Dispose(); + _redisConnectionMultiplexer = null; + } } \ No newline at end of file diff --git a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisServerToClientCommunicator.cs b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisServerToClientCommunicator.cs index 511863da..ae4fe572 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisServerToClientCommunicator.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Communicators/RedisServerToClientCommunicator.cs @@ -38,12 +38,7 @@ public void Send(string clientName, string envelopeType, TType data) public void SendToAll(string envelopeType, TType data) where TType : class { - var clientCount = SendEnvelope(ClientStreamAddress, new Envelope(envelopeType, DataHelper.Pack(data))); - - if (clientCount == 0) - { - Logger.LogWarning("SendEnvelope delivered a global message of type {EnvelopeType} to zero clients.", envelopeType); - } + SendEnvelope(ClientStreamAddress, new Envelope(envelopeType, DataHelper.Pack(data))); } /// @@ -51,10 +46,7 @@ public void SendToMany(List clientNames, string envelopeType, TTy where TType : class { var envelope = new Envelope(envelopeType, DataHelper.Pack(data)); - foreach (var clientName in clientNames) - { - SendSingle(clientName, envelope); - } + SendEnvelopeToMany(clientNames, envelope); } /// @@ -123,7 +115,7 @@ protected override async Task RunProcessorMethod(Envelope envelope) await HandleGetGameStateRequest(envelope.Data, envelope.CorrelationId); break; case RequestNames.GetPlayerOptions: - await HandleGetGameOptionsRequest(envelope.Data, envelope.CorrelationId); + await HandleGetPlayerOptionsRequest(envelope.Data, envelope.CorrelationId); break; case RequestNames.ForceReady: await HandleForceReadyRequest(envelope.Data, envelope.CorrelationId); diff --git a/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionOptions.cs b/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionOptions.cs new file mode 100644 index 00000000..eb643594 --- /dev/null +++ b/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionOptions.cs @@ -0,0 +1,26 @@ +using System; + +namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; + +/// +/// Defines options for on-wire compression of Redis pub/sub payloads. +/// +[Serializable] +public class CompressionOptions +{ + /// + /// The compression algorithm to use. Server and client must agree. + /// + public CompressionProvider Provider { get; set; } = CompressionProvider.Lzma; + + /// + /// The compression quality (0-11). Higher = better ratio but more CPU; 4 is a balanced default. + /// Mapped through to BrotliEncoder quality for Brotli and NumFastBytes for LZMA. + /// + /// + /// Brotli accepts 0-11, where 0 is no compression and 11 is maximum compression. + /// LZMA accepts 5-275, where 5 is some compression 273 is more compression. + /// Values above or below the ranges for the provider will be clamped to the maximum. + /// + public int Quality { get; set; } = 4; +} diff --git a/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionProvider.cs b/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionProvider.cs new file mode 100644 index 00000000..e6f8afbd --- /dev/null +++ b/BtDamageResolver/src/Api/ClientInterface/Compression/CompressionProvider.cs @@ -0,0 +1,17 @@ +namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; + +/// +/// Compression algorithms supported for on-wire Redis pub/sub payloads. +/// +public enum CompressionProvider +{ + /// + /// LZMA via the project-local CompressionLzma implementation. Highest ratio, slowest CPU. + /// + Lzma, + + /// + /// Brotli via System.IO.Compression. Much faster than LZMA at comparable ratios for small JSON. + /// + Brotli, +} diff --git a/BtDamageResolver/src/Api/ClientInterface/Compression/DataHelper.cs b/BtDamageResolver/src/Api/ClientInterface/Compression/DataHelper.cs index 70c5c247..a00813dc 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Compression/DataHelper.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Compression/DataHelper.cs @@ -1,4 +1,6 @@ -using System.Text; +using System; +using System.IO; +using System.IO.Compression; using System.Text.Json; using Microsoft.Extensions.Options; using SevenZip.Compression.LZMA; @@ -11,14 +13,17 @@ namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; public class DataHelper { private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly CompressionOptions _compressionOptions; /// /// Initializes a new instance of the class. /// /// The JSON serializer options. - public DataHelper(IOptions jsonSerializerOptions) + /// The compression options. + public DataHelper(IOptions jsonSerializerOptions, IOptions compressionOptions) { _jsonSerializerOptions = jsonSerializerOptions.Value; + _compressionOptions = compressionOptions.Value; } /// @@ -30,7 +35,7 @@ public DataHelper(IOptions jsonSerializerOptions) public byte[] Pack(TType input) where TType : class { - return CompressionHelper.Compress(Serialize(input)); + return Compress(Serialize(input)); } /// @@ -42,7 +47,7 @@ public byte[] Pack(TType input) public TType Unpack(byte[] input) where TType : class { - return Deserialize(CompressionHelper.Decompress(input)); + return Deserialize(Decompress(input)); } /// @@ -54,7 +59,7 @@ public TType Unpack(byte[] input) private TType Deserialize(byte[] input) where TType : class { - return JsonSerializer.Deserialize(Encoding.UTF8.GetString(input), _jsonSerializerOptions); + return JsonSerializer.Deserialize(input, _jsonSerializerOptions); } /// @@ -66,6 +71,50 @@ private TType Deserialize(byte[] input) private byte[] Serialize(TType input) where TType : class { - return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(input, _jsonSerializerOptions)); + return JsonSerializer.SerializeToUtf8Bytes(input, _jsonSerializerOptions); } -} \ No newline at end of file + + private byte[] Compress(byte[] input) + { + return _compressionOptions.Provider switch + { + CompressionProvider.Brotli => BrotliCompress(input, _compressionOptions.Quality), + CompressionProvider.Lzma => CompressionHelper.Compress(input, _compressionOptions.Quality), + _ => throw new InvalidOperationException($"Unsupported compression provider: {_compressionOptions.Provider}") + }; + } + + private byte[] Decompress(byte[] input) + { + return _compressionOptions.Provider switch + { + CompressionProvider.Brotli => BrotliDecompress(input), + CompressionProvider.Lzma => CompressionHelper.Decompress(input), + _ => throw new InvalidOperationException($"Unsupported compression provider: {_compressionOptions.Provider}") + }; + } + + private static byte[] BrotliCompress(byte[] input, int quality) + { + var clamped = Math.Clamp(quality, 0, 11); + var maxLength = BrotliEncoder.GetMaxCompressedLength(input.Length); + var output = new byte[maxLength]; + + if (!BrotliEncoder.TryCompress(input, output, out var bytesWritten, clamped, window: 22)) + { + throw new InvalidOperationException("Brotli compression failed: destination buffer too small."); + } + + return output.AsSpan(0, bytesWritten).ToArray(); + } + + private static byte[] BrotliDecompress(byte[] input) + { + using var inputStream = new MemoryStream(input); + using var outputStream = new MemoryStream(); + using var brotli = new BrotliStream(inputStream, CompressionMode.Decompress); + brotli.CopyTo(outputStream); + + return outputStream.ToArray(); + } +} diff --git a/BtDamageResolver/src/Api/ClientInterface/Repositories/CachedEntityRepository.cs b/BtDamageResolver/src/Api/ClientInterface/Repositories/CachedEntityRepository.cs index 2d1f03f9..79cce80b 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Repositories/CachedEntityRepository.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Repositories/CachedEntityRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -20,19 +21,25 @@ public class CachedEntityRepository : IEntityRepository> _logger; private readonly IEntityRepository _repository; - private readonly Dictionary _cache; + private readonly ConcurrentDictionary _cache; /// /// Initializes a new instance of the class. /// /// The logging interface. /// The repository to cache. + /// + /// This cache is a DI singleton accessed concurrently from both the owning *RepositoryActor grain + /// (single-threaded writes) and directly from many LogicUnit instances during fire events + /// (concurrent reads from arbitrary grain activations). A + /// is required to make this safe. + /// public CachedEntityRepository(ILogger> logger, IEntityRepository repository) { _logger = logger; _repository = repository; - _cache = []; - _logger.LogInformation("Filled entity cache for type {Type} with {Number} items.", typeof(TEntity).Name, FillCache().Result); + _cache = new ConcurrentDictionary(); + _logger.LogInformation("Filled entity cache for type {Type} with {Number} items.", typeof(TEntity).Name, FillCache()); } /// @@ -41,12 +48,15 @@ public async Task AddAsync(TEntity entity) try { await _repository.AddAsync(entity); - _cache.Add(entity.GetName(), entity); + if (!_cache.TryAdd(entity.GetName(), entity)) + { + throw new ArgumentException($"An item with key '{entity.GetName()}' already exists in the cache."); + } } catch (Exception ex) { - _logger.LogError(ex, "Could not add entity {EntityName} into the repository. Unknown failure.", entity.GetName()); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not add entity {EntityName} into the repository.", entity.GetName()); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not add entity {entity.GetName()} into the repository.", ex); } } @@ -56,19 +66,12 @@ public async Task AddOrUpdateAsync(TEntity entity) try { await _repository.AddOrUpdateAsync(entity); - if (_cache.ContainsKey(entity.GetName())) - { - _cache[entity.GetName()] = entity; - } - else - { - _cache.Add(entity.GetName(), entity); - } + _cache[entity.GetName()] = entity; } catch (Exception ex) { - _logger.LogError(ex, "Could not add or update entity {EntityName} in the repository. Unknown failure.", entity.GetName()); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not add or update entity {EntityName} in the repository.", entity.GetName()); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not add or update entity {entity.GetName()} in the repository.", ex); } } @@ -77,20 +80,15 @@ public async Task DeleteAsync(TKey key) { try { - if (_cache.ContainsKey(key)) - { - await _repository.DeleteAsync(key); - _cache.Remove(key); + var deleted = await _repository.DeleteAsync(key); + _cache.TryRemove(key, out _); - return true; - } - - return false; + return deleted; } catch (Exception ex) { - _logger.LogError(ex, "Could not delete entity {EntityName} from the repository. Unknown failure.", key); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not delete entity {EntityName} from the repository.", key); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not delete entity {key} from the repository.", ex); } } @@ -111,11 +109,20 @@ public IReadOnlyCollection GetAllKeys() { var keys = _repository.GetAllKeys(); - // Update local cache in this situation - foreach (var key in keys.Where(key => !_cache.ContainsKey(key))) + // Update local cache for any keys present in the backing store but missing here + // (typically entries added by another process — e.g. the client adding a Unit). + foreach (var key in keys) { + if (_cache.ContainsKey(key)) + { + continue; + } + var item = _repository.Get(key); - _cache.Add(item.GetName(), item); + if (item != null) + { + _cache.TryAdd(key, item); + } } return keys; @@ -131,18 +138,18 @@ public async Task UpdateAsync(TEntity entity) } catch (Exception ex) { - _logger.LogError(ex, "Could not update entity {EntityName} in repository. Unknown failure.", entity.GetName()); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not update entity {EntityName} in repository.", entity.GetName()); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not update entity {entity.GetName()} in the repository.", ex); } } - private async Task FillCache() + private int FillCache() { var items = _repository.GetAll(); var count = 0; foreach (var item in items) { - _cache.Add(item.GetName(), item); + _cache.TryAdd(item.GetName(), item); count++; } diff --git a/BtDamageResolver/src/Api/ClientInterface/Repositories/RedisEntityRepository.cs b/BtDamageResolver/src/Api/ClientInterface/Repositories/RedisEntityRepository.cs index 9c31df30..735ef658 100644 --- a/BtDamageResolver/src/Api/ClientInterface/Repositories/RedisEntityRepository.cs +++ b/BtDamageResolver/src/Api/ClientInterface/Repositories/RedisEntityRepository.cs @@ -1,6 +1,5 @@ -using System; +using System; using System.Collections.Generic; -using System.Data.Common; using System.Text.Json; using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Api.Entities.Interfaces; @@ -14,7 +13,7 @@ namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Repositories; /// -/// A redis-based entity repository, which directly stores all entities into database 0. +/// A Redis-based entity repository, which directly stores all entities into database 0. /// Entities are stored with their string-based name, and prefixed with Resolver{EntityName}. /// /// The entity to store. @@ -22,25 +21,23 @@ namespace Faemiyah.BtDamageResolver.Api.ClientInterface.Repositories; public class RedisEntityRepository : IEntityRepository where TEntity : class, IEntity { - private readonly string _connectionString; private readonly string _keyPrefix; private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonSerializerOptions; - private readonly ConnectionMultiplexer _redisConnectionMultiplexer; + private readonly ConnectionMultiplexer _connectionMultiplexer; /// /// Initializes a new instance of the class. /// /// The logging interface. - /// JSON serializer options. + /// JSON serializer options. /// The connection string. - public RedisEntityRepository(ILogger> logger, IOptions jsonSerializerSettings, string connectionString) + public RedisEntityRepository(ILogger> logger, IOptions jsonSerializerOptions, string connectionString) { _logger = logger; - _jsonSerializerOptions = jsonSerializerSettings.Value; - _connectionString = connectionString; + _jsonSerializerOptions = jsonSerializerOptions.Value; _keyPrefix = $"Resolver{typeof(TEntity).Name}"; - _redisConnectionMultiplexer = ConnectionMultiplexer.Connect(_connectionString); + _connectionMultiplexer = ConnectionMultiplexer.Connect(connectionString); } /// @@ -51,15 +48,10 @@ public async Task AddAsync(TEntity entity) var connection = GetConnection(); await connection.StringSetAsync(GetKey(entity), JsonSerializer.Serialize(entity, _jsonSerializerOptions)).ConfigureAwait(false); } - catch (DbException ex) - { - _logger.LogError(ex, "Could not add entity {EntityName} of type {EntityType} into redis database. Database failure with error code {Code}.", entity.GetName(), typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not add entity {EntityName} of type {EntityType} into redis database. Unknown failure.", entity.GetName(), typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not add entity {EntityName} of type {EntityType} into the database.", entity.GetName(), typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not add entity {entity.GetName()} of type {typeof(TEntity)} into the database.", ex); } } @@ -78,15 +70,10 @@ public async Task DeleteAsync(string key) var connection = GetConnection(); return await connection.KeyDeleteAsync(GetKey(key)).ConfigureAwait(false); } - catch (DbException ex) - { - _logger.LogError(ex, "Could not delete entity {EntityName} of type {EntityType}. Database failure with error code {Code}.", key, typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not delete entity {EntityName} of type {EntityType}. Unknown failure.", key, typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not delete entity {EntityName} of type {EntityType}.", key, typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not delete entity {key} of type {typeof(TEntity)}.", ex); } } @@ -106,15 +93,10 @@ public TEntity Get(string key) return null; } - catch (DbException ex) - { - _logger.LogError(ex, "Could not get entity {EntityName} of type {EntityType}. Database failure with error code {Code}.", key, typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not get entity {EntityName} of type {EntityType}. Unknown failure.", key, typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not get entity {EntityName} of type {EntityType}.", key, typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not get entity {key} of type {typeof(TEntity)}.", ex); } } @@ -136,15 +118,10 @@ public IReadOnlyCollection GetAll() { throw; } - catch (DbException ex) - { - _logger.LogError(ex, "Could not get all entities of type {EntityType}. Database failure with error code {Code}.", typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not get all entities of type {EntityType}. Unknown failure.", typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not get all entities of type {EntityType}.", typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not get all entities of type {typeof(TEntity)}.", ex); } } @@ -156,15 +133,10 @@ public IReadOnlyCollection GetAllKeys() var server = GetServer(); return server.Keys(pattern: $"{_keyPrefix}*").Select(key => key.ToString()[(_keyPrefix.Length + 1)..]).ToList(); } - catch (DbException ex) - { - _logger.LogError(ex, "Could not get keys for all entities of type {EntityType}. Database failure with error code {Code}.", typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not get keys for all entities of type {EntityType}. Unknown failure.", typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not get keys for all entities of type {EntityType}.", typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not get keys for all entities of type {typeof(TEntity)}.", ex); } } @@ -174,11 +146,12 @@ public async Task UpdateAsync(TEntity entity) try { var connection = GetConnection(); - if (await connection.KeyExistsAsync(GetKey(entity))) - { - await AddAsync(entity).ConfigureAwait(false); - } - else + var updated = await connection.StringSetAsync( + GetKey(entity), + JsonSerializer.Serialize(entity, _jsonSerializerOptions), + when: When.Exists).ConfigureAwait(false); + + if (!updated) { throw new DataAccessException(DataAccessErrorCode.NotFound); } @@ -187,21 +160,16 @@ public async Task UpdateAsync(TEntity entity) { throw; } - catch (DbException ex) - { - _logger.LogError(ex, "Could not update entity {EntityName} of type {EntityType}. Database failure with error code {Code}.", entity.GetName(), typeof(TEntity), ex.ErrorCode); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); - } catch (Exception ex) { - _logger.LogError(ex, "Could not update entity {EntityName} of type {EntityType}. Unknown failure.", entity.GetName(), typeof(TEntity)); - throw new DataAccessException(DataAccessErrorCode.OperationFailure); + _logger.LogError(ex, "Could not update entity {EntityName} of type {EntityType}.", entity.GetName(), typeof(TEntity)); + throw new DataAccessException(DataAccessErrorCode.OperationFailure, $"Could not update entity {entity.GetName()} of type {typeof(TEntity)}.", ex); } } private IDatabase GetConnection() { - return _redisConnectionMultiplexer.GetDatabase(); + return _connectionMultiplexer.GetDatabase(); } private string GetKey(TEntity entity) @@ -216,6 +184,6 @@ private string GetKey(string key) private IServer GetServer() { - return _redisConnectionMultiplexer.GetServer(_connectionString.Split(',')[0]); + return _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints()[0]); } -} \ No newline at end of file +} diff --git a/BtDamageResolver/src/Api/Constants/DisplayConstants.cs b/BtDamageResolver/src/Api/Constants/DisplayConstants.cs new file mode 100644 index 00000000..1f8cc5b0 --- /dev/null +++ b/BtDamageResolver/src/Api/Constants/DisplayConstants.cs @@ -0,0 +1,10 @@ + +namespace Faemiyah.BtDamageResolver.Api.Constants; + +/// +/// Constants for display formatting. +/// +public static class DisplayConstants +{ + public const string NumberFormat = "0.##"; +} diff --git a/BtDamageResolver/src/Api/Entities/Credentials.cs b/BtDamageResolver/src/Api/Entities/Credentials.cs index a62dd48f..dbabb1a0 100644 --- a/BtDamageResolver/src/Api/Entities/Credentials.cs +++ b/BtDamageResolver/src/Api/Entities/Credentials.cs @@ -31,7 +31,7 @@ public Credentials() /// User or game name. /// [Required] - [RegularExpression(@"^[A-Za-z0-9_-]*$", ErrorMessage = "Player or game name must be composed of alphanumeric characters, dashes and underscores.")] + [RegularExpression(@"^[A-Za-z0-9_-]+$", ErrorMessage = "Player or game name must be composed of alphanumeric characters, dashes and underscores.")] [StringLength(32, ErrorMessage = "{0} length must be between {2} and {1} characters.", MinimumLength = 1)] public string Name { get; set; } @@ -39,6 +39,6 @@ public Credentials() /// User or game password. /// [RegularExpression(@"^\S*$", ErrorMessage = "No whitespace allowed in password.")] - [StringLength(32, ErrorMessage = "{0} length must be less than {1} characters.")] + [StringLength(128, ErrorMessage = "{0} length must be less than {1} characters.")] public string Password { get; set; } } \ No newline at end of file diff --git a/BtDamageResolver/src/Api/Entities/CriticalDamageEntry.cs b/BtDamageResolver/src/Api/Entities/CriticalDamageEntry.cs deleted file mode 100644 index 2b0431bb..00000000 --- a/BtDamageResolver/src/Api/Entities/CriticalDamageEntry.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using Faemiyah.BtDamageResolver.Api.Enums; - -namespace Faemiyah.BtDamageResolver.Api.Entities; - -/// -/// A critical damage entry. -/// -[Serializable] -public class CriticalDamageEntry -{ - /// - /// Initializes a new instance of the class. - /// - /// - /// Empty constructor for serialization purposes. - /// - public CriticalDamageEntry() - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The damage amount which induced the critical damage. - /// The critical threat type. - /// The critical damage type. - public CriticalDamageEntry(int damage, CriticalThreatType threatType, CriticalDamageType criticalDamageType) - { - InducingDamage = damage; - ThreatType = threatType; - Type = criticalDamageType; - } - - /// - /// The inducing damage amount. - /// - public int InducingDamage { get; set; } - - /// - /// The inducing threat type. - /// - public CriticalThreatType ThreatType { get; set; } - - /// - /// The critical damage type. - /// - public CriticalDamageType Type { get; set; } -} \ No newline at end of file diff --git a/BtDamageResolver/src/Api/Entities/DamageEntry.cs b/BtDamageResolver/src/Api/Entities/DamageEntry.cs new file mode 100644 index 00000000..9bced69d --- /dev/null +++ b/BtDamageResolver/src/Api/Entities/DamageEntry.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Faemiyah.BtDamageResolver.Api.Enums; + +namespace Faemiyah.BtDamageResolver.Api.Entities; + +/// +/// A damage entry. +/// +[Serializable] +public class DamageEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// Empty constructor for serialization purposes. + /// + public DamageEntry() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The damage amount which induced the critical damage. + /// The location the damage occurred in. + public DamageEntry(int damage, Location location) + { + DamageAmount = damage; + Location = location; + ThreatType = CriticalThreatType.Normal; + CriticalType = CriticalDamageType.None; + SpecialType = SpecialDamageType.None; + } + + /// + /// Initializes a new instance of the class. + /// + /// The damage amount which induced the critical damage. + /// The location the damage occurred in. + /// The special damage type. + public DamageEntry(int damage, Location location, SpecialDamageType specialDamageType) + { + DamageAmount = damage; + Location = location; + ThreatType = CriticalThreatType.Normal; + CriticalType = CriticalDamageType.None; + SpecialType = specialDamageType; + } + + /// + /// Initializes a new instance of the class. + /// + /// The damage amount which induced the critical damage. + /// The location the damage occurred in. + /// The critical threat type. + /// The critical damage type. + public DamageEntry(int damage, Location location, CriticalThreatType threatType, CriticalDamageType criticalDamageType) + { + DamageAmount = damage; + Location = location; + ThreatType = threatType; + CriticalType = criticalDamageType; + SpecialType = SpecialDamageType.None; + } + + /// + /// The damage amount or inducing damage amount for critical damage entries. + /// + public int DamageAmount { get; set; } + + /// + /// The location the damage occurred. + /// + public Location Location { get; set; } + + /// + /// The inducing threat type for critical damage. + /// + public CriticalThreatType ThreatType { get; set; } + + /// + /// The critical damage type. + /// + public CriticalDamageType CriticalType { get; set; } + + /// + /// The special damage type. + /// + public SpecialDamageType SpecialType { get; set; } + + /// + /// Is this damage entry a critical damage entry? + /// + public bool IsCritical => CriticalType != CriticalDamageType.None; + + /// + /// Is this damage entry a special damage entry? + /// + public bool IsSpecial => SpecialType != SpecialDamageType.None; + + /// + /// Creates a list of damage entries for the given inducing damage, location, critical threat type, and critical damage types. + /// + /// The damage amount which induced the critical damage. + /// The location the damage occurred in. + /// The critical threat type. + /// The list of critical damage types. + /// A list of damage entries. + public static List CreateList(int inducingDamage, Location location, CriticalThreatType damageThreshold, List criticalDamageTypes) + { + return criticalDamageTypes.Select(cdt => new DamageEntry(inducingDamage, location, damageThreshold, cdt)).ToList(); + } + + /// + public override string ToString() + { + if (IsCritical) + { + switch (CriticalType) + { + case CriticalDamageType.Critical: + return "Critical hit"; + case CriticalDamageType.Immobilized: + return "Immobilized"; + case CriticalDamageType.HeavyMotive: + return "Heavy motive damage"; + case CriticalDamageType.LightMotive: + return "Light motive damage"; + case CriticalDamageType.ModerateMotive: + return "Moderate motive damage"; + case CriticalDamageType.LimbBlownOff: + return "Limb blown off"; + default: + switch (ThreatType) + { + case CriticalThreatType.DamageThreshold: + return $"Critical threat to: {CriticalType} (Threshold {DamageAmount})"; + case CriticalThreatType.Normal: + return $"Critical damage to: {CriticalType} ({DamageAmount})"; + default: + return $"Unknown critical threat type: {ThreatType}"; + } + } + } + else if (IsSpecial) + { + if (DamageAmount != 0) + { + return $"{SpecialType} — {DamageAmount}"; + } + else + { + return $"{SpecialType}"; + } + } + else + { + return $"{DamageAmount}"; + } + } +} \ No newline at end of file diff --git a/BtDamageResolver/src/Api/Entities/DamagePaperDoll.cs b/BtDamageResolver/src/Api/Entities/DamagePaperDoll.cs index 4db14ce2..2e9ce9d7 100644 --- a/BtDamageResolver/src/Api/Entities/DamagePaperDoll.cs +++ b/BtDamageResolver/src/Api/Entities/DamagePaperDoll.cs @@ -35,17 +35,7 @@ public DamagePaperDoll(PaperDoll basePaperDoll) /// /// The collection of normal damage entries. /// - public Dictionary>> DamageCollection { get; set; } = []; - - /// - /// The collection of critical damage entries. - /// - public Dictionary>> DamageCollectionCritical { get; set; } = []; - - /// - /// The collection of special damage entries. - /// - public Dictionary>> DamageCollectionSpecial { get; set; } = []; + public Dictionary> DamageCollection { get; set; } = []; /// /// The paperdoll. @@ -61,7 +51,7 @@ public DamagePaperDoll(PaperDoll basePaperDoll) /// Get all locations for this damage report. /// /// The locations for this damage report. - public List Locations => [.. DamageCollection.Keys]; + public HashSet Locations => DamageCollection.SelectMany(d => d.Value).Select(v => v.Location).ToHashSet(); /// /// Gets the total damage of a specific special damage type. @@ -70,14 +60,7 @@ public DamagePaperDoll(PaperDoll basePaperDoll) /// The total damage of a specific special damage type. public int GetTotalDamageOfType(SpecialDamageType type) { - var result = 0; - - foreach (var (_, value) in DamageCollectionSpecial) - { - result += value.SelectMany(r => r.Value).Where(l => l.Type == type).Sum(d => int.Parse(d.Data)); - } - - return result; + return DamageCollection.SelectMany(d => d.Value).Where(l => l.SpecialType == type).Sum(d => d.DamageAmount); } /// @@ -93,56 +76,25 @@ public void Merge(DamagePaperDoll damagePaperDoll) } DamageCollection.Merge(damagePaperDoll.DamageCollection); - DamageCollectionCritical.Merge(damagePaperDoll.DamageCollectionCritical); - DamageCollectionSpecial.Merge(damagePaperDoll.DamageCollectionSpecial); } /// /// Record damage to this damage report. /// /// The location to record to. - /// The amount of damage to record. - public void RecordDamage(Location location, Guid creatorId, int amount) + /// The damage entry to record. + public void RecordDamage(Guid creatorId, DamageEntry damageEntry) { - DamageCollection.Insert(location, creatorId, [amount]); + DamageCollection.Insert(creatorId, damageEntry); } /// - /// Record critical damage to this damage report. - /// - /// The location to record to. - /// The creator of the critical damage entry. - /// The damage amount to record. - /// The type of the critical damage threat. - /// The type of the critical damage to record. - public void RecordCriticalDamage(Location location, Guid creatorId, int damage, CriticalThreatType threatType, CriticalDamageType criticalDamageType) - { - DamageCollectionCritical.Insert(location, creatorId, [new CriticalDamageEntry(damage, threatType, criticalDamageType)]); - } - - /// - /// Record critical damage to this damage report. - /// - /// The location to record to. - /// The creator of the critical damage entry. - /// The damage amount to record. - /// The type of the critical damage threat. - /// The list of critical damage types to record. - public void RecordCriticalDamage(Location location, Guid creatorId, int damage, CriticalThreatType threatType, List criticalDamageTypes) - { - foreach (var criticalDamageType in criticalDamageTypes) - { - DamageCollectionCritical.Insert(location, creatorId, [new CriticalDamageEntry(damage, threatType, criticalDamageType)]); - } - } - - /// - /// Record special damage to this damage report. + /// Record damage to this damage report. /// /// The location to record to. - /// The special damage to record. - public void RecordSpecialDamage(Location location, Guid creatorId, SpecialDamageEntry specialDamageEntry) + /// The damage entry to record. + public void RecordDamage(Guid creatorId, List damageEntries) { - DamageCollectionSpecial.Insert(location, creatorId, [specialDamageEntry]); + DamageCollection.Insert(creatorId, damageEntries); } } \ No newline at end of file diff --git a/BtDamageResolver/src/Api/Entities/RepositoryEntities/Ammo.cs b/BtDamageResolver/src/Api/Entities/RepositoryEntities/Ammo.cs index 55c53409..d88e3235 100644 --- a/BtDamageResolver/src/Api/Entities/RepositoryEntities/Ammo.cs +++ b/BtDamageResolver/src/Api/Entities/RepositoryEntities/Ammo.cs @@ -12,6 +12,11 @@ namespace Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; [Serializable] public class Ammo : NamedEntity { + /// + /// If this ammo gets used, what is the proxy name for the ammo that gets spent. + /// + public string AmmoProxyName { get; set; } + /// /// How big of a cluster bonus the ammo bestows. /// diff --git a/BtDamageResolver/src/Api/Entities/RepositoryEntities/Weapon.cs b/BtDamageResolver/src/Api/Entities/RepositoryEntities/Weapon.cs index b62afa32..cbe40274 100644 --- a/BtDamageResolver/src/Api/Entities/RepositoryEntities/Weapon.cs +++ b/BtDamageResolver/src/Api/Entities/RepositoryEntities/Weapon.cs @@ -25,6 +25,11 @@ public class Weapon : NamedEntity, IEntityWithRulesValidation /// public string AmmoDefault { get; set; } + /// + /// If this weapon's ammo is used, what is the ammo name that gets spent. + /// + public string AmmoProxyName { get; set; } + /// /// Attack type of the weapon. Basically normal or various melee attacks. /// @@ -96,6 +101,11 @@ public class Weapon : NamedEntity, IEntityWithRulesValidation /// public int Instances { get; set; } = 1; + /// + /// Can this weapon use multiple different ammo types at the same time. If this is false, the weapon always spends ammo that is considered to be named after itself. + /// + public bool MultiAmmo { get; set; } + /// /// Dictionary of range bracket values. /// @@ -188,6 +198,7 @@ public Weapon ApplyAmmo(Ammo ammo) var applyTarget = Copy(); applyTarget.AppliedAmmo = ammo.Name; + applyTarget.AmmoProxyName = ammo.AmmoProxyName; if (ammo.ClusterBonus != null) { @@ -287,6 +298,7 @@ public Weapon Copy() { Ammo = Ammo.Copy(), AmmoDefault = AmmoDefault, + AmmoProxyName = AmmoProxyName, AppliedAmmo = AppliedAmmo, AttackType = AttackType, ClusterBonus = ClusterBonus.Copy(), @@ -298,6 +310,7 @@ public Weapon Copy() Heat = Heat, HitModifier = HitModifier, Instances = Instances, + MultiAmmo = MultiAmmo, Name = Name, Range = Range.Copy(), RangeAerospace = RangeAerospace, diff --git a/BtDamageResolver/src/Api/Entities/UnitEntry.cs b/BtDamageResolver/src/Api/Entities/UnitEntry.cs index 72881601..6d4f4e40 100644 --- a/BtDamageResolver/src/Api/Entities/UnitEntry.cs +++ b/BtDamageResolver/src/Api/Entities/UnitEntry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using Faemiyah.BtDamageResolver.Api.Entities.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; @@ -272,6 +273,7 @@ public UnitEntry Copy() return new UnitEntry { Consumables = Consumables.Copy(), + Evading = Evading, Features = Features.Copy(), Gunnery = Gunnery, Id = id, @@ -283,6 +285,7 @@ public UnitEntry Copy() Piloting = Piloting, Sinks = Sinks, Speed = Speed, + Stance = Stance, StaticDataHidden = StaticDataHidden, Tagged = Tagged, TimeStamp = DateTime.UtcNow, @@ -304,6 +307,7 @@ public void FromUnit(Unit unit) JumpJets = unit.JumpJets; Sinks = unit.Sinks; Speed = unit.Speed; + TimeStamp = DateTime.UtcNow; Tonnage = unit.Tonnage; Troopers = unit.Troopers; Type = unit.Type; @@ -335,9 +339,15 @@ public Unit ToUnit() private static string GenerateName(string name) { - var numbersAtEndOfString = name.AsEnumerable().Reverse().TakeWhile(char.IsNumber).Reverse().ToArray(); + var numbersAtEndOfString = name.AsEnumerable().Reverse().TakeWhile(char.IsAsciiDigit).Reverse().ToArray(); + + if (numbersAtEndOfString.Length == 0) + { + return $"{name} 2"; + } - return numbersAtEndOfString.Length != 0 ? $"{name.TrimEnd(numbersAtEndOfString)}{int.Parse(string.Concat(numbersAtEndOfString)) + 1}" : $"{name} 2"; + var nextNumber = int.Parse(string.Concat(numbersAtEndOfString), CultureInfo.InvariantCulture) + 1; + return $"{name.TrimEnd(numbersAtEndOfString)}{nextNumber.ToString(CultureInfo.InvariantCulture)}"; } private int GetCurrentSpeedInternal(bool accountForHeat = true) diff --git a/BtDamageResolver/src/Api/Enums/Cover.cs b/BtDamageResolver/src/Api/Enums/Cover.cs index 7e4fbecf..84c09534 100644 --- a/BtDamageResolver/src/Api/Enums/Cover.cs +++ b/BtDamageResolver/src/Api/Enums/Cover.cs @@ -13,6 +13,11 @@ public enum Cover /// None, + /// + /// Hull down cover (turret/weapons visible). + /// + HullDown, + /// /// Lower cover. /// diff --git a/BtDamageResolver/src/Api/Enums/Location.cs b/BtDamageResolver/src/Api/Enums/Location.cs index c544caaa..d5326fca 100644 --- a/BtDamageResolver/src/Api/Enums/Location.cs +++ b/BtDamageResolver/src/Api/Enums/Location.cs @@ -21,6 +21,8 @@ public enum Location LeftLeg, RightLeg, CenterLeg, + FrontLeftLeg, + FrontRightLeg, RearLeftLeg, RearRightLeg, Left, diff --git a/BtDamageResolver/src/Api/Extensions/CollectionExtensions.cs b/BtDamageResolver/src/Api/Extensions/CollectionExtensions.cs index a47048f0..3a7dc6d9 100644 --- a/BtDamageResolver/src/Api/Extensions/CollectionExtensions.cs +++ b/BtDamageResolver/src/Api/Extensions/CollectionExtensions.cs @@ -284,38 +284,49 @@ public static Dictionary Fill(this Dictionary - /// Insert objects to a location-guid -separated list. + /// Insert object to a guid -separated list. /// - /// The type of the entity in the location-owner separated list. + /// The type of the entity in the guid separated list. /// The owner of the special damage entries. /// The location to insert to. /// The special damage entries to insert. - public static void Insert(this Dictionary>> input, Location location, Guid ownerId, List objects) + public static void Insert(this Dictionary> input, Guid ownerId, TEntity obj) { - if (input.TryGetValue(location, out var ownerList)) + if (input.TryGetValue(ownerId, out var ownerList)) { - if (ownerList.TryGetValue(ownerId, out var damageList)) - { - damageList.AddRange(objects); - } - else - { - ownerList.Add(ownerId, [.. objects]); - } + ownerList.Add(obj); } else { - input.Add(location, new Dictionary> { { ownerId, [.. objects] } }); + input.Add(ownerId, [obj]); } } /// - /// Insert objects to a location-guid -separated list. + /// Insert objects to a guid -separated list. /// - /// The type of the entity in the location-owner separated list. + /// The type of the entity in the guid separated list. /// The owner of the special damage entries. /// The location to insert to. /// The special damage entries to insert. + public static void Insert(this Dictionary> input, Guid ownerId, List objects) + { + if (input.TryGetValue(ownerId, out var ownerList)) + { + ownerList.AddRange(objects); + } + else + { + input.Add(ownerId, objects); + } + } + + /// + /// Insert values to a dictionary. + /// + /// The type of the key in the dictionary. + /// The key of the value to insert. + /// The value to insert. public static void Insert(this Dictionary input, TKey key, int value) { if (input.TryGetValue(key, out var existingValue)) @@ -329,19 +340,16 @@ public static void Insert(this Dictionary input, TKey key, int } /// - /// Merge objects in location-guid separated lists. + /// Merge objects in a guid separated lists. /// - /// The type of the entity in the location-owner separated list. - /// - /// - public static void Merge(this Dictionary>> input, Dictionary>> target) + /// The type of the entity in the guid separated list. + /// The input dictionary. + /// The target dictionary to merge from. + public static void Merge(this Dictionary> input, Dictionary> target) { foreach (var locationEntry in target) { - foreach (var ownerEntry in locationEntry.Value) - { - input.Insert(locationEntry.Key, ownerEntry.Key, ownerEntry.Value); - } + input.Insert(locationEntry.Key, locationEntry.Value); } } } \ No newline at end of file diff --git a/BtDamageResolver/src/Api/IResolverRandom.cs b/BtDamageResolver/src/Api/IResolverRandom.cs index 5d18dfa6..958370c4 100644 --- a/BtDamageResolver/src/Api/IResolverRandom.cs +++ b/BtDamageResolver/src/Api/IResolverRandom.cs @@ -23,12 +23,12 @@ public interface IResolverRandom /// /// The maximum value (inclusive) of the given integer. /// A pseudorandom integer number. - int NextPlusOne(int max); + int DX(int max); /// /// Gives the next random integer value between 1 and max. /// /// The maximum value (inclusive) of the given integer. /// A pseudorandom decimal number. - int NextPlusOne(decimal max); + int DX(decimal max); } \ No newline at end of file diff --git a/BtDamageResolver/src/Api/Options/PlayerOptions.cs b/BtDamageResolver/src/Api/Options/PlayerOptions.cs index 0e5fda9e..ff8298df 100644 --- a/BtDamageResolver/src/Api/Options/PlayerOptions.cs +++ b/BtDamageResolver/src/Api/Options/PlayerOptions.cs @@ -25,53 +25,58 @@ public PlayerOptions() public Dictionary AttackLogEntryVisibility { get; set; } /// - /// Should damage reports be expanded on the main screen by default. + /// Should the attack log be visible by default. + /// + public bool DamageReportsCombine { get; set; } + + /// + /// Should the attack log be visible by default. + /// + public bool DamageReportsShowAttackLog { get; set; } + + /// + /// Should also damage reports concerning the unit's own movement be shown. /// /// - /// True by default. + /// This is simply clutter so it is disabled by default. /// - public bool DashboardShowDamageReportsByDefault { get; set; } = true; + public bool DamageReportsShowMovement { get; set; } /// - /// Should the damage request generator be expanded on the main screen by default. + /// Should also damage reports concerning other players be displayed. /// - public bool DashboardShowDamageRequestsByDefault { get; set; } + public bool DamageReportsShowOtherPlayers { get; set; } = true; /// - /// Should the tools be expanded by default. + /// Should damage reports show a damage summary instead of an exact list. /// - public bool DashboardShowToolsByDefault { get; set; } + public bool DamageReportsShowSummary { get; set; } = true; /// - /// Highlight fields on units, which have not been altered since last turn. + /// Should damage reports be expanded on the main screen by default. /// /// /// True by default. /// - public bool HighlightUnalteredFields { get; set; } = true; + public bool DashboardShowDamageReports { get; set; } = true; /// - /// Should the attack log be visible by default. + /// Should the damage request generator be expanded on the main screen by default. /// - public bool ShowAttackLogByDefault { get; set; } + public bool DashboardShowDamageRequests { get; set; } /// - /// Should the attack log be visible by default. + /// Should the tools be expanded by default. /// - public bool ShowCombinedDamageReports { get; set; } + public bool DashboardShowTools { get; set; } /// - /// Should also damage reports concerning the unit's own movement be shown. + /// Highlight fields on units, which have not been altered since last turn. /// /// - /// This is simply clutter so it is disabled by default. + /// True by default. /// - public bool ShowMovementDamageReports { get; set; } - - /// - /// Should also damage reports concerning other players be displayed. - /// - public bool ShowOtherPlayersDamageReports { get; set; } = true; + public bool HighlightUnalteredFields { get; set; } = true; /// /// The update timestamp. diff --git a/BtDamageResolver/src/Api/README.md b/BtDamageResolver/src/Api/README.md new file mode 100644 index 00000000..e40c0e12 --- /dev/null +++ b/BtDamageResolver/src/Api/README.md @@ -0,0 +1,8 @@ +# Faemiyah.BtDamageResolver.Api + +API classes and interfaces for BtDamageResolver. + +This package contains the data entities, enumerations, grain interfaces and +communication contracts needed to connect to and interface with a +BtDamageResolver cluster. Reference it when building your own client or +service that talks to the resolver. diff --git a/BtDamageResolver/src/Api/ResolverRandom.cs b/BtDamageResolver/src/Api/ResolverRandom.cs index 16211e6f..ec6f5b8c 100644 --- a/BtDamageResolver/src/Api/ResolverRandom.cs +++ b/BtDamageResolver/src/Api/ResolverRandom.cs @@ -5,39 +5,35 @@ namespace Faemiyah.BtDamageResolver.Api; /// /// A resolver-specific random number generator. /// +/// +/// Backed by , which is thread-safe. This type is registered as a DI singleton and used +/// concurrently by all LogicUnit instances across all grain activations, so the underlying RNG must be +/// thread-safe to avoid the well-known race condition where concurrent calls can produce +/// repeated zeros or trigger infinite loops in callers. +/// public class ResolverRandom : IResolverRandom { - private readonly Random _rand; - - /// - /// Initializes a new instance of the class. - /// - public ResolverRandom() - { - _rand = new Random(); - } - /// public int D26() { - return NextPlusOne(6) + NextPlusOne(6); + return DX(6) + DX(6); } /// public int Next(int max) { - return _rand.Next(max); + return Random.Shared.Next(max); } /// - public int NextPlusOne(int max) + public int DX(int max) { - return _rand.Next(max) + 1; + return Random.Shared.Next(max) + 1; } /// - public int NextPlusOne(decimal max) + public int DX(decimal max) { - return NextPlusOne(decimal.ToInt32(max)); + return DX(decimal.ToInt32(max)); } } \ No newline at end of file diff --git a/BtDamageResolver/src/Common/Common.csproj b/BtDamageResolver/src/Common/Common.csproj index 6f6902c7..dbc32cc7 100644 --- a/BtDamageResolver/src/Common/Common.csproj +++ b/BtDamageResolver/src/Common/Common.csproj @@ -5,25 +5,21 @@ Faemiyah.BtDamageResolver.Common Faemiyah.BtDamageResolver.Common true - 0.0.440 - https://github.com/Warmag2/BtDamageResolver - Faemiyah - BtDamageResolver - Warma - Faemiyah 2020 - https://github.com/Warmag2/BtDamageResolver + 0.0.467 Common helper methods and classes for BtDamageResolver. Useful if you want to build your own client. + README.md - - - - - - - + + + + + + + + diff --git a/BtDamageResolver/src/Common/ConfigurationUtilities.cs b/BtDamageResolver/src/Common/ConfigurationUtilities.cs index 3bd2d477..19460074 100644 --- a/BtDamageResolver/src/Common/ConfigurationUtilities.cs +++ b/BtDamageResolver/src/Common/ConfigurationUtilities.cs @@ -1,16 +1,10 @@ -using System; +using System; using System.Globalization; -using System.IO; using System.Net; using System.Net.Sockets; using System.Text.Json; using System.Text.Json.Serialization; -using Faemiyah.BtDamageResolver.Common.Options; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Serilog; -using Serilog.Core.Enrichers; -using Serilog.Filters; namespace Faemiyah.BtDamageResolver.Common; @@ -21,21 +15,14 @@ public static class ConfigurationUtilities { private const string EnvironmentOrleansClientPort = "ORLEANS_CLIENTPORT"; private const string EnvironmentOrleansSiloPort = "ORLEANS_SILOPORT"; - private const string EnvironmentName = "RESOLVER_ENVIRONMENT"; private const int DefaultOrleansClientPort = 30000; private const int DefaultOrleansSiloPort = 11111; - private const string DefaultDebugEnvironmentName = "Debug"; - private const string DefaultEnvironmentName = DefaultDebugEnvironmentName; /// /// Get the ports for the silo from environment variables. - /// Additionally, if the application is being run in debug mode and multiple silos are active - /// on the same machine, the ports can be padded by a number so, that one can run multiple silos - /// without encountering a port conflict. /// - /// Number to add to all ports if debug environment is active. In release environments, this parameter does nothing. /// A tuple containing the client and silo ports. - public static (int ClientPort, int SiloPort) GetSiloPortConfigurationFromEnvironment(int portPad = 0) + public static (int ClientPort, int SiloPort) GetSiloPortConfigurationFromEnvironment() { int clientPort = DefaultOrleansClientPort, siloPort = DefaultOrleansSiloPort; @@ -55,71 +42,9 @@ public static (int ClientPort, int SiloPort) GetSiloPortConfigurationFromEnviron throw new InvalidOperationException("Unable to resolve silo port configuration from environment variables.", ex); } - if (GetEnvironmentName() == DefaultDebugEnvironmentName) - { - clientPort += portPad; - siloPort += portPad; - } - return (clientPort, siloPort); } - /// - /// Gets the environment name of the system from an environment variable. - /// - /// The state of the ThorCore Environment variable. - public static string GetEnvironmentName() - { - try - { - return Environment.GetEnvironmentVariable(EnvironmentName); - } - catch (Exception ex) - { - throw new InvalidOperationException($"Unable to resolve environment name from environment variables, using default environment name: {DefaultEnvironmentName}).", ex); - } - } - - /// - /// Gets the configuration from disk. - /// This method respects the release status of the silo, and will load the appropriate configuration - /// override file in addition to the default configuration file. - /// - /// File name of the configuration file. - /// An object containing the program configuration. - /// If building the object is unsuccessful. - public static IConfiguration GetConfiguration(string settingsFile) - { - try - { - var environmentName = GetEnvironmentName(); - var environmentOverrideFile = settingsFile.Replace(".json", $".{environmentName}.json"); - - ConfigurationBuilder config = new(); - config.SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile(settingsFile); - - if (environmentName != null) - { - if (File.Exists(environmentOverrideFile)) - { - config.AddJsonFile(environmentOverrideFile); - } - else - { - throw new FileNotFoundException($"No configuration override file present for given environment name: {environmentName}"); - } - } - - config.AddEnvironmentVariables(); - - return config.Build(); - } - catch (Exception ex) - { - throw new InvalidOperationException("Unable to build configuration.", ex); - } - } - /// /// Gets the first non-loopback IPv4 address of the current computer. Should work also in Docker/Kubernetes. /// @@ -138,35 +63,6 @@ public static IPAddress GetHostIp() throw new InvalidOperationException("Could not resolve an IP address for this host."); } - /// - /// Initializes the logging system. - /// - /// The logging options. - /// A logging interface. - public static ILogger InitializeLogging(FaemiyahLoggingOptions options) - { - var logfile = options.LogFile; - var logLevel = options.LogLevel; - var logLevelOrleans = options.LogLevelOrleans; - - var logger = new LoggerConfiguration() - .MinimumLevel.Is(logLevel) - .Enrich.With(new PropertyEnricher("ProgramName", options.ProgramName)) - .Filter.ByExcluding(e => Matching.FromSource("Orleans").Invoke(e) && e.Level < logLevelOrleans); - - if (options.LogToConsole) - { - logger.WriteTo.Console(logLevel); - } - - if (options.LogToFile) - { - logger.WriteTo.File(logfile, logLevel, rollingInterval: RollingInterval.Day, shared: true); - } - - return logger.CreateLogger(); - } - /// /// Gets the default JSON Serializer options. /// @@ -191,12 +87,24 @@ public static JsonSerializerOptions GetJsonSerializerOptions() /// The service collection for further processing. public static IServiceCollection ConfigureJsonSerializerOptions(this IServiceCollection collection) { - return collection.Configure(options => - { - options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - options.PropertyNameCaseInsensitive = false; + return collection.Configure(ApplyDefaultJsonSerializerOptions); + } - options.Converters.Add(new JsonStringEnumConverter()); - }); + /// + /// Build a new instance with the Faemiyah defaults applied. + /// + /// The configured options instance. + public static JsonSerializerOptions CreateJsonSerializerOptions() + { + var options = new JsonSerializerOptions(); + ApplyDefaultJsonSerializerOptions(options); + return options; + } + + private static void ApplyDefaultJsonSerializerOptions(JsonSerializerOptions options) + { + options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; + options.PropertyNameCaseInsensitive = false; + options.Converters.Add(new JsonStringEnumConverter()); } } \ No newline at end of file diff --git a/BtDamageResolver/src/Common/Constants/Settings.cs b/BtDamageResolver/src/Common/Constants/Settings.cs index 682e88ba..94707ddb 100644 --- a/BtDamageResolver/src/Common/Constants/Settings.cs +++ b/BtDamageResolver/src/Common/Constants/Settings.cs @@ -16,19 +16,34 @@ public static class Settings public const string SessionStateStoreName = "SessionState"; /// - /// The name of the options block which contains the connection settings for RabbitMQ. + /// The name of the options block which contains Orleans clustering settings (the ADO.NET invariant). /// public const string ClusterOptionsBlockName = "ClusterOptions"; /// - /// The name of the options block which contains the connection settings for RabbitMQ. + /// The name of the options block which configures application logging. /// - public const string CommunicationOptionsBlockName = "CommunicationOptions"; + public const string LoggingOptionsBlockName = "LoggingOptions"; /// - /// The name of the options block which contains the connection settings for RabbitMQ. + /// The name of the options block which configures on-wire compression for Redis pub/sub payloads. /// - public const string LoggingOptionsBlockName = "LoggingOptions"; + public const string CompressionOptionsBlockName = "CompressionOptions"; + + /// + /// The name of the connection string for the Redis communication bus. + /// + public const string RedisConnectionStringName = "Redis"; + + /// + /// The name of the connection string for the Postgres database (Orleans clustering, grain storage, and logging). + /// + public const string PostgresConnectionStringName = "Postgres"; + + /// + /// The ADO.NET invariant name for the Postgres database provider. + /// + public const string PostgresInvariantName = "Npgsql"; /// /// Maximum age of game entries in hours. diff --git a/BtDamageResolver/src/Common/Logging/FaemiyahConsoleFormatter.cs b/BtDamageResolver/src/Common/Logging/FaemiyahConsoleFormatter.cs new file mode 100644 index 00000000..09bdb6ca --- /dev/null +++ b/BtDamageResolver/src/Common/Logging/FaemiyahConsoleFormatter.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace Faemiyah.BtDamageResolver.Common.Logging; + +/// +/// A minimal single-line console formatter producing lines of the form +/// {ISO-8601 UTC timestamp} - [{LogLevel}] - {Category} - {message}, +/// with any exception appended on subsequent lines. +/// +public sealed class FaemiyahConsoleFormatter : ConsoleFormatter +{ + /// + /// The name under which this formatter is registered. + /// + public const string FormatterName = "faemiyah"; + + /// + /// Initializes a new instance of the class. + /// + public FaemiyahConsoleFormatter() : base(FormatterName) + { + } + + /// + public override void Write(in LogEntry logEntry, IExternalScopeProvider scopeProvider, TextWriter textWriter) + { + var message = logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception); + + if (string.IsNullOrEmpty(message) && logEntry.Exception is null) + { + return; + } + + textWriter.Write(DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)); + textWriter.Write(" - ["); + textWriter.Write(GetLevelString(logEntry.LogLevel)); + textWriter.Write("] - "); + textWriter.Write(logEntry.Category); + textWriter.Write(" - "); + textWriter.Write(message); + + if (logEntry.Exception is not null) + { + textWriter.Write(Environment.NewLine); + textWriter.Write(logEntry.Exception.ToString()); + } + + textWriter.Write(Environment.NewLine); + } + + private static string GetLevelString(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => "Trace", + LogLevel.Debug => "Debug", + LogLevel.Information => "Information", + LogLevel.Warning => "Warning", + LogLevel.Error => "Error", + LogLevel.Critical => "Critical", + _ => logLevel.ToString() + }; +} diff --git a/BtDamageResolver/src/Common/Logging/FaemiyahLoggerFactory.cs b/BtDamageResolver/src/Common/Logging/FaemiyahLoggerFactory.cs index f2145b1d..5dbc4c0e 100644 --- a/BtDamageResolver/src/Common/Logging/FaemiyahLoggerFactory.cs +++ b/BtDamageResolver/src/Common/Logging/FaemiyahLoggerFactory.cs @@ -1,26 +1,19 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; -using Serilog.Extensions.Logging; - -using static Faemiyah.BtDamageResolver.Common.ConfigurationUtilities; -using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Faemiyah.BtDamageResolver.Common.Logging; /// -/// Specific implementation of ILoggerFactory. -/// Use extension method to enable logging in console / asp.net applications. -/// See . +/// Specific implementation of backed by the built-in +/// Microsoft.Extensions.Logging.Console provider, using +/// for output formatting. +/// Use the extension method to enable logging in console / asp.net applications. /// -public class FaemiyahLoggerFactory : ILoggerFactory, ILoggerProvider +public sealed class FaemiyahLoggerFactory : ILoggerFactory { - private static readonly SemaphoreSlim LogCreationSemaphore = new(1, 1); - private readonly ConcurrentDictionary _loggers; - private readonly SerilogLoggerFactory _logFactory; + private readonly ILoggerFactory _innerFactory; /// /// Initializes a new instance of the class. @@ -28,56 +21,37 @@ public class FaemiyahLoggerFactory : ILoggerFactory, ILoggerProvider /// The logging options. public FaemiyahLoggerFactory(IOptions options) { - _logFactory = new SerilogLoggerFactory(InitializeLogging(options.Value ?? new FaemiyahLoggingOptions())); - _loggers = new ConcurrentDictionary(); + var loggingOptions = options.Value ?? new FaemiyahLoggingOptions(); + _innerFactory = LoggerFactory.Create(builder => ConfigureBuilder(builder, loggingOptions)); } - /// - public ILogger CreateLogger(string categoryName) + /// + /// Configures a logging builder with the Faemiyah console logging setup. + /// + /// The logging builder to configure. + /// The logging options. + public static void ConfigureBuilder(ILoggingBuilder builder, FaemiyahLoggingOptions options) { - LogCreationSemaphore.Wait(); - try - { - if (_loggers.TryGetValue(categoryName, out var storedLogger)) - { - return storedLogger; - } - - var createdLogger = _logFactory.CreateLogger(categoryName); + builder.ClearProviders(); + builder.SetMinimumLevel(options.LogLevel); - if (!_loggers.TryAdd(categoryName, createdLogger)) - { - throw new InvalidOperationException("Could not add the new logger to the dictionary. This should never happen."); - } + // Infrastructure logging filters to its own level + builder.AddFilter("Orleans", options.LogLevelOrleans); + builder.AddFilter("Microsoft", options.LogLevelMicrosoft); - return createdLogger; - } - finally + if (options.LogToConsole) { - LogCreationSemaphore.Release(); + builder.AddConsoleFormatter(); + builder.AddConsole(consoleOptions => consoleOptions.FormatterName = FaemiyahConsoleFormatter.FormatterName); } } /// - public void AddProvider(ILoggerProvider provider) - { - // Inherited method that does not need to do anything. - } + public ILogger CreateLogger(string categoryName) => _innerFactory.CreateLogger(categoryName); /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } + public void AddProvider(ILoggerProvider provider) => _innerFactory.AddProvider(provider); - /// - /// Perform cleanup. - /// - /// Is the class disposing. - protected virtual void Dispose(bool disposing) - { - _loggers.Clear(); - _logFactory.Dispose(); - } -} \ No newline at end of file + /// + public void Dispose() => _innerFactory.Dispose(); +} diff --git a/BtDamageResolver/src/Common/Logging/FaemiyahLoggingExtensions.cs b/BtDamageResolver/src/Common/Logging/FaemiyahLoggingExtensions.cs index 518c6d29..dd21c270 100644 --- a/BtDamageResolver/src/Common/Logging/FaemiyahLoggingExtensions.cs +++ b/BtDamageResolver/src/Common/Logging/FaemiyahLoggingExtensions.cs @@ -1,5 +1,5 @@ using System; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; diff --git a/BtDamageResolver/src/Common/Options/FaemiyahLoggingOptions.cs b/BtDamageResolver/src/Common/Logging/Options/FaemiyahLoggingOptions.cs similarity index 63% rename from BtDamageResolver/src/Common/Options/FaemiyahLoggingOptions.cs rename to BtDamageResolver/src/Common/Logging/Options/FaemiyahLoggingOptions.cs index 834ad22a..3bd50e0b 100644 --- a/BtDamageResolver/src/Common/Options/FaemiyahLoggingOptions.cs +++ b/BtDamageResolver/src/Common/Logging/Options/FaemiyahLoggingOptions.cs @@ -1,7 +1,6 @@ -using System.Reflection; -using Serilog.Events; +using Microsoft.Extensions.Logging; -namespace Faemiyah.BtDamageResolver.Common.Options; +namespace Faemiyah.BtDamageResolver.Common.Logging.Options; /// /// Configurable logging options in Orleans projects. @@ -13,30 +12,28 @@ public class FaemiyahLoggingOptions /// public FaemiyahLoggingOptions() { - LogLevel = LogEventLevel.Debug; - LogLevelOrleans = LogEventLevel.Debug; + LogLevel = LogLevel.Debug; + LogLevelOrleans = LogLevel.Debug; LogToConsole = true; LogToDatabase = true; - LogToFile = false; - ProgramName = Assembly.GetExecutingAssembly().GetName().Name; - LogFile = $"/logs/{ProgramName}.log"; - SendDetailedErrorsToClient = true; + LoggingIntervalMilliseconds = 15000; + SendDetailedErrorsToClient = false; } /// - /// Gets or sets the name of the logfile to log to. + /// Gets or sets log level for program logic. /// - public string LogFile { get; set; } + public LogLevel LogLevel { get; set; } /// - /// Gets or sets log level for program logic. + /// Gets or sets log level for orleans infrastructure logic. /// - public LogEventLevel LogLevel { get; set; } + public LogLevel LogLevelOrleans { get; set; } /// - /// Gets or sets log level for orleans infrastructure logic. + /// Gets or sets log level for Microsoft infrastructure logic. /// - public LogEventLevel LogLevelOrleans { get; set; } + public LogLevel LogLevelMicrosoft { get; set; } /// /// Gets or sets a value indicating whether log lines are appended to the console. @@ -52,17 +49,12 @@ public FaemiyahLoggingOptions() public bool LogToDatabase { get; set; } /// - /// Gets or sets a value indicating whether log lines are appended to a file. - /// - public bool LogToFile { get; set; } - - /// - /// Gets or sets name of the executing program. + /// Gets or sets the interval, in milliseconds, between database log-write loop iterations. /// /// - /// This property will be enriched onto each log line. + /// Controls how often queued game/player log entries are flushed to the database. Defaults to 15000 (four flushes per minute). /// - public string ProgramName { get; set; } + public int LoggingIntervalMilliseconds { get; set; } /// /// Gets or sets a value indicating whether exception details (message and stack trace) diff --git a/BtDamageResolver/src/Common/Options/CommunicationOptions.cs b/BtDamageResolver/src/Common/Options/CommunicationOptions.cs deleted file mode 100644 index 604bda2d..00000000 --- a/BtDamageResolver/src/Common/Options/CommunicationOptions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace Faemiyah.BtDamageResolver.Common.Options; - -/// -/// Defines options for server-client communication. -/// -[Serializable] -public class CommunicationOptions -{ - /// - /// The connection string. - /// - public string ConnectionString { get; set; } -} \ No newline at end of file diff --git a/BtDamageResolver/src/Common/Options/FaemiyahClusterOptions.cs b/BtDamageResolver/src/Common/Options/FaemiyahClusterOptions.cs deleted file mode 100644 index 7cf5cad3..00000000 --- a/BtDamageResolver/src/Common/Options/FaemiyahClusterOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; - -namespace Faemiyah.BtDamageResolver.Common.Options; - -/// -/// Defines options for Orleans clustering. -/// -[Serializable] -public class FaemiyahClusterOptions -{ - /// - /// The connection string. - /// - public string ConnectionString { get; set; } - - /// - /// The invariant of AdoNet clustering. - /// - public string Invariant { get; set; } -} \ No newline at end of file diff --git a/BtDamageResolver/src/Common/README.md b/BtDamageResolver/src/Common/README.md new file mode 100644 index 00000000..83b8d238 --- /dev/null +++ b/BtDamageResolver/src/Common/README.md @@ -0,0 +1,7 @@ +# Faemiyah.BtDamageResolver.Common + +Common helper methods and classes for BtDamageResolver. + +This package bundles shared configuration, logging and utility helpers used +across the BtDamageResolver server and client. It is useful if you want to +build your own client against a resolver cluster. diff --git a/BtDamageResolver/src/Services/CommunicationService.cs b/BtDamageResolver/src/Services/CommunicationService.cs index 9cc1e6af..d655484c 100644 --- a/BtDamageResolver/src/Services/CommunicationService.cs +++ b/BtDamageResolver/src/Services/CommunicationService.cs @@ -1,12 +1,7 @@ using System.Collections.Generic; -using System.Text.Json; using System.Threading.Tasks; -using Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; -using Faemiyah.BtDamageResolver.Common.Options; using Faemiyah.BtDamageResolver.Services.Interfaces; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Orleans; using Orleans.Runtime; namespace Faemiyah.BtDamageResolver.Services; @@ -23,31 +18,25 @@ public class CommunicationService : GrainService, ICommunicationService /// Initializes a new instance of the class. /// /// The logging interface. - /// The communication options. - /// JSON serializer options. - /// The data compression helper. - /// The grain factory. + /// The server-to-client communicator. /// The grain ID. /// The silo. /// The logger factory. public CommunicationService( ILogger logger, - IOptions communicationOptions, - IOptions jsonSerializerOptions, - DataHelper dataHelper, - IGrainFactory grainFactory, + ServerToClientCommunicator serverToClientCommunicator, GrainId grainId, Silo silo, ILoggerFactory loggerFactory) : base(grainId, silo, loggerFactory) { _logger = logger; - _serverToClientCommunicator = new ServerToClientCommunicator(loggerFactory.CreateLogger(), jsonSerializerOptions, communicationOptions.Value.ConnectionString, dataHelper, grainFactory); + _serverToClientCommunicator = serverToClientCommunicator; } /// public override Task Start() { - _logger.LogInformation("{Service} connected to redis successfully.", GetType()); + _logger.LogInformation("{Service} connected to Redis successfully.", GetType()); return base.Start(); } diff --git a/BtDamageResolver/src/Services/Database/LoggingRepository.cs b/BtDamageResolver/src/Services/Database/LoggingRepository.cs index 083603c8..1260c09d 100644 --- a/BtDamageResolver/src/Services/Database/LoggingRepository.cs +++ b/BtDamageResolver/src/Services/Database/LoggingRepository.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Api.Extensions; -using Faemiyah.BtDamageResolver.Common.Options; using Faemiyah.BtDamageResolver.Services.Events; using Microsoft.Extensions.Logging; using Npgsql; @@ -19,18 +18,25 @@ namespace Faemiyah.BtDamageResolver.Services.Database; /// public class LoggingRepository { + /// + /// Upper bound on the number of entries retained in a queue across write failures. + /// If a write fails while the backlog is already at or above this size, the failed batch is + /// dropped instead of re-enqueued, bounding memory use when the database is unavailable for a long time. + /// + private const int MaxRetainedLogEntries = 50000; + private readonly ILogger _logger; - private readonly FaemiyahClusterOptions _clusterOptions; + private readonly string _connectionString; /// /// Initializes a new instance of the class. /// /// The logging interface. - /// The cluster options. - public LoggingRepository(ILogger logger, FaemiyahClusterOptions clusterOptions) + /// The database connection string. + public LoggingRepository(ILogger logger, string connectionString) { _logger = logger; - _clusterOptions = clusterOptions; + _connectionString = connectionString; } /// @@ -41,6 +47,8 @@ public LoggingRepository(ILogger logger, FaemiyahClusterOptio /// A task which finishes when the entries have been written. /// /// Requires ResolverLogGame.GameId and ResolverLogPlayer.PlayerId to be BIGINT columns. + /// All entries are written in a single pipelined inside one transaction, + /// so a write either fully succeeds or fully fails (the whole batch is re-enqueued on failure, never duplicated). /// public async Task WriteLogEntries(ConcurrentQueue entries) { @@ -52,42 +60,48 @@ public async Task WriteLogEntries(ConcurrentQueue entries) try { - await using var connection = new NpgsqlConnection(_clusterOptions.ConnectionString); + await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); await using var transaction = await connection.BeginTransactionAsync(); + await using var sqlBatch = new NpgsqlBatch(connection, transaction); foreach (var entry in batch) { - await using var cmd = BuildCommand(entry, connection, transaction); - await cmd.ExecuteNonQueryAsync(); + sqlBatch.BatchCommands.Add(BuildBatchCommand(entry)); } + await sqlBatch.ExecuteNonQueryAsync(); await transaction.CommitAsync(); } catch (Exception ex) { - _logger.LogError(ex, "Failure writing {Count} {Type} log entries. Re-enqueuing.", batch.Count, typeof(T).Name); - foreach (var entry in batch) + if (entries.Count >= MaxRetainedLogEntries) + { + _logger.LogError(ex, "Failure writing {Count} {Type} log entries. Backlog already at {Backlog}; dropping this batch to bound memory.", batch.Count, typeof(T).Name, entries.Count); + } + else { - entries.Enqueue(entry); + _logger.LogError(ex, "Failure writing {Count} {Type} log entries. Re-enqueuing for retry.", batch.Count, typeof(T).Name); + foreach (var entry in batch) + { + entries.Enqueue(entry); + } } } } - private static NpgsqlCommand BuildCommand(T entry, NpgsqlConnection connection, NpgsqlTransaction transaction) => + private static NpgsqlBatchCommand BuildBatchCommand(T entry) => entry switch { - GameLogEntry g => BuildGameLogCommand(g, connection, transaction), - PlayerLogEntry p => BuildPlayerLogCommand(p, connection, transaction), + GameLogEntry g => BuildGameLogCommand(g), + PlayerLogEntry p => BuildPlayerLogCommand(p), _ => throw new ArgumentException($"Unknown log entry type: {typeof(T).Name}") }; - private static NpgsqlCommand BuildGameLogCommand(GameLogEntry entry, NpgsqlConnection connection, NpgsqlTransaction transaction) + private static NpgsqlBatchCommand BuildGameLogCommand(GameLogEntry entry) { - var cmd = new NpgsqlCommand( - "INSERT INTO ResolverLogGame (EventTime, GameId, ActionType, ActionData) VALUES (@timeStamp, @gameId, @actionType, @actionData)", - connection, - transaction); + var cmd = new NpgsqlBatchCommand( + "INSERT INTO ResolverLogGame (EventTime, GameId, ActionType, ActionData) VALUES (@timeStamp, @gameId, @actionType, @actionData)"); cmd.Parameters.AddWithValue("timeStamp", entry.TimeStamp); cmd.Parameters.AddWithValue("gameId", entry.GameId.Fnv1aHash64()); cmd.Parameters.AddWithValue("actionType", (int)entry.ActionType); @@ -95,12 +109,10 @@ private static NpgsqlCommand BuildGameLogCommand(GameLogEntry entry, NpgsqlConne return cmd; } - private static NpgsqlCommand BuildPlayerLogCommand(PlayerLogEntry entry, NpgsqlConnection connection, NpgsqlTransaction transaction) + private static NpgsqlBatchCommand BuildPlayerLogCommand(PlayerLogEntry entry) { - var cmd = new NpgsqlCommand( - "INSERT INTO ResolverLogPlayer (EventTime, PlayerId, ActionType, ActionData) VALUES (@timeStamp, @playerId, @actionType, @actionData)", - connection, - transaction); + var cmd = new NpgsqlBatchCommand( + "INSERT INTO ResolverLogPlayer (EventTime, PlayerId, ActionType, ActionData) VALUES (@timeStamp, @playerId, @actionType, @actionData)"); cmd.Parameters.AddWithValue("timeStamp", entry.TimeStamp); cmd.Parameters.AddWithValue("playerId", entry.PlayerId.Fnv1aHash64()); cmd.Parameters.AddWithValue("actionType", (int)entry.ActionType); diff --git a/BtDamageResolver/src/Services/LoggingService.cs b/BtDamageResolver/src/Services/LoggingService.cs index 923b8a8a..742e6e4f 100644 --- a/BtDamageResolver/src/Services/LoggingService.cs +++ b/BtDamageResolver/src/Services/LoggingService.cs @@ -1,15 +1,16 @@ -using System; +using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Constants; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Faemiyah.BtDamageResolver.Services.Database; using Faemiyah.BtDamageResolver.Services.Events; using Faemiyah.BtDamageResolver.Services.Interfaces; using Faemiyah.BtDamageResolver.Services.Interfaces.Enums; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Orleans.Concurrency; using Orleans.Runtime; namespace Faemiyah.BtDamageResolver.Services; @@ -17,16 +18,14 @@ namespace Faemiyah.BtDamageResolver.Services; /// /// Provides stateful logging methods for grains. /// -[Reentrant] public class LoggingService : GrainService, ILoggingService { - private const int LoggingDelayMilliseconds = 15000; // Check for logs to write 4 times a minute - private readonly ILogger _logger; private readonly FaemiyahLoggingOptions _loggingOptions; private readonly LoggingRepository _loggingRepository; private readonly ConcurrentQueue _gameLogEntries; private readonly ConcurrentQueue _playerLogEntries; + private readonly int _loggingIntervalMilliseconds; private CancellationTokenSource _cancellationTokenSource; private Task _writeLoopTask; @@ -34,24 +33,25 @@ public class LoggingService : GrainService, ILoggingService /// Initializes a new instance of the class. /// /// The logging interface. - /// The cluster options. /// The logging options. + /// The database connection string. /// The grain ID. /// The silo. /// The logger factory. public LoggingService( ILogger logger, - IOptions clusterOptions, IOptions loggingOptions, + [FromKeyedServices(Settings.PostgresConnectionStringName)] string connectionString, GrainId grainId, Silo silo, ILoggerFactory loggerFactory) : base(grainId, silo, loggerFactory) { _logger = logger; _loggingOptions = loggingOptions.Value; + _loggingIntervalMilliseconds = _loggingOptions.LoggingIntervalMilliseconds > 0 ? _loggingOptions.LoggingIntervalMilliseconds : 15000; _gameLogEntries = new ConcurrentQueue(); _playerLogEntries = new ConcurrentQueue(); - _loggingRepository = new LoggingRepository(loggerFactory.CreateLogger(), clusterOptions.Value); + _loggingRepository = new LoggingRepository(loggerFactory.CreateLogger(), connectionString); } /// @@ -108,8 +108,8 @@ private async Task LogWriteLoop(CancellationToken cancellationToken) { if (_gameLogEntries.IsEmpty && _playerLogEntries.IsEmpty) { - _logger.LogDebug("LoggingService has nothing to do, sleeping for {Delay} milliseconds.", LoggingDelayMilliseconds); - await Task.Delay(LoggingDelayMilliseconds, cancellationToken); + _logger.LogDebug("LoggingService has nothing to do, sleeping for {Delay} milliseconds.", _loggingIntervalMilliseconds); + await Task.Delay(_loggingIntervalMilliseconds, cancellationToken); } else { @@ -135,7 +135,7 @@ private async Task LogWriteLoop(CancellationToken cancellationToken) _logger.LogError(ex, "Unexpected exception in log write loop. Retrying after delay."); try { - await Task.Delay(LoggingDelayMilliseconds, cancellationToken); + await Task.Delay(_loggingIntervalMilliseconds, cancellationToken); } catch (OperationCanceledException) { @@ -144,4 +144,4 @@ private async Task LogWriteLoop(CancellationToken cancellationToken) } } } -} \ No newline at end of file +} diff --git a/BtDamageResolver/src/Services/Services.csproj b/BtDamageResolver/src/Services/Services.csproj index 847ae7ca..d8f483fc 100644 --- a/BtDamageResolver/src/Services/Services.csproj +++ b/BtDamageResolver/src/Services/Services.csproj @@ -8,7 +8,7 @@ - + diff --git a/BtDamageResolver/src/Silo/Dockerfile b/BtDamageResolver/src/Silo/Dockerfile index 40115a6e..85cdcff9 100644 --- a/BtDamageResolver/src/Silo/Dockerfile +++ b/BtDamageResolver/src/Silo/Dockerfile @@ -1,9 +1,10 @@ -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine AS base +RUN apk add --no-cache krb5-libs WORKDIR /app FROM resolversdk:latest AS build WORKDIR /build -COPY ["src/", "src/"] +COPY ["BtDamageResolver/src/", "src/"] WORKDIR "/build/src/Silo" RUN dotnet publish "Silo.csproj" -c Release -o /app/publish diff --git a/BtDamageResolver/src/Silo/Program.cs b/BtDamageResolver/src/Silo/Program.cs index 3ad1d1b0..cdb85722 100644 --- a/BtDamageResolver/src/Silo/Program.cs +++ b/BtDamageResolver/src/Silo/Program.cs @@ -15,7 +15,7 @@ using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Common.Constants; using Faemiyah.BtDamageResolver.Common.Logging; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Faemiyah.BtDamageResolver.Services; using Faemiyah.BtDamageResolver.Services.Interfaces; using Microsoft.Extensions.Configuration; @@ -111,16 +111,22 @@ private static async Task StopSilo() private static IHost CreateSilo() { - var (clientPort, siloPort) = GetSiloPortConfigurationFromEnvironment(); - var configuration = GetConfiguration("SiloSettings.json"); - var clusterOptions = configuration.GetSection(Settings.ClusterOptionsBlockName).Get(); + //var (clientPort, siloPort) = GetSiloPortConfigurationFromEnvironment(); var siloHostBuilder = Host.CreateDefaultBuilder() - .ConfigureAppConfiguration((_, config) => config.AddConfiguration(configuration)) - .UseOrleans(siloBuilder => + .UseOrleans((context, siloBuilder) => { + var configuration = context.Configuration; + var postgresConnectionString = configuration.GetConnectionString(Settings.PostgresConnectionStringName); + if (string.IsNullOrEmpty(postgresConnectionString)) + { + throw new InvalidOperationException($"No '{Settings.PostgresConnectionStringName}' connection string configured."); + } + siloBuilder - .Services.AddSerializer(serializerBuilder => serializerBuilder.AddJsonSerializer(isSupported: type => type.Namespace != null && type.Namespace.StartsWith("Faemiyah.BtDamageResolver"))); + .Services.AddSerializer(serializerBuilder => serializerBuilder.AddJsonSerializer( + isSupported: type => type.Namespace != null && type.Namespace.StartsWith("Faemiyah.BtDamageResolver"), + jsonSerializerOptions: CreateJsonSerializerOptions())); siloBuilder .Configure(options => { @@ -135,41 +141,30 @@ private static IHost CreateSilo() options.NumMissedTableIAmAliveLimit = 1; options.NumVotesForDeathDeclaration = 1; }) - .Configure(options => - { - options.MaxRequestProcessingTime = TimeSpan.FromSeconds(15); - options.SystemResponseTimeout = TimeSpan.FromSeconds(15); - }) - .Configure(options => - { - options.DropExpiredMessages = true; - options.ResponseTimeout = TimeSpan.FromSeconds(15); - options.ResponseTimeoutWithDebugger = TimeSpan.FromMinutes(15); - }) .UseAdoNetClustering(options => { - options.Invariant = clusterOptions?.Invariant; - options.ConnectionString = clusterOptions?.ConnectionString; + options.Invariant = "Npgsql"; + options.ConnectionString = postgresConnectionString; }) - .AddGrainStorage(Settings.ActorStateStoreName, clusterOptions) - .AddGrainStorage(Settings.SessionStateStoreName, clusterOptions) + .AddGrainStorage(Settings.ActorStateStoreName, postgresConnectionString) + .AddGrainStorage(Settings.SessionStateStoreName, postgresConnectionString) .Configure(options => { options.AdvertisedIPAddress = GetHostIp(); // The socket used for silo-to-silo will bind to this endpoint - options.GatewayListeningEndpoint = new IPEndPoint(IPAddress.Any, clientPort); + //options.GatewayListeningEndpoint = new IPEndPoint(IPAddress.Any, clientPort); // The socket used by the gateway will bind to this endpoint - options.SiloListeningEndpoint = new IPEndPoint(IPAddress.Any, siloPort); + //options.SiloListeningEndpoint = new IPEndPoint(IPAddress.Any, siloPort); }) .AddGrainService() .AddGrainService() .ConfigureServices(services => { + services.AddKeyedSingleton(Settings.PostgresConnectionStringName, postgresConnectionString); services.ConfigureJsonSerializerOptions(); - services.Configure(configuration.GetSection(Settings.CommunicationOptionsBlockName)); - services.Configure(configuration.GetSection(Settings.ClusterOptionsBlockName)); + services.Configure(configuration.GetSection(Settings.CompressionOptionsBlockName)); services.Configure(configuration.GetSection(Settings.LoggingOptionsBlockName)); services.AddLogging(conf => { @@ -178,6 +173,13 @@ private static IHost CreateSilo() }); services.Configure(opt => opt.SuppressStatusMessages = true); services.AddSingleton(); + services.AddSingleton(serviceProvider => new ServerToClientCommunicator( + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService().GetConnectionString(Settings.RedisConnectionStringName) + ?? throw new InvalidOperationException($"No '{Settings.RedisConnectionStringName}' connection string configured."), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -221,18 +223,18 @@ private static IHost CreateSilo() private static RedisEntityRepository GetRedisEntityRepository(IServiceProvider serviceProvider) where TType : class, IEntity { - var options = serviceProvider.GetService>(); - return options != null - ? new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), options.Value.ConnectionString) - : throw new InvalidOperationException($"Unable to resolve options class providing connection string for entity repository of type {typeof(TType)}."); + var connectionString = serviceProvider.GetRequiredService().GetConnectionString(Settings.RedisConnectionStringName); + return !string.IsNullOrEmpty(connectionString) + ? new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), connectionString) + : throw new InvalidOperationException($"No '{Settings.RedisConnectionStringName}' connection string configured for entity repository of type {typeof(TType)}."); } - private static ISiloBuilder AddGrainStorage(this ISiloBuilder siloHostBuilder, string name, FaemiyahClusterOptions clusterOptions) + private static ISiloBuilder AddGrainStorage(this ISiloBuilder siloHostBuilder, string name, string connectionString) { siloHostBuilder.AddAdoNetGrainStorage(name, options => { - options.Invariant = clusterOptions.Invariant; - options.ConnectionString = clusterOptions.ConnectionString; + options.Invariant = Settings.PostgresInvariantName; + options.ConnectionString = connectionString; }); return siloHostBuilder; diff --git a/BtDamageResolver/src/Silo/Properties/launchSettings.json b/BtDamageResolver/src/Silo/Properties/launchSettings.json new file mode 100644 index 00000000..a42aa2ef --- /dev/null +++ b/BtDamageResolver/src/Silo/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "Silo": { + "commandName": "Project", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} \ No newline at end of file diff --git a/BtDamageResolver/src/Silo/Silo.csproj b/BtDamageResolver/src/Silo/Silo.csproj index d0b3124d..9e6921fd 100644 --- a/BtDamageResolver/src/Silo/Silo.csproj +++ b/BtDamageResolver/src/Silo/Silo.csproj @@ -9,7 +9,7 @@ - + @@ -24,19 +24,13 @@ - + Always - + Always - - Always - - - - - + Always diff --git a/BtDamageResolver/src/Silo/SiloSettings.json b/BtDamageResolver/src/Silo/SiloSettings.json deleted file mode 100644 index e5905619..00000000 --- a/BtDamageResolver/src/Silo/SiloSettings.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "LoggingOptions": { - "LogFile": "/logs/BtDamageResolver_.log", - "ProgramName": "BtDamageResolver", - "LogToConsole": true, - "LogToDatabase": true, - "LogToFile": false, - "LogLevel": "Information", - "LogLevelOrleans": "Warning", - "SendDetailedErrorsToClient": true - }, - "ClusterOptions": { - "Invariant": "Npgsql", - "ConnectionString": "User ID=USERNAME;Password=PASSWORD;Host=orleansdb;Port=5432;Database=BtDamageResolver;SSL Mode=Disable;" - }, - "CommunicationOptions": { - "ConnectionString": "redis:6379,password=PASSWORD" - } -} diff --git a/BtDamageResolver/src/Silo/SiloSettings.Release.json b/BtDamageResolver/src/Silo/appsettings.Release.json similarity index 100% rename from BtDamageResolver/src/Silo/SiloSettings.Release.json rename to BtDamageResolver/src/Silo/appsettings.Release.json diff --git a/BtDamageResolver/src/Silo/appsettings.json b/BtDamageResolver/src/Silo/appsettings.json new file mode 100644 index 00000000..f7746cd7 --- /dev/null +++ b/BtDamageResolver/src/Silo/appsettings.json @@ -0,0 +1,22 @@ +{ + "LoggingOptions": { + "LogToConsole": true, + "LogToDatabase": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning", + "LogLevelMicrosoft": "Warning", + "LoggingIntervalMilliseconds": 15000, + "SendDetailedErrorsToClient": true + }, + "ClusterOptions": { + "Invariant": "Npgsql" + }, + "ConnectionStrings": { + "Postgres": "User ID=USERNAME;Password=PASSWORD;Host=resolverpostgres;Port=5432;Database=BtDamageResolver;SSL Mode=Disable;", + "Redis": "redis:6379,password=PASSWORD" + }, + "CompressionOptions": { + "Provider": "Brotli", + "Quality": 4 + } +} diff --git a/BtDamageResolver/tests/CompressionTesterApp/Program.cs b/BtDamageResolver/tests/CompressionTesterApp/Program.cs index 8096c20e..6ea04203 100644 --- a/BtDamageResolver/tests/CompressionTesterApp/Program.cs +++ b/BtDamageResolver/tests/CompressionTesterApp/Program.cs @@ -36,8 +36,9 @@ Racing V8s continue to use the single plane crankshaft because it allows faster acceleration and more efficient exhaust system designs."; var jsonSerializerOptions = Options.Create(new JsonSerializerOptions()); + var compressionOptions = Options.Create(new CompressionOptions()); - var dataHelper = new DataHelper(jsonSerializerOptions); + var dataHelper = new DataHelper(jsonSerializerOptions, compressionOptions); // Compress it var compressed = dataHelper.Pack(originalText); diff --git a/BtDamageResolver/tests/Tests/ConfigurationPrecedenceTests.cs b/BtDamageResolver/tests/Tests/ConfigurationPrecedenceTests.cs new file mode 100644 index 00000000..5cf473fd --- /dev/null +++ b/BtDamageResolver/tests/Tests/ConfigurationPrecedenceTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Text; +using AwesomeAssertions; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; + +namespace Faemiyah.BtDamageResolver.Tests; + +/// +/// Verifies the native configuration chain resolves connection strings from the +/// ConnectionStrings block and that environment variables override JSON values, +/// matching the source order appsettings.json -> environment variables. +/// +[TestFixture] +internal class ConfigurationPrecedenceTests +{ + private const string RedisEnvVariable = "ConnectionStrings__Redis"; + private const string JsonConnectionString = "redis-from-json:6379"; + + private const string SampleJson = """ + { + "ConnectionStrings": { + "Redis": "redis-from-json:6379" + } + } + """; + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable(RedisEnvVariable, null); + } + + [Test] + public void GetConnectionString_ResolvesRedisFromJson() + { + // Arrange + var configuration = BuildConfiguration(); + + // Act + var connectionString = configuration.GetConnectionString("Redis"); + + // Assert + connectionString.Should().Be(JsonConnectionString); + } + + [Test] + public void EnvironmentVariable_OverridesJsonConnectionString() + { + // Arrange + const string overrideValue = "redis-from-env:6379"; + Environment.SetEnvironmentVariable(RedisEnvVariable, overrideValue); + var configuration = BuildConfiguration(); + + // Act + var connectionString = configuration.GetConnectionString("Redis"); + + // Assert + connectionString.Should().Be(overrideValue); + } + + private static IConfiguration BuildConfiguration() + { + return new ConfigurationBuilder() + .AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(SampleJson))) + .AddEnvironmentVariables() + .Build(); + } +} diff --git a/BtDamageResolver/tests/Tests/ExpressionTests.cs b/BtDamageResolver/tests/Tests/ExpressionTests.cs index 3fec258a..7a82d894 100644 --- a/BtDamageResolver/tests/Tests/ExpressionTests.cs +++ b/BtDamageResolver/tests/Tests/ExpressionTests.cs @@ -3,7 +3,6 @@ using Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver; using Faemiyah.BtDamageResolver.Api; using NUnit.Framework; -using static Faemiyah.BtDamageResolver.Actors.Logic.ExpressionSolver.ExpressionExtensions; namespace Faemiyah.BtDamageResolver.Tests; @@ -15,15 +14,6 @@ internal class ExpressionTests { private const decimal Epsilon = 0.000000000001m; - /// - /// Test setup. - /// - [SetUp] - public void Setup() - { - // Nothing to be done - } - /// /// Test for true token validity. /// @@ -40,7 +30,7 @@ public void Test_Token_IsAToken_ReturnsTrue(char input) // Arrange // Act - var result = input.IsToken(); + var result = Expression.IsToken(input); // Assert result.Should().BeTrue(); @@ -62,7 +52,7 @@ public void Test_Token_IsNotAToken_ReturnsFalse(char input) // Arrange // Act - var result = input.IsToken(); + var result = Expression.IsToken(input); // Assert result.Should().BeFalse(); diff --git a/BtDamageResolver/tests/Tests/LoggingProviderTests.cs b/BtDamageResolver/tests/Tests/LoggingProviderTests.cs new file mode 100644 index 00000000..c292f85e --- /dev/null +++ b/BtDamageResolver/tests/Tests/LoggingProviderTests.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using AwesomeAssertions; +using Faemiyah.BtDamageResolver.Common.Logging; +using Faemiyah.BtDamageResolver.Common.Logging.Options; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NUnit.Framework; + +namespace Faemiyah.BtDamageResolver.Tests; + +/// +/// Tests verifying that the defers message argument +/// formatting, so that on a logged argument is only invoked +/// when the log entry actually passes the configured minimum level filter. +/// +[TestFixture] +internal class LoggingProviderTests +{ + private const string TestCategory = "TestCategory"; + + private TextWriter _originalConsoleOut; + + /// + /// Redirects console output so the active console provider does not pollute the test runner output. + /// + [SetUp] + public void Setup() + { + _originalConsoleOut = Console.Out; + Console.SetOut(TextWriter.Null); + } + + /// + /// Restores the original console output. + /// + [TearDown] + public void TearDown() + { + Console.SetOut(_originalConsoleOut); + } + + /// + /// A log call below the configured minimum level must be filtered out before its arguments are + /// formatted, so is never invoked on the supplied argument. + /// + [Test] + public void Log_WhenLevelBelowMinimum_DoesNotInvokeToStringOnArgument() + { + // Arrange + var counter = new ToStringCounter(); + using var factory = CreateFactory(LogLevel.Information); + var logger = factory.CreateLogger(TestCategory); + + // Act + logger.LogDebug("Value: {Counter}", counter); + + // Assert + counter.ToStringInvocations.Should().Be(0); + } + + /// + /// A log call at or above the configured minimum level passes the filter and is formatted, + /// which invokes on the supplied argument exactly once. + /// + [Test] + public void Log_WhenLevelAtOrAboveMinimum_InvokesToStringOnArgument() + { + // Arrange + var counter = new ToStringCounter(); + using var factory = CreateFactory(LogLevel.Information); + var logger = factory.CreateLogger(TestCategory); + + // Act + logger.LogInformation("Value: {Counter}", counter); + + // Assert + counter.ToStringInvocations.Should().Be(1); + } + + private static FaemiyahLoggerFactory CreateFactory(LogLevel minimumLevel) + { + var options = Options.Create(new FaemiyahLoggingOptions + { + LogLevel = minimumLevel, + LogToConsole = true, + LogToDatabase = false + }); + + return new FaemiyahLoggerFactory(options); + } + + /// + /// Helper whose counts how many times it is invoked, used to detect + /// whether deferred log message formatting reached this argument. + /// + private sealed class ToStringCounter + { + public int ToStringInvocations { get; private set; } + + public override string ToString() + { + ToStringInvocations++; + return "counted"; + } + } +} diff --git a/BtDamageResolver/tests/Tests/Tests.csproj b/BtDamageResolver/tests/Tests/Tests.csproj index 62996aab..48798e65 100644 --- a/BtDamageResolver/tests/Tests/Tests.csproj +++ b/BtDamageResolver/tests/Tests/Tests.csproj @@ -11,7 +11,9 @@ - + + + diff --git a/BtDamageResolver/tools/DataExporter/DataExporter.cs b/BtDamageResolver/tools/DataExporter/DataExporter.cs index 43d93713..afd3f2cb 100644 --- a/BtDamageResolver/tools/DataExporter/DataExporter.cs +++ b/BtDamageResolver/tools/DataExporter/DataExporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -8,7 +8,6 @@ using Faemiyah.BtDamageResolver.Api.Entities.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Common.Constants; -using Faemiyah.BtDamageResolver.Common.Options; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -28,8 +27,9 @@ internal sealed class DataExporter /// /// Initializes a new instance of the class. /// - /// The logging interface. - public DataExporter(ILoggerFactory loggerFactory) + /// The logger factory. + /// The application configuration. + public DataExporter(ILoggerFactory loggerFactory, IConfiguration configuration) { _logger = loggerFactory.CreateLogger(); _jsonSerializerOptions = GetJsonSerializerOptions(); @@ -40,16 +40,16 @@ public DataExporter(ILoggerFactory loggerFactory) _jsonSerializerOptions.WriteIndented = true; // Repository provider setup - var configuration = GetConfiguration("DataImporterSettings.json"); - var communicationOptions = configuration.GetSection(Settings.CommunicationOptionsBlockName).Get(); + var connectionString = configuration.GetConnectionString(Settings.RedisConnectionStringName) + ?? throw new InvalidOperationException($"No '{Settings.RedisConnectionStringName}' connection string configured."); _repositoryProvider = new RepositoryProvider( - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString)); + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), connectionString)); } /// @@ -86,7 +86,7 @@ public async Task Work(DataExportOptions options) switch (dataObject) { case Unit unit: - var filename = Path.Combine(options.Folder, $"Unit {unit.Type} {dataName}.json".Replace(' ', '_')); + var filename = Path.Combine(options.Folder, $"Unit_{unit.Type}_{dataName}.json".Replace(' ', '_')); await File.WriteAllTextAsync(filename, JsonSerializer.Serialize(dataObject, _jsonSerializerOptions)); break; default: @@ -106,4 +106,4 @@ private List FetchData() return exportedDataObjects; } -} \ No newline at end of file +} diff --git a/BtDamageResolver/tools/DataExporter/DataExporter.csproj b/BtDamageResolver/tools/DataExporter/DataExporter.csproj index ab6db941..b91ad6a9 100644 --- a/BtDamageResolver/tools/DataExporter/DataExporter.csproj +++ b/BtDamageResolver/tools/DataExporter/DataExporter.csproj @@ -8,12 +8,12 @@ - + - + @@ -23,16 +23,16 @@ - + Always - + Always - + Always diff --git a/BtDamageResolver/tools/DataExporter/DataExporterSettings.json b/BtDamageResolver/tools/DataExporter/DataExporterSettings.json deleted file mode 100644 index d4f0ba35..00000000 --- a/BtDamageResolver/tools/DataExporter/DataExporterSettings.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "LoggingOptions": { - "LogFile": "/logs/DataExporter_.log", - "ProgramName": "DataExporter", - "LogToConsole": true, - "LogToFile": false, - "LogLevel": "Information", - "LogLevelOrleans": "Warning" - }, - "ClusterOptions": { - "Invariant": "Npgsql", - "ConnectionString": "User ID=USERNAME;Password=PASSWORD;Host=orleansdb;Port=5432;Database=BtDamageResolver" - } -} diff --git a/BtDamageResolver/tools/DataExporter/Dockerfile b/BtDamageResolver/tools/DataExporter/Dockerfile new file mode 100644 index 00000000..598fda0e --- /dev/null +++ b/BtDamageResolver/tools/DataExporter/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine AS base +WORKDIR /app + +FROM resolversdk:latest AS build +WORKDIR /build +COPY ["BtDamageResolver/src/", "src/"] +COPY ["BtDamageResolver/tools/", "tools/"] +WORKDIR "/build/tools/DataExporter" +RUN dotnet publish "DataExporter.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --chown=app:app --from=build /app/publish . +RUN mkdir -p data && chown app:app data +USER app +CMD ["dotnet", "DataExporter.dll", "export", "-f", "./data/"] diff --git a/BtDamageResolver/tools/DataExporter/Program.cs b/BtDamageResolver/tools/DataExporter/Program.cs index 009b35aa..aa3fa321 100644 --- a/BtDamageResolver/tools/DataExporter/Program.cs +++ b/BtDamageResolver/tools/DataExporter/Program.cs @@ -1,12 +1,12 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Threading.Tasks; using CommandLine; using Faemiyah.BtDamageResolver.Common.Constants; using Faemiyah.BtDamageResolver.Common.Logging; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -29,7 +29,7 @@ internal static class Program /// Zero on no errors, nonzero on error. public static int Main(string[] args) { - var configuration = GetConfiguration("DataExporterSettings.json"); + var configuration = Host.CreateApplicationBuilder(args).Configuration; var section = configuration.GetSection(Settings.LoggingOptionsBlockName); var loggingOptions = Options.Create(section.Get()); var loggerFactory = new FaemiyahLoggerFactory(loggingOptions); @@ -40,23 +40,17 @@ public static int Main(string[] args) var result = Parser.Default.ParseArguments(args) .MapResult( - initOptions => RunDataExport(loggerFactory, initOptions).Result, + initOptions => RunDataExport(loggerFactory, configuration, initOptions).Result, errs => 1); return result; } - private static IConfiguration GetConfiguration(string settingsFile) - { - var config = new ConfigurationBuilder(); - return config.SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile(settingsFile).Build(); - } - - private static async Task RunDataExport(ILoggerFactory loggerFactory, DataExportOptions options) + private static async Task RunDataExport(ILoggerFactory loggerFactory, IConfiguration configuration, DataExportOptions options) { try { - var dataExporter = new DataExporter(loggerFactory); + var dataExporter = new DataExporter(loggerFactory, configuration); await dataExporter.Work(options); } catch (Exception e) diff --git a/BtDamageResolver/tools/DataExporter/Properties/launchSettings.json b/BtDamageResolver/tools/DataExporter/Properties/launchSettings.json new file mode 100644 index 00000000..3171d028 --- /dev/null +++ b/BtDamageResolver/tools/DataExporter/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "DataExporter": { + "commandName": "Project", + "commandLineArgs": "unit" + } + } +} diff --git a/BtDamageResolver/tools/DataExporter/DataExporterSettings.Release.json b/BtDamageResolver/tools/DataExporter/appsettings.Release.json similarity index 100% rename from BtDamageResolver/tools/DataExporter/DataExporterSettings.Release.json rename to BtDamageResolver/tools/DataExporter/appsettings.Release.json diff --git a/BtDamageResolver/tools/DataExporter/appsettings.json b/BtDamageResolver/tools/DataExporter/appsettings.json new file mode 100644 index 00000000..db0a290e --- /dev/null +++ b/BtDamageResolver/tools/DataExporter/appsettings.json @@ -0,0 +1,10 @@ +{ + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning" + }, + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + } +} diff --git a/BtDamageResolver/tools/DataImporter/DataImporter.cs b/BtDamageResolver/tools/DataImporter/DataImporter.cs index a020fc37..07684a99 100644 --- a/BtDamageResolver/tools/DataImporter/DataImporter.cs +++ b/BtDamageResolver/tools/DataImporter/DataImporter.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,7 +10,6 @@ using Faemiyah.BtDamageResolver.Api.Entities.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Common.Constants; -using Faemiyah.BtDamageResolver.Common.Options; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -30,23 +29,24 @@ internal sealed class DataImporter /// /// Initializes a new instance of the class. /// - /// The logging interface. - public DataImporter(ILoggerFactory loggerFactory) + /// The logger factory. + /// The application configuration. + public DataImporter(ILoggerFactory loggerFactory, IConfiguration configuration) { _logger = loggerFactory.CreateLogger(); _jsonSerializerOptions = GetJsonSerializerOptions(); // Repository provider setup - var configuration = GetConfiguration("DataImporterSettings.json"); - var communicationOptions = configuration.GetSection(Settings.CommunicationOptionsBlockName).Get(); + var redisConnectionString = configuration.GetConnectionString(Settings.RedisConnectionStringName) + ?? throw new InvalidOperationException($"No '{Settings.RedisConnectionStringName}' connection string configured."); _repositoryProvider = new RepositoryProvider( - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString), - new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), communicationOptions.ConnectionString)); + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString), + new RedisEntityRepository(loggerFactory.CreateLogger>(), Options.Create(_jsonSerializerOptions), redisConnectionString)); } /// @@ -183,4 +183,4 @@ private IEnumerable ProcessFile(string path) _ => throw new InvalidOperationException($"Cannot infer file type from file name for file: {path}"), }; } -} \ No newline at end of file +} diff --git a/BtDamageResolver/tools/DataImporter/DataImporter.csproj b/BtDamageResolver/tools/DataImporter/DataImporter.csproj index 5ca54bc1..badea95b 100644 --- a/BtDamageResolver/tools/DataImporter/DataImporter.csproj +++ b/BtDamageResolver/tools/DataImporter/DataImporter.csproj @@ -8,12 +8,12 @@ - + - + @@ -23,16 +23,13 @@ - + Always - + Always - - - - + Always diff --git a/BtDamageResolver/tools/DataImporter/DataImporterSettings.json b/BtDamageResolver/tools/DataImporter/DataImporterSettings.json deleted file mode 100644 index 651629c2..00000000 --- a/BtDamageResolver/tools/DataImporter/DataImporterSettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "LoggingOptions": { - "LogFile": "/logs/DataImporter_.log", - "ProgramName": "DataImporter", - "LogToConsole": true, - "LogToFile": false, - "LogLevel": "Information", - "LogLevelOrleans": "Warning" - }, - "CommunicationOptions": { - "ConnectionString": "redis:6379,password=PASSWORD" - } -} diff --git a/BtDamageResolver/tools/DataImporter/Dockerfile b/BtDamageResolver/tools/DataImporter/Dockerfile index bbb79668..fb742867 100644 --- a/BtDamageResolver/tools/DataImporter/Dockerfile +++ b/BtDamageResolver/tools/DataImporter/Dockerfile @@ -1,17 +1,16 @@ -FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine AS base WORKDIR /app FROM resolversdk:latest AS build WORKDIR /build -COPY ["src/", "src/"] -COPY ["tools/", "tools/"] +COPY ["BtDamageResolver/src/", "src/"] +COPY ["BtDamageResolver/tools/", "tools/"] WORKDIR "/build/tools/DataImporter" RUN dotnet publish "DataImporter.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app COPY --chown=app:app --from=build /app/publish . -COPY --chown=app:app ["data/", "data/"] -COPY --chown=app:app importdata.sh . +COPY --chown=app:app ["BtDamageResolver/data/", "data/"] USER app -CMD ["sh", "importdata.sh"] +CMD ["dotnet", "DataImporter.dll", "import", "-s", "*.json", "-f", "./data/"] diff --git a/BtDamageResolver/tools/DataImporter/Program.cs b/BtDamageResolver/tools/DataImporter/Program.cs index 6aa972b8..76facf70 100644 --- a/BtDamageResolver/tools/DataImporter/Program.cs +++ b/BtDamageResolver/tools/DataImporter/Program.cs @@ -1,12 +1,12 @@ -using System; +using System; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Threading.Tasks; using CommandLine; using Faemiyah.BtDamageResolver.Common.Constants; using Faemiyah.BtDamageResolver.Common.Logging; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -29,7 +29,7 @@ internal static class Program /// Zero on no errors, nonzero on error. public static int Main(string[] args) { - var configuration = GetConfiguration("DataImporterSettings.json"); + var configuration = Host.CreateApplicationBuilder(args).Configuration; var section = configuration.GetSection(Settings.LoggingOptionsBlockName); var loggingOptions = Options.Create(section.Get()); var loggerFactory = new FaemiyahLoggerFactory(loggingOptions); @@ -40,23 +40,17 @@ public static int Main(string[] args) var result = Parser.Default.ParseArguments(args) .MapResult( - initOptions => RunDataImport(loggerFactory, initOptions).Result, + initOptions => RunDataImport(loggerFactory, configuration, initOptions).Result, errs => 1); return result; } - private static IConfiguration GetConfiguration(string settingsFile) - { - var config = new ConfigurationBuilder(); - return config.SetBasePath(Directory.GetCurrentDirectory()).AddJsonFile(settingsFile).Build(); - } - - private static async Task RunDataImport(ILoggerFactory loggerFactory, DataImportOptions options) + private static async Task RunDataImport(ILoggerFactory loggerFactory, IConfiguration configuration, DataImportOptions options) { try { - var dataImporter = new DataImporter(loggerFactory); + var dataImporter = new DataImporter(loggerFactory, configuration); await dataImporter.Work(options); } catch (Exception e) diff --git a/BtDamageResolver/tools/DataImporter/Properties/launchSettings.json b/BtDamageResolver/tools/DataImporter/Properties/launchSettings.json new file mode 100644 index 00000000..797e03e4 --- /dev/null +++ b/BtDamageResolver/tools/DataImporter/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "DataImporter": { + "commandName": "Project", + "commandLineArgs": "import -s *.json -f ..\\..\\..\\..\\..\\data", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } +} \ No newline at end of file diff --git a/BtDamageResolver/tools/DataImporter/DataImporterSettings.Release.json b/BtDamageResolver/tools/DataImporter/appsettings.Release.json similarity index 100% rename from BtDamageResolver/tools/DataImporter/DataImporterSettings.Release.json rename to BtDamageResolver/tools/DataImporter/appsettings.Release.json diff --git a/BtDamageResolver/tools/DataImporter/appsettings.json b/BtDamageResolver/tools/DataImporter/appsettings.json new file mode 100644 index 00000000..db0a290e --- /dev/null +++ b/BtDamageResolver/tools/DataImporter/appsettings.json @@ -0,0 +1,10 @@ +{ + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning" + }, + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + } +} diff --git a/BtDamageResolverClient/src/BlazorServer/BlazorServer.csproj b/BtDamageResolverClient/src/BlazorServer/BlazorServer.csproj index 284a1a27..33894c44 100644 --- a/BtDamageResolverClient/src/BlazorServer/BlazorServer.csproj +++ b/BtDamageResolverClient/src/BlazorServer/BlazorServer.csproj @@ -9,25 +9,19 @@ - - - - - - + + + - + Always - + Always - - - - + Always diff --git a/BtDamageResolverClient/src/BlazorServer/Communication/ClientMessageDispatcher.cs b/BtDamageResolverClient/src/BlazorServer/Communication/ClientMessageDispatcher.cs new file mode 100644 index 00000000..e3342d95 --- /dev/null +++ b/BtDamageResolverClient/src/BlazorServer/Communication/ClientMessageDispatcher.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Communication; + +/// +/// In-process, per-circuit dispatcher that delivers server-originated events directly to their +/// registered handlers. +/// +/// +/// Replaces the previous self-looping SignalR HubConnection/ClientHub pair, which existed only +/// to funnel Redis-delivered events back into the circuit. Events arrive on independent Redis subscription +/// queues (the player channel and the common client channel) that can run concurrently, so a single gate +/// serializes handler execution to preserve the one-at-a-time ordering the old single SignalR receive loop +/// provided. Handlers therefore continue to run off the Blazor render dispatcher, exactly as before. +/// +public sealed class ClientMessageDispatcher : IDisposable +{ + private readonly ConcurrentDictionary> _handlers = new(); + private readonly SemaphoreSlim _gate = new(1, 1); + + /// + /// Registers (or replaces) the handler invoked for the given event name. + /// + /// The event name. + /// The handler to invoke with the event payload. + public void On(string eventName, Func handler) + { + _handlers[eventName] = handler; + } + + /// + /// Removes the handler registered for the given event name, if any. + /// + /// The event name. + public void Off(string eventName) + { + _handlers.TryRemove(eventName, out _); + } + + /// + /// Dispatches the payload to the handler registered for the given event name, serializing execution so + /// that handlers fed by concurrent Redis subscription queues never run at the same time. + /// + /// The event name. + /// The event payload. + /// A task which completes when the handler has finished, or immediately if none is registered. + public async Task DispatchAsync(string eventName, byte[] data) + { + if (!_handlers.TryGetValue(eventName, out var handler)) + { + return; + } + + await _gate.WaitAsync().ConfigureAwait(false); + try + { + await handler(data).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + /// + public void Dispose() + { + _gate.Dispose(); + } +} diff --git a/BtDamageResolverClient/src/BlazorServer/Communication/ClientToServerCommunicator.cs b/BtDamageResolverClient/src/BlazorServer/Communication/ClientToServerCommunicator.cs index ca66825a..ca7a5c2a 100644 --- a/BtDamageResolverClient/src/BlazorServer/Communication/ClientToServerCommunicator.cs +++ b/BtDamageResolverClient/src/BlazorServer/Communication/ClientToServerCommunicator.cs @@ -4,7 +4,6 @@ using Faemiyah.BtDamageResolver.Api.ClientInterface.Communicators; using Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; using Faemiyah.BtDamageResolver.Api.Constants; -using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,7 +14,7 @@ namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Communication; /// public class ClientToServerCommunicator : RedisClientToServerCommunicator { - private readonly HubConnection _hubConnection; + private readonly ClientMessageDispatcher _dispatcher; /// /// Initializes a new instance of the class. @@ -24,17 +23,17 @@ public class ClientToServerCommunicator : RedisClientToServerCommunicator /// The JSON serializer settings. /// The Redis connection string. /// The data compression helper. - /// The SignalR hub connection. + /// The in-process dispatcher delivering events to the circuit. /// The player ID. - public ClientToServerCommunicator(ILogger logger, IOptions jsonSerializerOptions, string connectionString, DataHelper dataHelper, HubConnection hubConnection, string playerId) : base(logger, jsonSerializerOptions, connectionString, dataHelper, playerId) + public ClientToServerCommunicator(ILogger logger, IOptions jsonSerializerOptions, string connectionString, DataHelper dataHelper, ClientMessageDispatcher dispatcher, string playerId) : base(logger, jsonSerializerOptions, connectionString, dataHelper, playerId) { - _hubConnection = hubConnection; + _dispatcher = dispatcher; } /// public override async Task HandleConnectionResponse(byte[] connectionResponse, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.ConnectionResponse, _hubConnection.ConnectionId, connectionResponse); + await _dispatcher.DispatchAsync(EventNames.ConnectionResponse, connectionResponse); return true; } @@ -42,7 +41,7 @@ public override async Task HandleConnectionResponse(byte[] connectionRespo /// public override async Task HandleDamageReports(byte[] damageReports, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.DamageReports, _hubConnection.ConnectionId, damageReports); + await _dispatcher.DispatchAsync(EventNames.DamageReports, damageReports); return true; } @@ -50,7 +49,7 @@ public override async Task HandleDamageReports(byte[] damageReports, Guid /// public override async Task HandleErrorMessage(byte[] errorMessage, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.ErrorMessage, _hubConnection.ConnectionId, errorMessage); + await _dispatcher.DispatchAsync(EventNames.ErrorMessage, errorMessage); return true; } @@ -58,7 +57,7 @@ public override async Task HandleErrorMessage(byte[] errorMessage, Guid co /// public override async Task HandleGameEntries(byte[] gameEntries, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.GameEntries, _hubConnection.ConnectionId, gameEntries); + await _dispatcher.DispatchAsync(EventNames.GameEntries, gameEntries); return true; } @@ -66,7 +65,7 @@ public override async Task HandleGameEntries(byte[] gameEntries, Guid corr /// public override async Task HandleGameOptions(byte[] gameOptions, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.GameOptions, _hubConnection.ConnectionId, gameOptions); + await _dispatcher.DispatchAsync(EventNames.GameOptions, gameOptions); return true; } @@ -74,7 +73,7 @@ public override async Task HandleGameOptions(byte[] gameOptions, Guid corr /// public override async Task HandleGameState(byte[] gameState, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.GameState, _hubConnection.ConnectionId, gameState); + await _dispatcher.DispatchAsync(EventNames.GameState, gameState); return true; } @@ -82,7 +81,7 @@ public override async Task HandleGameState(byte[] gameState, Guid correlat /// public override async Task HandlePlayerOptions(byte[] playerOptions, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.PlayerOptions, _hubConnection.ConnectionId, playerOptions); + await _dispatcher.DispatchAsync(EventNames.PlayerOptions, playerOptions); return true; } @@ -90,7 +89,7 @@ public override async Task HandlePlayerOptions(byte[] playerOptions, Guid /// public override async Task HandleTargetNumberUpdates(byte[] targetNumbers, Guid correlationId) { - await _hubConnection.SendAsync(EventNames.TargetNumbers, _hubConnection.ConnectionId, targetNumbers); + await _dispatcher.DispatchAsync(EventNames.TargetNumbers, targetNumbers); return true; } diff --git a/BtDamageResolverClient/src/BlazorServer/Communication/ResolverCommunicator.cs b/BtDamageResolverClient/src/BlazorServer/Communication/ResolverCommunicator.cs index 4ebe5ec8..8aa73029 100644 --- a/BtDamageResolverClient/src/BlazorServer/Communication/ResolverCommunicator.cs +++ b/BtDamageResolverClient/src/BlazorServer/Communication/ResolverCommunicator.cs @@ -2,12 +2,12 @@ using System.Text.Json; using System.Threading.Tasks; using Faemiyah.BtDamageResolver.Api.ClientInterface.Compression; +using Faemiyah.BtDamageResolver.Api.ClientInterface.Events; using Faemiyah.BtDamageResolver.Api.ClientInterface.Requests; using Faemiyah.BtDamageResolver.Api.ClientInterface.Requests.Prototypes; +using Faemiyah.BtDamageResolver.Api.Constants; using Faemiyah.BtDamageResolver.Api.Entities; using Faemiyah.BtDamageResolver.Api.Options; -using Faemiyah.BtDamageResolver.Common.Options; -using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,8 +21,8 @@ public class ResolverCommunicator : IDisposable private readonly ILogger _logger; private readonly IOptions _jsonSerializerOptions; private readonly DataHelper _dataHelper; - private readonly CommunicationOptions _communicationOptions; - private readonly HubConnection _hubConnection; + private readonly string _connectionString; + private readonly ClientMessageDispatcher _dispatcher; private string _playerName; private Guid _authenticationToken; @@ -33,17 +33,17 @@ public class ResolverCommunicator : IDisposable /// Initializes a new instance of the class. /// /// The logging interface. - /// The communication options. + /// The Redis connection string. /// The JSON serializer options. /// The data compression helper. - /// The SignalR hub connection. - public ResolverCommunicator(ILogger logger, IOptions communicationOptions, IOptions jsonSerializerOptions, DataHelper dataHelper, HubConnection hubConnection) + /// The in-process dispatcher delivering events to the circuit. + public ResolverCommunicator(ILogger logger, string connectionString, IOptions jsonSerializerOptions, DataHelper dataHelper, ClientMessageDispatcher dispatcher) { _logger = logger; _jsonSerializerOptions = jsonSerializerOptions; - _communicationOptions = communicationOptions.Value; + _connectionString = connectionString; _dataHelper = dataHelper; - _hubConnection = hubConnection; + _dispatcher = dispatcher; } /// @@ -70,7 +70,7 @@ public void Connect(Credentials credentials) } catch (Exception ex) { - SendErrorMessage($"Error while trying to send data to server. Reason: {ex.Message}"); + _ = SendErrorMessage($"Error while trying to send data to server. Reason: {ex.Message}"); } } @@ -86,7 +86,6 @@ public void Disconnect() AuthenticationToken = _authenticationToken, PlayerName = _playerName }); - _clientToServerCommunicator = null; } /// @@ -327,7 +326,7 @@ protected virtual void Dispose(bool dispose) { if (dispose) { - _clientToServerCommunicator?.Stop(); + TeardownCommunicator(); } disposed = true; @@ -348,7 +347,7 @@ private void SendRequest(string requestType, TRequest request) } catch (Exception ex) { - SendErrorMessage($"Error while trying to send data to server. Reason: {ex.Message}"); + _ = SendErrorMessage($"Error while trying to send data to server. Reason: {ex.Message}"); } } @@ -356,7 +355,7 @@ private bool CheckAuthentication(string requestType) { if (_authenticationToken == Guid.Empty) { - SendErrorMessage($"Tried to send a request of type {requestType}, but no server authentication is available"); + _ = SendErrorMessage($"Tried to send a request of type {requestType}, but no server authentication is available"); return false; } @@ -365,12 +364,33 @@ private bool CheckAuthentication(string requestType) private void Reset() { - _clientToServerCommunicator = new ClientToServerCommunicator(_logger, _jsonSerializerOptions, _communicationOptions.ConnectionString, _dataHelper, _hubConnection, _playerName); + TeardownCommunicator(); + _clientToServerCommunicator = new ClientToServerCommunicator(_logger, _jsonSerializerOptions, _connectionString, _dataHelper, _dispatcher, _playerName); } - private void SendErrorMessage(string errorMessage) + /// + /// Disposes the current client-to-server communicator (if any), releasing its Redis subscription and connection. + /// + private void TeardownCommunicator() + { + if (_clientToServerCommunicator != null) + { + _clientToServerCommunicator.Dispose(); + _clientToServerCommunicator = null; + } + } + + private async Task SendErrorMessage(string errorMessage) { - _ = _hubConnection.SendAsync("ReceiveErrorMessage", _hubConnection.ConnectionId, errorMessage) - .ContinueWith(t => _logger.LogError(t.Exception, "Failed to send error message to hub"), TaskContinuationOptions.OnlyOnFaulted); + // Route local errors through the same in-process dispatcher the server uses, so they reach the + // existing ErrorMessage handler in Index.razor and are surfaced to the user. + try + { + await _dispatcher.DispatchAsync(EventNames.ErrorMessage, _dataHelper.Pack(new ClientErrorEvent(errorMessage))); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dispatch error message"); + } } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.Release.json b/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.Release.json deleted file mode 100644 index 8593c62d..00000000 --- a/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.Release.json +++ /dev/null @@ -1,2 +0,0 @@ -{ -} \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.json b/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.json deleted file mode 100644 index 502d69c2..00000000 --- a/BtDamageResolverClient/src/BlazorServer/CommunicationSettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "LoggingOptions": { - "LogFile": "/logs/BtDamageResolver_.log", - "ProgramName": "BtDamageResolver", - "LogToConsole": true, - "LogToFile": false, - "LogLevel": "Information", - "LogLevelOrleans": "Warning" - }, - "CommunicationOptions": { - "ConnectionString": "redis:6379,password=PASSWORD" - } -} diff --git a/BtDamageResolverClient/src/BlazorServer/Dockerfile b/BtDamageResolverClient/src/BlazorServer/Dockerfile index 5655c67d..8829a34d 100644 --- a/BtDamageResolverClient/src/BlazorServer/Dockerfile +++ b/BtDamageResolverClient/src/BlazorServer/Dockerfile @@ -1,9 +1,9 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base WORKDIR /app FROM resolversdk:latest AS build WORKDIR /build -COPY ["src/", "src/"] +COPY ["BtDamageResolverClient/src/", "src/"] WORKDIR "/build/src/BlazorServer" RUN dotnet publish "BlazorServer.csproj" -c Release -o /app/publish @@ -12,4 +12,5 @@ WORKDIR /app COPY --chown=app:app --from=build /app/publish . RUN mkdir -p /app/dpkeys && chown app:app /app/dpkeys USER app +EXPOSE 8080 ENTRYPOINT ["dotnet", "BlazorServer.dll"] diff --git a/BtDamageResolverClient/src/BlazorServer/Enums/ResolverTab.cs b/BtDamageResolverClient/src/BlazorServer/Enums/ResolverTab.cs index 0050a70f..9dbe6d41 100644 --- a/BtDamageResolverClient/src/BlazorServer/Enums/ResolverTab.cs +++ b/BtDamageResolverClient/src/BlazorServer/Enums/ResolverTab.cs @@ -1,11 +1,10 @@ -namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Enums +namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Enums; + +public enum ResolverTab { - public enum ResolverTab - { - Data, - Dashboard, - AllUnits, - DamageReports, - Options - } + Data, + Dashboard, + AllUnits, + DamageReports, + Options } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Hubs/ClientHub.cs b/BtDamageResolverClient/src/BlazorServer/Hubs/ClientHub.cs deleted file mode 100644 index 53985147..00000000 --- a/BtDamageResolverClient/src/BlazorServer/Hubs/ClientHub.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System.Threading.Tasks; -using Faemiyah.BtDamageResolver.Api.Constants; -using Microsoft.AspNetCore.SignalR; - -namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Hubs; - -/// -/// SignalR hub that the game server uses to push data to connected browser clients. -/// Each method receives a target connection ID and a compressed payload, then -/// forwards the payload to the appropriate client via . -/// -public class ClientHub : Hub -{ - /// Forwards a connection response payload to the specified client. - public async Task ConnectionResponse(string connectionId, byte[] connectionResponse) - { - await Clients.Client(connectionId).SendAsync(EventNames.ConnectionResponse, connectionResponse); - } - - /// Forwards a damage reports payload to the specified client. - public async Task DamageReports(string connectionId, byte[] damageReports) - { - await Clients.Client(connectionId).SendAsync(EventNames.DamageReports, damageReports); - } - - /// Forwards an error message payload to the specified client. - public async Task ErrorMessage(string connectionId, byte[] errorMessage) - { - await Clients.Client(connectionId).SendAsync(EventNames.ErrorMessage, errorMessage); - } - - /// Forwards a game entries payload to the specified client. - public async Task GameEntries(string connectionId, byte[] gameEntries) - { - await Clients.Client(connectionId).SendAsync(EventNames.GameEntries, gameEntries); - } - - /// Forwards a game options payload to the specified client. - public async Task GameOptions(string connectionId, byte[] gameOptions) - { - await Clients.Client(connectionId).SendAsync(EventNames.GameOptions, gameOptions); - } - - /// Forwards a game state payload to the specified client. - public async Task GameState(string connectionId, byte[] gameState) - { - await Clients.Client(connectionId).SendAsync(EventNames.GameState, gameState); - } - - /// Forwards a player options payload to the specified client. - public async Task PlayerOptions(string connectionId, byte[] playerOptions) - { - await Clients.Client(connectionId).SendAsync(EventNames.PlayerOptions, playerOptions); - } - - /// Forwards a target numbers payload to the specified client. - public async Task TargetNumbers(string connectionId, byte[] targetNumberUpdates) - { - await Clients.Client(connectionId).SendAsync(EventNames.TargetNumbers, targetNumberUpdates); - } -} \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Logic/CommonData.cs b/BtDamageResolverClient/src/BlazorServer/Logic/CommonData.cs index 7d0b7107..2e97645b 100644 --- a/BtDamageResolverClient/src/BlazorServer/Logic/CommonData.cs +++ b/BtDamageResolverClient/src/BlazorServer/Logic/CommonData.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -63,9 +64,19 @@ public CommonData( // Pre-bake lists used to generate options DictionaryUnitType = new List { - UnitType.Building, UnitType.AerospaceCapital, UnitType.AerospaceDropshipAerodyne, UnitType.AerospaceDropshipSpheroid, UnitType.AerospaceFighter, UnitType.BattleArmor, - UnitType.Infantry, UnitType.Mech, UnitType.VehicleTracked, UnitType.VehicleWheeled, - UnitType.VehicleHover, UnitType.VehicleVtol + UnitType.AerospaceCapital, + UnitType.AerospaceDropshipAerodyne, + UnitType.AerospaceDropshipSpheroid, + UnitType.AerospaceFighter, + UnitType.BattleArmor, + UnitType.Building, + UnitType.Infantry, + UnitType.Mech, + UnitType.MechQuad, + UnitType.VehicleHover, + UnitType.VehicleTracked, + UnitType.VehicleVtol, + UnitType.VehicleWheeled }.ToDictionary(u => u.ToString()); MapMovementAmount = new Dictionary { @@ -221,15 +232,23 @@ public static WeaponBay GetDefaultWeaponBay(UnitType unitType) /// A display map for cover options for the given unit type. public static Dictionary FormMapCover(UnitType unitType) { - switch (unitType) + return CoverMapCache.GetOrAdd(unitType, static type => { - case UnitType.Mech: - case UnitType.MechTripod: - case UnitType.MechQuad: - return new Dictionary { { "None", Cover.None }, { "Lower", Cover.Lower }, { "Upper", Cover.Upper }, { "Left", Cover.Left }, { "Right", Cover.Right } }; - default: - return new Dictionary { { "None", Cover.None } }; - } + switch (type) + { + case UnitType.Mech: + case UnitType.MechTripod: + return new Dictionary { { "None", Cover.None }, { "Lower", Cover.Lower }, { "Upper", Cover.Upper }, { "Left", Cover.Left }, { "Right", Cover.Right } }; + case UnitType.MechQuad: + return new Dictionary { { "None", Cover.None }, { "HullDown", Cover.HullDown }, { "Lower", Cover.Lower }, { "Upper", Cover.Upper }, { "Left", Cover.Left }, { "Right", Cover.Right } }; + case UnitType.VehicleHover: + case UnitType.VehicleTracked: + case UnitType.VehicleWheeled: + return new Dictionary { { "None", Cover.None }, { "HullDown", Cover.HullDown } }; + default: + return new Dictionary { { "None", Cover.None } }; + } + }); } /// @@ -296,19 +315,22 @@ public static Dictionary FormMapMovementClass(Unit unit) /// A map of valid stances for the given unit. public static Dictionary FormMapStance(UnitType type) { - switch (type) + return StanceMapCache.GetOrAdd(type, static unitType => { - case UnitType.BattleArmor: - return new Dictionary { { "None", Stance.Normal }, { "Light", Stance.Light }, { "Hardened", Stance.Hardened }, { "Heavy", Stance.Heavy } }; - case UnitType.Infantry: - return new Dictionary { { "None", Stance.Normal }, { "DugIn", Stance.DugIn }, { "Prone", Stance.Prone }, { "Light", Stance.Light }, { "Hardened", Stance.Hardened }, { "Heavy", Stance.Heavy } }; - case UnitType.Mech: - case UnitType.MechTripod: - case UnitType.MechQuad: - return new Dictionary { { "Normal", Stance.Normal }, { "Crouch", Stance.Crouch }, { "Prone", Stance.Prone } }; - default: - return new Dictionary { { "Normal", Stance.Normal } }; - } + switch (unitType) + { + case UnitType.BattleArmor: + return new Dictionary { { "None", Stance.Normal }, { "Light", Stance.Light }, { "Hardened", Stance.Hardened }, { "Heavy", Stance.Heavy } }; + case UnitType.Infantry: + return new Dictionary { { "None", Stance.Normal }, { "DugIn", Stance.DugIn }, { "Prone", Stance.Prone }, { "Light", Stance.Light }, { "Hardened", Stance.Hardened }, { "Heavy", Stance.Heavy } }; + case UnitType.Mech: + case UnitType.MechTripod: + case UnitType.MechQuad: + return new Dictionary { { "Normal", Stance.Normal }, { "Crouch", Stance.Crouch }, { "Prone", Stance.Prone } }; + default: + return new Dictionary { { "Normal", Stance.Normal } }; + } + }); } /// @@ -543,7 +565,7 @@ public Dictionary FormMapMovementAmount(UnitEntry unitEntry) /// A map of weapon ammo types. public SortedDictionary FormMapWeaponAmmo(string weaponName) { - return new SortedDictionary(DictionaryWeapon[weaponName].Ammo.Keys.ToDictionary(k => k)); + return WeaponAmmoMapCache.GetOrAdd(weaponName, name => new SortedDictionary(DictionaryWeapon[name].Ammo.Keys.ToDictionary(k => k))); } /// @@ -676,20 +698,31 @@ private static Dictionary GenerateOptions(List e.ToString()); } + private static readonly ConcurrentDictionary<(int Begin, int Interval, int End), List> SimplePickBracketCache = new(); + + private static readonly ConcurrentDictionary> CoverMapCache = new(); + + private static readonly ConcurrentDictionary> StanceMapCache = new(); + + private static readonly ConcurrentDictionary> WeaponAmmoMapCache = new(); + private static List MakeSimplePickBrackets(int begin, int interval, int end) { - var pickBrackets = new List(); - - for (var ii = begin; ii <= end; ii += interval) + return SimplePickBracketCache.GetOrAdd((begin, interval, end), static key => { - pickBrackets.Add(new PickBracket + var pickBrackets = new List(); + + for (var ii = key.Begin; ii <= key.End; ii += key.Interval) { - Begin = ii, - End = ii - }); - } + pickBrackets.Add(new PickBracket + { + Begin = ii, + End = ii + }); + } - return pickBrackets; + return pickBrackets; + }); } private static List MakeArbitraryPickBrackets(List allChangeLocations) diff --git a/BtDamageResolverClient/src/BlazorServer/Logic/UserStateController.cs b/BtDamageResolverClient/src/BlazorServer/Logic/UserStateController.cs index d281af82..211dbac7 100644 --- a/BtDamageResolverClient/src/BlazorServer/Logic/UserStateController.cs +++ b/BtDamageResolverClient/src/BlazorServer/Logic/UserStateController.cs @@ -46,6 +46,16 @@ public UserStateController() /// public event Action OnGameUnitListUpdated; + /// + /// Event for when player unit list gets changed. + /// + public event Action OnPlayerUnitListUpdated; + + /// + /// Event for when game state changes. + /// + public event Action OnGameStateUpdated; + /// /// Event for when unit of invalid units changes. /// @@ -66,11 +76,6 @@ public UserStateController() /// public event Action OnDamageReportsUpdated; - /// - /// Event for when player unit list gets changed. - /// - public event Action OnPlayerUnitListUpdated; - /// /// Event for when game entries are received. /// @@ -121,14 +126,31 @@ public GameState GameState { if (_gameState == null || value == null || _gameState.TimeStamp < value.TimeStamp) { + // The incoming game state is newer overall, but it may have been published before our latest + // local optimistic edit. Preserve our own player state if it is newer or equal. + if (PlayerName != null && + _gameState?.Players != null && + value?.Players != null && + _gameState.Players.TryGetValue(PlayerName, out var localPlayerState) && + value.Players.TryGetValue(PlayerName, out var incomingPlayerState) && + localPlayerState.TimeStamp > incomingPlayerState.TimeStamp) + { + value.Players[PlayerName] = localPlayerState; + } + _gameState = value; - } - // The below method checks whether it is actually necessary to invoke UI refresh. - // Most of the time, this is not the case - UpdateUnitList(); + // UpdateUnitList checks whether the identity-level unit list actually changed; most pushes only + // mutate values, so this is usually false and the heavier list-driven refresh is skipped. + if (UpdateUnitList()) + { + NotifyGameUnitListUpdated(); + } - NotifyPlayerUnitListUpdated(); + // The editing tree (FormGameState) listens to this to re-bind its child components to the freshly + // deserialized PlayerState/UnitEntry instances. Without it, edits would mutate orphaned objects. + NotifyGameStateUpdated(); + } } } @@ -310,6 +332,17 @@ public SortedDictionary GetUnitIds() return dictionary; } + /// + /// Notification for when game state changes. + /// + public void NotifyGameStateUpdated() + { + if (PlayerState != null) + { + OnGameStateUpdated?.Invoke(); + } + } + /// /// Notification for when list of invalid units updates. /// @@ -333,6 +366,17 @@ public void NotifyPlayerDataUpdated() } } + /// + /// Notification for when player list orders change. + /// + public void NotifyGameUnitListUpdated() + { + if (PlayerState != null) + { + OnGameUnitListUpdated?.Invoke(); + } + } + /// /// Notification for when player list orders change. /// @@ -465,7 +509,7 @@ public TargetNumberUpdateSingleWeapon GetTargetNumberUpdateSingleWeapon(Guid uni return null; } - private void UpdateUnitList() + private bool UpdateUnitList() { var newUnitList = new ConcurrentDictionary(); @@ -491,6 +535,7 @@ private void UpdateUnitList() if (UnitList.Count != newUnitList.Count || newUnitList.Any(u => !UnitList.ContainsKey(u.Key))) { UnitList = newUnitList; + return true; } else { @@ -501,9 +546,11 @@ private void UpdateUnitList() if (oldUnit.PlayerId != newUnit.Value.PlayerId || oldUnit.Unit.Name != newUnit.Value.Unit.Name || oldUnit.Unit.Type != newUnit.Value.Unit.Type) { UnitList = newUnitList; - break; + return true; } } } + + return false; } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Logic/VisualStyleController.cs b/BtDamageResolverClient/src/BlazorServer/Logic/VisualStyleController.cs index 45925569..c3225cc2 100644 --- a/BtDamageResolverClient/src/BlazorServer/Logic/VisualStyleController.cs +++ b/BtDamageResolverClient/src/BlazorServer/Logic/VisualStyleController.cs @@ -30,6 +30,9 @@ public static bool GetCoverHidden(UnitType unitType) case UnitType.Mech: case UnitType.MechTripod: case UnitType.MechQuad: + case UnitType.VehicleHover: + case UnitType.VehicleTracked: + case UnitType.VehicleWheeled: return false; default: return true; diff --git a/BtDamageResolverClient/src/BlazorServer/Pages/Index.razor b/BtDamageResolverClient/src/BlazorServer/Pages/Index.razor index 6b5df87f..a585a0fc 100644 --- a/BtDamageResolverClient/src/BlazorServer/Pages/Index.razor +++ b/BtDamageResolverClient/src/BlazorServer/Pages/Index.razor @@ -9,13 +9,12 @@ @using Faemiyah.BtDamageResolver.Api.Options @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @using Faemiyah.BtDamageResolver.Client.BlazorServer.Communication -@using Microsoft.AspNetCore.SignalR.Client @inject CommonData _commonData @inject DataHelper _dataHelper @inject LocalStorage _localStorage @inject NavigationManager _navigationManager @inject ResolverCommunicator _resolverCommunicator -@inject HubConnection _hubConnection; +@inject ClientMessageDispatcher _dispatcher @inject UserStateController _userStateController
@@ -24,9 +23,10 @@
-
- -
+@if (!_userStateController.IsConnectedToGame) +{ + +}
@@ -43,11 +43,11 @@ - + - + @@ -59,14 +59,13 @@ @code { - private FormServer _formServer; private readonly List _tabs = new() { "Data", "Dashboard", "AllUnits", "DamageReports", "Options" }; private string _selectedTab = "Dashboard"; private string _errorMessage; protected override void OnInitialized() { - _hubConnection.On(EventNames.ConnectionResponse, async (connectionResponseData) => + _dispatcher.On(EventNames.ConnectionResponse, async (connectionResponseData) => { var connectionResponse = _dataHelper.Unpack(connectionResponseData); @@ -101,48 +100,55 @@ InvokeStateChange(); }); - _hubConnection.On(EventNames.DamageReports, damageReportData => + _dispatcher.On(EventNames.DamageReports, damageReportData => { _userStateController.DamageReportContainer.AddRange(_dataHelper.Unpack>(damageReportData)); _userStateController.NotifyDamageReportsChanged(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.ErrorMessage, errorMessage => + _dispatcher.On(EventNames.ErrorMessage, errorMessage => { var errorEvent = _dataHelper.Unpack(errorMessage); _errorMessage = errorEvent.ErrorMessage; _userStateController.InvalidUnitIds = errorEvent.InvalidUnitIds; InvokeStateChange(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.GameEntries, gameEntries => + _dispatcher.On(EventNames.GameEntries, gameEntries => { _userStateController.GameEntries = _dataHelper.Unpack>(gameEntries).ToDictionary(g => g.GetName(), g => g); InvokeStateChange(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.GameOptions, gameOptionsData => + _dispatcher.On(EventNames.GameOptions, gameOptionsData => { _userStateController.GameOptions = _dataHelper.Unpack(gameOptionsData); InvokeStateChange(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.GameState, gameStateData => + _dispatcher.On(EventNames.GameState, gameStateData => { _userStateController.GameState = _dataHelper.Unpack(gameStateData); InvokeStateChange(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.PlayerOptions, playerOptionsData => + _dispatcher.On(EventNames.PlayerOptions, playerOptionsData => { _userStateController.PlayerOptions = _dataHelper.Unpack(playerOptionsData); InvokeStateChange(); + return Task.CompletedTask; }); - _hubConnection.On(EventNames.TargetNumbers, targetNumberUpdateData => + _dispatcher.On(EventNames.TargetNumbers, targetNumberUpdateData => { _userStateController.RecordTargetNumberUpdates(_dataHelper.Unpack>(targetNumberUpdateData)); // Not necessary to invoke state change on target number data + return Task.CompletedTask; }); _userStateController.OnPlayerStateUpdated += SendPlayerState; @@ -155,13 +161,11 @@ { if (firstRender) { - await _hubConnection.StartAsync(); - var credentials = await _localStorage.GetUserCredentials(); if (credentials != null) { - _formServer.Connect(credentials); + _resolverCommunicator.Connect(credentials); } } } @@ -193,19 +197,27 @@ private void LeaveGame() { - _formServer.LeaveGame(); + _resolverCommunicator.LeaveGame(); } - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { _userStateController.OnPlayerStateUpdated -= SendPlayerState; _userStateController.OnGameOptionsUpdated -= SendGameOptions; _userStateController.OnPlayerOptionsUpdated -= SendPlayerOptions; _userStateController.OnDamageInstanceRequested -= SendDamageInstance; + + _dispatcher.Off(EventNames.ConnectionResponse); + _dispatcher.Off(EventNames.DamageReports); + _dispatcher.Off(EventNames.ErrorMessage); + _dispatcher.Off(EventNames.GameEntries); + _dispatcher.Off(EventNames.GameOptions); + _dispatcher.Off(EventNames.GameState); + _dispatcher.Off(EventNames.PlayerOptions); + _dispatcher.Off(EventNames.TargetNumbers); + _resolverCommunicator?.Dispose(); - if (_hubConnection != null) - { - await _hubConnection.DisposeAsync(); - } + + return ValueTask.CompletedTask; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Pages/_Host.cshtml b/BtDamageResolverClient/src/BlazorServer/Pages/_Host.cshtml index 76d5bb8c..58d29eb9 100644 --- a/BtDamageResolverClient/src/BlazorServer/Pages/_Host.cshtml +++ b/BtDamageResolverClient/src/BlazorServer/Pages/_Host.cshtml @@ -1,8 +1,15 @@ @page "/" @namespace Faemiyah.BtDamageResolver.Client.BlazorServer.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using System.Reflection @{ Layout = null; + + // _framework/blazor.server.js cannot use asp-append-version (it is not a physical wwwroot + // file), so bust the browser cache with the client assembly's build time. This forces a + // refetch after every deploy, preventing a stale framework script from silently breaking + // the interactive circuit while initial render still appears to work. + var frameworkCacheBust = System.IO.File.GetLastWriteTimeUtc(Assembly.GetExecutingAssembly().Location).Ticks; } @@ -14,7 +21,7 @@ - + @@ -32,6 +39,6 @@ 🗙
- + diff --git a/BtDamageResolverClient/src/BlazorServer/Properties/launchSettings.json b/BtDamageResolverClient/src/BlazorServer/Properties/launchSettings.json new file mode 100644 index 00000000..452d05d5 --- /dev/null +++ b/BtDamageResolverClient/src/BlazorServer/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "BtDamageResolverClient": { + "commandName": "Project", + "launchBrowser": true, + "applicationUrl": "http://localhost:8786", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentGameState.razor b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentGameState.razor index 7919d1ef..6ae0a477 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentGameState.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentGameState.razor @@ -1,11 +1,11 @@ -@using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic +@inherits BaseFaemiyahComponent +@using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController +@implements IDisposable @if (_userStateController.IsConnectedToGame) { - var playerStateList = DisplayOwnUnits ? - _userStateController.GameState.Players.Values.Where(p => !p.IsSpectator) : - _userStateController.GameState.Players.Where(p => p.Value.PlayerId != _userStateController.PlayerName).Select(p => p.Value).Where(p => !p.IsSpectator); + var playerStateList = _userStateController.GameState.Players.Values.Where(p => !p.IsSpectator); var spectatorList = _userStateController.GameState.Players.Values.Where(p => p.IsSpectator).Select(p => p.PlayerId).ToList(); @@ -26,11 +26,18 @@ @foreach (var player in playerStateList) { - + } } @code { - [Parameter] - public bool DisplayOwnUnits { get; set; } + protected override void OnInitialized() + { + _userStateController.OnGameStateUpdated += InvokeStateChange; + } + + public void Dispose() + { + _userStateController.OnGameStateUpdated -= InvokeStateChange; + } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentHeatAmmoEstimate.razor b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentHeatAmmoEstimate.razor index 941305e2..acb41fd6 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentHeatAmmoEstimate.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentHeatAmmoEstimate.razor @@ -1,58 +1,77 @@ @inherits BaseFaemiyahComponent @using Faemiyah.BtDamageResolver.Api.Entities +@using Faemiyah.BtDamageResolver.Api.Constants @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController @implements IDisposable @{ - var targetNumberUpdate = _userStateController.GetTargetNumberUpdate(Unit.Id); + var targetNumberUpdate = _userStateController.GetTargetNumberUpdate(UnitEntry.Id); } @if (targetNumberUpdate != null) { -
- @if (targetNumberUpdate.HeatWorstCase > 0) + @if (targetNumberUpdate.HeatWorstCase > 0) + { +
+ Projected heat: +
+
+ @targetNumberUpdate.HeatEstimate.ToString(DisplayConstants.NumberFormat) + @if (targetNumberUpdate.HeatEstimate != targetNumberUpdate.HeatWorstCase) + { + @($" ({targetNumberUpdate.HeatWorstCase})") + } + → + @((targetNumberUpdate.HeatEstimate + UnitEntry.Consumables.Heat - UnitEntry.Sinks).ToString(DisplayConstants.NumberFormat)) +
+ } + + @if (targetNumberUpdate.AmmoEstimate.Any()) + { +
+ Projected ammo usage: +
+ foreach (var item in targetNumberUpdate.AmmoEstimate) { -
- Projected heat: @targetNumberUpdate.HeatEstimate - @if (targetNumberUpdate.HeatEstimate != targetNumberUpdate.HeatWorstCase) +
+ @($"{item.Key}: {item.Value.ToString(DisplayConstants.NumberFormat)}") + @if (targetNumberUpdate.AmmoWorstCase[item.Key] != targetNumberUpdate.AmmoEstimate[item.Key]) { - @($" ({targetNumberUpdate.HeatWorstCase})") + @($" ({targetNumberUpdate.AmmoWorstCase[item.Key]})") }
} - - @if (targetNumberUpdate.AmmoEstimate.Any()) - { -
- Projected ammo usage: -
- foreach (var item in targetNumberUpdate.AmmoEstimate) - { -
- @($"{item.Key}: {item.Value}") - @if (targetNumberUpdate.AmmoWorstCase[item.Key] != targetNumberUpdate.AmmoEstimate[item.Key]) - { - @($" ({targetNumberUpdate.AmmoWorstCase[item.Key]})") - } -
- } - } -
+ } } @code { + private DateTime _targetNumberTimeStamp; + [Parameter] - public UnitEntry Unit { get; set; } + public UnitEntry UnitEntry { get; set; } protected override void OnInitialized() { - _userStateController.OnTargetNumbersUpdated += InvokeStateChange; + _targetNumberTimeStamp = _userStateController.GetTargetNumberUpdate(UnitEntry.Id)?.TimeStamp ?? DateTime.MinValue; + _userStateController.OnTargetNumbersUpdated += CheckRefresh; + } + + // Only re-render when the target number update for THIS unit actually changed, + // rather than on every global target number packet. + private void CheckRefresh() + { + var timeStamp = _userStateController.GetTargetNumberUpdate(UnitEntry.Id)?.TimeStamp ?? DateTime.MinValue; + if (timeStamp != _targetNumberTimeStamp) + { + _targetNumberTimeStamp = timeStamp; + InvokeStateChange(); + } } public void Dispose() { - _userStateController.OnTargetNumbersUpdated -= InvokeStateChange; + _userStateController.OnTargetNumbersUpdated -= CheckRefresh; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentPlayerState.razor b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentPlayerState.razor index 697bcffa..02901b7b 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentPlayerState.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentPlayerState.razor @@ -1,18 +1,41 @@ -@using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic +@using Faemiyah.BtDamageResolver.Api.Entities +@using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController -

- @PlayerId @(_userStateController.GameState.AdminId == PlayerId ? "[Admin]" : string.Empty) @(_userStateController.GameState.Players[PlayerId].IsReady ? "[Ready]" : string.Empty) +

+ @Player.PlayerId @(_userStateController.GameState.AdminId == Player.PlayerId ? "[Admin]" : string.Empty) @(Player.IsReady ? "[Ready]" : string.Empty)

- @foreach (var unitEntry in _userStateController.GameState.Players[PlayerId].UnitEntries) + @foreach (var unitEntry in Player.UnitEntries) { }
@code { + private DateTime _renderedTimeStamp; + [Parameter] - public string PlayerId { get; set; } + public PlayerState Player { get; set; } + + // Capture the initial timestamp so the first post-init render is not treated as a change. + protected override void OnInitialized() + { + _renderedTimeStamp = Player.TimeStamp; + } + + // Skip re-rendering this player (and its whole unit subtree) unless the player's + // own state actually changed. ShouldRender is never called for the first render, + // so the initial render always happens. + protected override bool ShouldRender() + { + if (Player.TimeStamp != _renderedTimeStamp) + { + _renderedTimeStamp = Player.TimeStamp; + return true; + } + + return false; + } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentUnit.razor b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentUnit.razor index 5fdbf060..2cea7c54 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentUnit.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentUnit.razor @@ -2,130 +2,121 @@ @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController -
-
-
- @UnitEntry.Name -
+
+
+ @UnitEntry.Name
-
-
-
-
Type
-
@UnitEntry.Type
-
+
+
+
Type
+
@UnitEntry.Type
+
+
+
Gunnery/Piloting
+
@UnitEntry.Gunnery / @UnitEntry.Piloting
+
+
+
Tonnage
+
@UnitEntry.Tonnage
+
+
+
Troopers
+
@UnitEntry.Troopers
+
+ @if (UnitEntry.Features.Count > 0) + {
-
Gunnery/Piloting
-
@UnitEntry.Gunnery / @UnitEntry.Piloting
-
-
-
Tonnage
-
@UnitEntry.Tonnage
-
-
-
Troopers
-
@UnitEntry.Troopers
-
- @if (UnitEntry.Features.Any()) - { -
-
Features
-
-
- @foreach (var feature in UnitEntry.Features) - { -
- @($"{feature}") -
- } -
+
Features
+
+
+ @foreach (var feature in UnitEntry.Features) + { +
+ @($"{feature}") +
+ }
- } +
+ } -
-
Dissipation
-
@UnitEntry.Sinks
+
+
Dissipation
+
@UnitEntry.Sinks
+
+
+
Movement
+
+ @UnitEntry.Speed/@((int)Math.Ceiling((decimal)UnitEntry.Speed * 1.5m))/@UnitEntry.JumpJets + — + @UnitEntry.MovementClass + — + @UnitEntry.Movement
+
+
+
Stance
+
@UnitEntry.Stance
+
+ @if (UnitEntry.Narced || UnitEntry.Tagged) + {
-
Movement
+
Conditions
- @UnitEntry.Speed/@((int)Math.Ceiling((decimal)UnitEntry.Speed * 1.5m))/@UnitEntry.JumpJets - — - @UnitEntry.MovementClass - — - @UnitEntry.Movement + @if (UnitEntry.Narced) + { +
Narced
+ } + @if (UnitEntry.Tagged) + { +
Tagged
+ }
-
-
Stance
-
@UnitEntry.Stance
-
- @if (UnitEntry.Narced || UnitEntry.Tagged) - { -
-
Conditions
-
- @if (UnitEntry.Narced) - { -
Narced
- } - @if (UnitEntry.Tagged) - { -
Tagged
- } -
-
- } -
+ }
-
-
- @foreach (var weaponBay in UnitEntry.WeaponBays) +
+ @foreach (var weaponBay in UnitEntry.WeaponBays) + { + @if (UnitEntry.WeaponBays.Count > 1) { -
- @weaponBay.Arc - — - @weaponBay.Name +
+ @weaponBay.GetFormalName()
+ } -
-
-
-
Target
-
@_userStateController.GetUnitName(weaponBay.FiringSolution.Target)
-
-
-
Modifier
-
@weaponBay.FiringSolution.AttackModifier
-
-
-
Distance
-
@weaponBay.FiringSolution.Distance
-
-
-
Facing
-
@weaponBay.FiringSolution.Direction
-
-
-
Cover
-
@weaponBay.FiringSolution.Cover
-
-
+
+
+
Target
+
@_userStateController.GetUnitName(weaponBay.FiringSolution.Target)
- -
-
- @foreach (var weaponEntry in weaponBay.Weapons) - { - - } -
+
+
Modifier
+
@weaponBay.FiringSolution.AttackModifier
- } -
+
+
Distance
+
@weaponBay.FiringSolution.Distance
+
+
+
Facing
+
@weaponBay.FiringSolution.Direction
+
+
+
Cover
+
@weaponBay.FiringSolution.Cover
+
+
+ +
+ @foreach (var weaponEntry in weaponBay.Weapons) + { + + } +
+ }
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentWeaponEntry.razor b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentWeaponEntry.razor index afb2d721..6779d845 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/ComponentWeaponEntry.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/ComponentWeaponEntry.razor @@ -34,6 +34,8 @@ @code { + private DateTime _targetNumberTimeStamp; + [Parameter] public UnitEntry Unit { get; set; } @@ -42,11 +44,24 @@ protected override void OnInitialized() { - _userStateController.OnTargetNumbersUpdated += InvokeStateChange; + _targetNumberTimeStamp = _userStateController.GetTargetNumberUpdate(Unit.Id)?.TimeStamp ?? DateTime.MinValue; + _userStateController.OnTargetNumbersUpdated += CheckRefresh; + } + + // Only re-render when the target number update for THIS unit actually changed, + // rather than on every global target number packet. + private void CheckRefresh() + { + var timeStamp = _userStateController.GetTargetNumberUpdate(Unit.Id)?.TimeStamp ?? DateTime.MinValue; + if (timeStamp != _targetNumberTimeStamp) + { + _targetNumberTimeStamp = timeStamp; + InvokeStateChange(); + } } public void Dispose() { - _userStateController.OnTargetNumbersUpdated -= InvokeStateChange; + _userStateController.OnTargetNumbersUpdated -= CheckRefresh; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReport.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReport.razor index 3b04abbe..684e30ae 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReport.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReport.razor @@ -3,7 +3,6 @@ @using Faemiyah.BtDamageResolver.Api.Enums @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController -@implements IDisposable @{ var attackingUnitOwnedByYou = _userStateController.PlayerState.UnitEntries.Exists(u => DamageReport.FiringUnitIds.Contains(u.Id)); @@ -65,94 +64,94 @@
-
- -
+ @if (AllowDelete) + { +
+ +
+ }
-
-
- Phase: @DamageReport.Phase -
+
+
+ φ @DamageReport.Phase
- -
-
- Total damage to target: @DamageReport.DamagePaperDoll.DamageCollection.SelectMany(dc => dc.Value.SelectMany(l => l.Value)).Sum() -
+
+ Total damage:   @_totalDamageToTarget
+
- + - @foreach (var firingUnitId in DamageReport.FiringUnitIds) - { - @if (DamageReport.ConsumablesAttackers.TryGetValue(firingUnitId, out var attackerConsumables) && attackerConsumables.Heat > 0) - { - @switch (_userStateController.GetUnitType(firingUnitId)) - { - case UnitType.AerospaceFighter: - case UnitType.Mech: - case UnitType.MechTripod: - case UnitType.MechQuad: -
-
- @if (DamageReport.FiringUnitIds.Count > 1) - { - Attacker heat (@DamageReport.FiringUnitNames[firingUnitId]): - } - else - { - Attacker heat: - } - - @(attackerConsumables.Heat) -
-
- break; - } - } - } - - @foreach (var firingUnitId in DamageReport.FiringUnitIds) + @foreach (var firingUnitId in DamageReport.FiringUnitIds) + { + @if (DamageReport.ConsumablesAttackers.TryGetValue(firingUnitId, out var attackerConsumables) && attackerConsumables.Heat > 0) { - @if (DamageReport.ConsumablesAttackers.TryGetValue(firingUnitId, out var attackerConsumables) && attackerConsumables.AmmoUsage.Any()) + @switch (_userStateController.GetUnitType(firingUnitId)) { -
- @foreach (var ammoExpenditureItem in attackerConsumables.AmmoUsage) - { -
+ case UnitType.AerospaceFighter: + case UnitType.Mech: + case UnitType.MechTripod: + case UnitType.MechQuad: +
+
@if (DamageReport.FiringUnitIds.Count > 1) { - Attacker (@DamageReport.FiringUnitNames[firingUnitId]) spent @ammoExpenditureItem.Key: + Attacker heat (@DamageReport.FiringUnitNames[firingUnitId]): } else { - Attacker spent @ammoExpenditureItem.Key: + Attacker heat: } - @ammoExpenditureItem.Value + + @(attackerConsumables.Heat)
- } -
+
+ break; } } + } - @if (DamageReport.ConsumablesDefender.AmmoUsage.Any()) + @foreach (var firingUnitId in DamageReport.FiringUnitIds) + { + @if (DamageReport.ConsumablesAttackers.TryGetValue(firingUnitId, out var attackerConsumables) && attackerConsumables.AmmoUsage.Any()) {
- @foreach (var ammoExpenditureItem in DamageReport.ConsumablesDefender.AmmoUsage) + @foreach (var ammoExpenditureItem in attackerConsumables.AmmoUsage) {
- Defender @ammoExpenditureItem.Key ammo spent: @ammoExpenditureItem.Value + @if (DamageReport.FiringUnitIds.Count > 1) + { + Attacker (@DamageReport.FiringUnitNames[firingUnitId]) spent @ammoExpenditureItem.Key: + } + else + { + Attacker spent @ammoExpenditureItem.Key: + } + @ammoExpenditureItem.Value
}
} + } + + @if (DamageReport.ConsumablesDefender.AmmoUsage.Any()) + { +
+ @foreach (var ammoExpenditureItem in DamageReport.ConsumablesDefender.AmmoUsage) + { +
+ Defender @ammoExpenditureItem.Key ammo spent: @ammoExpenditureItem.Value +
+ } +
+ }
@foreach (var attackLogEntryType in _logLineVisibility) { - @attackLogEntryType.Key + @attackLogEntryType.Key }
@@ -176,15 +175,28 @@ { private Dictionary _logLineVisibility; private bool _attackLogVisible; + private int _totalDamageToTarget; + private DamageReport _lastSummedDamageReport; + + [Parameter] + public bool AllowDelete { get; set; } = true; [Parameter] public DamageReport DamageReport { get; set; } protected override void OnInitialized() { - _attackLogVisible = _userStateController.PlayerOptions?.ShowAttackLogByDefault ?? false; + _attackLogVisible = _userStateController.PlayerOptions?.DamageReportsShowAttackLog ?? false; _logLineVisibility = _userStateController.GetAttackLogEntryVisibilityCopy(); - _userStateController.OnDamageReportsUpdated += InvokeStateChange; + } + + protected override void OnParametersSet() + { + if (!ReferenceEquals(_lastSummedDamageReport, DamageReport)) + { + _lastSummedDamageReport = DamageReport; + _totalDamageToTarget = DamageReport.DamagePaperDoll.DamageCollection.SelectMany(dc => dc.Value).Where(dc => !dc.IsCritical && !dc.IsSpecial).Sum(dc => dc.DamageAmount); + } } public void Delete() @@ -201,9 +213,4 @@ InvokeStateChange(); } } - - public void Dispose() - { - _userStateController.OnDamageReportsUpdated -= InvokeStateChange; - } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReportContainer.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReportContainer.razor index b6aad8e9..93bb4ffb 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReportContainer.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReportContainer.razor @@ -3,15 +3,6 @@ @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController -@{ - var combinedDamageReport = DamageReports[0].BlankCopy(); - - foreach (var damageReport in DamageReports) - { - combinedDamageReport.Merge(damageReport); - } -} - @if (DamageReports.Count > 1) {
@@ -23,14 +14,14 @@
@if (_combined) { - + } else {
@foreach (var damageReport in DamageReports) { - + }
} @@ -38,18 +29,43 @@ } else { - + } @code { private bool _combined; + private bool _combinedInitialized; + private List _mergedSource; + private DamageReport _combinedDamageReport; [Parameter] public List DamageReports { get; set; } protected override void OnParametersSet() { - _combined = _userStateController.PlayerOptions?.ShowCombinedDamageReports ?? false; + // Only seed the combined state from player options once. Rebuilding the parent report list + // (e.g. when new damage reports arrive) re-runs OnParametersSet on persisted containers, so + // resetting here would discard the user's manual Combine/Split choice. + if (!_combinedInitialized) + { + _combined = _userStateController.PlayerOptions?.DamageReportsCombine ?? false; + _combinedInitialized = true; + } + + // Recreation guard that allows FormPaperDoll's ShouldRender guard skip redundant re-renders in combined mode, + // as the combined damage report's identity is preserved if the source damage reports haven't changed. + if (_mergedSource == null || !_mergedSource.SequenceEqual(DamageReports)) + { + var combinedDamageReport = DamageReports[0].BlankCopy(); + + foreach (var damageReport in DamageReports) + { + combinedDamageReport.Merge(damageReport); + } + + _combinedDamageReport = combinedDamageReport; + _mergedSource = DamageReports.ToList(); + } } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReports.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReports.razor index 89f714af..68d2429f 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReports.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormDamageReports.razor @@ -13,12 +13,12 @@ } else { - @foreach (var singleTurnDamageReports in _damageReportsToShow.Reverse()) + @foreach (var displayTurn in _damageReportsToShow) { - - @foreach (var damageReports in singleTurnDamageReports.Value.GroupBy(dr => $"{dr.TargetUnitId}-{dr.Phase}")) + + @foreach (var reportGroup in displayTurn.ReportGroups) { - + r.Id))) DamageReports=@reportGroup> } } @@ -26,7 +26,9 @@ else @code { - private SortedDictionary> _damageReportsToShow = new(); + private List<(int Turn, List> ReportGroups)> _damageReportsToShow = new(); + private (DateTime ContainerTimeStamp, bool? SpectatorStatus, bool ShowOther, bool ShowMovement, long GlobalUnitHash, bool OnlyNewest)? _lastSignature; + private bool _shouldRender = true; [Parameter] public bool OnlyNewest { get; set; } @@ -39,54 +41,102 @@ else protected override void OnParametersSet() { - BuildDamageReportsToShow(); + RefreshState(); } private void OnDataChanged() { - BuildDamageReportsToShow(); + RefreshState(); InvokeStateChange(); } + // Single gate driving both the rebuild and ShouldRender. The signature captures everything the whole + // subtree renders from: the (fixed-per-version) report set via DamageReportContainer.TimeStamp, the + // display options, the player's spectator status, and the global UnitListHash. + private void RefreshState() + { + var playerState = _userStateController.PlayerState; + var signature = ( + _userStateController.DamageReportContainer?.TimeStamp ?? default, + playerState?.IsSpectator, + _userStateController.PlayerOptions?.DamageReportsShowOtherPlayers ?? false, + _userStateController.PlayerOptions?.DamageReportsShowMovement ?? false, + _userStateController.UnitListHash, + OnlyNewest); + + if (_lastSignature == signature) + { + return; + } + + _lastSignature = signature; + _shouldRender = true; + BuildDamageReportsToShow(); + } + + protected override bool ShouldRender() + { + if (_shouldRender) + { + _shouldRender = false; + + return true; + } + + return false; + } + private void BuildDamageReportsToShow() { - var showOtherPlayers = _userStateController.PlayerOptions?.ShowOtherPlayersDamageReports ?? false; - var showMovement = _userStateController.PlayerOptions?.ShowMovementDamageReports ?? false; - var result = new SortedDictionary>(); + var showOtherPlayers = _userStateController.PlayerOptions?.DamageReportsShowOtherPlayers ?? false; + var showMovement = _userStateController.PlayerOptions?.DamageReportsShowMovement ?? false; + var filtered = new SortedDictionary>(); if (!_userStateController.DamageReportContainer.IsEmpty()) { if (OnlyNewest) { var last = _userStateController.DamageReportContainer.DamageReports.Last(); - result.Add(last.Key, new()); + filtered.Add(last.Key, new()); foreach (var damageReport in last.Value) { if ((_userStateController.PlayerState?.IsSpectator == true || showOtherPlayers || _userStateController.DamageReportConcernsPlayer(damageReport)) && (showMovement || damageReport.Phase != Phase.Movement)) { - result[last.Key].Add(damageReport); + filtered[last.Key].Add(damageReport); } } } else { - foreach (var singleTurnDamageReports in _userStateController.DamageReportContainer.DamageReports.Reverse()) + foreach (var singleTurnDamageReports in _userStateController.DamageReportContainer.DamageReports) { var turn = singleTurnDamageReports.Key; - result.Add(turn, new()); + filtered.Add(turn, new()); foreach (var damageReport in singleTurnDamageReports.Value) { if ((_userStateController.PlayerState?.IsSpectator == true || showOtherPlayers || _userStateController.DamageReportConcernsPlayer(damageReport)) && (showMovement || damageReport.Phase != Phase.Movement)) { - result[turn].Add(damageReport); + filtered[turn].Add(damageReport); } } } } } + var result = new List<(int Turn, List> ReportGroups)>(filtered.Count); + foreach (var singleTurnDamageReports in filtered.Reverse()) + { + var groups = new List>(); + foreach (var group in singleTurnDamageReports.Value.GroupBy(damageReport => $"{damageReport.TargetUnitId}-{damageReport.Phase}")) + { + groups.Add(group.ToList()); + } + + result.Add((singleTurnDamageReports.Key, groups)); + } + _damageReportsToShow = result; } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayUnit.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayUnit.razor index 7bc04522..32abf698 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayUnit.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayUnit.razor @@ -1,9 +1,9 @@ @using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic +@using System.Text.Json @using Microsoft.Extensions.Options -@using Newtonsoft.Json @inject CommonData _commonData -@inject IOptions _jsonSerializerSettings +@inject IOptions _jsonSerializerOptions @if (_unit != null) { @@ -101,20 +101,9 @@ @if (_showModalExport) { -
- -
+ + + } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayWeapon.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayWeapon.razor index b5bb74c9..d13e7bff 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayWeapon.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormDataDisplayWeapon.razor @@ -119,6 +119,14 @@ UsesAmmo @_weapon.UsesAmmo + + MultiAmmo + @_weapon.MultiAmmo + + + AmmoProxyName + @_weapon.AmmoProxyName + Hit modifiers @_weapon.HitModifier diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormFiringSolution.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormFiringSolution.razor index c05112db..b4742348 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormFiringSolution.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormFiringSolution.razor @@ -1,8 +1,6 @@ @inherits BaseFaemiyahComponent @using Faemiyah.BtDamageResolver.Api.Entities -@using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities @using Faemiyah.BtDamageResolver.Api.Enums -@using Faemiyah.BtDamageResolver.Client.BlazorServer.Entities @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject CommonData _commonData @inject UserStateController _userStateController @@ -39,7 +37,7 @@
Modifier
-
+
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormGameState.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormGameState.razor index 4745cac8..c64d523f 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormGameState.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormGameState.razor @@ -11,7 +11,7 @@ { @foreach (var player in _userStateController.GameState.Players.Values.Where(p => !p.IsSpectator)) { - + } } else @@ -26,7 +26,7 @@ ContainerClass="resolver_div_componentlistcontainer" OnChanged="OnUnitsReordered"> - + @@ -44,11 +44,11 @@ - + - + } @@ -60,10 +60,10 @@ protected override void OnInitialized() { - _sectionEnabledTools = _userStateController.PlayerOptions?.DashboardShowToolsByDefault ?? false; - _sectionEnabledDamageReports = _userStateController.PlayerOptions?.DashboardShowDamageReportsByDefault ?? true; - _sectionEnabledDamageRequests = _userStateController.PlayerOptions?.DashboardShowDamageRequestsByDefault ?? false; - _userStateController.OnPlayerUnitListUpdated += InvokeStateChange; + _sectionEnabledTools = _userStateController.PlayerOptions?.DashboardShowTools ?? false; + _sectionEnabledDamageReports = _userStateController.PlayerOptions?.DashboardShowDamageReports ?? true; + _sectionEnabledDamageRequests = _userStateController.PlayerOptions?.DashboardShowDamageRequests ?? false; + _userStateController.OnGameStateUpdated += InvokeStateChange; } private void AddUnit() @@ -95,6 +95,6 @@ public void Dispose() { - _userStateController.OnPlayerUnitListUpdated -= InvokeStateChange; + _userStateController.OnGameStateUpdated -= InvokeStateChange; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormOptions.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormOptions.razor index b41736b4..21e1c90f 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormOptions.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormOptions.razor @@ -20,9 +20,9 @@
- Tools - Damage requests - Damage reports + Tools + Damage requests + Damage reports

@@ -30,10 +30,11 @@

- Attack logs - Combine damage reports when possible - Unit movement damage reports - Other players' damage reports + Attack logs + Combine damage reports when possible + Unit movement damage reports + Other players' damage reports + Damage summary

@@ -52,7 +53,7 @@
@foreach (var attackLogEntryType in _userStateController.PlayerOptions.AttackLogEntryVisibility) { - @attackLogEntryType.Key + @attackLogEntryType.Key }
} @@ -106,7 +107,7 @@ else {
- Not game options have been received from the server or not connected to a game. + No game options have been received from the server or not connected to a game.
}

@@ -124,63 +125,72 @@ private void OnAttackLogVisibilityChanged(bool attackLogVisibility) { - if (_userStateController.PlayerOptions.ShowAttackLogByDefault != attackLogVisibility) + if (_userStateController.PlayerOptions.DamageReportsShowAttackLog != attackLogVisibility) { - _userStateController.PlayerOptions.ShowAttackLogByDefault = attackLogVisibility; + _userStateController.PlayerOptions.DamageReportsShowAttackLog = attackLogVisibility; NotifyPlayerOptionsChanged(); } } private void OnCombinedDamageReportsVisibilityChanged(bool combinedDamageReportsVisibility) { - if (_userStateController.PlayerOptions.ShowCombinedDamageReports != combinedDamageReportsVisibility) + if (_userStateController.PlayerOptions.DamageReportsCombine != combinedDamageReportsVisibility) { - _userStateController.PlayerOptions.ShowCombinedDamageReports = combinedDamageReportsVisibility; + _userStateController.PlayerOptions.DamageReportsCombine = combinedDamageReportsVisibility; NotifyPlayerOptionsChanged(); } } private void OnToolVisibilityChanged(bool toolVisibility) { - if (_userStateController.PlayerOptions.DashboardShowToolsByDefault != toolVisibility) + if (_userStateController.PlayerOptions.DashboardShowTools != toolVisibility) { - _userStateController.PlayerOptions.DashboardShowToolsByDefault = toolVisibility; + _userStateController.PlayerOptions.DashboardShowTools = toolVisibility; NotifyPlayerOptionsChanged(); } } private void OnDamageRequestVisibilityChanged(bool damageRequestVisibility) { - if (_userStateController.PlayerOptions.DashboardShowDamageRequestsByDefault != damageRequestVisibility) + if (_userStateController.PlayerOptions.DashboardShowDamageRequests != damageRequestVisibility) { - _userStateController.PlayerOptions.DashboardShowDamageRequestsByDefault = damageRequestVisibility; + _userStateController.PlayerOptions.DashboardShowDamageRequests = damageRequestVisibility; NotifyPlayerOptionsChanged(); } } private void OnDamageReportVisibilityChanged(bool damageReportVisibility) { - if (_userStateController.PlayerOptions.DashboardShowDamageReportsByDefault != damageReportVisibility) + if (_userStateController.PlayerOptions.DashboardShowDamageReports != damageReportVisibility) { - _userStateController.PlayerOptions.DashboardShowDamageReportsByDefault = damageReportVisibility; + _userStateController.PlayerOptions.DashboardShowDamageReports = damageReportVisibility; + NotifyPlayerOptionsChanged(); + } + } + + private void OnDamageReportsSummaryChanged(bool damageReportsShowSummary) + { + if (_userStateController.PlayerOptions.DamageReportsShowSummary != damageReportsShowSummary) + { + _userStateController.PlayerOptions.DamageReportsShowSummary = damageReportsShowSummary; NotifyPlayerOptionsChanged(); } } private void OnMovementDamageReportsVisibilityChanged(bool damageReportVisibility) { - if (_userStateController.PlayerOptions.ShowMovementDamageReports != damageReportVisibility) + if (_userStateController.PlayerOptions.DamageReportsShowMovement != damageReportVisibility) { - _userStateController.PlayerOptions.ShowMovementDamageReports = damageReportVisibility; + _userStateController.PlayerOptions.DamageReportsShowMovement = damageReportVisibility; NotifyPlayerOptionsChanged(); } } private void OnOtherPlayerDamageReportsVisibilityChanged(bool damageReportVisibility) { - if (_userStateController.PlayerOptions.ShowOtherPlayersDamageReports != damageReportVisibility) + if (_userStateController.PlayerOptions.DamageReportsShowOtherPlayers != damageReportVisibility) { - _userStateController.PlayerOptions.ShowOtherPlayersDamageReports = damageReportVisibility; + _userStateController.PlayerOptions.DamageReportsShowOtherPlayers = damageReportVisibility; NotifyPlayerOptionsChanged(); } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDoll.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDoll.razor index e5c5a1cf..6a17c4e8 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDoll.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDoll.razor @@ -1,7 +1,7 @@ -@using System.Globalization +@inherits BaseFaemiyahComponent +@using System.Globalization @using Faemiyah.BtDamageResolver.Api.Entities @using Faemiyah.BtDamageResolver.Api.Enums -@using Faemiyah.BtDamageResolver.Api.Extensions @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject UserStateController _userStateController @@ -28,6 +28,9 @@ case PaperDollType.Mech: break; + case PaperDollType.MechQuad: + + break; case PaperDollType.BattleArmor: break; @@ -46,105 +49,53 @@ }
-
- @foreach (var location in DamagePaperDoll.DamageCollection) +
+
+ @(_showSummary ? "Damage Summary" : "Damage detail") +   + ⇅ +
+ @if (_showSummary) { - var damageInLocation = location.Value.SelectMany(l => l.Value).Sum(); - var criticalDamagePresent = DamagePaperDoll.DamageCollectionCritical.ContainsKey(location.Key); - - if (damageInLocation > 0 || criticalDamagePresent) + @foreach (var location in DamagePaperDoll.Locations) { + var damageInLocation = LocationsWithDamage[location].Where(odp => !odp.DamageEntry.IsCritical && !odp.DamageEntry.IsSpecial).Sum(odp => odp.DamageEntry.DamageAmount); + var criticalDamageEntries = LocationsWithDamage[location].Where(odp => odp.DamageEntry.IsCritical).Select(odp => odp.DamageEntry).ToList(); + var specialDamageEntries = LocationsWithDamage[location].Where(odp => odp.DamageEntry.IsSpecial).Select(odp => odp.DamageEntry).ToList(); +
- @location.Key - - @if (damageInLocation > 0) + @location +
+ @damageInLocation + + @foreach (var criticalDamageEntry in criticalDamageEntries) {
- @location.Value.SelectMany(l => l.Value).Sum() + @(criticalDamageEntry.ToString()) } - @if (criticalDamagePresent) + @foreach (var specialDamageEntry in specialDamageEntries) { - foreach (var criticalDamageEntryList in DamagePaperDoll.DamageCollectionCritical[location.Key]) - { - foreach (var criticalDamageEntry in criticalDamageEntryList.Value) - { -
- - if (DamageReport.FiringUnitIds.Count > 1 && DamageReport.FiringUnitNames.ContainsKey(criticalDamageEntryList.Key)) - { - @(DamageReport.FiringUnitNames[criticalDamageEntryList.Key] + " — ") - } - - switch (criticalDamageEntry.Type) - { - case CriticalDamageType.Critical: - @("Critical hit") - break; - case CriticalDamageType.Immobilized: - @("Immobilized") - break; - case CriticalDamageType.HeavyMotive: - @("Heavy motive damage") - break; - case CriticalDamageType.LightMotive: - @("Light motive damage") - break; - case CriticalDamageType.ModerateMotive: - @("Moderate motive damage") - break; - case CriticalDamageType.LimbBlownOff: - @("Limb blown off") - break; - default: - switch (criticalDamageEntry.ThreatType) - { - case CriticalThreatType.DamageThreshold: - @($"Critical threat to: {criticalDamageEntry.Type} (Threshold {criticalDamageEntry.InducingDamage})") - break; - case CriticalThreatType.Normal: - @($"Critical damage to: {criticalDamageEntry.Type} ({criticalDamageEntry.InducingDamage})") - break; - default: - @($"Unknown critical threat type: {criticalDamageEntry.ThreatType}") - break; - } - - break; - } - } - } +
+ @(specialDamageEntry.ToString()) }
} } - - @foreach (var specialDamageEntryGroup in DamagePaperDoll.DamageCollectionSpecial.SelectMany(p => p.Value).SelectMany(p => p.Value).GroupBy(s => s.Type)) + else { - if (specialDamageEntryGroup.Key == SpecialDamageType.PilotingSkillRoll) + foreach (var (damageOwnerId, damageEntry) in DamagePaperDoll.DamageCollection.SelectMany(kvp => kvp.Value.Select(entity => (kvp.Key, entity))).ToList()) { - foreach (var value in specialDamageEntryGroup) - { -
- - @specialDamageEntryGroup.Key - - → - - @(int.TryParse(value.Data, out var parsedValue) ? parsedValue.ToStringExplicit() : value.Data) - -
- } - } - else {
- - @specialDamageEntryGroup.Key - - → - - @specialDamageEntryGroup.Sum(s => int.TryParse(s.Data, out var v) ? v : 0) - + @if (IsCombinedDamageReport) + { + @DamageReport.FiringUnitNames[damageOwnerId] + — + } + + @damageEntry.Location + — + @($"{damageEntry}")
} } @@ -152,45 +103,86 @@
@code { - [Parameter] public DamagePaperDoll DamagePaperDoll { get; set; } [Parameter] public DamageReport DamageReport { get; set; } + private DamagePaperDoll _lastRenderedPaperDoll; + private DamageReport _lastRenderedDamageReport; + private bool _lastRenderedShowSummary; + private bool _isCombinedDamageReport; + private bool _showSummary; + private readonly Dictionary> _locationsWithDamage = new(); + private readonly Dictionary _locationDamages = new(); + + public bool IsCombinedDamageReport => _isCombinedDamageReport; + public Dictionary> LocationsWithDamage => _locationsWithDamage; + public Dictionary LocationDamages => _locationDamages; + + // This component (and its paper doll variant + per-region children) is a pure projection of + // DamagePaperDoll + DamageReport with no internal interactive state. It lives on the DamageReports + // tab, which re-renders on every unrelated gamestate broadcast while it is open; skip those redundant + // renders — and the per-region damage colour/tooltip work they drive — unless one of the bound objects + // actually changes. New damage reports arrive as new object instances, so reference equality is the + // correct and cheapest signal. + protected override bool ShouldRender() + { + return !ReferenceEquals(_lastRenderedPaperDoll, DamagePaperDoll) + || !ReferenceEquals(_lastRenderedDamageReport, DamageReport) + || _lastRenderedShowSummary != _showSummary; + } + + protected override void OnParametersSet() + { + _showSummary = _userStateController.PlayerOptions?.DamageReportsShowSummary ?? true; + _isCombinedDamageReport = DamageReport.FiringUnitIds.Count > 1; + + _locationsWithDamage.Clear(); + _locationDamages.Clear(); + + foreach (var location in DamagePaperDoll.Locations) + { + LocationsWithDamage.Add(location, DamagePaperDoll.DamageCollection.SelectMany(kvp => kvp.Value.Select(entity => (kvp.Key, entity))).Where(odp => odp.entity.Location == location).ToList()); + LocationDamages.Add(location, LocationsWithDamage[location].Sum(odp => odp.DamageEntry.DamageAmount)); + } + } + + protected override void OnAfterRender(bool firstRender) + { + _lastRenderedPaperDoll = DamagePaperDoll; + _lastRenderedDamageReport = DamageReport; + _lastRenderedShowSummary = _showSummary; + } + public bool AnyDamage(Location location) { - return DamagePaperDoll.DamageCollection.ContainsKey(location); + return LocationsWithDamage.ContainsKey(location); } public string GetDamageText(Location location) { - if(DamagePaperDoll.DamageCollection.ContainsKey(location)) + if(LocationsWithDamage.ContainsKey(location)) { - if (DamageReport.FiringUnitIds.Count > 1) + if (IsCombinedDamageReport) { - var damageOwnerPairs = - DamagePaperDoll.DamageCollection[location] - .SelectMany(kvp => kvp.Value.Select(Damage => (kvp.Key, Damage))) - .ToList(); - - return $"'{location}
{string.Join("
", damageOwnerPairs.Select(dop => $"{DamageReport.FiringUnitNames[dop.Key]} — {dop.Damage}"))}'"; + return $"{location}
{string.Join("
", LocationsWithDamage[location].Select(dep => $"{System.Net.WebUtility.HtmlEncode(DamageReport.FiringUnitNames[dep.OwnerId])} — {dep.DamageEntry}"))}"; } else { - return $"'{location}
{string.Join("
", DamagePaperDoll.DamageCollection[location].SelectMany(dc => dc.Value))}'"; + return $"{location}
{string.Join("
", LocationsWithDamage[location].Select(dep => dep.DamageEntry.ToString()))}"; } } - return $"'{location}
0'"; + return $"{location}
0"; } public string GetDamageColor(Location location) { - if (DamagePaperDoll.DamageCollection.ContainsKey(location)) + if (LocationsWithDamage.ContainsKey(location)) { - var damage = DamagePaperDoll.DamageCollection[location].SelectMany(l => l.Value).Sum(); + var damage = LocationsWithDamage[location].Sum(odp => odp.DamageEntry.DamageAmount); if (damage > 0) { @@ -212,4 +204,10 @@ return $"#{red}{white}{white}"; } + + private void OnSummaryToggle() + { + _showSummary = !_showSummary; + InvokeStateChange(); + } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceCapital.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceCapital.razor index ca591acc..1d3e0e0e 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceCapital.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceCapital.razor @@ -3,34 +3,14 @@
- - - - - - - - - - - - - - - - - - + + + + + + - - - +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipAerodyne.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipAerodyne.razor index 3e56d40b..6da2f5b7 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipAerodyne.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipAerodyne.razor @@ -2,25 +2,11 @@
- - - - - - - - - - - - - - - + + + + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipSpheroid.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipSpheroid.razor index 403b6460..37a684ba 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipSpheroid.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceDropshipSpheroid.razor @@ -2,30 +2,16 @@
- - - - - - - - - - - - - - - - - - - - + + + + + + + + + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceFighter.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceFighter.razor index 38bbfa9f..f5afda9b 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceFighter.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollAerospaceFighter.razor @@ -3,25 +3,11 @@
- - - - - - - - - - - - - - - + + + + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBattleArmor.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBattleArmor.razor index d06892df..176a457c 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBattleArmor.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBattleArmor.razor @@ -7,10 +7,7 @@ @for (int ii = 0; ii < DamageReport.InitialTroopers; ii++) { - - - + } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBuilding.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBuilding.razor index bcd59eff..9eaa2c3f 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBuilding.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollBuilding.razor @@ -3,12 +3,8 @@
- - - - + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMech.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMech.razor index ee70dcd2..f18e88f5 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMech.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMech.razor @@ -2,58 +2,25 @@
- - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @if (FormPaperDoll.AnyDamage(Location.RearLeftTorso)) { - - - + } @if (FormPaperDoll.AnyDamage(Location.RearRightTorso)) { - - - + } @if (FormPaperDoll.AnyDamage(Location.RearCenterTorso)) { - - - + }
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMechQuad.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMechQuad.razor new file mode 100644 index 00000000..3e1e1833 --- /dev/null +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollMechQuad.razor @@ -0,0 +1,32 @@ +@using Faemiyah.BtDamageResolver.Api.Enums + +
+ + + + + + + + + + @if (FormPaperDoll.AnyDamage(Location.RearLeftTorso)) + { + + } + @if (FormPaperDoll.AnyDamage(Location.RearRightTorso)) + { + + } + @if (FormPaperDoll.AnyDamage(Location.RearCenterTorso)) + { + + } + +
+ + +@code { + [Parameter] + public FormPaperDoll FormPaperDoll { get; set; } +} diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollRegion.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollRegion.razor new file mode 100644 index 00000000..75c55e28 --- /dev/null +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollRegion.razor @@ -0,0 +1,36 @@ +@using Faemiyah.BtDamageResolver.Api.Enums + +@if (Path == null) +{ + +} +else +{ + +} + +@code { + [Parameter] + public FormPaperDoll FormPaperDoll { get; set; } + + [Parameter] + public Location Location { get; set; } + + [Parameter] + public string Points { get; set; } + + [Parameter] + public string Path { get; set; } + + [Parameter] + public bool RoundJoin { get; set; } + + [Parameter] + public string Fill { get; set; } + + private string FillColor => Fill ?? FormPaperDoll.GetDamageColor(Location); + + private string LineJoin => RoundJoin ? "round" : null; +} diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollTrooper.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollTrooper.razor index 6be993a6..eaa9717f 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollTrooper.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollTrooper.razor @@ -3,27 +3,14 @@
@{ - _totalDamage = DamageReport.DamagePaperDoll.DamageCollection.ContainsKey(Location.Trooper) ? DamageReport.DamagePaperDoll.DamageCollection[Location.Trooper].SelectMany(l => l.Value).Sum() : 0; + _totalDamage = FormPaperDoll.AnyDamage(Location.Trooper) ? FormPaperDoll.LocationDamages[Location.Trooper] : 0; } @for (int ii = 0; ii < DamageReport.InitialTroopers; ii++) { - - - @if (ii < _totalDamage) - { - - - } - else - { - - - } - - - } + + + + }
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicle.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicle.razor index 7d229f6f..cb6395df 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicle.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicle.razor @@ -3,41 +3,16 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicleVtol.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicleVtol.razor index 5f50419b..04d3bd64 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicleVtol.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormPaperDollVehicleVtol.razor @@ -3,38 +3,15 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + +
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormServer.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormServer.razor index 3dcf2bb9..07847764 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormServer.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormServer.razor @@ -63,29 +63,12 @@ @if (_showModal) { -
- -
+ + + } @code diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormTools.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormTools.razor index 52e98638..f238e170 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormTools.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormTools.razor @@ -26,51 +26,17 @@ @if (_showModalKick) { -
- -
+ + + } @if (_showModalMoveUnit) { -
- -
+ + + + } @code diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormUnitEntry.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormUnitEntry.razor index 44e99ae0..83684198 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormUnitEntry.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormUnitEntry.razor @@ -1,4 +1,4 @@ -@inherits BaseFaemiyahComponent +@inherits BaseFaemiyahComponent @using Faemiyah.BtDamageResolver.Api.Entities @using Faemiyah.BtDamageResolver.Api.Enums @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @@ -8,16 +8,20 @@ @if(_userStateController.IsConnectedToGame) { -
+ var isReady = _userStateController.PlayerState.IsReady; + var comparisonTime = _userStateController.ComparisonTime; + var isInvalidUnit = _userStateController.InvalidUnitIds.Contains(UnitEntry.Id); + +
- Editable - - - - - + Editable + + + + +
@@ -35,49 +39,49 @@
Name
- +
Type
- +
Gunnery
- +
Piloting
- +
Tonnage
- +
Speed
- +
Jump Jets
- +
-
Features
+
Features
- +
@@ -88,44 +92,44 @@
Troopers
- +
Dissipation
- +
Movement
- +
Evasion
- Evading + Evading
Hexes moved
- +
Stance
- +
Status effects
- Narced - Tagged + Narced + Tagged
@@ -143,13 +147,13 @@ Heat —   - +
Penalty —   - +
@@ -187,95 +191,44 @@ @ammoUsage.Key —   - +   - +
} - +
- +
- +
} @if (_showModalDelete) { -
- -
+ + Really delete @UnitEntry.Name? + } @if (_showModalSave) { -
- -
+ + Save unit @UnitEntry.Name to database? + } @if (_showModalLoad) { -
- -
+ + + } @code { @@ -556,6 +509,7 @@ private bool _showModalLoad = false; private bool _showModalSave = false; private string _modalUnitName = string.Empty; + private SortedDictionary _savedUnitNames = []; private void ShowModalDelete() { @@ -564,6 +518,9 @@ private void ShowModalLoad() { + // Fetch saved unit names from Redis once when the modal opens rather than on every + // re-render while it is open. Loading units is rare, so the snapshot is always current enough. + _savedUnitNames = _commonData.GetSavedUnitNames(); _showModalLoad = true; } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponBay.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponBay.razor index 4fe31193..48545701 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponBay.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponBay.razor @@ -4,7 +4,6 @@ @using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic @inject CommonData _commonData @inject UserStateController _userStateController -@implements IDisposable @if(_userStateController.IsConnectedToGame) { @@ -60,11 +59,6 @@ [Parameter] public EventCallback OnUpdated { get; set; } - protected override void OnInitialized() - { - _userStateController.OnPlayerUnitListUpdated += InvokeStateChange; - } - public void AddWeapon() { var weaponToAdd = WeaponBay.Weapons.LastOrDefault(); @@ -89,17 +83,19 @@ public void MoveUp() { if (UnitEntry.WeaponBays.MoveUp(WeaponBay)) + { OnUpdated.InvokeAsync(); + } } public void MoveDown() { if (UnitEntry.WeaponBays.MoveDown(WeaponBay)) + { OnUpdated.InvokeAsync(); + } } - #region Property changing delegates - private void OnArcChanged(Arc arc) { if (WeaponBay.Arc != arc) @@ -123,11 +119,4 @@ WeaponBay.Weapons.Remove(weaponEntry); OnUpdated.InvokeAsync(); } - - #endregion - - public void Dispose() - { - _userStateController.OnPlayerUnitListUpdated -= InvokeStateChange; - } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponEntry.razor b/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponEntry.razor index 29785df7..bcd656c0 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponEntry.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/FormWeaponEntry.razor @@ -10,12 +10,11 @@ @{ var targetNumberUpdate = _userStateController.GetTargetNumberUpdateSingleWeapon(UnitEntry.Id, WeaponEntry.Id); - var tooltipContent = GetTargetNumberText(targetNumberUpdate?.CalculationLog, false); + var tooltipContent = GetCachedTooltipContent(targetNumberUpdate?.CalculationLog); var targetNumber = targetNumberUpdate?.TargetNumber <= 12 ? $"{targetNumberUpdate.TargetNumber}" : "N/A"; - var mapWeaponAmmo = _commonData.FormMapWeaponAmmo(WeaponEntry.WeaponName); } -
+
@(targetNumber)
@@ -28,7 +27,7 @@ } else { - + }
@@ -38,7 +37,7 @@ } else { - + }
@@ -54,7 +53,7 @@
@if (_commonData.WeaponHasAmmo(WeaponEntry.WeaponName)) { - + }
@@ -70,6 +69,9 @@ private int _weaponModifier; private WeaponState _weaponState; private string _weaponName; + private DateTime _targetNumberTimeStamp; + private string _cachedTooltipContent; + private DateTime? _cachedTooltipKey; [Parameter] public WeaponBay WeaponBay { get; set; } @@ -97,13 +99,26 @@ _weaponModifier = WeaponEntry.Modifier; _weaponState = WeaponEntry.State; _weaponName = WeaponEntry.WeaponName; - _userStateController.OnTargetNumbersUpdated += InvokeStateChange; + _targetNumberTimeStamp = _userStateController.GetTargetNumberUpdate(UnitEntry.Id)?.TimeStamp ?? DateTime.MinValue; + _userStateController.OnTargetNumbersUpdated += CheckRefresh; if (CorrectAmmoForWeapon(_weaponName)) { UpdateWeaponEntry(); } } + // Only re-render when the target number update for THIS unit actually changed, + // rather than on every global target number packet. + private void CheckRefresh() + { + var timeStamp = _userStateController.GetTargetNumberUpdate(UnitEntry.Id)?.TimeStamp ?? DateTime.MinValue; + if (timeStamp != _targetNumberTimeStamp) + { + _targetNumberTimeStamp = timeStamp; + InvokeStateChange(); + } + } + private bool CorrectAmmoForWeapon(string weaponName) { var mapWeaponAmmo = _commonData.FormMapWeaponAmmo(weaponName); @@ -201,11 +216,26 @@ UpdateWeaponEntry(); } + // The tooltip string only changes when this unit's target-number data changes, so memoize it + // on the same timestamp that gates re-render instead of rebuilding it on every render. + private string GetCachedTooltipContent(AttackLog attackLog) + { + if (_cachedTooltipKey != _targetNumberTimeStamp) + { + _cachedTooltipContent = GetTargetNumberText(attackLog, false); + _cachedTooltipKey = _targetNumberTimeStamp; + } + + return _cachedTooltipContent; + } + private static string GetTargetNumberText(AttackLog attackLog, bool showAllLines) { if (attackLog != null && attackLog.Log.Any()) { - return attackLog.Log.Where(entry => showAllLines || entry.Number != 0).Aggregate(string.Empty, (current, entry) => current + (entry + "
")); + return string.Concat(attackLog.Log + .Where(entry => showAllLines || entry.Number != 0) + .Select(entry => entry + "
")); } return "No target number calculation log available."; @@ -213,6 +243,6 @@ public void Dispose() { - _userStateController.OnTargetNumbersUpdated -= InvokeStateChange; + _userStateController.OnTargetNumbersUpdated -= CheckRefresh; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerModal.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerModal.razor new file mode 100644 index 00000000..ecc3ba1d --- /dev/null +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerModal.razor @@ -0,0 +1,37 @@ +
+ +
+ +@code { + [Parameter] + public string Title { get; set; } + + [Parameter] + public string PrimaryText { get; set; } = "Submit"; + + [Parameter] + public RenderFragment ChildContent { get; set; } + + [Parameter] + public EventCallback OnPrimary { get; set; } + + [Parameter] + public EventCallback OnClose { get; set; } +} diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerReorderableList.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerReorderableList.razor index 893a8102..166d48e6 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerReorderableList.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/ContainerReorderableList.razor @@ -1,15 +1,13 @@ @typeparam TItem -
+
@for (var i = 0; i < Items.Count; i++) { var capturedIndex = i; -
} -
@@ -47,8 +41,6 @@ @code { private int? _draggedIndex; - private int? _dragOverIndex; - private bool _dragOverSentinel; [Parameter] public IList Items { get; set; } @@ -74,33 +66,11 @@ private void StartDrag(int index) { _draggedIndex = index; - _dragOverIndex = null; - _dragOverSentinel = false; - } - - private void SetDragOver(int index) - { - if (_draggedIndex.HasValue) - { - _dragOverIndex = index; - _dragOverSentinel = false; - } - } - - private void SetDragOverSentinel() - { - if (_draggedIndex.HasValue) - { - _dragOverIndex = null; - _dragOverSentinel = true; - } } private void CancelDrag() { _draggedIndex = null; - _dragOverIndex = null; - _dragOverSentinel = false; } private void Drop(int targetIndex) @@ -122,7 +92,5 @@ } _draggedIndex = null; - _dragOverIndex = null; - _dragOverSentinel = false; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormComboBox.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormComboBox.razor index dc26e834..40ac9ae8 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormComboBox.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormComboBox.razor @@ -20,17 +20,12 @@
- @foreach (var key in Options.Select(o => o.Key)) + @if (!_comboBoxItemsHidden) { - var optionToSelect = $"{key}"; - var hide = !key.Contains(_searchString, StringComparison.InvariantCultureIgnoreCase); - if (key.Equals(SelectedOptionInternal)) + @foreach (var key in Options.Where(o => o.Key.Contains(_searchString, StringComparison.InvariantCultureIgnoreCase)).Select(o => o.Key)) { -
@key
- } - else - { -
@key
+ var optionToSelect = $"{key}"; +
@key
} }
diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormNumberPickerDisplayOnly.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormNumberPickerDisplayOnly.razor index d9e19e81..72b27896 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormNumberPickerDisplayOnly.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormNumberPickerDisplayOnly.razor @@ -77,8 +77,15 @@ { _selectedOption = value; HideSelectionBox(); - OnChanged.InvokeAsync(value); - OnChangedWithHint.InvokeAsync((Hint, value)); + if (OnChanged.HasDelegate) + { + OnChanged.InvokeAsync(value); + } + + if (OnChangedWithHint.HasDelegate) + { + OnChangedWithHint.InvokeAsync((Hint, value)); + } } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormPickSet.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormPickSet.razor index 1be7382d..710be316 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormPickSet.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormPickSet.razor @@ -57,7 +57,7 @@ { _containedItemNames.Add(item); OnChanged.InvokeAsync(ProduceOutputType()); - _updateTimeStamp = DateTime.Now; + _updateTimeStamp = DateTime.UtcNow; } } @@ -67,7 +67,7 @@ { _containedItemNames.Remove(item); OnChanged.InvokeAsync(ProduceOutputType()); - _updateTimeStamp = DateTime.Now; + _updateTimeStamp = DateTime.UtcNow; } } diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormTextArea.razor b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormTextArea.razor index 52904729..4b7c4f0e 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormTextArea.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/Generic/FormTextArea.razor @@ -1,6 +1,4 @@ - + @code { diff --git a/BtDamageResolverClient/src/BlazorServer/Shared/MainLayout.razor b/BtDamageResolverClient/src/BlazorServer/Shared/MainLayout.razor index 0bd4dc13..e1a9a756 100644 --- a/BtDamageResolverClient/src/BlazorServer/Shared/MainLayout.razor +++ b/BtDamageResolverClient/src/BlazorServer/Shared/MainLayout.razor @@ -1,5 +1,3 @@ @inherits LayoutComponentBase -
- @Body -
+@Body diff --git a/BtDamageResolverClient/src/BlazorServer/Startup.cs b/BtDamageResolverClient/src/BlazorServer/Startup.cs index e91f1714..7ae98080 100644 --- a/BtDamageResolverClient/src/BlazorServer/Startup.cs +++ b/BtDamageResolverClient/src/BlazorServer/Startup.cs @@ -6,19 +6,16 @@ using Faemiyah.BtDamageResolver.Api.Entities.Interfaces; using Faemiyah.BtDamageResolver.Api.Entities.RepositoryEntities; using Faemiyah.BtDamageResolver.Client.BlazorServer.Communication; -using Faemiyah.BtDamageResolver.Client.BlazorServer.Hubs; using Faemiyah.BtDamageResolver.Client.BlazorServer.Logic; using Faemiyah.BtDamageResolver.Common.Constants; using Faemiyah.BtDamageResolver.Common.Logging; -using Faemiyah.BtDamageResolver.Common.Options; +using Faemiyah.BtDamageResolver.Common.Logging.Options; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.SignalR.Client; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -52,15 +49,19 @@ public Startup(IConfiguration configuration) /// For more information on how to configure your application, see https://go.microsoft.com/fwlink/?LinkID=398940. ///
/// The service collection. - public static void ConfigureServices(IServiceCollection services) + public void ConfigureServices(IServiceCollection services) { - var configuration = GetConfiguration("CommunicationSettings.json"); - - services.Configure(configuration.GetSection(Settings.CommunicationOptionsBlockName)); - services.Configure(configuration.GetSection(Settings.LoggingOptionsBlockName)); + services.Configure(Configuration.GetSection(Settings.CompressionOptionsBlockName)); + services.Configure(Configuration.GetSection(Settings.LoggingOptionsBlockName)); services.Configure(options => { options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromHours(1); + + // Each retained disconnected circuit keeps its scoped Redis subscription alive and + // continues processing its game's messages, so this cap bounds background CPU as well + // as memory. Raised to 256 to allow many simultaneously-paused phone clients to reconnect + // to their existing circuit within the retention window. + options.DisconnectedCircuitMaxRetained = 128; }) .Configure(options => { @@ -72,13 +73,17 @@ public static void ConfigureServices(IServiceCollection services) conf.AddFaemiyahLogging(); }); - services.AddDataProtection().SetApplicationName("BtDamageResolverClient").PersistKeysToFileSystem(new DirectoryInfo(configuration["DataProtectionKeysPath"] ?? "/app/dpkeys/")); + services.AddDataProtection().SetApplicationName("BtDamageResolverClient").PersistKeysToFileSystem(new DirectoryInfo(Configuration["DataProtectionKeysPath"] ?? "/app/dpkeys/")); services.AddRazorPages(); services.AddServerSideBlazor(); services.AddSignalR(options => { - options.EnableDetailedErrors = true; - options.MaximumReceiveMessageSize = 1048576; + // Detailed SignalR errors echo server-side exception messages to the client, so only + // enable them outside of Production to avoid leaking internal details. + options.EnableDetailedErrors = !string.Equals( + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + Environments.Production, + StringComparison.OrdinalIgnoreCase); }); services.ConfigureJsonSerializerOptions(); services.AddSingleton>(GetRedisEntityRepository); @@ -91,18 +96,16 @@ public static void ConfigureServices(IServiceCollection services) services.AddSingleton>(GetRedisEntityRepository); services.AddSingleton(); services.AddSingleton(); - services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(serviceProvider => new ResolverCommunicator( + serviceProvider.GetRequiredService>(), + Configuration.GetConnectionString(Settings.RedisConnectionStringName) + ?? throw new InvalidOperationException($"No '{Settings.RedisConnectionStringName}' connection string configured."), + serviceProvider.GetRequiredService>(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); services.AddScoped(); - services.AddScoped(serviceProvider => - { - var navigationManager = serviceProvider.GetRequiredService(); - return new HubConnectionBuilder() - .WithAutomaticReconnect() - .WithUrl(navigationManager.ToAbsoluteUri("/ClientHub")) - .Build(); - }); } /// @@ -133,21 +136,20 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseEndpoints(endpoints => { endpoints.MapBlazorHub(); - endpoints.MapHub("/ClientHub"); endpoints.MapFallbackToPage("/_Host"); }); } - private static RedisEntityRepository GetRedisEntityRepository(IServiceProvider serviceProvider) + private RedisEntityRepository GetRedisEntityRepository(IServiceProvider serviceProvider) where TType : class, IEntity { - var options = serviceProvider.GetService>(); + var connectionString = Configuration.GetConnectionString(Settings.RedisConnectionStringName); - if (options != null) + if (!string.IsNullOrEmpty(connectionString)) { - return new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), options.Value.ConnectionString); + return new RedisEntityRepository(serviceProvider.GetService>>(), serviceProvider.GetService>(), connectionString); } - throw new InvalidOperationException($"Unable to resolve options class providing connection string for entity repository of type {typeof(TType)}."); + throw new InvalidOperationException($"No 'Redis' connection string configured for entity repository of type {typeof(TType)}."); } } \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/appsettings.Development.json b/BtDamageResolverClient/src/BlazorServer/appsettings.Release.json similarity index 80% rename from BtDamageResolverClient/src/BlazorServer/appsettings.Development.json rename to BtDamageResolverClient/src/BlazorServer/appsettings.Release.json index b9091509..dea4efec 100644 --- a/BtDamageResolverClient/src/BlazorServer/appsettings.Development.json +++ b/BtDamageResolverClient/src/BlazorServer/appsettings.Release.json @@ -1,3 +1,3 @@ -{ +{ "DetailedErrors": true -} +} \ No newline at end of file diff --git a/BtDamageResolverClient/src/BlazorServer/appsettings.json b/BtDamageResolverClient/src/BlazorServer/appsettings.json index d9d9a9bf..ccdfbb00 100644 --- a/BtDamageResolverClient/src/BlazorServer/appsettings.json +++ b/BtDamageResolverClient/src/BlazorServer/appsettings.json @@ -1,10 +1,17 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } + "AllowedHosts": "*", + "DetailedErrors": true, + "LoggingOptions": { + "LogToConsole": true, + "LogLevel": "Information", + "LogLevelOrleans": "Warning", + "LogLevelMicrosoft": "Information" }, - "AllowedHosts": "*" + "ConnectionStrings": { + "Redis": "redis:6379,password=PASSWORD" + }, + "CompressionOptions": { + "Provider": "Brotli", + "Quality": 4 + } } diff --git a/BtDamageResolverClient/src/BlazorServer/wwwroot/css/Resolver.css b/BtDamageResolverClient/src/BlazorServer/wwwroot/css/Resolver.css index 0bed118c..05333bc4 100644 --- a/BtDamageResolverClient/src/BlazorServer/wwwroot/css/Resolver.css +++ b/BtDamageResolverClient/src/BlazorServer/wwwroot/css/Resolver.css @@ -68,6 +68,7 @@ a, .btn-link { --color_text_hover: #000000; --color_bg_hover_selected: #7080B1; --color_text_hover_selected: #FFFFFF; + --color_bg_clickable_hover: #EAF0FF; --color_bg_disabled: #D4D0C8; --color_text_disabled: #808080; --color_bg_disabled_selected: #8D8D8D; @@ -103,12 +104,6 @@ a, .btn-link { --color_text_status_ammo_normal: #403020; } -.resolver_content { width: 100%; } - -@media only screen and (min-width: 1024px) { - .resolver_content { padding: 1rem; } -} - .resolver_div_errormessage { background: var(--color_bg_error); color: var(--color_text_error); @@ -264,7 +259,7 @@ a, .btn-link { left: 0; height: 25px; width: 25px; - border: 1px solid var(--color_text_selected) !important; + border: 1px solid var(--color_text_selected); } /* On mouse-over, add a grey background color */ @@ -687,11 +682,17 @@ a, .btn-link { color: var(--color_text_error); } +/* Unit card (All Units view): stack name / stats / weapon groups vertically without wrapper rows */ +.resolver_div_unitcard { + display: flex; + flex-direction: column; + align-items: flex-start; +} + .resolver_div_componentblock { display: flex; margin: 0; padding: 0.1rem 0.25rem; - display: flex; } .resolver_div_componentgroup { @@ -739,6 +740,22 @@ a, .btn-link { padding: 0.1rem 0.3rem; } +.resolver_div_componentcell_label { +} + +.resolver_div_componentcell_content { + min-width: 10rem; +} + +.resolver_div_estimate { + padding-left: 0.5rem; +} + +.resolver_div_estimate_header { + margin-left: 0.5rem; + font-weight: bold; +} + .resolver_div_unitid { margin-top: 0.2rem; @@ -759,7 +776,6 @@ a, .btn-link { max-height: inherit; overflow: hidden; padding: 0.5rem 0.5rem; - font-weight: bold; font-family: "Lucida Console"; text-transform: uppercase; font-weight: bold; @@ -957,6 +973,28 @@ a, .btn-link { padding: 0.5rem; } +.resolver_div_damagereport.clickable { + cursor: pointer; + transition: background-color 0.1s ease-in-out; +} + +.resolver_div_damagereport.clickable:hover { + background-color: var(--color_bg_clickable_hover); +} + +.resolver_div_damagereporttoggleheader { + text-transform: uppercase; + letter-spacing: 0.1rem; + padding-bottom: 0.2rem; + margin-bottom: 0.25rem; + border-bottom: 1px solid var(--color_outline_base); +} + +.resolver_div_damagereporttoggleheader .toggleicon { + opacity: 0.6; + font-weight: normal; +} + .resolver_div_damagereport.damageentry { padding: 0.5rem; } .resolver_div_attackerheat { diff --git a/BtDamageResolverClient/src/BlazorServer/wwwroot/js/Resolver.js b/BtDamageResolverClient/src/BlazorServer/wwwroot/js/Resolver.js index fed96d40..cfbbba61 100644 --- a/BtDamageResolverClient/src/BlazorServer/wwwroot/js/Resolver.js +++ b/BtDamageResolverClient/src/BlazorServer/wwwroot/js/Resolver.js @@ -1,12 +1,156 @@ -function ShowTooltip(evt, tooltipId, text) { - let tooltip = document.getElementById(tooltipId); - tooltip.innerHTML = text; - tooltip.style.display = "block"; - tooltip.style.left = evt.pageX + 10 + "px"; - tooltip.style.top = evt.pageY + 10 + "px"; -} - -function HideTooltip(tooltipId) { - var tooltip = document.getElementById(tooltipId); - tooltip.style.display = "none"; -} \ No newline at end of file +// Delegated tooltip handling for elements carrying data-tooltip-id (+ data-tooltip-content). +// A single set of document-level listeners replaces per-element inline onmousemove/onmouseout +// handlers (e.g. the paper doll SVG regions), so the browser no longer parses a handler string +// for every shape. +(function () { + var activeTooltip = null; + var activeTarget = null; + var activeContent = null; + + function findTarget(evt) { + return evt.target instanceof Element ? evt.target.closest("[data-tooltip-id]") : null; + } + + function position(tooltip, evt) { + tooltip.style.left = evt.pageX + 10 + "px"; + tooltip.style.top = evt.pageY + 10 + "px"; + } + + document.addEventListener("mouseover", function (evt) { + var target = findTarget(evt); + if (target === null) { + return; + } + + var tooltip = document.getElementById(target.getAttribute("data-tooltip-id")); + if (tooltip === null) { + return; + } + + activeTarget = target; + activeTooltip = tooltip; + activeContent = target.getAttribute("data-tooltip-content"); + tooltip.innerHTML = activeContent; + tooltip.style.display = "block"; + position(tooltip, evt); + }); + + document.addEventListener("mousemove", function (evt) { + if (activeTooltip === null) { + return; + } + + var content = activeTarget.getAttribute("data-tooltip-content"); + if (content !== activeContent) { + activeContent = content; + activeTooltip.innerHTML = content; + } + + position(activeTooltip, evt); + }); + + document.addEventListener("mouseout", function (evt) { + var target = findTarget(evt); + if (target === null) { + return; + } + + if (evt.relatedTarget instanceof Node && target.contains(evt.relatedTarget)) { + return; + } + + var tooltip = document.getElementById(target.getAttribute("data-tooltip-id")); + if (tooltip !== null) { + tooltip.style.display = "none"; + } + + if (target === activeTarget) { + activeTooltip = null; + activeTarget = null; + activeContent = null; + } + }); +})(); + +// Delegated drag-and-drop visual feedback for ContainerReorderableList. The dragging/drop-over +// highlight classes used to be driven by server state, so every dragenter was a SignalR roundtrip +// (and dragenter bubbles from child elements, multiplying them). Here the visuals are handled +// purely client-side; only dragstart/drop/dragend still roundtrip to perform the actual reorder. +// A single active container is tracked so nested reorderable lists do not clobber each other. +(function () { + var containerSelector = ".resolver_div_reorderablelistcontainer"; + var targetSelector = ".resolver_div_reorderableitem, .resolver_div_reorderablesentinel"; + var sourceSelector = ".resolver_div_reorderableitem, .resolver_div_draghandle"; + var draggingClass = "resolver_div_reorderablelistcontainer--dragging"; + var itemOverClass = "resolver_div_reorderableitem--dropover"; + var sentinelOverClass = "resolver_div_reorderablesentinel--dropover"; + + var activeContainer = null; + + function clearDropOver(container) { + if (container === null) { + return; + } + + var marked = container.querySelectorAll("." + itemOverClass + ", ." + sentinelOverClass); + for (var i = 0; i < marked.length; i++) { + // Skip elements that belong to a nested reorderable list. + if (marked[i].closest(containerSelector) === container) { + marked[i].classList.remove(itemOverClass); + marked[i].classList.remove(sentinelOverClass); + } + } + } + + function endDrag() { + if (activeContainer === null) { + return; + } + + activeContainer.classList.remove(draggingClass); + clearDropOver(activeContainer); + activeContainer = null; + } + + document.addEventListener("dragstart", function (evt) { + if (!(evt.target instanceof Element)) { + return; + } + + var source = evt.target.closest(sourceSelector); + if (source === null) { + return; + } + + var container = source.closest(containerSelector); + if (container === null) { + return; + } + + endDrag(); + activeContainer = container; + container.classList.add(draggingClass); + }, true); + + document.addEventListener("dragenter", function (evt) { + if (activeContainer === null || !(evt.target instanceof Element)) { + return; + } + + var over = evt.target.closest(targetSelector); + if (over === null || over.closest(containerSelector) !== activeContainer) { + clearDropOver(activeContainer); + return; + } + + clearDropOver(activeContainer); + if (over.classList.contains("resolver_div_reorderableitem")) { + over.classList.add(itemOverClass); + } else { + over.classList.add(sentinelOverClass); + } + }, true); + + document.addEventListener("drop", endDrag, true); + document.addEventListener("dragend", endDrag, true); +})(); \ No newline at end of file diff --git a/BtDamageResolverClient/ui_modernization_plan.md b/BtDamageResolverClient/ui_modernization_plan.md deleted file mode 100644 index 14c0f72b..00000000 --- a/BtDamageResolverClient/ui_modernization_plan.md +++ /dev/null @@ -1,250 +0,0 @@ -# UI Modernization Plan: CSS Cleanup & Mobile Responsiveness - -## Problem Statement - -The UI uses a hybrid old-school layout (CSS `display:table` + `float:left`) that: -- Is not mobile-responsive (unit cards and detail panels don't stack on small screens) -- Has significant dead CSS inherited from the Blazor Server template -- Has several CSS bugs and anti-patterns - -The goal is to clean up all dead/buggy CSS, modernize the layout to flexbox, and add proper responsive breakpoints so the UI is usable on a phone. - ---- - -## Findings - -### 1. Dead CSS in `site.css` (~90% unused boilerplate) - -`site.css` is the default Blazor Server template stylesheet. Almost none of it applies to this app: - -| Rule | Status | -|------|--------| -| `.sidebar`, `.sidebar .top-row`, `.sidebar .navbar-brand`, etc. | **Dead** — no sidebar in the app | -| `.top-row`, `.main .top-row`, `.main .top-row > a`, etc. | **Dead** — no `.main` or `.top-row` elements exist | -| `.navbar-toggler` | **Dead** — no navbar | -| `.content { padding-top: 1.1rem; }` | **Dead** — `.content` class never used | -| `@media (min-width: 768px) { app { flex-direction: row; } .sidebar { ... } }` | **Dead** — sidebar layout | -| `@media (max-width: 767.98px) { .main .top-row:not(.auth) { display: none; } }` | **Dead** | -| `app { display: flex; flex-direction: column; }` | **Conflicts** with actual app layout | -| **Keep:** `html, body { font-family... }` | ✓ Used | -| **Keep:** `a, .btn-link { color: ... }` | ✓ Used | -| **Keep:** `#blazor-error-ui { ... }` and `.dismiss { ... }` | ✓ Used in `_Host.cshtml` | -| **Keep:** `.valid.modified`, `.invalid`, `.validation-message` | ✓ Blazor form validation | - -**Action:** Trim `site.css` to just the 5–6 rules that are actually used. - ---- - -### 2. CSS Bugs in `Resolver.css` - -| Bug | Location | Fix | -|-----|----------|-----| -| `background-color: none` | `.resolver_status_transparent` (line 911) | Change to `transparent` | -| `content: '+'` / `content: 'X'` on real elements | `.resolver_button_add`, `.resolver_button_delete`, `.resolver_button_leave` | Remove — `content` only works on `::before`/`::after` | -| Duplicate `.resolver_div_pickeritem` rule | Lines 307–313 and 316–319 | Merge into one | -| Empty rules with no declarations | `.resolver_tr_unitinformation { }`, `.resolver_td_unitinformation_label { }` | Delete | -| `background-color: beige` on `.flexbox-column` | Line 628 | Remove — debugging artifact | -| Outdated vendor prefixes (mostly harmless) | `user-select`, `transform` | Remove `-webkit-`/`-moz-`/`-ms-` variants | - ---- - -### 3. Layout Anti-Patterns - -#### 3a. `display: table` + `display: table-row` hybrid layout - -The main unit card system uses: -``` -.resolver_div_componentlistcontainer { position: relative } -.resolver_div_componentcontainer { display: table; float: left } ← non-wrapping -.resolver_div_componentrow { display: table-row } ← margin/padding ignored! -.resolver_div_componentcell { float: left } ← float inside table-row -``` - -Problems: -- `margin`, `padding`, `border-radius` on `display: table-row` **have no effect** (CSS spec) -- `float: left` on unit cards means they sit side-by-side with no wrapping breakpoints -- Cannot reflow to single-column on phones -- `float: left` on cells inside a table-row creates conflicting layout modes - -**Fix:** Replace with a flexbox layout: -- `resolver_div_componentlistcontainer` → `display: flex; flex-wrap: wrap; gap: ...` -- `resolver_div_componentcontainer` → flex item with appropriate `min-width` / `width` -- `resolver_div_componentrow` → `display: flex; flex-wrap: wrap; align-items: flex-start` -- `resolver_div_componentcell` → flex item (remove float) - -#### 3b. Unit entry detail panels are horizontally floated - -Inside each unit card, `FormUnitEntry` renders three `resolver_div_componentcell` blocks (unit static data table, unit parameters + firing solution table, weapons table) as `float: left`. On a phone, these sit side by side at tiny widths. - -**Fix:** Flex row that wraps to column at a breakpoint (e.g., ≤768px). - -#### 3c. Weapon table has 8 columns, no overflow handling - -`FormWeaponEntry.razor` renders a `` with: target number | state toggle | amount | modifier | weapon name | ammo | ↑↓ buttons | delete button. - -There is no `overflow-x: auto` wrapper, so on small screens the entire page widens. - -**Fix:** Wrap the weapon table in a div with `overflow-x: auto`. - -#### 3d. `float: left` scattered elsewhere - -Other elements using `float: left` that won't cooperate on mobile: -- `.resolver_div_damagereport { float: left }` — damage report sections -- `.resolver_div_imagecontainer { float: left }` — paper doll image -- `.resolver_accordion_indicator { float: left }` — harmless but can be flexbox -- `.resolver_modal_title { float: left }` — modal header -- `.resolver_div_tab { float: left }` — tab buttons - ---- - -### 4. Mobile Responsiveness Gaps - -| Element | Issue | Fix | -|---------|-------|-----| -| Unit cards | Float side-by-side, no wrap | Flex wrap, full-width below ~900px | -| Unit detail panels | Three columns shrink to unreadable | Stack vertically below ~768px | -| Weapon table | 8 columns overflow | `overflow-x: auto` wrapper | -| Paper doll | Fixed 18rem height | Cap with `max-height` or make relative | -| Modal | `width: 50%` — fine on phone, but `margin: 10% auto` wastes space | `width: min(90vw, 640px); margin: 5vh auto` | -| Combobox dropdown | Fixed `width: 16rem` | `width: min(16rem, 90vw)` | -| Picker dropdown | Fixed `max-width: 15rem` | Same treatment | -| Attack log indentation | Fixed `padding-left: 1–3rem` | Acceptable; reduce slightly | - ---- - -### 5. Space Efficiency Issues - -- `resolver_table td/th` has `padding-left: 1rem; padding-right: 1rem` — generous on desktop, wastes space on phones; could be tightened with a mobile breakpoint -- `.resolver_div_componentrow { margin: 1em; padding: 0.5em; border-radius: 1em; }` — these have **no effect** because the element is `display: table-row`; once layout is switched to flexbox, these values should be reconsidered intentionally -- `.resolver_div_accordioncontent { padding: 0; }` and `.resolver_div_tabcontent { padding: 0; }` — fine but redundant -- Accordion sections in `FormGameState` could use `padding` inside their content area for visual separation from the button - ---- - -## Implementation Plan - -### ~~Phase 1 — Remove Dead CSS~~ ✓ Done -`site.css` deleted. Live rules merged into top of `Resolver.css`. Open-iconic import dropped (unused). - -### ~~Phase 2 — Fix CSS Bugs~~ ✓ Done -- `background-color: none` → `transparent` on `.resolver_status_transparent` -- Removed `content` property from `.resolver_button_add/delete/leave` -- Merged duplicate `.resolver_div_pickeritem` rules -- Deleted empty `.resolver_tr_unitinformation` and `.resolver_td_unitinformation_label` -- Removed `background-color: beige` debug artifact from `.flexbox-column` -- Dropped all `-webkit-`/`-moz-`/`-ms-` vendor prefixes - -### Phase 3 — Modernize Container Layout (flexbox) - -Main container layout is done. Remaining floats: - -| Selector | Float | Notes | -|----------|-------|-------| -| `.resolver_div_tab` | `float: left` | Tab buttons — could switch to flex on tabcontainer | -| `.resolver_accordion_indicator` | `float: left` | Expand/collapse indicator — harmless, can be inline-flex | -| `.resolver_modal_title` | `float: left` | Modal header title — paired with `float: right` close button and `clear: both` header | -| `.resolver_div_damagereport` | `float: left` | Damage report sections | -| `.resolver_div_imagecontainer` | `float: left` | Paper doll image | -| `.resolver_style_alignright` | `float: right` | Intentional right-align utility | -| `.button_modal_close` | `float: right` | Intentional — close button in top-right of modal | - -### Phase 4 — Add Responsive Breakpoints - -**Done (already in CSS at `@media (max-width: 50rem)`):** -- Unit name desktop/mobile switch ✓ -- `resolver_div_componentcontainer` → `width: 100%` (units stack vertically) ✓ -- `resolver_div_componentgroup` → `width: 100%` ✓ -- `resolver_div_weaponentrylist` → `overflow-x: auto` ✓ -- Drag handle and reorderable sentinel hidden ✓ - -**Still missing from breakpoint:** -- Modal: still `width: 50%; margin: 10% auto` — should be `width: min(90vw, 640px); margin: 5vh auto` at ≤50rem - -### ~~Phase 5 — Replace Layout Tables with CSS Grid Divs~~ ✓ Done - -**Approach used:** Rather than introducing new CSS form section classes as originally planned, the existing `resolver_div_componentgroup` / `resolver_div_componentrow` / `resolver_div_componentcell` CSS grid classes were reused throughout, with: -- Inline `style` to set custom `grid-template-columns` when the default doesn't fit (e.g. 2-col, 5-col) -- A `tablerows` modifier class added to `resolver_div_componentgroup` for alternating row colors - -**Genuine data display tables (`FormDataDisplay*`) were intentionally kept as ``** — they display tabular data, not layout. - -**The game list (`FormGameList` + `FormGameEntry`) was reverted to `
`** — the converted result did not render well enough to keep. - -#### Status per file - -| File | Status | Notes | -|------|--------|-------| -| `FormWeaponBay.razor` | ✓ Done (prior session) | Uses `resolver_div_weaponentrylist` (8-col subgrid) | -| `FormWeaponEntry.razor` | ✓ Done (prior session) | Emits `resolver_div_componentrow resolver_div_weaponentry` with 8 cells | -| `FormUnitEntry.razor` | ✓ Done (prior session) | Uses `resolver_div_componentgroup` / row / cell throughout | -| `FormServer.razor` | ✓ Done | Converted from table to `resolver_div_componentgroup` / row / cell | -| `ComponentHeatAmmoEstimate.razor` | ✓ Done | Single-column table → plain nested divs | -| `FormDamageInstance.razor` | ✓ Done | 2-col componentgroup with inline grid-template-columns | -| `ComponentWeaponEntry.razor` | ✓ Done | Emits `resolver_div_componentrow resolver_div_weaponentry` with 5 cells (read-only context) | -| `ComponentUnit.razor` | ✓ Done | Rendering issue fixed | -| `FormGameList.razor` | ✗ Reverted to `
` | Converted result didn't render well | -| `FormGameEntry.razor` | ✗ Reverted to `/
` | Reverted together with FormGameList | -| `FormDataDisplayClusterTable.razor` | ✓ Kept as `` | Genuine 2D dynamic matrix | -| `FormDataDisplayWeapon.razor` | ✓ Kept as `
` | Tabular data | -| `FormDataDisplayUnit.razor` | ✓ Kept as `
` | Tabular data | -| `FormDataDisplayPaperDoll.razor` | ✓ Kept as `
` | Tabular data | -| `FormDataDisplayCriticalDamageTable.razor` | ✓ Kept as `
` | Tabular data | - -#### Dead CSS to clean up (now unused after table removals) -`resolver_td_unitinformation_data` — the only remaining dead class. `resolver_table_gamelist` is still used (FormGameList reverted to table). - -### Phase 6 — Visual Block Clarity -- Add a border or subtle background to `resolver_div_componentcontainer` for unit cards (currently alternating colors only) -- Add `gap` between unit cards in the list -- Add `gap` between unit detail cells -- Ensure accordion sections have consistent padding inside their content area - ---- - -## Mobile Responsiveness Fixes (completed outside original plan phases) - -### FormRadio wrapping -`FormRadio.razor` — wrapped all `