Add anger-scaled Sarka hunter waves every ten minutes

This commit is contained in:
2026-10-16 16:36:38 +02:00
parent ae9aaafaae
commit a66aca35bf
14 changed files with 332 additions and 20 deletions
@@ -55,7 +55,7 @@ modded class ERR_GameMode
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase;
array<string> independentFixtures = {"coordinated-ai", "optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "self-epinephrine", "starter-backpack", "speech-download", "bundled-speech", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "roadblocks", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
array<string> independentFixtures = {"coordinated-ai", "optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "self-epinephrine", "starter-backpack", "speech-download", "bundled-speech", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "sarka-hunters", "native-end-active", "native-end-preparing", "generated-sites", "roadblocks", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && independentFixtures.Contains(nativeEndCase)) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
@@ -0,0 +1,94 @@
#ifdef WORKBENCH
modded class ERR_HunterService
{
void ERR_ProbeSarkaDue(float now) { m_SarkaWaves.NextWave = now; }
void ERR_ProbeSarkas(out array<ERR_HunterVehicle> cars)
{
cars.Clear();
foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Sarka) cars.Insert(unit);
}
}
modded class ERR_HQ
{
void ERR_ProbeSarkaBodies(float now)
{
for (int slot = 0; slot < 6; 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.ObservedAt = now; report.Position = "5000 50 5000";
Accept(report, now);
}
}
}
modded class ERR_GameMode
{
protected int m_ERRSarkaStage;
protected int m_ERRSarkaDeadline;
protected bool m_ERRSarkaOrder;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "sarka-hunters") return;
m_ERRSarkaDeadline = System.GetTickCount() + 420000;
GetGame().GetCallqueue().CallLater(ERR_SarkaProbeTick, 1000, true);
}
protected bool ERR_SarkaProbeCheck(bool ok, string detail)
{
if (ok) { Print("ERR SMOKE PASS: sarka " + detail); return true; }
Print("ERR SMOKE FAIL: sarka " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick); return false;
}
protected void ERR_SarkaProbeTick()
{
if (System.GetTickCount() >= m_ERRSarkaDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_SarkaProbeCheck(false, "native spawn/admission/driving deadline"); return; }
if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
DamageManagerComponent damage = DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(false);
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return;
float now = GetWorld().GetWorldTime();
if (m_ERRSarkaStage == 0)
{
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;
// Accelerate only the opt-in wave deadline; production placement/admission run unchanged.
hq.Hunters().ERR_ProbeSarkaDue(now); m_ERRSarkaStage = 1;
}
array<ERR_HunterVehicle> cars = {}; hq.Hunters().ERR_ProbeSarkas(cars);
if (cars.Count() < 2) return;
if (m_ERRSarkaStage == 1)
{
if (!ERR_SarkaProbeCheck(cars.Count() == 2, "two physical cars for two anger points")) return;
array<vector> positions = {player.GetOrigin()};
foreach (ERR_HunterVehicle car : cars)
{
if (!car.Registered) return;
if (!ERR_SarkaProbeCheck(car.Entity.GetPrefabData().GetPrefabName() == ERR_SarkaRules.Prefab() && car.CrewValid(this) && car.Members.Count() == 4 && car.Seats.Count() == 4, "native S105 with four original seated crew")) return;
if (!ERR_SarkaProbeCheck(ERR_SarkaRules.InBand(car.PatrolHome, positions), "actual road spawn 1-1.25 km from player")) return;
if (!ERR_SarkaProbeCheck(hq.Hunters().VehicleRadioCapability(car.Driver) == 2 && car.Group.GetFaction() == EnemyFaction(), "physical vehicle manpack and selected hostile faction")) return;
int medics;
foreach (IEntity member : car.Members)
{
ResourceName profile = member.GetPrefabData().GetPrefabName();
if (profile == ERR_EnemyMedicalCatalog.Crew(this, true)) medics++;
else if (!ERR_SarkaProbeCheck(profile == ERR_EnemyMedicalCatalog.Crew(this, false), "selected special-forces profile")) return;
SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(member.FindComponent(SCR_AICombatComponent));
if (!combat || combat.GetAISkill() != EAISkill.CYLON || !combat.ERR_EnhancedEnemy()) return;
}
if (!ERR_SarkaProbeCheck(medics == 1, "one medic within the four-person team")) return;
}
m_ERRSarkaStage = 2;
}
if (cars[0].Waypoint) m_ERRSarkaOrder = true;
if (!m_ERRSarkaOrder || vector.DistanceXZ(cars[0].PatrolHome, cars[0].Entity.GetOrigin()) < 30) return;
if (!ERR_SarkaProbeCheck(hq.Hunters().Deployed() <= hq.Watch().HunterTarget(), "cars do not spend ordinary hunter allowance")) return;
ERR_SarkaProbeCheck(true, "native patrol order and over 30 metres driven; all crew enhanced");
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick);
Print("ERR SMOKE COMPLETE: sarka hunters, two anger points, two four-person SF cars, road distance, radio and driving");
}
}
#endif
@@ -64,7 +64,7 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH
string historicalFixture;
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "normal-escape-route" && historicalFixture != "crash-start" && historicalFixture != "mounted-ammo" && historicalFixture != "alarm-hearing" && historicalFixture != "optional-site-skip" && historicalFixture != "infantry-combat" && historicalFixture != "site-garrisons" && historicalFixture != "generated-sites" && historicalFixture != "roadblocks" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
if (historicalFixture != "self-epinephrine" && historicalFixture != "starter-backpack" && historicalFixture != "objective-map" && historicalFixture != "speech-download" && historicalFixture != "bundled-speech") m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
if (historicalFixture != "sarka-hunters" && historicalFixture != "self-epinephrine" && historicalFixture != "starter-backpack" && historicalFixture != "objective-map" && historicalFixture != "speech-download" && historicalFixture != "bundled-speech") m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
#endif
m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
if (!m_RuntimeDefinition || !m_RuntimeDefinition.Valid()) { m_Failed = true; return ERR_PrepareResult.FAILED; }
@@ -72,7 +72,7 @@ class ERR_ScenarioComponent : ScriptComponent
// Existing deterministic probes intentionally use authored coordinates.
string fixture;
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "normal-escape-route" && fixture != "crash-start" && fixture != "mounted-ammo" && fixture != "alarm-hearing" && fixture != "optional-site-skip" && fixture != "infantry-combat" && fixture != "site-garrisons" && fixture != "generated-sites" && fixture != "roadblocks" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
if (fixture != "self-epinephrine" && fixture != "starter-backpack" && fixture != "objective-map" && fixture != "speech-download" && fixture != "bundled-speech") foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
if (fixture != "sarka-hunters" && fixture != "self-epinephrine" && fixture != "starter-backpack" && fixture != "objective-map" && fixture != "speech-download" && fixture != "bundled-speech") foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
if (!ERR_ConfigurePatrolHQFixture(fixture)) { m_Failed = true; return ERR_PrepareResult.FAILED; }
#endif
if (m_Progressive && !m_RuntimeDefinition.ConfigurePopulation(m_Definition, mode.Settings().Get("fieldCampCount"), mode.Settings().Get("fieldHospitalCount")))
@@ -1741,6 +1741,7 @@ class ERR_HQ : ERR_CommandService
override void Maintain(float now)
{
m_Hunters.PauseSarkaIfEmpty();
// Aircraft already airborne keep flying even when the server is empty.
m_Recon.Tick(now);
m_PlaneRecon.Tick(now);
@@ -0,0 +1,46 @@
// Wave size is sampled from delivered body-driven aggression every ten minutes.
// Failed placements remain pending in this wave, never accumulate into a backlog.
class ERR_SarkaWaves
{
float NextWave = -1;
float NextAttempt;
int Pending;
int Target;
int Wave;
int Spawned;
void Pause() { NextWave = -1; Pending = 0; Target = 0; }
bool Due(float now, int aggression, bool playing)
{
if (!playing) { Pause(); return false; }
if (NextWave < 0) { NextWave = now + 600000; return false; }
if (now >= NextWave)
{
NextWave = now + 600000; NextAttempt = now;
Target = Math.Clamp(aggression, 0, 30); Pending = Target; Wave++;
}
if (Pending == 0 || now < NextAttempt) return false;
NextAttempt = now + 5000;
return true;
}
void Commit() { if (Pending > 0) { Pending--; Spawned++; } }
}
class ERR_SarkaRules
{
static ResourceName Prefab() { return "{AD80FB47D868C775}Prefabs/Vehicles/Wheeled/S105/S105_green.et"; }
static bool Seats(int drivers, int turrets, int cargo, int total)
{
return drivers == 1 && turrets == 0 && cargo == 3 && total == 4;
}
static bool InBand(vector position, array<vector> players)
{
if (players.IsEmpty() || !ERR_ForceRules.FiniteVector(position)) return false;
float nearest = 1000000;
foreach (vector player : players)
{
if (!ERR_ForceRules.FiniteVector(player)) return false;
nearest = Math.Min(nearest, vector.DistanceXZ(position, player));
}
return nearest >= 1000 && nearest <= 1250;
}
}
@@ -63,6 +63,7 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
float PendingObservedAt = -1;
bool TroopTransport;
bool Motorbike;
bool Sarka;
bool OpeningPatrol;
vector PatrolHome;
bool RadioRequested;
@@ -136,6 +137,87 @@ class ERR_HunterService : ERR_ConvoyService
protected int m_ReserveSpent;
protected float m_NextReserve;
protected ref ERR_HunterVehicle m_PendingReserve;
protected ref ERR_SarkaWaves m_SarkaWaves = new ERR_SarkaWaves();
protected string m_SarkaStatus = "first wave ten minutes after prison escape";
string SarkaStatus()
{
return string.Format(" Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5.", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus);
}
// HQ maintenance runs even while tactical ticks pause in an empty round.
void PauseSarkaIfEmpty()
{
if (m_Stopped || !m_Mode || !m_Mode.Active()) return;
array<IEntity> players = {}; ERR_Actors.Players(players);
if (players.IsEmpty()) m_SarkaWaves.Pause();
}
protected void TickSarka(ERR_HQ hq, float now)
{
// Released crews keep their physical/HQ ownership; discard only wave tracking.
for (int retired = m_Hunters.Count() - 1; retired >= 0; retired--)
if (m_Hunters[retired].Sarka && m_Hunters[retired].Released) m_Hunters.Remove(retired);
array<IEntity> players = {}; ERR_Actors.Players(players);
int previousWave = m_SarkaWaves.Wave;
bool due = m_SarkaWaves.Due(now, hq.Watch().Aggression(), !players.IsEmpty());
if (previousWave != m_SarkaWaves.Wave)
{
m_SarkaStatus = "placing four-person special-forces teams on roads";
if (m_SarkaWaves.Target == 0) m_SarkaStatus = "no aggression; next wave check in ten minutes";
ERR_HQ.Event("SARKA WAVE", string.Format("wave=%1 aggression=%2 cars=%3 identified bodies=%4", m_SarkaWaves.Wave, hq.Watch().Aggression(), m_SarkaWaves.Target, hq.Watch().IdentifiedBodies()));
}
if (!due) return;
int awaiting;
foreach (ERR_HunterVehicle pending : m_Hunters) if (!pending.Released && !pending.Registered) awaiting++;
if (m_Mode.Forces().Count() + awaiting >= 250) { m_SarkaStatus = "waiting for formation budget"; return; }
if (!ERR_EnemyLoadoutCatalog.Available(ERR_EnemyMedicalCatalog.Crew(m_Mode, false)) || !ERR_EnemyLoadoutCatalog.Available(ERR_EnemyMedicalCatalog.Crew(m_Mode, true)))
{ m_SarkaStatus = "selected faction lacks response crew"; return; }
array<vector> positions = {};
foreach (IEntity player : players) positions.Insert(player.GetOrigin());
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
for (int attempt = 0; attempt < 8; attempt++)
{
vector offset = Vector(Math.RandomFloat(-1, 1), 0, Math.RandomFloat(-1, 1));
if (offset.Length() < 0.1) continue;
vector seed = positions[Math.RandomInt(0, positions.Count())] + offset.Normalized() * 1125;
vector position, angles;
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles, 150) || !ERR_SarkaRules.InBand(position, positions) || !placement.Clear(position, angles[0])) continue;
bool occupied;
foreach (ERR_HunterVehicle other : m_Hunters)
if (other.Entity && vector.DistanceXZ(position, other.Entity.GetOrigin()) < 60) occupied = true;
if (occupied) continue;
if (SpawnSarka(now, position, angles))
{
m_SarkaWaves.Commit();
m_SarkaStatus = "four-person teams join HQ hunters; departures spaced five seconds apart";
}
else m_SarkaStatus = "vehicle or crew setup failed; retrying this wave";
return;
}
m_SarkaStatus = "waiting for a clear road 1-1.25 km from the nearest player";
}
protected bool SpawnSarka(float now, vector position, vector angles)
{
ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Sarka = true; unit.TroopTransport = true; unit.OpeningPatrol = true; unit.PatrolHome = position;
unit.Source = "aggression-sarka-" + (m_SarkaWaves.Spawned + 1).ToString();
IEntity spawned = ERR_Actors.Spawn(ERR_SarkaRules.Prefab(), position, angles);
unit.Entity = Vehicle.Cast(spawned);
if (!unit.Entity || !unit.Entity.FindComponent(AICarMovementComponent) || !unit.Entity.FindComponent(SCR_AIVehicleUsageComponent))
{ ERR_Actors.DeleteSpawned(spawned); return false; }
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position));
if (!unit.Group || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{
foreach (IEntity failed : unit.Members) ERR_Actors.DeleteSpawned(failed);
ERR_Actors.DeleteSpawned(unit.Group); ERR_Actors.DeleteSpawned(unit.Entity); return false;
}
m_Mode.Own(unit.Entity); m_Mode.Own(unit.Group);
ERR_Actors.SetFaction(unit.Entity, m_Mode.EnemyFaction());
unit.LaunchedAt = now; unit.Deadline = now + 120000; m_Hunters.Insert(unit);
unit.Recovery = new ERR_HunterCrewRecovery(unit); unit.Recovery.Update(m_Mode, now);
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
movement.SetCruiseSpeed(45);
ERR_HQ.Event("SARKA SPAWN", string.Format("%1 wave=%2 crew=%3 position=%4; four original response soldiers including driver and medic", unit.Source, m_SarkaWaves.Wave, unit.Members.Count(), position));
return true;
}
protected int m_OpeningDistance = 1000;
// Called once when the round's HQ is created; menu edits affect the next HQ.
void ConfigureOpening(int distance)
@@ -275,7 +357,6 @@ class ERR_HunterService : ERR_ConvoyService
if (!m_Mode.Active() || !actor || !entered || !m_Mode.Players()) return;
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
if (!hq || hq.Hunters() != this || !m_Mode.Players().CurrentActor(actor)) return;
if (m_Hunters.Count() > 21) return;
foreach (ERR_HunterVehicle unit : m_Hunters)
{
// Released survivors already have independent HQ ownership. Do not revoke
@@ -304,7 +385,7 @@ class ERR_HunterService : ERR_ConvoyService
int Deployed()
{
int count;
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Motorbike) count++;
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Motorbike && !unit.Sarka) count++;
return count;
}
int ActiveHunters()
@@ -324,7 +405,7 @@ class ERR_HunterService : ERR_ConvoyService
{
int count;
foreach (ERR_HunterVehicle unit : m_Hunters)
if (!unit.Motorbike && !ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) count++;
if (!unit.Motorbike && !unit.Sarka && !ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) count++;
return count;
}
protected ERR_HunterVehicle ReserveSource(ERR_HQ hq, float now)
@@ -332,7 +413,7 @@ class ERR_HunterService : ERR_ConvoyService
if (!ERR_HunterFleetRules.ReserveDue(Deployed(), m_Target, m_ReserveSpent, AvailableToHQ(hq, now), m_NextReserve, now)) return null;
foreach (ERR_HunterVehicle unit : m_Hunters)
{
if (unit.Motorbike || unit.ReserveCovered || now - unit.LaunchedAt < 480000) continue;
if (unit.Motorbike || unit.Sarka || unit.ReserveCovered || now - unit.LaunchedAt < 480000) continue;
if (ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) return unit;
}
return null;
@@ -344,7 +425,7 @@ class ERR_HunterService : ERR_ConvoyService
ERR_HQ.Event("HUNTER RESERVE", string.Format("%1 replaces unavailable patrol %2; reserve=%3/3; next departure in at least 480s; silence is not a confirmed death", unit.Source, m_PendingReserve.Source, m_ReserveSpent));
m_PendingReserve = null;
}
override string Status() { return string.Format("HQ hunting patrols: %1 active including bikes; %2 UAZ/trucks deployed; aggression allowance %3, reserve used %4/3 (15 total maximum). Reserve departures at least eight minutes apart. Motorbikes have a separate allowance.", ActiveHunters(), Deployed(), m_Target, m_ReserveSpent); }
override string Status() { return string.Format("HQ hunting patrols: %1 active including bikes and Sarkas; %2 UAZ/trucks deployed; aggression allowance %3, reserve used %4/3 (15 total maximum). Reserve departures at least eight minutes apart. Motorbikes have a separate allowance.", ActiveHunters(), Deployed(), m_Target, m_ReserveSpent) + SarkaStatus(); }
protected bool StartHunter(ERR_HQ hq, float now)
{
if ((!m_PendingReserve && Deployed() >= m_Target + m_ReserveSpent) || Deployed() >= 15 || m_Mode.Forces().Count() >= 250) return false;
@@ -410,6 +491,10 @@ class ERR_HunterService : ERR_ConvoyService
{
if (!ERR_MotorbikeRules.Seats(drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
}
else if (hunter.Sarka)
{
if (!ERR_SarkaRules.Seats(drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
}
else if (!ERR_HunterFleetRules.Seats(hunter.TroopTransport, drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
unit.Seats.Insert(drivers[0]);
foreach (BaseCompartmentSlot turret : turrets) unit.Seats.Insert(turret);
@@ -653,6 +738,7 @@ class ERR_HunterService : ERR_ConvoyService
}
m_PendingReserve = null;
TickMotorbikes(hq, now);
TickSarka(hq, now);
if (!m_Coordination) m_Coordination = new ERR_HunterCoordination(m_Mode);
m_Coordination.Tick(hq, m_Hunters, now);
foreach (ERR_HunterVehicle unit : m_Hunters)