diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b89084..1b10d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Latest published Workshop version: **0.0.112** +## 2026-10-17 — Fewer Šárka hunters + +- Set how many anger points are needed per Šárka in the mission menu. Higher values mean fewer cars. +- Default: one car per three anger points, rounded down. Zero disables new waves. + ## 2026-10-17 — Mission status readability - Fixed clipped status and journal text so long reports can be scrolled. diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c index dd6ef51..bf438a8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c @@ -3,12 +3,12 @@ class ERR_AdminMenuFields { static array Keys() { - return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "sarkaCarsPerAnger", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; + return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; } static bool Available(string key, bool hq, bool intel) { if (key == "aiDoctrine" || key == "aiShadow") return hq; - if (key == "sarkaCarsPerAnger") return hq; + if (key == "sarkaAngerPerCar") return hq; if (key == "aiIntelChancePercent") return intel; if (key == "automaticEquipmentChancePercent" || key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq; return Keys().Contains(key); @@ -46,7 +46,7 @@ class ERR_AdminMenuFields if (key == "aiDoctrine") prefix = "mission doctrine"; else if (key == "aiShadow") prefix = "mission aishadow"; else if (key == "automaticEquipmentChancePercent") prefix = "hq equipment"; - else if (key == "sarkaCarsPerAnger") prefix = "mission sarkacars"; + else if (key == "sarkaAngerPerCar") prefix = "mission sarkapoints"; else if (key == "motorbikeCount") prefix = "mission motorbikes"; else if (key == "planeReconEnabled") prefix = "mission plane"; else if (key == "planeAltitudeMetres") prefix = "mission planealtitude"; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c index 616c6b0..8340b8c 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c @@ -6,7 +6,7 @@ class ERR_CommandArguments { if (section == "mission" && option == "doctrine") return "aiDoctrine"; if (section == "mission" && option == "aishadow") return "aiShadow"; - if (section == "mission" && option == "sarkacars") return "sarkaCarsPerAnger"; + if (section == "mission" && option == "sarkapoints") return "sarkaAngerPerCar"; if (section == "mission" && option == "motorbikes") return "motorbikeCount"; if (section == "mission" && option == "planealtitude") return "planeAltitudeMetres"; if (section == "mission" && option == "planereplacement") return "planeReplacementSeconds"; @@ -171,7 +171,7 @@ class ERR_AdminService if (key == "aiShadow") return "On (1) records coordinated proposals without issuing their orders; Off (0) uses the selected doctrine. Saved for next round."; if (key == "startWeaponCount") return "0..12 pistols in the shared prison/crash supply backpack; default 5. Applies next round."; if (key == "startMagazineCount") return "0..48 spare pistol magazines in the shared prison/crash supply backpack; default 8. Applies next round."; - if (key == "sarkaCarsPerAnger") return "0-5 cars per anger point; 0 disables new waves. Default 1; maximum 30 cars per wave, four special-forces crew per car. Applies to new waves; existing teams remain."; + if (key == "sarkaAngerPerCar") return "1-30 anger points per car; 0 disables new waves. Default 3. Whole thresholds only: 5 means one car at 5 anger, two at 10. Four special-forces crew per car. Applies to new waves; existing teams remain."; if (key == "motorbikeCount") return "0-6; default 2; 0 disabled. Optional WCS Motorbikes, two response specialists per bike. Applies next round; losses are not replaced."; if (key == "planeAltitudeMetres") return "150-500; default 250. Applies next round."; if (key == "planeReplacementSeconds") return "30-1800; default 60. Applies next round."; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c index fc94db5..90a9f0d 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeAdminMenuProbe.c @@ -208,7 +208,7 @@ modded class ERR_AdminMenu if (!text) return false; string report = text.GetText(); array parts = {}; report.Split("Sarka waves:", parts, false); - return report.Contains("HQ hunting patrols:") && parts.Count() == 2 && report.Contains("cars per anger point"); + return report.Contains("HQ hunting patrols:") && parts.Count() == 2 && report.Contains("One car per"); } bool ERR_ProbeJournalVisible() { diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeSarkaProbe.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeSarkaProbe.c index 75ed4f9..a0cdc1b 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeSarkaProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_NativeSarkaProbe.c @@ -1,20 +1,20 @@ #ifdef WORKBENCH modded class ERR_AdminMenu { - bool ERR_SarkaProbeReady() { return m_Ready && m_AvailableFields.Contains("sarkaCarsPerAnger"); } - bool ERR_SarkaProbeSet(int cars) + bool ERR_SarkaProbeReady() { return m_Ready && m_AvailableFields.Contains("sarkaAngerPerCar"); } + bool ERR_SarkaProbeSet(int points) { ShowPage(Navigation().Find("OpenSarka")); - SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("sarkaCarsPerAnger", GetRootWidget()); - SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Apply_sarkaCarsPerAnger", GetRootWidget()); + SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("sarkaAngerPerCar", GetRootWidget()); + SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Apply_sarkaAngerPerCar", GetRootWidget()); if (!edit || !button || !button.IsEnabled()) return false; - edit.SetValue(cars.ToString()); OnButton(button); return true; + edit.SetValue(points.ToString()); OnButton(button); return true; } } modded class ERR_HunterService { void ERR_ProbeSarkaDue(float now) { m_SarkaWaves.NextWave = now; } - bool ERR_ProbeSarkaDisabled() { return m_SarkaWaves.CarsPerAnger == 0 && m_SarkaWaves.Pending == 0; } + bool ERR_ProbeSarkaDisabled() { return m_SarkaWaves.AngerPerCar == 0 && m_SarkaWaves.Pending == 0; } void ERR_ProbeSarkas(out array cars) { cars.Clear(); @@ -25,11 +25,11 @@ modded class ERR_HQ { void ERR_ProbeSarkaBodies(float now) { - for (int slot = 0; slot < 6; slot++) + for (int slot = 0; slot < 24; slot++) { ERR_HQReport report = new ERR_HQReport(); report.TravelMode = "body"; report.MessageId = 200000 + slot; - report.CasualtySourceId = "fixture-sarka-bodies"; report.CasualtyStrength = 6; report.CasualtySlot = slot; + report.CasualtySourceId = "fixture-sarka-bodies"; report.CasualtyStrength = 24; report.CasualtySlot = slot; report.ObservedAt = now; report.Position = "5000 50 5000"; Accept(report, now); } @@ -66,7 +66,7 @@ modded class ERR_GameMode float now = GetWorld().GetWorldTime(); if (m_ERRSarkaStage == 0) { - if (!ERR_SarkaProbeCheck(Settings().Get("sarkaCarsPerAnger") == 1, "default one car per anger point")) return; + if (!ERR_SarkaProbeCheck(Settings().Get("sarkaAngerPerCar") == 3, "default three anger points per car")) return; ERR_AdminMenu.Open(); m_ERRSarkaStage = 9; return; } if (m_ERRSarkaStage == 9) @@ -100,19 +100,19 @@ modded class ERR_GameMode { ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (!menu || !menu.ERR_SarkaProbeReady()) return; - if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(2), "real menu submits two cars per anger point")) return; + if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(2), "real menu submits two anger points per car")) return; m_ERRSarkaStage = 11; return; } if (m_ERRSarkaStage == 11) { - if (Settings().Get("sarkaCarsPerAnger") != 2) return; + if (Settings().Get("sarkaAngerPerCar") != 2) return; ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (!menu || !menu.ERR_SarkaProbeReady()) return; System.MakeScreenshot("$logs:err-sarka-settings"); menu.Close(); ERR_Settings reloaded = new ERR_Settings(); - if (!ERR_SarkaProbeCheck(reloaded.Get("sarkaCarsPerAnger") == 2, "menu owner RPC persists multiplier")) return; + if (!ERR_SarkaProbeCheck(reloaded.Get("sarkaAngerPerCar") == 2, "menu owner RPC persists anger threshold")) return; if (!ERR_SarkaProbeCheck(UnlockPrison(player) || Phase() >= ERR_Phase.RADIO_SEARCH, "prison released")) return; hq.ERR_ProbeSarkaBodies(now); - if (!ERR_SarkaProbeCheck(hq.Watch().Aggression() == 2, "six delivered bodies produce two anger points")) return; + if (!ERR_SarkaProbeCheck(hq.Watch().Aggression() == 8, "24 delivered bodies produce eight anger points")) return; // Accelerate only the opt-in wave deadline; production placement/admission run unchanged. hq.Hunters().ERR_ProbeSarkaDue(now); m_ERRSarkaStage = 1; } @@ -127,16 +127,16 @@ modded class ERR_GameMode } if (m_ERRSarkaStage == 21) { - if (Settings().Get("sarkaCarsPerAnger") != 0 || !hq.Hunters().ERR_ProbeSarkaDisabled()) return; + if (Settings().Get("sarkaAngerPerCar") != 0 || !hq.Hunters().ERR_ProbeSarkaDisabled()) return; foreach (ERR_HunterVehicle retained : cars) if (!ERR_SarkaProbeCheck(retained.Entity && retained.Members.Count() == 4, "disabling retains deployed cars and teams")) return; ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (menu) menu.Close(); GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick); - Print("ERR SMOKE COMPLETE: sarka menu save, multiplier, four SF cars, radio, driving and disable"); return; + Print("ERR SMOKE COMPLETE: sarka menu save, anger threshold, four SF cars, radio, driving and disable"); return; } if (m_ERRSarkaStage == 1) { - if (!ERR_SarkaProbeCheck(cars.Count() == 4, "two cars per anger point produce four physical cars")) return; + if (!ERR_SarkaProbeCheck(cars.Count() == 4, "eight anger at two points per car produces four physical cars")) return; array positions = {player.GetOrigin()}; foreach (ERR_HunterVehicle car : cars) { diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c index 6363ef8..602bd7c 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c @@ -9,13 +9,13 @@ class ERR_Settings static array Keys() { - return {"aiDoctrine", "aiShadow", "sarkaCarsPerAnger", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; + return {"aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; } static bool Valid(string key, int value) { if (key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1; - if (key == "sarkaCarsPerAnger") return value >= 0 && value <= 5; + if (key == "sarkaAngerPerCar") return value >= 0 && value <= 30; if (key == "motorbikeCount") return value >= 0 && value <= 6; if (key == "planeAltitudeMetres") return value >= 150 && value <= 500; if (key == "planeReplacementSeconds") return value >= 30 && value <= 1800; @@ -118,7 +118,7 @@ class ERR_Settings static int AddedDefault(string key) { if (key == "aiDoctrine" || key == "aiShadow") return 0; - if (key == "sarkaCarsPerAnger") return 1; + if (key == "sarkaAngerPerCar") return 3; if (key == "motorbikeCount") return 2; if (key == "planeAltitudeMetres") return 250; if (key == "planeReplacementSeconds") return 60; @@ -154,7 +154,7 @@ class ERR_Settings m_Path = path; m_Values.Set("aiDoctrine", AddedDefault("aiDoctrine")); m_Values.Set("aiShadow", AddedDefault("aiShadow")); - m_Values.Set("sarkaCarsPerAnger", AddedDefault("sarkaCarsPerAnger")); + m_Values.Set("sarkaAngerPerCar", AddedDefault("sarkaAngerPerCar")); m_Values.Set("motorbikeCount", AddedDefault("motorbikeCount")); m_Values.Set("planeAltitudeMetres", AddedDefault("planeAltitudeMetres")); m_Values.Set("planeReplacementSeconds", AddedDefault("planeReplacementSeconds")); @@ -214,6 +214,19 @@ class ERR_Settings values = new map(); foreach (string key : Keys()) { + // The former multiplier cannot express a lower frequency. Keep disabled + // saves disabled; migrate enabled saves to the new three-points-per-car default. + // An explicit new setting always takes precedence over the legacy key. + if (key == "sarkaAngerPerCar" && !json.DoesKeyExist(key) && json.DoesKeyExist("sarkaCarsPerAnger")) + { + float oldCars; + if (!json.ReadValue("sarkaCarsPerAnger", oldCars)) return false; + int oldMultiplier = Math.Round(oldCars); + if (oldCars != oldMultiplier || oldMultiplier < 0 || oldMultiplier > 5) return false; + int points = AddedDefault(key); + if (oldMultiplier == 0) points = 0; + values.Set(key, points); continue; + } // Migrate the former one-in-N setting to the nearest integer percent. if (key == "antiAirChancePercent" && !json.DoesKeyExist(key) && json.DoesKeyExist("stingerChanceOneIn")) { diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterSarkaRules.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterSarkaRules.c index 7af8c76..2b4c109 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterSarkaRules.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterSarkaRules.c @@ -8,22 +8,23 @@ class ERR_SarkaWaves int Target; int Wave; int Spawned; - int CarsPerAnger = 1; - void Configure(int cars) + int AngerPerCar = 3; + void Configure(int points) { - if (CarsPerAnger == cars) return; - CarsPerAnger = cars; - if (cars == 0) Pause(); + if (AngerPerCar == points) return; + AngerPerCar = points; + if (points == 0) Pause(); } void Pause() { NextWave = -1; Pending = 0; Target = 0; } bool Due(float now, int aggression, bool playing) { - if (!playing || CarsPerAnger == 0) { Pause(); return false; } + if (!playing || AngerPerCar == 0) { Pause(); return false; } if (NextWave < 0) { NextWave = now + 600000; return false; } if (now >= NextWave) { NextWave = now + 600000; NextAttempt = now; - Target = Math.Clamp(Math.Clamp(aggression, 0, 30) * CarsPerAnger, 0, 30); Pending = Target; Wave++; + // Whole thresholds only: five points per car means zero at four, one at five. + Target = Math.Clamp(aggression, 0, 30) / AngerPerCar; Pending = Target; Wave++; } if (Pending == 0 || now < NextAttempt) return false; NextAttempt = now + 5000; diff --git a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c index b4e631e..d6498c4 100644 --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c @@ -141,15 +141,17 @@ class ERR_HunterService : ERR_ConvoyService protected string m_SarkaStatus = "waiting for first wave after prison escape"; void ConfigureSarka() { - int cars = m_Mode.Settings().Get("sarkaCarsPerAnger"); - bool changed = cars != m_SarkaWaves.CarsPerAnger; - m_SarkaWaves.Configure(cars); - if (changed) m_SarkaStatus = "saved car count applies to the next wave"; - if (cars == 0) m_SarkaStatus = "new waves disabled; existing teams remain"; + int points = m_Mode.Settings().Get("sarkaAngerPerCar"); + bool changed = points != m_SarkaWaves.AngerPerCar; + m_SarkaWaves.Configure(points); + if (changed) m_SarkaStatus = "saved anger threshold applies to the next wave"; + if (points == 0) m_SarkaStatus = "new waves disabled; existing teams remain"; } string SarkaStatus() { - return string.Format("Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5. %6 cars per anger point (30 per wave maximum).", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus, m_SarkaWaves.CarsPerAnger); + string ratio = "New waves disabled."; + if (m_SarkaWaves.AngerPerCar > 0) ratio = string.Format("One car per %1 anger points (rounded down).", m_SarkaWaves.AngerPerCar); + return string.Format("Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5. %6", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus, ratio); } // HQ maintenance runs even while tactical ticks pause in an empty round. void PauseSarkaIfEmpty() diff --git a/addons/core/UI/layouts/ERR/AdminMenu.layout b/addons/core/UI/layouts/ERR/AdminMenu.layout index 30622c7..8c561b1 100644 --- a/addons/core/UI/layouts/ERR/AdminMenu.layout +++ b/addons/core/UI/layouts/ERR/AdminMenu.layout @@ -1149,11 +1149,11 @@ FrameWidgetClass { "Is Visible" 0 Name "SarkaPage" Slot FrameWidgetSlot { Anchor TextWidgetClass { Name "SarkaHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 32 OffsetBottom -38 } Color 0.9 0.92 0.94 1 Text "Šárka special-forces hunters" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } TextWidgetClass { Name "SarkaHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 50 OffsetTop 50 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -94 } Color 0.9 0.92 0.94 1 Text "Four soldiers per car, including a medic. Anger rises from identified enemy casualties." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Label_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 590 OffsetRight -618 SizeY 32 OffsetBottom -144 } Color 0.9 0.92 0.94 1 Text "Šárkas per anger point (0-5; 0 disables waves)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 108 OffsetTop 108 SizeX 112 OffsetRight -752 SizeY 38 OffsetBottom -146 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 764 OffsetLeft 764 PositionY 108 OffsetTop 108 SizeX 200 OffsetRight -964 SizeY 38 OffsetBottom -146 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 148 OffsetTop 148 SizeX 324 OffsetRight -964 SizeY 24 OffsetBottom -172 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } -TextWidgetClass { Name "Help_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 176 OffsetTop 176 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -202 } Color 0.9 0.92 0.94 1 Text "Default 1; up to 30 cars per wave. Existing teams remain when disabled." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Label_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 590 OffsetRight -618 SizeY 32 OffsetBottom -144 } Color 0.9 0.92 0.94 1 Text "Anger points per Šárka (1-30; 0 disables waves)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 108 OffsetTop 108 SizeX 112 OffsetRight -752 SizeY 38 OffsetBottom -146 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 764 OffsetLeft 764 PositionY 108 OffsetTop 108 SizeX 200 OffsetRight -964 SizeY 38 OffsetBottom -146 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 148 OffsetTop 148 SizeX 324 OffsetRight -964 SizeY 24 OffsetBottom -172 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 176 OffsetTop 176 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -202 } Color 0.9 0.92 0.94 1 Text "Default 3. At 5 points per car: anger 4 = none, 5 = one car, 10 = two." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } TextWidgetClass { Name "SarkaSettingsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 424 OffsetTop 424 SizeX 936 OffsetRight -964 SizeY 52 OffsetBottom -476 } Color 0.9 0.92 0.94 1 Text "Apply saves this setting for new waves. Existing teams remain. Empty sessions do not build up extra waves." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } } } TextWidgetClass { Name "Feedback" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 248 OffsetLeft 248 PositionY 670 OffsetTop 670 SizeX 936 OffsetRight -1184 SizeY 70 OffsetBottom -740 } Color 0.9 0.92 0.94 1 Text "" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } diff --git a/docs/configuration.md b/docs/configuration.md index 8634f44..73add31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,6 +63,21 @@ requested totals instead of preventing mission startup. The admin Status page also shows these totals. This is an interim checkpoint system, independent of the existing HQ-directed road interceptions. +## Šárka special-forces hunters + +Under **Forces → Šárka hunters**, set **Anger points per Šárka** from 1 to 30. +The default is 3. Each wave requests the current anger divided by this value, +rounded down: a value of 5 gives no car below 5 anger, one at 5–9, and two at +10–14. Zero disables new waves; already deployed cars and crews remain. +Each car carries four special-forces soldiers, including a medic. Changes apply +to the next wave; the existing interval and placement rules remain unchanged. + +The saved key is `mission.sarkaAngerPerCar` in `$profile:Escapists.json`. +The command is `/esc mission sarkapoints set N` (also `show` and `help`). +Older `sarkaCarsPerAnger` settings migrate to the new default of 3 when enabled; +an old zero remains disabled. An explicit new key takes precedence. The old +`sarkacars` command is retired to avoid silently reversing its meaning. + ## Mission restarts The admin menu **Mission** page includes two independent settings: diff --git a/tests/test_admin_menu.py b/tests/test_admin_menu.py index a2381eb..a613944 100644 --- a/tests/test_admin_menu.py +++ b/tests/test_admin_menu.py @@ -55,7 +55,7 @@ assert 'Apply_planeReconEnabled' not in names # Saved-state toggle pair, not a assert len(names) == len(set(names)), 'Menu widget names must be unique' for key in keys: assert all(name in names for name in (key, 'Apply_' + key, 'Saved_' + key, 'Help_' + key)) - if key not in ('sarkaCarsPerAnger','emptyRestartHours','aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names + if key not in ('sarkaAngerPerCar','emptyRestartHours','aiDoctrine','aiShadow','motorbikeCount','planeAltitudeMetres','planeReplacementSeconds','planeSectorSeconds','fieldCampCount','fieldHospitalCount','startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres','objectGuardCount','objectGuardEvery','objectGuardHQMode','startWeaponCount','startMagazineCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names else: assert 'Random_startMode' not in names for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'JournalContent', 'JournalScroll', 'PauseJournal', 'LatestJournal', 'Status'): assert name in names diff --git a/tests/test_admin_setting_policy.py b/tests/test_admin_setting_policy.py index e00e73f..ccd5cce 100644 --- a/tests/test_admin_setting_policy.py +++ b/tests/test_admin_setting_policy.py @@ -21,9 +21,9 @@ int main(){ assert(!Valid("emptyRestartHours",-1)&&!Valid("emptyRestartHours",169)); for(std::string key:{"aiDoctrine","aiShadow"}){ assert(Valid(key,0)&&Valid(key,1)); assert(!Valid(key,-1)&&!Valid(key,2)); } assert(CoreSetting("mission","motorbikes")=="motorbikeCount"); - assert(CoreSetting("mission","sarkacars")=="sarkaCarsPerAnger"); - assert(Valid("sarkaCarsPerAnger",0)&&Valid("sarkaCarsPerAnger",1)&&Valid("sarkaCarsPerAnger",5)); - assert(!Valid("sarkaCarsPerAnger",-1)&&!Valid("sarkaCarsPerAnger",6)); + assert(CoreSetting("mission","sarkapoints")=="sarkaAngerPerCar"); + assert(Valid("sarkaAngerPerCar",0)&&Valid("sarkaAngerPerCar",1)&&Valid("sarkaAngerPerCar",30)); + assert(!Valid("sarkaAngerPerCar",-1)&&!Valid("sarkaAngerPerCar",31)); assert(Valid("motorbikeCount",0)&&Valid("motorbikeCount",2)&&Valid("motorbikeCount",6)); assert(!Valid("motorbikeCount",-1)&&!Valid("motorbikeCount",7)); assert(CoreSetting("mission","planealtitude")=="planeAltitudeMetres"); diff --git a/tests/test_convoy_auto_settings.py b/tests/test_convoy_auto_settings.py index aff80ec..1ab9e5d 100644 --- a/tests/test_convoy_auto_settings.py +++ b/tests/test_convoy_auto_settings.py @@ -47,6 +47,24 @@ int main(){ for(float bad:{-1.0f,10001.0f,0.5f}){files["legacy"]=files["old"];files["legacy"]["stingerChanceOneIn"]=bad;assert(!s.Read("legacy",loaded));} files["new"]=files["old"];files["new"]["stingerChanceOneIn"]=15;files["new"]["antiAirChancePercent"]=100; assert(s.Read("new",loaded)&&loaded->Get("antiAirChancePercent")==100&&loaded->Get("antiPersonnelMineChancePercent")==60); + assert(Settings::AddedDefault("sarkaAngerPerCar")==3); + for(int old=0;old<=5;++old){ + files["sarka"]=files["old"];files["sarka"]["sarkaCarsPerAnger"]=old; + assert(s.Read("sarka",loaded)&&loaded->Get("sarkaAngerPerCar")== (old==0?0:3)); + } + for(float bad:{-1.0f,6.0f,0.5f}){ + files["sarka"]=files["old"];files["sarka"]["sarkaCarsPerAnger"]=bad; + assert(!s.Read("sarka",loaded)); + } + for(int points:{0,1,3,5,30}){ + files["sarka"]=files["old"];files["sarka"]["sarkaCarsPerAnger"]=0; + files["sarka"]["sarkaAngerPerCar"]=points; + assert(s.Read("sarka",loaded)&&loaded->Get("sarkaAngerPerCar")==points); + } + for(float bad:{-1.0f,31.0f,0.5f}){ + files["sarka"]=files["old"];files["sarka"]["sarkaAngerPerCar"]=bad; + assert(!s.Read("sarka",loaded)); + } assert(!s.Read("missing",loaded)); } ''') diff --git a/tests/test_sarka_hunters.py b/tests/test_sarka_hunters.py index 07c0a08..1cc59ca 100644 --- a/tests/test_sarka_hunters.py +++ b/tests/test_sarka_hunters.py @@ -21,6 +21,8 @@ struct Waves struct Rules {'''+band+seats+r'''}; int main(){ Waves w{}; + assert(w.AngerPerCar==3); + w.Configure(1); // Preserve the timing/retry checks at the densest allowed rate. assert(!w.Due(100,3,true)&&w.NextWave==600100); assert(!w.Due(600099,3,true)); assert(w.Due(600100,3,true)&&w.Pending==3&&w.Wave==1); @@ -41,16 +43,26 @@ int main(){ Waves configured{}; configured.Configure(2); assert(!configured.Due(0,3,true)); - assert(configured.Due(600000,3,true)&&configured.Target==6); - configured.Commit();assert(configured.Pending==5&&configured.Spawned==1); + assert(configured.Due(600000,5,true)&&configured.Target==2); + configured.Commit();assert(configured.Pending==1&&configured.Spawned==1); configured.Configure(5); // Already sampled wave and deadline stay unchanged. - assert(configured.Pending==5&&configured.NextWave==1200000); - assert(configured.Due(1200000,30,true)&&configured.Target==30); // Keep the formation cap. + assert(configured.Pending==1&&configured.NextWave==1200000); + assert(configured.Due(1200000,30,true)&&configured.Target==6); configured.Configure(0);assert(configured.Pending==0&&configured.Target==0); assert(!configured.Due(9999999,30,true)&&configured.Spawned==1); // No waves; retain deployed count. configured.Configure(2);assert(!configured.Due(10000000,3,true)); assert(!configured.Due(10599999,3,true)); // Re-enable waits normally, no backlog. - assert(configured.Due(10600000,3,true)&&configured.Target==6); + assert(configured.Due(10600000,3,true)&&configured.Target==1); + // All supported divisors, exact thresholds, remainders and clamped anger. + for(int points=1;points<=30;++points){ + for(int anger=-1;anger<=31;++anger){ + Waves threshold{};threshold.Configure(points); + assert(!threshold.Due(0,anger,true)); + int expected=std::clamp(anger,0,30)/points; + assert(threshold.Due(600000,anger,true)==(expected>0)); + assert(threshold.Target==expected&&threshold.Pending==expected); + } + } Array players{{0,10,0}}; assert(!Rules::InBand({999,0,0},players)); assert(Rules::InBand({1000,900,0},players)&&Rules::InBand({1250,0,0},players)); @@ -61,4 +73,4 @@ int main(){ assert(Rules::Seats(1,0,3,4)&&!Rules::Seats(1,1,2,4)&&!Rules::Seats(1,0,4,5)); } ''') -print('PASS: configurable anger multiplier, disable/re-enable, wave cap, staged retries, empty-round reset, no backlog, four seats and nearest-player road distance') +print('PASS: anger per car, all thresholds and remainders, disable/re-enable, wave cap, staged retries, empty-round reset, no backlog, four seats and nearest-player road distance') diff --git a/ui/admin_menu/pages/SarkaPage.layout.in b/ui/admin_menu/pages/SarkaPage.layout.in index 7d585e8..862dbe0 100644 --- a/ui/admin_menu/pages/SarkaPage.layout.in +++ b/ui/admin_menu/pages/SarkaPage.layout.in @@ -2,10 +2,10 @@ FrameWidgetClass { "Is Visible" {{VISIBLE_SarkaPage}} Name "SarkaPage" Slot Fram TextWidgetClass { Name "SarkaHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 32 OffsetBottom -38 } Color {{COLOR_TEXT}} Text "Šárka special-forces hunters" "Font Size" {{SIZE_HEADING}} "Min Font Size" {{SIZE_HEADING}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } TextWidgetClass { Name "SarkaHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 50 OffsetTop 50 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -94 } Color {{COLOR_TEXT}} Text "Four soldiers per car, including a medic. Anger rises from identified enemy casualties." "Font Size" {{SIZE_BODY}} "Min Font Size" {{SIZE_BODY}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } -TextWidgetClass { Name "Label_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 590 OffsetRight -618 SizeY 32 OffsetBottom -144 } Color {{COLOR_TEXT}} Text "Šárkas per anger point (0-5; 0 disables waves)" "Font Size" {{SIZE_LABEL}} "Min Font Size" {{SIZE_LABEL}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 108 OffsetTop 108 SizeX 112 OffsetRight -752 SizeY 38 OffsetBottom -146 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 764 OffsetLeft 764 PositionY 108 OffsetTop 108 SizeX 200 OffsetRight -964 SizeY 38 OffsetBottom -146 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } -TextWidgetClass { Name "Saved_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 148 OffsetTop 148 SizeX 324 OffsetRight -964 SizeY 24 OffsetBottom -172 } Color {{COLOR_TEXT}} Text "Waiting for server" "Font Size" {{SIZE_CAPTION}} "Min Font Size" {{SIZE_CAPTION}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } -TextWidgetClass { Name "Help_sarkaCarsPerAnger" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 176 OffsetTop 176 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -202 } Color {{COLOR_TEXT}} Text "Default 1; up to 30 cars per wave. Existing teams remain when disabled." "Font Size" {{SIZE_HELP}} "Min Font Size" {{SIZE_HELP}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } +TextWidgetClass { Name "Label_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 112 OffsetTop 112 SizeX 590 OffsetRight -618 SizeY 32 OffsetBottom -144 } Color {{COLOR_TEXT}} Text "Anger points per Šárka (1-30; 0 disables waves)" "Font Size" {{SIZE_LABEL}} "Min Font Size" {{SIZE_LABEL}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 108 OffsetTop 108 SizeX 112 OffsetRight -752 SizeY 38 OffsetBottom -146 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 764 OffsetLeft 764 PositionY 108 OffsetTop 108 SizeX 200 OffsetRight -964 SizeY 38 OffsetBottom -146 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 640 OffsetLeft 640 PositionY 148 OffsetTop 148 SizeX 324 OffsetRight -964 SizeY 24 OffsetBottom -172 } Color {{COLOR_TEXT}} Text "Waiting for server" "Font Size" {{SIZE_CAPTION}} "Min Font Size" {{SIZE_CAPTION}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } +TextWidgetClass { Name "Help_sarkaAngerPerCar" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 176 OffsetTop 176 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -202 } Color {{COLOR_TEXT}} Text "Default 3. At 5 points per car: anger 4 = none, 5 = one car, 10 = two." "Font Size" {{SIZE_HELP}} "Min Font Size" {{SIZE_HELP}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } TextWidgetClass { Name "SarkaSettingsHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 424 OffsetTop 424 SizeX 936 OffsetRight -964 SizeY 52 OffsetBottom -476 } Color {{COLOR_TEXT}} Text "Apply saves this setting for new waves. Existing teams remain. Empty sessions do not build up extra waves." "Font Size" {{SIZE_BODY}} "Min Font Size" {{SIZE_BODY}} Wrap 1 FontProperties FontProperties { Font "{{FONT}}" } } } }