Gate radio intel behind configurable site discovery; record Workshop 0.0.119
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-21 16:36:22 +02:00
parent ac285b5c97
commit 253dde90cd
43 changed files with 5131 additions and 28 deletions
@@ -198,6 +198,7 @@ class ERR_AdminMenu : ChimeraMenuBase
int divider = entry.IndexOf("=");
if (divider <= 0) continue;
string key = entry.Substring(0, divider);
if (key == "radioIntelProgress") { SetText("RadioIntelProgress", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
if (key == "droneRuntime") { SetText("DroneRuntime", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
if (key == "recommendedBikeRuntime") { SetText("RecommendedBikeRuntime", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
if (key.IndexOf("recommended_") == 0)
@@ -3,7 +3,7 @@ class ERR_AdminMenuFields
{
static array<string> Keys()
{
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "sarkaAngerPerCar", "motorbikeCount", "droneReconCount", "droneCooldownSeconds", "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", "droneReconCount", "droneCooldownSeconds", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
}
static bool Available(string key, bool hq, bool intel)
{
@@ -69,6 +69,7 @@ class ERR_AdminMenuFields
else if (key == "radioDefenceSeconds") prefix = "mission radio";
else if (key == "aiIntelChancePercent") prefix = "intel carrying";
else if (key == "intelRevealChancePercent") prefix = "intel reveal";
else if (key == "radioIntelSiteCount") prefix = "intel radiosites";
else if (key == "startTimeMinutes") prefix = "mission time";
else if (key == "timeMultiplier") prefix = "mission speed";
else if (key == "startDateCode") prefix = "mission date";
@@ -24,17 +24,18 @@ enum ERR_AdminPage
STARTER = 20,
SARKA = 21,
DRONES = 22,
RADIO_INTEL = 23,
}
class ERR_AdminMenuPages
{
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage", "DronesPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka", "OpenDrones"}; }
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage", "DronesPage", "RadioIntelPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka", "OpenDrones", "OpenRadioIntel"}; }
static array<string> MainButtons() { return {"Main_Mission", "Main_Forces", "Main_HQ", "Main_Intel", "Main_Items", "Main_Mods", "Main_Audio", "Main_Status"}; }
static array<string> Sidebars() { return {"Sidebar_Mission", "Sidebar_Forces", "Sidebar_HQ", "Sidebar_Intel", "Sidebar_Items", "Sidebar_Mods", "Sidebar_Audio", "Sidebar_Status"}; }
static int Group(int page)
{
array<int> groups = {0, 2, 7, 5, 1, 0, 3, 7, 0, 4, 6, 1, 1, 1, 2, 5, 4, 4, 4, 2, 4, 1, 2};
array<int> groups = {0, 2, 7, 5, 1, 0, 3, 7, 0, 4, 6, 1, 1, 1, 2, 5, 4, 4, 4, 2, 4, 1, 2, 3};
if (page < 0 || page >= groups.Count()) return -1;
return groups[page];
}
@@ -31,6 +31,7 @@ class ERR_CommandArguments
if (section == "mission" && option == "emptyhours") return "emptyRestartHours";
if (section == "mission" && option == "radio") return "radioDefenceSeconds";
if (section == "intel" && option == "sites") return "showAllSites";
if (section == "intel" && option == "radiosites") return "radioIntelSiteCount";
if (section == "intel" && option == "reveal") return "intelRevealChancePercent";
return "";
}
@@ -197,12 +198,14 @@ class ERR_AdminService
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.";
if (key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent")
return "Set 0..100 percent or Random. Applies to newly equipped enemy soldiers; existing loadouts are not rerolled or replenished.";
if (key == "radioIntelSiteCount") return "Sites required before radio intel: 0..20, default 5; 0 disables. Fully identify distinct non-radio sites; starting reveals do not count. Applies next round. Finding and using radios in person remains unrestricted.";
if (key == "intelRevealChancePercent")
return "Use /esc intel reveal set N (0-100) or random. This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. With all sites shown, the first document always identifies the type. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged.";
return "";
}
protected string RoadblockTiming(string key)
{
if (key == "radioIntelSiteCount") return " for next round";
if (key == "aiDoctrine" || key == "aiShadow" || key == "droneReconCount" || key == "droneCooldownSeconds") return " for next round";
if (key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "fieldCampCount" || key == "fieldHospitalCount" || key == "startWeaponCount" || key == "startMagazineCount" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round";
return "";
@@ -154,6 +154,7 @@ class ERR_AdminSession
if (key == "convoyCount" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile") continue;
if (m_Mode.Settings().Get(key) == -1) values.Insert("resolved_" + key + "=" + m_Mode.Settings().Runtime(key).ToString());
}
if (m_Mode.Discovery()) values.Insert("radioIntelProgress=" + m_Mode.Discovery().RadioIntelStatus());
m_Mode.Admin().Capabilities(values);
ERR_RecommendedAddons.Snapshot(m_Mode, values);
GetGame().ERR_ServerItems().Snapshot(m_Mode, values, m_ItemAddon, m_ItemPage);
@@ -29,7 +29,58 @@ class ERR_DiscoveryService
protected int m_CleanupCursor;
protected float m_CleanupNow;
protected bool m_Stopped;
void ERR_DiscoveryService(ERR_GameMode mode) { m_Mode = mode; }
protected int m_RadioIntelRequired;
protected int m_RadioIntelIdentified;
protected bool m_RadioIntelFinalized;
protected bool m_RadioIntelUnlocked;
void ERR_DiscoveryService(ERR_GameMode mode)
{
m_Mode = mode;
// Freeze the saved setting for this round; menu edits affect the next one.
m_RadioIntelRequired = mode.Settings().Get("radioIntelSiteCount");
m_RadioIntelUnlocked = m_RadioIntelRequired == 0;
}
protected bool CountsForRadioIntel(ERR_LocationComponent location)
{
if (!location || !location.Prepared() || location.Kind() == ERR_LocationKind.RADIO || location.Kind() == ERR_LocationKind.PRISON || location.Kind() == ERR_LocationKind.EXTRACTION) return false;
ERR_SiteService sites = m_Mode.Sites();
ERR_SiteRuntime site;
if (sites) site = sites.Find(location.Id());
return !site || !site.Definition || !site.Definition.RevealOnEscape;
}
protected void RefreshRadioIntel()
{
if (m_Stopped || !m_Mode || !Replication.IsServer()) return;
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
set<string> available = new set<string>();
set<string> identified = new set<string>();
foreach (ERR_LocationComponent location : locations)
{
if (!CountsForRadioIntel(location)) continue;
available.Insert(location.Id());
if (location.Knowledge() == 2) identified.Insert(location.Id());
}
m_RadioIntelIdentified = identified.Count();
// Background generation can still add eligible sites. Finalize only once
// the active world's preparation has finished, including sparse terrains.
if (!m_RadioIntelFinalized && m_Mode.Active() && !m_Mode.WorldPreparing())
{
m_RadioIntelRequired = Math.Min(m_RadioIntelRequired, available.Count());
m_RadioIntelFinalized = true;
}
if (m_RadioIntelIdentified >= m_RadioIntelRequired) m_RadioIntelUnlocked = true;
}
bool RadioIntelAllowed() { RefreshRadioIntel(); return m_RadioIntelUnlocked; }
string RadioIntelStatus()
{
RefreshRadioIntel();
string state = "locked";
if (m_RadioIntelUnlocked) state = "unlocked";
string progress = string.Format("Radio intel %1: %2 / %3 non-radio sites identified this round.", state, m_RadioIntelIdentified, m_RadioIntelRequired);
if (!m_RadioIntelFinalized) progress += " Site preparation is still in progress.";
return progress;
}
// Shared ledger for recovered and administratively retired documents.
bool IsSpent(IEntity folder) { return folder && m_Spent.Contains(folder); }
bool CanMarkSpent(IEntity folder)
@@ -145,10 +196,22 @@ class ERR_DiscoveryService
array<ERR_LocationComponent> eligible = {};
ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent));
string sealedTarget = m_Mode.Evidence().Target(folder);
bool radioAllowed = RadioIntelAllowed();
bool redirectRadio;
if (!radioAllowed && !document && !sealedTarget.IsEmpty())
{
foreach (ERR_LocationComponent target : locations)
if (target && target.Id() == sealedTarget && target.Kind() == ERR_LocationKind.RADIO) redirectRadio = true;
}
foreach (ERR_LocationComponent location : locations)
{
if (!location || location.Knowledge() >= 2) continue;
if (!sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue;
if (!radioAllowed && location.Kind() == ERR_LocationKind.RADIO)
{
if (document && location.Id() == document.TargetId()) m_Mode.ERR_RadioIntelWaiting();
continue;
}
if (!redirectRadio && !sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue;
if (document)
{
if (location.Id() == document.TargetId()) eligible.Insert(location);
@@ -157,7 +220,7 @@ class ERR_DiscoveryService
}
if (eligible.IsEmpty())
{
if (!document && !m_Mode.WorldPreparing() && MarkSpent(folder))
if (!document && radioAllowed && !m_Mode.WorldPreparing() && MarkSpent(folder))
{
QueueCleanup(folder, m_CleanupNow);
if (!mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
@@ -166,6 +229,7 @@ class ERR_DiscoveryService
}
ERR_LocationComponent chosen = eligible.GetRandomElement();
if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Runtime("intelRevealChancePercent"))) continue;
if (redirectRadio) m_Mode.Evidence().Retarget(folder, chosen.Id());
m_Spent.Insert(folder);
PrintFormat("Reintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge());
@@ -775,6 +775,10 @@ modded class ERR_GameMode
ERR_SiteRuntime medical = Sites().Find("everon-medical-1");
if (!ERR_BaseCheck(medical.Location.Knowledge() == 2 && medical.Location.Label() == "Medical stash" && m_ERRMedicalDiscoveries == 1, "conscious nearby player visually discovers named medical stash once")) return;
if (!ERR_BaseCheck(!Sites().CanObserve(m_ERRBasePlayer, Sites().Find("everon-radio-1")), "distant medical-site discovery does not reveal the radio")) return;
// This sparse historical fixture has two eligible sites. Arrange the
// remaining identification before testing document ownership and recovery.
Sites().Find("everon-outpost-1").Location.Discover(0, 100);
if (!ERR_BaseCheck(Discovery().RadioIntelAllowed(), "identified optional sites unlock the historical radio documents")) return;
m_ERRBaseStep = 1;
}
else if (m_ERRBaseStep == 1 || m_ERRBaseStep == 2)
@@ -9,12 +9,13 @@ class ERR_Settings
static array<string> Keys()
{
return {"aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "droneReconCount", "droneCooldownSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "showAllSites", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
return {"aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "droneReconCount", "droneCooldownSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "showAllSites", "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 == "radioIntelSiteCount") return value >= 0 && value <= 20;
if (key == "sarkaAngerPerCar") return value >= 0 && value <= 30;
if (key == "motorbikeCount") return value >= 0 && value <= 6;
if (key == "droneReconCount") return value >= 0 && value <= 3;
@@ -121,6 +122,7 @@ class ERR_Settings
static int AddedDefault(string key)
{
if (key == "aiDoctrine" || key == "aiShadow") return 0;
if (key == "radioIntelSiteCount") return 5;
if (key == "sarkaAngerPerCar") return 3;
if (key == "motorbikeCount") return 2;
if (key == "droneReconCount") return 0;
@@ -186,6 +188,7 @@ class ERR_Settings
m_Values.Set("responseRadiusMetres", 2000);
m_Values.Set("aiIntelChancePercent", 60);
m_Values.Set("intelRevealChancePercent", 15);
m_Values.Set("radioIntelSiteCount", 5);
m_Values.Set("showAllSites", AddedDefault("showAllSites"));
m_Values.Set("radioDefenceSeconds", 180);
m_Values.Set("startTimeMinutes", AddedDefault("startTimeMinutes"));
@@ -13,6 +13,8 @@ class ERR_IntelAdminProvider : ERR_AdminProvider
if (arguments == "intel show")
{
replies.Insert("aiIntelChancePercent=" + mode.Settings().Get("aiIntelChancePercent").ToString());
replies.Insert("radioIntelSiteCount=" + mode.Settings().Get("radioIntelSiteCount").ToString() + " (next round)");
if (mode.Discovery()) replies.Insert(mode.Discovery().RadioIntelStatus());
replies.Insert("showAllSites=" + mode.Settings().Get("showAllSites").ToString());
replies.Insert("intelRevealChancePercent=" + mode.Settings().Get("intelRevealChancePercent").ToString());
return true;
@@ -31,6 +33,7 @@ class ERR_IntelAdminProvider : ERR_AdminProvider
override bool Help(string topic, array<string> replies)
{
if (topic != "intel") return false;
replies.Insert("/esc intel radiosites show|set N|help (0-20; 0 disables); non-radio sites required before radio intel, next round.");
replies.Insert("/esc intel sites show|set on|off; show anonymous site positions and let intel identify their types.");
replies.Insert("/esc intel show | intel carrying show|set N (0-100; alias: intel chance)");
replies.Insert("Carrying changes affect newly registered AI. /esc intel reroll applies to eligible living AI; intel reroll show reports progress.");
@@ -7,6 +7,7 @@ modded class ERR_GameMode
protected float m_ERRIntelMessageAt;
protected float m_ERRIntelExhaustedAt;
protected bool m_ERRIntelDuplicateReported, m_ERRIntelExhaustionReported;
protected bool m_ERRRadioIntelWaitingReported;
protected bool m_ERRIntelFeedbackStopped;
protected int m_ERRIntelMessageSequence;
protected int m_ERRIntelReceivedSequence;
@@ -62,6 +63,15 @@ modded class ERR_GameMode
m_ERRIntelReceivedSequence = sequence;
SCR_ChatComponent.RadioProtocolMessage(message);
}
void ERR_RadioIntelWaiting()
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || m_ERRRadioIntelWaitingReported) return;
m_ERRRadioIntelWaitingReported = true;
string message = "Keep this radio document. Identify more non-radio sites to decipher it; it will be read automatically when ready.";
m_ERRIntelMessageSequence++;
ERR_ReceiveDiscoveryFeedback(m_ERRIntelMessageSequence, message);
Rpc(ERR_ReceiveDiscoveryFeedback, m_ERRIntelMessageSequence, message);
}
void ERR_IntelExhausted(bool duplicate = false)
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active()) return;
@@ -48,16 +48,18 @@ class ERR_IntelEvidence
}
// Spread new folders over unfinished sites. Counts persist after recovery or
// retirement so extra camps/AI do not repeatedly roll the same early targets.
// Existing sealed contents are never rewritten when team knowledge changes.
// Only recovery of a progression-locked radio folder can redirect its contents.
protected string NextTarget()
{
array<ERR_LocationComponent> locations = {};
ERR_GameMode.Get().Locations(locations);
array<ERR_LocationComponent> candidates = {};
int least = -1;
bool radioAllowed = ERR_GameMode.Get().Discovery() && ERR_GameMode.Get().Discovery().RadioIntelAllowed();
foreach (ERR_LocationComponent location : locations)
{
if (!location || location.Knowledge() >= 2 || location.Kind() == ERR_LocationKind.PRISON || location.Kind() == ERR_LocationKind.EXTRACTION) continue;
if (!radioAllowed && location.Kind() == ERR_LocationKind.RADIO) continue;
int assigned = 0;
m_TargetAssignments.Find(location.Id(), assigned);
if (least < 0 || assigned < least) { least = assigned; candidates.Clear(); }
@@ -99,6 +101,20 @@ class ERR_IntelEvidence
foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) return issued.SiteId;
return "";
}
// Update physical document bookkeeping only. Inspect() has already copied
// any queued report into its own Missing list; delivered HQ reports stay intact.
void Retarget(IEntity folder, string target)
{
if (!Replication.IsServer() || m_Stopped || !folder || target.IsEmpty()) return;
bool changed;
foreach (ERR_BodyEvidence record : m_Roster)
foreach (ERR_IssuedIntel issued : record.Issued)
if (issued.Folder == folder && issued.SiteId != target) { issued.SiteId = target; changed = true; }
if (!changed) return;
int assigned;
m_TargetAssignments.Find(target, assigned);
m_TargetAssignments.Set(target, assigned + 1);
}
void Tick(float now)
{
if (m_Stopped) return;