Integrate owned Intel rerolls, discovery feedback and fair HQ reports

This commit is contained in:
2026-09-19 23:00:02 +02:00
parent 6cf989a060
commit 27989b85a3
46 changed files with 5756 additions and 15 deletions
@@ -0,0 +1,93 @@
// Adapts the owned Intel addon's team discovery messages to successor events.
// Core already owns the local phase briefing; this never hides or replaces it.
modded class ERR_GameMode
{
protected ref map<ERR_LocationComponent, int> m_ERRIntelSeen = new map<ERR_LocationComponent, int>();
protected ref map<ERR_LocationComponent, int> m_ERRIntelPending = new map<ERR_LocationComponent, int>();
protected float m_ERRIntelMessageAt;
protected bool m_ERRIntelFeedbackStopped;
protected int m_ERRIntelMessageSequence;
protected int m_ERRIntelReceivedSequence;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (SCR_Global.IsEditMode() || !Replication.IsServer()) return;
OnLocationDiscovered().Insert(ERR_QueueDiscoveryFeedback);
OnTick().Insert(ERR_FlushDiscoveryFeedback);
OnStop().Insert(ERR_StopDiscoveryFeedback);
}
protected void ERR_QueueDiscoveryFeedback(ERR_LocationComponent location, int previous, int current)
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || Phase() == ERR_Phase.PREPARING || !location) return;
// Initial prison/cache setup and automatic extraction disclosure are already
// explained by Core. A rough clue must never disclose a site's name or kind.
if (location.Kind() == ERR_LocationKind.PRISON || location.Kind() == ERR_LocationKind.EXTRACTION) return;
if (previous < 0 || current <= previous || current > 2 || current != location.Knowledge()) return;
int seen;
if (m_ERRIntelSeen.Find(location, seen) && current <= seen) return;
if (!m_ERRIntelSeen.Contains(location) && m_ERRIntelSeen.Count() >= 64) return;
m_ERRIntelSeen.Set(location, current);
if (m_ERRIntelPending.IsEmpty())
m_ERRIntelMessageAt = Math.Max(m_ERRIntelMessageAt, GetWorld().GetWorldTime() + 1500);
// Several folders recovered in one inventory scan become one final update.
m_ERRIntelPending.Set(location, current);
}
protected void ERR_FlushDiscoveryFeedback(float now)
{
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || m_ERRIntelPending.IsEmpty() || now < m_ERRIntelMessageAt) return;
int identified;
int rough;
string label;
foreach (ERR_LocationComponent location, int knowledge : m_ERRIntelPending)
{
if (!location) continue;
if (knowledge == 2)
{
identified++;
label = location.Label();
}
else rough++;
}
m_ERRIntelPending.Clear();
m_ERRIntelMessageAt = now + 5000;
string message = ERR_IntelDiscoveryText.Message(identified, rough, label);
if (message.IsEmpty()) return;
// The listen host and remote recipients share the same per-round sequence.
// Local delivery is explicit and cannot duplicate a transport callback.
m_ERRIntelMessageSequence++;
ERR_ReceiveDiscoveryFeedback(m_ERRIntelMessageSequence, message);
Rpc(ERR_ReceiveDiscoveryFeedback, m_ERRIntelMessageSequence, message);
}
[RplRpc(RplChannel.Reliable, RplRcver.Broadcast)]
protected void ERR_ReceiveDiscoveryFeedback(int sequence, string message)
{
if (!GetGame() || this != ERR_GameMode.Get() || m_ERRIntelFeedbackStopped || !Active() || !GetGame().GetPlayerController() || SCR_Global.IsEditMode() || message.IsEmpty() || sequence <= m_ERRIntelReceivedSequence) return;
m_ERRIntelReceivedSequence = sequence;
SCR_ChatComponent.RadioProtocolMessage(message);
}
protected void ERR_StopDiscoveryFeedback()
{
m_ERRIntelFeedbackStopped = true;
m_ERRIntelPending.Clear();
m_ERRIntelSeen.Clear();
m_ERRIntelMessageAt = 0;
OnLocationDiscovered().Remove(ERR_QueueDiscoveryFeedback);
OnTick().Remove(ERR_FlushDiscoveryFeedback);
}
}
class ERR_IntelDiscoveryText
{
static string Message(int identified, int rough, string label)
{
if (identified + rough == 0) return "";
if (identified == 0 && rough == 1) return "A new 500 m search area is marked on your field map. Recover another folder to identify the location.";
if (identified == 1 && rough == 0)
{
if (label.IsEmpty()) label = "A location";
return label + " identified. Check your field map.";
}
return string.Format("Field map updated: %1 identified locations and %2 search areas. Open a carried map to review them.", identified, rough);
}
}
@@ -0,0 +1,173 @@
// Adapted from the owner's Escapists Intel reroll: snapshot the cohort/chance,
// wait for accepted native operations, and process a bounded amount each tick.
class ERR_IntelRerollEntry
{
ref ERR_IntelInsertCallback Operation;
IEntity Actor;
IEntity Removing;
int Attempts;
string WaitingFor = "inventory";
bool GiveIntel;
bool Inserting;
bool Accepted;
bool Done;
}
class ERR_IntelReroll
{
protected ERR_GameMode m_Mode;
protected ERR_IntelService m_Service;
protected ref array<ref ERR_IntelRerollEntry> m_Entries = {};
protected int m_Percent;
protected int m_Next;
protected int m_Completed;
protected int m_Failed;
protected int m_Skipped;
protected bool m_Cancelled;
void ERR_IntelReroll(ERR_GameMode mode, ERR_IntelService service, int percent)
{
m_Mode = mode;
m_Service = service;
m_Percent = percent;
}
void Add(IEntity actor)
{
ERR_IntelRerollEntry entry = new ERR_IntelRerollEntry();
entry.Actor = actor;
entry.GiveIntel = Math.RandomInt(0, 100) < m_Percent;
m_Entries.Insert(entry);
}
bool Running() { return !m_Cancelled && m_Completed < m_Entries.Count(); }
void Tick()
{
if (!Replication.IsServer() || !Running()) return;
// Round-robin visits keep delayed inventory operations from starving others.
int budget = Math.Min(8, m_Entries.Count());
while (budget-- > 0)
{
if (m_Next >= m_Entries.Count()) m_Next = 0;
ERR_IntelRerollEntry entry = m_Entries[m_Next++];
if (!entry.Done) Process(entry);
}
}
protected void Finish(ERR_IntelRerollEntry entry, bool failed = false, bool skipped = false)
{
entry.Done = true;
entry.Removing = null;
m_Completed++;
if (failed) m_Failed++;
if (skipped) m_Skipped++;
}
protected void Fail(ERR_IntelRerollEntry entry, string reason)
{
Print(string.Format("Rereintroduced [intel]: reroll failed reason=%1 actor=%2 attempts=%3", reason, entry.Actor.GetID(), entry.Attempts), LogLevel.WARNING);
Finish(entry, true);
}
protected void Process(ERR_IntelRerollEntry entry)
{
if (!ERR_Actors.Alive(entry.Actor) || ERR_Actors.Player(entry.Actor))
{
if (entry.Accepted) m_Service.Unsettled(entry.Actor);
Finish(entry, false, true);
return;
}
entry.Attempts++;
if (entry.Attempts > 30)
{
// A timed-out accepted insertion can still finish inside the engine.
// Keep its actor quarantined for the remainder of this round.
if (entry.Accepted) m_Service.Unsettled(entry.Actor);
Fail(entry, "timeout-" + entry.WaitingFor);
return;
}
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(entry.Actor.FindComponent(SCR_InventoryStorageManagerComponent));
if (!inventory) { entry.WaitingFor = "inventory"; return; }
array<IEntity> folders = {};
ERR_DiscoveryService.Folders(inventory, folders);
if (m_Service.HasPendingInsertion(entry.Actor)) { entry.WaitingFor = "original-insertion"; return; }
if (!m_Service.Settled(entry.Actor)) { entry.WaitingFor = "quarantined-insertion"; return; }
if (!entry.Inserting)
{
if (entry.Removing)
{
if (folders.Contains(entry.Removing)) { entry.WaitingFor = "native-removal"; return; }
InventoryItemComponent item = InventoryItemComponent.Cast(entry.Removing.FindComponent(InventoryItemComponent));
// It may have transferred to another inventory meanwhile. Preserve it
// and decline the replacement rather than racing its new owner.
if (!item || item.GetParentSlot() || entry.Removing.GetParent())
{
Finish(entry, false, true);
return;
}
entry.Removing = null;
}
foreach (IEntity known : folders)
{
// Never reroll mission clue documents or recovered/spent folders.
if (m_Mode.Discovery().IsSpent(known) || known.FindComponent(ERR_DocumentComponent))
{
Finish(entry, false, true);
return;
}
}
if (!folders.IsEmpty())
{
IEntity folder = folders[0];
// Check capacity before mutation; rejected removal leaves usable intel.
if (!m_Mode.Discovery().CanMarkSpent(folder)) { Fail(entry, "spent-capacity"); return; }
// Busy native inventory may reject a request transiently. Retry within
// the existing phase budget without spending or owning the folder.
if (!inventory.TryRemoveItemFromInventory(folder)) { entry.WaitingFor = "removal-refused"; return; }
m_Mode.Discovery().MarkSpent(folder);
m_Mode.Own(folder);
entry.Removing = folder;
entry.WaitingFor = "native-removal";
return;
}
entry.Inserting = true;
entry.Attempts = 0;
if (!entry.GiveIntel) { Finish(entry); return; }
}
if (entry.Accepted)
{
entry.WaitingFor = "native-insertion";
if (entry.Operation && entry.Operation.Finished)
{
if (entry.Operation.Succeeded) Finish(entry);
else Fail(entry, "insertion-callback");
}
return;
}
if (!folders.IsEmpty()) { Finish(entry); return; }
entry.Operation = new ERR_IntelInsertCallback();
entry.Accepted = inventory.TrySpawnPrefabToStorage(ERR_Resources.IntelFolder(), purpose: EStoragePurpose.PURPOSE_ANY, cb: entry.Operation);
entry.WaitingFor = "insertion-refused";
if (entry.Accepted) entry.WaitingFor = "native-insertion";
}
string Status()
{
string state = "complete";
if (Running()) state = "running";
if (m_Cancelled) state = "cancelled";
return string.Format("Intel reroll %1 at %2 percent: %3/%4 AI processed, %5 failed, %6 skipped.", state, m_Percent, m_Completed, m_Entries.Count(), m_Failed, m_Skipped);
}
void Stop()
{
m_Cancelled = true;
foreach (ERR_IntelRerollEntry entry : m_Entries)
{
entry.Actor = null;
entry.Removing = null;
}
}
}
@@ -1,5 +1,19 @@
// Native completion is separate from inventory contents: an unrelated transferred
// folder must never acknowledge a pending spawn and permit a duplicate request.
class ERR_IntelInsertCallback : ScriptedInventoryOperationCallback
{
bool Finished;
bool Succeeded;
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
protected override void OnComplete() { Finish(true); }
protected override void OnFailed() { Finish(false); }
protected override void OnAllComplete() { Finish(true); }
protected override void OnAllFailed() { Finish(false); }
}
class ERR_IntelRequest
{
ref ERR_IntelInsertCallback Operation;
IEntity Actor;
int Attempts;
bool Accepted;
@@ -11,11 +25,14 @@ class ERR_IntelService
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_IntelRequest> m_Requests = {};
protected ref set<IEntity> m_Rolled = new set<IEntity>();
protected ref set<IEntity> m_Unsettled = new set<IEntity>();
protected ref ERR_IntelReroll m_Reroll;
protected bool m_Stopped;
void ERR_IntelService(ERR_GameMode mode) { m_Mode = mode; }
void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !group) return;
if (!Replication.IsServer() || m_Stopped || !group) return;
array<AIAgent> members = {};
group.GetAgents(members);
foreach (AIAgent member : members)
@@ -35,28 +52,85 @@ class ERR_IntelService
void Tick(float now)
{
if (!Replication.IsServer()) return;
if (!Replication.IsServer() || m_Stopped) return;
int budget = 8;
for (int i = m_Requests.Count() - 1; i >= 0 && budget > 0; i--)
{
budget--;
ERR_IntelRequest request = m_Requests[i];
if (!ERR_Actors.Alive(request.Actor) || ERR_Actors.Player(request.Actor)) { m_Requests.Remove(i); continue; }
if (!ERR_Actors.Alive(request.Actor) || ERR_Actors.Player(request.Actor))
{
if (request.Accepted) Unsettled(request.Actor);
m_Requests.Remove(i); continue;
}
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(request.Actor.FindComponent(SCR_InventoryStorageManagerComponent));
array<IEntity> folders = {};
if (inventory) ERR_DiscoveryService.Folders(inventory, folders);
if (!folders.IsEmpty()) { m_Requests.Remove(i); continue; }
if (request.Accepted && request.Operation && request.Operation.Finished)
{
m_Requests.Remove(i); continue;
}
if (!request.Accepted && !folders.IsEmpty()) { m_Requests.Remove(i); continue; }
request.Attempts++;
if (request.Attempts > 30)
{
Print("Rereintroduced [intel]: bounded assignment failed", LogLevel.WARNING);
if (request.Accepted) Unsettled(request.Actor);
m_Requests.Remove(i); continue;
}
if (inventory && !request.Accepted)
request.Accepted = inventory.TrySpawnPrefabToStorage(ERR_Resources.IntelFolder(), purpose: EStoragePurpose.PURPOSE_ANY);
{
request.Operation = new ERR_IntelInsertCallback();
request.Accepted = inventory.TrySpawnPrefabToStorage(ERR_Resources.IntelFolder(), purpose: EStoragePurpose.PURPOSE_ANY, cb: request.Operation);
}
}
if (m_Reroll) m_Reroll.Tick();
}
// Only the successor's registered enemy cohort is rerolled. Other AI, newly
// registered groups and documents already carried away are outside this batch.
bool BeginReroll()
{
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active() || !m_Mode.Discovery() || (m_Reroll && m_Reroll.Running())) return false;
m_Reroll = new ERR_IntelReroll(m_Mode, this, m_Mode.Settings().Get("aiIntelChancePercent"));
set<IEntity> cohort = new set<IEntity>();
foreach (IEntity actor : m_Rolled)
{
if (!ERR_Actors.Alive(actor) || ERR_Actors.Player(actor)) continue;
cohort.Insert(actor);
m_Reroll.Add(actor);
}
for (int i = m_Requests.Count() - 1; i >= 0; i--)
{
ERR_IntelRequest request = m_Requests[i];
// Native accepted spawns cannot be cancelled; retain their observation.
if (cohort.Contains(request.Actor) && !request.Accepted) m_Requests.Remove(i);
}
return true;
}
bool HasPendingInsertion(IEntity actor)
{
foreach (ERR_IntelRequest request : m_Requests)
if (request.Actor == actor && request.Accepted) return true;
return false;
}
void Unsettled(IEntity actor) { if (actor) m_Unsettled.Insert(actor); }
bool Settled(IEntity actor)
{
// Unknown native completion remains quarantined until round teardown.
// Seeing some folder is not proof that this particular spawn completed.
return !m_Unsettled.Contains(actor);
}
bool RerollRunning() { return m_Reroll && m_Reroll.Running(); }
string RerollStatus()
{
if (!m_Reroll) return "No intel reroll has been started this mission.";
return m_Reroll.Status();
}
#ifdef WORKBENCH
// Observation only: a negative carrying decision still counts as its one roll.
@@ -66,12 +140,20 @@ class ERR_IntelService
}
#endif
void Stop() { m_Requests.Clear(); m_Rolled.Clear(); }
void Stop()
{
m_Stopped = true;
if (m_Reroll) m_Reroll.Stop();
m_Requests.Clear();
m_Rolled.Clear();
m_Unsettled.Clear();
}
}
modded class ERR_GameMode
{
protected ref ERR_IntelService m_Intel;
ERR_IntelService Intel() { return m_Intel; }
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);