--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c @@ -8,12 +8,12 @@ { static array Keys() { - return {"roadblockCount", "civilianTrafficCount", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"}; + return {"hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "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 == "aiIntelChancePercent") return intel; - if (key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq; + if (key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq; return Keys().Contains(key); } // A short journal has no older off-screen lines, even if its slider is zero. @@ -44,7 +44,8 @@ static string Command(string key, string value) { string prefix; - if (key == "roadblockCount") prefix = "mission roadblocks"; + if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance"; + else if (key == "roadblockCount") prefix = "mission roadblocks"; else if (key == "civilianTrafficCount") prefix = "mission traffic"; else if (key == "startMode") prefix = "mission start"; else if (key == "radioDefenceSeconds") prefix = "mission radio"; --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_AdminService.c @@ -4,6 +4,7 @@ { static string CoreSetting(string section, string option) { + if (section == "mission" && option == "hunterdistance") return "hunterSpawnDistanceMetres"; if (section == "mission" && option == "roadblocks") return "roadblockCount"; if (section == "mission" && option == "traffic") return "civilianTrafficCount"; if (section == "mission" && option == "start") return "startMode"; @@ -144,6 +145,7 @@ } protected string SettingHelp(string key) { + if (key == "hunterSpawnDistanceMetres") return "Set 800..3000 metres (default 1000). Applies next round to the opening UAZs and troop truck, on separate sides. Roads allow 250m variation; unsuitable terrain uses safe objective-road fallback."; if (key == "roadblockCount") return "Set 0..12 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped."; if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved."; if (key == "keepWeaponOnUnconscious") return "Set 1 to retain the held firearm during unconsciousness and equip it after recovery; 0 keeps normal dropping. Applies to the next knockout."; @@ -155,7 +157,7 @@ } protected string RoadblockTiming(string key) { - if (key == "roadblockCount") return " for next round"; + if (key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round"; return ""; } protected void SettingCommand(ERR_Settings settings, string key, array parts, array replies) --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c @@ -9,11 +9,12 @@ static array Keys() { - return {"roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"}; + return {"hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "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 == "hunterSpawnDistanceMetres") return value >= 800 && value <= 3000; if (key == "roadblockCount") return value >= 0 && value <= 12; if (key == "civilianTrafficCount") return value >= 0 && value <= 6; if (key == "startMode") return value == 0 || value == 1; @@ -101,6 +102,7 @@ static int AddedDefault(string key) { + if (key == "hunterSpawnDistanceMetres") return 1000; if (key == "roadblockCount") return 4; if (key == "civilianTrafficCount") return 3; if (key == "startMode") return 0; @@ -120,6 +122,7 @@ void ERR_Settings(string path = PATH) { m_Path = path; + m_Values.Set("hunterSpawnDistanceMetres", AddedDefault("hunterSpawnDistanceMetres")); m_Values.Set("roadblockCount", AddedDefault("roadblockCount")); m_Values.Set("civilianTrafficCount", AddedDefault("civilianTrafficCount")); m_Values.Set("startMode", AddedDefault("startMode")); --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HQ.c @@ -156,6 +156,7 @@ m_Mode = mode; m_Equipment = new ERR_EnemyEquipment(mode); m_Hunters = new ERR_HunterService(mode, null); + m_Hunters.ConfigureOpening(mode.Settings().Get("hunterSpawnDistanceMetres")); m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this); m_Towns = new ERR_TownGarrisons(mode); m_Roadblocks = new ERR_Roadblocks(mode); --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterOpeningPlan.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterOpeningPlan.c @@ -1,6 +1,18 @@ // Deployment geometry only. Pursuit destinations always come from delivered HQ reports. class ERR_HunterOpeningRules { + static bool InBand(vector center, vector point, float radius) + { + if (!ERR_ForceRules.FiniteVector(center) || !ERR_ForceRules.FiniteVector(point) || !ERR_ForceRules.Finite(radius) || radius < 800 || radius > 3000) return false; + float distance = vector.DistanceXZ(center, point); + return distance >= Math.Max(600, radius - 250) && distance <= radius + 250; + } + static float ScanRadius(float radius, int ring) + { + if (ring == 1) return radius - 150; + if (ring == 2) return radius + 150; + return radius; + } static bool Spread(vector center, vector first, vector second) { vector a = first - center; a[1] = 0; @@ -30,6 +42,7 @@ class ERR_HunterOpeningPlan { vector Center; + float Radius; ref array Positions = {}; ref array Angles = {}; protected ref array m_Candidates = {}; @@ -37,7 +50,7 @@ protected int m_Scanned; protected bool m_Done; protected float m_Heading; - void ERR_HunterOpeningPlan(vector center) { Center = center; m_Heading = Math.RandomFloat(0, 360); } + void ERR_HunterOpeningPlan(vector center, float radius = 1000) { Center = center; Radius = radius; m_Heading = Math.RandomFloat(0, 360); } bool Ready() { return Positions.Count() == 3; } bool Done() { return m_Done; } static bool AwayFromPlayers(vector position, array players) @@ -55,12 +68,12 @@ int candidate = m_Scanned++; int ring = candidate / 16; int sector = candidate % 16; - float radius = 800 + ring * 450; - float bearing = (m_Heading + sector * 22.5) * Math.DEG2RAD; + float radius = ERR_HunterOpeningRules.ScanRadius(Radius, ring); + float bearing = (m_Heading + sector * 22.5 + ring * 5.625) * Math.DEG2RAD; vector seed = Center + Vector(Math.Sin(bearing) * radius, 0, Math.Cos(bearing) * radius); vector position, angles; if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles, 300)) continue; - if (vector.DistanceXZ(Center, position) < 600 || !AwayFromPlayers(position, players)) continue; + if (!ERR_HunterOpeningRules.InBand(Center, position, Radius) || !AwayFromPlayers(position, players)) continue; if (!ERR_SupplyConvoyRules.Separated(position, m_Candidates, 100) || !placement.Clear(position, angles[0])) continue; m_Candidates.Insert(position); m_Angles.Insert(angles); } @@ -69,12 +82,12 @@ { foreach (int index : selected) { Positions.Insert(m_Candidates[index]); Angles.Insert(m_Angles[index]); } m_Done = true; - ERR_HQ.Event("HUNTER OPENING", "three clear road starts reserved at least 90 degrees apart; center=" + Center.ToString()); + ERR_HQ.Event("HUNTER OPENING", string.Format("two UAZs and one troop truck: target radius=%1m (+/-250m), three clear road starts at least 90 degrees apart; center=%2", Radius, Center)); } else if (m_Scanned == 64) { m_Done = true; - ERR_HQ.Event("HUNTER OPENING FALLBACK", "no three safe, widely separated road starts in 64 candidates; deployment will use checked roads near friendly objectives"); + ERR_HQ.Event("HUNTER OPENING FALLBACK", string.Format("no three safe, widely separated road starts near %1m in 64 candidates; deployment will use checked roads near friendly objectives, possibly farther away", Radius)); } } } --- a/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c +++ b/addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c @@ -19,6 +19,8 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle { bool TroopTransport; + bool OpeningPatrol; + vector PatrolHome; bool RadioRequested; ref ERR_EnemyEquipmentCallback RadioOperation; string Source; @@ -80,6 +82,12 @@ protected float m_NextSpawn; protected float m_NextSummary; protected int m_Target = 3; + protected int m_OpeningDistance = 1000; + // Called once when the round's HQ is created; menu edits affect the next HQ. + void ConfigureOpening(int distance) + { + if (!m_Opening && m_Hunters.IsEmpty() && ERR_Settings.Valid("hunterSpawnDistanceMetres", distance)) m_OpeningDistance = distance; + } protected ref ERR_HunterOpeningPlan m_Opening; protected ref ERR_HunterCoordination m_Coordination; bool Contains(SCR_AIGroup group) @@ -238,6 +246,7 @@ } ERR_HunterVehicle unit = new ERR_HunterVehicle(); unit.TroopTransport = ERR_HunterFleetRules.Transport(m_Hunters.Count()); + unit.OpeningPatrol = m_Hunters.Count() < 3; unit.PatrolHome = position; unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString(); if (unit.TroopTransport) unit.Source = "aggression-truck-" + (m_Hunters.Count() + 1).ToString(); ResourceName vehiclePrefab = ERR_HunterFleetRules.VehiclePrefab(m_Hunters.Count()); @@ -282,6 +291,19 @@ return true; } protected bool Coordinated(vector position) { return m_Coordination && m_Coordination.Covered(position); } + // Before a delivered sighting, opening crews patrol their own deployment + // sector. This uses their fixed starting position, never a live player position. + protected bool OpeningPatrolPoint(ERR_HunterVehicle unit, out vector destination, out vector angles) + { + for (int attempt = 0; attempt < 8; attempt++) + { + vector seed = unit.PatrolHome + Vector(Math.RandomFloat(-300, 300), 0, Math.RandomFloat(-300, 300)); + if (!ERR_SupplyConvoySystem.ApproachDirected(seed, destination, angles, 150)) continue; + if (vector.DistanceXZ(destination, unit.PatrolHome) > 500 || vector.DistanceXZ(destination, unit.Entity.GetOrigin()) < 100) continue; + return true; + } + return false; // Hold/retry rather than sending an opening crew across the island. + } protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now) { array reports = {}; hq.HunterContacts(reports, now); @@ -320,11 +342,19 @@ unit.SourceMessage = 0; if (unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group)) return; bool found; + if (unit.OpeningPatrol && !unit.DeliveredContact) + { + found = OpeningPatrolPoint(unit, destination, angles); + reason = "local patrol around opening sector"; + } + else + { for (int i = 0; i < m_Mode.Sites().Count(); i++) { ERR_SiteRuntime site = m_Mode.Sites().At(Math.RandomInt(0, m_Mode.Sites().Count())); if (!site || !site.Definition || !ERR_ObjectiveDefence.Objective(site.Definition.Kind) || (site.Group && site.Group.GetFaction() != m_Mode.EnemyFaction())) continue; if (ERR_SupplyConvoySystem.ApproachDirected(site.Definition.Position, destination, angles) && vector.DistanceXZ(destination, unit.Entity.GetOrigin()) > 200) { found = true; break; } + } } if (!found) return; } @@ -371,7 +401,7 @@ vector center = m_Mode.PrisonPosition(); vector sum; int count; foreach (IEntity player : openingPlayers) if (player) { sum += player.GetOrigin(); count++; } if (count > 0) center = sum / count; - m_Opening = new ERR_HunterOpeningPlan(center); + m_Opening = new ERR_HunterOpeningPlan(center, m_OpeningDistance); } if (!m_Opening.Done()) { --- a/addons/core/UI/layouts/ERR/AdminMenu.layout +++ b/addons/core/UI/layouts/ERR/AdminMenu.layout @@ -385,47 +385,52 @@ } } TextWidgetClass { - Name "Label_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 384 OffsetTop 384 SizeX 368 OffsetRight -396 SizeY 28 OffsetBottom -412 } + Name "Label_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 384 OffsetTop 384 SizeX 444 OffsetRight -472 SizeY 24 OffsetBottom -408 } Color 0.9 0.92 0.94 1 Text "Anti-personnel mine carriers (0-100%)" "Font Size" 20 "Min Font Size" 20 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } TextWidgetClass { - Name "Saved_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 416 OffsetTop 416 SizeX 936 OffsetRight -964 SizeY 24 OffsetBottom -440 } - Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { - Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" - } - } -TextWidgetClass { - Name "Help_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 440 OffsetTop 440 SizeX 936 OffsetRight -964 SizeY 20 OffsetBottom -460 } - Color 0.72 0.81 0.87 1 Text "New soldiers only: up to two carried AP mines. Mines are not automatically planted." "Font Size" 16 "Min Font Size" 16 Wrap 1 - FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } -} - -ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { - Name "antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 408 OffsetLeft 408 PositionY 384 OffsetTop 384 SizeX 240 OffsetRight -648 SizeY 42 OffsetBottom -426 } - components { - SCR_EditBoxComponent "{547290FFBD5B33E9}" { - m_sLabel "" - } - } - } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Apply_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 660 OffsetLeft 660 PositionY 384 OffsetTop 384 SizeX 112 OffsetRight -772 SizeY 42 OffsetBottom -426 } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Apply" - } - } - } -ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { - Name "Random_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 784 OffsetLeft 784 PositionY 384 OffsetTop 384 SizeX 180 OffsetRight -964 SizeY 42 OffsetBottom -426 } - components { - SCR_ButtonTextComponent "{5548F0143AE99095}" { - m_sText "Random" - } - } - } + Name "Saved_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 452 OffsetTop 452 SizeX 444 OffsetRight -472 SizeY 20 OffsetBottom -472 } + Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 Wrap 1 FontProperties FontProperties { + Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" + } + } +TextWidgetClass { + Name "Help_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 472 OffsetTop 472 SizeX 444 OffsetRight -472 SizeY 14 OffsetBottom -486 } + Color 0.72 0.81 0.87 1 Text "New soldiers only; carried mines are not planted automatically." "Font Size" 12 "Min Font Size" 12 Wrap 1 + FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } +} + +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { + Name "antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 412 OffsetTop 412 SizeX 130 OffsetRight -158 SizeY 38 OffsetBottom -450 } + components { + SCR_EditBoxComponent "{547290FFBD5B33E9}" { + m_sLabel "" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Apply_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 170 OffsetLeft 170 PositionY 412 OffsetTop 412 SizeX 110 OffsetRight -280 SizeY 38 OffsetBottom -450 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Apply" + } + } + } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { + Name "Random_antiPersonnelMineChancePercent" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 292 OffsetLeft 292 PositionY 412 OffsetTop 412 SizeX 180 OffsetRight -472 SizeY 38 OffsetBottom -450 } + components { + SCR_ButtonTextComponent "{5548F0143AE99095}" { + m_sText "Random" + } + } + } +TextWidgetClass { Name "Label_hunterSpawnDistanceMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 384 OffsetTop 384 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -408 } Color 0.9 0.92 0.94 1 Text "Opening patrol distance (800-3000 m)" "Font Size" 20 "Min Font Size" 20 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "hunterSpawnDistanceMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 412 OffsetTop 412 SizeX 130 OffsetRight -638 SizeY 38 OffsetBottom -450 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } } +ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Apply_hunterSpawnDistanceMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 650 OffsetLeft 650 PositionY 412 OffsetTop 412 SizeX 110 OffsetRight -760 SizeY 38 OffsetBottom -450 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Apply" } } } +TextWidgetClass { Name "Saved_hunterSpawnDistanceMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 452 OffsetTop 452 SizeX 456 OffsetRight -964 SizeY 20 OffsetBottom -472 } Color 0.9 0.92 0.94 1 Text "Waiting for saved value" "Font Size" 16 "Min Font Size" 16 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } +TextWidgetClass { Name "Help_hunterSpawnDistanceMetres" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 472 OffsetTop 472 SizeX 456 OffsetRight -964 SizeY 14 OffsetBottom -486 } Color 0.9 0.92 0.94 1 Text "UAZs + truck; next round. Safe terrain fallback may be farther." "Font Size" 12 "Min Font Size" 12 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } } } } FrameWidgetClass { Name "WorldPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1000 SizeY 486 OffsetBottom -646 } { TextWidgetClass { --- a/tests/test_admin_menu.py +++ b/tests/test_admin_menu.py @@ -10,7 +10,7 @@ layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout' keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array Keys(')) settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array Keys(')) -assert len(keys) == 18 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"} +assert len(keys) == 19 and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "automaticEquipmentChancePercent"} assert set(keys) <= set(settings) # Every menu field must have a server setting. # Native layouts place child widget declarations inside an anonymous Children # block. A missing brace can preserve most widget names yet prevent menu load. @@ -48,7 +48,7 @@ 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 ('startMode','civilianTrafficCount','roadblockCount'): assert ('Default_' if key == 'viewDistanceMetres' else 'Random_') + key in names + if key not in ('startMode','civilianTrafficCount','roadblockCount','hunterSpawnDistanceMetres'): 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 @@ -99,7 +99,7 @@ {"intelRevealChancePercent","intel reveal"},{"startTimeMinutes","mission time"}, {"timeMultiplier","mission speed"},{"startDateCode","mission date"}, {"weatherProfile","mission weather"},{"viewDistanceMetres","mission viewdistance"},{"reportIntervalSeconds","hq interval"}, - {"roadblockCount","mission roadblocks"},{"civilianTrafficCount","mission traffic"},{"heliTimerMinutes","hq heli timer"},{"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"},{"antiAirChancePercent","hq aa"},{"antiPersonnelMineChancePercent","hq mines"}}; + {"hunterSpawnDistanceMetres","mission hunterdistance"},{"roadblockCount","mission roadblocks"},{"civilianTrafficCount","mission traffic"},{"heliTimerMinutes","hq heli timer"},{"supportSquadLimit","hq squads"},{"responseRadiusMetres","hq radius"},{"convoyCount","hq convoy count"},{"antiAirChancePercent","hq aa"},{"antiPersonnelMineChancePercent","hq mines"}}; for(auto pair:cases){ assert(Fields::Command(pair.first," 42 ")==pair.second+" set 42"); assert(Fields::Command(pair.first,"Random")==pair.second+" set Random"); @@ -122,7 +122,7 @@ for(string key:{"intelRevealChancePercent","radioDefenceSeconds","startTimeMinutes","timeMultiplier","startDateCode","weatherProfile"}) assert(Fields::Available(key,false,false)); } ''') -print('PASS: actual menu command routing covers all eighteen settings, rejects unsupported keys, gates companions, and matches unique authored widgets') +print('PASS: actual menu command routing covers all nineteen settings, rejects unsupported keys, gates companions, and matches unique authored widgets') assert all(name in names for name in ('KeepWeaponOn', 'KeepWeaponOff', 'Saved_keepWeaponOnUnconscious')) click=body(source,'protected void OnButton(') --- a/tests/test_hunter_opening.py +++ b/tests/test_hunter_opening.py @@ -2,7 +2,8 @@ import re from policy_harness import ROOT, body, run rules_path=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterOpeningPlan.c' -rules='\n'.join(body(rules_path,x) for x in ('static bool Spread(', 'static bool FreshOrder(', 'static bool Select(')) +rules='\n'.join(body(rules_path,x) for x in ('static bool InBand(', 'static float ScanRadius(', 'static bool Spread(', 'static bool FreshOrder(', 'static bool Select(')) +rules=rules.replace('ERR_ForceRules.FiniteVector', 'FiniteVector').replace('ERR_ForceRules.Finite', 'std::isfinite').replace('Math.Max', 'std::max') rules=re.sub(r'\bvector (?=\w)', 'Vec ', rules).replace('vector.', 'Geometry::').replace('array candidates','Array candidates').replace('array selected','Array& selected') p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c' queue=body(p,'protected void QueueDestination(').replace('protected ','').replace('foreach (','for (') @@ -28,6 +29,7 @@ using string=std::string; templatestring Format(string,T...){return "investigate";}void Event(string,string,int){} struct Vec{float x=0,y=0,z=0;float& operator[](int i){return i==0?x:i==1?y:z;}Vec operator-(Vec b){return {x-b.x,y-b.y,z-b.z};}string ToString(){return "position";}}; +bool FiniteVector(Vec v){return std::isfinite(v.x)&&std::isfinite(v.y)&&std::isfinite(v.z);} namespace Geometry{float DistanceXZ(Vec a,Vec b){return std::hypot(a.x-b.x,a.z-b.z);}float Dot(Vec a,Vec b){return a.x*b.x+a.y*b.y+a.z*b.z;}} templatestruct Array:std::vector{using std::vector::vector;int Count(){return this->size();}void Insert(T v){this->push_back(v);}void Clear(){this->clear();}}; struct ERR_HunterOpeningRules{ @@ -37,7 +39,7 @@ struct Entity{Vec GetOrigin(){return {};}}; struct Waypoint{bool complete=false;bool IsWithinCompletionRadius(Group*){return complete;}}; struct Route{}; -struct ERR_HunterVehicle{float NextOrder=99999,LastContact=-1,NextContactOrder=0;Route* Downlink=nullptr;int SourceMessage=0;Vec Destination,ContactPosition,DeliveredContactPosition;bool DeliveredContact=false;string Source="hunter";struct Group* Group=nullptr;struct Entity* Entity=nullptr;struct Waypoint* Waypoint=nullptr;}; +struct ERR_HunterVehicle{bool OpeningPatrol=false,TroopTransport=false;Vec PatrolHome;float NextOrder=99999,LastContact=-1,NextContactOrder=0;Route* Downlink=nullptr;int SourceMessage=0;Vec Destination,ContactPosition,DeliveredContactPosition;bool DeliveredContact=false;string Source="hunter";struct Group* Group=nullptr;struct Entity* Entity=nullptr;struct Waypoint* Waypoint=nullptr;}; struct ERR_HQReport{int Target=1;}; struct ERR_HQBelief{bool actionable=true;float ObservedAt=10,Confidence=1,Radius=50;int MessageId=7;Vec PredictedPosition={1000,0,2000};bool Actionable(){return actionable;}}; struct Memory{ERR_HQBelief belief;ERR_HQBelief* Get(int){return &belief;}}; @@ -48,12 +50,23 @@ namespace Math{int RandomInt(int,int){return 0;}} namespace ERR_ObjectiveDefence{bool Objective(int){return true;}} namespace ERR_SupplyConvoySystem{bool available=true;int calls=0;bool ApproachDirected(Vec p,Vec& dst,Vec& angles,float radius=500){calls++;dst=p;return available;}} -struct Service{bool coordinated=false;bool Coordinated(Vec){return coordinated;}Mode mode;Mode* m_Mode=&mode; +struct Service{int localCalls=0;bool localRoad=true;bool OpeningPatrolPoint(ERR_HunterVehicle*,Vec& point,Vec&){localCalls++;point={800,0,0};return localRoad;}bool coordinated=false;bool Coordinated(Vec){return coordinated;}Mode mode;Mode* m_Mode=&mode; '''+queue+r''' }; Vec polar(float degrees,float radius=1000){return {std::sin(degrees*3.14159265f/180)*radius,0,std::cos(degrees*3.14159265f/180)*radius};} int main(){ Array chosen;Vec center; + assert(ERR_HunterOpeningRules::InBand(center,polar(0,750),1000)); + assert(ERR_HunterOpeningRules::InBand(center,polar(0,1250),1000)); + assert(!ERR_HunterOpeningRules::InBand(center,polar(0,749),1000)); + assert(!ERR_HunterOpeningRules::InBand(center,polar(0,1251),1000)); + assert(!ERR_HunterOpeningRules::InBand(center,polar(0,599),800)); + assert(ERR_HunterOpeningRules::InBand(center,polar(0,600),800)); + assert(!ERR_HunterOpeningRules::InBand(center,polar(0,1000),0)); + assert(ERR_HunterOpeningRules::ScanRadius(1000,0)==1000); + assert(ERR_HunterOpeningRules::ScanRadius(1000,1)==850); + assert(ERR_HunterOpeningRules::ScanRadius(1000,2)==1150); + assert(ERR_HunterOpeningRules::ScanRadius(1000,3)==1000); assert(ERR_HunterOpeningRules::Select(center,{polar(0),polar(120),polar(240)},chosen)&&chosen.Count()==3); assert(!ERR_HunterOpeningRules::Select(center,{polar(0),polar(5),polar(10)},chosen)&&chosen.empty()); assert(!ERR_HunterOpeningRules::Spread(center,polar(359),polar(1))); @@ -63,6 +76,15 @@ assert(!ERR_HunterOpeningRules::FreshOrder(10,10,100,0,false)); assert(!ERR_HunterOpeningRules::FreshOrder(11,10,100,101,false)); assert(!ERR_HunterOpeningRules::FreshOrder(11,10,100,0,true)); + // Both opening vehicle types use a local patrol until a delivered contact. + for(bool transport:{false,true}){ + Service opening;ERR_HQ command;ERR_HunterVehicle crew;crew.OpeningPatrol=true;crew.TroopTransport=transport;crew.NextOrder=0; + opening.QueueDestination(&crew,&command,100);assert(opening.localCalls==1&&command.requests==1&&crew.SourceMessage==0&&crew.Destination.x==800); + crew.Downlink=nullptr;crew.NextOrder=0;opening.localRoad=false; + opening.QueueDestination(&crew,&command,200);assert(command.requests==1); // unsafe/absent local road holds, no distant fallback + crew.NextOrder=0;command.contact=true; + opening.QueueDestination(&crew,&command,300);assert(command.requests==2&&crew.SourceMessage==7&&crew.Destination.x==1000); + } Service service;ERR_HQ hq;ERR_HunterVehicle unit;Route patrol;unit.Downlink=&patrol; service.QueueDestination(&unit,&hq,100);assert(unit.Downlink==&patrol&&hq.requests==0); // no magical position hq.contact=true;service.QueueDestination(&unit,&hq,100); --- a/tests/test_start_settings.py +++ b/tests/test_start_settings.py @@ -56,6 +56,12 @@ disk["old"]={{"reportIntervalSeconds",10},{"supportSquadLimit",4},{"responseRadiusMetres",2000},{"aiIntelChancePercent",60},{"intelRevealChancePercent",15},{"radioDefenceSeconds",180}}; ERR_Settings settings;assert(settings.Read("old",settings.m_Values)&&settings.Get("startMode")==0); for(float bad:{-1.f,-2.f,2.f,.5f}){disk["bad"]=disk["old"];disk["bad"]["startMode"]=bad;Values ignored;assert(!settings.Read("bad",ignored));} + assert(settings.Get("hunterSpawnDistanceMetres")==1000); + assert(!ERR_Settings::Randomizable("hunterSpawnDistanceMetres")); + assert(!settings.Set("hunterSpawnDistanceMetres",799)&&!settings.Set("hunterSpawnDistanceMetres",3001)); + assert(settings.Set("hunterSpawnDistanceMetres",1800)); + ERR_Settings savedRadius;assert(savedRadius.Read("settings",savedRadius.m_Values)&&savedRadius.Get("hunterSpawnDistanceMetres")==1800); + failSave=true;assert(!settings.Set("hunterSpawnDistanceMetres",1000)&&settings.Get("hunterSpawnDistanceMetres")==1800);failSave=false; Mode mode{&settings,0};Admin admin;Array replies; assert(!admin.StartCommand(&mode,{"mission","radio","show"},replies)); assert(admin.StartCommand(&mode,{"mission","start","set","crash"},replies));