Make the base escape mission independent and add native objectives

This commit is contained in:
2026-09-19 02:03:52 +02:00
parent 7a507a7371
commit 92fd23f274
54 changed files with 5271 additions and 52 deletions
+11
View File
@@ -0,0 +1,11 @@
SCR_MissionHeader {
World "{B6C516F86302C679}Worlds/ERR_Everon.ent"
SystemsConfig "{C50579A2EC48E8EF}Configs/Systems/MissionSystems.conf"
m_sName "Rereintroduced: Everon Base (development)"
m_sAuthor "Rereintroduced contributors"
m_sDescription "Independent base escape mission using vanilla Reforger. Companion addons optional. Development scenario."
m_sGameMode "Escape"
m_iPlayerCount 12
m_eSaveTypes 0
m_bShowInScenarioMenu 1
}
@@ -0,0 +1,9 @@
MetaFileClass {
Name "{AD62322D5213C440}Missions/ERR_Everon_Base.conf"
Configurations {
ResourceClass PC {
}
ResourceClass HEADLESS : PC {
}
}
}
@@ -0,0 +1,46 @@
// Read replicated discovery locally. A server-wide marker list never leaks
// locations which have not been recovered through gameplay.
modded class ERR_LocationComponent
{
protected ref SCR_MapMarkerBase m_ERRMarker;
protected int m_ERRShown;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (RplSession.Mode() != RplMode.Dedicated && !SCR_Global.IsEditMode())
GetGame().GetCallqueue().CallLater(ERR_UpdateMarker, 1500, true);
}
protected void ERR_UpdateMarker()
{
if (!GetGame().GetPlayerController() || Knowledge() <= 0 || Knowledge() == m_ERRShown) return;
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return;
if (!m_ERRMarker)
{
m_ERRMarker = new SCR_MapMarkerBase();
m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
vector p = Position();
m_ERRMarker.SetWorldPos(Math.Round(p[0]), Math.Round(p[2]));
m_ERRMarker.SetMarkerConfigID(0);
manager.InsertStaticMarker(m_ERRMarker, true);
}
string label = "Unknown location";
if (Knowledge() == 2)
{
if (Kind() == ERR_LocationKind.RADIO) label = "Radio station";
else if (Kind() == ERR_LocationKind.EXTRACTION) label = "Extraction helicopter";
else label = Id();
}
m_ERRMarker.SetCustomText(label);
m_ERRShown = Knowledge();
}
protected override void OnDelete(IEntity owner)
{
if (GetGame()) GetGame().GetCallqueue().Remove(ERR_UpdateMarker);
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (manager && m_ERRMarker) manager.RemoveStaticMarker(m_ERRMarker);
m_ERRMarker = null;
super.OnDelete(owner);
}
}
@@ -0,0 +1,48 @@
// Base mission discovery: recovering physical documents works without companions.
// The Intel addon optionally assigns extra documents to enemy inventories.
class ERR_DiscoveryService
{
protected ERR_GameMode m_Mode;
protected ref set<IEntity> m_Spent = new set<IEntity>();
void ERR_DiscoveryService(ERR_GameMode mode) { m_Mode = mode; }
static void Folders(InventoryStorageManagerComponent inventory, out array<IEntity> folders)
{
folders.Clear();
SCR_PrefabNamePredicate predicate = new SCR_PrefabNamePredicate();
predicate.prefabName = ERR_FactionCatalog.IntelFolder();
inventory.FindItems(folders, predicate, EStoragePurpose.PURPOSE_ANY);
}
void Tick(float now)
{
if (!Replication.IsServer()) return;
array<IEntity> players = {};
ERR_Actors.Players(players);
foreach (IEntity player : players) Recover(player);
}
protected void Recover(IEntity player)
{
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(player.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return;
array<IEntity> folders = {};
Folders(inventory, folders);
foreach (IEntity folder : folders)
{
if (!folder || m_Spent.Contains(folder) || m_Spent.Count() >= 4096) continue;
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
array<ERR_LocationComponent> eligible = {};
foreach (ERR_LocationComponent location : locations)
{
if (location && location.Kind() != ERR_LocationKind.PRISON && location.Kind() != ERR_LocationKind.EXTRACTION && location.Knowledge() < 2) eligible.Insert(location);
}
if (eligible.IsEmpty()) continue;
ERR_LocationComponent chosen = eligible.GetRandomElement();
if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Get("intelRevealChancePercent"))) continue;
m_Spent.Insert(folder);
PrintFormat("Rereintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge());
}
}
void Stop() { m_Spent.Clear(); }
}
@@ -12,6 +12,8 @@ class ERR_GameMode : SCR_BaseGameMode
[RplProp()]
protected float m_DefenceSince = -1;
protected ref ERR_Settings m_Settings;
protected ref ERR_DiscoveryService m_Discovery;
protected ref ERR_Objectives m_Objectives;
protected ref ERR_PlayerRoster m_Roster = new ERR_PlayerRoster();
protected ref ERR_CommandService m_Command;
protected ref array<ERR_LocationComponent> m_Locations = {};
@@ -37,6 +39,16 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_PlayerRoster Players() { return m_Roster; }
ERR_Settings Settings() { return m_Settings; }
ERR_CommandService Command() { return m_Command; }
ERR_Objectives Objectives() { return m_Objectives; }
float EscapeRadius() { return m_EscapeRadius; }
bool DefenceOccupied() { return m_DefenceSince >= 0; }
int DefenceSecondsRemaining()
{
if (!m_Settings) return 0;
int duration = m_Settings.Get("radioDefenceSeconds");
if (m_DefenceSince < 0) return duration;
return Math.Max(0, Math.Ceil(duration - (GetWorld().GetWorldTime() - m_DefenceSince) / 1000.0));
}
ScriptInvoker OnPhaseChanged() { return m_OnPhaseChanged; }
ScriptInvoker OnTick() { return m_OnTick; }
ScriptInvoker OnStop() { return m_OnStop; }
@@ -124,6 +136,13 @@ class ERR_GameMode : SCR_BaseGameMode
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true);
if (!Replication.IsServer()) return;
m_Settings = new ERR_Settings();
m_Discovery = new ERR_DiscoveryService(this);
OnTick().Insert(m_Discovery.Tick);
OnStop().Insert(m_Discovery.Stop);
m_Objectives = new ERR_Objectives(this);
OnPhaseChanged().Insert(m_Objectives.PhaseChanged);
OnTick().Insert(m_Objectives.Tick);
OnStop().Insert(m_Objectives.Stop);
m_Command = CreateCommandService();
GetGame().GetCallqueue().CallLater(ServerTick, 1000, true);
GetGame().GetCallqueue().CallLater(StartScenario, 1500, false);
@@ -0,0 +1,154 @@
// Explicit local engine fixture, excluded from game/server builds.
#ifdef WORKBENCH
modded class ERR_GameMode
{
protected int m_ERRBaseStep;
protected float m_ERRBaseDeadline;
protected float m_ERRBaseWait;
protected IEntity m_ERRBasePlayer;
protected ERR_LocationComponent m_ERRBaseRadio;
protected ref array<IEntity> m_ERRBaseDocuments = {};
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
m_ERRBaseDeadline = GetWorld().GetWorldTime() + 150000;
OnPhaseChanged().Insert(ERR_BaseResult);
GetGame().GetCallqueue().CallLater(ERR_BaseTick, 500, true);
Print("ERR BASE SMOKE: core-only scripted escape; not manual or multiplayer acceptance");
}
protected bool ERR_BaseCheck(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: base " + detail); return true; }
Print("ERR SMOKE FAIL: base " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_BaseTick);
return false;
}
protected void ERR_BaseMove(vector position)
{
vector transform[4];
m_ERRBasePlayer.GetWorldTransform(transform);
transform[3] = ERR_Actors.Ground(position);
ChimeraCharacter.Cast(m_ERRBasePlayer).Teleport(transform);
}
protected ERR_UnlockPrisonAction ERR_BaseLock(IEntity entity)
{
BaseActionsManagerComponent manager = BaseActionsManagerComponent.Cast(entity.FindComponent(BaseActionsManagerComponent));
if (manager)
{
array<BaseUserAction> actions = {};
manager.GetActionsList(actions);
foreach (BaseUserAction action : actions)
{
ERR_UnlockPrisonAction lock = ERR_UnlockPrisonAction.Cast(action);
if (lock) return lock;
}
}
IEntity child = entity.GetChildren();
while (child)
{
ERR_UnlockPrisonAction lock = ERR_BaseLock(child);
if (lock) return lock;
child = child.GetSibling();
}
return null;
}
protected void ERR_BaseResult(ERR_Phase previous, ERR_Phase current)
{
if (!ERR_MissionRules.Terminal(current)) return;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
if (!ERR_BaseCheck(current == ERR_Phase.VICTORY && m_ERRBaseStep == 5, "escape and restart reached without companion services")) return;
Print("ERR SMOKE COMPLETE: core-only escape");
}
protected void ERR_BaseTick()
{
float now = GetWorld().GetWorldTime();
if (now > m_ERRBaseDeadline) { ERR_BaseCheck(false, "timeout at " + m_ERRBaseStep.ToString()); return; }
if (now < m_ERRBaseWait) return;
if (m_ERRBaseStep == 0)
{
array<IEntity> players = {};
ERR_Actors.Players(players, true);
if (players.Count() != 1 || Phase() != ERR_Phase.PRISON) return;
m_ERRBasePlayer = players[0];
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!ERR_BaseCheck(Objectives() && Objectives().Current() && Objectives().Current().GetTaskName() == "Escape the prison", "native prison objective exists")) return;
if (!ERR_BaseCheck(Command() == null, "no companion HQ or support controller loaded")) return;
if (!ERR_BaseCheck(vector.DistanceXZ(m_ERRBasePlayer.GetOrigin(), PrisonPosition()) < 5, "prisoner spawned in authored prison")) return;
foreach (ERR_LocationComponent location : m_Locations)
if (location.Kind() == ERR_LocationKind.RADIO) m_ERRBaseRadio = location;
ERR_UnlockPrisonAction lock;
foreach (IEntity entity : m_OwnedEntities)
{
if (!entity) continue;
if (!lock) lock = ERR_BaseLock(entity);
if (entity.GetPrefabData() && entity.GetPrefabData().GetPrefabName() == ERR_FactionCatalog.IntelFolder()) m_ERRBaseDocuments.Insert(entity);
}
if (!ERR_BaseCheck(lock && m_ERRBaseRadio && m_ERRBaseDocuments.Count() == 2 && ERR_Actors.Alive(m_ExtractionVehicle), "gate, clues, radio and helicopter created by core")) return;
ERR_BaseMove(lock.GetOwner().GetOrigin() + "1 0 1");
lock.PerformAction(lock.GetOwner(), m_ERRBasePlayer);
if (!ERR_BaseCheck(Phase() == ERR_Phase.RADIO_SEARCH, "gate action begins escape")) return;
if (!ERR_BaseCheck(m_Settings.Set("radioDefenceSeconds", 30), "test defence duration accepted")) return;
SCR_Task prisonTask = SCR_TaskSystem.GetTaskFromTaskID(ERR_Objectives.Id(ERR_Phase.PRISON), false);
if (!ERR_BaseCheck(prisonTask && prisonTask.GetTaskState() == SCR_ETaskState.COMPLETED, "previous native objective completes on phase change")) return;
if (!ERR_BaseCheck(Objectives().Current().GetTaskUIVisibility() == SCR_ETaskUIVisibility.LIST_ONLY, "search objective does not expose a hidden site")) return;
m_ERRBaseStep = 1;
}
else if (m_ERRBaseStep == 1 || m_ERRBaseStep == 2)
{
SCR_InventoryStorageManagerComponent inventory = SCR_InventoryStorageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(SCR_InventoryStorageManagerComponent));
if (!ERR_BaseCheck(inventory && inventory.TryInsertItem(m_ERRBaseDocuments[m_ERRBaseStep - 1]), "physical clue recovered")) return;
m_ERRBaseStep++;
m_ERRBaseWait = now + 2500;
}
else if (m_ERRBaseStep == 3)
{
if (!ERR_BaseCheck(Objectives().Current().GetTaskDescription().Contains("identify the radio station on your map. Reach"), "objective guidance reacts to discovered radio")) return;
if (!ERR_BaseCheck(m_ERRBaseRadio.Knowledge() == 2, "documents identify radio without Intel addon")) return;
ERR_BaseMove(m_ERRBaseRadio.Position() + "2 0 0");
m_ERRBaseStep = 30;
m_ERRBaseWait = now + 1000;
}
else if (m_ERRBaseStep == 30)
{
if (!ERR_BaseCheck(TuneRadio(m_ERRBasePlayer, m_ERRBaseRadio), "identified radio accepts transmission")) return;
m_ERRBaseStep = 31;
m_ERRBaseWait = now + 5000;
}
else if (m_ERRBaseStep == 31)
{
if (!ERR_BaseCheck(DefenceOccupied() && DefenceSecondsRemaining() > 0 && DefenceSecondsRemaining() < 30 && Objectives().Current().GetTaskDescription().Contains("seconds remaining"), "native objective reports advancing radio progress")) return;
ERR_BaseMove(m_ERRBaseRadio.Position() + "90 0 0");
m_ERRBaseStep = 32;
m_ERRBaseWait = now + 2500;
}
else if (m_ERRBaseStep == 32)
{
if (!ERR_BaseCheck(!DefenceOccupied() && DefenceSecondsRemaining() == 30 && Objectives().Current().GetTaskDescription().Contains("interrupted"), "objective reports interruption and reset")) return;
ERR_BaseMove(m_ERRBaseRadio.Position() + "2 0 0");
m_ERRBaseStep = 4;
m_ERRBaseWait = now + 33000;
}
else if (m_ERRBaseStep == 4)
{
if (!ERR_BaseCheck(Phase() == ERR_Phase.EXTRACTION, "radio defence reveals extraction")) return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERRBasePlayer.FindComponent(CompartmentAccessComponent));
BaseCompartmentSlot slot = access.FindFreeCompartment(m_ExtractionVehicle, ECompartmentType.PILOT);
if (!ERR_BaseCheck(slot && access.GetInVehicle(m_ExtractionVehicle, slot, true, -1, ECloseDoorAfterActions.INVALID, false), "player physically boards extraction helicopter")) return;
m_ERRBaseStep = 5;
m_ERRBaseWait = now + 3000;
}
else if (m_ERRBaseStep == 5)
{
if (!ERR_BaseCheck(CompartmentAccessComponent.GetVehicleIn(m_ERRBasePlayer) == m_ExtractionVehicle, "extraction requires real occupancy")) return;
vector transform[4];
m_ExtractionVehicle.GetWorldTransform(transform);
transform[3] = m_EscapeCenter + Vector(m_EscapeRadius + 100, 150, 0);
m_ExtractionVehicle.SetWorldTransform(transform);
m_ERRBaseWait = now + 4000;
}
}
}
#endif
@@ -0,0 +1,90 @@
// Server-owned presentation adapter: mission rules decide progress, native tasks
// replicate it. Companion addons neither create nor advance these objectives.
class ERR_Objectives
{
protected ERR_GameMode m_Mode;
protected SCR_Task m_Current;
protected string m_LastDescription;
protected bool m_Stopped;
void ERR_Objectives(ERR_GameMode mode) { m_Mode = mode; }
SCR_Task Current() { return m_Current; }
static string Id(ERR_Phase phase) { return "erri-mission-" + phase.ToString(); }
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Replication.IsServer() || m_Stopped) return;
SCR_TaskSystem tasks = SCR_TaskSystem.GetInstance();
if (!tasks) { Print("Rereintroduced: native task system missing", LogLevel.ERROR); return; }
if (m_Current)
{
SCR_ETaskState result = SCR_ETaskState.COMPLETED;
if (current == ERR_Phase.DEFEAT) result = SCR_ETaskState.FAILED;
tasks.SetTaskState(m_Current, result);
}
m_Current = null;
if (ERR_MissionRules.Terminal(current)) return;
string title;
if (current == ERR_Phase.PRISON) title = "Escape the prison";
else if (current == ERR_Phase.RADIO_SEARCH) title = "Locate the radio station";
else if (current == ERR_Phase.RADIO_DEFENCE) title = "Defend the radio transmission";
else if (current == ERR_Phase.EXTRACTION) title = "Escape Everon by helicopter";
if (title.IsEmpty()) return;
m_Current = tasks.CreateTask("{1D0F815858EE24AD}Prefabs/Tasks/BaseTask.et", Id(current), title, Description());
if (!m_Current) { Print("Rereintroduced: objective creation failed", LogLevel.ERROR); return; }
// Location markers use the discovery registry. A task cannot expose an unknown site.
tasks.SetTaskUIVisibility(m_Current, SCR_ETaskUIVisibility.LIST_ONLY);
m_Mode.Own(m_Current);
m_LastDescription = "";
Tick(GetGame().GetWorld().GetWorldTime());
}
protected string Description()
{
ERR_Phase phase = m_Mode.Phase();
if (phase == ERR_Phase.PRISON)
return "Collect supplies and the field map inside the prison. Pick the gate lock, then get clear of the guards.";
if (phase == ERR_Phase.RADIO_SEARCH)
{
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
foreach (ERR_LocationComponent location : locations)
if (location.Kind() == ERR_LocationKind.RADIO && location.Knowledge() == 2)
return "The recovered documents identify the radio station on your map. Reach the station and tune its radio to start the transmission.";
return "Recover the two documents on the guard's table outside the prison gate. Carry them to identify the radio station on your map.";
}
if (phase == ERR_Phase.RADIO_DEFENCE)
{
int seconds = m_Mode.DefenceSecondsRemaining();
if (!m_Mode.DefenceOccupied())
return string.Format("Transmission interrupted. Keep a conscious survivor within 60 metres of the radio for %1 seconds. Leaving resets progress.", seconds);
// Update at most once per five-second interval; do not broadcast every frame.
int displayed = Math.Ceil(seconds / 5.0) * 5;
return string.Format("Transmission in progress: up to %1 seconds remaining. Keep a conscious survivor within 60 metres of the radio. Leaving resets progress.", displayed);
}
if (phase == ERR_Phase.EXTRACTION)
return string.Format("Reach the helicopter marked on your map. Board and fly beyond %1 kilometres from the prison. Everyone aboard escapes; survivors left behind do not.", m_Mode.EscapeRadius() / 1000.0);
return "";
}
void Tick(float now)
{
if (!Replication.IsServer() || m_Stopped || !m_Current) return;
string description = Description();
if (description != m_LastDescription)
{
m_Current.SetTaskDescription(description);
m_LastDescription = description;
}
SCR_TaskSystem tasks = SCR_TaskSystem.GetInstance();
if (!tasks) return;
array<int> players = {};
GetGame().GetPlayerManager().GetPlayers(players);
foreach (int playerId : players)
{
SCR_TaskExecutor executor = SCR_TaskSystem.TaskExecutorFromPlayerID(playerId);
if (executor && !m_Current.IsTaskAssignedTo(executor)) tasks.AssignTask(m_Current, executor, true);
}
}
void Stop() { m_Stopped = true; }
}
@@ -0,0 +1,10 @@
[WorkbenchPluginAttribute(name: "Validate Rereintroduced base", wbModules: {"WorldEditor"})]
class ERR_ValidateBaseWorldPlugin : WorldEditorPlugin
{
override void RunCommandline()
{
string play;
WorldEditor editor = Workbench.GetModule(WorldEditor);
if (editor && System.GetCLIParam("errPlay", play) && play == "1") editor.SwitchToGameMode();
}
}