Compare commits

...
2 Commits
Author SHA1 Message Date
jakub 7b13231f29 Add changelog-only Discord notifications after successful pushes 2026-10-14 19:48:29 +02:00
jakub fda19a5200 Complete roadblocks in background and fix session regressions
Continue safe roadblock placement after admission and prison unlock, build posts incrementally, and clean up incomplete transactions. Add bounded stuck-patrol dismount recovery, defer deployment initialization during terrain takeover, reset deferred site search timing on first player arrival, and fix atlas page serialization and terrain resource lookup.

Validation: 30 native Everon posts with 120 living guards after unlock; seven affected regression suites pass; two final restart smoke rounds complete with 21 assertions and no script/runtime failures. Existing dependency asset errors remain.
2026-10-14 19:48:29 +02:00
20 changed files with 776 additions and 74 deletions
+23
View File
@@ -0,0 +1,23 @@
# Push changelogs
The user authorized Discord changelogs after new changes are successfully pushed.
After each successful push to `origin/main` from this checkout:
1. Prepare a concise, player-facing changelog covering the commits in that push.
Include only the changelog: describe fixes/features accurately, without server,
deployment, or Git push status. Do not add operational disclaimers or footers.
Phrase gameplay fixes without mentioning the server (for example, "Fixed radio
station generation after an empty-session wait").
2. Save the message to a temporary file and run
`python3 tools/discord_changelog.py --commit <pushed-sha> --message-file <path>`.
3. Report a delivery failure honestly. Do not blindly retry a pending delivery;
check Discord first to avoid duplicates.
The webhook credential is in `.local/discord-webhook.url` (ignored, owner-only).
Never print it, commit it, or put it into changelogs or command-line arguments.
The helper verifies the remote commit, disables mentions, and records delivery in
`.local/discord-changelog-state.json`. Do not backfill old commits unless requested.
This is an assistant workflow, not a server-side Git hook. Notifications for
pushes from other checkouts require a Discord push webhook in Gitea repository
settings; that server-side automation has not been configured.
@@ -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", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "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", "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"};
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && independentFixtures.Contains(nativeEndCase)) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
@@ -10,6 +10,29 @@ modded class ERR_HQ
}
modded class ERR_Roadblocks
{
override ERR_PrepareResult Prepare(ERR_ScenarioDefinition definition)
{
string outcome;
if (!m_Begun && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "roadblocks")
m_Mode.Settings().Set("roadblockCount", 30);
return super.Prepare(definition);
}
int ERR_ProbePostCount() { return m_Positions.Count(); }
bool ERR_ProbeBackgroundPending() { return m_Background; }
bool ERR_ProbeCompleteRoadblocks()
{
if (m_Target != 30 || m_Positions.Count() != 30 || m_Background || !m_Ready) return false;
foreach (SCR_AIGroup group : m_Groups)
{
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
if (!group || !record || record.Kind != ERR_ForceKind.CHECKPOINT || record.InitialStrength != 4 || m_Mode.ForceLiving(group) != 4) return false;
if (group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
}
int count = m_Positions.Count();
BackgroundStep();
PrintFormat("ERR ROADBLOCK BACKGROUND NATIVE: posts=%1 guards=%2 steps=%3 workMs=%4 maxStepMs=%5 attempts=%6", count, count * 4, m_BackgroundSteps, m_BackgroundWork, m_MaxBackgroundStep, m_Attempts);
return m_Positions.Count() == count && m_BackgroundSteps > 0;
}
bool ERR_ProbePreparedRoadblocks()
{
if (!m_Ready || m_Target != 4 || m_Groups.IsEmpty() || m_Groups.Count() > m_Target || m_Groups.Count() != m_Positions.Count()) return false;
@@ -27,4 +50,45 @@ modded class ERR_Roadblocks
return true;
}
}
modded class ERR_GameMode
{
protected int m_ERRRoadblockDeadline, m_ERRRoadblockInitial = -1;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "roadblocks") return;
m_ERRRoadblockDeadline = System.GetTickCount() + 600000;
GetGame().GetCallqueue().CallLater(ERR_RoadblockProbeTick, 500, true);
OnStop().Insert(ERR_StopRoadblockProbe);
}
protected void ERR_StopRoadblockProbe() { GetGame().GetCallqueue().Remove(ERR_RoadblockProbeTick); }
protected void ERR_RoadblockProbeFail(string reason)
{
Print("ERR SMOKE FAIL: roadblocks " + reason, LogLevel.ERROR);
ERR_StopRoadblockProbe();
}
protected void ERR_RoadblockProbeTick()
{
if (System.GetTickCount() > m_ERRRoadblockDeadline || !PreparationError().IsEmpty()) { ERR_RoadblockProbeFail("deadline or preparation error"); return; }
if (!Active()) return;
ERR_Roadblocks posts = ERR_HQ.Get().Roadblocks();
if (m_ERRRoadblockInitial < 0)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
IEntity player; if (controller) player = controller.GetControlledEntity();
if (!player || !Players().CurrentActor(player)) return;
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!posts.ERR_ProbeBackgroundPending() || posts.ERR_ProbePostCount() >= 30 || !UnlockPrison(player)) { ERR_RoadblockProbeFail("did not enter background search with prison unlocked"); return; }
m_ERRRoadblockInitial = posts.ERR_ProbePostCount();
PrintFormat("ERR SMOKE PASS: roadblocks prison unlocked with %1/30 posts; background still pending", m_ERRRoadblockInitial);
}
if (posts.ERR_ProbeBackgroundPending()) return;
if (!posts.ERR_ProbeCompleteRoadblocks() || posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial) { ERR_RoadblockProbeFail("30 physical four-guard posts, ledger and HQ admission"); return; }
Print("ERR SMOKE PASS: roadblocks 30 physical posts / 120 guards; background completed after prison unlock, no extra posts on repeat tick");
ERR_StopRoadblockProbe();
Transition(ERR_Phase.DEFEAT);
Print("ERR SMOKE COMPLETE: roadblocks background completion on Everon; scripted local fixture");
}
}
#endif
@@ -63,7 +63,7 @@ class ERR_ScenarioComponent : ScriptComponent
#endif
#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 != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
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")
m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
#endif
m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
@@ -71,7 +71,7 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH
// 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 != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
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")
foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
if (!ERR_ConfigurePatrolHQFixture(fixture)) { m_Failed = true; return ERR_PrepareResult.FAILED; }
#endif
@@ -114,7 +114,6 @@ class ERR_ScenarioComponent : ScriptComponent
m_Finished = true;
if (m_Progressive)
{
m_Generated.StartupOnly(false);
GetGame().GetCallqueue().CallLater(BackgroundStep, 250, true);
PrintFormat("Reintroduced [generation]: spawn-ready in %1 ms; remaining sites continue in background", System.GetTickCount() - m_PreparationStarted);
}
@@ -132,6 +131,9 @@ class ERR_ScenarioComponent : ScriptComponent
if (!m_BackgroundActivated)
{
if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
// Resume the placement clock only when work can actually start. An empty
// server may have waited hours since the prison became spawn-ready.
m_Generated.StartupOnly(false);
m_BackgroundActivated = true;
}
if (!m_PlacementDone)
@@ -0,0 +1,47 @@
// A replicated player controller can arrive in the gap between native mode
// removal and Escape's bootstrap. Vanilla deployment initialization dereferences
// GetGameMode() and can also retain the outgoing respawn singleton in that gap.
// Defer the original initialization intact; never initialize it a second time.
modded class SCR_PlayerDeployMenuHandlerComponent
{
protected IEntity m_ERRDeferredDeployOwner;
protected BaseWorld m_ERRDeferredDeployWorld;
protected int m_ERRDeployInitAttempts;
protected bool m_ERRDeployInitComplete;
protected override void OnPostInit(IEntity owner)
{
if (m_ERRDeployInitComplete || m_ERRDeferredDeployOwner) return;
if (!GetGame().ERR_ClientTerrainModePending())
{
m_ERRDeployInitComplete = true;
super.OnPostInit(owner);
return;
}
m_ERRDeferredDeployOwner = owner;
m_ERRDeferredDeployWorld = owner.GetWorld();
GetGame().GetCallqueue().CallLater(ERR_RetryDeployInit, 50, false);
}
protected void ERR_RetryDeployInit()
{
IEntity owner = m_ERRDeferredDeployOwner;
if (!owner || !GetGame() || GetGame().GetWorld() != m_ERRDeferredDeployWorld) { m_ERRDeferredDeployOwner = null; return; }
if (!GetGame().GetGameMode())
{
if (++m_ERRDeployInitAttempts < 200) GetGame().GetCallqueue().CallLater(ERR_RetryDeployInit, 50, false);
else { m_ERRDeferredDeployOwner = null; Print("Reintroduced [terrain client]: deployment initialization timed out waiting for game mode", LogLevel.ERROR); }
return;
}
m_ERRDeferredDeployOwner = null;
m_ERRDeployInitComplete = true;
super.OnPostInit(owner);
Print("Reintroduced [terrain client]: deployment initialized after game mode arrived");
}
override protected void OnDelete(IEntity owner)
{
if (GetGame()) GetGame().GetCallqueue().Remove(ERR_RetryDeployInit);
m_ERRDeferredDeployOwner = null;
m_ERRDeferredDeployWorld = null;
super.OnDelete(owner);
}
}
@@ -16,6 +16,10 @@ modded class ArmaReforgerScripted
protected ref array<IEntity> m_ERRClientOldManagers = {};
protected bool m_ERRClientTerrainTakeover;
bool ERR_IsClientTerrainTakeover() { return m_ERRClientTerrainTakeover; }
bool ERR_ClientTerrainModePending()
{
return !Replication.IsServer() && !GetGameMode() && !m_ERRClientOldManagers.IsEmpty();
}
protected SCR_EntityCatalogManagerComponent m_ERROldCatalog;
protected EntityID m_ERROldLoadoutId;
protected SCR_AdditionalGameModeSettingsComponent m_ERROldSettings;
@@ -46,6 +46,9 @@ class ERR_TerrainDisplayName
{
if (seen.Contains(world)) break;
seen.Insert(world);
// Native subscene names can be plain paths. They are already the loaded
// terrain, not GUID resources that LoadContainer can traverse safely.
if (!world.IsExternal() && !world.IsInternal()) break;
Resource resource = BaseContainerTools.LoadContainer(world);
if (!resource || !resource.IsValid()) break;
BaseContainer container = resource.GetResource().ToBaseContainer();
@@ -682,6 +682,7 @@ class ERR_HunterService : ERR_ConvoyService
// throwing away the delivered destination or the original physical crew.
if (!unit.CrewValid(m_Mode)) continue;
ObserveMovement(unit, now);
if (unit.Released) continue;
if (unit.PendingSearch && unit.Downlink && !unit.Search.Active(now)) unit.CancelHQDownlink();
QueueDestination(unit, hq, now);
if (unit.Downlink)
@@ -737,7 +738,16 @@ class ERR_HunterService : ERR_ConvoyService
if (unit.Waypoint) deliveredDestination = unit.Waypoint.GetOrigin();
ERR_HQ.Event("HUNTER MOVEMENT", string.Format("%1 position=%2 speed=%3m/s delivered destination=%4 travelling=%5 combat=%6 no progress=%7s replans=%8", unit.Source, position, speed, deliveredDestination, travelling, combat, (now - unit.ProgressAt) / 1000, unit.Repaths));
}
if (!travelling || combat || now - unit.ProgressAt < 60000 || unit.Repaths >= 3) return;
if (!travelling || combat || now - unit.ProgressAt < 60000) return;
if (unit.Repaths >= 3)
{
// A fourth minute without progress must not leave a patrol seated forever.
// Native exit actions are safe only once the physical vehicle is slow.
Physics physics = unit.Entity.GetPhysics();
if (!physics || physics.GetVelocity().Length() > 3) return;
AbandonStuckVehicle(unit);
return;
}
unit.ProgressAt = now; unit.Repaths++;
// Destination can already contain a newer order awaiting radio delivery.
// Retry only the waypoint the crew has actually received.
@@ -747,6 +757,26 @@ class ERR_HunterService : ERR_ConvoyService
if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); }
ERR_HQ.Event("HUNTER REPATH", unit.Source + ": no movement for 60 seconds; native route requested again, attempt " + unit.Repaths.ToString());
}
protected void AbandonStuckVehicle(ERR_HunterVehicle unit)
{
if (unit.Released || unit.PlayerCaptureObserved || ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) return;
// Stop reboarding/tactics before detaching the vehicle. Preserve the original
// actors, their radios and HQ registration so survivors can receive foot orders.
unit.Release(m_Mode);
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (utility && usage) utility.RemoveUsableVehicle(usage);
foreach (IEntity member : unit.Members)
{
if (!ERR_Actors.CanFight(member) || ERR_Actors.Player(member) || ERR_Actors.Group(member) != unit.Group) continue;
ERR_ConvoyMountedCrew.Release(member);
if (CompartmentAccessComponent.GetVehicleIn(member) != unit.Entity) continue;
SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(member));
if (!agent || !agent.m_UtilityComponent || !agent.m_UtilityComponent.m_AIInfo) continue;
agent.m_UtilityComponent.AddAction(new SCR_AIGetOutVehicle(agent.m_UtilityComponent, null, unit.Entity, priority: 1000));
}
ERR_HQ.Event("HUNTER RELEASE", unit.Source + ": three route retries failed; original crew ordered out for on-foot HQ duties, vehicle retained");
}
override void Stop()
{
m_Stopped = true;
@@ -1,7 +1,11 @@
// Optional finite roadside checkpoints. Planned before player admission, using
// this terrain's road network and navmesh; never replenish casualties in-round.
// Finite roadside checkpoints. A short initial pass hands unfinished placement
// to a throttled background search; never replenish casualties in-round.
class ERR_RoadblockRules
{
// Admission never waits longer for a high count. Remaining posts are built
// by the same safe placement path in background, including after prison unlock.
static int SearchMilliseconds() { return 12000; }
static int SearchAttempts() { return 300; }
static bool Road(float width, float length, float heightDifference)
{
return ERR_ForceRules.Finite(width) && ERR_ForceRules.Finite(length) && ERR_ForceRules.Finite(heightDifference)
@@ -26,10 +30,17 @@ class ERR_Roadblocks
protected ref array<SCR_AIGroup> m_Groups = {};
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected ref ERR_SettlementSafety m_Settlements = new ERR_SettlementSafety();
protected bool m_Begun, m_Ready, m_Stopped, m_HaveCandidate;
protected bool m_Begun, m_Ready, m_Stopped, m_HaveCandidate, m_Background;
protected ref ERR_ScenarioDefinition m_Definition;
protected int m_NextSearch, m_NextReport, m_BackgroundSteps, m_BackgroundWork, m_MaxBackgroundStep;
protected int m_Target, m_Attempts, m_Started, m_CandidateStarted, m_Candidates, m_Planned;
protected vector m_Low, m_High, m_Centre, m_Forward, m_Right;
protected float m_Shoulder;
protected SCR_AIGroup m_BuildGroup;
protected AIWaypoint m_BuildHold;
protected ref array<IEntity> m_BuildSpawned = {};
protected ref array<vector> m_BuildPositions = {}, m_BuildCovers = {};
protected int m_BuildActors, m_BuildCoverCount;
protected ref array<ResourceName> m_Roster = {};
protected ref array<ref Resource> m_Loaded = {};
static const ResourceName COVER = "{CFFEB23FBEA00322}Prefabs/Props/Military/Sandbags/Sandbag_01_short_burlap.et";
@@ -39,11 +50,17 @@ class ERR_Roadblocks
string Status()
{
if (!m_Begun) return "Roadblocks: awaiting terrain preparation.";
return string.Format("Roadblocks: %1/%2 placed this round; next-round setting %3. Four guards per post, no replacements.", m_Groups.Count(), m_Target, m_Mode.Settings().Get("roadblockCount"));
string progress = "placement complete";
if (m_Ready && m_Positions.Count() < m_Target) progress = "placement stopped; see server log";
if (m_Background) progress = "background placement pending";
else if (!m_Ready) progress = "initial placement in progress";
return string.Format("Roadblocks: %1/%2 placed this round; next-round setting %3. Four guards per post, no replacements. %4.", m_Positions.Count(), m_Target, m_Mode.Settings().Get("roadblockCount"), progress);
}
protected ERR_PrepareResult Finish(string reason)
{
m_Ready = true; m_Loaded.Clear();
m_Ready = true; m_Background = false; m_HaveCandidate = false;
m_Loaded.Clear(); m_Definition = null;
if (GetGame()) GetGame().GetCallqueue().Remove(BackgroundStep);
PrintFormat("Reintroduced [roadblocks]: placed=%1 requested=%2 attempts=%3 roadCandidates=%4 clearPlans=%5; %6", m_Groups.Count(), m_Target, m_Attempts, m_Candidates, m_Planned, reason);
ERR_HQ.Event("ROADBLOCKS", Status() + " " + reason);
return ERR_PrepareResult.READY;
@@ -136,40 +153,68 @@ class ERR_Roadblocks
foreach (IEntity entity : spawned) ERR_Actors.DeleteSpawned(entity);
ERR_Actors.DeleteSpawned(group);
}
// Start a transaction; physical pieces are created separately in BuildPost.
protected bool SpawnPost(array<vector> positions, array<vector> covers)
{
if (!PlayersClear() || m_Mode.Forces().Count() >= 250) return false;
SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", m_Centre));
if (!group) return false;
group.SetFaction(m_Mode.EnemyFaction()); group.SetSpawnImmediately(false); group.SetDeleteWhenEmpty(false); group.SetPermanentLOD(0);
array<IEntity> spawned = {};
for (int i = 0; i < positions.Count(); i++)
{
if (!PlayersClear()) { Rollback(spawned, group); return false; }
IEntity actor = ERR_Actors.Spawn(m_Roster[i % m_Roster.Count()], positions[i]);
if (!actor) { Rollback(spawned, group); return false; }
spawned.Insert(actor);
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !group.AddAIEntityToGroup(actor)) { Rollback(spawned, group); return false; }
}
foreach (vector coverPosition : covers)
{
IEntity cover = ERR_Actors.Spawn(COVER, coverPosition, m_Forward.VectorToAngles());
if (!cover) { Rollback(spawned, group); return false; }
spawned.Insert(cover);
}
AIWaypoint hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), positions[0]));
if (!hold) { Rollback(spawned, group); return false; }
spawned.Insert(hold); hold.SetCompletionRadius(25); group.AddWaypoint(hold);
string id = "roadblock-" + m_Groups.Count().ToString();
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, 4, null, group, GetGame().GetWorld().GetWorldTime()))
{ Rollback(spawned, group); return false; }
foreach (IEntity ownedEntity : spawned) m_Mode.Own(ownedEntity);
m_Mode.Own(group); m_Groups.Insert(group); m_Positions.Insert(m_Centre);
m_Mode.RegisterGroup(group); group.ActivateAI();
m_Mining.Register(group, hold, m_Right);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 guards=4 roadPassage=open HQ=registered", id, m_Centre);
if (!PlayersClear() || m_Mode.Forces().Count() >= 250 || m_BuildGroup || positions.Count() != 4 || covers.Count() != 2) return false;
m_BuildGroup = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", m_Centre));
if (!m_BuildGroup) return false;
m_BuildGroup.SetFaction(m_Mode.EnemyFaction()); m_BuildGroup.SetSpawnImmediately(false); m_BuildGroup.SetDeleteWhenEmpty(false); m_BuildGroup.SetPermanentLOD(0);
m_BuildPositions.Copy(positions); m_BuildCovers.Copy(covers);
m_BuildActors = 0; m_BuildCoverCount = 0;
return true;
}
protected void CancelPost()
{
Rollback(m_BuildSpawned, m_BuildGroup);
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
}
protected void BuildPost()
{
if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); return; }
// One actor/prop/waypoint per callback. Recheck moving obstructions and
// player distance while the transaction is in progress; roll back on failure.
if (m_BuildActors < m_BuildPositions.Count())
{
vector point = m_BuildPositions[m_BuildActors];
if (!m_Placement.Clear(point, null)) { CancelPost(); return; }
IEntity actor = ERR_Actors.Spawn(m_Roster[m_BuildActors % m_Roster.Count()], point);
if (!actor) { CancelPost(); return; }
m_BuildSpawned.Insert(actor);
if (!ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !m_BuildGroup.AddAIEntityToGroup(actor)) { CancelPost(); return; }
m_BuildActors++;
return;
}
if (m_BuildCoverCount < m_BuildCovers.Count())
{
vector coverPosition = m_BuildCovers[m_BuildCoverCount];
if (!CoverClear(coverPosition)) { CancelPost(); return; }
IEntity cover = ERR_Actors.Spawn(COVER, coverPosition, m_Forward.VectorToAngles());
if (!cover) { CancelPost(); return; }
m_BuildSpawned.Insert(cover); m_BuildCoverCount++;
return;
}
if (!m_BuildHold)
{
m_BuildHold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), m_BuildPositions[0]));
if (!m_BuildHold) { CancelPost(); return; }
m_BuildSpawned.Insert(m_BuildHold); m_BuildHold.SetCompletionRadius(25);
return;
}
string id = "roadblock-" + m_Positions.Count().ToString();
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, 4, null, m_BuildGroup, GetGame().GetWorld().GetWorldTime()))
{ CancelPost(); return; }
foreach (IEntity ownedEntity : m_BuildSpawned) m_Mode.Own(ownedEntity);
m_Mode.Own(m_BuildGroup); m_Groups.Insert(m_BuildGroup); m_Positions.Insert(m_Centre);
m_BuildGroup.AddWaypoint(m_BuildHold);
m_Mode.RegisterGroup(m_BuildGroup); m_BuildGroup.ActivateAI();
m_Mining.Register(m_BuildGroup, m_BuildHold, m_Right);
PrintFormat("Reintroduced [roadblocks]: %1 at %2 guards=4 roadPassage=open HQ=registered", id, m_Centre);
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
m_BuildPositions.Clear(); m_BuildCovers.Clear();
m_NextSearch = System.GetTickCount() + 2000;
}
// 0: reject, 1: awaiting native tile, 2: ready.
protected int Plan(out array<vector> positions, out array<vector> covers)
{
@@ -210,19 +255,77 @@ class ERR_Roadblocks
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!ERR_TerrainSites.Bounds(m_Low, m_High) || !LoadRoster()) return Finish("terrain or faction resources unavailable; optional roadblocks skipped");
}
if (m_Groups.Count() >= m_Target) return Finish("requested count reached");
if (System.GetTickCount() - m_Started >= 12000 || m_Attempts >= 300) return Finish("bounded search complete; unsuitable locations skipped");
// Amortize geometry work: at most eight cheap road samples and one detailed
// placement per preparation call. Never depend on finding every requested post.
for (int sample = 0; sample < 8 && !m_HaveCandidate && m_Attempts < 300; sample++)
{ m_Attempts++; m_HaveCandidate = Candidate(definition); }
if (!m_HaveCandidate) return ERR_PrepareResult.PENDING;
array<vector> positions = {}, covers = {};
int plan = Plan(positions, covers);
if (plan == 1 && System.GetTickCount() - m_CandidateStarted < 1500) return ERR_PrepareResult.PENDING;
if (plan == 2) { m_Planned++; SpawnPost(positions, covers); }
m_HaveCandidate = false;
if (m_Positions.Count() >= m_Target) return Finish("requested count reached");
if (System.GetTickCount() - m_Started >= ERR_RoadblockRules.SearchMilliseconds() || m_Attempts >= ERR_RoadblockRules.SearchAttempts())
{
m_Ready = true; m_Background = true; m_Definition = definition;
m_NextReport = System.GetTickCount() + 60000;
GetGame().GetCallqueue().CallLater(BackgroundStep, 500, true);
ERR_HQ.Event("ROADBLOCKS", Status() + " Initial search yielded; remaining posts will continue in background.");
return ERR_PrepareResult.READY;
}
SearchStep(definition, 8);
if (m_Positions.Count() >= m_Target) return Finish("requested count reached");
return ERR_PrepareResult.PENDING;
}
void Stop() { m_Stopped = true; m_Mining.Stop(); m_Loaded.Clear(); }
protected void SearchStep(ERR_ScenarioDefinition definition, int samples)
{
if (m_BuildGroup) { BuildPost(); return; }
int started = System.GetTickCount();
// A cooperative 2ms search budget, checked between native calls. A single
// native road/trace/spawn call cannot be preempted. Limit background to eight
// cheap candidates OR one detailed plan/build piece per callback, never a whole scan.
if (!m_HaveCandidate)
{
for (int sample = 0; sample < samples; sample++)
{
if (sample > 0 && System.GetTickCount() - started >= 2) break;
m_Attempts++;
if (Candidate(definition)) { m_HaveCandidate = true; break; }
}
return;
}
array<vector> positions = {}, covers = {};
int plan = Plan(positions, covers);
if (plan == 1 && System.GetTickCount() - m_CandidateStarted < 2000) return;
if (plan == 2)
{
m_Planned++;
SpawnPost(positions, covers);
}
m_HaveCandidate = false;
}
protected void BackgroundStep()
{
if (m_Stopped || !m_Background) return;
if (!Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get()) { Stop(); return; }
if (m_Mode.Phase() == ERR_Phase.PREPARING) return;
if (!m_Mode.Active()) { Stop(); return; }
if (m_Positions.Count() >= m_Target) { Finish("requested count reached in background"); return; }
int now = System.GetTickCount();
// Empty servers do no terrain searches. No new actors can fit a full ledger.
if (GetGame().GetPlayerManager().GetPlayerCount() == 0 || now < m_NextSearch) return;
if (m_Mode.Forces().Count() >= 250)
{
if (m_BuildGroup) CancelPost();
return;
}
SearchStep(m_Definition, 8);
int elapsed = System.GetTickCount() - now;
m_BackgroundSteps++; m_BackgroundWork += elapsed;
m_MaxBackgroundStep = Math.Max(m_MaxBackgroundStep, elapsed);
if (m_Positions.Count() >= m_Target) { Finish("requested count reached in background"); return; }
if (now >= m_NextReport)
{
m_NextReport = now + 60000;
PrintFormat("Reintroduced [roadblocks background]: placed=%1/%2 attempts=%3 steps=%4 workMs=%5 maxStepMs=%6; still searching safe locations", m_Positions.Count(), m_Target, m_Attempts, m_BackgroundSteps, m_BackgroundWork, m_MaxBackgroundStep);
}
}
void Stop()
{
m_Stopped = true; m_Background = false; m_HaveCandidate = false;
if (GetGame()) GetGame().GetCallqueue().Remove(BackgroundStep);
CancelPost();
m_Mining.Stop(); m_Loaded.Clear(); m_Definition = null;
}
}
@@ -739,12 +739,13 @@ class ERRM_SharedMapComponent : SCR_MapGadgetComponent
override bool RplSave(ScriptBitWriter writer)
{
writer.WriteIntRange(m_ESMPage, 0, 16777215);
// Regional sheets start at 16777216, beyond the old 24-bit detail range.
writer.WriteInt(m_ESMPage);
return super.RplSave(writer);
}
override bool RplLoad(ScriptBitReader reader)
{
reader.ReadIntRange(m_ESMPage, 0, 16777215);
if (!reader.ReadInt(m_ESMPage)) return false;
m_ESMLastSelectedPage = m_ESMPage;
return super.RplLoad(reader);
}
+29
View File
@@ -82,3 +82,32 @@ int main(){
}
''')
print('PASS: actual generation scheduler bounds each tick, enforces deadline/candidate ceiling, validates fallback, commits only accepted positions, skips only dispensable sites, preserves required objectives/intel, and resets the next search budget')
# Exercise the production background activation path: waiting for the first
# player must not consume the next mandatory site's placement budget.
scenario=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_ScenarioComponent.c'
activation=body(scenario, 'protected void BackgroundStep()').split(' if (m_PendingSite)')[0]+'}\n'
activation=activation.replace('protected ', '').replace('ERR_GameMode mode = ERR_GameMode.Cast(GetOwner());', 'Mode* mode = GetOwner();')
for a,b in [('Replication.IsServer()', 'true'),('ERR_GameMode.Get()', 'GetOwner()'),('mode.Active()', 'mode->Active()'),('GetGame().GetPlayerManager().GetPlayerCount()', 'players'),('ERR_PrepareResult.', 'Result::')]: activation=activation.replace(a,b)
assert 'm_Generated.StartupOnly(false)' not in body(scenario,'ERR_PrepareResult Prepare(')
run(r'''
#include <cassert>
#include <string>
using string=std::string;
namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value;
int now=0,players=0;
struct Mode{bool active=true;bool Active(){return active;}};
struct Generated{int began=0,resumes=0,ticks=0;bool startup=true;void StartupOnly(bool v){assert(!v);startup=v;began=now;resumes++;}ERR_PrepareResult Tick(){assert(!startup);ticks++;return now-began>=30000?Result::FAILED:Result::PENDING;}string Error(){return "expired";}};
struct Scenario{Mode mode;Generated m_Generated;bool m_BackgroundActivated=false,m_PlacementDone=false,stopped=false,failed=false;Mode* GetOwner(){return &mode;}bool BackgroundPending(){return true;}void StopBackground(){stopped=true;}void FailBackground(string){failed=true;}
'''+activation+r'''
};
int main(){Scenario s;
for(now=0;now<3600000;now+=250)s.BackgroundStep();
assert(s.m_Generated.resumes==0&&s.m_Generated.ticks==0&&!s.failed);
players=1;s.BackgroundStep();assert(s.m_Generated.resumes==1&&s.m_Generated.ticks==1&&!s.failed&&s.m_Generated.began==now);
players=0;now+=250;s.BackgroundStep();assert(s.m_Generated.resumes==1&&s.m_Generated.ticks==2&&!s.failed);
now+=30000;s.BackgroundStep();assert(s.failed); // the active search is still bounded
Scenario ended;ended.mode.active=false;ended.BackgroundStep();assert(ended.stopped&&ended.m_Generated.resumes==0);
}
''')
print('PASS: actual background activation waits an hour empty without consuming placement time; disconnect does not reset active budget')
+82
View File
@@ -0,0 +1,82 @@
"""Execute production stuck-route timing and original-crew dismount selection."""
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_HunterService.c'
s=body(p,'protected void ObserveMovement(')
s=s.replace('protected ', '').replace('ERR_HunterVehicle unit','Unit* unit').replace('unit.', 'unit->')
for name in ('Entity','Waypoint','Group'):
s=s.replace(name+'.',name+'->')
s=s.replace('Physics physics','Physics* physics').replace('physics.','physics->').replace('GetPhysics().','GetPhysics()->')
s=s.replace('vector.DistanceXZ','Vec::DistanceXZ').replace('vector ', 'Vec ').replace('ERR_HunterCombat.Active','Active').replace('ERR_HQ.Event','Event').replace('string.Format','Format')
s=s.replace('AIWaypoint->Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), retryDestination))','Spawn(retryDestination)').replace('unit->Repaths.ToString()', 'std::to_string(unit->Repaths)')
run(r'''
#include <cassert>
#include <string>
#include <cmath>
struct Vec{float x=0;float Length(){return std::abs(x);}static float DistanceXZ(Vec a,Vec b){return std::abs(a.x-b.x);}};
struct Physics{float speed=0;Vec GetVelocity(){return {speed};}};
struct Entity{Vec position;Physics physics;bool hasPhysics=true;Vec GetOrigin(){return position;}Physics* GetPhysics(){return hasPhysics?&physics:nullptr;}};
struct Group{bool contact=false;int orders=0;void AddWaypoint(void*){orders++;}};
struct Waypoint{Vec position;bool arrived=false;Vec GetOrigin(){return position;}bool IsWithinCompletionRadius(Group*){return arrived;}void SetCompletionRadius(int value){assert(value==50);}};
struct Unit{Entity* Entity=nullptr;Group* Group=nullptr;Waypoint* Waypoint=nullptr;float ProgressAt=0,NextMotionLog=0;Vec ProgressPosition,Destination{9999};int Repaths=0,clears=0;bool Released=false;std::string Source="bike";void ClearOrder(){clears++;Waypoint=nullptr;}};
bool Active(Group* g){return g->contact;}void Event(std::string,std::string){}template<class...T>std::string Format(T...){return {};}
Waypoint replacement;int spawned=0;Waypoint* Spawn(Vec dest){spawned++;replacement.position=dest;return &replacement;}
int abandoned=0;void AbandonStuckVehicle(Unit* unit){abandoned++;unit->Released=true;}
'''.replace('Entity* Entity','struct Entity* Entity').replace('Group* Group','struct Group* Group').replace('Waypoint* Waypoint','struct Waypoint* Waypoint')+s+r'''
int main(){
Entity vehicle;Group group;Waypoint waypoint;waypoint.position={1234};Unit unit;unit.Entity=&vehicle;unit.Group=&group;unit.Waypoint=&waypoint;
ObserveMovement(&unit,1000);ObserveMovement(&unit,60999);assert(spawned==0&&abandoned==0);
for(int i=1;i<=3;i++){ObserveMovement(&unit,1000+i*60000);assert(unit.Repaths==i&&spawned==i&&abandoned==0&&replacement.position.x==1234);}
ObserveMovement(&unit,240999);assert(!abandoned);
vehicle.physics.speed=4;ObserveMovement(&unit,241000);assert(!abandoned);
vehicle.physics.speed=0;vehicle.hasPhysics=false;ObserveMovement(&unit,241001);assert(!abandoned);
vehicle.hasPhysics=true;group.contact=true;ObserveMovement(&unit,241002);assert(!abandoned);
group.contact=false;ObserveMovement(&unit,301001);assert(!abandoned);
ObserveMovement(&unit,301002);assert(abandoned==1&&unit.Released&&spawned==3);
// Actual movement restores retry allowance; arrival is never a stuck patrol.
unit.Released=false;vehicle.position={20};ObserveMovement(&unit,400000);assert(unit.Repaths==0&&!unit.Released);
replacement.arrived=true;ObserveMovement(&unit,500000);assert(!unit.Released&&abandoned==1&&spawned==3);
}
''')
print('PASS: actual movement recovery retries delivered orders three times, respects combat/speed/arrival/progress, then abandons the stalled vehicle')
s=body(p,'protected void AbandonStuckVehicle(').replace('protected ', '').replace('ERR_HunterVehicle unit','Unit* unit').replace('unit.', 'unit->')
s=s.replace('SCR_AIGroupUtilityComponent utility = unit->Group.GetGroupUtilityComponent();','Utility* utility = unit->Group->GetGroupUtilityComponent();')
s=s.replace('SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(unit->Entity.FindComponent(SCR_AIVehicleUsageComponent));','void* usage = unit->Entity;')
s=s.replace('foreach (IEntity member : unit->Members)','for (Actor* member : unit->Members)')
s=s.replace('SCR_ChimeraAIAgent agent = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(member));','Agent* agent = member->agent;')
s=s.replace('agent.', 'agent->').replace('m_UtilityComponent.', 'm_UtilityComponent->').replace('utility.', 'utility->').replace('null','nullptr').replace('priority: ', '')
for ns in ('ERR_ConvoyRules','ERR_Actors','ERR_ConvoyMountedCrew','CompartmentAccessComponent','ERR_HQ'):s=s.replace(ns+'.',ns+'::')
run(r'''
#include <cassert>
#include <vector>
#include <string>
struct Utility{int detached=0;void RemoveUsableVehicle(void*){detached++;}};
struct Group{Utility utility;Utility* GetGroupUtilityComponent(){return &utility;}};
struct Action{};
struct AgentUtility{bool m_AIInfo=true;int exits=0;void AddAction(Action* action){exits++;delete action;}};
struct SCR_AIGetOutVehicle:Action{SCR_AIGetOutVehicle(AgentUtility*,void*,void*,int priority){assert(priority==1000);}};
struct Agent{AgentUtility* m_UtilityComponent=nullptr;};
struct Actor{bool fit=true,player=false;Group* group=nullptr;void* vehicle=nullptr;Agent* agent=nullptr;int releases=0;};
struct Unit{bool Released=false,PlayerCaptureObserved=false;struct Group* Group=nullptr;void* Entity=nullptr;std::vector<Actor*> Members;std::string Source="original bike";int releases=0;void Release(void*){releases++;Released=true;}};
void* m_Mode=nullptr;
namespace ERR_ConvoyRules{bool captured=false;bool Captured(void*,Group*){return captured;}}
namespace ERR_Actors{bool CanFight(Actor*a){return a&&a->fit;}bool Player(Actor*a){return a->player;}struct Group* Group(Actor*a){return a->group;}}
namespace ERR_ConvoyMountedCrew{void Release(Actor*a){a->releases++;}}
namespace CompartmentAccessComponent{void* GetVehicleIn(Actor*a){return a->vehicle;}}
namespace ERR_HQ{void Event(std::string,std::string){}}
'''+s+r'''
int main(){Group group,other;Unit unit;unit.Group=&group;unit.Entity=&unit;
AgentUtility utility;Agent agent{&utility};Actor driver,passenger,player,unfit,transferred,foot;
for(auto*a:{&driver,&passenger,&player,&unfit,&transferred,&foot}){a->group=&group;a->vehicle=&unit;a->agent=&agent;unit.Members.push_back(a);}
player.player=true;unfit.fit=false;transferred.group=&other;foot.vehicle=nullptr;
unit.PlayerCaptureObserved=true;AbandonStuckVehicle(&unit);assert(unit.releases==0);
unit.PlayerCaptureObserved=false;ERR_ConvoyRules::captured=true;AbandonStuckVehicle(&unit);assert(unit.releases==0);
ERR_ConvoyRules::captured=false;AbandonStuckVehicle(&unit);
assert(unit.releases==1&&group.utility.detached==1&&utility.exits==2);
assert(driver.releases==1&&passenger.releases==1&&foot.releases==1);
assert(player.releases==0&&unfit.releases==0&&transferred.releases==0);
AbandonStuckVehicle(&unit);assert(unit.releases==1&&utility.exits==2);
}
''')
assert 'ObserveMovement(unit, now);\n if (unit.Released) continue;' in p.read_text()
print('PASS: actual abandonment releases once, detaches transport, requests native exits only for fit original AI aboard, and leaves players/captured crews alone')
+152
View File
@@ -0,0 +1,152 @@
"""Run the shipped initial/background scheduler with fake native work and clock."""
import re
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c'
s='\n'.join(body(p,signature) for signature in ('protected ERR_PrepareResult Finish(', 'ERR_PrepareResult Prepare(', 'protected void SearchStep(', 'protected void BackgroundStep()', 'void Stop()'))
s=re.sub(r'#ifdef WORKBENCH.*?#endif','',s,flags=re.S)
s=s.replace('protected ', '').replace('ERR_ScenarioDefinition definition', 'Definition* definition')
s=s.replace('array<vector> positions = {}, covers = {};', 'Array<int> positions, covers;')
s=s.replace('GetGame().GetCallqueue().CallLater(BackgroundStep, 500, true)', 'queue.Schedule(500)').replace('GetGame().GetCallqueue().Remove(BackgroundStep)', 'queue.Remove()')
s=s.replace('GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High)', 'Bounds(m_Low, m_High)').replace('GetGame().GetPlayerManager().GetPlayerCount()', 'players')
s=s.replace('m_Mode.Settings().Get("roadblockCount")', 'm_Mode->target').replace('m_Mode.Forces().Count()', 'm_Mode->ledger')
s=s.replace('m_Mode.', 'm_Mode->').replace('System.GetTickCount()', 'Now()').replace('Replication.IsServer()', 'server').replace('ERR_GameMode.Get()', 'current')
s=s.replace('ERR_PrepareResult.', 'Result::').replace('ERR_Phase.', 'Phase::').replace('ERR_RoadblockRules.', 'Rules::').replace('ERR_TerrainSites.Bounds', 'ValidBounds').replace('ERR_HQ.Event','Event').replace('Math.Max','std::max').replace('null','nullptr')
rules='\n'.join(body(p,sig) for sig in ('static int SearchMilliseconds(', 'static int SearchAttempts('))
run(r'''
#include <cassert>
#include <algorithm>
#include <string>
#include <vector>
using string=std::string;
namespace Result{enum Value{FAILED=-1,PENDING,READY};}using ERR_PrepareResult=Result::Value;
namespace Phase{enum{PREPARING,PRISON,ESCAPED,ENDED};}
namespace Rules{
'''+rules+r'''
}
int clockMs=0,players=1;bool server=true;
int Now(){return clockMs;}bool GetGame(){return true;}void Bounds(int&,int&){}bool ValidBounds(int,int){return true;}
template<class...T>void PrintFormat(T...){}void Event(string,string){}
template<class T>struct Array:std::vector<T>{int Count(){return this->size();}void Clear(){this->clear();}};
struct Queue{int scheduled=0,removed=0;bool active=false;void Schedule(int ms){assert(ms==500);scheduled++;active=true;}void Remove(){removed++;active=false;}};
struct Mode{int target=30,ledger=0,phase=Phase::PRISON;bool active=true;int Phase(){return phase;}bool Active(){return active;}};Mode* current=nullptr;
struct Definition{};
struct Mining{bool stopped=false;void Stop(){stopped=true;}};
struct Scheduler{
Mode* m_Mode;Definition* m_Definition=nullptr;Queue queue;Mining m_Mining;
bool m_BuildGroup=false;void BuildPost(){if(m_Mode->ledger>=250)CancelPost();}void CancelPost(){m_BuildGroup=false;}
bool m_Begun=false,m_Ready=false,m_Stopped=false,m_HaveCandidate=false,m_Background=false;
int m_Target=0,m_Attempts=0,m_Started=0,m_CandidateStarted=0,m_Candidates=0,m_Planned=0;
int m_NextSearch=0,m_NextReport=0,m_BackgroundSteps=0,m_BackgroundWork=0,m_MaxBackgroundStep=0,m_Low=0,m_High=0;
Array<int>m_Loaded,m_Positions,m_Groups;
int candidateCost=0,candidateCalls=0,planCalls=0,spawnCalls=0;bool accept=false,spawn=true,roster=true;int plan=2;
string Status(){return "status";}bool LoadRoster(){m_Loaded.push_back(1);return roster;}
bool Candidate(Definition*){candidateCalls++;clockMs+=candidateCost;if(accept){m_Candidates++;m_CandidateStarted=Now();}return accept;}
int Plan(Array<int>&,Array<int>&){planCalls++;return plan;}
bool SpawnPost(Array<int>&,Array<int>&){spawnCalls++;if(spawn){m_Groups.push_back(1);m_Positions.push_back(1);m_NextSearch=Now()+2000;}return spawn;}
'''+s+r'''
};
int main(){
Mode mode;current=&mode;Definition definition;Scheduler s;s.m_Mode=&mode;
// No candidates in the entire initial window: admission yields, never gives up.
assert(s.Prepare(&definition)==Result::PENDING&&s.candidateCalls==8);
clockMs=12000;assert(s.Prepare(&definition)==Result::READY&&s.m_Background&&s.queue.active&&s.m_Definition==&definition);
assert(s.m_Loaded.Count()==1&&s.m_Positions.Count()==0&&s.queue.scheduled==1);
assert(s.Prepare(&definition)==Result::READY&&s.queue.scheduled==1);
// Empty, full ledger and inactive/initializing cases cannot do terrain work.
players=0;s.BackgroundStep();assert(s.candidateCalls==8);
players=1;mode.ledger=250;s.BackgroundStep();assert(s.candidateCalls==8);mode.ledger=0;
mode.phase=Phase::PREPARING;s.BackgroundStep();assert(s.candidateCalls==8);mode.phase=Phase::ESCAPED;
// Native operations yield between samples if the cooperative time budget expires.
s.candidateCost=3;s.BackgroundStep();assert(s.candidateCalls==9&&s.m_MaxBackgroundStep==3);
s.candidateCost=0;s.BackgroundStep();assert(s.candidateCalls==17);
// Arbitrarily many exhausted attempts do not end background placement.
s.m_Attempts=50000;clockMs+=60000;s.BackgroundStep();assert(s.m_Background&&s.m_Attempts==50008);
s.accept=true;
// Candidate planning/physical creation occur on a separate callback.
s.BackgroundStep();assert(s.m_HaveCandidate&&s.planCalls==0);
s.plan=1;clockMs+=500;s.BackgroundStep();assert(s.m_HaveCandidate&&s.spawnCalls==0);
clockMs+=2000;s.BackgroundStep();assert(!s.m_HaveCandidate&&s.spawnCalls==0);s.plan=2;
// Failed creation remains pending. No partial count or allowance is consumed.
s.spawn=false;s.BackgroundStep();s.BackgroundStep();assert(s.m_Positions.Count()==0&&s.m_Background);s.spawn=true;
// Finish all 30 after prison unlock, at most one physical post per callback.
for(int i=0;i<30;i++){
s.BackgroundStep();assert(s.m_Positions.Count()==i);
s.BackgroundStep();assert(s.m_Positions.Count()==i+1);
int samples=s.candidateCalls;s.BackgroundStep();assert(s.candidateCalls==samples); // two-second spawn cooldown
clockMs+=2000;
}
assert(!s.m_Background&&!s.queue.active&&s.m_Loaded.Count()==0&&!s.m_Definition&&s.m_Ready);
int calls=s.spawnCalls;s.m_Groups.clear(); // killed/removed groups never refund the round's posts
s.BackgroundStep();assert(s.spawnCalls==calls&&s.Prepare(&definition)==Result::READY&&s.m_Positions.Count()==30);
// A newly full ledger must cancel a partial post instead of leaving actors stranded.
{Scheduler pending;pending.m_Mode=&mode;pending.Prepare(&definition);clockMs+=12000;pending.Prepare(&definition);
pending.m_BuildGroup=true;mode.ledger=250;pending.BackgroundStep();assert(!pending.m_BuildGroup);mode.ledger=0;}
// Attempt ceiling also hands off, without waiting for the wall-clock deadline.
clockMs=0;Scheduler attempts;attempts.m_Mode=&mode;attempts.Prepare(&definition);attempts.m_Attempts=300;
assert(attempts.Prepare(&definition)==Result::READY&&attempts.m_Background);
mode.active=false;attempts.BackgroundStep();assert(attempts.m_Stopped&&!attempts.queue.active&&attempts.m_Mining.stopped&&!attempts.m_Definition);
mode.active=true;Scheduler ended;ended.m_Mode=&mode;ended.Prepare(&definition);clockMs=12000;ended.Prepare(&definition);current=nullptr;
ended.BackgroundStep();assert(ended.m_Stopped&&!ended.queue.active);current=&mode;
mode.target=0;Scheduler disabled;disabled.m_Mode=&mode;assert(disabled.Prepare(&definition)==Result::READY&&!disabled.queue.active);
mode.target=30;Scheduler missing;missing.m_Mode=&mode;missing.roster=false;assert(missing.Prepare(&definition)==Result::READY&&!missing.queue.active);
}
''')
# Every background plan and physical transaction still uses the original safety checks.
assert 'if (!mesh || !PlayersClear()) return 0;' in body(p,'protected int Plan(')
assert 'if (!PlayersClear() || m_Mode.Forces().Count() >= 250 ||' in body(p,'protected bool SpawnPost(')
assert 'if (!PlayersClear() || m_Mode.Forces().Count() >= 250) { CancelPost(); return; }' in body(p,'protected void BuildPost(')
print('PASS: actual roadblock scheduler yields initial deadline/attempt cap to background, budgets samples, pauses empty/full worlds, completes 30 after unlock, preserves safety and never replenishes losses; teardown cancels work')
# Run actual staged physical creation and rollback, with native entities doubled.
s='\n'.join(body(p,sig) for sig in ('protected bool SpawnPost(', 'protected void CancelPost()', 'protected void BuildPost()'))
s=s.replace('protected ', '').replace('array<vector> positions','Array<int>& positions').replace('array<vector> covers','Array<int>& covers')
s=s.replace('SCR_AIGroup.Cast','Node::Cast').replace('AIWaypoint.Cast','Node::Cast').replace('IEntity actor','Node* actor').replace('IEntity cover','Node* cover')
s=s.replace('foreach (IEntity ownedEntity : m_BuildSpawned)','for (Node* ownedEntity : m_BuildSpawned)').replace('vector point','int point').replace('vector coverPosition','int coverPosition')
s=s.replace('m_Forward.VectorToAngles()', '0').replace('m_Positions.Count().ToString()', 'std::to_string(m_Positions.Count())')
s=s.replace('GetGame().GetWorld().GetWorldTime()', 'Now()').replace('System.GetTickCount()', 'Now()').replace('null','nullptr')
s=s.replace('m_BuildGroup.', 'm_BuildGroup->').replace('m_BuildHold.', 'm_BuildHold->')
for ns in ('ERR_Actors','ERR_Resources','ERR_ForceKind'):s=s.replace(ns+'.',ns+'::')
run(r'''
#include <cassert>
#include <string>
#include <vector>
using string=std::string;
int Now(){return 100;}
template<class...T>void PrintFormat(T...){}
template<class T>struct Array:std::vector<T>{using std::vector<T>::vector;using std::vector<T>::operator=;int Count(){return this->size();}void Clear(){this->clear();}void Copy(Array<T>&other){*this=other;}void Insert(T value){this->push_back(value);}};
struct Node{static Node* Cast(Node*n){return n;}void SetFaction(int){}void SetSpawnImmediately(bool){}void SetDeleteWhenEmpty(bool){}void SetPermanentLOD(int){}bool AddAIEntityToGroup(Node*){return true;}void SetCompletionRadius(int r){assert(r==25);}void AddWaypoint(Node*){}void ActivateAI(){}};
namespace ERR_Actors{int calls=0,failAt=0;Array<Node*> all;Node* Spawn(string,int,int=0){calls++;if(calls==failAt)return nullptr;auto*n=new Node;all.Insert(n);return n;}bool SetFaction(Node*,int){return true;}}
namespace ERR_Resources{string DefendWaypoint(){return "hold";}}
namespace ERR_ForceKind{int CHECKPOINT=1;}
struct Ledger{int count=0,registered=0;bool accept=true;int Count(){return count;}bool Register(string,int,int,int strength,void*,Node*,int){assert(strength==4);if(!accept)return false;registered++;return true;}};
struct Mode{Ledger ledger;int owned=0,groups=0;Ledger& Forces(){return ledger;}int EnemyFaction(){return 1;}void Own(Node*n){assert(n);owned++;}void RegisterGroup(Node*){groups++;}};
struct Placement{bool clear=true;bool Clear(int,void*){return clear;}};
struct Mining{int registered=0;void Register(Node*,Node*,int){registered++;}};
struct Build{
Mode m_Mode;Placement m_Placement;Mining m_Mining;Node* m_BuildGroup=nullptr;Node* m_BuildHold=nullptr;
Array<int> m_BuildPositions,m_BuildCovers,m_Positions;Array<Node*>m_BuildSpawned,m_Groups;Array<string>m_Roster={"one","two"};
int m_BuildActors=0,m_BuildCoverCount=0,m_Centre=10,m_Right=0,m_NextSearch=0,rolledBack=0;string COVER="cover";bool playersClear=true,coversClear=true;
bool PlayersClear(){return playersClear;}bool CoverClear(int){return coversClear;}
void Rollback(Array<Node*>&entities,Node*group){rolledBack+=entities.Count()+(group?1:0);}
'''+s+r'''
};
int main(){Array<int> positions={1,2,3,4},covers={5,6};
{Build b;int start=ERR_Actors::calls;assert(b.SpawnPost(positions,covers));assert(ERR_Actors::calls==start+1);
for(int piece=0;piece<7;piece++){int calls=ERR_Actors::calls;b.BuildPost();assert(ERR_Actors::calls==calls+1&&b.m_Positions.Count()==0&&b.m_Mode.owned==0);}
b.BuildPost();assert(!b.m_BuildGroup&&b.m_Positions.Count()==1&&b.m_Groups.Count()==1&&b.m_Mode.owned==8&&b.m_Mode.groups==1&&b.m_Mining.registered==1&&b.m_NextSearch==2100);
}
// Player approaches at any stage: no registered post and every owned piece removed.
for(int stage=0;stage<=7;stage++){Build b;assert(b.SpawnPost(positions,covers));for(int i=0;i<stage;i++)b.BuildPost();b.playersClear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.m_Positions.Count()==0&&b.m_Mode.owned==0&&b.rolledBack==stage+1);}
// Any native entity creation failure rolls back rather than consuming a post.
for(int stage=0;stage<8;stage++){Build b;ERR_Actors::failAt=ERR_Actors::calls+stage+1;bool begun=b.SpawnPost(positions,covers);
if(stage==0)assert(!begun);else{for(int i=0;i<stage;i++)b.BuildPost();assert(b.rolledBack==stage);}
assert(!b.m_BuildGroup&&b.m_Positions.Count()==0&&b.m_Mode.owned==0);ERR_Actors::failAt=0;
}
{Build b;b.SpawnPost(positions,covers);b.m_Placement.clear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==1);}
{Build b;b.SpawnPost(positions,covers);for(int i=0;i<4;i++)b.BuildPost();b.coversClear=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==5);}
{Build b;b.SpawnPost(positions,covers);for(int i=0;i<7;i++)b.BuildPost();b.m_Mode.ledger.accept=false;b.BuildPost();assert(!b.m_BuildGroup&&b.rolledBack==8&&b.m_Positions.Count()==0);}
{Build b;b.SpawnPost(positions,covers);b.BuildPost();b.CancelPost();assert(!b.m_BuildGroup&&b.rolledBack==2&&b.m_Mode.owned==0);}
for(auto*n:ERR_Actors::all)delete n;
}
''')
print('PASS: actual staged post creation spawns at most one entity per callback, commits only all four guards/two covers/waypoint, and rolls back player approach, obstructions, spawn/ledger failure and cancellation')
+8 -6
View File
@@ -1,9 +1,10 @@
"""Execute road suitability/spacing and validate admin/settings integration."""
from policy_harness import ROOT, body, run
p=ROOT/'addons/core/Scripts/Game/ERR/10_AI/ERR_Roadblocks.c'
code=body(p,'static bool Road(')+'\n'+body(p,'static bool Spaced(')
code=code.replace('array<vector> used','Array<Vec>* used').replace('vector ', 'Vec ').replace('foreach (','for (').replace(' : used)', ' : *used)').replace('vector.DistanceXZ','Vec::DistanceXZ').replace('ERR_ForceRules.FiniteVector','Finite').replace('ERR_ForceRules.Finite','std::isfinite').replace('Math.AbsFloat','std::abs')
code=body(p,'static int SearchMilliseconds(')+'\n'+body(p,'static int SearchAttempts(')+'\n'+body(p,'static bool Road(')+'\n'+body(p,'static bool Spaced(')
code=code.replace('array<vector> used','Array<Vec>* used').replace('vector ', 'Vec ').replace('foreach (','for (').replace(' : used)', ' : *used)').replace('vector.DistanceXZ','Vec::DistanceXZ').replace('ERR_ForceRules.FiniteVector','Finite').replace('ERR_ForceRules.Finite','std::isfinite').replace('Math.AbsFloat','std::abs').replace('Math.Clamp','std::clamp')
run(r'''
#include <algorithm>
#include <cassert>
#include <cmath>
#include <limits>
@@ -13,6 +14,7 @@ bool Finite(Vec v){return std::isfinite(v.x)&&std::isfinite(v.y)&&std::isfinite(
template<class T>using Array=std::vector<T>;
'''+code+r'''
int main(){
assert(SearchMilliseconds()==12000 && SearchAttempts()==300);
float nan=std::numeric_limits<float>::quiet_NaN();
assert(Road(4,40,2)&&Road(16,100,-2));
assert(!Road(3.9,100,0)&&!Road(17,100,0)&&!Road(6,39,0));
@@ -38,10 +40,10 @@ valid=re.search(r'if \(key == "roadblockCount"\) return ([^;]+);',body(settings,
default=re.search(r'if \(key == "roadblockCount"\) return ([^;]+);',body(settings,'static int AddedDefault('))[1]
run('#include <cassert>\nbool valid(int value){return '+valid+';}\nint main(){assert(!valid(-1)&&valid(0)&&valid(4)&&valid(12)&&valid(13)&&valid(30)&&!valid(31));assert('+default+'==4);}')
s=p.read_text()
assert s.index('m_Mode.Forces().Register(')<s.index('m_Mode.RegisterGroup(group)')
assert 'm_Groups.Insert(group)' in s and 'm_Mode.Own(ownedEntity)' in s
assert 'm_Attempts >= 300' in s and '>= 12000' in s and 'm_Ready) return ERR_PrepareResult.READY' in s
assert s.index('m_Mode.Forces().Register(')<s.index('m_Mode.RegisterGroup(m_BuildGroup)')
assert 'm_Groups.Insert(m_BuildGroup)' in s and 'm_Mode.Own(ownedEntity)' in s
assert 'm_Attempts >= ERR_RoadblockRules.SearchAttempts()' in s and '>= ERR_RoadblockRules.SearchMilliseconds()' in s and 'm_Ready) return ERR_PrepareResult.READY' in s
assert 'if (m_Target == 0) return Finish("disabled")' in s
assert 'm_Groups.Count() >= m_Target' in s
assert 'm_Positions.Count() >= m_Target' in s
assert 'roadblockCount' not in body(settings,'static bool Randomizable(')
print('PASS: road width/bridge/length checks, prison and post spacing, setting bounds/default, finite startup and owned HQ admission')
+23
View File
@@ -72,3 +72,26 @@ int main(){
}
''')
print('PASS: shipped sheet geometry covers square, rectangular and small terrains with overlap and shifted origins; regional grids cover all detail pages with round-trip numbering; Chernarus has 12 x 9 fixed detail sheets')
component=ROOT/'addons/core/Scripts/Game/ERR/30_Map/ERRM_SharedMapComponent.c'
wire='\n'.join(body(component,sig) for sig in ('override bool RplSave(', 'override bool RplLoad('))
wire=wire.replace('override ', '').replace('ScriptBitWriter writer', 'ScriptBitWriter& writer').replace('ScriptBitReader reader','ScriptBitReader& reader').replace('super.','Native::')
run(r'''
#include <cassert>
#include <cstdint>
#include <vector>
struct ScriptBitWriter{std::vector<int32_t> data;void WriteInt(int value){data.push_back(value);}};
struct ScriptBitReader{std::vector<int32_t> data;bool ReadInt(int& value){if(data.empty())return false;value=data[0];data.erase(data.begin());return true;}};
struct Native{int saves=0,loads=0;bool RplSave(ScriptBitWriter&){saves++;return true;}bool RplLoad(ScriptBitReader&){loads++;return true;}};
struct Map:Native{int m_ESMPage=-1,m_ESMLastSelectedPage=-1;
'''+wire+r'''
};
int main(){
for(int page:{0,4097,16777214,16777215,16777216,16777227,16777615}){
Map source,target;source.m_ESMPage=page;ScriptBitWriter writer;assert(source.RplSave(writer)&&source.saves==1);
ScriptBitReader reader{writer.data};assert(target.RplLoad(reader)&&target.loads==1&&target.m_ESMPage==page&&target.m_ESMLastSelectedPage==page);
}
Map missing;ScriptBitReader empty;assert(!missing.RplLoad(empty)&&missing.loads==0&&missing.m_ESMLastSelectedPage==-1);
}
''')
print('PASS: actual map replication round-trips cover, overview, detail and regional pages, including the failing server value 16777227; truncated reads fail')
+62
View File
@@ -0,0 +1,62 @@
"""Execute native deployment init with delayed mode arrival and teardown."""
from policy_harness import ROOT, body, run
p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainControllerInit.c'
for kind in ('Deploy',):
protected = 'protected ' if kind == 'Deploy' else ''
init_signature = 'protected override void OnPostInit(' if kind == 'Deploy' else '\n override void OnPostInit('
code = '\n'.join(body(p, signature) for signature in (
init_signature, f'protected void ERR_Retry{kind}Init(', f'override {protected}void OnDelete('))
for before, after in (
('protected ', ''), ('override ', ''), ('IEntity owner', 'IEntity* owner'),
('owner.', 'owner->'), ('GetGame().', 'GetGame()->'), ('GetCallqueue().', 'GetCallqueue()->'),
('super.', 'Base::'), ('null', 'nullptr'), ('LogLevel.ERROR', '1'),
(f'CallLater(ERR_Retry{kind}Init,', 'CallLater(1,'),
(f'Remove(ERR_Retry{kind}Init)', 'Remove(1)'),
):
code = code.replace(before, after)
fields = f'''
IEntity* m_ERRDeferred{kind}Owner=nullptr;
World* m_ERRDeferred{kind}World=nullptr;
int m_ERR{kind}InitAttempts=0;
bool m_ERR{kind}InitComplete=false;
'''
run(r'''
#include <cassert>
struct World{};
struct IEntity{World* world;World* GetWorld(){return world;}};
struct Queue{int scheduled=0,removed=0;void CallLater(int,int delay,bool repeat){assert(delay==50&&!repeat);scheduled++;}void Remove(int){removed++;}};
struct Game{World* world=nullptr;bool pending=false,mode=false;Queue queue;
bool ERR_ClientTerrainModePending(){return pending;}bool GetGameMode(){return mode;}
World* GetWorld(){return world;}Queue* GetCallqueue(){return &queue;}} game;
Game* current=&game;Game* GetGame(){return current;}
void Print(const char*,int=0){}
struct Base{int initialized=0,deleted=0;void OnPostInit(IEntity*){initialized++;}void OnDelete(IEntity*){deleted++;}};
struct Component:Base{
''' + fields + code + r'''
};
int main(){
World first,next;IEntity owner{&first};game.world=&first;
// Ordinary startup still calls native initialization immediately, only once.
game.mode=true;Component ordinary;ordinary.OnPostInit(&owner);ordinary.OnPostInit(&owner);
assert(ordinary.initialized==1&&game.queue.scheduled==0);
game.pending=true;game.mode=false;Component delayed;
delayed.OnPostInit(&owner);delayed.OnPostInit(&owner);
assert(delayed.initialized==0&&game.queue.scheduled==1);
delayed.RETRY();assert(delayed.initialized==0&&game.queue.scheduled==2);
game.mode=true;game.pending=false;delayed.RETRY();delayed.RETRY();delayed.OnPostInit(&owner);
assert(delayed.initialized==1);
// Deletion cancels pending initialization; even a late callback is harmless.
game.pending=true;game.mode=false;Component deleted;deleted.OnPostInit(&owner);deleted.OnDelete(&owner);
game.mode=true;deleted.RETRY();assert(deleted.initialized==0&&deleted.deleted==1&&game.queue.removed==1);
// World replacement must not initialize a controller from the outgoing round.
game.mode=false;Component stale;stale.OnPostInit(&owner);game.world=&next;game.mode=true;stale.RETRY();
assert(stale.initialized==0);
game.world=&first;game.mode=false;Component timeout;int before=game.queue.scheduled;timeout.OnPostInit(&owner);
for(int i=0;i<250;i++)timeout.RETRY();
assert(timeout.initialized==0&&game.queue.scheduled-before==200);
game.mode=true;timeout.RETRY();assert(timeout.initialized==0);
}
'''.replace('RETRY', f'ERR_Retry{kind}Init'))
assert 'modded class SCR_RespawnComponent' not in p.read_text(), 'Native respawn RPC registration must stay immediate'
print('PASS: deployment init waits for mode, runs once, cancels on deletion/world change and bounds retries; respawn RPC registration stays immediate')
+15 -10
View File
@@ -121,6 +121,8 @@ struct string:std::string {
void Replace(string a,string b){size_t n=0;while((n=find(a,n))!=npos){replace(n,a.size(),b);n+=b.size();}}
string Trim(){auto a=find_first_not_of(" \r\n");if(a==npos)return "";return substr(a,find_last_not_of(" \r\n")-a+1);}
string GetPath(){auto n=find('}');return n==npos?*this:string(substr(n+1));}
bool IsExternal(){return size()>18&&front()=='{'&&at(17)=='}';}
bool IsInternal(){return size()==18&&front()=='{'&&at(17)=='}';}
};
string StripPath(string s){auto n=s.find_last_of('/');return n==s.npos?s:string(s.substr(n+1));}
template<class T>struct Array:std::vector<T>{void Insert(T v){this->push_back(v);}bool Contains(T v){return std::find(this->begin(),this->end(),v)!=this->end();}};
@@ -128,16 +130,19 @@ struct Container{string parent;bool Get(string& p){p=parent;return !p.IsEmpty();
struct Resource{Container container;bool valid=true;bool IsValid(){return valid;}Resource* GetResource(){return this;}Container* ToBaseContainer(){return &container;}};
std::map<string,Resource> resources;int calls=0;
Resource* Load(string world){calls++;auto it=resources.find(world);return it==resources.end()?nullptr:&it->second;}
string Res(string path){return "{0123456789ABCDEF}"+path;}
struct Names {
'''+resolver+r'''
};
int main(){
resources["Worlds/GM/Conflict.ent"].container.parent="Worlds/Armenhof/Armenhof.ent";
resources["Worlds/Armenhof/Armenhof.ent"];
assert(Names::Resolve("Worlds/GM/Conflict.ent")=="Armenhof");
resources["Worlds/Nogova/Nogova_Test.ent"].container.parent="Worlds/Nogova/Nogova.ent";
resources["Worlds/Nogova/Nogova.ent"];
assert(Names::Resolve("Worlds/Nogova/Nogova_Test.ent")=="Nogova");
resources[Res("Worlds/GM/Conflict.ent")].container.parent=Res("Worlds/Armenhof/Armenhof.ent");
resources[Res("Worlds/Armenhof/Armenhof.ent")];
assert(Names::Resolve(Res("Worlds/GM/Conflict.ent"))=="Armenhof");
resources[Res("Worlds/Nogova/Nogova_Test.ent")].container.parent=Res("Worlds/Nogova/Nogova.ent");
resources[Res("Worlds/Nogova/Nogova.ent")];
assert(Names::Resolve(Res("Worlds/Nogova/Nogova_Test.ent"))=="Nogova");
calls=0;assert(Names::Resolve("worlds/Eden/Eden.ent")=="Everon"&&calls==0);
assert(Names::Resolve("$NogovaReforger:Worlds/Nogova/Nogova.ent")=="Nogova"&&calls==0);
assert(Names::Label("{853E92315D1D9EFE}worlds/Eden/Eden.ent")=="Everon");
assert(Names::Resolve("Worlds/New_Island.ent")=="New Island");
assert(Names::Label("Worlds/GameMaster/GM_Armenhof.ent")=="Armenhof");
@@ -150,10 +155,10 @@ int main(){
assert(Names::Path("{bad}Worlds/Island.ent")=="{bad}Worlds/Island.ent");
assert(Names::Label("$NogovaReforger:Worlds/Nogova/Nogova_Test.ent")=="Nogova");
resources["a.ent"].container.parent="b.ent";resources["b.ent"].container.parent="a.ent";
calls=0;assert(Names::Resolve("a.ent")=="B"&&calls==2);
for(int i=0;i<30;i++)resources[std::to_string(i)+".ent"].container.parent=std::to_string(i+1)+".ent";
calls=0;assert(Names::Resolve("0.ent")=="16"&&calls==16);
resources[Res("a.ent")].container.parent=Res("b.ent");resources[Res("b.ent")].container.parent=Res("a.ent");
calls=0;assert(Names::Resolve(Res("a.ent"))=="B"&&calls==2);
for(int i=0;i<30;i++)resources[Res(std::to_string(i)+".ent")].container.parent=Res(std::to_string(i+1)+".ent");
calls=0;assert(Names::Resolve(Res("0.ent"))=="16"&&calls==16);
}
''')
print('PASS: actual terrain parent traversal, Armenhof/Nogova names, Everon alias, missing-resource fallback, empty names and bounded cycles/depth')
+2 -1
View File
File diff suppressed because one or more lines are too long
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Post a prepared changelog after verifying a successful push to origin/main.
The webhook is local configuration, never a command-line argument or tracked file.
API: https://docs.discord.com/developers/resources/webhook#execute-webhook
"""
import argparse
import json
import shlex
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--commit', required=True, help='Commit just pushed to origin/main')
parser.add_argument('--message-file', required=True, type=Path, help='Prepared player-facing changelog, at most 2000 characters')
args = parser.parse_args()
commit = subprocess.check_output(['git', 'rev-parse', '--verify', args.commit + '^{commit}'], cwd=ROOT, text=True).strip()
ssh = 'ssh -F ' + shlex.quote(str(Path.home() / '.ssh/config'))
remote = subprocess.check_output(['git', '-c', 'core.sshCommand=' + ssh, 'ls-remote', '--exit-code', 'origin', 'refs/heads/main'], cwd=ROOT, text=True).split()[0]
if commit != remote:
parser.error('Requested commit is not the current origin/main tip; no message sent.')
message = args.message_file.read_text().strip()
if not message or len(message) > 2000:
parser.error('Changelog must contain 1..2000 characters.')
state_path = ROOT / '.local/discord-changelog-state.json'
state = json.loads(state_path.read_text()) if state_path.exists() else {}
if state.get('commit') == commit:
print('This commit is already recorded; no duplicate sent. Status: ' + state.get('status', 'unknown'))
return
url = (ROOT / '.local/discord-webhook.url').read_text().strip()
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != 'https' or parsed.netloc != 'discord.com' or not parsed.path.startswith('/api/webhooks/') or parsed.query or parsed.fragment:
parser.error('Local configuration must contain a Discord HTTPS webhook URL.')
payload = json.dumps({'content': message, 'allowed_mentions': {'parse': []}}).encode()
request = urllib.request.Request(url + '?wait=true', data=payload, headers={
'Content-Type': 'application/json', 'User-Agent': 'EscapistsReintroduced-Changelog/1.0'})
# Preserve an uncertain attempt so a timeout cannot silently cause duplicates.
state_path.write_text(json.dumps({'commit': commit, 'status': 'pending'}) + '\n')
try:
with urllib.request.build_opener(NoRedirect()).open(request, timeout=30) as response:
result = json.load(response)
if not result.get('id'):
raise ValueError('Missing message confirmation')
except urllib.error.HTTPError as error:
print('Discord returned HTTP ' + str(error.code) + '; inspect delivery before retrying.', file=sys.stderr)
return 1
except (urllib.error.URLError, TimeoutError, ValueError):
print('Discord delivery could not be confirmed; inspect delivery before retrying.', file=sys.stderr)
return 1
state_path.write_text(json.dumps({'commit': commit, 'status': 'sent', 'message_id': result['id']}) + '\n')
print('Discord changelog delivered for ' + commit[:7])
return 0
if __name__ == '__main__':
sys.exit(main())
+2 -2
View File
@@ -14,7 +14,7 @@ p.add_argument('--steam', type=Path, default=Path.home()/'.local/share/Steam')
p.add_argument('--external-addons', type=Path, help='Installed RHS/WCS/BMP source root; expose only audited closure in a separate Chat profile')
p.add_argument('--compat', type=Path, required=True, help='Disposable cloned Proton compatdata directory outside Steam')
p.add_argument('--profile', type=Path, required=True, help='Separate disposable successor Workbench profile')
p.add_argument('--smoke', choices=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--smoke', choices=['coordinated-ai', 'crash-start', 'crash-geometry', 'normal-escape-route', 'escape-route', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'hq-local-handoff', 'infantry-transfer', 'hq-body', 'convoy', 'supply-convoys', 'supply-travel', 'mounted-ammo', 'alarm-hearing', 'hq-memory', 'hq-awareness', 'aircraft-field', 'aircraft-route', 'native-end-active', 'native-end-preparing', 'aircraft-control', 'aircraft-cancel', 'aircraft-translate', 'map-ownership', 'mortar-refresh', 'mortar-salvo', 'mortar-courier', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'mortar-expiry', 'mortar-transfer', 'mortar-fire', 'patrol-hq', 'victory', 'lost-helicopter', 'player-death', 'radio-network', 'hq-response', 'mortar-inventory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'], default='victory')
p.add_argument('--trace-random-radius', action='store_true', help='Workbench-only read-only zero-radius caller traces; one normal Chat round, capped at sixteen records')
p.add_argument('--maintenance-refuel', action='store_true', help='Opt-in normal-route check: original held Southern fuel can transfers finite fuel through its native UAZ action; one arranged tank depletion, no stock creation')
p.add_argument('--mounted-swap-eligibility', action='store_true', help='Read-only mounted-ammo variant: one exact finite cargo/mounted magazine CanSwap call; no transfer or radio proof')
@@ -158,7 +158,7 @@ if a.recon_takeover_only: command += ['-errReconTakeoverOnly', '1']
if a.body_recovery_only: command += ['-errBodyRecoveryOnly', '1']
if a.coordinated_blocked_only: command += ['-errCoordinatedBlockedOnly', '1']
if a.project == 'core':
if a.smoke not in {'coordinated-ai', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'native-end-active', 'native-end-preparing', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
if a.smoke not in {'coordinated-ai', 'optional-site-skip', 'infantry-combat', 'medical-combat', 'hunter-faction', 'unconscious-camera', 'objective-map', 'town-garrisons', 'site-garrisons', 'radio-interaction', 'enemy-loadout', 'aggression', 'air-logistics', 'air-assault', 'air-recon', 'airfield-markers', 'opening-tools', 'mortar-station', 'generated-sites', 'roadblocks', 'native-end-active', 'native-end-preparing', 'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'victory', 'startup-failure', 'site-survey', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'}: p.error('Unsupported Core-only fixture')
command[command.index('-plugin=ERR_ValidateWorldPlugin')] = '-plugin=ERR_ValidateBaseWorldPlugin'
command[command.index('-errSmoke')] = '-errBaseSmoke'
if a.smoke in {'station-teardown', 'inventory-hitzone', 'prison-breach', 'powered-flight', 'powered-translate', 'local-admission', 'cache-loss', 'extraction-capacity', 'early-helicopter-loss', 'extraction-startup-failure', 'extraction-boundary-failure', 'transport-loss', 'transport-survey', 'lifecycle', 'objective-ui', 'recovery', 'prisoner-kit', 'medical', 'casualty-load', 'casualty-direct', 'casualty-bench', 'casualty-remove', 'environment', 'environment-restart', 'patrols', 'radio-document-loss', 'radio-document-loss-partial', 'guard-cancel', 'casualty-service'} and a.project != 'core': p.error('This fixture requires --project core')