Compare commits
36
Commits
ae9aaafaae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828bb0a1a7 | ||
|
|
4f02ad64b4 | ||
|
|
6ba3036de6 | ||
|
|
b8dfa9184e | ||
|
|
0e1e7790f9 | ||
|
|
2c2158702d | ||
|
|
d21adb90d5 | ||
|
|
6738eda748 | ||
|
|
2dcbe3107d | ||
|
|
3ec882dc4e | ||
|
|
08e5cbf5ea | ||
|
|
681d5e5eae | ||
|
|
213c31dfb2 | ||
|
|
5453159867 | ||
|
|
c6ee764c6c | ||
|
|
970dd338f5 | ||
|
|
95d21b06ff | ||
|
|
9233320fd1 | ||
|
|
6397fe89b6 | ||
|
|
3768ce7ed8 | ||
|
|
253dde90cd | ||
|
|
ac285b5c97 | ||
|
|
f41d8ba2f9 | ||
|
|
87d85ff02e | ||
|
|
ae2aedd131 | ||
|
|
cc322572b4 | ||
|
|
372fca7a68 | ||
|
|
82b52ae1fc | ||
|
|
d5464f7438 | ||
|
|
0e71ef1fa7 | ||
|
|
38fd8f7dd2 | ||
|
|
8f2e1f210d | ||
|
|
7a7ac59edf | ||
|
|
96f471ef4a | ||
|
|
f15c915e54 | ||
|
|
a66aca35bf |
+31
@@ -0,0 +1,31 @@
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: discord-changelog
|
||||
|
||||
clone:
|
||||
depth: 0
|
||||
|
||||
steps:
|
||||
- name: test-changelog
|
||||
image: python:3.13-alpine
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- python tools/build_admin_menu.py --check
|
||||
- python -m unittest discover -s tests -p test_admin_menu_sources.py
|
||||
- python -m unittest discover -s tests -p test_discord_changelog_ci.py
|
||||
- python -m unittest discover -s tests -p test_workshop_changelog.py
|
||||
|
||||
- name: notify-discord
|
||||
image: python:3.13-alpine
|
||||
environment:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
from_secret: discord_webhook_url
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- python tools/discord_changelog_ci.py
|
||||
|
||||
trigger:
|
||||
branch:
|
||||
- main
|
||||
event:
|
||||
- push
|
||||
@@ -1,23 +1,32 @@
|
||||
# Push changelogs
|
||||
|
||||
The user authorized Discord changelogs after new changes are successfully pushed.
|
||||
After each successful push to `origin/main` from this checkout:
|
||||
Drone is the sole Discord changelog sender. Its first delivery was verified in
|
||||
build #1 on 2026-10-17, and the user confirmed receipt and disabled local sending.
|
||||
Do not run a local/manual Discord sender after pushes or as an automatic fallback.
|
||||
`tools/discord_changelog.py` is disabled and cannot send messages.
|
||||
|
||||
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.
|
||||
For player-facing changes, maintain the newest entry in `CHANGELOG.md` before
|
||||
pushing to `origin/main`. Keep it concise and describe only fixes/features, without
|
||||
server, deployment, Git push status, operational disclaimers or footers. Do not
|
||||
backfill old changes unless requested. Internal workflow-only changes need no
|
||||
announcement.
|
||||
|
||||
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.
|
||||
After a confirmed successful Workbench Workshop upload, run
|
||||
`python3 tools/workshop_changelog.py --published` before committing the changelog.
|
||||
This reads the local publishing manifest and records the version in the changelog
|
||||
preamble. Compilation/bundling alone is not confirmation of publication; check
|
||||
the successful upload result first. With no flag the helper only displays the
|
||||
bundled version. Drone includes the recorded version with new changelog entries;
|
||||
a version-only edit does not announce an old entry again.
|
||||
|
||||
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.
|
||||
The `.drone.yml` pipeline posts the latest changed entry using Drone's
|
||||
`discord_webhook_url` repository secret. See `docs/discord-changelog-ci.md`.
|
||||
Check the Drone result and notification log when available; a successful Git push
|
||||
alone does not prove delivery. Report delivery failures honestly. Inspect Discord
|
||||
before rerunning an uncertain delivery or rebuilding the same push, since builds
|
||||
do not share delivery state and retries can produce duplicates.
|
||||
|
||||
The saved local webhook in `.local/discord-webhook.url` is no longer used for
|
||||
post-push notifications. Never print it, commit it, or put it into changelogs or
|
||||
command-line arguments. Do not configure an additional Discord push webhook
|
||||
alongside Drone.
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Changelog
|
||||
|
||||
Latest published Workshop version: **0.0.139**
|
||||
|
||||
## 2026-11-03 — Spectator body camera and player names
|
||||
|
||||
- Spectator cycling now includes your own unconscious body, with ground-level framing and orbit controls.
|
||||
- The statistics dashboard now shows player names alongside their account IDs.
|
||||
|
||||
## 2026-11-01 — Dedicated-server names
|
||||
|
||||
- Server-name telemetry now reads the running dedicated-server configuration before checking lobby metadata.
|
||||
|
||||
## 2026-11-01 — Statistics dashboard
|
||||
|
||||
- Added a private statistics webpage with server win/loss records, last received reports, mission details, player totals and event history, with manual refresh.
|
||||
- Mission telemetry automatically reports server names and keeps dashboard labels updated when servers are renamed.
|
||||
|
||||
## 2026-11-01 — Checkpoints and reconnects
|
||||
|
||||
- Added larger checkpoints with eight guards, extra sandbags and longer barrier approaches on wide roads.
|
||||
- Added separate small/large checkpoint counts and a road-width cutoff under Forces → Checkpoints.
|
||||
- Fixed saving mission settings on fresh profiles.
|
||||
- Returning players receive a fresh soldier when their reserved body is gone, until extraction begins.
|
||||
|
||||
## 2026-10-31 — Terrain mission text
|
||||
|
||||
- Terrain takeover now shows Escape’s mission description on the loading screen instead of the original terrain scenario’s text.
|
||||
|
||||
## 2026-10-31 — Extraction finish
|
||||
|
||||
- Escape statistics open after 30 seconds of airborne evacuation instead of a long flight across the escape boundary.
|
||||
- Rescue helicopters remain safely in place while the results screen is open.
|
||||
|
||||
## 2026-10-31 — Recovery forecast
|
||||
|
||||
- All-down rounds now check whether anyone can wake within five minutes before starting the defeat countdown, while preserving usable self-epinephrine opportunities.
|
||||
|
||||
## 2026-10-30 — Passenger handbrake
|
||||
|
||||
- The front passenger can hold Space to apply the handbrake and release it to let go.
|
||||
|
||||
## 2026-10-30 — Roadblocks, fuel pumps and garage vehicles
|
||||
|
||||
- Added civilian cars and optional motorbikes to garages near exploring players, with gradual spawning, retention of used vehicles, and menu controls for count, chance, radius and spawn delay.
|
||||
- Reduced roadside checkpoint width and length to fit more locations while retaining four guards and a clear vehicle passage.
|
||||
- Fixed existing petrol-station pumps losing their refuelling service when Escape starts on a terrain scenario.
|
||||
|
||||
## 2026-10-30 — Community statistics delivery
|
||||
|
||||
- Fixed rejected mission reports so wins, defeats and queued results can update community statistics.
|
||||
- Game servers now report the loaded island name in statistics instead of the Game Master scenario path.
|
||||
|
||||
## 2026-10-30 — Quieter AI traffic
|
||||
|
||||
- Limited AI drivers to short horn beeps with a 30-second cooldown to reduce repeated honking.
|
||||
|
||||
## 2026-10-30 — Results reading time and spectator hints
|
||||
|
||||
- Kept mission results open for two minutes before automatic restart.
|
||||
- Prevented objective hints from interrupting unconscious spectators and stabilized recovery messages to reduce repeated switching.
|
||||
|
||||
## 2026-10-30 — Results panel visibility
|
||||
|
||||
- Fixed the blank end-of-mission panel so victory, failure and mission statistics are visible before restart.
|
||||
|
||||
## 2026-10-30 — Community mission statistics
|
||||
|
||||
- Mission statistics now connect automatically on community servers without manual registration.
|
||||
- Server administrators can disable reporting under Mission → Statistics.
|
||||
- Victory and failure screens show the current map's wins, failures and win rate on this server and across all reporting servers.
|
||||
|
||||
## 2026-10-30 — Mission results
|
||||
|
||||
- Added an after-mission summary with duration, kills, friendly kills, deaths, times downed and recoveries for each participant.
|
||||
- Results show who escaped and retain the records of players who disconnected.
|
||||
|
||||
## 2026-10-28 — Extraction landing zones
|
||||
|
||||
- Extraction can use suitable clear ground anywhere on the terrain, including original Everon, without prioritizing airports.
|
||||
- Landing zones retain terrain, obstacle, aircraft-space and escape-boundary checks.
|
||||
|
||||
## 2026-10-28 — Starting supplies and mission statistics
|
||||
|
||||
- Oversized starting weapons and other items remain available beside the backpack instead of delaying preparation and being removed.
|
||||
- Choose whether each starting item spawns inside the backpack or on the ground beside it, with separate quantities for each destination.
|
||||
|
||||
- Added optional mission statistics reporting for starts, restarts, terrain, mods, player participation and mission results.
|
||||
- Statistics are enabled by default and can be disabled under Mission → Statistics; player escape records distinguish actual escapees from spectators.
|
||||
|
||||
## 2026-10-25 — Startup error screen
|
||||
|
||||
- Failed mission starts now show an interactive error screen with a Return to main menu button.
|
||||
- Esc opens the normal pause menu from the error screen, so leaving no longer requires closing Arma.
|
||||
|
||||
## 2026-10-25 — Small-terrain startup
|
||||
|
||||
- Fixed low-lying dry terrain being rejected during site placement, player spawning and mortar setup; water checks now use the actual ground surface.
|
||||
- Helicopter landing sites can use clear ground near roads and use the landing-pad flatness limit instead of the stricter camp limit.
|
||||
- Fixed aircraft clearance checks using the wrong ground height during extraction placement.
|
||||
|
||||
## 2026-10-24 — Placement on smaller terrains
|
||||
|
||||
- Improved mission placement on small maps with shorter airport separation for prisons and support for terrain bounds down to 1 km.
|
||||
- Extraction searches can find a clear landing zone elsewhere when an airport has no suitable space.
|
||||
- Difficult terrain searches try more positions for longer before giving up.
|
||||
- Startup failures show their cause directly on the loading screen, including placement rejection reasons.
|
||||
|
||||
## 2026-10-21 — Radio intel progression
|
||||
|
||||
- Radio intel unlocks after five distinct non-radio sites are fully identified; finding and using radios in person remains unrestricted.
|
||||
- Configure the requirement under Intel → Radio intel, with live progress and a zero option to disable it for the next round.
|
||||
- Early ordinary folders reveal other sites; fixed radio documents stay available and are read automatically after unlocking.
|
||||
|
||||
## 2026-10-21 — Recon drones, body burial and spectator camera
|
||||
|
||||
- Players holding a shovel can bury dead bodies and their remaining equipment.
|
||||
- Added optional enemy reconnaissance drones using Realistic Combat Drones, responding to recent HQ sightings.
|
||||
- Configure the active drone limit and launch cooldown under HQ → Recon drones; disabled by default.
|
||||
- Buffered the full first-person spectator camera position to reduce shaking from remote character movement corrections.
|
||||
|
||||
## 2026-10-20 — Optional site map hints
|
||||
|
||||
- Added an Intel menu option to show sites as question marks until intel identifies their types.
|
||||
|
||||
## 2026-10-20 — Safe intel cleanup
|
||||
|
||||
- Removing used intel folders preserves enemies' ability to discover stolen documents.
|
||||
- Used ordinary folders are cleaned up even when no new locations remain to reveal.
|
||||
- Failed inventory cleanup retries safely without granting extra intel or deleting objective documents.
|
||||
|
||||
## 2026-10-20 — Hunter recovery and intel cleanup
|
||||
|
||||
- Hunters with a blocked driver seat or failed reboarding can abandon the vehicle and continue on foot.
|
||||
- Hunters remember failed approach positions and pause before attempting another coordinated manoeuvre.
|
||||
- Ordinary intel folders are removed after revealing a location; objective documents are retained.
|
||||
|
||||
## 2026-10-17 — Fewer Šárka hunters
|
||||
|
||||
- Set how many anger points are needed per Šárka in the mission menu. Higher values mean fewer cars.
|
||||
- Default: one car per three anger points, rounded down. Zero disables new waves.
|
||||
|
||||
## 2026-10-17 — Mission status readability
|
||||
|
||||
- Fixed clipped status and journal text so long reports can be scrolled.
|
||||
- Fixed duplicated Šárka wave details and restored the hunting-patrol summary.
|
||||
|
||||
## 2026-10-17 — Mission menu navigation
|
||||
|
||||
- Reorganized the mission menu with main tabs across the top and related submenus on the left.
|
||||
|
||||
## 2026-10-17 — Configurable Šárka hunters
|
||||
|
||||
- Added a mission-menu setting for the number of Šárka special-forces cars per anger point.
|
||||
- Set cars per anger point to zero to disable new waves. Deployed teams remain.
|
||||
|
||||
## 2026-10-17 — Discord changelog test
|
||||
|
||||
- Test notification for automatic mission changelogs.
|
||||
|
||||
## 2026-10-17 — Changelog notifications
|
||||
|
||||
- Added automatic Discord changelogs for new mission updates.
|
||||
@@ -39,12 +39,26 @@ Scripts are organized under `addons/core/Scripts/Game/ERR/`:
|
||||
The prefixes preserve Enforce script extension order. All modules load together.
|
||||
Resources retain their existing paths and GUIDs within the combined project.
|
||||
|
||||
Menu appearance and navigation are maintained in [ui/admin_menu](ui/admin_menu/README.md):
|
||||
one theme, a navigation manifest, and separate page layouts. Run
|
||||
`python3 tools/build_admin_menu.py` after changing those sources; the generated
|
||||
native layouts remain in the addon for Workbench and publishing.
|
||||
|
||||
Register only this project and its external dependencies in Workbench. Remove stale
|
||||
AI/Intel/Map/Chat project entries if you previously added them. RHS: Status Quo,
|
||||
WCS Armaments and WCS BMP-1 (including their dependencies) remain separate,
|
||||
author-supplied mods; see [external dependencies](docs/external-addons.md).
|
||||
Their assets are referenced, never redistributed.
|
||||
|
||||
With a shovel equipped, look at a dead body and hold **Bury body** for eight
|
||||
seconds. This removes the corpse and its remaining equipment; loot it first.
|
||||
Living and unconscious characters cannot be buried. Existing HQ casualty reports
|
||||
remain valid after burial.
|
||||
|
||||
Optional [enemy reconnaissance drones](docs/drone-reconnaissance.md) use Realistic
|
||||
Combat Drones. Configure their limit and cooldown in **HQ → Recon drones**;
|
||||
they are disabled by default.
|
||||
|
||||
See the [single-mod migration and current commands](docs/single-mod.md).
|
||||
Earlier five-addon build reports below are historical evidence, not validation of
|
||||
the merged project. Nothing has been published to Workshop.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
MenuManager {
|
||||
MenuPresets {
|
||||
MenuPreset ERR_PreparationFailure {
|
||||
Layout "{28DD8E27B1CF40C5}UI/layouts/ERR/PreparationFailure.layout"
|
||||
ActionContext "MenuContext"
|
||||
Class "ERR_PreparationFailureMenu"
|
||||
}
|
||||
MenuPreset ERR_AdminPanel {
|
||||
Layout "{AE761C0439D25FB8}UI/layouts/ERR/AdminMenu.layout"
|
||||
ActionContext "MenuContext"
|
||||
|
||||
@@ -70,12 +70,13 @@ ERR_ScenarioDefinition {
|
||||
}
|
||||
ERR_SiteDefinition {
|
||||
Id "everon-extraction-1"
|
||||
Label "Initial helicopter pickup site"
|
||||
Label "Extraction landing zone"
|
||||
Kind 2
|
||||
Prefab "{033503110AC34EAC}Prefabs/ERR/Extraction.et"
|
||||
Position 4800.953 0 11487.412
|
||||
AircraftOffset 0 0 0
|
||||
RequireAirfield 1
|
||||
RequireAirfield 0
|
||||
RandomClear 1
|
||||
Guarded 1
|
||||
GuardOffset 30 0 0
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
|
||||
m_aGameOverScreens {
|
||||
ERR_EscapeGameOverScreenInfo "{6B193DC8DDC63507}" {
|
||||
m_GameOverScreenId VICTORY
|
||||
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
|
||||
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{4984A5C1BFEFC04E}" {
|
||||
m_sTitle "Escape complete"
|
||||
m_sAudioOneShot "SOUND_ONVICTORY"
|
||||
@@ -10,7 +10,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
|
||||
}
|
||||
ERR_FailureGameOverScreenInfo "{E66F29A7A8AD7438}" {
|
||||
m_GameOverScreenId LOOSE
|
||||
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
|
||||
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{3DBF0BDE715945FA}" {
|
||||
m_sTitle "Escape failed"
|
||||
m_sDebriefing "No surviving prisoners can complete the escape, or the extraction helicopter has been lost."
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
ActionManager : "{795184CF9AD764DB}Configs/System/chimeraInputCommon.conf" {
|
||||
Contexts {
|
||||
ActionContext ERR_PassengerBrakeContext {
|
||||
Priority 60
|
||||
Flags 0xa 0
|
||||
Actions {
|
||||
Action ERR_PassengerBrake {
|
||||
InputSource InputSourceValue "{A7B19F416E541030}" {
|
||||
Input "keyboard:KC_SPACE"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ActionContext ERR_FieldMapContext {
|
||||
Priority 60
|
||||
Flags 0xa 0
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ERR_TelemetryConfig {
|
||||
Endpoint "https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
MetaFileClass {
|
||||
Name "{E72B130684FA492C}Configs/ERR/Telemetry.conf"
|
||||
Configurations {
|
||||
CONFResourceClass PC { }
|
||||
CONFResourceClass HEADLESS : PC { }
|
||||
CONFResourceClass XBOX_ONE : PC { }
|
||||
CONFResourceClass XBOX_SERIES : PC { }
|
||||
CONFResourceClass PS4 : PC { }
|
||||
CONFResourceClass PS5 : PC { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
SCR_ChimeraCharacter {
|
||||
ID "520EC961A090B1EE"
|
||||
components {
|
||||
ActionsManagerComponent "{520EA1D2F659CE02}" {
|
||||
additionalActions {
|
||||
ERR_BuryBodyAction "{A62CB789905D4011}" {
|
||||
ParentContextList { "Chest" "LowerTorso" }
|
||||
UIInfo SCR_ActionUIInfo "{A62CB789905D4012}" {
|
||||
Name "Bury body"
|
||||
Description "Bury the body and its remaining equipment. Loot anything you want to keep first."
|
||||
}
|
||||
Duration 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
MetaFileClass {
|
||||
Name "{37578B1666981FCE}Prefabs/Characters/Core/Character_Base.et"
|
||||
Configurations {
|
||||
EntityTemplateResourceClass PC {}
|
||||
EntityTemplateResourceClass HEADLESS : PC {}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,6 @@ ERR_GameMode : "{0F307326459A1395}Prefabs/MP/Modes/GameMode_Base.et" {
|
||||
m_SpawnLogic ERR_SpawnLogic "{79AD722B2EFD595F}" {}
|
||||
}
|
||||
}
|
||||
m_fAutoReloadTime 10
|
||||
m_fAutoReloadTime 120
|
||||
m_bUseSpawnPreload 1
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
GenericEntity {
|
||||
ID "E83CA269A0D647B1"
|
||||
components {
|
||||
MeshObject { Object "{76D738EEB0CA431F}Assets/Items/ERR_Atlas/ERR_AtlasLeaf.xob" InheritVisibility 1 }
|
||||
}
|
||||
|
||||
@@ -78,6 +78,15 @@ class ERR_Actors
|
||||
return position;
|
||||
}
|
||||
|
||||
// Map altitude is not water depth. Query the terrain surface itself, without
|
||||
// Ground's 10cm spawn lift, so even shallow water remains unsuitable.
|
||||
static bool DryGround(vector position)
|
||||
{
|
||||
position[1] = GetGame().GetWorld().GetSurfaceY(position[0], position[2]);
|
||||
if (!ERR_ForceRules.Finite(position[1])) return false;
|
||||
return !ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position);
|
||||
}
|
||||
|
||||
static void Players(out array<IEntity> players, bool conscious = false)
|
||||
{
|
||||
players.Clear();
|
||||
|
||||
@@ -3,91 +3,6 @@ modded enum ChimeraMenuPreset
|
||||
ERR_AdminPanel
|
||||
}
|
||||
|
||||
// Presentation rules only: all mutations still use the authenticated command service.
|
||||
class ERR_AdminMenuFields
|
||||
{
|
||||
static array<string> Keys()
|
||||
{
|
||||
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
|
||||
}
|
||||
static bool Available(string key, bool hq, bool intel)
|
||||
{
|
||||
if (key == "aiDoctrine" || key == "aiShadow") return hq;
|
||||
if (key == "aiIntelChancePercent") return intel;
|
||||
if (key == "automaticEquipmentChancePercent" || key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
|
||||
return Keys().Contains(key);
|
||||
}
|
||||
// A short journal has no older off-screen lines, even if its slider is zero.
|
||||
static bool FollowJournal(bool empty, float slider, float height, float viewHeight, bool latest)
|
||||
{
|
||||
return latest || empty || slider >= 0.98 || height <= viewHeight;
|
||||
}
|
||||
// TextWidget interprets hash-prefixed tokens as localization IDs. Keep the
|
||||
// server audit and retained entries intact; render their numeric IDs literally.
|
||||
static string JournalText(string line, bool details = false)
|
||||
{
|
||||
return ERR_JournalText.Menu(line, details);
|
||||
}
|
||||
static string Display(string key, string value)
|
||||
{
|
||||
if (key == "aiDoctrine") { if (value == "1") return "Coordinated"; return "Current"; }
|
||||
if (key == "aiShadow") { if (value == "1") return "On"; return "Off"; }
|
||||
if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt());
|
||||
if (key == "startMode") { if (value == "1") return "Crash"; return "Prison"; }
|
||||
if (key == "startTimeMinutes") return ERR_TimeService.Clock(value.ToInt());
|
||||
if (key == "startDateCode") return ERR_EnvironmentService.Date(value.ToInt());
|
||||
if (key == "weatherProfile") return ERR_EnvironmentService.ProfileName(value.ToInt());
|
||||
if (key == "viewDistanceMetres" && value == "0") return "default";
|
||||
return value;
|
||||
}
|
||||
static string StartSummary(int saved, int current)
|
||||
{
|
||||
return "Next start: " + Display("startMode", saved.ToString()) + " | Current round: " + Display("startMode", current.ToString());
|
||||
}
|
||||
static string Command(string key, string value)
|
||||
{
|
||||
string prefix;
|
||||
if (key == "aiDoctrine") prefix = "mission doctrine";
|
||||
else if (key == "aiShadow") prefix = "mission aishadow";
|
||||
else if (key == "automaticEquipmentChancePercent") prefix = "hq equipment";
|
||||
else if (key == "motorbikeCount") prefix = "mission motorbikes";
|
||||
else if (key == "planeReconEnabled") prefix = "mission plane";
|
||||
else if (key == "planeAltitudeMetres") prefix = "mission planealtitude";
|
||||
else if (key == "planeReplacementSeconds") prefix = "mission planereplacement";
|
||||
else if (key == "planeSectorSeconds") prefix = "mission planesector";
|
||||
else if (key == "startWeaponCount") prefix = "mission startweapons";
|
||||
else if (key == "startMagazineCount") prefix = "mission startammo";
|
||||
else if (key == "objectGuardEvery") prefix = "mission objectevery";
|
||||
else if (key == "objectGuardHQMode") prefix = "mission objecthq";
|
||||
else if (key == "objectGuardCount") prefix = "mission objectguards";
|
||||
else if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance";
|
||||
else if (key == "fieldCampCount") prefix = "mission camps";
|
||||
else if (key == "fieldHospitalCount") prefix = "mission hospitals";
|
||||
else if (key == "roadblockCount") prefix = "mission roadblocks";
|
||||
else if (key == "emptyRestartHours") prefix = "mission emptyhours";
|
||||
else if (key == "civilianTrafficCount") prefix = "mission traffic";
|
||||
else if (key == "startMode") prefix = "mission start";
|
||||
else if (key == "radioDefenceSeconds") prefix = "mission radio";
|
||||
else if (key == "aiIntelChancePercent") prefix = "intel carrying";
|
||||
else if (key == "intelRevealChancePercent") prefix = "intel reveal";
|
||||
else if (key == "startTimeMinutes") prefix = "mission time";
|
||||
else if (key == "timeMultiplier") prefix = "mission speed";
|
||||
else if (key == "startDateCode") prefix = "mission date";
|
||||
else if (key == "weatherProfile") prefix = "mission weather";
|
||||
else if (key == "viewDistanceMetres") prefix = "mission viewdistance";
|
||||
else if (key == "reportIntervalSeconds") prefix = "hq interval";
|
||||
else if (key == "supportSquadLimit") prefix = "hq squads";
|
||||
else if (key == "responseRadiusMetres") prefix = "hq radius";
|
||||
else if (key == "antiAirChancePercent") prefix = "hq aa";
|
||||
else if (key == "antiPersonnelMineChancePercent") prefix = "hq mines";
|
||||
else if (key == "heliTimerMinutes") prefix = "hq heli timer";
|
||||
else if (key == "convoyCount") prefix = "hq convoy count";
|
||||
value = value.Trim();
|
||||
if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return "";
|
||||
return prefix + " set " + value;
|
||||
}
|
||||
}
|
||||
|
||||
// Own mission dialog. ChimeraMenuBase owns focus, cursor and input-context lifetime.
|
||||
class ERR_AdminMenu : ChimeraMenuBase
|
||||
{
|
||||
@@ -131,16 +46,9 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
m_Mode = ERR_GameMode.Cast(GetGame().GetGameMode());
|
||||
if (!m_Controller) { Close(); return; }
|
||||
array<string> names = {"PlaneOn", "PlaneOff", "Close", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "SelfEpiOn", "SelfEpiOff", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS"};
|
||||
names.Insert("RandomDay_startTimeMinutes");
|
||||
names.Insert("Default_viewDistanceMetres");
|
||||
names.Insert("OpenForces");
|
||||
names.Insert("RestartEmptyOn"); names.Insert("RestartEmptyOff");
|
||||
names.Insert("OpenGuards"); names.Insert("GuardsBack");
|
||||
names.Insert("OpenSites"); names.Insert("SitesBack");
|
||||
names.Insert("OpenPlane"); names.Insert("PlaneBack");
|
||||
names.Insert("OpenAI"); names.Insert("AIBack");
|
||||
names.Insert("OpenStarter"); names.Insert("EquipmentOverview"); names.Insert("OpenAllowedItems"); names.Insert("OpenItemPresets"); names.Insert("OpenWeights"); names.Insert("OpenRecommendations"); names.Insert("RecommendationsBack"); names.Insert("RecommendedPlane"); names.Insert("RecommendedWeapons");
|
||||
array<string> names = {"TelemetryOn", "TelemetryOff","SitesOn", "SitesOff", "PlaneOn", "PlaneOff", "Close", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "SelfEpiOn", "SelfEpiOff", "IntelSummary", "RerollIntel", "RandomDay_startTimeMinutes", "Default_viewDistanceMetres", "RestartEmptyOn", "RestartEmptyOff", "RecommendedPlane", "RecommendedWeapons", "RecommendedDrones"};
|
||||
foreach (string mainButton : ERR_AdminMenuPages.MainButtons()) names.Insert(mainButton);
|
||||
foreach (string pageButton : Navigation()) names.Insert(pageButton);
|
||||
for (int density = 1; density <= 4; density++) names.Insert("GuardEvery_" + density.ToString());
|
||||
for (int policy = 0; policy <= 2; policy++) names.Insert("GuardHQ_" + policy.ToString());
|
||||
foreach (string key : ERR_AdminMenuFields.Keys())
|
||||
@@ -164,7 +72,7 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
m_Ready = false; m_Age = 0; m_Poll = 0;
|
||||
GetGame().GetInputManager().AddActionListener(UIConstants.MENU_ACTION_BACK, EActionTrigger.PRESSED, OnBack);
|
||||
m_Bound = true;
|
||||
ShowPage(0);
|
||||
ShowPage(ERR_AdminPage.MISSION);
|
||||
UpdateActions();
|
||||
RequestSnapshot();
|
||||
}
|
||||
@@ -191,7 +99,7 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
UpdateActions();
|
||||
}
|
||||
if (IsFocused() && m_Poll >= 2) RequestSnapshot();
|
||||
if (m_Page != 2) return;
|
||||
if (m_Page != ERR_AdminPage.JOURNAL) return;
|
||||
m_LogPoll += tDelta;
|
||||
if (m_LogPending && m_LogPoll > 5) m_LogPending = false;
|
||||
if (IsFocused() && m_Ready && m_HQ && !m_LogPaused && !m_LogPending && m_LogPoll >= 1)
|
||||
@@ -227,7 +135,7 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget());
|
||||
if (edit && edit.IsInWriteMode()) return;
|
||||
}
|
||||
if (m_Page != 0) ShowPage(0);
|
||||
if (m_Page != ERR_AdminPage.MISSION) ShowPage(ERR_AdminPage.MISSION);
|
||||
else Close();
|
||||
}
|
||||
protected void SetText(string name, string value)
|
||||
@@ -235,32 +143,24 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
TextWidget widget = TextWidget.Cast(GetRootWidget().FindAnyWidget(name));
|
||||
if (widget) widget.SetText(value);
|
||||
}
|
||||
// Stable page order is also used by the shared navigation bar and native probe.
|
||||
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage"}; }
|
||||
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter"}; }
|
||||
// Stable IDs are generated from the navigation manifest; extensions use named pages.
|
||||
static array<string> Pages() { return ERR_AdminMenuPages.Pages(); }
|
||||
static array<string> Navigation() { return ERR_AdminMenuPages.Navigation(); }
|
||||
protected void ShowPage(int page)
|
||||
{
|
||||
array<string> pages = Pages();
|
||||
array<string> navigation = Navigation();
|
||||
if (page < 0 || page >= pages.Count()) return;
|
||||
if (!ERR_AdminMenuView.ShowPage(GetRootWidget(), page)) return;
|
||||
m_Page = page;
|
||||
for (int i = 0; i < pages.Count(); i++)
|
||||
{
|
||||
GetRootWidget().FindAnyWidget(pages[i]).SetVisible(i == page);
|
||||
bool selected = i == page || ((page == 12 || page == 13) && i == 11) || ((page == 16 || page == 17 || page == 18 || page == 20) && i == 9) || ((page == 14 || page == 19) && i == 1);
|
||||
GetRootWidget().FindAnyWidget("Selected_" + navigation[i]).SetVisible(selected);
|
||||
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText(navigation[i], GetRootWidget()), selected);
|
||||
}
|
||||
GetRootWidget().FindAnyWidget("EquipmentNavigation").SetVisible(page == 9 || page == 16 || page == 17 || page == 18 || page == 20);
|
||||
GetRootWidget().FindAnyWidget("Selected_EquipmentOverview").SetVisible(page == 9);
|
||||
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("EquipmentOverview", GetRootWidget()), page == 9);
|
||||
if (page == 2) m_LogPoll = 1;
|
||||
if (page == ERR_AdminPage.JOURNAL) m_LogPoll = 1;
|
||||
}
|
||||
protected int m_KeepWeaponState = -1;
|
||||
protected int m_SelfEpiState = -1;
|
||||
protected int m_SitesState = -1;
|
||||
protected int m_RestartEmptyState = -1;
|
||||
protected int m_TelemetryState = -1;
|
||||
protected void UpdateActions()
|
||||
{
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "TelemetryOn", "TelemetryOff", m_TelemetryState);
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "SitesOn", "SitesOff", m_SitesState);
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "SelfEpiOn", "SelfEpiOff", m_SelfEpiState);
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "KeepWeaponOn", "KeepWeaponOff", m_KeepWeaponState);
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "RestartEmptyOn", "RestartEmptyOff", m_RestartEmptyState);
|
||||
@@ -278,8 +178,12 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
else if (name.IndexOf("GuardHQ_") == 0) button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("objectGuardHQMode"));
|
||||
else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel);
|
||||
else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ);
|
||||
else if (name == "TelemetryOn") button.SetEnabled(m_Ready && m_TelemetryState == 0);
|
||||
else if (name == "TelemetryOff") button.SetEnabled(m_Ready && m_TelemetryState == 1);
|
||||
else if (name == "RestartEmptyOn") button.SetEnabled(m_Ready && m_RestartEmptyState == 0);
|
||||
else if (name == "RestartEmptyOff") button.SetEnabled(m_Ready && m_RestartEmptyState == 1);
|
||||
else if (name == "SitesOn") button.SetEnabled(m_Ready && m_SitesState == 0);
|
||||
else if (name == "SitesOff") button.SetEnabled(m_Ready && m_SitesState == 1);
|
||||
else if (name == "SelfEpiOn") button.SetEnabled(m_Ready && m_SelfEpiState == 0);
|
||||
else if (name == "SelfEpiOff") button.SetEnabled(m_Ready && m_SelfEpiState == 1);
|
||||
else if (name == "KeepWeaponOn") button.SetEnabled(m_Ready && m_KeepWeaponState == 0);
|
||||
@@ -290,7 +194,7 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
{
|
||||
if (!authorized) { Close(); return; }
|
||||
if (!values) return;
|
||||
m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; m_SelfEpiState = -1; m_RestartEmptyState = -1;
|
||||
m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; m_SelfEpiState = -1; m_RestartEmptyState = -1; m_TelemetryState = -1;
|
||||
foreach (string entry : values)
|
||||
{
|
||||
if (entry == "supports=hq") { m_HQ = true; continue; }
|
||||
@@ -298,6 +202,8 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
int divider = entry.IndexOf("=");
|
||||
if (divider <= 0) continue;
|
||||
string key = entry.Substring(0, divider);
|
||||
if (key == "radioIntelProgress") { SetText("RadioIntelProgress", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
|
||||
if (key == "droneRuntime") { SetText("DroneRuntime", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
|
||||
if (key == "recommendedBikeRuntime") { SetText("RecommendedBikeRuntime", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
|
||||
if (key.IndexOf("recommended_") == 0)
|
||||
{
|
||||
@@ -309,8 +215,8 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
Widget state = GetRootWidget().FindAnyWidget("recommended_" + addonKey);
|
||||
if (state)
|
||||
{
|
||||
if (entry.Substring(divider + 1, entry.Length() - divider - 1) == "1") state.SetColor(Color.FromRGBA(105, 190, 140, 255));
|
||||
else state.SetColor(Color.FromRGBA(220, 115, 115, 255));
|
||||
if (entry.Substring(divider + 1, entry.Length() - divider - 1) == "1") state.SetColor(Color.FromInt(ERR_MenuTheme.READY_TEXT));
|
||||
else state.SetColor(Color.FromInt(ERR_MenuTheme.UNAVAILABLE_TEXT));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -326,6 +232,15 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
ERR_SelectionStyle.Pair(GetRootWidget(), "PlaneOn", "PlaneOff", planeState);
|
||||
m_AvailableFields.Insert(key); continue;
|
||||
}
|
||||
if (key == "telemetryStatus") { SetText("TelemetryStatus", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
|
||||
if (key == "telemetryEnabled")
|
||||
{
|
||||
m_TelemetryState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
|
||||
string telemetryState = "Disabled";
|
||||
if (m_TelemetryState == 1) telemetryState = "Enabled";
|
||||
SetText("Saved_telemetryEnabled", "Saved: " + telemetryState);
|
||||
continue;
|
||||
}
|
||||
if (key == "restartOnEmpty")
|
||||
{
|
||||
m_RestartEmptyState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
|
||||
@@ -334,6 +249,11 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
SetText("Saved_restartOnEmpty", "Saved: " + restartState);
|
||||
continue;
|
||||
}
|
||||
if (key == "showAllSites")
|
||||
{
|
||||
m_SitesState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
|
||||
continue;
|
||||
}
|
||||
if (key == "selfEpinephrine")
|
||||
{
|
||||
m_SelfEpiState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
|
||||
@@ -432,13 +352,11 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
if (!button || !IsFocused()) return;
|
||||
string name = button.GetRootWidget().GetName();
|
||||
if (name == "Close") { Close(); return; }
|
||||
if (name == "RecommendationsBack") { ShowPage(3); return; }
|
||||
if (name == "RecommendedPlane") { ShowPage(14); return; }
|
||||
if (name == "EquipmentOverview") { ShowPage(9); return; }
|
||||
if (name == "RecommendedWeapons") { ShowPage(9); return; }
|
||||
if (name == "PlaneBack") { ShowPage(1); return; }
|
||||
if (name == "AIBack") { ShowPage(1); return; }
|
||||
if (name == "GuardsBack" || name == "SitesBack") { ShowPage(11); return; }
|
||||
int section = ERR_AdminMenuPages.MainButtons().Find(name);
|
||||
if (section >= 0) { ShowPage(ERR_AdminMenuPages.MainPage(section)); return; }
|
||||
if (name == "RecommendedDrones") { ShowPage(ERR_AdminPage.DRONES); return; }
|
||||
if (name == "RecommendedPlane") { ShowPage(ERR_AdminPage.PLANE); return; }
|
||||
if (name == "RecommendedWeapons") { ShowPage(ERR_AdminPage.ITEMS); return; }
|
||||
int page = Navigation().Find(name);
|
||||
if (page >= 0) { ShowPage(page); return; }
|
||||
if (name == "JournalDetails")
|
||||
@@ -487,6 +405,12 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
m_ClickDelay = 0.3; SetText("Feedback", "Saving reconnaissance plane selection for next round...");
|
||||
m_Controller.ERR_AdminSubmit("mission plane set " + enabled, true); return;
|
||||
}
|
||||
if (name == "TelemetryOn" || name == "TelemetryOff")
|
||||
{
|
||||
if (m_TelemetryState < 0) return;
|
||||
string enabled = "0"; if (name == "TelemetryOn") enabled = "1";
|
||||
m_Controller.ERR_AdminSubmit("mission telemetry set " + enabled, true); return;
|
||||
}
|
||||
if (name == "RestartEmptyOn" || name == "RestartEmptyOff")
|
||||
{
|
||||
if (m_RestartEmptyState < 0) return;
|
||||
@@ -494,6 +418,13 @@ class ERR_AdminMenu : ChimeraMenuBase
|
||||
m_ClickDelay = 0.3; SetText("Feedback", "Saving restart after last disconnect...");
|
||||
m_Controller.ERR_AdminSubmit("mission restartempty set " + restartEnabled, true); return;
|
||||
}
|
||||
if (name == "SitesOn" || name == "SitesOff")
|
||||
{
|
||||
if (m_SitesState < 0) return;
|
||||
string sitesEnabled = "0"; if (name == "SitesOn") sitesEnabled = "1";
|
||||
m_ClickDelay = 0.3; SetText("Feedback", "Saving site map visibility...");
|
||||
m_Controller.ERR_AdminSubmit("intel sites set " + sitesEnabled, true); return;
|
||||
}
|
||||
if (name == "SelfEpiOn" || name == "SelfEpiOff")
|
||||
{
|
||||
if (m_SelfEpiState < 0) return;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Presentation rules only: all mutations still use the authenticated command service.
|
||||
class ERR_AdminMenuFields
|
||||
{
|
||||
static array<string> Keys()
|
||||
{
|
||||
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "sarkaAngerPerCar", "motorbikeCount", "droneReconCount", "droneCooldownSeconds", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "largeRoadblockCount", "mainRoadWidthMetres", "civilianTrafficCount", "garageVehicleCount", "garageChancePercent", "garageRadiusMetres", "garageSpawnIntervalSeconds", "emptyRestartHours", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
|
||||
}
|
||||
static bool Available(string key, bool hq, bool intel)
|
||||
{
|
||||
if (key == "aiDoctrine" || key == "aiShadow") return hq;
|
||||
if (key == "sarkaAngerPerCar" || key == "droneReconCount" || key == "droneCooldownSeconds") return hq;
|
||||
if (key == "aiIntelChancePercent") return intel;
|
||||
if (key == "automaticEquipmentChancePercent" || key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
|
||||
return Keys().Contains(key);
|
||||
}
|
||||
// A short journal has no older off-screen lines, even if its slider is zero.
|
||||
static bool FollowJournal(bool empty, float slider, float height, float viewHeight, bool latest)
|
||||
{
|
||||
return latest || empty || slider >= 0.98 || height <= viewHeight;
|
||||
}
|
||||
// TextWidget interprets hash-prefixed tokens as localization IDs. Keep the
|
||||
// server audit and retained entries intact; render their numeric IDs literally.
|
||||
static string JournalText(string line, bool details = false)
|
||||
{
|
||||
return ERR_JournalText.Menu(line, details);
|
||||
}
|
||||
static string Display(string key, string value)
|
||||
{
|
||||
if (key == "aiDoctrine") { if (value == "1") return "Coordinated"; return "Current"; }
|
||||
if (key == "aiShadow") { if (value == "1") return "On"; return "Off"; }
|
||||
if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt());
|
||||
if (key == "startMode") { if (value == "1") return "Crash"; return "Prison"; }
|
||||
if (key == "startTimeMinutes") return ERR_TimeService.Clock(value.ToInt());
|
||||
if (key == "startDateCode") return ERR_EnvironmentService.Date(value.ToInt());
|
||||
if (key == "weatherProfile") return ERR_EnvironmentService.ProfileName(value.ToInt());
|
||||
if (key == "viewDistanceMetres" && value == "0") return "default";
|
||||
return value;
|
||||
}
|
||||
static string StartSummary(int saved, int current)
|
||||
{
|
||||
return "Next start: " + Display("startMode", saved.ToString()) + " | Current round: " + Display("startMode", current.ToString());
|
||||
}
|
||||
static string Command(string key, string value)
|
||||
{
|
||||
string prefix;
|
||||
if (key == "aiDoctrine") prefix = "mission doctrine";
|
||||
else if (key == "aiShadow") prefix = "mission aishadow";
|
||||
else if (key == "automaticEquipmentChancePercent") prefix = "hq equipment";
|
||||
else if (key == "sarkaAngerPerCar") prefix = "mission sarkapoints";
|
||||
else if (key == "motorbikeCount") prefix = "mission motorbikes";
|
||||
else if (key == "droneReconCount") prefix = "mission drones";
|
||||
else if (key == "droneCooldownSeconds") prefix = "mission dronecooldown";
|
||||
else if (key == "planeReconEnabled") prefix = "mission plane";
|
||||
else if (key == "planeAltitudeMetres") prefix = "mission planealtitude";
|
||||
else if (key == "planeReplacementSeconds") prefix = "mission planereplacement";
|
||||
else if (key == "planeSectorSeconds") prefix = "mission planesector";
|
||||
else if (key == "startWeaponCount") prefix = "mission startweapons";
|
||||
else if (key == "startMagazineCount") prefix = "mission startammo";
|
||||
else if (key == "objectGuardEvery") prefix = "mission objectevery";
|
||||
else if (key == "objectGuardHQMode") prefix = "mission objecthq";
|
||||
else if (key == "objectGuardCount") prefix = "mission objectguards";
|
||||
else if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance";
|
||||
else if (key == "fieldCampCount") prefix = "mission camps";
|
||||
else if (key == "fieldHospitalCount") prefix = "mission hospitals";
|
||||
else if (key == "roadblockCount") prefix = "mission roadblocks";
|
||||
else if (key == "largeRoadblockCount") prefix = "mission largeroadblocks";
|
||||
else if (key == "mainRoadWidthMetres") prefix = "mission mainroadwidth";
|
||||
else if (key == "emptyRestartHours") prefix = "mission emptyhours";
|
||||
else if (key == "garageChancePercent") prefix = "mission garagechance";
|
||||
else if (key == "garageRadiusMetres") prefix = "mission garageradius";
|
||||
else if (key == "garageSpawnIntervalSeconds") prefix = "mission garageinterval";
|
||||
else if (key == "garageVehicleCount") prefix = "mission garagevehicles";
|
||||
else if (key == "civilianTrafficCount") prefix = "mission traffic";
|
||||
else if (key == "startMode") prefix = "mission start";
|
||||
else if (key == "radioDefenceSeconds") prefix = "mission radio";
|
||||
else if (key == "aiIntelChancePercent") prefix = "intel carrying";
|
||||
else if (key == "intelRevealChancePercent") prefix = "intel reveal";
|
||||
else if (key == "radioIntelSiteCount") prefix = "intel radiosites";
|
||||
else if (key == "startTimeMinutes") prefix = "mission time";
|
||||
else if (key == "timeMultiplier") prefix = "mission speed";
|
||||
else if (key == "startDateCode") prefix = "mission date";
|
||||
else if (key == "weatherProfile") prefix = "mission weather";
|
||||
else if (key == "viewDistanceMetres") prefix = "mission viewdistance";
|
||||
else if (key == "reportIntervalSeconds") prefix = "hq interval";
|
||||
else if (key == "supportSquadLimit") prefix = "hq squads";
|
||||
else if (key == "responseRadiusMetres") prefix = "hq radius";
|
||||
else if (key == "antiAirChancePercent") prefix = "hq aa";
|
||||
else if (key == "antiPersonnelMineChancePercent") prefix = "hq mines";
|
||||
else if (key == "heliTimerMinutes") prefix = "hq heli timer";
|
||||
else if (key == "convoyCount") prefix = "hq convoy count";
|
||||
value = value.Trim();
|
||||
if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return "";
|
||||
return prefix + " set " + value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Generated by tools/build_admin_menu.py; edit ui/admin_menu/navigation.json.
|
||||
enum ERR_AdminPage
|
||||
{
|
||||
MISSION = 0,
|
||||
HQ = 1,
|
||||
JOURNAL = 2,
|
||||
MODS = 3,
|
||||
FACTIONS = 4,
|
||||
WORLD = 5,
|
||||
INTEL = 6,
|
||||
STATUS = 7,
|
||||
TERRAINS = 8,
|
||||
ITEMS = 9,
|
||||
SPEECH = 10,
|
||||
FORCES = 11,
|
||||
GUARDS = 12,
|
||||
SITES = 13,
|
||||
PLANE = 14,
|
||||
RECOMMENDATIONS = 15,
|
||||
WEIGHTS = 16,
|
||||
ALLOWED_ITEMS = 17,
|
||||
ITEM_PRESETS = 18,
|
||||
AI = 19,
|
||||
STARTER = 20,
|
||||
SARKA = 21,
|
||||
DRONES = 22,
|
||||
RADIO_INTEL = 23,
|
||||
TELEMETRY = 24,
|
||||
VEHICLES = 25,
|
||||
CHECKPOINTS = 26,
|
||||
}
|
||||
|
||||
class ERR_AdminMenuPages
|
||||
{
|
||||
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage", "DronesPage", "RadioIntelPage", "TelemetryPage", "VehiclesPage", "CheckpointsPage"}; }
|
||||
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka", "OpenDrones", "OpenRadioIntel", "OpenTelemetry", "OpenVehicles", "OpenCheckpoints"}; }
|
||||
static array<string> MainButtons() { return {"Main_Mission", "Main_Forces", "Main_HQ", "Main_Intel", "Main_Items", "Main_Mods", "Main_Audio", "Main_Status"}; }
|
||||
static array<string> Sidebars() { return {"Sidebar_Mission", "Sidebar_Forces", "Sidebar_HQ", "Sidebar_Intel", "Sidebar_Items", "Sidebar_Mods", "Sidebar_Audio", "Sidebar_Status"}; }
|
||||
static int Group(int page)
|
||||
{
|
||||
array<int> groups = {0, 2, 7, 5, 1, 0, 3, 7, 0, 4, 6, 1, 1, 1, 2, 5, 4, 4, 4, 2, 4, 1, 2, 3, 0, 0, 1};
|
||||
if (page < 0 || page >= groups.Count()) return -1;
|
||||
return groups[page];
|
||||
}
|
||||
static int MainPage(int group)
|
||||
{
|
||||
array<int> pages = {ERR_AdminPage.MISSION, ERR_AdminPage.FORCES, ERR_AdminPage.HQ, ERR_AdminPage.INTEL, ERR_AdminPage.ITEMS, ERR_AdminPage.MODS, ERR_AdminPage.SPEECH, ERR_AdminPage.STATUS};
|
||||
if (group < 0 || group >= pages.Count()) return -1;
|
||||
return pages[group];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Widget presentation only. No settings writes, authorization or transport here.
|
||||
class ERR_AdminMenuView
|
||||
{
|
||||
static bool ShowPage(Widget root, int page)
|
||||
{
|
||||
int group = ERR_AdminMenuPages.Group(page);
|
||||
if (!root || group < 0) return false;
|
||||
array<string> pages = ERR_AdminMenuPages.Pages();
|
||||
array<string> navigation = ERR_AdminMenuPages.Navigation();
|
||||
for (int i = 0; i < pages.Count(); i++)
|
||||
{
|
||||
root.FindAnyWidget(pages[i]).SetVisible(i == page);
|
||||
root.FindAnyWidget("Selected_" + navigation[i]).SetVisible(i == page);
|
||||
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText(navigation[i], root), i == page);
|
||||
}
|
||||
array<string> buttons = ERR_AdminMenuPages.MainButtons();
|
||||
array<string> sidebars = ERR_AdminMenuPages.Sidebars();
|
||||
for (int section = 0; section < buttons.Count(); section++)
|
||||
{
|
||||
root.FindAnyWidget(sidebars[section]).SetVisible(section == group);
|
||||
root.FindAnyWidget("Selected_" + buttons[section]).SetVisible(section == group);
|
||||
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText(buttons[section], root), section == group);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ class ERR_CommandArguments
|
||||
{
|
||||
static string CoreSetting(string section, string option)
|
||||
{
|
||||
if (section == "mission" && option == "telemetry") return "telemetryEnabled";
|
||||
if (section == "mission" && option == "doctrine") return "aiDoctrine";
|
||||
if (section == "mission" && option == "aishadow") return "aiShadow";
|
||||
if (section == "mission" && option == "sarkapoints") return "sarkaAngerPerCar";
|
||||
if (section == "mission" && option == "motorbikes") return "motorbikeCount";
|
||||
if (section == "mission" && option == "planealtitude") return "planeAltitudeMetres";
|
||||
if (section == "mission" && option == "planereplacement") return "planeReplacementSeconds";
|
||||
if (section == "mission" && option == "planesector") return "planeSectorSeconds";
|
||||
if (section == "mission" && option == "drones") return "droneReconCount";
|
||||
if (section == "mission" && option == "dronecooldown") return "droneCooldownSeconds";
|
||||
if (section == "mission" && option == "plane") return "planeReconEnabled";
|
||||
if (section == "mission" && option == "camps") return "fieldCampCount";
|
||||
if (section == "mission" && option == "hospitals") return "fieldHospitalCount";
|
||||
@@ -20,6 +24,12 @@ class ERR_CommandArguments
|
||||
if (section == "mission" && option == "objectguards") return "objectGuardCount";
|
||||
if (section == "mission" && option == "hunterdistance") return "hunterSpawnDistanceMetres";
|
||||
if (section == "mission" && option == "roadblocks") return "roadblockCount";
|
||||
if (section == "mission" && option == "largeroadblocks") return "largeRoadblockCount";
|
||||
if (section == "mission" && option == "mainroadwidth") return "mainRoadWidthMetres";
|
||||
if (section == "mission" && option == "garagechance") return "garageChancePercent";
|
||||
if (section == "mission" && option == "garageradius") return "garageRadiusMetres";
|
||||
if (section == "mission" && option == "garageinterval") return "garageSpawnIntervalSeconds";
|
||||
if (section == "mission" && option == "garagevehicles") return "garageVehicleCount";
|
||||
if (section == "mission" && option == "traffic") return "civilianTrafficCount";
|
||||
if (section == "mission" && option == "start") return "startMode";
|
||||
if (section == "mission" && option == "selfepi") return "selfEpinephrine";
|
||||
@@ -27,6 +37,8 @@ class ERR_CommandArguments
|
||||
if (section == "mission" && option == "restartempty") return "restartOnEmpty";
|
||||
if (section == "mission" && option == "emptyhours") return "emptyRestartHours";
|
||||
if (section == "mission" && option == "radio") return "radioDefenceSeconds";
|
||||
if (section == "intel" && option == "sites") return "showAllSites";
|
||||
if (section == "intel" && option == "radiosites") return "radioIntelSiteCount";
|
||||
if (section == "intel" && option == "reveal") return "intelRevealChancePercent";
|
||||
return "";
|
||||
}
|
||||
@@ -34,8 +46,8 @@ class ERR_CommandArguments
|
||||
{
|
||||
if (key == "aiDoctrine" && text == "current") { value = 0; return true; }
|
||||
if (key == "aiDoctrine" && text == "coordinated") { value = 1; return true; }
|
||||
if ((key == "aiShadow" || key == "restartOnEmpty") && text == "off") { value = 0; return true; }
|
||||
if ((key == "aiShadow" || key == "restartOnEmpty") && text == "on") { value = 1; return true; }
|
||||
if ((key == "aiShadow" || key == "restartOnEmpty" || key == "showAllSites") && text == "off") { value = 0; return true; }
|
||||
if ((key == "aiShadow" || key == "restartOnEmpty" || key == "showAllSites") && text == "on") { value = 1; return true; }
|
||||
if (key == "startMode" && text == "prison") { value = 0; return true; }
|
||||
if (key == "startMode" && text == "crash") { value = 1; return true; }
|
||||
if (text == "random" && ERR_Settings.Randomizable(key)) { value = -1; return true; }
|
||||
@@ -170,7 +182,10 @@ class ERR_AdminService
|
||||
if (key == "aiShadow") return "On (1) records coordinated proposals without issuing their orders; Off (0) uses the selected doctrine. Saved for next round.";
|
||||
if (key == "startWeaponCount") return "0..12 pistols in the shared prison/crash supply backpack; default 5. Applies next round.";
|
||||
if (key == "startMagazineCount") return "0..48 spare pistol magazines in the shared prison/crash supply backpack; default 8. Applies next round.";
|
||||
if (key == "sarkaAngerPerCar") return "1-30 anger points per car; 0 disables new waves. Default 3. Whole thresholds only: 5 means one car at 5 anger, two at 10. Four special-forces crew per car. Applies to new waves; existing teams remain.";
|
||||
if (key == "motorbikeCount") return "0-6; default 2; 0 disabled. Optional WCS Motorbikes, two response specialists per bike. Applies next round; losses are not replaced.";
|
||||
if (key == "droneReconCount") return "0-3; default 0. Applies next round.";
|
||||
if (key == "droneCooldownSeconds") return "60-1800; default 300. Applies next round.";
|
||||
if (key == "planeAltitudeMetres") return "150-500; default 250. Applies next round.";
|
||||
if (key == "planeReplacementSeconds") return "30-1800; default 60. Applies next round.";
|
||||
if (key == "planeSectorSeconds") return "30-600; default 120. Applies next round.";
|
||||
@@ -181,22 +196,32 @@ class ERR_AdminService
|
||||
if (key == "objectGuardHQMode") return "0: all hold their posts; 1: half hold, half respond to HQ; 2: all may respond to HQ. All report contacts. Next round.";
|
||||
if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. HQ participation is configured separately; all report. Towers and chimneys use suitable platforms. Lost guards are not replaced.";
|
||||
if (key == "hunterSpawnDistanceMetres") return "Opening UAZ and troop-truck patrol placement. Applies next round.";
|
||||
if (key == "roadblockCount") return "Set 0..30 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped.";
|
||||
if (key == "largeRoadblockCount") return "Set 0..30 main-road checkpoints (default 4; 0 disables large posts). Eight guards per post. Applies next round; unsuitable sites are skipped.";
|
||||
if (key == "mainRoadWidthMetres") return "Set main-road minimum width to 5..12 metres (default 6). Wider roads use large checkpoints; narrower roads use small ones. Applies next round.";
|
||||
if (key == "roadblockCount") return "Set 0..30 small-road checkpoints (default 4; 0 disables small posts). Four guards per post. Applies next round; unsuitable sites are skipped.";
|
||||
if (key == "telemetryEnabled") return "Default on. Sends mission results and player identities to the configured statistics service. Disabling clears unsent events immediately.";
|
||||
if (key == "restartOnEmpty") return "On (1) restarts after the last disconnect; Off (0) preserves the round for reconnects. Default On. Applies immediately; normal round endings still apply.";
|
||||
if (key == "emptyRestartHours") return "0 disables idle restarts; 1..168 real hours continuously empty. Default 5. Applies immediately, measured from mission start or the last disconnect. Connected players, including spectators, prevent idle restarts.";
|
||||
if (key == "garageChancePercent") return "Garage selection chance (0-100%). Default 70%; 100 selects every suitable garage. Next round.";
|
||||
if (key == "garageRadiusMetres") return "Spawn radius (250-1500 metres). Distance around players; default 1000 m. Next round.";
|
||||
if (key == "garageSpawnIntervalSeconds") return "Time between spawns (5-60 seconds). Default 5 s. Increase to spread out spawning. Next round.";
|
||||
if (key == "garageVehicleCount") return "0..24 unclaimed garage vehicles near players; default 16. Used vehicles are kept; distant unused vehicles retire. Optional bikes. Applies next round.";
|
||||
if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved.";
|
||||
if (key == "showAllSites") return "0 disabled (default), 1 enabled. Show prepared sites at their positions as question marks; recovered intel identifies their types. Applies immediately to everyone.";
|
||||
if (key == "selfEpinephrine") return "0 disabled (default), 1 enabled. Unconscious players can press Space to use a carried epinephrine injector. Applies immediately; consumes the item and keeps its normal medical effect.";
|
||||
if (key == "keepWeaponOnUnconscious") return "Set 1 to retain the held firearm during unconsciousness and equip it after recovery; 0 keeps normal dropping. Applies to the next knockout.";
|
||||
if (key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent")
|
||||
return "Set 0..100 percent or Random. Applies to newly equipped enemy soldiers; existing loadouts are not rerolled or replenished.";
|
||||
if (key == "radioIntelSiteCount") return "Sites required before radio intel: 0..20, default 5; 0 disables. Fully identify distinct non-radio sites; starting reveals do not count. Applies next round. Finding and using radios in person remains unrestricted.";
|
||||
if (key == "intelRevealChancePercent")
|
||||
return "Use /esc intel reveal set N (0-100) or random. This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged.";
|
||||
return "Use /esc intel reveal set N (0-100) or random. This is the chance that a first document identifies its location exactly; otherwise it gives a rough location. A second document for that location always identifies it. With all sites shown, the first document always identifies the type. Changes affect newly recovered documents immediately; known locations and spent documents remain unchanged.";
|
||||
return "";
|
||||
}
|
||||
protected string RoadblockTiming(string key)
|
||||
{
|
||||
if (key == "aiDoctrine" || key == "aiShadow") return " for next round";
|
||||
if (key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "fieldCampCount" || key == "fieldHospitalCount" || key == "startWeaponCount" || key == "startMagazineCount" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round";
|
||||
if (key == "radioIntelSiteCount" || key == "garageVehicleCount" || key == "garageChancePercent" || key == "garageRadiusMetres" || key == "garageSpawnIntervalSeconds") return " for next round";
|
||||
if (key == "aiDoctrine" || key == "aiShadow" || key == "droneReconCount" || key == "droneCooldownSeconds") return " for next round";
|
||||
if (key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "fieldCampCount" || key == "fieldHospitalCount" || key == "startWeaponCount" || key == "startMagazineCount" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "roadblockCount" || key == "largeRoadblockCount" || key == "mainRoadWidthMetres") return " for next round";
|
||||
return "";
|
||||
}
|
||||
protected void SettingCommand(ERR_Settings settings, string key, array<string> parts, array<string> replies)
|
||||
@@ -238,6 +263,7 @@ class ERR_AdminService
|
||||
}
|
||||
if (topic == "intel")
|
||||
{
|
||||
replies.Insert("/esc intel sites show|set on|off; show anonymous site positions and let intel identify their types.");
|
||||
replies.Insert("/esc intel reveal show|set N|help (0-100); affects newly recovered documents immediately.");
|
||||
replies.Insert("AI carrying and reroll commands require the Intel addon.");
|
||||
return;
|
||||
@@ -327,6 +353,7 @@ class ERR_AdminService
|
||||
if (!key.IsEmpty())
|
||||
{
|
||||
SettingCommand(mode.Settings(), key, parts, replies);
|
||||
if (key == "showAllSites") mode.PublishSiteVisibility();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,19 +80,24 @@ class ERR_AdminSession
|
||||
ERR_StartBackpack supplies = GetGame().ERR_StartingSupplies();
|
||||
bool saved;
|
||||
if (parts.Count() == 3 && parts[1] == "mode" && (parts[2] == "default" || parts[2] == "custom")) saved = supplies.SetMode(parts[2] == "custom");
|
||||
else if (parts.Count() == 3 && parts[1] == "remove") saved = supplies.Remove(parts[2]);
|
||||
else if (parts.Count() == 5 && parts[1] == "set")
|
||||
else if ((parts.Count() == 3 || parts.Count() == 4) && parts[1] == "remove")
|
||||
{
|
||||
string removeDestination = "backpack"; if (parts.Count() == 4) removeDestination = parts[3];
|
||||
saved = supplies.Remove(parts[2], removeDestination);
|
||||
}
|
||||
else if ((parts.Count() == 5 || parts.Count() == 6) && parts[1] == "set")
|
||||
{
|
||||
ERR_ServerItemCatalog catalog = GetGame().ERR_ServerItems(); catalog.Start(m_Mode);
|
||||
int count;
|
||||
if (catalog.Contains(parts[2], parts[3]) && ERR_CommandArguments.Integer(parts[4], count))
|
||||
{
|
||||
ERR_ServerItem item = catalog.Item(parts[3]);
|
||||
if (item) saved = supplies.Set(item.Prefab, count);
|
||||
string destination = "backpack"; if (parts.Count() == 6) destination = parts[5];
|
||||
if (item) saved = supplies.Set(item.Prefab, count, destination);
|
||||
}
|
||||
}
|
||||
if (saved) replies.Insert("Starting backpack saved for next round. Custom contents are used only when Custom is selected.");
|
||||
else replies.Insert("Not saved. Choose a catalog item and quantity 0-48; maximum 32 item types and 64 items total. Check configuration if read-only.");
|
||||
if (saved) replies.Insert("Starting supplies saved for next round. Custom contents are used only when Custom is selected.");
|
||||
else replies.Insert("Not saved. Choose a catalog item and quantity 0-48; maximum 32 entries and 64 items across both destinations. Check configuration if read-only.");
|
||||
return true;
|
||||
}
|
||||
// Per-session paging: another administrator cannot change this menu's selection.
|
||||
@@ -154,6 +159,7 @@ class ERR_AdminSession
|
||||
if (key == "convoyCount" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile") continue;
|
||||
if (m_Mode.Settings().Get(key) == -1) values.Insert("resolved_" + key + "=" + m_Mode.Settings().Runtime(key).ToString());
|
||||
}
|
||||
if (m_Mode.Discovery()) values.Insert("radioIntelProgress=" + m_Mode.Discovery().RadioIntelStatus());
|
||||
m_Mode.Admin().Capabilities(values);
|
||||
ERR_RecommendedAddons.Snapshot(m_Mode, values);
|
||||
GetGame().ERR_ServerItems().Snapshot(m_Mode, values, m_ItemAddon, m_ItemPage);
|
||||
|
||||
@@ -36,7 +36,7 @@ class ERR_AirfieldPlacement
|
||||
}
|
||||
static bool Clear(vector position)
|
||||
{
|
||||
if (!Replication.IsServer() || position[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) return false;
|
||||
if (!Replication.IsServer() || !ERR_Actors.DryGround(position)) return false;
|
||||
ERR_AirfieldPlacement survey = new ERR_AirfieldPlacement();
|
||||
array<vector> authored = {};
|
||||
AuthoredAirports(authored);
|
||||
@@ -49,12 +49,12 @@ class ERR_AirfieldPlacement
|
||||
// Same aircraft footprint/vertical sweep, without requiring airport scenery.
|
||||
static bool ClearGround(vector position)
|
||||
{
|
||||
if (!Replication.IsServer() || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) return false;
|
||||
if (!Replication.IsServer() || !ERR_Actors.DryGround(position)) return false;
|
||||
for (int edge = 0; edge < 4; edge++)
|
||||
{
|
||||
float side = edge * Math.PI / 2;
|
||||
vector sample = position + Vector(Math.Cos(side) * 14, 0, Math.Sin(side) * 14);
|
||||
if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(sample) - position[1]) > 0.8) return false;
|
||||
if (!ERR_Actors.DryGround(sample) || Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(sample) - position[1]) > 0.8) return false;
|
||||
}
|
||||
if (!SCR_WorldTools.TraceCilinderUtil(position + "0 2.5 0", 40, 4, TraceFlags.ENTS)) return false;
|
||||
TraceBox clearance = new TraceBox();
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// The native action supplies held progress/cancellation. Authority rechecks the
|
||||
// current player, shovel and corpse immediately before replicated deletion.
|
||||
class ERR_Burial
|
||||
{
|
||||
static bool Dead(IEntity entity)
|
||||
{
|
||||
ChimeraCharacter character = ChimeraCharacter.Cast(entity);
|
||||
return character && character.GetCharacterController()
|
||||
&& character.GetCharacterController().GetLifeState() == ECharacterLifeState.DEAD;
|
||||
}
|
||||
static bool ShovelHeld(IEntity user)
|
||||
{
|
||||
ChimeraCharacter character = ChimeraCharacter.Cast(user);
|
||||
if (!character || !character.GetCharacterController()) return false;
|
||||
CharacterControllerComponent controller = character.GetCharacterController();
|
||||
// Gadgets occupy the left-hand slot; GetCurrentItemInHands alone only finds
|
||||
// weapons. Inventory ownership and a selected quickslot are not sufficient.
|
||||
IEntity item = controller.GetAttachedGadgetAtLeftHandSlot();
|
||||
if (!item) item = controller.GetCurrentItemInHands();
|
||||
return item && item.FindComponent(SCR_CampaignBuildingGadgetToolComponent) != null;
|
||||
}
|
||||
static bool Reach(float distance)
|
||||
{
|
||||
return distance >= 0 && distance <= 2.5;
|
||||
}
|
||||
static bool Clear(IEntity user, IEntity body)
|
||||
{
|
||||
TraceParam trace = new TraceParam();
|
||||
trace.Start = user.GetOrigin() + Vector(0, 1.2, 0);
|
||||
trace.End = body.GetOrigin() + Vector(0, 0.25, 0);
|
||||
trace.Exclude = user; trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
|
||||
if (GetGame().GetWorld().TraceMove(trace, null) >= 1) return true;
|
||||
IEntity hit = trace.TraceEnt;
|
||||
for (int depth = 0; hit && depth < 16; depth++)
|
||||
{
|
||||
if (hit == body) return true;
|
||||
hit = hit.GetParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static string Rejection(IEntity user, IEntity body)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (!mode || !mode.Active()) return "Mission is not active";
|
||||
if (!ERR_Actors.Player(user) || !ERR_Actors.CanFight(user)) return "You must be alive and conscious";
|
||||
if (user == body || !Dead(body)) return "Only dead bodies can be buried";
|
||||
if (!ShovelHeld(user)) return "Take a shovel into your hands";
|
||||
if (CompartmentAccessComponent.GetVehicleIn(user) || CompartmentAccessComponent.GetVehicleIn(body)) return "Move yourself and the body out of the vehicle";
|
||||
if (!Reach(vector.Distance(user.GetOrigin(), body.GetOrigin()))) return "Move closer to the body";
|
||||
if (!Clear(user, body)) return "The body is obstructed";
|
||||
return string.Empty;
|
||||
}
|
||||
static bool Permitted(IEntity user, IEntity body)
|
||||
{
|
||||
if (!Replication.IsServer()) return false;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
return mode && mode.Players() && mode.Players().CurrentActor(user) && Rejection(user, body).IsEmpty();
|
||||
}
|
||||
static void Complete(IEntity user, IEntity body)
|
||||
{
|
||||
if (!Permitted(user, body)) return;
|
||||
// Corpses and their remaining inventory disappear on every peer. Already
|
||||
// queued HQ casualty reports retain their immutable attribution and position.
|
||||
SCR_EntityHelper.DeleteEntityAndChildren(body);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_BuryBodyAction : ScriptedUserAction
|
||||
{
|
||||
override bool HasLocalEffectOnlyScript() { return false; }
|
||||
override bool CheckOnServerFirstScript() { return true; }
|
||||
override bool CanBroadcastScript() { return false; }
|
||||
override bool CanBeShownScript(IEntity user)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
return mode && mode.Active() && user != GetOwner() && ERR_Burial.Dead(GetOwner()) && ERR_Burial.ShovelHeld(user);
|
||||
}
|
||||
override bool CanBePerformedScript(IEntity user)
|
||||
{
|
||||
string reason = ERR_Burial.Rejection(user, GetOwner());
|
||||
SetCannotPerformReason(reason);
|
||||
return reason.IsEmpty();
|
||||
}
|
||||
override void PerformAction(IEntity pOwnerEntity, IEntity pUserEntity)
|
||||
{
|
||||
if (pOwnerEntity != GetOwner() || !ERR_Burial.Permitted(pUserEntity, pOwnerEntity)) return;
|
||||
// Let the native action finish before destroying its owning manager. The
|
||||
// deferred callback revalidates, so duplicate completions are harmless.
|
||||
GetGame().GetCallqueue().CallLater(ERR_Burial.Complete, 1, false, pUserEntity, pOwnerEntity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
class ERR_CommunityMapRecord
|
||||
{
|
||||
int wins = -1;
|
||||
int losses = -1;
|
||||
int completed = -1;
|
||||
int partialMissions = -1;
|
||||
bool Valid()
|
||||
{
|
||||
return wins >= 0 && wins <= 1000000000 && losses >= 0 && losses <= 1000000000 && completed == wins + losses && completed > 0 && partialMissions >= 0 && partialMissions <= completed;
|
||||
}
|
||||
string Line(string label)
|
||||
{
|
||||
int rate = Math.Round(100.0 * wins / completed);
|
||||
return string.Format("%1: %2 wins | %3 failures | ", label, wins, losses) + rate.ToString() + "% win rate";
|
||||
}
|
||||
}
|
||||
class ERR_CommunityStatistics
|
||||
{
|
||||
int schemaVersion;
|
||||
string terrain;
|
||||
string serverId;
|
||||
string missionId;
|
||||
string eventId;
|
||||
string outcome;
|
||||
ref ERR_CommunityMapRecord server;
|
||||
ref ERR_CommunityMapRecord allServers;
|
||||
bool Matches(string expectedServer, string expectedMission, string expectedEvent, string expectedTerrain, string expectedOutcome)
|
||||
{
|
||||
if (schemaVersion != 1 || serverId != expectedServer || missionId != expectedMission || eventId != expectedEvent || terrain != expectedTerrain || outcome != expectedOutcome) return false;
|
||||
if (outcome != "victory" && outcome != "defeat") return false;
|
||||
if (!server || !allServers || !server.Valid() || !allServers.Valid()) return false;
|
||||
if (server.wins > allServers.wins || server.losses > allServers.losses) return false;
|
||||
if (outcome == "victory" && server.wins == 0) return false;
|
||||
if (outcome == "defeat" && server.losses == 0) return false;
|
||||
return true;
|
||||
}
|
||||
string Summary()
|
||||
{
|
||||
string text = "This map's record (including this round)\n";
|
||||
text += server.Line("This server") + "\n" + allServers.Line("All reporting servers");
|
||||
if (allServers.partialMissions > 0) text += "\nIncludes rounds with partial statistics.";
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ modded class ERR_AdminMenu
|
||||
{
|
||||
Open();
|
||||
ERR_AdminMenu menu = GetOpenMenu();
|
||||
if (menu) menu.ShowPage(3);
|
||||
if (menu) menu.ShowPage(ERR_AdminPage.MODS);
|
||||
}
|
||||
override void OnMenuOpen()
|
||||
{
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// A first clue identifies a stable search square, not the exact objective.
|
||||
// Stable grid centers prevent repeated updates from triangulating the site.
|
||||
// Normal clues use stable search squares to prevent triangulating the site.
|
||||
// The optional anonymous-site mode intentionally exposes positions, never types.
|
||||
class ERR_DiscoveryMapRules
|
||||
{
|
||||
// -1 hidden, 0 anonymous exact position, 1 approximate clue, 2 identified.
|
||||
static int DisplayKnowledge(int knowledge, bool prepared, bool prison, bool showAll)
|
||||
{
|
||||
if (prison || !prepared) return -1;
|
||||
if (knowledge >= 2) return 2;
|
||||
if (showAll) return 0;
|
||||
if (knowledge == 1) return 1;
|
||||
return -1;
|
||||
}
|
||||
static int Coordinate(float coordinate, int knowledge)
|
||||
{
|
||||
if (knowledge >= 2) return Math.Round(coordinate);
|
||||
if (knowledge == 0 || knowledge >= 2) return Math.Round(coordinate);
|
||||
return Math.Floor(coordinate / 500.0) * 500 + 250;
|
||||
}
|
||||
}
|
||||
@@ -109,12 +118,12 @@ modded class SCR_MapMarkerManagerComponent
|
||||
}
|
||||
}
|
||||
|
||||
// Read replicated discovery locally. A server-wide marker list never leaks
|
||||
// locations which have not been recovered through gameplay.
|
||||
// Read replicated discovery and the administrator-selected visibility locally.
|
||||
// Anonymous markers never expose a site label or type before discovery.
|
||||
modded class ERR_LocationComponent
|
||||
{
|
||||
protected ref ERR_DiscoveryMarker m_ERRMarker;
|
||||
protected int m_ERRShown;
|
||||
protected int m_ERRShown = -1;
|
||||
protected string m_ERRShownLabel;
|
||||
|
||||
protected override void EOnInit(IEntity owner)
|
||||
@@ -126,20 +135,24 @@ modded class ERR_LocationComponent
|
||||
protected void ERR_UpdateMarker()
|
||||
{
|
||||
if (!GetGame().GetPlayerController()) return;
|
||||
if (Kind() == ERR_LocationKind.PRISON || Knowledge() < 1)
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
bool showAll = mode && mode.ShowAllSites();
|
||||
int shown = ERR_DiscoveryMapRules.DisplayKnowledge(Knowledge(), Prepared(), Kind() == ERR_LocationKind.PRISON, showAll);
|
||||
if (shown < 0)
|
||||
{
|
||||
SCR_MapMarkerManagerComponent oldManager = SCR_MapMarkerManagerComponent.GetInstance();
|
||||
if (m_ERRMarker && oldManager) oldManager.ERR_RemoveDiscoveryMarker(m_ERRMarker);
|
||||
else if (m_ERRMarker) m_ERRMarker.OnDelete();
|
||||
m_ERRMarker = null; m_ERRShown = 0; m_ERRShownLabel = "";
|
||||
m_ERRMarker = null; m_ERRShown = -1; m_ERRShownLabel = "";
|
||||
return;
|
||||
}
|
||||
if (m_ERRMarker) m_ERRMarker.RefreshPresentation();
|
||||
if (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel) return;
|
||||
if (m_ERRMarker && shown == m_ERRShown && Label() == m_ERRShownLabel) return;
|
||||
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
|
||||
if (!manager || !manager.GetMarkerConfig()) return;
|
||||
string label = "Unknown location (500 m area)";
|
||||
if (Knowledge() == 2)
|
||||
if (shown == 0) label = "Unknown site";
|
||||
if (shown == 2)
|
||||
{
|
||||
if (!Label().IsEmpty()) label = Label();
|
||||
else if (Kind() == ERR_LocationKind.RADIO) label = "Radio station";
|
||||
@@ -152,18 +165,18 @@ modded class ERR_LocationComponent
|
||||
{
|
||||
m_ERRMarker = new ERR_DiscoveryMarker();
|
||||
m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
|
||||
m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge());
|
||||
m_ERRMarker.SetDiscoveryPosition(Position(), shown);
|
||||
m_ERRMarker.SetMarkerConfigID(0);
|
||||
m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge());
|
||||
m_ERRMarker.SetLocationIcon(Kind(), Id(), shown);
|
||||
m_ERRMarker.SetCustomText(label);
|
||||
manager.ERR_InsertDiscoveryMarker(m_ERRMarker);
|
||||
}
|
||||
m_ERRMarker.SetDiscoveryPosition(Position(), Knowledge());
|
||||
m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge());
|
||||
m_ERRMarker.SetDiscoveryPosition(Position(), shown);
|
||||
m_ERRMarker.SetLocationIcon(Kind(), Id(), shown);
|
||||
m_ERRMarker.SetCustomText(label);
|
||||
// The objective presentation performs its own normal viewport culling.
|
||||
m_ERRMarker.RefreshPresentation();
|
||||
m_ERRShown = Knowledge();
|
||||
m_ERRShown = shown;
|
||||
m_ERRShownLabel = Label();
|
||||
}
|
||||
protected override void OnDelete(IEntity owner)
|
||||
|
||||
@@ -1,10 +1,86 @@
|
||||
// Base mission discovery: recovering physical documents works without companions.
|
||||
// The Intel addon optionally assigns extra documents to enemy inventories.
|
||||
class ERR_IntelDeleteCallback : ScriptedInventoryOperationCallback
|
||||
{
|
||||
bool Finished;
|
||||
bool Succeeded;
|
||||
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
|
||||
protected override void OnComplete() { Finish(true); }
|
||||
protected override void OnFailed() { Finish(false); }
|
||||
protected override void OnAllComplete() { Finish(true); }
|
||||
protected override void OnAllFailed() { Finish(false); }
|
||||
}
|
||||
|
||||
class ERR_SpentIntelCleanup
|
||||
{
|
||||
IEntity Folder;
|
||||
ref ERR_IntelDeleteCallback Operation;
|
||||
int Attempts;
|
||||
float NextAttempt;
|
||||
float Deadline;
|
||||
bool TimeoutReported;
|
||||
}
|
||||
|
||||
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; }
|
||||
protected ref array<ref ERR_SpentIntelCleanup> m_Cleanup = {};
|
||||
protected int m_CleanupCursor;
|
||||
protected float m_CleanupNow;
|
||||
protected bool m_Stopped;
|
||||
protected int m_RadioIntelRequired;
|
||||
protected int m_RadioIntelIdentified;
|
||||
protected bool m_RadioIntelFinalized;
|
||||
protected bool m_RadioIntelUnlocked;
|
||||
void ERR_DiscoveryService(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode;
|
||||
// Freeze the saved setting for this round; menu edits affect the next one.
|
||||
m_RadioIntelRequired = mode.Settings().Get("radioIntelSiteCount");
|
||||
m_RadioIntelUnlocked = m_RadioIntelRequired == 0;
|
||||
}
|
||||
protected bool CountsForRadioIntel(ERR_LocationComponent location)
|
||||
{
|
||||
if (!location || !location.Prepared() || location.Kind() == ERR_LocationKind.RADIO || location.Kind() == ERR_LocationKind.PRISON || location.Kind() == ERR_LocationKind.EXTRACTION) return false;
|
||||
ERR_SiteService sites = m_Mode.Sites();
|
||||
ERR_SiteRuntime site;
|
||||
if (sites) site = sites.Find(location.Id());
|
||||
return !site || !site.Definition || !site.Definition.RevealOnEscape;
|
||||
}
|
||||
protected void RefreshRadioIntel()
|
||||
{
|
||||
if (m_Stopped || !m_Mode || !Replication.IsServer()) return;
|
||||
array<ERR_LocationComponent> locations = {};
|
||||
m_Mode.Locations(locations);
|
||||
set<string> available = new set<string>();
|
||||
set<string> identified = new set<string>();
|
||||
foreach (ERR_LocationComponent location : locations)
|
||||
{
|
||||
if (!CountsForRadioIntel(location)) continue;
|
||||
available.Insert(location.Id());
|
||||
if (location.Knowledge() == 2) identified.Insert(location.Id());
|
||||
}
|
||||
m_RadioIntelIdentified = identified.Count();
|
||||
// Background generation can still add eligible sites. Finalize only once
|
||||
// the active world's preparation has finished, including sparse terrains.
|
||||
if (!m_RadioIntelFinalized && m_Mode.Active() && !m_Mode.WorldPreparing())
|
||||
{
|
||||
m_RadioIntelRequired = Math.Min(m_RadioIntelRequired, available.Count());
|
||||
m_RadioIntelFinalized = true;
|
||||
}
|
||||
if (m_RadioIntelIdentified >= m_RadioIntelRequired) m_RadioIntelUnlocked = true;
|
||||
}
|
||||
bool RadioIntelAllowed() { RefreshRadioIntel(); return m_RadioIntelUnlocked; }
|
||||
string RadioIntelStatus()
|
||||
{
|
||||
RefreshRadioIntel();
|
||||
string state = "locked";
|
||||
if (m_RadioIntelUnlocked) state = "unlocked";
|
||||
string progress = string.Format("Radio intel %1: %2 / %3 non-radio sites identified this round.", state, m_RadioIntelIdentified, m_RadioIntelRequired);
|
||||
if (!m_RadioIntelFinalized) progress += " Site preparation is still in progress.";
|
||||
return progress;
|
||||
}
|
||||
// Shared ledger for recovered and administratively retired documents.
|
||||
bool IsSpent(IEntity folder) { return folder && m_Spent.Contains(folder); }
|
||||
bool CanMarkSpent(IEntity folder)
|
||||
@@ -37,14 +113,74 @@ class ERR_DiscoveryService
|
||||
|
||||
void Tick(float now)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (!Replication.IsServer() || m_Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
|
||||
array<IEntity> players = {};
|
||||
ERR_Actors.Players(players);
|
||||
m_CleanupNow = now;
|
||||
foreach (IEntity player : players) Recover(player);
|
||||
// Pending callbacks are observed only; even a missing callback never
|
||||
// permits a second operation against the same physical item.
|
||||
int budget = Math.Min(8, m_Cleanup.Count());
|
||||
while (budget-- > 0)
|
||||
{
|
||||
if (m_CleanupCursor >= m_Cleanup.Count()) m_CleanupCursor = 0;
|
||||
if (ProcessCleanup(m_Cleanup[m_CleanupCursor], players, now)) m_Cleanup.Remove(m_CleanupCursor);
|
||||
else m_CleanupCursor++;
|
||||
}
|
||||
}
|
||||
protected void QueueCleanup(IEntity folder, float now)
|
||||
{
|
||||
if (!folder || folder.FindComponent(ERR_DocumentComponent)) return;
|
||||
// One request per newly spent folder; admission shares the spent-ledger
|
||||
// bound. Administrative retirement does not enter this queue.
|
||||
ERR_SpentIntelCleanup request = new ERR_SpentIntelCleanup();
|
||||
request.Folder = folder;
|
||||
request.Deadline = now + 60000;
|
||||
m_Cleanup.Insert(request);
|
||||
}
|
||||
protected bool ProcessCleanup(ERR_SpentIntelCleanup request, array<IEntity> players, float now)
|
||||
{
|
||||
if (!request.Folder) return true;
|
||||
if (request.Operation)
|
||||
{
|
||||
if (!request.Operation.Finished)
|
||||
{
|
||||
if (now >= request.Deadline && !request.TimeoutReported)
|
||||
{
|
||||
request.TimeoutReported = true;
|
||||
Print("Reintroduced [intel]: spent-folder deletion still pending; no duplicate request", LogLevel.WARNING);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (request.Operation.Succeeded) return true;
|
||||
request.Operation = null;
|
||||
request.NextAttempt = now + 5000;
|
||||
}
|
||||
if (now >= request.Deadline || request.Attempts >= 3)
|
||||
{
|
||||
Print("Reintroduced [intel]: spent-folder cleanup expired; item retained without granting more intel", LogLevel.WARNING);
|
||||
return true;
|
||||
}
|
||||
if (request.Folder.FindComponent(ERR_DocumentComponent)) return true;
|
||||
if (now < request.NextAttempt) return false;
|
||||
foreach (IEntity player : players)
|
||||
{
|
||||
if (!m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) continue;
|
||||
if (!m_Mode.PlayerFaction() || SCR_FactionManager.SGetFaction(player) != m_Mode.PlayerFaction()) continue;
|
||||
if (!ERR_InfantryAmmoTransfer.StoredUnder(request.Folder, player)) continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(player.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory || !inventory.Contains(request.Folder)) continue;
|
||||
request.Attempts++;
|
||||
request.NextAttempt = now + 5000;
|
||||
request.Operation = new ERR_IntelDeleteCallback();
|
||||
if (!inventory.TryDeleteItem(request.Folder, request.Operation)) request.Operation = null;
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
protected void Recover(IEntity player)
|
||||
{
|
||||
if (!Replication.IsServer() || !m_Mode || !m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) return;
|
||||
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Players() || !m_Mode.Players().CurrentActor(player) || !ERR_Actors.Alive(player)) return;
|
||||
if (!m_Mode.PlayerFaction() || SCR_FactionManager.SGetFaction(player) != m_Mode.PlayerFaction()) return;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(player.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory) return;
|
||||
@@ -60,10 +196,22 @@ class ERR_DiscoveryService
|
||||
array<ERR_LocationComponent> eligible = {};
|
||||
ERR_DocumentComponent document = ERR_DocumentComponent.Cast(folder.FindComponent(ERR_DocumentComponent));
|
||||
string sealedTarget = m_Mode.Evidence().Target(folder);
|
||||
bool radioAllowed = RadioIntelAllowed();
|
||||
bool redirectRadio;
|
||||
if (!radioAllowed && !document && !sealedTarget.IsEmpty())
|
||||
{
|
||||
foreach (ERR_LocationComponent target : locations)
|
||||
if (target && target.Id() == sealedTarget && target.Kind() == ERR_LocationKind.RADIO) redirectRadio = true;
|
||||
}
|
||||
foreach (ERR_LocationComponent location : locations)
|
||||
{
|
||||
if (!location || location.Knowledge() >= 2) continue;
|
||||
if (!sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue;
|
||||
if (!radioAllowed && location.Kind() == ERR_LocationKind.RADIO)
|
||||
{
|
||||
if (document && location.Id() == document.TargetId()) m_Mode.ERR_RadioIntelWaiting();
|
||||
continue;
|
||||
}
|
||||
if (!redirectRadio && !sealedTarget.IsEmpty() && location.Id() != sealedTarget) continue;
|
||||
if (document)
|
||||
{
|
||||
if (location.Id() == document.TargetId()) eligible.Insert(location);
|
||||
@@ -72,15 +220,24 @@ class ERR_DiscoveryService
|
||||
}
|
||||
if (eligible.IsEmpty())
|
||||
{
|
||||
if (!document && !m_Mode.WorldPreparing() && MarkSpent(folder) && !mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
|
||||
if (!document && radioAllowed && !m_Mode.WorldPreparing() && MarkSpent(folder))
|
||||
{
|
||||
QueueCleanup(folder, m_CleanupNow);
|
||||
if (!mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
ERR_LocationComponent chosen = eligible.GetRandomElement();
|
||||
if (!chosen.Discover(Math.RandomInt(0, 100), m_Mode.Settings().Runtime("intelRevealChancePercent"))) continue;
|
||||
if (redirectRadio) m_Mode.Evidence().Retarget(folder, chosen.Id());
|
||||
m_Spent.Insert(folder);
|
||||
PrintFormat("Reintroduced [intel]: player %1 recovered folder; location=%2 knowledge=%3", GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(player), chosen.Id(), chosen.Knowledge());
|
||||
|
||||
// Preserve issued evidence: a later corpse inspection must still
|
||||
// report this site's missing document after the physical item is gone.
|
||||
if (!document) QueueCleanup(folder, m_CleanupNow);
|
||||
}
|
||||
}
|
||||
|
||||
void Stop() { m_Spent.Clear(); }
|
||||
void Stop() { m_Stopped = true; m_Cleanup.Clear(); m_Spent.Clear(); }
|
||||
}
|
||||
|
||||
@@ -33,13 +33,22 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
|
||||
modded class SCR_GameOverScreenContentUIComponent
|
||||
{
|
||||
protected bool m_ERREscapeContent;
|
||||
protected ERR_GameMode m_ERRStatisticsMode;
|
||||
protected ERR_GameMode m_ERRFailureMode;
|
||||
override void InitContent(SCR_GameOverScreenUIContentData endScreenUIContent)
|
||||
{
|
||||
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
|
||||
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
|
||||
m_ERRFailureMode = null;
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
|
||||
m_ERRStatisticsMode = null;
|
||||
super.InitContent(endScreenUIContent);
|
||||
if (ERR_EscapeGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo) || ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
|
||||
{
|
||||
m_ERRStatisticsMode = ERR_GameMode.Get();
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Insert(ERR_RefreshStatistics);
|
||||
ERR_RefreshStatistics();
|
||||
}
|
||||
if (ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
|
||||
{
|
||||
m_ERRFailureMode = ERR_GameMode.Get();
|
||||
@@ -71,10 +80,21 @@ modded class SCR_GameOverScreenContentUIComponent
|
||||
TextWidget debrief = TextWidget.Cast(m_wRoot.FindAnyWidget(m_sDebriefingName));
|
||||
if (debrief) debrief.SetText(info.GetDebriefing(-1, null));
|
||||
}
|
||||
protected void ERR_RefreshStatistics()
|
||||
{
|
||||
if (!m_wRoot || !m_ERRStatisticsMode || m_ERRStatisticsMode != ERR_GameMode.Get()) return;
|
||||
TextWidget text = TextWidget.Cast(m_wRoot.FindAnyWidget("RoundStatistics"));
|
||||
if (!text) return;
|
||||
string summary = m_ERRStatisticsMode.ERR_RoundSummary();
|
||||
if (summary.IsEmpty()) summary = "Waiting for mission statistics...";
|
||||
text.SetText(summary);
|
||||
}
|
||||
override void HandlerDeattached(Widget w)
|
||||
{
|
||||
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
|
||||
m_ERREscapeContent = false;
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
|
||||
m_ERRStatisticsMode = null;
|
||||
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
|
||||
m_ERRFailureMode = null;
|
||||
super.HandlerDeattached(w);
|
||||
|
||||
@@ -8,6 +8,16 @@ class ERR_RescueRules
|
||||
if (since < 0 || now < since) return now;
|
||||
return since;
|
||||
}
|
||||
static float AirborneSince(bool airborne, float since, float now)
|
||||
{
|
||||
if (!airborne || now < 0) return -1;
|
||||
if (since < 0 || now < since) return now;
|
||||
return since;
|
||||
}
|
||||
static bool FlightComplete(float since, float now)
|
||||
{
|
||||
return since >= 0 && now >= since && now - since >= 30000;
|
||||
}
|
||||
static bool CanDepart(float since, float now)
|
||||
{
|
||||
return since >= 0 && now >= since && now - since >= 8000;
|
||||
@@ -234,6 +244,7 @@ class ERR_ExtractionService
|
||||
protected bool m_Stopped;
|
||||
protected vector m_Center;
|
||||
protected float m_Radius;
|
||||
protected float m_AirborneSince = -1;
|
||||
protected ERR_ExtractionState m_State;
|
||||
protected ref set<UUID> m_Escapees = new set<UUID>();
|
||||
protected int m_ParticipantCount;
|
||||
@@ -328,8 +339,12 @@ class ERR_ExtractionService
|
||||
if (m_State != ERR_ExtractionState.WAITING || !m_Assigned) return;
|
||||
if (!FleetAlive()) { Resolve(ERR_ExtractionState.LOST); return; }
|
||||
if (m_Stage != 5 || phase != ERR_Phase.EXTRACTION) return;
|
||||
bool airborne = !m_Transports.IsEmpty();
|
||||
foreach (ERR_RescueFlightComponent departing : m_Transports)
|
||||
if (vector.DistanceXZ(departing.Aircraft().GetOrigin(), m_Pickup) < 1000 || vector.DistanceXZ(departing.Aircraft().GetOrigin(), m_Center) <= m_Radius) return;
|
||||
if (!departing || !departing.AirborneAfterPickup()) airborne = false;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
m_AirborneSince = ERR_RescueRules.AirborneSince(airborne, m_AirborneSince, now);
|
||||
if (!ERR_RescueRules.FlightComplete(m_AirborneSince, now)) return;
|
||||
array<ref ERR_PlayerLife> lives = {};
|
||||
m_Roster.Participants(lives);
|
||||
foreach (ERR_PlayerLife life : lives)
|
||||
@@ -359,6 +374,13 @@ class ERR_ExtractionService
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
// Capture the final aircraft state before terminal cleanup releases autopilots
|
||||
// and removes the AI groups. Keep the same occupied airframes until reload.
|
||||
if (m_State == ERR_ExtractionState.ESCAPED)
|
||||
{
|
||||
foreach (ERR_RescueFlightComponent safeTransport : m_Transports) if (safeTransport) safeTransport.HoldForResults();
|
||||
foreach (ERR_RescueFlightComponent safeEscort : m_Escorts) if (safeEscort && !safeEscort.Lost()) safeEscort.HoldForResults();
|
||||
}
|
||||
m_Stopped = true; m_RadioPending = ""; m_PreviousSignals.Clear();
|
||||
foreach (ERR_RescueFlightComponent transport : m_Transports) if (transport) transport.Release(false);
|
||||
foreach (ERR_RescueFlightComponent escort : m_Escorts) if (escort) escort.Release(false);
|
||||
|
||||
@@ -26,6 +26,16 @@ class ERR_GameMode : SCR_BaseGameMode
|
||||
m_SelectedPlayerFaction = player; m_SelectedEnemyFaction = enemy; Replication.BumpMe();
|
||||
}
|
||||
protected ref ERR_Settings m_Settings;
|
||||
[RplProp()] protected bool m_ShowAllSites;
|
||||
bool ShowAllSites() { return m_ShowAllSites; }
|
||||
void PublishSiteVisibility()
|
||||
{
|
||||
if (!Replication.IsServer() || !m_Settings || m_Stopped || m_NativeEndHandled) return;
|
||||
bool enabled = m_Settings.Get("showAllSites") == 1;
|
||||
if (m_ShowAllSites == enabled) return;
|
||||
m_ShowAllSites = enabled;
|
||||
Replication.BumpMe();
|
||||
}
|
||||
[RplProp()] protected int m_StartMode;
|
||||
protected bool m_StartModeFrozen;
|
||||
protected bool m_StartPrepared;
|
||||
@@ -55,6 +65,7 @@ class ERR_GameMode : SCR_BaseGameMode
|
||||
protected ref ERR_Performance m_Performance;
|
||||
ERR_Performance Performance() { return m_Performance; }
|
||||
protected ref ERR_CivilianTraffic m_CivilianTraffic;
|
||||
protected ref ERR_GarageVehicles m_GarageVehicles;
|
||||
protected ref ERR_ForceLedger m_Forces = new ERR_ForceLedger();
|
||||
protected ref ERR_ForceSimulation m_ForceSimulation;
|
||||
protected ref array<AIAgent> m_ForceAgents = {};
|
||||
@@ -133,6 +144,7 @@ class ERR_GameMode : SCR_BaseGameMode
|
||||
ERR_ForceLedger Forces() { return m_Forces; }
|
||||
ERR_CommandService Command() { return m_Command; }
|
||||
ERR_CivilianTraffic CivilianTraffic() { return m_CivilianTraffic; }
|
||||
ERR_GarageVehicles GarageVehicles() { return m_GarageVehicles; }
|
||||
ERR_Objectives Objectives() { return m_Objectives; }
|
||||
ERR_ExtractionService Extraction() { return m_Extraction; }
|
||||
string EndReason() { return m_EndReason; }
|
||||
@@ -498,6 +510,7 @@ class ERR_GameMode : SCR_BaseGameMode
|
||||
if (!Replication.IsServer()) return;
|
||||
m_EmptySince = System.GetTickCount();
|
||||
m_Settings = new ERR_Settings(SettingsPath());
|
||||
PublishSiteVisibility();
|
||||
if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance();
|
||||
PublishViewDistance();
|
||||
m_ContentSettings = new ERR_ContentSettings();
|
||||
@@ -535,6 +548,9 @@ class ERR_GameMode : SCR_BaseGameMode
|
||||
m_Command = CreateCommandService();
|
||||
m_CivilianTraffic = new ERR_CivilianTraffic(this);
|
||||
OnStop().Insert(m_CivilianTraffic.Stop);
|
||||
m_GarageVehicles = new ERR_GarageVehicles(this);
|
||||
OnStop().Insert(m_GarageVehicles.Stop);
|
||||
m_GarageVehicles.Start();
|
||||
GetGame().GetCallqueue().CallLater(ServerTick, 1000, true);
|
||||
GetGame().GetCallqueue().CallLater(StartScenario, 1500, false);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
class ERR_CampTerrain
|
||||
{
|
||||
protected ref array<vector> m_Airfields = {};
|
||||
protected string m_Rejection;
|
||||
string Rejection() { return m_Rejection; }
|
||||
protected bool Reject(string reason) { m_Rejection = reason; return false; }
|
||||
protected bool Airport(IEntity entity)
|
||||
{
|
||||
if (!entity || !entity.GetPrefabData()) return false;
|
||||
@@ -27,21 +30,19 @@ class ERR_CampTerrain
|
||||
}
|
||||
bool Clear(vector center, float heading, float halfSize, float maximumRange, out float lowest, float clearanceHeight = 4)
|
||||
{
|
||||
m_Rejection = "";
|
||||
lowest = float.MAX; float highest = -float.MAX;
|
||||
for (int x = -1; x <= 1; x++)
|
||||
for (int z = -1; z <= 1; z++)
|
||||
{
|
||||
vector corner = center + Rotate(Vector(x * halfSize, 0, z * halfSize), heading);
|
||||
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), corner)) return false;
|
||||
}
|
||||
// A 2m grid covers the walls, door approach, furniture and all interior slots.
|
||||
for (float dx = -halfSize; dx <= halfSize; dx += 2)
|
||||
for (float dz = -halfSize; dz <= halfSize; dz += 2)
|
||||
{
|
||||
vector sample = center + Rotate(Vector(dx, 0, dz), heading);
|
||||
float height = GetGame().GetWorld().GetSurfaceY(sample[0], sample[2]);
|
||||
if (!ERR_ForceRules.Finite(height)) return Reject("invalid terrain height");
|
||||
sample[1] = height;
|
||||
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), sample)) return Reject("water");
|
||||
lowest = Math.Min(lowest, height); highest = Math.Max(highest, height);
|
||||
if (lowest < 1 || highest - lowest > maximumRange) return false;
|
||||
if (highest - lowest > maximumRange) return Reject("uneven ground");
|
||||
}
|
||||
for (float cx = -halfSize + 1; cx < halfSize; cx += 2)
|
||||
for (float cz = -halfSize + 1; cz < halfSize; cz += 2)
|
||||
@@ -53,33 +54,42 @@ class ERR_CampTerrain
|
||||
// Bounding the rotated cell conservatively also protects the exit corridor.
|
||||
trace.Mins = "-1.42 0 -1.42"; trace.Maxs = "1.42 0.1 1.42";
|
||||
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
|
||||
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false;
|
||||
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return Reject("obstructed footprint");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
|
||||
bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight, float airportBuffer = 1500)
|
||||
{
|
||||
m_Rejection = "";
|
||||
bool prison = site.Kind == ERR_LocationKind.PRISON;
|
||||
if (prison)
|
||||
{
|
||||
foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < 1500) return false;
|
||||
foreach (vector airport : m_Airfields) if (vector.DistanceXZ(position, airport) < airportBuffer) return Reject("airport separation");
|
||||
}
|
||||
SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld());
|
||||
if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return false;
|
||||
BaseRoad road; float distance;
|
||||
aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
|
||||
if (road && distance < 12) return false;
|
||||
if (!Clear(position, heading, 10, 0.6, baseHeight)) return false;
|
||||
bool extraction = site.Kind == ERR_LocationKind.EXTRACTION;
|
||||
// Camps must stay off roads; a helicopter pad may use clear paved ground.
|
||||
// Its physical terrain, obstacle and fleet checks still decide acceptance.
|
||||
if (!extraction)
|
||||
{
|
||||
SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld());
|
||||
if (!aiWorld || !aiWorld.GetRoadNetworkManager()) return Reject("road network unavailable");
|
||||
BaseRoad road; float distance;
|
||||
aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
|
||||
if (road && distance < 12) return Reject("road proximity");
|
||||
}
|
||||
float maximumRange = 0.6;
|
||||
if (extraction) maximumRange = 0.8;
|
||||
if (!Clear(position, heading, 10, maximumRange, baseHeight)) return false;
|
||||
if (ERR_StartSetup.IsCrash(site))
|
||||
{
|
||||
vector offset, center; float halfSize, height;
|
||||
if (!ERR_StartSetup.Geometry(offset, center, halfSize, height)) return false;
|
||||
if (!ERR_StartSetup.Geometry(offset, center, halfSize, height)) return Reject("crash footprint unavailable");
|
||||
float wreckFloor;
|
||||
if (!Clear(position + Rotate(center, heading), heading, halfSize, 0.6, wreckFloor, height)) return false;
|
||||
if (!Clear(position + Rotate(center, heading), heading, halfSize, 0.6, wreckFloor, height)) return Reject("wreck area: " + m_Rejection);
|
||||
}
|
||||
float guardHeight;
|
||||
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false;
|
||||
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return false;
|
||||
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return Reject("guard area: " + m_Rejection);
|
||||
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return Reject("patrol area: " + m_Rejection);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +100,38 @@ class ERR_GeneratedSites
|
||||
protected ref array<ERR_SiteDefinition> m_Pending = {};
|
||||
protected vector m_Low, m_High;
|
||||
protected int m_Index, m_Attempts, m_Began;
|
||||
protected int m_NextDiagnostic;
|
||||
protected ref map<string, int> m_Rejections = new map<string, int>();
|
||||
protected void RecordRejection(string reason)
|
||||
{
|
||||
if (reason.IsEmpty()) reason = "unspecified terrain clearance";
|
||||
int count;
|
||||
m_Rejections.Find(reason, count);
|
||||
m_Rejections.Set(reason, count + 1);
|
||||
}
|
||||
protected string RejectionSummary()
|
||||
{
|
||||
string summary;
|
||||
foreach (string reason, int count : m_Rejections)
|
||||
{
|
||||
if (!summary.IsEmpty()) summary += "; ";
|
||||
summary += reason + "=" + count.ToString();
|
||||
}
|
||||
if (summary.IsEmpty()) return "none recorded";
|
||||
return summary;
|
||||
}
|
||||
protected void ReportSearch(ERR_SiteDefinition site, string stage)
|
||||
{
|
||||
PrintFormat("Reintroduced [placement search]: stage=%1 site=%2 (%3) attempts=%4 elapsedMs=%5 failed/deferred checks: %6", stage, site.Id, site.Label, m_Attempts, System.GetTickCount() - m_Began, RejectionSummary());
|
||||
}
|
||||
protected void ReportProgress(ERR_SiteDefinition site)
|
||||
{
|
||||
int now = System.GetTickCount();
|
||||
if (now - m_Began < 30000 || now < m_NextDiagnostic) return;
|
||||
ReportSearch(site, "still searching");
|
||||
m_NextDiagnostic = now + 30000;
|
||||
}
|
||||
protected void ClearDiagnostics() { m_Rejections.Clear(); m_NextDiagnostic = 0; }
|
||||
protected bool m_Started;
|
||||
protected ERR_PrepareResult m_Result = ERR_PrepareResult.PENDING;
|
||||
protected string m_Error;
|
||||
@@ -144,6 +186,7 @@ class ERR_GeneratedSites
|
||||
if (candidate.Kind == ERR_LocationKind.RADIO && other.Kind == ERR_LocationKind.PRISON) minimum = 1000;
|
||||
if (vector.DistanceXZ(position, other.Position) < minimum) return false;
|
||||
if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.EXTRACTION && vector.DistanceXZ(position, other.Position) + 100 >= ERR_GameMode.Get().EscapeRadius()) return false;
|
||||
if (candidate.Kind == ERR_LocationKind.EXTRACTION && other.Kind == ERR_LocationKind.PRISON && vector.DistanceXZ(position, other.Position) + 100 >= ERR_GameMode.Get().EscapeRadius()) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -169,6 +212,10 @@ class ERR_GeneratedSites
|
||||
{
|
||||
if (site.Kind != ERR_LocationKind.EXTRACTION) return true;
|
||||
vector primary = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.AircraftOffset, heading));
|
||||
// Validate the primary pad before committing a random candidate, so an
|
||||
// obstructed clearing is retried instead of failing later during site creation.
|
||||
if (site.RequireAirfield) { if (!ERR_AirfieldPlacement.Clear(primary)) return false; }
|
||||
else if (!ERR_AirfieldPlacement.ClearGround(primary)) return false;
|
||||
return ERR_RescueLanding.Fits(primary);
|
||||
}
|
||||
ERR_PrepareResult Tick()
|
||||
@@ -180,6 +227,7 @@ class ERR_GeneratedSites
|
||||
{
|
||||
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
|
||||
ERR_SiteDefinition site = m_Pending[m_Index];
|
||||
ReportProgress(site);
|
||||
bool fallback = m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000;
|
||||
vector position = site.Position; float heading = site.Heading;
|
||||
if (!fallback)
|
||||
@@ -188,22 +236,30 @@ class ERR_GeneratedSites
|
||||
heading = Math.RandomFloat(0, 360); m_Attempts++;
|
||||
}
|
||||
float floor;
|
||||
if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor) && RescueFits(site, position, heading))
|
||||
bool fits;
|
||||
if (!Separated(site, position, fallback)) RecordRejection("site spacing / escape boundary");
|
||||
else if (!m_Terrain.Site(site, position, heading, floor)) RecordRejection(m_Terrain.Rejection());
|
||||
else if (!RescueFits(site, position, heading)) RecordRejection("aircraft landing clearance / fleet space");
|
||||
else fits = true;
|
||||
if (fits)
|
||||
{
|
||||
position[1] = floor + 0.02;
|
||||
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
|
||||
PrintFormat("Co10 Escape Reintroduced [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback);
|
||||
ReportSearch(site, "placed"); ClearDiagnostics();
|
||||
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
|
||||
}
|
||||
else if (fallback)
|
||||
{
|
||||
ReportSearch(site, "exhausted; authored fallback rejected");
|
||||
if (SkipOptional(site))
|
||||
{
|
||||
ClearDiagnostics();
|
||||
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
|
||||
}
|
||||
else
|
||||
{
|
||||
m_Error = "No safe terrain or valid authored fallback for required site " + site.Id;
|
||||
m_Error = "No safe terrain or valid authored fallback for required site " + site.Id + ". Checks: " + RejectionSummary();
|
||||
m_Result = ERR_PrepareResult.FAILED; return m_Result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ class ERR_LocationComponent : ScriptComponent
|
||||
bool Discover(int roll, int chance)
|
||||
{
|
||||
if (!Replication.IsServer() || !m_Prepared || m_Knowledge >= 2) return false;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (mode && mode.ShowAllSites()) chance = 100;
|
||||
int previous = m_Knowledge;
|
||||
m_Knowledge = ERR_MissionRules.Discovery(m_Knowledge, roll, chance);
|
||||
Replication.BumpMe();
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (mode) mode.LocationDiscovered(this, previous, m_Knowledge);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Generated by tools/build_admin_menu.py; edit ui/admin_menu/theme.json.
|
||||
class ERR_MenuTheme
|
||||
{
|
||||
static const int NEUTRAL = 0xFF454B52;
|
||||
static const int NEUTRAL_HOVER = 0xFF606870;
|
||||
static const int POSITIVE = 0xFF246B3C;
|
||||
static const int POSITIVE_HOVER = 0xFF31894F;
|
||||
static const int NEGATIVE = 0xFF8F2929;
|
||||
static const int NEGATIVE_HOVER = 0xFFB43B3B;
|
||||
static const int MIXED = 0xFF805B17;
|
||||
static const int READY_TEXT = 0xFF69BE8C;
|
||||
static const int UNAVAILABLE_TEXT = 0xFFDC7373;
|
||||
}
|
||||
@@ -19,16 +19,33 @@ modded class ERR_AdminMenu
|
||||
{
|
||||
GetRootWidget().Update();
|
||||
if (m_Page != expected) return false;
|
||||
int activeGroup = ERR_AdminMenuPages.Group(expected);
|
||||
Widget content = GetRootWidget().FindAnyWidget(Pages()[expected]);
|
||||
if (!content) return false;
|
||||
float contentX, contentY, contentWidth, contentHeight;
|
||||
content.GetScreenPos(contentX, contentY); content.GetScreenSize(contentWidth, contentHeight);
|
||||
array<string> mainButtons = ERR_AdminMenuPages.MainButtons();
|
||||
array<string> sidebars = ERR_AdminMenuPages.Sidebars();
|
||||
for (int group = 0; group < mainButtons.Count(); group++)
|
||||
{
|
||||
Widget mainTab = GetRootWidget().FindAnyWidget(mainButtons[group]);
|
||||
Widget marker = GetRootWidget().FindAnyWidget("Selected_" + mainButtons[group]);
|
||||
Widget sidebar = GetRootWidget().FindAnyWidget(sidebars[group]);
|
||||
if (!mainTab || !marker || !sidebar || !mainTab.IsVisible() || marker.IsVisible() != (group == activeGroup) || sidebar.IsVisible() != (group == activeGroup)) return false;
|
||||
float x, y, width, height;
|
||||
mainTab.GetScreenPos(x, y); mainTab.GetScreenSize(width, height);
|
||||
if (width <= 1 || height <= 1 || y + height > contentY) return false;
|
||||
}
|
||||
for (int i = 0; i < Pages().Count(); i++)
|
||||
{
|
||||
Widget panel = GetRootWidget().FindAnyWidget(Pages()[i]);
|
||||
Widget tab = GetRootWidget().FindAnyWidget(Navigation()[i]);
|
||||
Widget selected = GetRootWidget().FindAnyWidget("Selected_" + Navigation()[i]);
|
||||
if (!panel || !tab || !selected || panel.IsVisible() != (i == expected) || !tab.IsVisible() || selected.IsVisible() != (i == expected)) return false;
|
||||
float x, y, width, height, px, py;
|
||||
tab.GetScreenPos(x, y); tab.GetScreenSize(width, height); panel.GetScreenPos(px, py);
|
||||
if (width <= 1 || height <= 1 || (i == expected && y + height > py))
|
||||
{ PrintFormat("ERR ADMIN NAV BOUNDS: page=%1 tab=%2 bounds=%3,%4,%5,%6 panelY=%7", expected, i, x, y, width, height, py); return false; }
|
||||
if (!panel || !tab || !selected || panel.IsVisible() != (i == expected) || selected.IsVisible() != (i == expected)) return false;
|
||||
if (ERR_AdminMenuPages.Group(i) != activeGroup) continue;
|
||||
float x, y, width, height;
|
||||
tab.GetScreenPos(x, y); tab.GetScreenSize(width, height);
|
||||
if (!tab.IsVisible() || width <= 1 || height <= 1 || x + width > contentX || y < contentY || y + height > contentY + contentHeight) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -163,6 +180,36 @@ modded class ERR_AdminMenu
|
||||
}
|
||||
ERR_ProbePage(0); return true;
|
||||
}
|
||||
bool ERR_ProbeReportOverflow(string textName, string scrollName)
|
||||
{
|
||||
TextWidget text = TextWidget.Cast(GetRootWidget().FindAnyWidget(textName));
|
||||
ScrollLayoutWidget scroll = ScrollLayoutWidget.Cast(GetRootWidget().FindAnyWidget(scrollName));
|
||||
if (!text || !scroll) return false;
|
||||
string saved = text.GetText();
|
||||
string report;
|
||||
for (int i = 0; i < 80; i++) report += "Report line " + i.ToString() + ": a long mission report must remain readable.\n";
|
||||
report += "FINAL REPORT LINE";
|
||||
text.SetText(report); GetRootWidget().Update();
|
||||
float width, height, textWidth, textHeight, viewWidth, viewHeight;
|
||||
text.GetScreenSize(width, height); text.GetTextSize(textWidth, textHeight);
|
||||
scroll.GetScreenSize(viewWidth, viewHeight);
|
||||
bool fits = GetGame().GetWorkspace().DPIUnscale(height) >= textHeight - 2 && height > viewHeight;
|
||||
scroll.SetSliderPos(0, 1); GetRootWidget().Update();
|
||||
float x, y, viewX, viewY, sliderX, sliderY;
|
||||
text.GetScreenPos(x, y); scroll.GetScreenPos(viewX, viewY); scroll.GetSliderPos(sliderX, sliderY);
|
||||
bool reachable = sliderY >= 0.98 && y + height <= viewY + viewHeight + 2 && y + height > viewY;
|
||||
PrintFormat("ERR REPORT OVERFLOW: widget=%1 height=%2 textHeight=%3 viewport=%4 slider=%5 fits=%6 reachable=%7", textName, height, textHeight, viewHeight, sliderY, fits, reachable);
|
||||
text.SetText(saved); scroll.SetSliderPos(0, 0); GetRootWidget().Update();
|
||||
return fits && reachable;
|
||||
}
|
||||
bool ERR_ProbeHunterSummary()
|
||||
{
|
||||
TextWidget text = TextWidget.Cast(GetRootWidget().FindAnyWidget("Status"));
|
||||
if (!text) return false;
|
||||
string report = text.GetText();
|
||||
array<string> parts = {}; report.Split("Sarka waves:", parts, false);
|
||||
return report.Contains("HQ hunting patrols:") && parts.Count() == 2 && report.Contains("One car per");
|
||||
}
|
||||
bool ERR_ProbeJournalVisible()
|
||||
{
|
||||
TextWidget text = TextWidget.Cast(GetRootWidget().FindAnyWidget("JournalText"));
|
||||
|
||||
@@ -168,7 +168,7 @@ modded class ERR_GameMode
|
||||
if (!ERR_MortarIntelReady()) return;
|
||||
if (!ERR_AirfieldCheck(!Sites().Find("everon-supply-2"), "guarded car-and-crate site absent")) return;
|
||||
ERR_SiteRuntime extraction = Sites().Find("everon-extraction-1");
|
||||
if (!ERR_AirfieldCheck(extraction.Definition.RequireAirfield && vector.DistanceXZ(ExtractionVehicle().GetOrigin(), "4800.953 0 11487.412") < 3 && ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "production aircraft settled intact on validated Everon airfield pad")) return;
|
||||
if (!ERR_AirfieldCheck(!extraction.Definition.RequireAirfield && extraction.Definition.PlacementResolved && vector.DistanceXZ(ExtractionVehicle().GetOrigin(), extraction.Definition.Position) < 3 && ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "production aircraft settled intact on validated generated landing zone")) return;
|
||||
if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ERR_Actors.Ground("7012 0 7500")), "old off-airfield helicopter position rejected")) return;
|
||||
if (!ERR_AirfieldCheck(!ERR_AirfieldPlacement.Clear(ExtractionVehicle().GetOrigin()), "occupied airfield pad rejected")) return;
|
||||
if (!ERR_AirfieldCheck(Sites().Find("everon-medical-1").Definition.RevealOnEscape && Sites().Find("everon-supply-1").Definition.RevealOnEscape, "two reachable intel sites retained")) return;
|
||||
|
||||
@@ -55,7 +55,7 @@ modded class ERR_GameMode
|
||||
string enabled;
|
||||
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
|
||||
string nativeEndCase;
|
||||
array<string> independentFixtures = {"coordinated-ai", "optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "self-epinephrine", "starter-backpack", "speech-download", "bundled-speech", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "roadblocks", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
|
||||
array<string> independentFixtures = {"coordinated-ai", "optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "self-epinephrine", "starter-backpack", "speech-download", "bundled-speech", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "sarka-hunters", "native-end-active", "native-end-preparing", "generated-sites", "roadblocks", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
|
||||
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && independentFixtures.Contains(nativeEndCase)) return;
|
||||
ERR_NativeObjectiveProbe.BeginRound();
|
||||
ERR_NativeProgressionIdentityProbe.BeginRound();
|
||||
@@ -775,6 +775,10 @@ modded class ERR_GameMode
|
||||
ERR_SiteRuntime medical = Sites().Find("everon-medical-1");
|
||||
if (!ERR_BaseCheck(medical.Location.Knowledge() == 2 && medical.Location.Label() == "Medical stash" && m_ERRMedicalDiscoveries == 1, "conscious nearby player visually discovers named medical stash once")) return;
|
||||
if (!ERR_BaseCheck(!Sites().CanObserve(m_ERRBasePlayer, Sites().Find("everon-radio-1")), "distant medical-site discovery does not reveal the radio")) return;
|
||||
// This sparse historical fixture has two eligible sites. Arrange the
|
||||
// remaining identification before testing document ownership and recovery.
|
||||
Sites().Find("everon-outpost-1").Location.Discover(0, 100);
|
||||
if (!ERR_BaseCheck(Discovery().RadioIntelAllowed(), "identified optional sites unlock the historical radio documents")) return;
|
||||
m_ERRBaseStep = 1;
|
||||
}
|
||||
else if (m_ERRBaseStep == 1 || m_ERRBaseStep == 2)
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
modded class ERR_CampTerrain
|
||||
{
|
||||
static bool s_ERROptionalSiteRefused;
|
||||
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
|
||||
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight, float airportBuffer = 1500)
|
||||
{
|
||||
string outcome;
|
||||
if ((site.Id == "everon-camp-5" || site.Id == "everon-outpost-1") && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "optional-site-skip")
|
||||
{
|
||||
s_ERROptionalSiteRefused = true; return false;
|
||||
}
|
||||
return super.Site(site, position, heading, baseHeight);
|
||||
return super.Site(site, position, heading, baseHeight, airportBuffer);
|
||||
}
|
||||
}
|
||||
modded class ERR_GameMode
|
||||
|
||||
@@ -56,7 +56,7 @@ modded class ERR_GameMode
|
||||
float now = GetWorld().GetWorldTime();
|
||||
if (Phase() == ERR_Phase.VICTORY)
|
||||
{
|
||||
ERR_RescueCheck(Extraction().EscapedCount() == 1, "native flight carried the original prisoner across the escape boundary");
|
||||
ERR_RescueCheck(Extraction().EscapedCount() == 1, "native flight carried the original prisoner through thirty seconds of airborne evacuation");
|
||||
Print("ERR RESCUE COMPLETE"); GetGame().GetCallqueue().Remove(ERR_RescueProbeTick); return;
|
||||
}
|
||||
if (!PreparationError().IsEmpty() || Phase() == ERR_Phase.DEFEAT) { ERR_RescueCheck(false, "mission failed: " + PreparationError()); return; }
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
#ifdef WORKBENCH
|
||||
class ERR_RoadblockProbeRules
|
||||
{
|
||||
static int Target()
|
||||
static int SmallTarget()
|
||||
{
|
||||
string value;
|
||||
if (System.GetCLIParam("errRoadblockProbeCount", value)) return Math.Clamp(value.ToInt(), 1, 30);
|
||||
if (System.GetCLIParam("errRoadblockProbeCount", value)) return Math.Clamp(value.ToInt(), 0, 30);
|
||||
return 30;
|
||||
}
|
||||
static int LargeTarget()
|
||||
{
|
||||
string value;
|
||||
if (System.GetCLIParam("errLargeRoadblockProbeCount", value)) return Math.Clamp(value.ToInt(), 0, 30);
|
||||
return 0;
|
||||
}
|
||||
static int Target() { return SmallTarget() + LargeTarget(); }
|
||||
static int Guards() { return SmallTarget() * 4 + LargeTarget() * 8; }
|
||||
static int Props() { return SmallTarget() * 8 + LargeTarget() * 14; }
|
||||
|
||||
}
|
||||
modded class ERR_HQ
|
||||
{
|
||||
@@ -23,7 +33,11 @@ modded class ERR_Roadblocks
|
||||
{
|
||||
string outcome;
|
||||
if (!m_Begun && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "roadblocks")
|
||||
m_Mode.Settings().Set("roadblockCount", ERR_RoadblockProbeRules.Target());
|
||||
{
|
||||
m_Mode.Settings().Set("roadblockCount", ERR_RoadblockProbeRules.SmallTarget());
|
||||
m_Mode.Settings().Set("largeRoadblockCount", ERR_RoadblockProbeRules.LargeTarget());
|
||||
m_Mode.Settings().Set("mainRoadWidthMetres", 6);
|
||||
}
|
||||
return super.Prepare(definition);
|
||||
}
|
||||
protected int m_ERRLayoutPosts;
|
||||
@@ -40,7 +54,7 @@ modded class ERR_Roadblocks
|
||||
for (int i = 0; i < m_BuildCovers.Count() && valid; i++)
|
||||
{
|
||||
ERR_RoadblockProp prop = m_BuildCovers[i];
|
||||
IEntity entity = m_BuildSpawned[i + 4];
|
||||
IEntity entity = m_BuildSpawned[i + m_BuildPositions.Count()];
|
||||
vector low, high; entity.GetBounds(low, high);
|
||||
if (m_ERRLayoutPosts == 0) PrintFormat("ERR ROADBLOCK PROP NATIVE: prefab=%1 low=%2 high=%3", prop.Prefab, low, high);
|
||||
if (Math.Max(Math.AbsFloat(low[0]), Math.AbsFloat(high[0])) > prop.HalfWidth
|
||||
@@ -102,28 +116,29 @@ modded class ERR_Roadblocks
|
||||
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 || !record || record.Kind != ERR_ForceKind.CHECKPOINT || (record.InitialStrength != 4 && record.InitialStrength != 8) || m_Mode.ForceLiving(group) != record.InitialStrength) return false;
|
||||
if (group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
|
||||
}
|
||||
if (m_SmallPlaced != ERR_RoadblockProbeRules.SmallTarget() || m_LargePlaced != ERR_RoadblockProbeRules.LargeTarget()) 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;
|
||||
PrintFormat("ERR ROADBLOCK BACKGROUND NATIVE: posts=%1 guards=%2 steps=%3 workMs=%4 maxStepMs=%5 attempts=%6", count, ERR_RoadblockProbeRules.Guards(), m_BackgroundSteps, m_BackgroundWork, m_MaxBackgroundStep, m_Attempts);
|
||||
return m_Positions.Count() == count && (m_BackgroundSteps > 0 || ERR_RoadblockProbeRules.Target() <= 4);
|
||||
}
|
||||
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;
|
||||
if (!m_Ready || m_Target != m_SmallTarget + m_LargeTarget || m_Groups.IsEmpty() || m_Groups.Count() > m_Target || m_Groups.Count() != m_Positions.Count()) return false;
|
||||
int count = m_Groups.Count();
|
||||
foreach (SCR_AIGroup group : m_Groups)
|
||||
{
|
||||
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
|
||||
if (!record || record.Kind != ERR_ForceKind.CHECKPOINT || record.InitialStrength != 4 || m_Mode.ForceLiving(group) != 4
|
||||
if (!record || record.Kind != ERR_ForceKind.CHECKPOINT || (record.InitialStrength != 4 && record.InitialStrength != 8) || m_Mode.ForceLiving(group) != record.InitialStrength
|
||||
|| group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
|
||||
}
|
||||
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
|
||||
// Repeated preparation cannot replenish or create another squad.
|
||||
if (Prepare(definition) != ERR_PrepareResult.READY || m_Groups.Count() != count) return false;
|
||||
PrintFormat("ERR ROADBLOCK NATIVE: posts=%1 soldiers=%2 ledger=checkpoint HQ=available repeatedPrepare=unchanged", count, count * 4);
|
||||
PrintFormat("ERR ROADBLOCK NATIVE: posts=%1 soldiers=%2 ledger=checkpoint HQ=available repeatedPrepare=unchanged", count, m_SmallPlaced * 4 + m_LargePlaced * 8);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -156,13 +171,13 @@ modded class ERR_GameMode
|
||||
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() >= ERR_RoadblockProbeRules.Target() || !UnlockPrison(player)) { ERR_RoadblockProbeFail("did not enter background search with prison unlocked"); return; }
|
||||
if ((ERR_RoadblockProbeRules.Target() > 4 && (!posts.ERR_ProbeBackgroundPending() || posts.ERR_ProbePostCount() >= ERR_RoadblockProbeRules.Target())) || !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/%2 posts; background still pending", m_ERRRoadblockInitial, ERR_RoadblockProbeRules.Target());
|
||||
PrintFormat("ERR SMOKE PASS: roadblocks prison unlocked with %1/%2 posts; placement observation started", m_ERRRoadblockInitial, ERR_RoadblockProbeRules.Target());
|
||||
}
|
||||
if (posts.ERR_ProbeBackgroundPending()) return;
|
||||
if (!posts.ERR_ProbeCompleteRoadblocks() || posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial) { ERR_RoadblockProbeFail("physical four-guard posts, replicated props, ledger and HQ admission"); return; }
|
||||
PrintFormat("ERR SMOKE PASS: roadblocks %1 physical posts / %2 guards / %3 replicated checkpoint props with distance streaming disabled; verified model footprints and centre lane; background completed after prison unlock, no extra posts on repeat tick", posts.ERR_ProbePostCount(), posts.ERR_ProbePostCount() * 4, posts.ERR_ProbePostCount() * 10);
|
||||
if (!posts.ERR_ProbeCompleteRoadblocks() || (ERR_RoadblockProbeRules.Target() > 4 && posts.ERR_ProbePostCount() <= m_ERRRoadblockInitial)) { ERR_RoadblockProbeFail("physical small/large posts, independent quotas, replicated props, ledger and HQ admission"); return; }
|
||||
PrintFormat("ERR SMOKE PASS: roadblocks %1 physical posts / %2 guards / %3 replicated checkpoint props with distance streaming disabled; verified model footprints and centre lane; placement complete after prison unlock, no extra posts on repeat tick", posts.ERR_ProbePostCount(), ERR_RoadblockProbeRules.Guards(), ERR_RoadblockProbeRules.Props());
|
||||
ERR_StopRoadblockProbe();
|
||||
Transition(ERR_Phase.DEFEAT);
|
||||
Print("ERR SMOKE COMPLETE: roadblocks background completion on Everon; scripted local fixture");
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
#ifdef WORKBENCH
|
||||
modded class ERR_AdminMenu
|
||||
{
|
||||
bool ERR_SarkaProbeReady() { return m_Ready && m_AvailableFields.Contains("sarkaAngerPerCar"); }
|
||||
bool ERR_SarkaProbeSet(int points)
|
||||
{
|
||||
ShowPage(Navigation().Find("OpenSarka"));
|
||||
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("sarkaAngerPerCar", GetRootWidget());
|
||||
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Apply_sarkaAngerPerCar", GetRootWidget());
|
||||
if (!edit || !button || !button.IsEnabled()) return false;
|
||||
edit.SetValue(points.ToString()); OnButton(button); return true;
|
||||
}
|
||||
}
|
||||
modded class ERR_HunterService
|
||||
{
|
||||
void ERR_ProbeSarkaDue(float now) { m_SarkaWaves.NextWave = now; }
|
||||
bool ERR_ProbeSarkaDisabled() { return m_SarkaWaves.AngerPerCar == 0 && m_SarkaWaves.Pending == 0; }
|
||||
void ERR_ProbeSarkas(out array<ERR_HunterVehicle> cars)
|
||||
{
|
||||
cars.Clear();
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Sarka) cars.Insert(unit);
|
||||
}
|
||||
}
|
||||
modded class ERR_HQ
|
||||
{
|
||||
void ERR_ProbeSarkaBodies(float now)
|
||||
{
|
||||
for (int slot = 0; slot < 24; slot++)
|
||||
{
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.TravelMode = "body"; report.MessageId = 200000 + slot;
|
||||
report.CasualtySourceId = "fixture-sarka-bodies"; report.CasualtyStrength = 24; report.CasualtySlot = slot;
|
||||
report.ObservedAt = now; report.Position = "5000 50 5000";
|
||||
Accept(report, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected int m_ERRSarkaStage;
|
||||
protected int m_ERRSarkaMenuPage;
|
||||
protected int m_ERRSarkaDeadline;
|
||||
protected bool m_ERRSarkaOrder;
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
string outcome;
|
||||
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "sarka-hunters") return;
|
||||
m_ERRSarkaDeadline = System.GetTickCount() + 420000;
|
||||
GetGame().GetCallqueue().CallLater(ERR_SarkaProbeTick, 1000, true);
|
||||
}
|
||||
protected bool ERR_SarkaProbeCheck(bool ok, string detail)
|
||||
{
|
||||
if (ok) { Print("ERR SMOKE PASS: sarka " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: sarka " + detail, LogLevel.ERROR);
|
||||
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick); return false;
|
||||
}
|
||||
protected void ERR_SarkaProbeTick()
|
||||
{
|
||||
if (System.GetTickCount() >= m_ERRSarkaDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_SarkaProbeCheck(false, "native spawn/admission/driving deadline"); return; }
|
||||
if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
|
||||
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
|
||||
DamageManagerComponent damage = DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent));
|
||||
if (damage) damage.EnableDamageHandling(false);
|
||||
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return;
|
||||
float now = GetWorld().GetWorldTime();
|
||||
if (m_ERRSarkaStage == 0)
|
||||
{
|
||||
if (!ERR_SarkaProbeCheck(Settings().Get("sarkaAngerPerCar") == 3, "default three anger points per car")) return;
|
||||
ERR_AdminMenu.Open(); m_ERRSarkaStage = 9; return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 9)
|
||||
{
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
|
||||
if (!menu || !menu.ERR_SarkaProbeReady()) return;
|
||||
menu.ERR_ProbePage(m_ERRSarkaMenuPage); m_ERRSarkaStage = 90; return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 90)
|
||||
{
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
|
||||
if (!ERR_SarkaProbeCheck(menu && menu.ERR_ProbeNavigation(m_ERRSarkaMenuPage), "top main tabs and left sidebar reach page " + m_ERRSarkaMenuPage.ToString())) return;
|
||||
if (m_ERRSarkaMenuPage == ERR_AdminPage.STATUS)
|
||||
{
|
||||
if (!ERR_SarkaProbeCheck(menu.ERR_ProbeHunterSummary(), "patrol summary and exactly one Sarka wave summary")) return;
|
||||
if (!ERR_SarkaProbeCheck(menu.ERR_ProbeReportOverflow("Status", "StatusScroll"), "long status grows and last line scrolls into view")) return;
|
||||
}
|
||||
if (m_ERRSarkaMenuPage == ERR_AdminPage.JOURNAL)
|
||||
if (!ERR_SarkaProbeCheck(menu.ERR_ProbeReportOverflow("JournalText", "JournalScroll"), "long journal grows and last line scrolls into view")) return;
|
||||
System.MakeScreenshot("$logs:err-menu-sidebar-" + m_ERRSarkaMenuPage.ToString());
|
||||
m_ERRSarkaStage = 91; return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 91)
|
||||
{
|
||||
m_ERRSarkaMenuPage++;
|
||||
if (m_ERRSarkaMenuPage < ERR_AdminMenu.Pages().Count()) m_ERRSarkaStage = 9;
|
||||
else m_ERRSarkaStage = 10;
|
||||
return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 10)
|
||||
{
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
|
||||
if (!menu || !menu.ERR_SarkaProbeReady()) return;
|
||||
if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(2), "real menu submits two anger points per car")) return;
|
||||
m_ERRSarkaStage = 11; return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 11)
|
||||
{
|
||||
if (Settings().Get("sarkaAngerPerCar") != 2) return;
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (!menu || !menu.ERR_SarkaProbeReady()) return;
|
||||
System.MakeScreenshot("$logs:err-sarka-settings"); menu.Close();
|
||||
ERR_Settings reloaded = new ERR_Settings();
|
||||
if (!ERR_SarkaProbeCheck(reloaded.Get("sarkaAngerPerCar") == 2, "menu owner RPC persists anger threshold")) return;
|
||||
if (!ERR_SarkaProbeCheck(UnlockPrison(player) || Phase() >= ERR_Phase.RADIO_SEARCH, "prison released")) return;
|
||||
hq.ERR_ProbeSarkaBodies(now);
|
||||
if (!ERR_SarkaProbeCheck(hq.Watch().Aggression() == 8, "24 delivered bodies produce eight anger points")) return;
|
||||
// Accelerate only the opt-in wave deadline; production placement/admission run unchanged.
|
||||
hq.Hunters().ERR_ProbeSarkaDue(now); m_ERRSarkaStage = 1;
|
||||
}
|
||||
array<ERR_HunterVehicle> cars = {}; hq.Hunters().ERR_ProbeSarkas(cars);
|
||||
if (cars.Count() < 4) return;
|
||||
if (m_ERRSarkaStage == 20)
|
||||
{
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
|
||||
if (!menu || !menu.ERR_SarkaProbeReady()) return;
|
||||
if (!ERR_SarkaProbeCheck(menu.ERR_SarkaProbeSet(0), "real menu submits disable")) return;
|
||||
m_ERRSarkaStage = 21; return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 21)
|
||||
{
|
||||
if (Settings().Get("sarkaAngerPerCar") != 0 || !hq.Hunters().ERR_ProbeSarkaDisabled()) return;
|
||||
foreach (ERR_HunterVehicle retained : cars)
|
||||
if (!ERR_SarkaProbeCheck(retained.Entity && retained.Members.Count() == 4, "disabling retains deployed cars and teams")) return;
|
||||
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (menu) menu.Close();
|
||||
GetGame().GetCallqueue().Remove(ERR_SarkaProbeTick);
|
||||
Print("ERR SMOKE COMPLETE: sarka menu save, anger threshold, four SF cars, radio, driving and disable"); return;
|
||||
}
|
||||
if (m_ERRSarkaStage == 1)
|
||||
{
|
||||
if (!ERR_SarkaProbeCheck(cars.Count() == 4, "eight anger at two points per car produces four physical cars")) return;
|
||||
array<vector> positions = {player.GetOrigin()};
|
||||
foreach (ERR_HunterVehicle car : cars)
|
||||
{
|
||||
if (!car.Registered) return;
|
||||
if (!ERR_SarkaProbeCheck(car.Entity.GetPrefabData().GetPrefabName() == ERR_SarkaRules.Prefab() && car.CrewValid(this) && car.Members.Count() == 4 && car.Seats.Count() == 4, "native S105 with four original seated crew")) return;
|
||||
if (!ERR_SarkaProbeCheck(ERR_SarkaRules.InBand(car.PatrolHome, positions), "actual road spawn 1-1.25 km from player")) return;
|
||||
if (!ERR_SarkaProbeCheck(hq.Hunters().VehicleRadioCapability(car.Driver) == 2 && car.Group.GetFaction() == EnemyFaction(), "physical vehicle manpack and selected hostile faction")) return;
|
||||
int medics;
|
||||
foreach (IEntity member : car.Members)
|
||||
{
|
||||
ResourceName profile = member.GetPrefabData().GetPrefabName();
|
||||
if (profile == ERR_EnemyMedicalCatalog.Crew(this, true)) medics++;
|
||||
else if (!ERR_SarkaProbeCheck(profile == ERR_EnemyMedicalCatalog.Crew(this, false), "selected special-forces profile")) return;
|
||||
SCR_AICombatComponent combat = SCR_AICombatComponent.Cast(member.FindComponent(SCR_AICombatComponent));
|
||||
if (!combat || combat.GetAISkill() != EAISkill.CYLON || !combat.ERR_EnhancedEnemy()) return;
|
||||
}
|
||||
if (!ERR_SarkaProbeCheck(medics == 1, "one medic within the four-person team")) return;
|
||||
}
|
||||
m_ERRSarkaStage = 2;
|
||||
}
|
||||
if (cars[0].Waypoint) m_ERRSarkaOrder = true;
|
||||
if (!m_ERRSarkaOrder || vector.DistanceXZ(cars[0].PatrolHome, cars[0].Entity.GetOrigin()) < 30) return;
|
||||
if (!ERR_SarkaProbeCheck(hq.Hunters().Deployed() <= hq.Watch().HunterTarget(), "cars do not spend ordinary hunter allowance")) return;
|
||||
ERR_SarkaProbeCheck(true, "native patrol order and over 30 metres driven; all crew enhanced");
|
||||
ERR_AdminMenu.Open(); m_ERRSarkaStage = 20;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -35,7 +35,7 @@ class ERR_ObjectiveText
|
||||
ERR_Phase phase = mode.Phase();
|
||||
if (phase == ERR_Phase.PREPARING)
|
||||
{
|
||||
if (!mode.PreparationError().IsEmpty()) return "The mission could not start. An administrator can use /esc status for the cause. Spawning is disabled until the configuration is corrected and the scenario restarted.";
|
||||
if (!mode.PreparationError().IsEmpty()) return "Reason: " + mode.PreparationError() + "\n\nAn administrator must correct the problem and restart the scenario before players can spawn.";
|
||||
return "Preparing mission sites and equipment. You will spawn when the mission is ready.";
|
||||
}
|
||||
if (phase == ERR_Phase.PRISON)
|
||||
|
||||
@@ -52,7 +52,7 @@ class ERR_ObserverGuidance
|
||||
}
|
||||
if (!survivorAvailable)
|
||||
return message + " No teammate or body is currently available to follow. Use camera look controls to orbit the last known position and zoom controls to change distance.";
|
||||
return message + " Press Left / Right arrow to change teammate or body (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and the living teammate's camera. Player view waits in third-person if no live camera stream is available. Use camera look controls to orbit and zoom controls to change distance in third-person.";
|
||||
return message + " Press Left / Right arrow to change teammate or body, including your own (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and the living teammate's camera. Player view waits in third-person if no live camera stream is available. Use camera look controls to orbit and zoom controls to change distance in third-person.";
|
||||
}
|
||||
static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable)
|
||||
{
|
||||
@@ -225,7 +225,7 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
|
||||
{
|
||||
target.GetWorldTransform(transform);
|
||||
aim = transform[3] + "0 1.5 0";
|
||||
if (!ERR_Actors.Alive(target)) aim = transform[3] + "0 0.4 0";
|
||||
if (!ERR_Actors.CanFight(target)) aim = transform[3] + "0 0.4 0";
|
||||
}
|
||||
if (target != m_Target || vehicle != m_Vehicle)
|
||||
{
|
||||
@@ -242,7 +242,7 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
|
||||
m_Target = null; m_Vehicle = null;
|
||||
m_Distance = Math.Clamp(m_Distance, 2, 12);
|
||||
}
|
||||
if ((!m_PlayerView || !target || !ERR_Actors.Alive(target)) && param.isManualInputEnabled)
|
||||
if ((!m_PlayerView || !target || !ERR_Actors.CanFight(target)) && param.isManualInputEnabled)
|
||||
{
|
||||
m_Yaw += GetInputManager().GetActionValue("ManualCameraRotateYaw") * 90 * param.timeSlice;
|
||||
m_Pitch = Math.Clamp(m_Pitch - GetInputManager().GetActionValue("ManualCameraRotatePitch") * 90 * param.timeSlice, 5, 75);
|
||||
@@ -284,6 +284,8 @@ modded class SCR_PlayerController
|
||||
protected ERR_ObserverReason m_ERRShownObserverReason;
|
||||
protected bool m_ERRShownObserverAvailable;
|
||||
protected string m_ERRShownRecoveryStatus;
|
||||
protected string m_ERRPendingRecoveryStatus;
|
||||
protected int m_ERRRecoveryStatusSince;
|
||||
protected float m_ERRLastCycle = -1000;
|
||||
protected IEntity m_ERRRecoveryBody;
|
||||
protected float m_ERRRecoveryDeadline = -1;
|
||||
@@ -364,10 +366,13 @@ modded class SCR_PlayerController
|
||||
if (survivors[i] == body || (casualty && !ERR_Actors.CanFight(survivors[i])) || !friendly || !mode.Players() || !mode.Players().CurrentActor(survivors[i]) || SCR_FactionManager.SGetFaction(survivors[i]) != friendly) survivors.RemoveOrdered(i);
|
||||
// Dead teammates remain selectable alongside survivors, including our own body.
|
||||
ERR_ObserverTargets.Bodies(mode, survivors);
|
||||
// Lethal damage normally leaves a treatable casualty, not a native corpse.
|
||||
// Keep that player's own canonical body in the cycle without possessing it.
|
||||
if (casualty && !survivors.Contains(body)) survivors.Insert(body);
|
||||
int index = survivors.Find(m_ERRServerObserved);
|
||||
if (index < 0)
|
||||
{
|
||||
if (life && life.Actor) index = survivors.Find(life.Actor);
|
||||
if (!casualty && life && life.Actor) index = survivors.Find(life.Actor);
|
||||
if (index < 0) index = 0;
|
||||
}
|
||||
else index += direction;
|
||||
@@ -468,13 +473,24 @@ modded class SCR_PlayerController
|
||||
string recovery = mode.RecoveryStatus();
|
||||
string selfEpiHint = ERR_SelfEpinephrineHint();
|
||||
if (!selfEpiHint.IsEmpty()) recovery += "\n" + selfEpiHint;
|
||||
if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable) || m_ERRShownRecoveryStatus != recovery)
|
||||
bool contextChanged = ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable);
|
||||
int now = System.GetTickCount();
|
||||
// Medical samples can alternate between improving and stalled. Keep the
|
||||
// accepted hint readable until the new status has remained stable for 3s.
|
||||
// This affects presentation only, never the authoritative defeat timer.
|
||||
if (m_ERRPendingRecoveryStatus != recovery || now < m_ERRRecoveryStatusSince)
|
||||
{
|
||||
m_ERRPendingRecoveryStatus = recovery;
|
||||
m_ERRRecoveryStatusSince = now;
|
||||
}
|
||||
if (!contextChanged && m_ERRShownRecoveryStatus != recovery && now - m_ERRRecoveryStatusSince < 3000) return;
|
||||
if (contextChanged || m_ERRShownRecoveryStatus != recovery)
|
||||
{
|
||||
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
|
||||
if (!hints) return; // retry on the existing monitor; do not lose the first hint
|
||||
string message = ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable);
|
||||
if (!recovery.IsEmpty()) message += "\n" + recovery;
|
||||
if (!hints.ShowCustom(message, "Spectating", 12)) return;
|
||||
if (!hints.ShowCustom(message, "Spectating", 12, m_ERRObserverReady)) return;
|
||||
m_ERRObserverReady = true;
|
||||
m_ERRShownObserverReason = m_ERRObserverReason;
|
||||
m_ERRShownObserverAvailable = survivorAvailable;
|
||||
@@ -510,6 +526,8 @@ modded class SCR_PlayerController
|
||||
{
|
||||
m_ERRObserverReady = false;
|
||||
m_ERRShownRecoveryStatus = "";
|
||||
m_ERRPendingRecoveryStatus = "";
|
||||
m_ERRRecoveryStatusSince = 0;
|
||||
if (!m_ERRObserverCamera) return;
|
||||
if (GetGame().GetPlayerController() == this) m_ERRObserverCamera.TrySwitchToControlledEntityCamera();
|
||||
m_ERRObserverCamera.Terminate();
|
||||
|
||||
@@ -3,30 +3,44 @@
|
||||
class ERR_ObserverViewRules
|
||||
{
|
||||
// One unreliable packet contains both cameras, so loss/reordering cannot
|
||||
// pair a new eye pose with an old aiming pose. Four vectors for ordinary/2D,
|
||||
// nine for PiP: scope pose/lens, eye pose/lens, native lens geometry.
|
||||
// pair a new eye pose with an old aiming pose. Five vectors for ordinary/2D,
|
||||
// ten for PiP: the existing pose/lens data followed by the sampled body origin.
|
||||
// Older offset-only packets are rejected rather than decoded as world poses.
|
||||
static bool Packet(array<vector> packet)
|
||||
{
|
||||
if (!packet || (packet.Count() != 4 && packet.Count() != 9)) return false;
|
||||
if (!packet || (packet.Count() != 5 && packet.Count() != 10)) return false;
|
||||
vector origin = packet[packet.Count() - 1];
|
||||
if (!Origin(origin) || !Origin(WorldPosition(origin, packet[0]))) return false;
|
||||
if (!Sample(packet[0], packet[1], packet[2], packet[3][0], packet[3][1])) return false;
|
||||
if (packet.Count() == 4) return packet[3][2] == 0;
|
||||
if (packet.Count() == 5) return packet[3][2] == 0;
|
||||
if (!(packet[3][2] >= 0.01 && packet[3][2] <= 150)) return false;
|
||||
if (!Origin(WorldPosition(origin, packet[4]))) return false;
|
||||
if (!Sample(packet[4], packet[5], packet[6], packet[7][0], packet[7][1])) return false;
|
||||
return packet[7][2] >= 0 && packet[7][2] <= 100 && Math.AbsFloat(packet[8][0]) <= 90 && Math.AbsFloat(packet[8][1]) <= 90 && packet[8][2] >= 0.001 && packet[8][2] <= 100;
|
||||
}
|
||||
// The body and camera arrive through independently timed replication streams.
|
||||
// Keep the sampled world axes and world-axis eye offset together. Applying
|
||||
// the proxy body's rotation to delayed body-local aim adds its yaw error to
|
||||
// both aim and eye position, even when the owner's camera is steady.
|
||||
// Sample axes, world-axis eye offset and body origin on the owner together.
|
||||
// Neither the proxy body rotation nor its position is used during playback.
|
||||
static void Encode(vector body[4], vector view[4], out vector pose[4])
|
||||
{
|
||||
for (int axis = 0; axis < 3; axis++) pose[axis] = view[axis];
|
||||
pose[3] = view[3] - body[3];
|
||||
}
|
||||
static void Decode(vector body[4], vector pose[4], out vector view[4])
|
||||
// Reconstruct before interpolation, once per received sample. Both eye and
|
||||
// scope use the exact same captured origin, never the current proxy position.
|
||||
static vector WorldPosition(vector origin, vector offset) { return origin + offset; }
|
||||
static bool Origin(vector position)
|
||||
{
|
||||
for (int axis = 0; axis < 3; axis++) view[axis] = pose[axis];
|
||||
view[3] = body[3] + pose[3];
|
||||
// Finite coordinate bounds cover large terrains while rejecting NaN/infinity.
|
||||
return Math.AbsFloat(position[0]) <= 10000000 && Math.AbsFloat(position[1]) <= 10000000 && Math.AbsFloat(position[2]) <= 10000000;
|
||||
}
|
||||
static bool NearBody(vector origin, vector authoritative)
|
||||
{
|
||||
if (!Origin(origin) || !Origin(authoritative)) return false;
|
||||
return (origin - authoritative).LengthSq() <= 10000;
|
||||
}
|
||||
static bool WorldSample(vector position, vector forward, vector up, float fov, float nearPlane)
|
||||
{
|
||||
return Origin(position) && Sample(vector.Zero, forward, up, fov, nearPlane);
|
||||
}
|
||||
static bool Sample(vector position, vector forward, vector up, float fov, float nearPlane)
|
||||
{
|
||||
@@ -42,6 +56,7 @@ class ERR_ObserverViewRules
|
||||
class ERR_ObserverViewSample
|
||||
{
|
||||
int Stamp;
|
||||
// Complete sampled world pose, not an offset from the replicated proxy.
|
||||
vector Position, Forward, Up;
|
||||
float Fov, Near;
|
||||
float PipFov, Vignette;
|
||||
@@ -74,7 +89,7 @@ class ERR_ObserverViewBuffer
|
||||
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData, float pipFov = 0, vector geometry = "0 0 0", float vignette = 0)
|
||||
{
|
||||
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
|
||||
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
|
||||
if (!ERR_ObserverViewRules.WorldSample(position, forward, up, fov, nearPlane)) return;
|
||||
if (target == m_Target && stamp <= m_Stamp) return;
|
||||
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
|
||||
{
|
||||
@@ -144,13 +159,11 @@ modded class SCR_PlayerController
|
||||
bool ERR_ReadPlayerEye(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane, out float pipFov, out vector geometry, out float vignette)
|
||||
{
|
||||
if (!m_ERRHasPIP || this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
|
||||
vector pose[4], world[4];
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
// Use the exact aiming playhead even after a streamed optic temporarily
|
||||
// fails resolution and this eye buffer is not read for several frames.
|
||||
m_ERREyeBuffer.Align(m_ERRViewBuffer.Playback(), now);
|
||||
if (!m_ERREyeBuffer.Read(m_ERRObservedId, now, seconds, pose, fov, nearPlane)) return false;
|
||||
target.GetWorldTransform(world); ERR_ObserverViewRules.Decode(world, pose, transform);
|
||||
if (!m_ERREyeBuffer.Read(m_ERRObservedId, now, seconds, transform, fov, nearPlane)) return false;
|
||||
m_ERRViewBuffer.PIP(pipFov, geometry, vignette);
|
||||
return true;
|
||||
}
|
||||
@@ -160,13 +173,10 @@ modded class SCR_PlayerController
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now - m_ERRViewRequestAt >= 500)
|
||||
{ m_ERRViewRequestAt = now; Rpc(ERR_RequestPlayerView); }
|
||||
vector pose[4], world[4];
|
||||
if (!m_ERRViewBuffer.Read(m_ERRObservedId, now, seconds, pose, fov, nearPlane)) return false;
|
||||
if (!m_ERRViewBuffer.Read(m_ERRObservedId, now, seconds, transform, fov, nearPlane)) return false;
|
||||
RplId scopeWeapon; vector scopeData;
|
||||
m_ERRViewBuffer.Scope(scopeWeapon, scopeData);
|
||||
if (scopeWeapon != RplId.Invalid() && !ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData)) return false;
|
||||
target.GetWorldTransform(world);
|
||||
ERR_ObserverViewRules.Decode(world, pose, transform);
|
||||
return true;
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
@@ -229,12 +239,14 @@ modded class SCR_PlayerController
|
||||
packet[3] = Vector(fov, nearPlane, pip.GetPIPCamera().GetVerticalFOV());
|
||||
packet.Insert(eye[3]); packet.Insert(eye[2]); packet.Insert(eye[1]);
|
||||
packet.Insert(Vector(eyeFov, nearPlane, pip.ERR_ObserverPIPVignette(actor, camera))); packet.Insert(pip.ERR_ObserverPIPGeometry());
|
||||
packet.Insert(world[3]);
|
||||
if (!ERR_ObserverViewRules.Packet(packet))
|
||||
{
|
||||
// An unusual addon lens must not suppress an otherwise valid 2D view.
|
||||
packet.Resize(4); packet[3] = Vector(fov, nearPlane, 0);
|
||||
packet.Resize(4); packet[3] = Vector(fov, nearPlane, 0); packet.Insert(world[3]);
|
||||
}
|
||||
}
|
||||
if (packet.Count() == 4) packet.Insert(world[3]);
|
||||
if (!ERR_ObserverViewRules.Packet(packet)) return;
|
||||
Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, packet, scopeWeapon, scopeData);
|
||||
}
|
||||
@@ -242,12 +254,13 @@ modded class SCR_PlayerController
|
||||
protected void ERR_SubmitPlayerView(int sequence, array<vector> packet, RplId scopeWeapon, vector scopeData)
|
||||
{
|
||||
if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Packet(packet)) return;
|
||||
if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
|
||||
if (packet.Count() == 10 && scopeWeapon == RplId.Invalid()) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
IEntity actor = GetControlledEntity();
|
||||
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor)) return;
|
||||
ERR_PlayerLife life = mode.Players().CurrentActor(actor);
|
||||
if (!life || GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId) != this) return;
|
||||
if (!ERR_ObserverViewRules.NearBody(packet[packet.Count() - 1], actor.GetOrigin())) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (actor != m_ERRViewSourceBody) { m_ERRViewSourceBody = actor; m_ERRViewInputSequence = 0; }
|
||||
if (sequence <= m_ERRViewInputSequence || now - m_ERRViewLastPacket < 35) return;
|
||||
@@ -275,19 +288,20 @@ modded class SCR_PlayerController
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (this != GetGame().GetPlayerController() || target == RplId.Invalid() || target != m_ERRObservedId || !mode || !ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()))) return;
|
||||
if (!ERR_ObserverViewRules.Packet(packet) || !ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
|
||||
if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
|
||||
if (packet.Count() == 10 && scopeWeapon == RplId.Invalid()) return;
|
||||
// Reject before updating either stream or PiP metadata.
|
||||
if (!m_ERRViewBuffer.Accepts(target, stamp)) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
bool pip = packet.Count() == 9;
|
||||
bool pip = packet.Count() == 10;
|
||||
if (pip != m_ERRHasPIP) { m_ERRViewBuffer.Clear(); m_ERREyeBuffer.Clear(); }
|
||||
vector geometry; float vignette;
|
||||
if (pip) { geometry = packet[8]; vignette = packet[7][2]; }
|
||||
m_ERRViewBuffer.Push(target, stamp, now, packet[0], packet[1], packet[2], packet[3][0], packet[3][1], scopeWeapon, scopeData, packet[3][2], geometry, vignette);
|
||||
vector origin = packet[packet.Count() - 1];
|
||||
m_ERRViewBuffer.Push(target, stamp, now, ERR_ObserverViewRules.WorldPosition(origin, packet[0]), packet[1], packet[2], packet[3][0], packet[3][1], scopeWeapon, scopeData, packet[3][2], geometry, vignette);
|
||||
m_ERRHasPIP = pip;
|
||||
if (pip)
|
||||
{
|
||||
m_ERREyeBuffer.Push(target, stamp, now, packet[4], packet[5], packet[6], packet[7][0], packet[7][1], scopeWeapon, scopeData);
|
||||
m_ERREyeBuffer.Push(target, stamp, now, ERR_ObserverViewRules.WorldPosition(origin, packet[4]), packet[5], packet[6], packet[7][0], packet[7][1], scopeWeapon, scopeData);
|
||||
}
|
||||
else m_ERREyeBuffer.Clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Front-seat passenger assistance is an extra held input, not a parking-brake
|
||||
// toggle. The driver's persistent brake, gears and service brake stay native.
|
||||
class ERR_PassengerHandbrake
|
||||
{
|
||||
static bool Beside(vector delta)
|
||||
{
|
||||
float lateral = Math.AbsFloat(delta[0]);
|
||||
return lateral >= 0.35 && lateral <= 1.7 && Math.AbsFloat(delta[1]) <= 0.5 && Math.AbsFloat(delta[2]) <= 0.65;
|
||||
}
|
||||
static bool FrontSeat(IEntity vehicle, BaseCompartmentSlot slot)
|
||||
{
|
||||
if (!vehicle || !slot || slot.GetType() != ECompartmentType.CARGO) return false;
|
||||
CarControllerComponent car = CarControllerComponent.Cast(vehicle.FindComponent(CarControllerComponent));
|
||||
if (!car) return false;
|
||||
BaseCompartmentSlot pilot = car.GetPilotCompartmentSlot();
|
||||
if (!pilot || pilot == slot || !slot.GetOwner() || slot.GetOwner().GetRootParent() != vehicle.GetRootParent()) return false;
|
||||
PointInfo passengerPoint = slot.GetPassengerPointInfo(), pilotPoint = pilot.GetPassengerPointInfo();
|
||||
if (!passengerPoint || !pilotPoint) return false;
|
||||
vector passenger = vehicle.CoordToLocal(passengerPoint.GetWorldTransformAxis(3));
|
||||
vector driver = vehicle.CoordToLocal(pilotPoint.GetWorldTransformAxis(3));
|
||||
return Beside(passenger - driver);
|
||||
}
|
||||
static IEntity VehicleFor(IEntity actor)
|
||||
{
|
||||
if (!ERR_Actors.CanFight(actor)) return null;
|
||||
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(actor.FindComponent(CompartmentAccessComponent));
|
||||
if (!access) return null;
|
||||
BaseCompartmentSlot slot = access.GetCompartment();
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(actor);
|
||||
if (!slot || slot.GetOccupant() != actor || !FrontSeat(vehicle, slot)) return null;
|
||||
return vehicle;
|
||||
}
|
||||
static bool Requested(IEntity driver)
|
||||
{
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(driver);
|
||||
if (!vehicle) return false;
|
||||
CarControllerComponent car = CarControllerComponent.Cast(vehicle.FindComponent(CarControllerComponent));
|
||||
if (!car || !car.GetPilotCompartmentSlot() || car.GetPilotCompartmentSlot().GetOccupant() != driver) return false;
|
||||
// Native held-handbrake input releases the parking latch. A parked car
|
||||
// already satisfies the passenger's demand, so leave that latch untouched.
|
||||
if (car.GetPersistentHandBrake()) return false;
|
||||
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(vehicle.FindComponent(BaseCompartmentManagerComponent));
|
||||
if (!manager) return false;
|
||||
array<BaseCompartmentSlot> slots = {};
|
||||
manager.GetCompartments(slots);
|
||||
foreach (BaseCompartmentSlot slot : slots)
|
||||
{
|
||||
IEntity actor = slot.GetOccupant();
|
||||
if (!actor || actor == driver) continue;
|
||||
SCR_CharacterControllerComponent character = SCR_CharacterControllerComponent.Cast(actor.FindComponent(SCR_CharacterControllerComponent));
|
||||
if (character && character.ERR_PassengerBrakeHeld() && VehicleFor(actor) == vehicle) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_PlayerController
|
||||
{
|
||||
protected SCR_CharacterControllerComponent m_ERRBrakingPassenger;
|
||||
// No caller-supplied body, seat or vehicle: authority derives all three.
|
||||
void ERR_SendPassengerBrake(bool held)
|
||||
{
|
||||
if (this != GetGame().GetPlayerController()) return;
|
||||
if (Replication.IsServer()) ERR_RequestPassengerBrake(held);
|
||||
else Rpc(ERR_RequestPassengerBrake, held);
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
|
||||
protected void ERR_RequestPassengerBrake(bool held)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
IEntity actor = GetControlledEntity();
|
||||
SCR_CharacterControllerComponent character;
|
||||
if (actor) character = SCR_CharacterControllerComponent.Cast(actor.FindComponent(SCR_CharacterControllerComponent));
|
||||
if (m_ERRBrakingPassenger && (m_ERRBrakingPassenger != character || !held)) m_ERRBrakingPassenger.ERR_SetPassengerBrake(false);
|
||||
m_ERRBrakingPassenger = null;
|
||||
if (!character) return;
|
||||
bool allowed = held && mode && mode.Active() && mode.Players() && mode.Players().CurrentActor(actor) && ERR_PassengerHandbrake.VehicleFor(actor);
|
||||
character.ERR_SetPassengerBrake(allowed);
|
||||
if (allowed) m_ERRBrakingPassenger = character;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_CharacterControllerComponent
|
||||
{
|
||||
[RplProp()] protected bool m_ERRPassengerBrake;
|
||||
protected int m_ERRPassengerBrakeHeartbeat;
|
||||
protected bool m_ERRPassengerBrakeSent;
|
||||
protected int m_ERRPassengerBrakeNextSend;
|
||||
protected IEntity m_ERRPassengerBrakeHintVehicle;
|
||||
|
||||
bool ERR_PassengerBrakeHeld() { return m_ERRPassengerBrake; }
|
||||
void ERR_SetPassengerBrake(bool held)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
m_ERRPassengerBrakeHeartbeat = System.GetTickCount();
|
||||
if (held == m_ERRPassengerBrake) return;
|
||||
m_ERRPassengerBrake = held;
|
||||
Replication.BumpMe();
|
||||
GetGame().GetCallqueue().Remove(ERR_ExpirePassengerBrake);
|
||||
if (held) GetGame().GetCallqueue().CallLater(ERR_ExpirePassengerBrake, 250, true);
|
||||
}
|
||||
protected void ERR_ExpirePassengerBrake()
|
||||
{
|
||||
if (!Replication.IsServer() || !m_ERRPassengerBrake) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
int age = System.GetTickCount() - m_ERRPassengerBrakeHeartbeat;
|
||||
if (!mode || !mode.Active() || age < 0 || age > 1000 || !ERR_PassengerHandbrake.VehicleFor(GetOwner())) ERR_SetPassengerBrake(false);
|
||||
}
|
||||
void ~SCR_CharacterControllerComponent()
|
||||
{
|
||||
if (GetGame() && GetGame().GetCallqueue()) GetGame().GetCallqueue().Remove(ERR_ExpirePassengerBrake);
|
||||
}
|
||||
override void OnPrepareControls(IEntity owner, ActionManager am, float dt, bool player)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
// Feed the actor's native action before it prepares driving controls. The
|
||||
// device/AI refreshes this value each tick; only add a held demand, never
|
||||
// write a release over the driver's own handbrake or parking-brake input.
|
||||
if (am && mode && mode.Active() && !am.GetActionTriggered("CarHandBrakePersistent") && ERR_PassengerHandbrake.Requested(owner)) am.SetActionValue("CarHandBrake", 1);
|
||||
super.OnPrepareControls(owner, am, dt, player);
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
if (!controller || controller.GetControlledEntity() != owner) return;
|
||||
IEntity vehicle;
|
||||
if (mode && mode.Active()) vehicle = ERR_PassengerHandbrake.VehicleFor(owner);
|
||||
bool held;
|
||||
InputManager input = GetGame().GetInputManager();
|
||||
if (vehicle && input && GetGame().GetMenuManager() && !GetGame().GetMenuManager().GetTopMenu() && !ERR_ObserverChatInput.Capture())
|
||||
{
|
||||
input.ActivateContext("ERR_PassengerBrakeContext", 1);
|
||||
held = input.GetActionValue("ERR_PassengerBrake") > 0.5;
|
||||
if (m_ERRPassengerBrakeHintVehicle != vehicle)
|
||||
{
|
||||
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
|
||||
if (hints) hints.ShowCustom("Hold Space to apply the handbrake. Release Space to let go.", "Passenger handbrake", 5);
|
||||
m_ERRPassengerBrakeHintVehicle = vehicle;
|
||||
}
|
||||
}
|
||||
if (!vehicle) m_ERRPassengerBrakeHintVehicle = null;
|
||||
int now = System.GetTickCount();
|
||||
if (held != m_ERRPassengerBrakeSent || (held && now >= m_ERRPassengerBrakeNextSend))
|
||||
{
|
||||
controller.ERR_SendPassengerBrake(held);
|
||||
m_ERRPassengerBrakeSent = held;
|
||||
m_ERRPassengerBrakeNextSend = now + 250;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,9 @@ modded class SCR_PlayerController
|
||||
}
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (!mode) return;
|
||||
if (mode.Phase() != ERR_Phase.PREPARING && (ERR_ObserverState() != ERR_ObserverReason.NONE || !ERR_Actors.Alive(GetControlledEntity()))) return;
|
||||
// Local unconsciousness can arrive before the replicated observer reason.
|
||||
// Do not let objective hints compete with casualty/spectator guidance then.
|
||||
if (mode.Phase() != ERR_Phase.PREPARING && (ERR_ObserverState() != ERR_ObserverReason.NONE || !ERR_Actors.CanFight(GetControlledEntity()))) return;
|
||||
string message = ERR_ObjectiveText.Description(mode, false);
|
||||
if (message.IsEmpty()) return;
|
||||
if (m_ERRGuidePhase == mode.Phase() && m_ERRGuideMessage == message) return;
|
||||
|
||||
@@ -43,6 +43,22 @@ modded class SCR_ReconnectComponent
|
||||
PrintFormat("Reintroduced [reconnect]: player %1 original body reserved; timeoutSeconds=%2", playerId, seconds);
|
||||
return true;
|
||||
}
|
||||
// Only an actual disconnected account may receive a replacement. Retire an
|
||||
// orphaned living body through native cleanup before admitting a fresh one.
|
||||
protected void PrepareReplacement(int playerId, ERR_GameMode mode, ERR_PlayerLife life)
|
||||
{
|
||||
if (!life.ReconnectPending || !ERR_PlayerLifeRules.CanEnter(false, mode.Phase())) return;
|
||||
if (GetGame().GetPlayerManager().GetPlayerControlledEntity(playerId)) return;
|
||||
if (life.Actor && GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(life.Actor) > 0) return;
|
||||
if (ERR_Actors.Alive(life.Actor))
|
||||
{
|
||||
SCR_ReconnectData orphan = new SCR_ReconnectData();
|
||||
orphan.m_ReservedEntity = life.Actor;
|
||||
HandleDataExpiery(orphan);
|
||||
}
|
||||
if (mode.Players().AllowReconnectReplacement(playerId, mode.Phase()))
|
||||
PrintFormat("Reintroduced [reconnect]: player %1 has no recoverable reservation; fresh admission allowed", playerId);
|
||||
}
|
||||
override bool HandlePlayerReconnect(int playerId)
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
@@ -54,11 +70,21 @@ modded class SCR_ReconnectComponent
|
||||
UUID identity = SCR_PlayerIdentityUtils.GetPlayerIdentityId(playerId);
|
||||
if (!life || !life.Connected || !life.UsedLife || identity.IsNull() || life.AccountId != identity) return false;
|
||||
SCR_ReconnectData data;
|
||||
if (!m_mReconnectData.Find(identity, data)) return false;
|
||||
if (!m_mReconnectData.Find(identity, data))
|
||||
{
|
||||
PrepareReplacement(playerId, mode, life);
|
||||
return false;
|
||||
}
|
||||
IEntity actor = data.m_ReservedEntity;
|
||||
int owner;
|
||||
if (actor) owner = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(actor);
|
||||
if (!actor || actor != life.Actor || (owner > 0 && owner != playerId))
|
||||
if (!actor)
|
||||
{
|
||||
m_mReconnectData.Remove(identity); UpdateExpieryCheck();
|
||||
PrepareReplacement(playerId, mode, life);
|
||||
return false;
|
||||
}
|
||||
if (actor != life.Actor || (owner > 0 && owner != playerId))
|
||||
{
|
||||
m_mReconnectData.Remove(identity); UpdateExpieryCheck();
|
||||
PrintFormat("Reintroduced [reconnect]: player %1 rejected mismatched body reservation", playerId);
|
||||
@@ -69,7 +95,7 @@ modded class SCR_ReconnectComponent
|
||||
if (IsDataExpired(data, GetGame().GetWorld().GetTimestamp()) || !IsDataRelevant(data))
|
||||
{
|
||||
m_mReconnectData.Remove(identity); HandleDataExpiery(data); UpdateExpieryCheck();
|
||||
PrintFormat("Reintroduced [reconnect]: player %1 reservation expired or body lost; no replacement life", playerId);
|
||||
PrepareReplacement(playerId, mode, life);
|
||||
return false;
|
||||
}
|
||||
bool restored = super.HandlePlayerReconnect(playerId);
|
||||
|
||||
@@ -13,6 +13,7 @@ class ERR_PlayerLife
|
||||
int ConnectionId;
|
||||
bool Connected;
|
||||
bool UsedLife;
|
||||
bool ReconnectPending;
|
||||
IEntity Actor;
|
||||
}
|
||||
|
||||
@@ -66,6 +67,18 @@ class ERR_PlayerRoster
|
||||
ERR_PlayerLife life = Get(playerId);
|
||||
return life && life.Connected && ERR_PlayerLifeRules.CanEnter(life.UsedLife, phase);
|
||||
}
|
||||
bool AllowReconnectReplacement(int playerId, int phase)
|
||||
{
|
||||
if (!Replication.IsServer()) return false;
|
||||
ERR_PlayerLife life = Get(playerId);
|
||||
if (!life || !life.Connected || !life.UsedLife || !life.ReconnectPending || !ERR_PlayerLifeRules.CanEnter(false, phase)) return false;
|
||||
if (ERR_Actors.Alive(life.Actor)) return false;
|
||||
life.Actor = null;
|
||||
life.UsedLife = false;
|
||||
life.ReconnectPending = false;
|
||||
m_OnChanged.Invoke(playerId);
|
||||
return true;
|
||||
}
|
||||
void Spawned(int playerId, IEntity actor)
|
||||
{
|
||||
if (!Replication.IsServer() || !actor) return;
|
||||
@@ -73,13 +86,18 @@ class ERR_PlayerRoster
|
||||
if (!life) return;
|
||||
life.Actor = actor;
|
||||
life.UsedLife = true;
|
||||
life.ReconnectPending = false;
|
||||
m_OnChanged.Invoke(playerId);
|
||||
}
|
||||
void Disconnect(int playerId)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
ERR_PlayerLife life = Get(playerId);
|
||||
if (life) life.Connected = false;
|
||||
if (life)
|
||||
{
|
||||
life.Connected = false;
|
||||
life.ReconnectPending = life.UsedLife;
|
||||
}
|
||||
m_Connections.Remove(playerId);
|
||||
m_OnChanged.Invoke(playerId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
modded enum ChimeraMenuPreset
|
||||
{
|
||||
ERR_PreparationFailure
|
||||
}
|
||||
|
||||
// The native loading placeholder is not a menu and has no focus/input lifetime.
|
||||
// Use a real local menu on failure; never grant admission or change server state.
|
||||
class ERR_PreparationFailureMenu : ChimeraMenuBase
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected SCR_ButtonTextComponent m_ReturnButton, m_PauseButton;
|
||||
protected TextWidget m_Description;
|
||||
protected string m_LastError;
|
||||
protected bool m_Bound, m_Leaving;
|
||||
|
||||
static bool Failed(ERR_GameMode mode)
|
||||
{
|
||||
return mode && mode == ERR_GameMode.Get() && mode.Phase() == ERR_Phase.PREPARING && !mode.PreparationError().IsEmpty();
|
||||
}
|
||||
static bool Show(ERR_GameMode mode)
|
||||
{
|
||||
if (System.IsConsoleApp() || !Failed(mode) || !GetGame() || !GetGame().GetMenuManager()) return false;
|
||||
MenuManager manager = GetGame().GetMenuManager();
|
||||
// Leave existing menus and exit confirmation dialogs in control of focus.
|
||||
if (manager.IsAnyMenuOpen() || manager.IsAnyDialogOpen()) return true;
|
||||
if (manager.OpenMenu(ChimeraMenuPreset.ERR_PreparationFailure)) return true;
|
||||
// A missing custom layout must not trap the player on the loading overlay.
|
||||
GetGame().OpenPauseMenu(false, true);
|
||||
return manager.FindMenuByPreset(ChimeraMenuPreset.PauseMenu) != null;
|
||||
}
|
||||
override void OnMenuOpen()
|
||||
{
|
||||
super.OnMenuOpen();
|
||||
m_Mode = ERR_GameMode.Get();
|
||||
if (!Failed(m_Mode)) { Close(); return; }
|
||||
m_Description = TextWidget.Cast(GetRootWidget().FindAnyWidget("FailureDescription"));
|
||||
m_ReturnButton = SCR_ButtonTextComponent.GetButtonText("ReturnToMainMenu", GetRootWidget());
|
||||
m_PauseButton = SCR_ButtonTextComponent.GetButtonText("OpenPauseMenu", GetRootWidget());
|
||||
if (m_ReturnButton) m_ReturnButton.m_OnClicked.Insert(OnReturn);
|
||||
if (m_PauseButton) m_PauseButton.m_OnClicked.Insert(OnPause);
|
||||
GetGame().GetInputManager().AddActionListener(UIConstants.MENU_ACTION_BACK, EActionTrigger.PRESSED, OnBack);
|
||||
m_Bound = true;
|
||||
Refresh();
|
||||
}
|
||||
protected void Refresh()
|
||||
{
|
||||
string detail = ERR_ObjectiveText.Description(m_Mode, false);
|
||||
if (m_Description && detail != m_LastError) { m_Description.SetText(detail); m_LastError = detail; }
|
||||
}
|
||||
override void OnMenuUpdate(float tDelta)
|
||||
{
|
||||
super.OnMenuUpdate(tDelta);
|
||||
if (!Failed(m_Mode)) { Close(); return; }
|
||||
if (!m_Leaving) Refresh();
|
||||
}
|
||||
protected void OpenPause()
|
||||
{
|
||||
if (!IsFocused() || m_Leaving || !Failed(m_Mode)) return;
|
||||
GetGame().OpenPauseMenu(false, true);
|
||||
}
|
||||
protected void OnBack() { OpenPause(); }
|
||||
protected void OnPause(SCR_ButtonBaseComponent button) { OpenPause(); }
|
||||
protected void OnReturn(SCR_ButtonBaseComponent button)
|
||||
{
|
||||
if (!IsFocused() || m_Leaving || !Failed(m_Mode)) return;
|
||||
m_Leaving = true;
|
||||
if (m_ReturnButton) m_ReturnButton.SetEnabled(false);
|
||||
if (m_PauseButton) m_PauseButton.SetEnabled(false);
|
||||
ChimeraWorld world = GetGame().GetWorld();
|
||||
if (world) world.PauseGameTime(false);
|
||||
// Same local exit transition as vanilla's pause-menu exit. No mission RPC.
|
||||
GameStateTransitions.RequestGameplayEndTransition();
|
||||
}
|
||||
override void OnMenuClose()
|
||||
{
|
||||
if (m_Bound && GetGame() && GetGame().GetInputManager())
|
||||
GetGame().GetInputManager().RemoveActionListener(UIConstants.MENU_ACTION_BACK, EActionTrigger.PRESSED, OnBack);
|
||||
m_Bound = false;
|
||||
if (m_ReturnButton) m_ReturnButton.m_OnClicked.Remove(OnReturn);
|
||||
if (m_PauseButton) m_PauseButton.m_OnClicked.Remove(OnPause);
|
||||
m_ReturnButton = null; m_PauseButton = null; m_Description = null; m_Mode = null;
|
||||
super.OnMenuClose();
|
||||
}
|
||||
#ifdef WORKBENCH
|
||||
static bool Visible(ERR_GameMode mode)
|
||||
{
|
||||
if (!Failed(mode)) return false;
|
||||
ERR_PreparationFailureMenu menu = ERR_PreparationFailureMenu.Cast(GetGame().GetMenuManager().FindMenuByPreset(ChimeraMenuPreset.ERR_PreparationFailure));
|
||||
return menu && menu.m_Mode == mode && menu.GetRootWidget().IsVisible() && menu.m_Description && menu.m_ReturnButton && menu.m_PauseButton && menu.m_Description.GetText() == ERR_ObjectiveText.Description(mode, false);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -109,6 +109,10 @@ modded class SCR_RespawnSystemComponent
|
||||
if (m_ERRPreparationSpinner) m_ERRPreparationSpinner.SetVisible(m_ERRPreparationSpinnerWasVisible);
|
||||
m_ERRPreparationSpinnerHidden = false;
|
||||
}
|
||||
// The placeholder's high-Z black layer would obscure even the pause menu.
|
||||
// Hide it only after an interactive menu is available; native updates restore
|
||||
// it if preparation recovers. The failed round still admits no players.
|
||||
if (failed && ERR_PreparationFailureMenu.Show(mode)) m_wLoadingPlaceholder.SetVisible(false);
|
||||
}
|
||||
|
||||
override void DestroyLoadingPlaceholder()
|
||||
@@ -130,6 +134,7 @@ modded class SCR_RespawnSystemComponent
|
||||
// Inspect the actual production-created widgets, without creating or changing them.
|
||||
bool ERR_ProbePreparationVisible(ERR_GameMode mode, bool failed)
|
||||
{
|
||||
if (failed && ERR_PreparationFailureMenu.Visible(mode)) return true;
|
||||
if (!mode || mode != ERR_GameMode.Get() || m_pGameMode != mode || !m_wLoadingPlaceholder || !m_wLoadingPlaceholder.IsVisible()) return false;
|
||||
if (!m_ERRPreparationRoot || m_ERRPreparationParent != m_wLoadingPlaceholder || m_ERRPreparationRoot.GetParent() != m_wLoadingPlaceholder || !m_ERRPreparationRoot.IsVisible()) return false;
|
||||
if (!ERR_PreparationWidgetVisible(m_ERRPreparationTitle) || !ERR_PreparationWidgetVisible(m_ERRPreparationDescription) || !ERR_PreparationWidgetVisible(m_ERRPreparationRoot)) return false;
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Immutable snapshots only: never tick native effects or alter a patient's zones.
|
||||
// Seconds: >=0 = predicted wake, -1 = beyond horizon, -2 = unsupported/uncertain.
|
||||
class ERR_RecoveryFlow
|
||||
{
|
||||
bool Passive;
|
||||
float BloodRate;
|
||||
float ResilienceRate;
|
||||
float Delay;
|
||||
float End = 301;
|
||||
}
|
||||
class ERR_RecoveryForecast
|
||||
{
|
||||
static bool KeepsRound(float forecast, bool selfRescue, bool existingRecovery)
|
||||
{
|
||||
return selfRescue || forecast >= 0 || (forecast == -2 && existingRecovery);
|
||||
}
|
||||
static float Horizon() { return 300; }
|
||||
static bool Awake(float blood, float resilience, float bloodThreshold, float resilienceThreshold)
|
||||
{
|
||||
return blood > bloodThreshold && resilience > resilienceThreshold;
|
||||
}
|
||||
static float Overlap(float start, float end, float delay, float expires)
|
||||
{
|
||||
return Math.Max(0, Math.Min(end, expires) - Math.Max(start, delay));
|
||||
}
|
||||
static float Predict(float blood, float resilience, float bloodThreshold, float resilienceThreshold, array<ref ERR_RecoveryFlow> flows)
|
||||
{
|
||||
if (Awake(blood, resilience, bloodThreshold, resilienceThreshold)) return 0;
|
||||
for (int second = 0; second < 300; second++)
|
||||
{
|
||||
// All flow starts/ends partition this second, including finite medicines.
|
||||
float time = second;
|
||||
while (time < second + 1)
|
||||
{
|
||||
float next = second + 1;
|
||||
foreach (ERR_RecoveryFlow flow : flows)
|
||||
{
|
||||
if (flow.Delay > time && flow.Delay < next) next = flow.Delay;
|
||||
if (flow.End > time && flow.End < next) next = flow.End;
|
||||
}
|
||||
float db, dr;
|
||||
foreach (ERR_RecoveryFlow rate : flows)
|
||||
{
|
||||
float duration = Overlap(time, next, rate.Delay, rate.End);
|
||||
db += rate.BloodRate * duration; dr += rate.ResilienceRate * duration;
|
||||
}
|
||||
blood = Math.Clamp(blood + db, 0, 1);
|
||||
resilience = Math.Clamp(resilience + dr, 0, 1);
|
||||
if (Awake(blood, resilience, bloodThreshold, resilienceThreshold)) return next;
|
||||
time = next;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
static float Estimate(SCR_CharacterDamageManagerComponent damage)
|
||||
{
|
||||
if (!damage || !damage.ERR_IsRevivablePlayer()) return -2;
|
||||
return damage.ERR_ForecastRecovery();
|
||||
}
|
||||
}
|
||||
modded class SCR_PassiveHitZoneRegenDamageEffect
|
||||
{
|
||||
float ERR_RemainingRegenDelay() { return Math.Max(0, m_fLocalRegenDelayTime - m_fDelayTimer); }
|
||||
}
|
||||
modded class SCR_CharacterDamageManagerComponent
|
||||
{
|
||||
float ERR_ForecastRecovery()
|
||||
{
|
||||
SCR_CharacterBloodHitZone blood = GetBloodHitZone();
|
||||
SCR_CharacterResilienceHitZone resilience = GetResilienceHitZone();
|
||||
if (!blood || !resilience || blood.GetMaxHealth() <= 0 || resilience.GetMaxHealth() <= 0) return -2;
|
||||
if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return -2;
|
||||
array<ref ERR_RecoveryFlow> flows = {};
|
||||
array<ref SCR_PersistentDamageEffect> effects = GetAllPersistentEffectsOfType(SCR_PersistentDamageEffect, true);
|
||||
bool bloodChanges, passiveResilience, unknown, pausedNatural;
|
||||
foreach (SCR_PersistentDamageEffect effect : effects)
|
||||
{
|
||||
DotDamageEffect dot = DotDamageEffect.Cast(effect);
|
||||
if (!dot) { unknown = true; continue; }
|
||||
HitZone zone = dot.GetAffectedHitZone();
|
||||
typename kind = effect.Type();
|
||||
bool bleeding = kind == SCR_BleedingDamageEffect;
|
||||
bool passive = kind == SCR_PassiveHitZoneRegenDamageEffect;
|
||||
bool saline = kind == SCR_SalineDamageEffect;
|
||||
bool epi = kind == ERR_EpinephrineEffect;
|
||||
// Physical wound regen and native morphine do not restore consciousness.
|
||||
if (kind == SCR_PhysicalHitZonesRegenDamageEffect || kind == SCR_MorphineDamageEffect) continue;
|
||||
if (passive && !effect.IsActive()) { pausedNatural = true; continue; }
|
||||
if (!effect.IsActive()) { unknown = true; continue; }
|
||||
if (!bleeding && !passive && !saline && !epi) { unknown = true; continue; }
|
||||
if (!bleeding && zone != blood && zone != resilience) continue;
|
||||
ERR_RecoveryFlow flow = new ERR_RecoveryFlow();
|
||||
flow.Passive = passive;
|
||||
float maximum = dot.GetMaxDuration();
|
||||
if (maximum > 0) flow.End = Math.Max(0, maximum - dot.GetCurrentDuration());
|
||||
HitZone target = zone;
|
||||
if (bleeding) target = blood;
|
||||
float rate = -dot.GetDPS() * target.GetBaseDamageMultiplier() * target.GetDamageMultiplier(dot.GetDamageType());
|
||||
if (passive)
|
||||
{
|
||||
flow.Delay = SCR_PassiveHitZoneRegenDamageEffect.Cast(effect).ERR_RemainingRegenDelay();
|
||||
// A terminated blood regen can still be visible until native cleanup.
|
||||
if (IsBleeding()) rate = 0;
|
||||
if (zone == resilience) passiveResilience = true;
|
||||
}
|
||||
if (bleeding)
|
||||
{
|
||||
SCR_CharacterHitZone wound = SCR_CharacterHitZone.Cast(zone);
|
||||
if (!wound) { unknown = true; continue; }
|
||||
if (GetGroupTourniquetted(wound.GetHitZoneGroup())) rate *= GetTourniquetStrengthMultiplier();
|
||||
flow.BloodRate = rate / blood.GetMaxHealth();
|
||||
// Expiring bleeding can create new delayed native regen: do not declare
|
||||
// hopelessness based on a snapshot that cannot yet contain that effect.
|
||||
if (flow.End <= 300) unknown = true;
|
||||
}
|
||||
else if (zone == blood) flow.BloodRate = rate / blood.GetMaxHealth();
|
||||
else flow.ResilienceRate = rate / resilience.GetMaxHealth();
|
||||
if (flow.BloodRate != 0) bloodChanges = true;
|
||||
flows.Insert(flow);
|
||||
}
|
||||
// Native treatment suppresses passive resilience regeneration while the
|
||||
// medicine acts. Do not add both DPS values as simultaneous recovery.
|
||||
bool treatmentOverlap;
|
||||
foreach (ERR_RecoveryFlow regen : flows)
|
||||
{
|
||||
if (!regen.Passive || regen.ResilienceRate <= 0) continue;
|
||||
foreach (ERR_RecoveryFlow medicine : flows)
|
||||
{
|
||||
if (medicine.Passive || medicine.ResilienceRate <= 0) continue;
|
||||
regen.Delay = Math.Max(regen.Delay, medicine.End);
|
||||
treatmentOverlap = true;
|
||||
}
|
||||
}
|
||||
float bt = blood.GetDamageStateThreshold(ECharacterBloodState.UNCONSCIOUS);
|
||||
float rt = resilience.GetDamageStateThreshold(ECharacterResilienceState.WEAKENED);
|
||||
float predicted = ERR_RecoveryForecast.Predict(blood.GetHealthScaled(), resilience.GetHealthScaled(), bt, rt, flows);
|
||||
if (unknown || (pausedNatural && predicted < 0)) return -2;
|
||||
// If the estimate needs post-treatment passive recovery, the native delay
|
||||
// reset may move that start later. Retain safeguards instead of inventing it.
|
||||
if (treatmentOverlap)
|
||||
foreach (ERR_RecoveryFlow treatment : flows)
|
||||
if (!treatment.Passive && treatment.ResilienceRate > 0 && (predicted < 0 || predicted > treatment.End)) return -2;
|
||||
// Native resilience rates depend on changing blood-state bands. A snapshot's
|
||||
// DPS cannot establish that future rate. Use an optimistic blood-only bound
|
||||
// to prove impossibility; otherwise defer rather than kill a viable casualty.
|
||||
if (bloodChanges && passiveResilience)
|
||||
{
|
||||
float optimistic = ERR_RecoveryForecast.Predict(blood.GetHealthScaled(), 1, bt, rt, flows);
|
||||
if (optimistic < 0) return -1;
|
||||
return -2;
|
||||
}
|
||||
return predicted;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ class ERR_RecoveryService
|
||||
protected float m_NextMedicalLog;
|
||||
protected string m_MedicalDetails;
|
||||
protected bool m_DefeatLogged;
|
||||
protected float m_NextForecast;
|
||||
protected ref ScriptInvoker m_OnChanged = new ScriptInvoker();
|
||||
void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; }
|
||||
ScriptInvoker OnChanged() { return m_OnChanged; }
|
||||
@@ -55,7 +56,7 @@ class ERR_RecoveryService
|
||||
if (due && !m_DefeatLogged)
|
||||
{
|
||||
m_DefeatLogged = true;
|
||||
ERR_HQ.Event("MEDICAL DEFEAT", "30 seconds without a viable rescuer or recovery; " + m_MedicalDetails);
|
||||
ERR_HQ.Event("MEDICAL DEFEAT", "30 seconds without a viable rescuer, self-injection or recovery within five minutes; " + m_MedicalDetails);
|
||||
}
|
||||
return due;
|
||||
}
|
||||
@@ -70,9 +71,11 @@ class ERR_RecoveryService
|
||||
{
|
||||
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active()) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now >= m_NextForecast - 1000 && now < m_NextForecast) return;
|
||||
m_NextForecast = now + 1000;
|
||||
bool logMedical = now >= m_NextMedicalLog;
|
||||
string medicalDetails;
|
||||
int conscious, incapacitated, reserved, reservedRescuers, recovering;
|
||||
int conscious, incapacitated, reserved, reservedRescuers, recovering, selfRescuers, uncertain;
|
||||
map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>();
|
||||
array<ref ERR_PlayerLife> lives = {};
|
||||
m_Mode.Players().Participants(lives);
|
||||
@@ -99,7 +102,12 @@ class ERR_RecoveryService
|
||||
bool improving = previous && (sample.Blood > previous.Blood + 0.001 || sample.Resilience > previous.Resilience + 0.001);
|
||||
bool treatment = medical.ERR_TreatmentInProgress();
|
||||
bool natural = medical.ERR_CanRecoverNaturally();
|
||||
if (improving || treatment || natural) recovering++;
|
||||
float forecast = ERR_RecoveryForecast.Estimate(medical);
|
||||
bool selfRescue = life.Connected && controlled == life.Actor && m_Mode.Settings().Get("selfEpinephrine") == 1 && ERR_SelfEpinephrineInventory.FindUsable(life.Actor);
|
||||
if (selfRescue) selfRescuers++;
|
||||
if (forecast == -2) uncertain++;
|
||||
if (ERR_RecoveryForecast.KeepsRound(forecast, selfRescue, improving || treatment || natural)) recovering++;
|
||||
medicalDetails += string.Format("forecastSeconds=%1 selfInjection=%2; ", forecast, selfRescue);
|
||||
medicalDetails += string.Format("player=%1 connected=%2 blood=%3 resilience=%4 bleeding=%5 improving=%6 treatment=%7 naturalRecovery=%8; ", life.ConnectionId, life.Connected, sample.Blood, sample.Resilience, medical.IsBleeding(), improving, treatment, natural);
|
||||
nextSamples.Insert(life.Actor, sample);
|
||||
}
|
||||
@@ -122,9 +130,11 @@ class ERR_RecoveryService
|
||||
{
|
||||
message = "No conscious prisoner is currently available to help.";
|
||||
if (evacuating) message += " The rescue crew is carrying all living prisoners to safety; evacuation continues.";
|
||||
else if (recovering > 0) message += " Treatment or natural recovery is possible; the round continues.";
|
||||
else if (selfRescuers > 0) message += " Self epinephrine is available. Press Space to use your carried injector; the round continues.";
|
||||
else if (recovering > 0 && uncertain > 0) message += " Recovery timing is uncertain; existing treatment and recovery safeguards remain active.";
|
||||
else if (recovering > 0) message += " A prisoner is expected to wake within five minutes under current medical conditions.";
|
||||
else if (reservedRescuers > 0) message += " A conscious prisoner's reconnection may provide help.";
|
||||
else message += " Everyone is unconscious with no treatment or recovery progressing. The mission fails after 30 uninterrupted seconds in this state.";
|
||||
else message += " Nobody is expected to wake within five minutes and no self-injection is available. The mission fails after 30 uninterrupted seconds in this state.";
|
||||
}
|
||||
Publish(message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Round-local statistics are always available, independently of telemetry.
|
||||
// Only authoritative participant bodies own records; transient connection IDs
|
||||
// and native winner-ID lists never decide kill credit or escape results.
|
||||
class ERR_RoundPlayerStats
|
||||
{
|
||||
UUID Account;
|
||||
string Name;
|
||||
IEntity Actor;
|
||||
bool Connected;
|
||||
bool Down;
|
||||
int Kills;
|
||||
int FriendlyKills;
|
||||
int Deaths;
|
||||
int Downed;
|
||||
int Recoveries;
|
||||
}
|
||||
class ERR_RoundStatistics
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref array<ref ERR_RoundPlayerStats> m_Players = {};
|
||||
protected bool m_Finished;
|
||||
protected bool m_Truncated;
|
||||
protected int m_StartedAt = -1;
|
||||
void ERR_RoundStatistics(ERR_GameMode mode) { m_Mode = mode; }
|
||||
ERR_RoundPlayerStats Find(UUID account)
|
||||
{
|
||||
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Account == account) return row;
|
||||
return null;
|
||||
}
|
||||
protected ERR_RoundPlayerStats Body(IEntity actor)
|
||||
{
|
||||
if (!actor) return null;
|
||||
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Actor == actor) return row;
|
||||
return null;
|
||||
}
|
||||
static string DisplayName(string name)
|
||||
{
|
||||
name.Replace("\n", " "); name.Replace("\r", " "); name.Replace("\t", " ");
|
||||
if (name.Length() > 48) name = name.Substring(0, 48);
|
||||
if (name.IsEmpty()) return "Player";
|
||||
return name;
|
||||
}
|
||||
void RosterChanged(int playerId) { Sync(); }
|
||||
void Sync()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Players()) return;
|
||||
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
|
||||
foreach (ERR_PlayerLife life : lives)
|
||||
{
|
||||
ERR_RoundPlayerStats row = Find(life.AccountId);
|
||||
if (!row)
|
||||
{
|
||||
if (m_Players.Count() >= 128) { m_Truncated = true; continue; }
|
||||
row = new ERR_RoundPlayerStats(); row.Account = life.AccountId; m_Players.Insert(row);
|
||||
}
|
||||
row.Connected = life.Connected;
|
||||
if (life.Connected) row.Name = DisplayName(GetGame().GetPlayerManager().GetPlayerName(life.ConnectionId));
|
||||
if (row.Actor != life.Actor) { row.Actor = life.Actor; row.Down = false; }
|
||||
}
|
||||
}
|
||||
// A consciousness edge is a down/recovery, not a death. Repeated callbacks
|
||||
// cannot count the same incapacitation twice; corpse deletion is not a kill.
|
||||
static void Consciousness(ERR_RoundPlayerStats row, bool conscious)
|
||||
{
|
||||
if (!row) return;
|
||||
if (!conscious && !row.Down) row.Downed++;
|
||||
if (conscious && row.Down) row.Recoveries++;
|
||||
row.Down = !conscious;
|
||||
}
|
||||
void ConsciousnessChanged(IEntity actor, bool conscious)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ERR_Actors.Alive(actor)) return;
|
||||
Sync(); Consciousness(Body(actor), conscious);
|
||||
}
|
||||
static void CountDeath(ERR_RoundPlayerStats victim, ERR_RoundPlayerStats killer, bool friendly, bool credit)
|
||||
{
|
||||
if (victim) { victim.Deaths++; victim.Down = false; }
|
||||
if (!credit || !killer || victim == killer) return;
|
||||
if (friendly) killer.FriendlyKills++;
|
||||
else killer.Kills++;
|
||||
}
|
||||
void Killed(IEntity victim, IEntity killerEntity, Instigator instigator)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ChimeraCharacter.Cast(victim)) return;
|
||||
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(victim.FindComponent(SCR_CharacterDamageManagerComponent));
|
||||
if (!damage || !damage.IsDestroyed()) return;
|
||||
Sync();
|
||||
ERR_RoundPlayerStats killed = Body(victim);
|
||||
// Native damage normally provides the original instigator body, including
|
||||
// delayed explosives. Never recover credit from a possibly recycled player ID.
|
||||
IEntity source = killerEntity;
|
||||
if (!ChimeraCharacter.Cast(source) && instigator) source = instigator.GetInstigatorEntity();
|
||||
ERR_RoundPlayerStats killer = Body(source);
|
||||
if (!killed && !killer) return;
|
||||
if (!damage.ERR_ClaimRoundDeath()) return;
|
||||
bool credit = instigator && instigator.GetInstigatorType() != InstigatorType.INSTIGATOR_GM;
|
||||
bool friendly = killed != null;
|
||||
Faction faction = SCR_FactionManager.SGetFaction(victim);
|
||||
if (faction && faction == m_Mode.PlayerFaction()) friendly = true;
|
||||
CountDeath(killed, killer, friendly, credit);
|
||||
}
|
||||
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished) return;
|
||||
if (m_StartedAt < 0 && current >= ERR_Phase.PRISON && current <= ERR_Phase.EXTRACTION) m_StartedAt = System.GetTickCount();
|
||||
if (ERR_MissionRules.Terminal(current)) Finish();
|
||||
}
|
||||
protected string Outcome(ERR_RoundPlayerStats row)
|
||||
{
|
||||
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) return "Escaped";
|
||||
if (row.Deaths > 0 && !ERR_Actors.Alive(row.Actor)) return "Dead";
|
||||
if (!row.Connected) return "Disconnected - did not escape";
|
||||
if (row.Down) return "Down - did not escape";
|
||||
return "Did not escape";
|
||||
}
|
||||
void Finish()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode) return;
|
||||
Sync(); m_Finished = true;
|
||||
int seconds;
|
||||
if (m_StartedAt >= 0) seconds = Math.Max(0, (System.GetTickCount() - m_StartedAt) / 1000);
|
||||
int kills, friendly, deaths, downed, recovered, escaped;
|
||||
string rows;
|
||||
foreach (ERR_RoundPlayerStats row : m_Players)
|
||||
{
|
||||
kills += row.Kills; friendly += row.FriendlyKills; deaths += row.Deaths; downed += row.Downed; recovered += row.Recoveries;
|
||||
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) escaped++;
|
||||
if (!rows.IsEmpty()) rows += "\n\n";
|
||||
rows += DisplayName(row.Name) + " - " + Outcome(row) + "\n";
|
||||
rows += string.Format("Kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5", row.Kills, row.FriendlyKills, row.Deaths, row.Downed, row.Recoveries);
|
||||
}
|
||||
string summary = string.Format("Duration: %1m %2s Participants: %3 Escaped: %4\n", seconds / 60, Math.Mod(seconds, 60), m_Players.Count(), escaped);
|
||||
summary += string.Format("Total kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5\n\n", kills, friendly, deaths, downed, recovered);
|
||||
summary += "Downed counts unconscious episodes; recoveries include self-recovery. Deaths count fatal losses.\n\n";
|
||||
if (rows.IsEmpty()) rows = "No players participated in this round.";
|
||||
summary += rows;
|
||||
if (m_Truncated) summary += "\n\nOnly the first 128 participants are shown; totals cover those players.";
|
||||
m_Mode.ERR_PublishRoundStatistics(summary);
|
||||
}
|
||||
}
|
||||
modded class SCR_CharacterDamageManagerComponent
|
||||
{
|
||||
protected bool m_ERRRoundDeathCounted;
|
||||
bool ERR_ClaimRoundDeath()
|
||||
{
|
||||
if (!Replication.IsServer() || m_ERRRoundDeathCounted) return false;
|
||||
m_ERRRoundDeathCounted = true; return true;
|
||||
}
|
||||
}
|
||||
modded class SCR_CharacterControllerComponent
|
||||
{
|
||||
override void OnConsciousnessChanged(bool conscious)
|
||||
{
|
||||
super.OnConsciousnessChanged(conscious);
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (mode && mode.ERR_Statistics()) mode.ERR_Statistics().ConsciousnessChanged(GetOwner(), conscious);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected ref ERR_RoundStatistics m_ERRStatistics;
|
||||
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
|
||||
protected string m_ERRRoundSummary;
|
||||
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
|
||||
protected string m_ERRCommunitySummary = "Community map records unavailable.";
|
||||
protected ref ScriptInvoker m_ERRStatisticsChanged = new ScriptInvoker();
|
||||
ERR_RoundStatistics ERR_Statistics() { return m_ERRStatistics; }
|
||||
string ERR_RoundSummary()
|
||||
{
|
||||
if (m_ERRRoundSummary.IsEmpty()) return "";
|
||||
return m_ERRCommunitySummary + "\n\n" + m_ERRRoundSummary;
|
||||
}
|
||||
void ERR_PublishCommunityStatistics(string summary)
|
||||
{
|
||||
if (!Replication.IsServer() || summary.Length() > 2048 || m_ERRCommunitySummary == summary) return;
|
||||
m_ERRCommunitySummary = summary;
|
||||
Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();
|
||||
}
|
||||
ScriptInvoker ERR_OnStatisticsChanged() { return m_ERRStatisticsChanged; }
|
||||
protected void ERR_OnRoundStatisticsReplicated() { m_ERRStatisticsChanged.Invoke(); }
|
||||
void ERR_PublishRoundStatistics(string summary)
|
||||
{
|
||||
if (!Replication.IsServer() || !m_ERRRoundSummary.IsEmpty()) return;
|
||||
m_ERRRoundSummary = summary;
|
||||
Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();
|
||||
}
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !Players()) return;
|
||||
m_ERRStatistics = new ERR_RoundStatistics(this);
|
||||
OnPhaseChanged().Insert(m_ERRStatistics.PhaseChanged);
|
||||
Players().OnChanged().Insert(m_ERRStatistics.RosterChanged);
|
||||
}
|
||||
protected override void OnControllableDestroyed(IEntity entity, IEntity killerEntity, notnull Instigator instigator)
|
||||
{
|
||||
if (m_ERRStatistics) m_ERRStatistics.Killed(entity, killerEntity, instigator);
|
||||
super.OnControllableDestroyed(entity, killerEntity, instigator);
|
||||
}
|
||||
protected override void OnPlayerKilled(int playerId, IEntity playerEntity, IEntity killerEntity, notnull Instigator killer)
|
||||
{
|
||||
if (m_ERRStatistics) m_ERRStatistics.Killed(playerEntity, killerEntity, killer);
|
||||
super.OnPlayerKilled(playerId, playerEntity, killerEntity, killer);
|
||||
}
|
||||
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
|
||||
{
|
||||
// Freeze before native shutdown can detach bodies or remove roster ownership.
|
||||
if (m_ERRStatistics) m_ERRStatistics.Finish();
|
||||
super.OnGameModeEnd(endData);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class ERR_ScenarioComponent : ScriptComponent
|
||||
#ifdef WORKBENCH
|
||||
string historicalFixture;
|
||||
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "normal-escape-route" && historicalFixture != "crash-start" && historicalFixture != "mounted-ammo" && historicalFixture != "alarm-hearing" && historicalFixture != "optional-site-skip" && historicalFixture != "infantry-combat" && historicalFixture != "site-garrisons" && historicalFixture != "generated-sites" && historicalFixture != "roadblocks" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
|
||||
if (historicalFixture != "self-epinephrine" && historicalFixture != "starter-backpack" && historicalFixture != "objective-map" && historicalFixture != "speech-download" && historicalFixture != "bundled-speech") m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
|
||||
if (historicalFixture != "sarka-hunters" && historicalFixture != "self-epinephrine" && historicalFixture != "unconscious-camera" && historicalFixture != "starter-backpack" && historicalFixture != "objective-map" && historicalFixture != "speech-download" && historicalFixture != "bundled-speech") m_Definition = "{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf";
|
||||
#endif
|
||||
m_RuntimeDefinition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_Definition));
|
||||
if (!m_RuntimeDefinition || !m_RuntimeDefinition.Valid()) { m_Failed = true; return ERR_PrepareResult.FAILED; }
|
||||
@@ -72,7 +72,7 @@ class ERR_ScenarioComponent : ScriptComponent
|
||||
// Existing deterministic probes intentionally use authored coordinates.
|
||||
string fixture;
|
||||
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "normal-escape-route" && fixture != "crash-start" && fixture != "mounted-ammo" && fixture != "alarm-hearing" && fixture != "optional-site-skip" && fixture != "infantry-combat" && fixture != "site-garrisons" && fixture != "generated-sites" && fixture != "roadblocks" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
|
||||
if (fixture != "self-epinephrine" && fixture != "starter-backpack" && fixture != "objective-map" && fixture != "speech-download" && fixture != "bundled-speech") foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
|
||||
if (fixture != "sarka-hunters" && fixture != "self-epinephrine" && fixture != "unconscious-camera" && fixture != "starter-backpack" && fixture != "objective-map" && fixture != "speech-download" && fixture != "bundled-speech") foreach (ERR_SiteDefinition fixedSite : m_RuntimeDefinition.Sites) fixedSite.RandomClear = false;
|
||||
if (!ERR_ConfigurePatrolHQFixture(fixture)) { m_Failed = true; return ERR_PrepareResult.FAILED; }
|
||||
#endif
|
||||
if (m_Progressive && !m_RuntimeDefinition.ConfigurePopulation(m_Definition, mode.Settings().Get("fieldCampCount"), mode.Settings().Get("fieldHospitalCount")))
|
||||
|
||||
@@ -4,8 +4,8 @@ class ERR_SelectionStyle
|
||||
static void Action(SCR_ButtonTextComponent button, bool enable)
|
||||
{
|
||||
if (!button) return;
|
||||
Color normal = Color.FromInt(0xFF8F2929), hover = Color.FromInt(0xFFB43B3B);
|
||||
if (enable) { normal = Color.FromInt(0xFF246B3C); hover = Color.FromInt(0xFF31894F); }
|
||||
Color normal = Color.FromInt(ERR_MenuTheme.NEGATIVE), hover = Color.FromInt(ERR_MenuTheme.NEGATIVE_HOVER);
|
||||
if (enable) { normal = Color.FromInt(ERR_MenuTheme.POSITIVE); hover = Color.FromInt(ERR_MenuTheme.POSITIVE_HOVER); }
|
||||
button.SetBackgroundColors(normal, hover, normal, hover, hover);
|
||||
button.ColorizeBackground(false);
|
||||
}
|
||||
@@ -14,11 +14,11 @@ class ERR_SelectionStyle
|
||||
static void Choice(SCR_ButtonTextComponent button, bool selected, bool positive = true)
|
||||
{
|
||||
if (!button) return;
|
||||
Color normal = Color.FromInt(0xFF454B52), hover = Color.FromInt(0xFF606870);
|
||||
Color normal = Color.FromInt(ERR_MenuTheme.NEUTRAL), hover = Color.FromInt(ERR_MenuTheme.NEUTRAL_HOVER);
|
||||
if (selected)
|
||||
{
|
||||
normal = Color.FromInt(0xFF246B3C); hover = Color.FromInt(0xFF31894F);
|
||||
if (!positive) { normal = Color.FromInt(0xFF8F2929); hover = Color.FromInt(0xFFB43B3B); }
|
||||
normal = Color.FromInt(ERR_MenuTheme.POSITIVE); hover = Color.FromInt(ERR_MenuTheme.POSITIVE_HOVER);
|
||||
if (!positive) { normal = Color.FromInt(ERR_MenuTheme.NEGATIVE); hover = Color.FromInt(ERR_MenuTheme.NEGATIVE_HOVER); }
|
||||
}
|
||||
button.SetDisabledOpacity(1);
|
||||
button.SetBackgroundColors(normal, hover, normal, hover, hover);
|
||||
@@ -34,10 +34,10 @@ class ERR_SelectionStyle
|
||||
{
|
||||
SCR_ButtonTextComponent badge = SCR_ButtonTextComponent.GetButtonText(name, parent);
|
||||
if (!badge) return;
|
||||
string label = "Waiting..."; Color color = Color.FromInt(0xFF454B52);
|
||||
if (state == 0) { label = "Disabled"; color = Color.FromInt(0xFF8F2929); }
|
||||
if (state == 1) { label = "Enabled"; color = Color.FromInt(0xFF246B3C); }
|
||||
if (state == 2) { label = "Mixed"; color = Color.FromInt(0xFF805B17); }
|
||||
string label = "Waiting..."; Color color = Color.FromInt(ERR_MenuTheme.NEUTRAL);
|
||||
if (state == 0) { label = "Disabled"; color = Color.FromInt(ERR_MenuTheme.NEGATIVE); }
|
||||
if (state == 1) { label = "Enabled"; color = Color.FromInt(ERR_MenuTheme.POSITIVE); }
|
||||
if (state == 2) { label = "Mixed"; color = Color.FromInt(ERR_MenuTheme.MIXED); }
|
||||
badge.SetText(label); badge.SetDisabledOpacity(1); badge.SetEnabled(false, false);
|
||||
badge.SetBackgroundColors(color, color, color, color, color); badge.ColorizeBackground(false);
|
||||
}
|
||||
|
||||
@@ -51,10 +51,34 @@ modded class SCR_PlayerController
|
||||
if (mode && mode.Players()) life = mode.Players().Get(GetPlayerId());
|
||||
if (!mode || !ERR_SelfEpinephrineRules.Allowed(mode.Active(), mode.Settings().Get("selfEpinephrine") == 1,
|
||||
life && life.Actor == actor && mode.Players().CurrentActor(actor) == life, ERR_Actors.Alive(actor), controller && controller.IsUnconscious())) return "";
|
||||
IEntity injector = ERR_SelfEpinephrineInventory.FindUsable(actor);
|
||||
if (!injector) return "No usable epinephrine injector in your inventory, or treatment is already active.";
|
||||
SCR_ConsumableItemComponent item = SCR_ConsumableItemComponent.Cast(injector.FindComponent(SCR_ConsumableItemComponent));
|
||||
SCR_ConsumableMorphine effect = SCR_ConsumableMorphine.Cast(item.GetConsumableEffect());
|
||||
item.ApplyItemEffect(actor, actor, effect.GetAnimationParameters(injector, actor), injector, effect.GetDeleteOnUse());
|
||||
return "Epinephrine administered. Its normal medical effect is active.";
|
||||
}
|
||||
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
protected void ERR_SelfEpinephrineResponse(string message)
|
||||
{
|
||||
if (this != GetGame().GetPlayerController()) return;
|
||||
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
|
||||
if (hints) hints.ShowCustom(message, "Self epinephrine", 6);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared read-only eligibility: defeat prediction and the actual Space action
|
||||
// must agree, including native duplicate-dose rejection and item preference.
|
||||
class ERR_SelfEpinephrineInventory
|
||||
{
|
||||
static IEntity FindUsable(IEntity actor)
|
||||
{
|
||||
if (!ERR_Actors.Alive(actor) || ERR_Actors.CanFight(actor)) return null;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
array<IEntity> items = {};
|
||||
if (inventory) inventory.GetItems(items);
|
||||
if (items.Count() > 512) return "Unable to inspect your medical supplies.";
|
||||
if (items.Count() > 512) return null;
|
||||
// Prefer the standard injector; preserve each item's ordinary medical effect.
|
||||
for (int kind = 0; kind < 2; kind++)
|
||||
foreach (IEntity injector : items)
|
||||
@@ -68,18 +92,8 @@ modded class SCR_PlayerController
|
||||
if (kind == 1 && !ERR_ConsumableCombatEpinephrine.Cast(effect)) continue;
|
||||
SCR_EConsumableFailReason reason;
|
||||
if (!effect.CanApplyEffect(actor, actor, reason)) continue;
|
||||
// An unconscious player cannot perform the standing injection animation.
|
||||
// Use the native effect/consumption path on this exact carried item once.
|
||||
item.ApplyItemEffect(actor, actor, effect.GetAnimationParameters(injector, actor), injector, effect.GetDeleteOnUse());
|
||||
return "Epinephrine administered. Its normal medical effect is active.";
|
||||
return injector;
|
||||
}
|
||||
return "No usable epinephrine injector in your inventory, or treatment is already active.";
|
||||
}
|
||||
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
|
||||
protected void ERR_SelfEpinephrineResponse(string message)
|
||||
{
|
||||
if (this != GetGame().GetPlayerController()) return;
|
||||
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
|
||||
if (hints) hints.ShowCustom(message, "Self epinephrine", 6);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ modded class ERR_AdminMenu
|
||||
if (button.GetRootWidget() != row.FindAnyWidget("Enable")) continue;
|
||||
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsMod", GetRootWidget());
|
||||
if (combo) combo.SetCurrentItem(m_ServerAddonKeys.Find(addon), false, false, false);
|
||||
ShowPage(17); SubmitItems("items list " + addon + " 0"); return;
|
||||
ShowPage(ERR_AdminPage.ALLOWED_ITEMS); SubmitItems("items list " + addon + " 0"); return;
|
||||
}
|
||||
}
|
||||
protected void SubmitItems(string command)
|
||||
|
||||
@@ -9,13 +9,17 @@ class ERR_Settings
|
||||
|
||||
static array<string> Keys()
|
||||
{
|
||||
return {"aiDoctrine", "aiShadow", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
|
||||
return {"telemetryEnabled", "aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "droneReconCount", "droneCooldownSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "largeRoadblockCount", "mainRoadWidthMetres", "civilianTrafficCount", "garageVehicleCount", "garageChancePercent", "garageRadiusMetres", "garageSpawnIntervalSeconds", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "showAllSites", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
|
||||
}
|
||||
|
||||
static bool Valid(string key, int value)
|
||||
{
|
||||
if (key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
|
||||
if (key == "telemetryEnabled" || key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
|
||||
if (key == "radioIntelSiteCount") return value >= 0 && value <= 20;
|
||||
if (key == "sarkaAngerPerCar") return value >= 0 && value <= 30;
|
||||
if (key == "motorbikeCount") return value >= 0 && value <= 6;
|
||||
if (key == "droneReconCount") return value >= 0 && value <= 3;
|
||||
if (key == "droneCooldownSeconds") return value >= 60 && value <= 1800;
|
||||
if (key == "planeAltitudeMetres") return value >= 150 && value <= 500;
|
||||
if (key == "planeReplacementSeconds") return value >= 30 && value <= 1800;
|
||||
if (key == "planeSectorSeconds") return value >= 30 && value <= 600;
|
||||
@@ -29,8 +33,15 @@ class ERR_Settings
|
||||
if (key == "objectGuardCount") return value >= 0 && value <= 16;
|
||||
if (key == "hunterSpawnDistanceMetres") return value >= 800 && value <= 3000;
|
||||
if (key == "roadblockCount") return value >= 0 && value <= 30;
|
||||
if (key == "largeRoadblockCount") return value >= 0 && value <= 30;
|
||||
if (key == "mainRoadWidthMetres") return value >= 5 && value <= 12;
|
||||
if (key == "garageChancePercent") return value >= 0 && value <= 100;
|
||||
if (key == "garageRadiusMetres") return value >= 250 && value <= 1500;
|
||||
if (key == "garageSpawnIntervalSeconds") return value >= 5 && value <= 60;
|
||||
if (key == "garageVehicleCount") return value >= 0 && value <= 24;
|
||||
if (key == "civilianTrafficCount") return value >= 0 && value <= 6;
|
||||
if (key == "startMode") return value == 0 || value == 1;
|
||||
if (key == "showAllSites") return value == 0 || value == 1;
|
||||
if (key == "selfEpinephrine" || key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return value == 0 || value == 1;
|
||||
if (key == "emptyRestartHours") return value >= 0 && value <= 168;
|
||||
if (key == "heliTimerMinutes") return value >= 1 && value <= 1440;
|
||||
@@ -117,7 +128,11 @@ class ERR_Settings
|
||||
static int AddedDefault(string key)
|
||||
{
|
||||
if (key == "aiDoctrine" || key == "aiShadow") return 0;
|
||||
if (key == "radioIntelSiteCount") return 5;
|
||||
if (key == "sarkaAngerPerCar") return 3;
|
||||
if (key == "motorbikeCount") return 2;
|
||||
if (key == "droneReconCount") return 0;
|
||||
if (key == "droneCooldownSeconds") return 300;
|
||||
if (key == "planeAltitudeMetres") return 250;
|
||||
if (key == "planeReplacementSeconds") return 60;
|
||||
if (key == "planeSectorSeconds") return 120;
|
||||
@@ -131,9 +146,16 @@ class ERR_Settings
|
||||
if (key == "objectGuardCount") return 8;
|
||||
if (key == "hunterSpawnDistanceMetres") return 1000;
|
||||
if (key == "roadblockCount") return 4;
|
||||
if (key == "largeRoadblockCount") return 4;
|
||||
if (key == "mainRoadWidthMetres") return 6;
|
||||
if (key == "civilianTrafficCount") return 3;
|
||||
if (key == "garageChancePercent") return 70;
|
||||
if (key == "garageRadiusMetres") return 1000;
|
||||
if (key == "garageSpawnIntervalSeconds") return 5;
|
||||
if (key == "garageVehicleCount") return 16;
|
||||
if (key == "showAllSites") return 0;
|
||||
if (key == "startMode" || key == "selfEpinephrine") return 0;
|
||||
if (key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return 1;
|
||||
if (key == "telemetryEnabled" || key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return 1;
|
||||
if (key == "emptyRestartHours") return 5;
|
||||
if (key == "heliTimerMinutes") return 15;
|
||||
if (key == "convoyCount") return 3;
|
||||
@@ -150,9 +172,17 @@ class ERR_Settings
|
||||
void ERR_Settings(string path = PATH)
|
||||
{
|
||||
m_Path = path;
|
||||
m_Values.Set("telemetryEnabled", AddedDefault("telemetryEnabled"));
|
||||
m_Values.Set("aiDoctrine", AddedDefault("aiDoctrine"));
|
||||
m_Values.Set("aiShadow", AddedDefault("aiShadow"));
|
||||
m_Values.Set("sarkaAngerPerCar", AddedDefault("sarkaAngerPerCar"));
|
||||
m_Values.Set("garageChancePercent", AddedDefault("garageChancePercent"));
|
||||
m_Values.Set("garageRadiusMetres", AddedDefault("garageRadiusMetres"));
|
||||
m_Values.Set("garageSpawnIntervalSeconds", AddedDefault("garageSpawnIntervalSeconds"));
|
||||
m_Values.Set("garageVehicleCount", AddedDefault("garageVehicleCount"));
|
||||
m_Values.Set("motorbikeCount", AddedDefault("motorbikeCount"));
|
||||
m_Values.Set("droneReconCount", AddedDefault("droneReconCount"));
|
||||
m_Values.Set("droneCooldownSeconds", AddedDefault("droneCooldownSeconds"));
|
||||
m_Values.Set("planeAltitudeMetres", AddedDefault("planeAltitudeMetres"));
|
||||
m_Values.Set("planeReplacementSeconds", AddedDefault("planeReplacementSeconds"));
|
||||
m_Values.Set("planeSectorSeconds", AddedDefault("planeSectorSeconds"));
|
||||
@@ -166,6 +196,8 @@ class ERR_Settings
|
||||
m_Values.Set("objectGuardHQMode", AddedDefault("objectGuardHQMode"));
|
||||
m_Values.Set("hunterSpawnDistanceMetres", AddedDefault("hunterSpawnDistanceMetres"));
|
||||
m_Values.Set("roadblockCount", AddedDefault("roadblockCount"));
|
||||
m_Values.Set("largeRoadblockCount", AddedDefault("largeRoadblockCount"));
|
||||
m_Values.Set("mainRoadWidthMetres", AddedDefault("mainRoadWidthMetres"));
|
||||
m_Values.Set("civilianTrafficCount", AddedDefault("civilianTrafficCount"));
|
||||
m_Values.Set("startMode", AddedDefault("startMode"));
|
||||
m_Values.Set("keepWeaponOnUnconscious", AddedDefault("keepWeaponOnUnconscious"));
|
||||
@@ -177,6 +209,8 @@ class ERR_Settings
|
||||
m_Values.Set("responseRadiusMetres", 2000);
|
||||
m_Values.Set("aiIntelChancePercent", 60);
|
||||
m_Values.Set("intelRevealChancePercent", 15);
|
||||
m_Values.Set("radioIntelSiteCount", 5);
|
||||
m_Values.Set("showAllSites", AddedDefault("showAllSites"));
|
||||
m_Values.Set("radioDefenceSeconds", 180);
|
||||
m_Values.Set("startTimeMinutes", AddedDefault("startTimeMinutes"));
|
||||
m_Values.Set("timeMultiplier", AddedDefault("timeMultiplier"));
|
||||
@@ -211,6 +245,19 @@ class ERR_Settings
|
||||
values = new map<string, int>();
|
||||
foreach (string key : Keys())
|
||||
{
|
||||
// The former multiplier cannot express a lower frequency. Keep disabled
|
||||
// saves disabled; migrate enabled saves to the new three-points-per-car default.
|
||||
// An explicit new setting always takes precedence over the legacy key.
|
||||
if (key == "sarkaAngerPerCar" && !json.DoesKeyExist(key) && json.DoesKeyExist("sarkaCarsPerAnger"))
|
||||
{
|
||||
float oldCars;
|
||||
if (!json.ReadValue("sarkaCarsPerAnger", oldCars)) return false;
|
||||
int oldMultiplier = Math.Round(oldCars);
|
||||
if (oldCars != oldMultiplier || oldMultiplier < 0 || oldMultiplier > 5) return false;
|
||||
int points = AddedDefault(key);
|
||||
if (oldMultiplier == 0) points = 0;
|
||||
values.Set(key, points); continue;
|
||||
}
|
||||
// Migrate the former one-in-N setting to the nearest integer percent.
|
||||
if (key == "antiAirChancePercent" && !json.DoesKeyExist(key) && json.DoesKeyExist("stingerChanceOneIn"))
|
||||
{
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
[BaseContainerProps()]
|
||||
class ERR_SiteStock
|
||||
{
|
||||
// Runtime starting-loadout choice; authored objective stock remains in storage.
|
||||
bool OnGround;
|
||||
[Attribute("", UIWidgets.ResourceNamePicker, "Physical inventory item", "et")]
|
||||
ResourceName Prefab;
|
||||
[Attribute("1", UIWidgets.EditBox, "Finite initial count")]
|
||||
@@ -59,7 +61,7 @@ class ERR_SiteDefinition
|
||||
ResourceName Prefab;
|
||||
[Attribute("0 0 0", UIWidgets.Coords)]
|
||||
vector Position;
|
||||
[Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, radio, mortar, hospital, depot or camp)")]
|
||||
[Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, extraction, radio, mortar, hospital, depot or camp)")]
|
||||
bool RandomClear;
|
||||
[Attribute("0", UIWidgets.EditBox, "Composition heading in degrees")]
|
||||
float Heading;
|
||||
@@ -183,7 +185,7 @@ class ERR_ScenarioDefinition
|
||||
if (!site || site.Id.IsEmpty() || ids.Contains(site.Id) || site.Prefab.IsEmpty()) return false;
|
||||
if (site.RequireAirfield && site.Kind != ERR_LocationKind.EXTRACTION) return false;
|
||||
if (site.Kind == ERR_LocationKind.RADIO && !site.Guarded && site.Guards.IsEmpty()) return false;
|
||||
if (site.RandomClear && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR && site.Kind != ERR_LocationKind.HOSPITAL && site.Kind != ERR_LocationKind.OUTPOST && site.Kind != ERR_LocationKind.DEPOT) return false;
|
||||
if (site.RandomClear && site.Kind != ERR_LocationKind.EXTRACTION && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR && site.Kind != ERR_LocationKind.HOSPITAL && site.Kind != ERR_LocationKind.OUTPOST && site.Kind != ERR_LocationKind.DEPOT) return false;
|
||||
if (site.Label.IsEmpty() || site.Label.Length() > 64) return false;
|
||||
if (!ERR_SiteDiscoveryRules.ValidRadius(site.Kind, site.DiscoverRadius)) return false;
|
||||
bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST || site.Kind == ERR_LocationKind.HOSPITAL;
|
||||
|
||||
@@ -7,6 +7,7 @@ class ERR_StockPlacement
|
||||
bool Accepted;
|
||||
bool Optional;
|
||||
bool Skipped;
|
||||
bool OnGround;
|
||||
}
|
||||
|
||||
class ERR_SiteRuntime
|
||||
@@ -389,7 +390,7 @@ class ERR_SiteService
|
||||
if (only && spec != only) continue;
|
||||
if (startupOnly && !ERR_GeneratedSites.StartupSite(spec)) continue;
|
||||
vector position = ERR_Actors.Ground(spec.Position);
|
||||
if (position[1] < 1) return Fail("site below sea level: " + spec.Id);
|
||||
if (!ERR_Actors.DryGround(position)) return Fail("site ground is submerged or invalid: " + spec.Id);
|
||||
vector rootPosition = position;
|
||||
if (spec.PlacementResolved && spec.Kind == ERR_LocationKind.PRISON) rootPosition = spec.Position;
|
||||
rootPosition[1] = rootPosition[1] + spec.RootHeight;
|
||||
@@ -397,7 +398,7 @@ class ERR_SiteService
|
||||
{
|
||||
rootPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(spec.AircraftOffset, spec.Heading));
|
||||
if (spec.RequireAirfield && !ERR_AirfieldPlacement.Clear(rootPosition)) return Fail("extraction airfield pad is missing or obstructed; no off-airfield fallback");
|
||||
if (definition.TerrainGenerated && !spec.RequireAirfield && !ERR_AirfieldPlacement.ClearGround(rootPosition)) return Fail("generated extraction landing zone is obstructed");
|
||||
if ((definition.TerrainGenerated || spec.RandomClear) && !spec.RequireAirfield && !ERR_AirfieldPlacement.ClearGround(rootPosition)) return Fail("generated extraction landing zone is obstructed");
|
||||
}
|
||||
// Spawn the replicated marker at the final pickup coordinate from the start.
|
||||
IEntity root = ERR_Actors.Spawn(spec.Prefab, rootPosition, Vector(spec.Heading, 0, 0));
|
||||
@@ -473,6 +474,7 @@ class ERR_SiteService
|
||||
&& site.DepotMines.Count() < 2) site.DepotMines.Insert(item);
|
||||
}
|
||||
placement.Optional = customStart && stock.Prefab != ERR_Supplies.Map();
|
||||
placement.OnGround = customStart && stock.OnGround;
|
||||
placement.Item = item;
|
||||
placement.Inventory = inventory;
|
||||
m_Placements.Insert(placement);
|
||||
@@ -628,6 +630,75 @@ class ERR_SiteService
|
||||
}
|
||||
}
|
||||
|
||||
// Optional starting items stay usable when native inventory rejects their
|
||||
// dimensions/volume/type. Keep the exact entity; do not spawn copies or delete it.
|
||||
protected void LeaveStartingItem(ERR_StockPlacement placement)
|
||||
{
|
||||
InventoryItemComponent item = InventoryItemComponent.Cast(placement.Item.FindComponent(InventoryItemComponent));
|
||||
// An accepted asynchronous move or an administrator may already own the item.
|
||||
// Never pull it out of another storage while resolving starter overflow.
|
||||
if (!item || !item.GetParentSlot())
|
||||
{
|
||||
vector origin = placement.Inventory.GetOwner().GetOrigin();
|
||||
array<vector> offsets = {"1.2 0 0", "0 0 1.2", "-1.2 0 0", "0 0 -1.2", "0.9 0 0.9", "-0.9 0 0.9", "-0.9 0 -0.9", "0.9 0 -0.9"};
|
||||
foreach (vector offset : offsets)
|
||||
{
|
||||
vector point = ERR_Actors.Ground(origin + offset);
|
||||
if (!ERR_Actors.DryGround(point) || Math.AbsFloat(point[1] - origin[1]) > 0.5 || !BackpackClearance(point)) continue;
|
||||
// Do not place supplies across a cell wall or inside the crash wreckage.
|
||||
TraceParam access = new TraceParam(); access.Start = origin + "0 0.6 0"; access.End = point + "0 0.6 0";
|
||||
access.Flags = TraceFlags.ENTS | TraceFlags.WORLD; access.Exclude = placement.Inventory.GetOwner();
|
||||
if (GetGame().GetWorld().TraceMove(access, null) < 1) continue;
|
||||
placement.Item.SetOrigin(point + "0 0.2 0");
|
||||
Physics physics = placement.Item.GetPhysics();
|
||||
if (physics && physics.IsDynamic()) physics.SetActive(ActiveState.ACTIVE);
|
||||
break;
|
||||
}
|
||||
}
|
||||
string resource = "unknown item";
|
||||
if (placement.Item.GetPrefabData()) resource = placement.Item.GetPrefabData().GetPrefabName();
|
||||
if (!placement.OnGround) Print("Reintroduced [starting backpack]: inventory rejected item; retained as starting-area loot: " + resource, LogLevel.WARNING);
|
||||
placement.Skipped = true;
|
||||
}
|
||||
|
||||
protected ERR_PrepareResult PrepareStock()
|
||||
{
|
||||
int pending, accepted;
|
||||
string firstRejected;
|
||||
foreach (ERR_StockPlacement placement : m_Placements)
|
||||
{
|
||||
if (placement.Skipped) continue;
|
||||
if (!placement.Item || !placement.Inventory)
|
||||
{
|
||||
if (placement.Optional) { placement.Skipped = true; Print("Reintroduced [starting backpack]: optional item disappeared before insertion", LogLevel.WARNING); continue; }
|
||||
Fail("stock disappeared during initialization"); return ERR_PrepareResult.FAILED;
|
||||
}
|
||||
if (placement.OnGround) { LeaveStartingItem(placement); continue; }
|
||||
array<IEntity> items = {};
|
||||
placement.Inventory.GetItems(items);
|
||||
if (items.Contains(placement.Item)) continue;
|
||||
bool expired = GetGame().GetWorld().GetWorldTime() > m_Deadline;
|
||||
if (placement.Optional && (expired || (!placement.Accepted && !placement.Inventory.CanInsertItem(placement.Item))))
|
||||
{
|
||||
LeaveStartingItem(placement); continue;
|
||||
}
|
||||
pending++;
|
||||
if (firstRejected.IsEmpty() && placement.Item.GetPrefabData()) firstRejected = placement.Item.GetPrefabData().GetPrefabName();
|
||||
if (placement.Accepted) accepted++;
|
||||
if (!placement.Accepted) placement.Accepted = placement.Inventory.TryInsertItem(placement.Item);
|
||||
}
|
||||
if (pending)
|
||||
{
|
||||
if (GetGame().GetWorld().GetWorldTime() > m_Deadline)
|
||||
{
|
||||
Fail(string.Format("physical stock insertion timed out: pending=%1 accepted=%2 sample=%3", pending, accepted, firstRejected));
|
||||
return ERR_PrepareResult.FAILED;
|
||||
}
|
||||
return ERR_PrepareResult.PENDING;
|
||||
}
|
||||
return ERR_PrepareResult.READY;
|
||||
}
|
||||
|
||||
ERR_PrepareResult Prepare()
|
||||
{
|
||||
if (!Replication.IsServer() || !m_Begun || m_Stopped) return ERR_PrepareResult.FAILED;
|
||||
@@ -636,7 +707,7 @@ class ERR_SiteService
|
||||
ERR_PrepareResult aircraftPlacement = ERR_PrepareResult.READY;
|
||||
if (m_ExtractionPlacement) aircraftPlacement = m_ExtractionPlacement.Poll(GetGame().GetWorld().GetWorldTime());
|
||||
if (aircraftPlacement == ERR_PrepareResult.FAILED) { Fail(m_ExtractionPlacement.FailureReason()); return m_State; }
|
||||
int pending, accepted, pendingGuards;
|
||||
int pendingGuards;
|
||||
foreach (ERR_SiteRuntime site : m_Sites)
|
||||
{
|
||||
if (site.Definition.Transport && !site.Definition.Transport.Optional && !TransportReady(site))
|
||||
@@ -654,29 +725,7 @@ class ERR_SiteService
|
||||
if (mobile == ERR_PrepareResult.PENDING) pendingGuards++;
|
||||
}
|
||||
}
|
||||
string firstRejected;
|
||||
foreach (ERR_StockPlacement placement : m_Placements)
|
||||
{
|
||||
if (placement.Skipped) continue;
|
||||
if (!placement.Item || !placement.Inventory) { Fail("stock disappeared during initialization"); return m_State; }
|
||||
array<IEntity> items = {};
|
||||
placement.Inventory.GetItems(items);
|
||||
if (items.Contains(placement.Item)) continue;
|
||||
if (placement.Optional && GetGame().GetWorld().GetWorldTime() > m_Deadline)
|
||||
{
|
||||
Print("Reintroduced [starting backpack]: item did not fit; omitted " + placement.Item.GetPrefabData().GetPrefabName(), LogLevel.WARNING);
|
||||
ERR_Actors.DeleteSpawned(placement.Item); placement.Skipped = true; continue;
|
||||
}
|
||||
pending++;
|
||||
if (firstRejected.IsEmpty() && placement.Item.GetPrefabData()) firstRejected = placement.Item.GetPrefabData().GetPrefabName();
|
||||
if (placement.Accepted) accepted++;
|
||||
if (!placement.Accepted) placement.Accepted = placement.Inventory.TryInsertItem(placement.Item);
|
||||
}
|
||||
if (pending)
|
||||
{
|
||||
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) Fail(string.Format("physical stock insertion timed out: pending=%1 accepted=%2 sample=%3", pending, accepted, firstRejected));
|
||||
return m_State;
|
||||
}
|
||||
if (PrepareStock() != ERR_PrepareResult.READY) return m_State;
|
||||
if (pendingGuards > 0 || aircraftPlacement == ERR_PrepareResult.PENDING) return m_State;
|
||||
// All required scenery, stock and physical guards now exist. Survey optional
|
||||
// parking once against that real population, then bound only vehicle settling.
|
||||
|
||||
@@ -29,13 +29,13 @@ class ERR_SpawnPlacement
|
||||
foreach (int id, vector reserved : reservations)
|
||||
if (id != ownReservation && vector.DistanceXZ(position, reserved) < 1.2 && Math.AbsFloat(position[1] - reserved[1]) < 2) return false;
|
||||
vector ground = ERR_Actors.Ground(position);
|
||||
if (ground[1] < 1 || Math.AbsFloat(position[1] - ground[1]) > 0.3) return false;
|
||||
if (!ERR_Actors.DryGround(ground) || Math.AbsFloat(position[1] - ground[1]) > 0.3) return false;
|
||||
// Feet need supported, reasonably level terrain across the character width.
|
||||
for (int x = -1; x <= 1; x++)
|
||||
for (int z = -1; z <= 1; z++)
|
||||
{
|
||||
vector sample = ERR_Actors.Ground(position + Vector(x * 0.45, 0, z * 0.45));
|
||||
if (Math.AbsFloat(sample[1] - ground[1]) > 0.3) return false;
|
||||
if (!ERR_Actors.DryGround(sample) || Math.AbsFloat(sample[1] - ground[1]) > 0.3) return false;
|
||||
}
|
||||
m_Exclude = exclude;
|
||||
m_Candidate = ground; m_Blocked = false;
|
||||
|
||||
@@ -11,6 +11,11 @@ class ERR_StartBackpack
|
||||
if (Read(PATH, loaded) || Read(PATH + ".bak", loaded)) m_Records = loaded;
|
||||
else if (ERR_ProfileConfig.Exists(PATH) || ERR_ProfileConfig.Exists(PATH + ".bak")) m_ReadOnly = true;
|
||||
}
|
||||
static string Destination(array<string> parts)
|
||||
{
|
||||
if (parts.Count() == 3) return parts[2];
|
||||
return "backpack";
|
||||
}
|
||||
static bool Valid(array<string> records)
|
||||
{
|
||||
if (!records || records.IsEmpty() || records.Count() > 33) return false;
|
||||
@@ -19,19 +24,21 @@ class ERR_StartBackpack
|
||||
for (int i = 1; i < records.Count(); i++)
|
||||
{
|
||||
array<string> parts = {}; records[i].Split("|", parts, false);
|
||||
if (parts.Count() != 2 || parts[0].Length() > 512 || !parts[0].EndsWith(".et")) return false;
|
||||
if ((parts.Count() != 2 && parts.Count() != 3) || parts[0].Length() > 512 || !parts[0].EndsWith(".et")) return false;
|
||||
string key = ERR_TerrainCatalog.Key(parts[0]); int count;
|
||||
if (!ERR_ItemPreferences.Guid(key) || seen.Contains(key)) return false;
|
||||
string destination = Destination(parts);
|
||||
if (destination != "backpack" && destination != "ground") return false;
|
||||
if (!ERR_ItemPreferences.Guid(key) || seen.Contains(key + "|" + destination)) return false;
|
||||
if (!ERR_CommandArguments.Integer(parts[1], count) || count < 1 || count > 48) return false;
|
||||
total += count; if (total > 64) return false;
|
||||
seen.Insert(key);
|
||||
seen.Insert(key + "|" + destination);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static bool Parse(JsonLoadContext json, out array<string> records)
|
||||
{
|
||||
int version; records = {};
|
||||
return json.ReadValue("version", version) && version == 1 && json.ReadValue("records", records) && Valid(records);
|
||||
return json.ReadValue("version", version) && (version == 1 || version == 2) && json.ReadValue("records", records) && Valid(records);
|
||||
}
|
||||
protected bool Read(string path, out array<string> records)
|
||||
{
|
||||
@@ -42,7 +49,7 @@ class ERR_StartBackpack
|
||||
{
|
||||
if (!Replication.IsServer() || m_ReadOnly || !Valid(records)) return false;
|
||||
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
|
||||
json.WriteValue("version", 1); json.WriteValue("records", records);
|
||||
json.WriteValue("version", 2); json.WriteValue("records", records);
|
||||
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
|
||||
m_Records = records; return true;
|
||||
}
|
||||
@@ -53,26 +60,26 @@ class ERR_StartBackpack
|
||||
next[0] = "default"; if (custom) next[0] = "custom";
|
||||
return Save(next);
|
||||
}
|
||||
bool Set(ResourceName prefab, int quantity)
|
||||
bool Set(ResourceName prefab, int quantity, string destination = "backpack")
|
||||
{
|
||||
if (quantity < 0 || quantity > 48) return false;
|
||||
if (quantity < 0 || quantity > 48 || (destination != "backpack" && destination != "ground")) return false;
|
||||
string key = ERR_TerrainCatalog.Key(prefab);
|
||||
if (!ERR_ItemPreferences.Guid(key)) return false;
|
||||
array<string> next = {m_Records[0]};
|
||||
for (int i = 1; i < m_Records.Count(); i++)
|
||||
{
|
||||
array<string> parts = {}; m_Records[i].Split("|", parts, false);
|
||||
if (ERR_TerrainCatalog.Key(parts[0]) != key) next.Insert(m_Records[i]);
|
||||
if (ERR_TerrainCatalog.Key(parts[0]) != key || Destination(parts) != destination) next.Insert(m_Records[i]);
|
||||
}
|
||||
if (quantity > 0) next.Insert(prefab + "|" + quantity.ToString());
|
||||
if (quantity > 0) next.Insert(prefab + "|" + quantity.ToString() + "|" + destination);
|
||||
return Save(next);
|
||||
}
|
||||
bool Remove(string key)
|
||||
bool Remove(string key, string destination = "backpack")
|
||||
{
|
||||
for (int i = 1; i < m_Records.Count(); i++)
|
||||
{
|
||||
array<string> parts = {}; m_Records[i].Split("|", parts, false);
|
||||
if (ERR_TerrainCatalog.Key(parts[0]) == key) return Set(parts[0], 0);
|
||||
if (ERR_TerrainCatalog.Key(parts[0]) == key && Destination(parts) == destination) return Set(parts[0], 0, destination);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -81,13 +88,13 @@ class ERR_StartBackpack
|
||||
for (int i = 1; i < m_Records.Count(); i++)
|
||||
{
|
||||
array<string> parts = {}; m_Records[i].Split("|", parts, false);
|
||||
ERR_SiteStock item = new ERR_SiteStock(); item.Prefab = parts[0]; item.Count = parts[1].ToInt(); stock.Insert(item);
|
||||
ERR_SiteStock item = new ERR_SiteStock(); item.Prefab = parts[0]; item.Count = parts[1].ToInt(); item.OnGround = Destination(parts) == "ground"; stock.Insert(item);
|
||||
}
|
||||
}
|
||||
void Snapshot(array<string> values)
|
||||
{
|
||||
values.Insert("starterMode=" + m_Records[0]);
|
||||
string state = "Saved for next round. The shared field map is always included. Add matching magazines separately.";
|
||||
string state = "Saved for next round. The shared field map is always included. Oversized items are left nearby. Add matching magazines separately.";
|
||||
if (m_ReadOnly) state = "Read-only: starting backpack configuration needs repair.";
|
||||
values.Insert("starterState=" + state);
|
||||
for (int i = 1; i < m_Records.Count(); i++)
|
||||
@@ -96,7 +103,7 @@ class ERR_StartBackpack
|
||||
string key = ERR_TerrainCatalog.Key(parts[0]);
|
||||
ERR_ServerItem item = GetGame().ERR_ServerItems().Item(key);
|
||||
string label = FilePath.StripPath(parts[0]); if (item) label = item.Label;
|
||||
values.Insert("starterItem=" + key + "|" + ERR_ServerItemCatalog.Clean(label) + "|" + parts[1]);
|
||||
values.Insert("starterItem=" + key + "|" + ERR_ServerItemCatalog.Clean(label) + "|" + parts[1] + "|" + Destination(parts));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ modded class ERR_AdminMenu
|
||||
button.m_OnClicked.Insert(OnStarterButton); m_StarterButtons.Insert(button);
|
||||
}
|
||||
SCR_EditBoxComponent.GetEditBoxComponent("StarterQuantity", GetRootWidget()).SetValue("1");
|
||||
SCR_ComboBoxComponent destination = SCR_ComboBoxComponent.GetComboBoxComponent("StarterDestination", GetRootWidget());
|
||||
destination.ClearAll(); destination.AddItem("Inside backpack"); destination.AddItem("On ground beside backpack");
|
||||
destination.SetCurrentItem(0, false, false, false);
|
||||
UpdateActions();
|
||||
}
|
||||
override void OnMenuClose()
|
||||
@@ -69,8 +72,13 @@ modded class ERR_AdminMenu
|
||||
if (field == "starterState") SetText("StarterState", value);
|
||||
if (field == "serverItem" && parts.Count() == 6)
|
||||
{ catalogKeys.Insert(parts[0]); catalogLabels.Insert(parts[1]); catalogSignature += value; }
|
||||
if (field == "starterItem" && parts.Count() == 3)
|
||||
{ savedKeys.Insert(parts[0]); savedLabels.Insert(parts[2] + " x " + WidgetManager.Translate(parts[1])); savedSignature += value; total += parts[2].ToInt(); }
|
||||
if (field == "starterItem" && parts.Count() == 4)
|
||||
{
|
||||
string location = "Backpack"; if (parts[3] == "ground") location = "Ground";
|
||||
savedKeys.Insert(parts[0] + " " + parts[3]);
|
||||
savedLabels.Insert(location + " | " + parts[2] + " x " + WidgetManager.Translate(parts[1]));
|
||||
savedSignature += value; total += parts[2].ToInt();
|
||||
}
|
||||
}
|
||||
if (catalogSignature != m_StarterCatalogSignature)
|
||||
{
|
||||
@@ -82,7 +90,7 @@ modded class ERR_AdminMenu
|
||||
if (savedSignature != m_StarterSavedSignature)
|
||||
{ StarterCombo("StarterSaved", savedKeys, savedLabels, m_StarterSaved); m_StarterSavedSignature = savedSignature; }
|
||||
string mode = "Default loadout selected"; if (m_StarterMode == 1) mode = "Custom loadout selected";
|
||||
SetText("StarterSummary", mode + " | Custom: " + savedKeys.Count().ToString() + "/32 types, " + total.ToString() + "/64 items + field map");
|
||||
SetText("StarterSummary", mode + " | Custom: " + savedKeys.Count().ToString() + "/32 entries, " + total.ToString() + "/64 items + field map");
|
||||
string page = "Choose a mod, then Show items. Quantity 0 removes an item.";
|
||||
if (!m_StarterAddon.IsEmpty()) page = "Catalog page " + (m_ShownItemPage + 1).ToString() + "/" + (m_LastItemPage + 1).ToString() + " | Select an item above; enter its total desired quantity (0 removes).";
|
||||
SetText("StarterCatalogState", page); UpdateActions();
|
||||
@@ -104,7 +112,9 @@ modded class ERR_AdminMenu
|
||||
int selected = SCR_ComboBoxComponent.GetComboBoxComponent("StarterCatalogItem", GetRootWidget()).GetCurrentIndex();
|
||||
int count; string quantity = SCR_EditBoxComponent.GetEditBoxComponent("StarterQuantity", GetRootWidget()).GetValue().Trim();
|
||||
if (!ERR_CommandArguments.Integer(quantity, count) || count < 0 || count > 48) { ReceiveReply("Enter a quantity from 0 to 48; 0 removes the item."); return; }
|
||||
if (selected >= 0 && selected < m_StarterCatalog.Count() && !m_StarterAddon.IsEmpty()) SubmitStarter("starter set " + m_StarterAddon + " " + m_StarterCatalog[selected] + " " + count.ToString());
|
||||
string destination = "backpack";
|
||||
if (SCR_ComboBoxComponent.GetComboBoxComponent("StarterDestination", GetRootWidget()).GetCurrentIndex() == 1) destination = "ground";
|
||||
if (selected >= 0 && selected < m_StarterCatalog.Count() && !m_StarterAddon.IsEmpty()) SubmitStarter("starter set " + m_StarterAddon + " " + m_StarterCatalog[selected] + " " + count.ToString() + " " + destination);
|
||||
return;
|
||||
}
|
||||
int index = SCR_ComboBoxComponent.GetComboBoxComponent("StarterMod", GetRootWidget()).GetCurrentIndex();
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
// The mission only produces facts. Aggregation and public leaderboards belong to
|
||||
// the receiver; retries retain event IDs so they cannot count a result twice.
|
||||
[BaseContainerProps()]
|
||||
class ERR_TelemetryConfig
|
||||
{
|
||||
[Attribute("")] string Endpoint;
|
||||
static const string RESOURCE = "{E72B130684FA492C}Configs/ERR/Telemetry.conf";
|
||||
static const string PATH = "$profile:EscapistsTelemetry.json";
|
||||
string Token;
|
||||
string ServerName;
|
||||
static ERR_TelemetryConfig Load()
|
||||
{
|
||||
ERR_TelemetryConfig config = ERR_TelemetryConfig.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(RESOURCE));
|
||||
if (!config) config = new ERR_TelemetryConfig();
|
||||
if (FileIO.FileExists(PATH))
|
||||
{
|
||||
JsonLoadContext json = new JsonLoadContext();
|
||||
if (!json.LoadFromFile(PATH)) { config.Endpoint = ""; return config; }
|
||||
json.ReadValue("endpoint", config.Endpoint);
|
||||
json.ReadValue("token", config.Token);
|
||||
json.ReadValue("serverName", config.ServerName);
|
||||
}
|
||||
config.Endpoint.TrimInPlace();
|
||||
if (!config.Endpoint.StartsWith("https://") || !ERR_SpeechRules.Endpoint(config.Endpoint)) config.Endpoint = "";
|
||||
if (config.Token.Length() > 256 || config.ServerName.Length() > 120) config.Endpoint = "";
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_TelemetryPlayer
|
||||
{
|
||||
string id;
|
||||
string name;
|
||||
bool authenticated;
|
||||
bool connected;
|
||||
bool participated;
|
||||
bool escaped;
|
||||
int connectedSeconds;
|
||||
int kills;
|
||||
int friendlyKills;
|
||||
int deaths;
|
||||
int downed;
|
||||
int recoveries;
|
||||
}
|
||||
class ERR_TelemetryMod
|
||||
{
|
||||
string id;
|
||||
string name;
|
||||
string version;
|
||||
}
|
||||
class ERR_TelemetryState
|
||||
{
|
||||
int schemaVersion = 1;
|
||||
string serverId;
|
||||
string endpoint;
|
||||
string previousMissionId;
|
||||
string previousOutcome;
|
||||
int droppedEvents;
|
||||
ref array<string> pending = {};
|
||||
}
|
||||
class ERR_TelemetryRules
|
||||
{
|
||||
static bool Acknowledged(int status) { return status >= 200 && status < 300; }
|
||||
static int RetrySeconds(int failures)
|
||||
{
|
||||
return Math.Min(300, 5 * Math.Pow(2, Math.Min(failures, 6)));
|
||||
}
|
||||
static string Outcome(int phase, bool emptyRestart, bool preparationFailed)
|
||||
{
|
||||
if (preparationFailed) return "preparation_failed";
|
||||
if (emptyRestart) return "restarted_empty";
|
||||
if (phase == ERR_Phase.VICTORY) return "victory";
|
||||
if (phase == ERR_Phase.DEFEAT) return "defeat";
|
||||
return "interrupted";
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_Telemetry
|
||||
{
|
||||
static const string STATE = "$profile:EscapistsTelemetryState.json";
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref ERR_TelemetryConfig m_Config;
|
||||
protected ref ERR_TelemetryState m_State;
|
||||
protected ref array<ref ERR_TelemetryPlayer> m_Players = {};
|
||||
protected ref array<ref ERR_TelemetryMod> m_Mods = {};
|
||||
protected ref RestCallback m_Callback;
|
||||
protected RestContext m_Context;
|
||||
protected string m_MissionId;
|
||||
protected string m_InFlight;
|
||||
protected string m_FinalEventId;
|
||||
protected string m_FinalOutcome;
|
||||
protected string m_FinalTerrain;
|
||||
protected bool m_CommunityReady;
|
||||
protected int m_Started;
|
||||
protected int m_LastSample;
|
||||
protected int m_NextHeartbeat;
|
||||
protected int m_NextAttempt;
|
||||
protected int m_Peak;
|
||||
protected int m_Failures;
|
||||
protected int m_RequestStarted;
|
||||
protected int m_Delivered;
|
||||
protected bool m_Collected;
|
||||
protected bool m_Partial;
|
||||
protected bool m_Enabled;
|
||||
protected bool m_Ended;
|
||||
protected bool m_Closed;
|
||||
protected bool m_Dirty;
|
||||
protected bool m_TruncatedPlayers;
|
||||
protected bool m_TruncatedMods;
|
||||
protected bool m_CredentialBlocked;
|
||||
|
||||
void ERR_Telemetry(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode; m_Config = ERR_TelemetryConfig.Load();
|
||||
m_State = new ERR_TelemetryState();
|
||||
// A damaged primary can recover from the last fully written generation.
|
||||
if (!LoadState(STATE) && !LoadState(STATE + ".bak"))
|
||||
{
|
||||
if (FileIO.FileExists(STATE) || FileIO.FileExists(STATE + ".bak"))
|
||||
{
|
||||
Print("Reintroduced [telemetry]: unreadable queue; telemetry suspended, files preserved", LogLevel.ERROR);
|
||||
m_Closed = true; return;
|
||||
}
|
||||
m_State.serverId = UUID.GenV4();
|
||||
}
|
||||
if (m_State.endpoint != m_Config.Endpoint)
|
||||
{
|
||||
// Never redirect previously collected player data to a different receiver.
|
||||
Purge(); m_State.endpoint = m_Config.Endpoint;
|
||||
}
|
||||
array<string> addons = {}; GameProject.GetLoadedAddons(addons);
|
||||
foreach (string addon : addons)
|
||||
{
|
||||
if (GameProject.IsVanillaAddon(addon)) continue;
|
||||
if (m_Mods.Count() >= 128) { m_TruncatedMods = true; break; }
|
||||
ERR_TelemetryMod mod = new ERR_TelemetryMod();
|
||||
mod.id = addon; mod.name = GameProject.GetAddonTitle(addon);
|
||||
BackendApi backend = GetGame().GetBackendApi();
|
||||
if (backend && backend.GetWorkshop())
|
||||
{
|
||||
WorkshopItem item = backend.GetWorkshop().FindItem(addon);
|
||||
if (item && item.GetActiveRevision()) mod.version = item.GetActiveRevision().GetVersion();
|
||||
}
|
||||
m_Mods.Insert(mod);
|
||||
}
|
||||
m_MissionId = UUID.GenV4(); m_Started = System.GetTickCount();
|
||||
Refresh();
|
||||
GetGame().GetCallqueue().CallLater(Pulse, 5000, true);
|
||||
}
|
||||
protected bool ReadState(string path, out ERR_TelemetryState state)
|
||||
{
|
||||
string raw;
|
||||
JsonLoadContext json = new JsonLoadContext();
|
||||
if (!ERR_ProfileConfig.Text(path, raw) || !json.LoadFromString(raw) || !json.ReadValue("state", state) || !state || state.schemaVersion != 1 || !UUID.IsUUID(state.serverId) || !state.pending || state.pending.Count() > 64) return false;
|
||||
foreach (string queuedEvent : state.pending)
|
||||
{
|
||||
JsonLoadContext queued = new JsonLoadContext(); string eventId, missionId, serverId;
|
||||
if (queuedEvent.Length() > 65536 || !queued.LoadFromString(queuedEvent) || !queued.ReadValue("eventId", eventId) || !UUID.IsUUID(eventId) || !queued.ReadValue("missionId", missionId) || !UUID.IsUUID(missionId) || !queued.ReadValue("serverId", serverId) || serverId != state.serverId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool LoadState(string path)
|
||||
{
|
||||
ERR_TelemetryState state;
|
||||
if (!ReadState(path, state)) return false;
|
||||
m_State = state; return true;
|
||||
}
|
||||
protected bool Save()
|
||||
{
|
||||
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
|
||||
json.WriteValue("state", m_State);
|
||||
if (!json.SaveToFile(STATE + ".tmp")) return false;
|
||||
string written;
|
||||
if (!ERR_ProfileConfig.Text(STATE + ".tmp", written) || written != json.SaveToString()) return false;
|
||||
ERR_TelemetryState previous;
|
||||
if (ReadState(STATE, previous) && !FileIO.CopyFile(STATE, STATE + ".bak")) return false;
|
||||
if (!FileIO.CopyFile(STATE + ".tmp", STATE)) return false;
|
||||
string confirmed;
|
||||
if (!ERR_ProfileConfig.Text(STATE, confirmed) || confirmed != written) return false;
|
||||
FileIO.DeleteFile(STATE + ".tmp"); m_Dirty = false; return true;
|
||||
}
|
||||
protected void Purge()
|
||||
{
|
||||
m_State.pending.Clear(); m_State.previousMissionId = ""; m_State.previousOutcome = "";
|
||||
m_Players.Clear(); m_InFlight = ""; m_Callback = null;
|
||||
// Do not retain personal data in recovery files after opting out.
|
||||
FileIO.DeleteFile(STATE); FileIO.DeleteFile(STATE + ".bak"); FileIO.DeleteFile(STATE + ".tmp");
|
||||
m_Dirty = true;
|
||||
}
|
||||
protected bool Current() { return GetGame() && m_Mode && ERR_GameMode.Get() == m_Mode; }
|
||||
string Status()
|
||||
{
|
||||
if (m_Closed) return "Telemetry suspended: queue recovery required.";
|
||||
if (m_Mode.Settings().Get("telemetryEnabled") == 0) return "Disabled. Unsent records discarded.";
|
||||
if (m_Config.Endpoint.IsEmpty()) return "Not sending: receiving URL is missing or invalid.";
|
||||
if (m_Dirty) return "Waiting for profile storage; events have not been sent.";
|
||||
if (m_CredentialBlocked) return "Telemetry credential storage needs attention; delivery suspended.";
|
||||
if (m_Failures > 0) return "Delivery unavailable; pending records will retry.";
|
||||
return string.Format("Configured. %1 delivered this session; %2 pending.", m_Delivered, m_State.pending.Count());
|
||||
}
|
||||
bool Enabled() { return m_Enabled && !m_Closed; }
|
||||
void Refresh()
|
||||
{
|
||||
if (m_Closed || !Current() || !Replication.IsServer() || !m_Mode.Settings()) return;
|
||||
bool enabled = m_Mode.Settings().Get("telemetryEnabled") == 1 && !m_Config.Endpoint.IsEmpty();
|
||||
if (!enabled)
|
||||
{
|
||||
if (m_Enabled || !m_State.pending.IsEmpty() || !m_State.previousMissionId.IsEmpty())
|
||||
{
|
||||
m_Enabled = false; Purge(); Save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (m_Enabled || m_Ended) return;
|
||||
m_Enabled = true;
|
||||
int now = System.GetTickCount(); m_LastSample = now; m_NextHeartbeat = now + 300000;
|
||||
m_Partial = m_Collected || now - m_Started > 1000;
|
||||
m_Peak = 0; m_TruncatedPlayers = false;
|
||||
string previous = m_State.previousMissionId;
|
||||
string outcome = m_State.previousOutcome;
|
||||
m_State.previousMissionId = m_MissionId; m_State.previousOutcome = "unfinished";
|
||||
Sample();
|
||||
if (!m_Collected && !previous.IsEmpty()) Event("mission_restarted", "", previous, outcome);
|
||||
if (m_Collected) Event("telemetry_resumed");
|
||||
else Event("mission_started", "", previous, outcome);
|
||||
m_Collected = true;
|
||||
}
|
||||
protected void Sample()
|
||||
{
|
||||
if (!Enabled() || m_Ended) return;
|
||||
int now = System.GetTickCount();
|
||||
int elapsed = Math.Max(0, (now - m_LastSample) / 1000); m_LastSample = now;
|
||||
foreach (ERR_TelemetryPlayer known : m_Players)
|
||||
{
|
||||
if (known.connected) known.connectedSeconds += elapsed;
|
||||
known.connected = false;
|
||||
}
|
||||
array<int> connections = {}; GetGame().GetPlayerManager().GetPlayers(connections);
|
||||
m_Peak = Math.Max(m_Peak, connections.Count());
|
||||
foreach (int connection : connections)
|
||||
{
|
||||
UUID account = SCR_PlayerIdentityUtils.GetPlayerIdentityId(connection);
|
||||
string key = account;
|
||||
bool authenticated = !account.IsNull();
|
||||
if (!authenticated) key = m_MissionId + ":connection:" + connection.ToString();
|
||||
ERR_TelemetryPlayer player;
|
||||
foreach (ERR_TelemetryPlayer candidate : m_Players) if (candidate.id == key) { player = candidate; break; }
|
||||
if (!player)
|
||||
{
|
||||
if (m_Players.Count() >= 128) { m_TruncatedPlayers = true; continue; }
|
||||
player = new ERR_TelemetryPlayer(); player.id = key; player.authenticated = authenticated; m_Players.Insert(player);
|
||||
}
|
||||
player.name = GetGame().GetPlayerManager().GetPlayerName(connection);
|
||||
if (player.name.Length() > 128) player.name = player.name.Substring(0, 128);
|
||||
player.connected = true;
|
||||
ERR_PlayerLife life = m_Mode.Players().Get(connection);
|
||||
if (life && life.Connected && life.UsedLife) player.participated = true;
|
||||
if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Extraction().DidEscape(account);
|
||||
}
|
||||
// Include disconnected escapees/participants with their final authoritative result.
|
||||
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
|
||||
foreach (ERR_PlayerLife participant : lives)
|
||||
foreach (ERR_TelemetryPlayer tracked : m_Players)
|
||||
if (tracked.authenticated && tracked.id == participant.AccountId)
|
||||
{
|
||||
tracked.participated = true;
|
||||
ERR_RoundPlayerStats stats;
|
||||
if (m_Mode.ERR_Statistics()) stats = m_Mode.ERR_Statistics().Find(participant.AccountId);
|
||||
if (stats)
|
||||
{
|
||||
tracked.kills = stats.Kills; tracked.friendlyKills = stats.FriendlyKills;
|
||||
tracked.deaths = stats.Deaths; tracked.downed = stats.Downed; tracked.recoveries = stats.Recoveries;
|
||||
}
|
||||
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId);
|
||||
}
|
||||
}
|
||||
void RosterChanged(int playerId) { Sample(); }
|
||||
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
|
||||
{
|
||||
if (!Enabled() || m_Ended) return;
|
||||
if (ERR_MissionRules.Terminal(current)) Finish(ERR_TelemetryRules.Outcome(current, false, false));
|
||||
else Event("phase_changed");
|
||||
}
|
||||
void Finish(string outcome)
|
||||
{
|
||||
Refresh();
|
||||
if (!Enabled() || m_Ended) return;
|
||||
Sample(); m_Ended = true;
|
||||
m_State.previousOutcome = outcome;
|
||||
Event("mission_finished", outcome);
|
||||
}
|
||||
protected string Terrain()
|
||||
{
|
||||
// The loaded root scene identifies the island beneath GM/Conflict scenarios.
|
||||
// Use the same terrain resolver as the mission title, on the reporting server.
|
||||
string world = GetGame().GetWorldFile();
|
||||
if (GetGame().GetWorld() && GetGame().GetWorld().GetNumSubScenes() > 0)
|
||||
{
|
||||
string root = GetGame().GetWorld().GetSubSceneName(0);
|
||||
if (!root.IsEmpty()) world = root;
|
||||
}
|
||||
return ERR_TerrainDisplayName.Resolve(world);
|
||||
}
|
||||
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "")
|
||||
{
|
||||
if (!Enabled()) return;
|
||||
if (!m_Ended) Sample();
|
||||
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
|
||||
json.WriteValue("schemaVersion", 1);
|
||||
string eventId = UUID.GenV4();
|
||||
json.WriteValue("eventId", eventId); json.WriteValue("missionId", m_MissionId);
|
||||
json.WriteValue("serverId", m_State.serverId); json.WriteValue("serverName", ERR_TelemetryServerName.Resolve(m_Config.ServerName));
|
||||
json.WriteValue("type", type); json.WriteValue("occurredAt", System.GetUnixTime());
|
||||
json.WriteValue("partial", m_Partial);
|
||||
int elapsedSeconds = Math.Max(0, (System.GetTickCount() - m_Started) / 1000);
|
||||
json.WriteValue("elapsedSeconds", elapsedSeconds);
|
||||
json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome);
|
||||
json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome);
|
||||
string terrain = Terrain();
|
||||
json.WriteValue("terrain", terrain);
|
||||
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
|
||||
string scenario; if (header) scenario = header.m_sName;
|
||||
json.WriteValue("gameVersion", GetGame().GetBuildVersion());
|
||||
json.WriteValue("scenario", scenario); json.WriteValue("mods", m_Mods);
|
||||
json.WriteValue("modsTruncated", m_TruncatedMods); json.WriteValue("playersTruncated", m_TruncatedPlayers);
|
||||
json.WriteValue("connectedPlayers", GetGame().GetPlayerManager().GetPlayerCount());
|
||||
json.WriteValue("peakPlayers", m_Peak); json.WriteValue("players", m_Players);
|
||||
json.WriteValue("reason", m_Mode.EndReason()); json.WriteValue("preparationError", m_Mode.PreparationError());
|
||||
map<string, int> settings = new map<string, int>();
|
||||
foreach (string key : ERR_Settings.Keys()) settings.Set(key, m_Mode.Settings().Get(key));
|
||||
json.WriteValue("settings", settings); json.WriteValue("droppedEvents", m_State.droppedEvents);
|
||||
string payload = json.SaveToString();
|
||||
if (payload.Length() > 65536) { m_State.droppedEvents++; m_Dirty = true; return; }
|
||||
// Bound disk and memory during long outages. Prefer retaining result records.
|
||||
if (m_State.pending.Count() >= 64)
|
||||
{
|
||||
int remove = -1;
|
||||
for (int i = 0; i < m_State.pending.Count(); i++)
|
||||
{
|
||||
if (m_State.pending[i] == m_InFlight) continue;
|
||||
JsonLoadContext queued = new JsonLoadContext(); string queuedType;
|
||||
if (queued.LoadFromString(m_State.pending[i]) && queued.ReadValue("type", queuedType) && queuedType == "heartbeat") { remove = i; break; }
|
||||
}
|
||||
if (remove < 0) remove = 0;
|
||||
if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1;
|
||||
m_State.pending.Remove(remove); m_State.droppedEvents++;
|
||||
}
|
||||
if (type == "mission_finished")
|
||||
{
|
||||
m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain;
|
||||
// Finish can arrive while a previous request is in flight. RemoveItem on its
|
||||
// acknowledgement still removes that exact payload, leaving this final first.
|
||||
m_State.pending.InsertAt(payload, 0);
|
||||
if (outcome == "victory" || outcome == "defeat")
|
||||
m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded...");
|
||||
GetGame().GetCallqueue().CallLater(Pulse, 1, false);
|
||||
}
|
||||
else m_State.pending.Insert(payload);
|
||||
m_Dirty = true;
|
||||
Save();
|
||||
}
|
||||
protected void Pulse()
|
||||
{
|
||||
if (m_Closed || ERR_GameMode.Get() != m_Mode) { Close(); return; }
|
||||
Refresh();
|
||||
if (!Enabled()) return;
|
||||
int now = System.GetTickCount();
|
||||
if (!m_Ended)
|
||||
{
|
||||
Sample();
|
||||
if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); }
|
||||
}
|
||||
if (m_Dirty && !Save()) { CommunityUnavailable(); return; }
|
||||
if (m_Callback && now - m_RequestStarted > 30000) Failed(m_Callback);
|
||||
if (m_Callback || m_State.pending.IsEmpty() || now < m_NextAttempt) return;
|
||||
if (m_Config.Token.IsEmpty())
|
||||
{
|
||||
string token;
|
||||
if (!ERR_TelemetryCredentials.Resolve(m_Config.Endpoint, m_State.serverId, token))
|
||||
{
|
||||
CommunityUnavailable();
|
||||
m_CredentialBlocked = true; m_NextAttempt = now + 30000; return;
|
||||
}
|
||||
m_Config.Token = token; m_CredentialBlocked = false;
|
||||
}
|
||||
m_Context = GetGame().GetRestApi().GetContext(m_Config.Endpoint);
|
||||
if (!m_Context) { CommunityUnavailable(); m_NextAttempt = now + 300000; return; }
|
||||
m_Callback = new RestCallback(); m_Callback.SetOnSuccess(Delivered); m_Callback.SetOnError(Failed);
|
||||
m_InFlight = m_State.pending[0]; m_RequestStarted = now;
|
||||
JsonSaveContext request = new JsonSaveContext(); request.EnableTypeDiscriminator(false);
|
||||
request.WriteValue("token", m_Config.Token);
|
||||
// Embed the event object as JSON rather than double-encoding a JSON string.
|
||||
string wrapper = request.SaveToString();
|
||||
wrapper = wrapper.Substring(0, wrapper.Length() - 1) + ",\"event\":" + m_InFlight + "}";
|
||||
m_Context.SetHeaders("Content-Type,application/json"); m_Context.SetTimeout(15);
|
||||
m_Context.POST(m_Callback, "", wrapper);
|
||||
}
|
||||
protected void Delivered(RestCallback callback)
|
||||
{
|
||||
if (callback != m_Callback || !Enabled() || !Current()) return;
|
||||
if (!ERR_TelemetryRules.Acknowledged(callback.GetHttpCode())) { Failed(callback); return; }
|
||||
UpdateCommunity(callback);
|
||||
m_State.pending.RemoveItem(m_InFlight); m_InFlight = ""; m_Callback = null;
|
||||
m_Failures = 0; m_Delivered++; m_Dirty = true; Save();
|
||||
if (m_Ended) GetGame().GetCallqueue().CallLater(Pulse, 1, false);
|
||||
}
|
||||
protected void CommunityUnavailable()
|
||||
{
|
||||
if (!m_CommunityReady && Current() && m_Ended && (m_FinalOutcome == "victory" || m_FinalOutcome == "defeat"))
|
||||
m_Mode.ERR_PublishCommunityStatistics("Community map records unavailable. This round's local statistics are shown below.");
|
||||
}
|
||||
protected void UpdateCommunity(RestCallback callback)
|
||||
{
|
||||
if (!m_Ended || m_FinalEventId.IsEmpty() || !Current()) return;
|
||||
JsonLoadContext sent = new JsonLoadContext(); string sentEvent;
|
||||
if (!sent.LoadFromString(m_InFlight) || !sent.ReadValue("eventId", sentEvent) || sentEvent != m_FinalEventId) return;
|
||||
if (m_FinalOutcome != "victory" && m_FinalOutcome != "defeat") return;
|
||||
JsonLoadContext reply = new JsonLoadContext();
|
||||
ERR_CommunityStatistics community;
|
||||
if (callback.GetData().Length() > 8192 || !reply.LoadFromString(callback.GetData()) || !reply.ReadValue("community", community) || !community || !community.Matches(m_State.serverId, m_MissionId, m_FinalEventId, m_FinalTerrain, m_FinalOutcome))
|
||||
{
|
||||
CommunityUnavailable(); return;
|
||||
}
|
||||
m_CommunityReady = true;
|
||||
m_Mode.ERR_PublishCommunityStatistics(community.Summary());
|
||||
}
|
||||
protected void Failed(RestCallback callback)
|
||||
{
|
||||
if (callback != m_Callback || !Enabled()) return;
|
||||
m_Callback = null; m_InFlight = ""; m_Failures = Math.Min(100, m_Failures + 1);
|
||||
m_NextAttempt = System.GetTickCount() + 1000 * ERR_TelemetryRules.RetrySeconds(m_Failures);
|
||||
CommunityUnavailable();
|
||||
if (m_Failures == 1) Print("Reintroduced [telemetry]: delivery failed; queued events will retry", LogLevel.WARNING);
|
||||
}
|
||||
void Close()
|
||||
{
|
||||
if (m_Closed) return;
|
||||
if (Current()) Finish("interrupted");
|
||||
m_Closed = true;
|
||||
if (GetGame()) GetGame().GetCallqueue().Remove(Pulse);
|
||||
m_Callback = null; m_Context = null; m_Mode = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Each installation claims its random server ID with its own persistent secret.
|
||||
// No receiver administrator secret is bundled with the mod or sent by players.
|
||||
class ERR_TelemetryCredential
|
||||
{
|
||||
string endpoint;
|
||||
string serverId;
|
||||
string token;
|
||||
}
|
||||
class ERR_TelemetryCredentials
|
||||
{
|
||||
static const string PATH = "$profile:EscapistsTelemetryCredentials.json";
|
||||
static bool Valid(ERR_TelemetryCredential entry)
|
||||
{
|
||||
return entry && !entry.endpoint.IsEmpty() && UUID.IsUUID(entry.serverId) && entry.token.Length() >= 32 && entry.token.Length() <= 256;
|
||||
}
|
||||
static bool Resolve(string endpoint, string serverId, out string token)
|
||||
{
|
||||
token = "";
|
||||
ref array<ref ERR_TelemetryCredential> entries = {};
|
||||
if (FileIO.FileExists(PATH))
|
||||
{
|
||||
JsonLoadContext loaded = new JsonLoadContext();
|
||||
// Never silently replace a damaged credential: the receiver already owns its
|
||||
// hash, and a new secret must not take over the same server's history.
|
||||
if (!loaded.LoadFromFile(PATH) || !loaded.ReadValue("credentials", entries) || !entries || entries.Count() > 16) return false;
|
||||
foreach (ERR_TelemetryCredential known : entries)
|
||||
{
|
||||
if (!Valid(known)) return false;
|
||||
if (known.endpoint == endpoint && known.serverId == serverId) token = known.token;
|
||||
}
|
||||
if (!token.IsEmpty()) return true;
|
||||
}
|
||||
if (entries.Count() >= 16) return false;
|
||||
ERR_TelemetryCredential created = new ERR_TelemetryCredential();
|
||||
created.endpoint = endpoint; created.serverId = serverId;
|
||||
string first = UUID.GenV4(); string second = UUID.GenV4();
|
||||
created.token = first + second;
|
||||
entries.Insert(created);
|
||||
JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false);
|
||||
saved.WriteValue("credentials", entries);
|
||||
if (!saved.SaveToFile(PATH + ".tmp")) return false;
|
||||
string written;
|
||||
if (!ERR_ProfileConfig.Text(PATH + ".tmp", written) || written != saved.SaveToString()) return false;
|
||||
if (!FileIO.CopyFile(PATH + ".tmp", PATH)) return false;
|
||||
string confirmed;
|
||||
if (!ERR_ProfileConfig.Text(PATH, confirmed) || confirmed != written) return false;
|
||||
FileIO.DeleteFile(PATH + ".tmp");
|
||||
// Publish to the HTTP sender only after the exact credential is durable.
|
||||
token = created.token;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Keep instrumentation outside mission rules: telemetry cannot decide an outcome
|
||||
// or delay vanilla shutdown/restarts. Unsent results survive in the profile queue.
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected ref ERR_Telemetry m_ERRTelemetry;
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !Settings()) return;
|
||||
#ifdef WORKBENCH
|
||||
if (!System.IsCLIParam("errTelemetryTest")) return;
|
||||
#endif
|
||||
m_ERRTelemetry = new ERR_Telemetry(this);
|
||||
Admin().Register(new ERR_TelemetryAdminProvider(m_ERRTelemetry));
|
||||
OnPhaseChanged().Insert(m_ERRTelemetry.PhaseChanged);
|
||||
Players().OnChanged().Insert(m_ERRTelemetry.RosterChanged);
|
||||
}
|
||||
void ERR_RefreshTelemetry() { if (m_ERRTelemetry) m_ERRTelemetry.Refresh(); }
|
||||
protected override void FailPreparation(string detail)
|
||||
{
|
||||
super.FailPreparation(detail);
|
||||
if (m_ERRTelemetry && !PreparationError().IsEmpty()) m_ERRTelemetry.Finish("preparation_failed");
|
||||
}
|
||||
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
|
||||
{
|
||||
// Observe the phase BEFORE vanilla maps native interruption/empty restart to defeat.
|
||||
if (m_ERRTelemetry) m_ERRTelemetry.Finish(ERR_TelemetryRules.Outcome(Phase(), m_EmptyRestartRequested, !PreparationError().IsEmpty()));
|
||||
super.OnGameModeEnd(endData);
|
||||
}
|
||||
override void OnGameEnd()
|
||||
{
|
||||
if (m_ERRTelemetry) m_ERRTelemetry.Close();
|
||||
super.OnGameEnd();
|
||||
}
|
||||
void ~ERR_GameMode() { if (m_ERRTelemetry) m_ERRTelemetry.Close(); }
|
||||
}
|
||||
modded class ERR_AdminService
|
||||
{
|
||||
override void Execute(ERR_GameMode mode, bool authorized, string arguments, array<string> replies)
|
||||
{
|
||||
super.Execute(mode, authorized, arguments, replies);
|
||||
// Settings only change through the existing authenticated, save-before-apply path.
|
||||
if (authorized && mode) mode.ERR_RefreshTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_TelemetryAdminProvider : ERR_AdminProvider
|
||||
{
|
||||
protected ERR_Telemetry m_Service;
|
||||
void ERR_TelemetryAdminProvider(ERR_Telemetry service) { m_Service = service; }
|
||||
override string Id() { return "telemetry"; }
|
||||
override void Capabilities(array<string> values)
|
||||
{
|
||||
if (m_Service) values.Insert("telemetryStatus=" + m_Service.Status());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Resolve on the reporting server for every new event: lobby metadata can be
|
||||
// unavailable during initial startup. Never infer identity from the scenario.
|
||||
class ERR_TelemetryServerName
|
||||
{
|
||||
static string Resolve(string configured)
|
||||
{
|
||||
string name = configured;
|
||||
name.TrimInPlace();
|
||||
if (name.IsEmpty())
|
||||
{
|
||||
BackendApi backend = GetGame().GetBackendApi();
|
||||
SCR_DSConfig dedicated = new SCR_DSConfig();
|
||||
if (backend && backend.GetRunningDSConfig(dedicated) && dedicated.game)
|
||||
name = dedicated.game.name;
|
||||
name.TrimInPlace();
|
||||
}
|
||||
if (name.IsEmpty())
|
||||
{
|
||||
ServerConfig config = ServerLobbyApi.GetServerConfig();
|
||||
if (config) name = config.GetName();
|
||||
name.TrimInPlace();
|
||||
}
|
||||
if (name.IsEmpty())
|
||||
{
|
||||
ServerInfo info = GetGame().GetServerInfo();
|
||||
if (info) name = info.GetName();
|
||||
name.TrimInPlace();
|
||||
}
|
||||
if (name.Length() > 120) name = name.Substring(0, 120);
|
||||
return name;
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,21 @@ class ERR_TerrainSites : ERR_GeneratedSites
|
||||
protected vector m_Candidate;
|
||||
protected float m_CandidateHeading;
|
||||
protected int m_CandidateBegan;
|
||||
protected ref ERR_ReconAirports m_Airports = new ERR_ReconAirports();
|
||||
protected bool m_AirportsReady;
|
||||
protected int m_GeometryRejected, m_NavigationRejected, m_PadRejected;
|
||||
protected string m_NavigationReason;
|
||||
// Keep the same work slice; difficult terrain gets more slices, not larger stalls.
|
||||
static int SearchAttempts() { return 48000; }
|
||||
static int SearchMilliseconds() { return 120000; }
|
||||
static float PrisonAirportBuffer(vector low, vector high)
|
||||
{
|
||||
// The old 1.5km tactical separation could exclude an entire small island.
|
||||
// Physical ground, scenery and navigation checks still apply independently.
|
||||
return Math.Clamp(Math.Min(high[0] - low[0], high[2] - low[2]) * 0.15, 200, 1500);
|
||||
}
|
||||
static bool Bounds(vector low, vector high)
|
||||
{
|
||||
float width = high[0] - low[0], depth = high[2] - low[2];
|
||||
return width >= 2000 && depth >= 2000 && width <= 100000 && depth <= 100000;
|
||||
return width >= 1000 && depth >= 1000 && width <= 100000 && depth <= 100000;
|
||||
}
|
||||
static float Spacing(vector low, vector high)
|
||||
{
|
||||
@@ -23,12 +31,12 @@ class ERR_TerrainSites : ERR_GeneratedSites
|
||||
{
|
||||
if (m_Started || !Replication.IsServer() || !definition || !definition.Valid() || !GetGame().GetWorldEntity()) return false;
|
||||
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
|
||||
if (!Bounds(m_Low, m_High)) { m_Error = "Terrain bounds must span 2-100 km on each axis."; return false; }
|
||||
if (!Bounds(m_Low, m_High)) { m_Error = "Terrain bounds must span 1-100 km on each axis."; return false; }
|
||||
AIWorld world = GetGame().GetAIWorld();
|
||||
if (!world || !world.GetNavmeshWorldComponent("Soldiers")) { m_Error = "Terrain requires its own Soldiers navigation mesh."; return false; }
|
||||
m_Started = true; m_Definition = definition; definition.TerrainGenerated = true;
|
||||
m_Spacing = Spacing(m_Low, m_High);
|
||||
// Resolve the terrain's airport first, then keep the prison in escape range.
|
||||
// Resolve a clear landing zone first, then keep the prison in escape range.
|
||||
array<int> order = {ERR_LocationKind.EXTRACTION, ERR_LocationKind.PRISON, ERR_LocationKind.RADIO, ERR_LocationKind.DEPOT, ERR_LocationKind.HOSPITAL, ERR_LocationKind.MORTAR, ERR_LocationKind.OUTPOST};
|
||||
foreach (int kind : order)
|
||||
foreach (ERR_SiteDefinition site : definition.Sites)
|
||||
@@ -37,7 +45,7 @@ class ERR_TerrainSites : ERR_GeneratedSites
|
||||
site.PlacementResolved = false;
|
||||
// The queue, rather than RandomClear, owns placement including extraction.
|
||||
site.RandomClear = false;
|
||||
if (kind == ERR_LocationKind.EXTRACTION) { site.RequireAirfield = true; site.AircraftOffset = vector.Zero; }
|
||||
if (kind == ERR_LocationKind.EXTRACTION) { site.RequireAirfield = false; site.Label = "Extraction landing zone"; site.AircraftOffset = vector.Zero; }
|
||||
if (kind == ERR_LocationKind.DEPOT) site.Label = "Supply cache";
|
||||
m_Pending.Insert(site);
|
||||
}
|
||||
@@ -62,89 +70,74 @@ class ERR_TerrainSites : ERR_GeneratedSites
|
||||
}
|
||||
protected bool Navigable(vector position)
|
||||
{
|
||||
m_NavigationReason = "";
|
||||
NavmeshWorldComponent mesh = GetGame().GetAIWorld().GetNavmeshWorldComponent("Soldiers");
|
||||
if (!mesh) return false;
|
||||
if (!mesh.IsTileLoaded(position)) { m_WaitingNav = mesh.LoadTileIn(position); return false; }
|
||||
if (!mesh) { m_NavigationReason = "Soldiers navmesh unavailable"; return false; }
|
||||
if (!mesh.IsTileLoaded(position))
|
||||
{
|
||||
m_WaitingNav = mesh.LoadTileIn(position);
|
||||
m_NavigationReason = "navmesh tile unavailable";
|
||||
if (m_WaitingNav) m_NavigationReason = "waiting for navmesh tile";
|
||||
return false;
|
||||
}
|
||||
vector reachable;
|
||||
return mesh.GetReachablePoint(position, 10, reachable);
|
||||
if (mesh.GetReachablePoint(position, 10, reachable)) return true;
|
||||
m_NavigationReason = "no reachable AI ground";
|
||||
return false;
|
||||
}
|
||||
protected bool Survey(ERR_SiteDefinition site, vector position, float heading, out float floor)
|
||||
{
|
||||
if (!m_Terrain.Site(site, position, heading, floor)) { m_GeometryRejected++; return false; }
|
||||
if (!m_Terrain.Site(site, position, heading, floor, PrisonAirportBuffer(m_Low, m_High))) { m_GeometryRejected++; RecordRejection(m_Terrain.Rejection()); return false; }
|
||||
vector ground = Vector(position[0], floor, position[2]);
|
||||
if (!Navigable(ground)) { m_NavigationRejected++; return false; }
|
||||
if (site.Guarded && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.GuardOffset, heading)))) { m_NavigationRejected++; return false; }
|
||||
if (site.ResponseCount > 0 && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.ResponseOffset, heading)))) { m_NavigationRejected++; return false; }
|
||||
// Aircraft spawn at the centre's terrain height, not the camp grid's minimum.
|
||||
// Using the minimum here falsely steepens the uphill edge of a valid pad.
|
||||
if (site.Kind == ERR_LocationKind.EXTRACTION)
|
||||
{
|
||||
ground = ERR_Actors.Ground(position);
|
||||
floor = ground[1];
|
||||
}
|
||||
if (!Navigable(ground)) { m_NavigationRejected++; RecordRejection(m_NavigationReason); return false; }
|
||||
if (site.Guarded && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.GuardOffset, heading)))) { m_NavigationRejected++; RecordRejection("guard area: " + m_NavigationReason); return false; }
|
||||
if (site.ResponseCount > 0 && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.ResponseOffset, heading)))) { m_NavigationRejected++; RecordRejection("patrol area: " + m_NavigationReason); return false; }
|
||||
if (site.Kind != ERR_LocationKind.EXTRACTION) return true;
|
||||
float padFloor;
|
||||
if (m_Terrain.Clear(ground, heading, 14, 0.8, padFloor))
|
||||
{
|
||||
bool primaryClear;
|
||||
if (site.RequireAirfield) primaryClear = ERR_AirfieldPlacement.Clear(ground);
|
||||
else primaryClear = ERR_AirfieldPlacement.ClearGround(ground);
|
||||
if (primaryClear && ERR_RescueLanding.Fits(ground)) return true;
|
||||
bool primaryClear = ERR_AirfieldPlacement.ClearGround(ground);
|
||||
if (!primaryClear) RecordRejection("primary aircraft clearance");
|
||||
else if (!ERR_RescueLanding.Fits(ground)) RecordRejection("secondary aircraft pad");
|
||||
else return true;
|
||||
}
|
||||
else RecordRejection("landing zone: " + m_Terrain.Rejection());
|
||||
m_PadRejected++; return false;
|
||||
}
|
||||
protected vector SamplePosition(ERR_SiteDefinition site)
|
||||
{
|
||||
if (site.Kind != ERR_LocationKind.EXTRACTION || m_Airports.m_Positions.IsEmpty())
|
||||
return Vector(Math.RandomFloat(m_Low[0] + 60, m_High[0] - 60), 0, Math.RandomFloat(m_Low[2] + 60, m_High[2] - 60));
|
||||
vector centre = m_Airports.m_Positions[Math.RandomInt(0, m_Airports.m_Positions.Count())];
|
||||
float angle = Math.RandomFloat(0, Math.PI2), radius = Math.RandomFloat(0, 450);
|
||||
return centre + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius);
|
||||
}
|
||||
protected bool PrepareAirports()
|
||||
{
|
||||
if (m_AirportsReady) return true;
|
||||
// The scenario ticks every 250ms. One 2km tile per tick made Chernarus
|
||||
// spend roughly 48 seconds just waiting between airport queries. Batch
|
||||
// inexpensive tiles, but yield after 4ms or eight queries. A native query
|
||||
// itself cannot be interrupted, so check the budget after every call.
|
||||
int started = System.GetTickCount();
|
||||
for (int tile = 0; tile < 8; tile++)
|
||||
{
|
||||
m_Airports.Update();
|
||||
if (m_Airports.Complete() || System.GetTickCount() - started >= 4) break;
|
||||
}
|
||||
if (!m_Airports.Complete()) return false;
|
||||
if (m_Airports.m_Positions.IsEmpty())
|
||||
{
|
||||
// A painted runway or an island without an airport must not require a
|
||||
// connector or administrator coordinates. Survey a helicopter landing zone
|
||||
// with the same ground/navmesh/aircraft checks and finite placement budget.
|
||||
foreach (ERR_SiteDefinition site : m_Pending)
|
||||
if (site.Kind == ERR_LocationKind.EXTRACTION)
|
||||
{
|
||||
site.RequireAirfield = false;
|
||||
site.Label = "Extraction landing zone";
|
||||
}
|
||||
Print("Reintroduced [terrain]: no airport infrastructure found; surveying a clear helicopter extraction landing zone");
|
||||
}
|
||||
PrintFormat("Reintroduced [generation]: airport survey ready in %1 ms; airports=%2", System.GetTickCount() - m_Began, m_Airports.m_Positions.Count());
|
||||
m_AirportsReady = true; m_Began = System.GetTickCount();
|
||||
return true;
|
||||
// Helicopters need a clear pad, not airport infrastructure or an airport-first pass.
|
||||
return Vector(Math.RandomFloat(m_Low[0] + 60, m_High[0] - 60), 0, Math.RandomFloat(m_Low[2] + 60, m_High[2] - 60));
|
||||
}
|
||||
override ERR_PrepareResult Tick()
|
||||
{
|
||||
if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
|
||||
if (!m_Started || !Replication.IsServer()) return ERR_PrepareResult.FAILED;
|
||||
if (!PrepareAirports()) return m_Result;
|
||||
int tick = System.GetTickCount();
|
||||
while (m_Index < m_Pending.Count())
|
||||
{
|
||||
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
|
||||
ERR_SiteDefinition site = m_Pending[m_Index];
|
||||
if (m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000)
|
||||
ReportProgress(site);
|
||||
if (m_Attempts >= SearchAttempts() || System.GetTickCount() - m_Began >= SearchMilliseconds())
|
||||
{
|
||||
PrintFormat("Reintroduced [terrain survey exhausted]: id=%1 attempts=%2 rejected geometry=%3 navigation=%4 aircraft=%5", site.Id, m_Attempts, m_GeometryRejected, m_NavigationRejected, m_PadRejected);
|
||||
ReportSearch(site, "exhausted");
|
||||
if (!SkipOptional(site))
|
||||
{
|
||||
m_Error = "Terrain has no safe navigable placement for " + site.Label + ". Check this map's Soldiers navmesh, terrain clearance and size; no foreign-map fallback was used.";
|
||||
m_Error = "Terrain has no safe navigable placement for " + site.Label + " (" + site.Id + "). Checks: " + RejectionSummary();
|
||||
m_Result = ERR_PrepareResult.FAILED; return m_Result;
|
||||
}
|
||||
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount(); m_HaveCandidate = false;
|
||||
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
|
||||
ClearDiagnostics();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -157,11 +150,14 @@ class ERR_TerrainSites : ERR_GeneratedSites
|
||||
vector position = m_Candidate;
|
||||
float heading = m_CandidateHeading, floor;
|
||||
m_WaitingNav = false;
|
||||
if (Separated(site, position, false) && Survey(site, position, heading, floor))
|
||||
bool separated = Separated(site, position, false);
|
||||
if (!separated) RecordRejection("site spacing / escape boundary");
|
||||
if (separated && Survey(site, position, heading, floor))
|
||||
{
|
||||
position[1] = floor + 0.02;
|
||||
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
|
||||
PrintFormat("Reintroduced [terrain site]: id=%1 position=%2 heading=%3 attempts=%4", site.Id, position, heading, m_Attempts);
|
||||
ReportSearch(site, "placed"); ClearDiagnostics();
|
||||
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
|
||||
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// World-authored pumps survive a game-mode takeover. Their native registrations
|
||||
// belong to the discarded manager and must move to the replacement on each peer.
|
||||
modded class SCR_SupportStationManagerComponent
|
||||
{
|
||||
void ERR_CollectTerrainStations(array<SCR_BaseSupportStationComponent> stations)
|
||||
{
|
||||
for (int i = 0; i < m_mSupportStations.Count(); i++)
|
||||
{
|
||||
array<SCR_BaseSupportStationComponent> registered = m_mSupportStations.GetElement(i);
|
||||
if (!registered) continue;
|
||||
foreach (SCR_BaseSupportStationComponent station : registered)
|
||||
if (station && !stations.Contains(station)) stations.Insert(station);
|
||||
}
|
||||
}
|
||||
override void OnPostInit(IEntity owner)
|
||||
{
|
||||
super.OnPostInit(owner);
|
||||
if (ERR_TerrainTakeoverMode.Cast(owner) && GetInstance() == this)
|
||||
GetGame().ERR_RestoreTerrainSupportStations(this);
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_BaseSupportStationComponent
|
||||
{
|
||||
bool ERR_CanRestoreTerrainStation()
|
||||
{
|
||||
IEntity owner = GetOwner();
|
||||
return owner && GetGame() && owner.GetWorld() == GetGame().GetWorld()
|
||||
&& UsesRange() && m_eCurrentDamageState != EDamageState.DESTROYED;
|
||||
}
|
||||
void ERR_TerrainStationRestored(SCR_SupportStationManagerComponent manager) {}
|
||||
}
|
||||
|
||||
modded class SCR_BaseItemSupportStationComponent
|
||||
{
|
||||
override void ERR_TerrainStationRestored(SCR_SupportStationManagerComponent manager)
|
||||
{
|
||||
super.ERR_TerrainStationRestored(manager);
|
||||
// Item cleanup must remove the station from the manager it now belongs to.
|
||||
if (m_ERRItemStationInitialized && UsesRange()) m_ERRItemStationManager = manager;
|
||||
}
|
||||
}
|
||||
|
||||
modded class ArmaReforgerScripted
|
||||
{
|
||||
protected ref array<SCR_BaseSupportStationComponent> m_ERRTerrainSupportStations = {};
|
||||
void ERR_CaptureTerrainSupportStations(SCR_SupportStationManagerComponent original)
|
||||
{
|
||||
m_ERRTerrainSupportStations.Clear();
|
||||
if (original) original.ERR_CollectTerrainStations(m_ERRTerrainSupportStations);
|
||||
}
|
||||
void ERR_RestoreTerrainSupportStations(SCR_SupportStationManagerComponent manager)
|
||||
{
|
||||
if (!manager) return;
|
||||
int restored;
|
||||
foreach (SCR_BaseSupportStationComponent station : m_ERRTerrainSupportStations)
|
||||
{
|
||||
if (!station || !station.ERR_CanRestoreTerrainStation()) continue;
|
||||
manager.AddSupportStation(station);
|
||||
station.ERR_TerrainStationRestored(manager);
|
||||
restored++;
|
||||
}
|
||||
m_ERRTerrainSupportStations.Clear();
|
||||
PrintFormat("Reintroduced [terrain support]: retained %1 existing support stations", restored);
|
||||
}
|
||||
override protected void OnBeforeWorldCleanup()
|
||||
{
|
||||
m_ERRTerrainSupportStations.Clear();
|
||||
super.OnBeforeWorldCleanup();
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,7 @@ modded class ArmaReforgerScripted
|
||||
}
|
||||
}
|
||||
Print("Reintroduced [terrain takeover]: stopping original scenario; preserving radio and chat managers");
|
||||
ERR_CaptureTerrainSupportStations(SCR_SupportStationManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_SupportStationManagerComponent)));
|
||||
m_ERRTerrainOriginal.OnGameEnd();
|
||||
if (m_DataCollectorComponent && m_DataCollectorComponent.GetOwner() == m_ERRTerrainOriginal) m_DataCollectorComponent = null;
|
||||
SCR_EntityCatalogManagerComponent.ERR_ReleaseTerrainCatalog(m_ERRTerrainOriginal);
|
||||
|
||||
@@ -102,6 +102,7 @@ modded class ArmaReforgerScripted
|
||||
m_ERRClientTerrainTakeover = true;
|
||||
SCR_BaseGameMode mode = SCR_BaseGameMode.Cast(m_ERRClientOldManagers[0]);
|
||||
PrintFormat("Reintroduced [terrain client]: drain retained mode=%1 incoming=%2", mode, incoming);
|
||||
ERR_CaptureTerrainSupportStations(m_ERROldSupport);
|
||||
if (mode) mode.OnGameEnd();
|
||||
// Owner entities can already be invalid while native destruction is deferred.
|
||||
// Compare the captured component itself, never clear a newer singleton.
|
||||
|
||||
@@ -48,7 +48,9 @@ class ERR_TerrainDisplayName
|
||||
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;
|
||||
// IsExternal/IsInternal also accept ordinary paths in the native runtime.
|
||||
// Inspect the serialized GUID prefix without invoking resource resolution.
|
||||
if (world.Length() < 18 || world.Substring(0, 1) != "{" || world.Substring(17, 1) != "}") break;
|
||||
Resource resource = BaseContainerTools.LoadContainer(world);
|
||||
if (!resource || !resource.IsValid()) break;
|
||||
BaseContainer container = resource.GetResource().ToBaseContainer();
|
||||
@@ -61,6 +63,18 @@ class ERR_TerrainDisplayName
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the authored Escape header as the source of mission text.
|
||||
class ERR_TerrainMissionText
|
||||
{
|
||||
static bool Load(out string description, out string details)
|
||||
{
|
||||
Resource resource = BaseContainerTools.LoadContainer("{C1E20667DCE324ED}Missions/ERR_Everon.conf");
|
||||
if (!resource || !resource.IsValid()) return false;
|
||||
BaseContainer header = resource.GetResource().ToBaseContainer();
|
||||
return header && header.Get("m_sDescription", description) && header.Get("m_sDetails", details);
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario selection still loads the terrain's header. Advertise the configured
|
||||
// Escape mode through the native DS name callback, then update local metadata
|
||||
// only when the authority actually starts Escape or its bootstrap reaches a client.
|
||||
@@ -69,6 +83,9 @@ modded class ArmaReforgerScripted
|
||||
protected ref SCR_MissionHeader m_ERRPresentedTerrainHeader;
|
||||
protected string m_ERROriginalTerrainTitle;
|
||||
protected string m_ERROriginalTerrainGameMode;
|
||||
protected string m_ERROriginalTerrainDescription, m_ERROriginalTerrainDetails;
|
||||
protected string m_ERRAppliedTerrainDescription, m_ERRAppliedTerrainDetails;
|
||||
protected bool m_ERRTerrainTextApplied;
|
||||
protected string m_ERRAppliedTerrainTitle;
|
||||
protected string m_ERRTitleWorld;
|
||||
protected string m_ERRTerrainLabel;
|
||||
@@ -129,6 +146,14 @@ modded class ArmaReforgerScripted
|
||||
m_ERRAppliedTerrainTitle = ERR_TerrainMissionTitle(header);
|
||||
header.m_sName = m_ERRAppliedTerrainTitle;
|
||||
header.m_sGameMode = "Escape";
|
||||
m_ERROriginalTerrainDescription = header.m_sDescription;
|
||||
m_ERROriginalTerrainDetails = header.m_sDetails;
|
||||
m_ERRTerrainTextApplied = ERR_TerrainMissionText.Load(m_ERRAppliedTerrainDescription, m_ERRAppliedTerrainDetails);
|
||||
if (m_ERRTerrainTextApplied)
|
||||
{
|
||||
header.m_sDescription = m_ERRAppliedTerrainDescription;
|
||||
header.m_sDetails = m_ERRAppliedTerrainDetails;
|
||||
}
|
||||
PrintFormat("Reintroduced [terrain presentation]: mission=%1; source scenario=%2", header.m_sName, header.GetHeaderResourceName());
|
||||
}
|
||||
|
||||
@@ -143,17 +168,30 @@ modded class ArmaReforgerScripted
|
||||
PrintFormat("Reintroduced [terrain presentation]: refreshed mission=%1; loaded world=%2", title, GetWorldFile());
|
||||
}
|
||||
|
||||
SCR_MissionHeader ERR_TerrainLoadingHeader(SCR_MissionHeader requested)
|
||||
{
|
||||
if (!requested) return null;
|
||||
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
|
||||
// A loading screen may hold a copy. Only replace the confirmed scenario;
|
||||
// another incoming map must never inherit the outgoing world's text.
|
||||
if (!header || header != m_ERRPresentedTerrainHeader || m_ERRPresentedTerrainWorld != GetWorld()) return requested;
|
||||
if (requested != header && (requested.GetHeaderResourceName().IsEmpty() || requested.GetHeaderResourceName() != header.GetHeaderResourceName())) return requested;
|
||||
ERR_RefreshTerrainPresentation();
|
||||
return header;
|
||||
}
|
||||
string ERR_TerrainLoadingTitle(SCR_MissionHeader requested)
|
||||
{
|
||||
if (!requested) return "";
|
||||
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
|
||||
// The loading screen can hold a separate copy of the same scenario header.
|
||||
// Never replace another incoming scenario's title with the outgoing world.
|
||||
if (!header || header != m_ERRPresentedTerrainHeader || m_ERRPresentedTerrainWorld != GetWorld()) return requested.m_sName;
|
||||
if (requested != header && (requested.GetHeaderResourceName().IsEmpty() || requested.GetHeaderResourceName() != header.GetHeaderResourceName())) return requested.m_sName;
|
||||
ERR_RefreshTerrainPresentation();
|
||||
SCR_MissionHeader header = ERR_TerrainLoadingHeader(requested);
|
||||
if (!header) return "";
|
||||
return header.m_sName;
|
||||
}
|
||||
string ERR_TerrainLoadingDescription(SCR_MissionHeader requested)
|
||||
{
|
||||
SCR_MissionHeader header = ERR_TerrainLoadingHeader(requested);
|
||||
if (!header) return "";
|
||||
if (!header.m_sDetails.IsEmpty()) return header.m_sDetails;
|
||||
return header.m_sDescription;
|
||||
}
|
||||
|
||||
override void OnUpdate(BaseWorld world, float timeslice)
|
||||
{
|
||||
@@ -171,6 +209,11 @@ modded class ArmaReforgerScripted
|
||||
{
|
||||
// A later change by another system belongs to that system.
|
||||
if (m_ERRPresentedTerrainHeader.m_sName == m_ERRAppliedTerrainTitle) m_ERRPresentedTerrainHeader.m_sName = m_ERROriginalTerrainTitle;
|
||||
if (m_ERRTerrainTextApplied)
|
||||
{
|
||||
if (m_ERRPresentedTerrainHeader.m_sDescription == m_ERRAppliedTerrainDescription) m_ERRPresentedTerrainHeader.m_sDescription = m_ERROriginalTerrainDescription;
|
||||
if (m_ERRPresentedTerrainHeader.m_sDetails == m_ERRAppliedTerrainDetails) m_ERRPresentedTerrainHeader.m_sDetails = m_ERROriginalTerrainDetails;
|
||||
}
|
||||
if (m_ERRPresentedTerrainHeader.m_sGameMode == "Escape") m_ERRPresentedTerrainHeader.m_sGameMode = m_ERROriginalTerrainGameMode;
|
||||
}
|
||||
m_ERRPresentedTerrainHeader = null;
|
||||
@@ -178,6 +221,9 @@ modded class ArmaReforgerScripted
|
||||
m_ERROriginalTerrainTitle = "";
|
||||
m_ERROriginalTerrainGameMode = "";
|
||||
m_ERRAppliedTerrainTitle = "";
|
||||
m_ERRTerrainTextApplied = false;
|
||||
m_ERROriginalTerrainDescription = ""; m_ERROriginalTerrainDetails = "";
|
||||
m_ERRAppliedTerrainDescription = ""; m_ERRAppliedTerrainDetails = "";
|
||||
}
|
||||
|
||||
override protected void OnBeforeWorldCleanup()
|
||||
@@ -189,27 +235,37 @@ modded class ArmaReforgerScripted
|
||||
}
|
||||
|
||||
// Vanilla copies the header title once, before the replicated Escape bootstrap
|
||||
// may arrive. Refresh only the title; do not recreate loading widgets/hints.
|
||||
// may arrive. Refresh text in existing widgets; do not recreate widgets/hints.
|
||||
modded class SCR_LoadingScreenComponent
|
||||
{
|
||||
protected ref SCR_MissionHeader m_ERRLoadingTerrainHeader;
|
||||
protected string m_ERRLoadingTerrainTitle;
|
||||
protected string m_ERRLoadingTerrainTitle, m_ERRLoadingTerrainDescription;
|
||||
protected float m_ERRLoadingTerrainWait;
|
||||
|
||||
override void OnMissionDataRetrieved(SCR_MissionHeader header)
|
||||
{
|
||||
super.OnMissionDataRetrieved(header);
|
||||
m_ERRLoadingTerrainHeader = header;
|
||||
m_ERRLoadingTerrainTitle = ""; m_ERRLoadingTerrainWait = 0;
|
||||
m_ERRLoadingTerrainTitle = ""; m_ERRLoadingTerrainDescription = ""; m_ERRLoadingTerrainWait = 0;
|
||||
ERR_RefreshLoadingTerrainTitle();
|
||||
}
|
||||
protected void ERR_RefreshLoadingTerrainTitle()
|
||||
{
|
||||
if (!m_ERRLoadingTerrainHeader || !m_Widgets || !m_Widgets.m_wTitle || !GetGame()) return;
|
||||
if (!m_ERRLoadingTerrainHeader || !m_Widgets || !GetGame()) return;
|
||||
string title = GetGame().ERR_TerrainLoadingTitle(m_ERRLoadingTerrainHeader);
|
||||
if (title == m_ERRLoadingTerrainTitle) return;
|
||||
m_ERRLoadingTerrainTitle = title;
|
||||
m_Widgets.m_wTitle.SetText(title);
|
||||
if (m_Widgets.m_wTitle && title != m_ERRLoadingTerrainTitle)
|
||||
{
|
||||
m_ERRLoadingTerrainTitle = title;
|
||||
m_Widgets.m_wTitle.SetText(title);
|
||||
}
|
||||
// Match vanilla details/description precedence, translation and length limit.
|
||||
string description = WidgetManager.Translate(GetGame().ERR_TerrainLoadingDescription(m_ERRLoadingTerrainHeader));
|
||||
if (description.Length() > MAX_DESCRIPTION_LENGTH) description = description.Substring(0, MAX_DESCRIPTION_LENGTH) + "...";
|
||||
if (m_Widgets.m_wDescription && description != m_ERRLoadingTerrainDescription)
|
||||
{
|
||||
m_ERRLoadingTerrainDescription = description;
|
||||
m_Widgets.m_wDescription.SetText(description);
|
||||
}
|
||||
}
|
||||
override void Update(float timeSlice, float progress = 0, float minDurationRatio = 0)
|
||||
{
|
||||
|
||||
@@ -35,11 +35,10 @@ modded class SCR_PlayerController
|
||||
RplComponent component = RplComponent.Cast(target.FindComponent(RplComponent));
|
||||
if (!component) return false;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (!m_ERRProbeViewMissing) m_ERRProbeViewBuffer.Push(component.Id(), now, now, "0 1.6 0", "0 0 1", "0 1 0", 45, 0.0125, RplId.Invalid(), vector.Zero);
|
||||
vector local[4], world[4];
|
||||
if (!m_ERRProbeViewBuffer.Read(component.Id(), now, seconds, local, fov, nearPlane)) return false;
|
||||
target.GetWorldTransform(world); Math3D.MatrixMultiply4(world, local, transform);
|
||||
return true;
|
||||
// Feed complete sampled world poses, as the real receiver does. The target
|
||||
// remains a fixture stand-in; this does not qualify live multiplayer relay.
|
||||
if (!m_ERRProbeViewMissing) m_ERRProbeViewBuffer.Push(component.Id(), now, now, target.GetOrigin() + "0 1.6 0", "0 0 1", "0 1 0", 45, 0.0125, RplId.Invalid(), vector.Zero);
|
||||
return m_ERRProbeViewBuffer.Read(component.Id(), now, seconds, transform, fov, nearPlane);
|
||||
}
|
||||
|
||||
string ERR_ProbeAcceptedRecoveryStatus() { return m_ERRShownRecoveryStatus; }
|
||||
@@ -65,7 +64,7 @@ modded class SCR_PlayerController
|
||||
bool ERR_ProbeOrbitFrame(float yaw, float pitch, float zoom, bool enabled, out vector transform[4])
|
||||
{
|
||||
string outcome;
|
||||
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera" || this != GetGame().GetPlayerController() || !ERR_ProbeCameraActive() || ERR_ObservedEntity()) return false;
|
||||
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera" || this != GetGame().GetPlayerController() || !ERR_ProbeCameraActive() || ERR_ObservedEntity() != GetControlledEntity() || ERR_Actors.CanFight(GetControlledEntity())) return false;
|
||||
ERR_FollowSurvivorCameraComponent follow = ERR_ProbeFollowComponent();
|
||||
InputManager input = GetGame().GetInputManager();
|
||||
if (!follow || !input) return false;
|
||||
@@ -163,17 +162,30 @@ modded class ERR_GameMode
|
||||
foreach (SCR_PersistentDamageEffect effect : effects) m_ERRCameraDamage.TerminateDamageEffect(effect);
|
||||
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
|
||||
}
|
||||
protected bool ERR_CameraNoTargetOrbit(SCR_PlayerController pc)
|
||||
protected bool ERR_CameraOwnBodyOrbit(SCR_PlayerController pc)
|
||||
{
|
||||
vector body = m_ERRCameraBody.GetOrigin();
|
||||
pc.ERR_UpdateObserverServer(1);
|
||||
pc.ERR_UpdateObserverServer(-1);
|
||||
if (!ERR_CameraCheck(pc.ERR_ObservedEntity() == m_ERRCameraBody, "solo forward/backward cycling retains the actual own body")) return false;
|
||||
ERR_FollowSurvivorCameraComponent follow = pc.ERR_ProbeFollowComponent();
|
||||
follow.Input(true, false, false, true);
|
||||
follow.Input(true, false, false, false);
|
||||
if (!ERR_CameraCheck(follow.IsPlayerView(), "own-body orbit is tested with player-view mode selected")) return false;
|
||||
vector baseline[4], yaw[4], pitch[4], zoom[4], blocked[4];
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, 0, true, baseline), "no-target frame accepts native orbit action values")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, 0, 0, true, yaw) && vector.Distance(baseline[2], yaw[2]) > 0.05, "no-target yaw action changes production camera direction")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, -1, 0, true, pitch) && Math.AbsFloat(yaw[2][1] - pitch[2][1]) > 0.03, "no-target pitch action changes production camera elevation")) return false;
|
||||
vector aim = pc.ERR_ObserverAnchor() + "0 1.5 0";
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, -1, true, zoom) && vector.Distance(zoom[3], aim) < vector.Distance(pitch[3], aim) - 0.05, "no-target zoom action moves production camera closer to casualty anchor")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, -1, -1, false, blocked) && vector.Distance(zoom[3], blocked[3]) < 0.001 && vector.Distance(zoom[2], blocked[2]) < 0.001, "disabled no-target input preserves camera pose")) return false;
|
||||
if (!ERR_CameraCheck(vector.Distance(body, m_ERRCameraBody.GetOrigin()) < 0.001 && pc.GetControlledEntity() == m_ERRCameraBody && !pc.ERR_ObservedEntity(), "no-target orbit leaves casualty and possession unchanged")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, 0, true, baseline), "own-body frame accepts native orbit action values")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, 0, 0, true, yaw) && vector.Distance(baseline[2], yaw[2]) > 0.05, "own-body yaw action changes production camera direction")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, -1, 0, true, pitch) && Math.AbsFloat(yaw[2][1] - pitch[2][1]) > 0.03, "own-body pitch action changes production camera elevation")) return false;
|
||||
vector aim = m_ERRCameraBody.GetOrigin() + "0 0.4 0";
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, -1, true, zoom) && vector.Distance(zoom[3], aim) < vector.Distance(pitch[3], aim) - 0.05, "own-body zoom action moves production camera closer to casualty anchor")) return false;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, -1, -1, false, blocked) && vector.Distance(zoom[3], blocked[3]) < 0.001 && vector.Distance(zoom[2], blocked[2]) < 0.001, "disabled own-body input preserves camera pose")) return false;
|
||||
if (!ERR_CameraCheck(vector.Distance(body, m_ERRCameraBody.GetOrigin()) < 0.001 && pc.GetControlledEntity() == m_ERRCameraBody && pc.ERR_ObservedEntity() == m_ERRCameraBody, "own-body orbit leaves casualty and possession unchanged")) return false;
|
||||
// The disabled frame starts the normal two-frame input-resume gate.
|
||||
follow.Input(true, false, false, false);
|
||||
follow.Input(true, false, false, false);
|
||||
follow.Input(true, false, false, true);
|
||||
follow.Input(true, false, false, false);
|
||||
if (!ERR_CameraCheck(!follow.IsPlayerView(), "own-body camera returns to ordinary third-person selection")) return false;
|
||||
Math3D.MatrixCopy(zoom, m_ERRCameraOrbitPose);
|
||||
Print("ERR CAMERA ORBIT: native action/frame regression; physical mouse delivery and multiplayer are not tested");
|
||||
return true;
|
||||
@@ -193,6 +205,9 @@ modded class ERR_GameMode
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_ERRCameraBody.FindComponent(InventoryStorageManagerComponent));
|
||||
m_ERRCameraWeapon = ERR_Actors.Spawn("{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et", m_ERRCameraBody.GetOrigin()); Own(m_ERRCameraWeapon);
|
||||
if (!ERR_CameraCheck(m_ERRCameraWeapon && inventory && inventory.TryInsertItem(m_ERRCameraWeapon, EStoragePurpose.PURPOSE_WEAPON_PROXY), "one physical rifle granted to the player")) return;
|
||||
// Keep this deliberately unconscious solo fixture recoverable under the all-down rules.
|
||||
IEntity injector = ERR_Actors.Spawn(ERR_EnemyMedicalCatalog.EPI, m_ERRCameraBody.GetOrigin()); Own(injector);
|
||||
if (!ERR_CameraCheck(injector && inventory.TryInsertItem(injector) && Settings().Set("selfEpinephrine", 1), "carried injector keeps the camera fixture recoverable")) return;
|
||||
m_ERRCameraStage = -1; m_ERRCameraWait = now + 1500; return;
|
||||
}
|
||||
SCR_CharacterControllerComponent character = SCR_CharacterControllerComponent.Cast(m_ERRCameraBody.FindComponent(SCR_CharacterControllerComponent));
|
||||
@@ -209,22 +224,24 @@ modded class ERR_GameMode
|
||||
m_ERRCameraBody = pc.GetControlledEntity();
|
||||
m_ERRCameraDamage = SCR_CharacterDamageManagerComponent.Cast(m_ERRCameraBody.FindComponent(SCR_CharacterDamageManagerComponent));
|
||||
if (!ERR_CameraCheck(m_ERRCameraDamage && m_ERRCameraDamage.ERR_IsRevivablePlayer(), "admitted player retains treatable body")) return;
|
||||
ERR_CameraKnockout(); m_ERRCameraStage = 1; m_ERRCameraWait = now + 3000; return;
|
||||
// Allow the server medical sample plus the 3s spectator hint settling window.
|
||||
ERR_CameraKnockout(); m_ERRCameraStage = 1; m_ERRCameraWait = now + 6000; return;
|
||||
}
|
||||
if (m_ERRCameraStage == 1)
|
||||
{
|
||||
if (!ERR_CameraCheck(pc.ERR_ObserverState() == ERR_ObserverReason.UNCONSCIOUS && pc.ERR_ProbeCameraActive(), "native knockout automatically opens the manual third-person camera")) return;
|
||||
if (!ERR_CameraCheck(pc.GetControlledEntity() == m_ERRCameraBody && ERR_Actors.Alive(m_ERRCameraBody) && !ERR_Actors.CanFight(m_ERRCameraBody), "possession and living unconscious body unchanged")) return;
|
||||
if (!ERR_CameraCheck(!pc.ERR_ObservedEntity() && vector.DistanceXZ(pc.ERR_ObserverAnchor(), m_ERRCameraBody.GetOrigin()) < 1, "no teammate falls back to the casualty body")) return;
|
||||
if (!ERR_CameraCheck(pc.ERR_ObservedEntity() == m_ERRCameraBody && vector.DistanceXZ(pc.ERR_ObserverAnchor(), m_ERRCameraBody.GetOrigin()) < 1, "own casualty body is a real selected camera target")) return;
|
||||
if (!ERR_CameraCheck(playerInventory.Contains(m_ERRCameraWeapon), "enabled retention keeps the exact rifle with the unconscious player")) return;
|
||||
if (!ERR_CameraCheck(!RecoveryStatus().IsEmpty() && RecoveryStatus().Contains("Treatment or natural recovery") && pc.ERR_ProbeAcceptedRecoveryStatus() == RecoveryStatus(), "accepted observer hint contains the current ongoing recovery status")) return;
|
||||
if (!ERR_CameraNoTargetOrbit(pc)) return;
|
||||
string expectedRecovery = RecoveryStatus() + "\n" + pc.ERR_SelfEpinephrineHint();
|
||||
if (!ERR_CameraCheck(RecoveryStatus().Contains("Self epinephrine is available") && pc.ERR_ProbeAcceptedRecoveryStatus() == expectedRecovery, "accepted observer hint contains the current recovery status and self-injection controls")) return;
|
||||
if (!ERR_CameraOwnBodyOrbit(pc)) return;
|
||||
m_ERRCameraStage = 11; m_ERRCameraWait = now + 500; return;
|
||||
}
|
||||
if (m_ERRCameraStage == 11)
|
||||
{
|
||||
vector applied[4]; GetGame().GetCameraManager().CurrentCamera().GetWorldTransform(applied);
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeCameraActive() && vector.Distance(applied[2], m_ERRCameraOrbitPose[2]) < 0.01, "native camera applies and retains no-target orbit direction on subsequent frames")) return;
|
||||
if (!ERR_CameraCheck(pc.ERR_ProbeCameraActive() && vector.Distance(applied[2], m_ERRCameraOrbitPose[2]) < 0.01, "native camera applies and retains own-body orbit direction on subsequent frames")) return;
|
||||
if (!ERR_CameraOpenChat(pc, false)) return;
|
||||
m_ERRCameraStage = 111; m_ERRCameraWait = now + 500; return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Bound native AI horn requests without changing steering, braking or player input.
|
||||
class ERR_AIDriverHornLimit
|
||||
{
|
||||
protected int m_End = -1;
|
||||
protected int m_Next = 0;
|
||||
protected int m_Last = -1;
|
||||
bool Allow(bool requested, int now)
|
||||
{
|
||||
if (m_Last >= 0 && now < m_Last) { m_End = -1; m_Next = now + 30000; }
|
||||
m_Last = now;
|
||||
if (!requested) { m_End = -1; return false; }
|
||||
if (m_End >= 0)
|
||||
{
|
||||
if (now < m_End) return true;
|
||||
m_End = -1;
|
||||
}
|
||||
if (now < m_Next) return false;
|
||||
m_End = now + 500;
|
||||
m_Next = now + 30000;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_CharacterControllerComponent
|
||||
{
|
||||
protected ref ERR_AIDriverHornLimit m_ERRHornLimit;
|
||||
protected override void OnApplyControls(IEntity owner, float timeSlice)
|
||||
{
|
||||
super.OnApplyControls(owner, timeSlice);
|
||||
if (!Replication.IsServer() || !owner || ERR_Actors.Player(owner)) return;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (!mode) return;
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(owner);
|
||||
if (!vehicle || !vehicle.FindComponent(AICarMovementComponent)) return;
|
||||
BaseVehicleControllerComponent controls = BaseVehicleControllerComponent.Cast(vehicle.FindComponent(BaseVehicleControllerComponent));
|
||||
if (!controls || !controls.GetPilotCompartmentSlot() || controls.GetPilotCompartmentSlot().GetOccupant() != owner) return;
|
||||
CharacterInputContext input = GetInputContext();
|
||||
if (!input) return;
|
||||
if (!m_ERRHornLimit) m_ERRHornLimit = new ERR_AIDriverHornLimit();
|
||||
bool permitted = m_ERRHornLimit.Allow(mode.Active() && input.GetVehicleHorn() != 0, System.GetTickCount());
|
||||
if (!permitted) input.SetVehicleHorn(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Optional RCD 2.3.7 adapter. Foreign types are compiled only after the addon
|
||||
// is mounted. No workshop asset or player controller override is shipped here.
|
||||
class ERR_DroneBridge
|
||||
{
|
||||
static const string ADDON = "65AD60E204191D37";
|
||||
protected static ref ScriptModule s_Module;
|
||||
protected static bool s_Attempted;
|
||||
static bool Available()
|
||||
{
|
||||
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
|
||||
return loaded.Contains(ADDON);
|
||||
}
|
||||
static bool Initialize()
|
||||
{
|
||||
if (s_Module) return true;
|
||||
if (s_Attempted || !Replication.IsServer() || !Available()) return false;
|
||||
s_Attempted = true;
|
||||
#ifndef WORKBENCH
|
||||
if (!System.IsConsoleApp()) return false;
|
||||
#endif
|
||||
string code;
|
||||
code += "class ERR_OptionalDroneAPI {\n";
|
||||
code += "static bool ERR_DroneReady() {\n";
|
||||
code += "return SAL_DroneConnectionManager.GetInstance()!=null;\n";
|
||||
code += "}\n";
|
||||
code += "static int ERR_DroneState(IEntity entity) {\n";
|
||||
code += "int state=0;\n";
|
||||
code += "if(entity) {\n";
|
||||
code += "SAL_DroneControllerComponent control=SAL_DroneControllerComponent.Cast(entity.FindComponent(SAL_DroneControllerComponent));\n";
|
||||
code += "if(control) {\n";
|
||||
code += "if(!control.m_bIsDestroyed) state=1;\n";
|
||||
code += "if(control.m_iOwner != -1 || control.m_bIsConnected || entity.GetParent()) state=2;\n";
|
||||
code += "}\n";
|
||||
code += "}\n";
|
||||
code += "return state;\n";
|
||||
code += "}\n";
|
||||
code += "static bool ERR_DroneStart(IEntity e) {\n";
|
||||
code += "SAL_DroneControllerComponent c=SAL_DroneControllerComponent.Cast(e.FindComponent(SAL_DroneControllerComponent));\n";
|
||||
code += "SAL_DroneCleanUpComponent clean=SAL_DroneCleanUpComponent.Cast(e.FindComponent(SAL_DroneCleanUpComponent));\n";
|
||||
code += "SAL_DroneBatteryComponent b=SAL_DroneBatteryComponent.Cast(e.FindComponent(SAL_DroneBatteryComponent));\n";
|
||||
code += "if(!c || !clean || !b || !c.m_DroneManager || !c.m_CameraManager || !c.m_Drone || !e.GetPhysics() || ERR_DroneState(e)!=1) return false;\n";
|
||||
code += "c.ClearEventMask(e,EntityEvent.SIMULATE|EntityEvent.FIXEDFRAME);\n";
|
||||
code += "b.ClearEventMask(e,EntityEvent.FIXEDFRAME);\n";
|
||||
code += "clean.m_bIsConnected=true;\n";
|
||||
code += "c.m_bIsArmed=true;\n";
|
||||
code += "c.m_bIsActive=true;\n";
|
||||
code += "SAL_DroneNetworkPacket p=new SAL_DroneNetworkPacket();\n";
|
||||
code += "p.SetDrone(SAL_DroneConnectionManager.GetRplId(e));\n";
|
||||
code += "p.SetIsArmed(true);\n";
|
||||
code += "c.m_DroneManager.DisarmDrone(p);\n";
|
||||
code += "return true;\n";
|
||||
code += "}\n";
|
||||
code += "static bool ERR_DroneStep(IEntity e, vector controls, float dt, bool send) {\n";
|
||||
code += "if(ERR_DroneState(e)!=1) return false;\n";
|
||||
code += "SAL_DroneControllerComponent c=SAL_DroneControllerComponent.Cast(e.FindComponent(SAL_DroneControllerComponent));\n";
|
||||
code += "if(!c.m_BatteryComponent || c.m_BatteryComponent.m_fCurrentBattery<=0) return false;\n";
|
||||
code += "c.m_iThrottle=controls[0];\n";
|
||||
code += "c.m_iPitch=controls[1];\n";
|
||||
code += "c.m_iRoll=controls[2];\n";
|
||||
code += "c.m_iYaw=0;\n";
|
||||
code += "e.GetPhysics().EnableGravity(true);\n";
|
||||
code += "e.GetPhysics().SetActive(true);\n";
|
||||
code += "int old=c.m_iOwner;\n";
|
||||
code += "c.m_iOwner=SCR_PlayerController.GetLocalPlayerId();\n";
|
||||
code += "c.EOnSimulate(e,dt);\n";
|
||||
code += "c.m_iOwner=old;\n";
|
||||
code += "c.m_BatteryComponent.m_fCurrentBattery=Math.Max(0,c.m_BatteryComponent.m_fCurrentBattery-dt*(1+Math.AbsFloat(controls[1])+Math.AbsFloat(controls[2])));\n";
|
||||
code += "if(send) c.SendPacketServer(e,dt);\n";
|
||||
code += "return true;\n";
|
||||
code += "}\n";
|
||||
code += "static void ERR_DroneRelease(IEntity e) {\n";
|
||||
code += "if(!e) return;\n";
|
||||
code += "SAL_DroneControllerComponent c=SAL_DroneControllerComponent.Cast(e.FindComponent(SAL_DroneControllerComponent));\n";
|
||||
code += "SAL_DroneBatteryComponent b=SAL_DroneBatteryComponent.Cast(e.FindComponent(SAL_DroneBatteryComponent));\n";
|
||||
code += "SAL_DroneCleanUpComponent clean=SAL_DroneCleanUpComponent.Cast(e.FindComponent(SAL_DroneCleanUpComponent));\n";
|
||||
code += "if(!c) return;\n";
|
||||
code += "c.SetEventMask(e,EntityEvent.SIMULATE|EntityEvent.FIXEDFRAME);\n";
|
||||
code += "if(b) b.SetEventMask(e,EntityEvent.FIXEDFRAME);\n";
|
||||
code += "if(ERR_DroneState(e)==2) return;\n";
|
||||
code += "if(clean) clean.m_bIsConnected=false;\n";
|
||||
code += "c.m_bIsArmed=false;\n";
|
||||
code += "c.m_iThrottle=0;\n";
|
||||
code += "SAL_DroneNetworkPacket p=new SAL_DroneNetworkPacket();\n";
|
||||
code += "p.SetDrone(SAL_DroneConnectionManager.GetRplId(e));\n";
|
||||
code += "p.SetIsArmed(false);\n";
|
||||
code += "if(c.m_DroneManager)c.m_DroneManager.DisarmDrone(p);\n";
|
||||
code += "}\n";
|
||||
code += "static bool ERR_DroneJammed(IEntity e) {\n";
|
||||
code += "SAL_DroneConnectionManager m=SAL_DroneConnectionManager.GetInstance();\n";
|
||||
code += "if(!m)return true;\n";
|
||||
code += "int count=0;\n";
|
||||
code += "foreach(RplId id:m.m_aJammers) {\n";
|
||||
code += "if(count++>=64)break;\n";
|
||||
code += "RplComponent r=RplComponent.Cast(Replication.FindItem(id));\n";
|
||||
code += "if(!r)continue;\n";
|
||||
code += "IEntity jammer=r.GetEntity();\n";
|
||||
code += "if(!jammer)continue;\n";
|
||||
code += "SAL_DroneJammerComponent j=SAL_DroneJammerComponent.Cast(jammer.FindComponent(SAL_DroneJammerComponent));\n";
|
||||
code += "if(j && vector.Distance(e.GetOrigin(),jammer.GetOrigin())<j.m_fJammingRange)return true;\n";
|
||||
code += "}\n";
|
||||
code += "return false;\n";
|
||||
code += "}\n";
|
||||
code += "}\n";
|
||||
code += "bool ERR_DroneReady() {\n";
|
||||
code += "return ERR_OptionalDroneAPI.ERR_DroneReady();\n";
|
||||
code += "}\n";
|
||||
code += "int ERR_DroneState(IEntity e) {\n";
|
||||
code += "return ERR_OptionalDroneAPI.ERR_DroneState(e);\n";
|
||||
code += "}\n";
|
||||
code += "bool ERR_DroneStart(IEntity e) {\n";
|
||||
code += "return ERR_OptionalDroneAPI.ERR_DroneStart(e);\n";
|
||||
code += "}\n";
|
||||
code += "bool ERR_DroneStep(IEntity e, vector controls, float dt, bool send) {\n";
|
||||
code += "return ERR_OptionalDroneAPI.ERR_DroneStep(e,controls,dt,send);\n";
|
||||
code += "}\n";
|
||||
code += "void ERR_DroneRelease(IEntity e) {\n";
|
||||
code += "ERR_OptionalDroneAPI.ERR_DroneRelease(e);\n";
|
||||
code += "}\n";
|
||||
code += "bool ERR_DroneJammed(IEntity e) {\n";
|
||||
code += "return ERR_OptionalDroneAPI.ERR_DroneJammed(e);\n";
|
||||
code += "}\n";
|
||||
string error; int line;
|
||||
s_Module = ScriptModule.CompileScript(GetGame().GetScriptModule(), code, error, line);
|
||||
if (!s_Module)
|
||||
{
|
||||
PrintFormat("Reintroduced [drone adapter]: line=%1 error=%2", line, error);
|
||||
ERR_HQ.Event("DRONE UNAVAILABLE", string.Format("optional adapter line=%1: %2", line, error));
|
||||
}
|
||||
return s_Module != null;
|
||||
}
|
||||
static bool Ready()
|
||||
{
|
||||
bool result;
|
||||
return s_Module && s_Module.Call(null, "ERR_DroneReady", false, result) && result;
|
||||
}
|
||||
static int State(IEntity entity)
|
||||
{
|
||||
int result;
|
||||
if (!entity || !s_Module || !s_Module.Call(null, "ERR_DroneState", false, result, entity)) return 0;
|
||||
return result;
|
||||
}
|
||||
static bool Start(IEntity entity)
|
||||
{
|
||||
bool result;
|
||||
return s_Module && s_Module.Call(null, "ERR_DroneStart", false, result, entity) && result;
|
||||
}
|
||||
static bool Step(IEntity entity, vector controls, float dt, bool send)
|
||||
{
|
||||
bool result;
|
||||
return s_Module && s_Module.Call(null, "ERR_DroneStep", false, result, entity, controls, dt, send) && result;
|
||||
}
|
||||
static void Release(IEntity entity)
|
||||
{
|
||||
int ignored;
|
||||
if (entity && s_Module) s_Module.Call(null, "ERR_DroneRelease", false, ignored, entity);
|
||||
}
|
||||
static bool Jammed(IEntity entity)
|
||||
{
|
||||
bool result;
|
||||
return !s_Module || !s_Module.Call(null, "ERR_DroneJammed", false, result, entity) || result;
|
||||
}
|
||||
}
|
||||
class ERR_DroneCatalog
|
||||
{
|
||||
ResourceName Prefab;
|
||||
void Find()
|
||||
{
|
||||
if (!ERR_DroneBridge.Available()) return;
|
||||
SearchResourcesFilter filter = new SearchResourcesFilter();
|
||||
filter.fileExtensions = {"et"}; filter.recursive = true;
|
||||
filter.rootPath = "$" + GameProject.GetAddonID(ERR_DroneBridge.ADDON) + ":Prefabs/Drones/DJIBase";
|
||||
ResourceDatabase.SearchResources(filter, Add);
|
||||
}
|
||||
protected void Add(ResourceName resource, string path = "")
|
||||
{
|
||||
string name = resource; name.ToLower();
|
||||
// Exact inspected unarmed airframe; never a dropper, FPV, placeholder or wreck.
|
||||
if (name.EndsWith("/djibase.et")) Prefab = resource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Bounded, delivered-report-driven reconnaissance. This scheduler never follows
|
||||
// a target entity: only the camera sensor may sample a visible actor's position.
|
||||
class ERR_DroneSchedule
|
||||
{
|
||||
float NextLaunch;
|
||||
float LastObservation = -1;
|
||||
int Launched, Failures;
|
||||
bool Stopped;
|
||||
bool Due(float now, int count, int limit) { return !Stopped && limit > 0 && count < limit && now >= NextLaunch && Launched < 64 && Failures < 3; }
|
||||
bool Fresh(float observed, float now) { return observed > LastObservation && observed >= 0 && observed <= now && now - observed <= 60000; }
|
||||
void Attempt(float observed, float now, int cooldown) { LastObservation = observed; NextLaunch = now + cooldown * 1000; }
|
||||
void Result(bool success) { if (success) Failures = 0; else Failures++; }
|
||||
}
|
||||
class ERR_DroneFlightRules
|
||||
{
|
||||
static vector Controls(vector velocity, vector desired, vector forward, vector right)
|
||||
{
|
||||
vector error = desired - velocity;
|
||||
return Vector(Math.Clamp(0.49 + error[1] * 0.12, 0.15, 0.85), Math.Clamp(-vector.Dot(error, forward) * 0.12, -0.5, 0.5), Math.Clamp(-vector.Dot(error, right) * 0.12, -0.5, 0.5));
|
||||
}
|
||||
static bool CameraCone(vector origin, vector aim, vector point)
|
||||
{
|
||||
vector offset = point - origin;
|
||||
float distance = offset.Length();
|
||||
return distance >= 5 && distance <= 350 && vector.Dot(offset.Normalized(), (aim - origin).Normalized()) >= 0.8660254;
|
||||
}
|
||||
static bool HitsTarget(IEntity hit, IEntity target)
|
||||
{
|
||||
if (!target) return false;
|
||||
// Helmets and other attached equipment have their own collision entities.
|
||||
// Accept only the intended character's descendants, not an unrelated blocker.
|
||||
for (int depth = 0; hit && depth < 16; depth++)
|
||||
{
|
||||
if (hit == target) return true;
|
||||
hit = hit.GetParent();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static bool Clear(vector from, vector to, IEntity exclude, IEntity target = null)
|
||||
{
|
||||
TraceParam trace = new TraceParam(); trace.Start = from; trace.End = to;
|
||||
trace.Exclude = exclude; trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
|
||||
float fraction = GetGame().GetWorld().TraceMove(trace, null);
|
||||
return fraction >= 1 || HitsTarget(trace.TraceEnt, target);
|
||||
}
|
||||
}
|
||||
class ERR_DroneFlight
|
||||
{
|
||||
IEntity Drone;
|
||||
IEntity Operator;
|
||||
SCR_AIGroup Group;
|
||||
vector Home, Search;
|
||||
float Created, LastStep, NextPacket, NextScan, NextReport, SearchStarted = -1;
|
||||
bool Started, Returning, Finished, Released, Counted;
|
||||
int Candidate;
|
||||
float NextLink; bool LinkGood;
|
||||
string Reason = "preparing";
|
||||
bool Link()
|
||||
{
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now < NextLink) return LinkGood;
|
||||
NextLink = now + 1000;
|
||||
LinkGood = ERR_Actors.CanFight(Operator) && Drone && vector.Distance(Drone.GetOrigin(), Operator.GetOrigin()) <= 1800
|
||||
&& !ERR_DroneBridge.Jammed(Drone)
|
||||
&& ERR_DroneFlightRules.Clear(Drone.GetOrigin(), Operator.GetOrigin() + Vector(0, 1.5, 0), Drone, Operator);
|
||||
return LinkGood;
|
||||
}
|
||||
void Finish(string reason)
|
||||
{
|
||||
if (Finished) return;
|
||||
Finished = true; Reason = reason;
|
||||
ERR_DroneBridge.Release(Drone); Released = true;
|
||||
ERR_HQ.Event("DRONE RETIRED", reason);
|
||||
}
|
||||
void Advance(float now)
|
||||
{
|
||||
if (Finished) return;
|
||||
if (!Drone) { Finish("airframe removed"); return; }
|
||||
int state = ERR_DroneBridge.State(Drone);
|
||||
if (state == 2) { Finish("player takeover; control released"); return; }
|
||||
if (state == 0 || !ERR_Actors.Alive(Drone)) { Finish("airframe destroyed"); return; }
|
||||
if (!Started)
|
||||
{
|
||||
if (now - Created < 1000) return;
|
||||
if (!ERR_DroneBridge.Start(Drone))
|
||||
{
|
||||
if (now - Created >= 5000) Finish("flight adapter initialization failed");
|
||||
return;
|
||||
}
|
||||
Started = true; LastStep = now; Reason = "taking off";
|
||||
}
|
||||
float dt = Math.Clamp((now - LastStep) / 1000, 0, 0.1); LastStep = now;
|
||||
if (dt <= 0) return;
|
||||
vector position = Drone.GetOrigin();
|
||||
if (now - Created >= 600000) { Finish("flight time limit reached"); return; }
|
||||
if (!ERR_Actors.CanFight(Operator)) Returning = true;
|
||||
if (now - Created > 15000 && !Link()) Returning = true;
|
||||
if (vector.DistanceXZ(position, Search) < 120 && SearchStarted < 0) SearchStarted = now;
|
||||
if (SearchStarted >= 0 && now - SearchStarted >= 120000) Returning = true;
|
||||
if (now - Created >= 360000) Returning = true;
|
||||
vector goal = Search;
|
||||
if (Returning) goal = Home;
|
||||
float ground = GetGame().GetWorld().GetSurfaceY(position[0], position[2]);
|
||||
float altitude = Math.Max(ground, GetGame().GetWorld().GetSurfaceY(goal[0], goal[2])) + 70;
|
||||
vector horizontal = goal - position; horizontal[1] = 0;
|
||||
float distance = horizontal.Length();
|
||||
vector desired = horizontal.Normalized() * Math.Min(8, distance * 0.2);
|
||||
// Clear obstacles vertically before departing; return to the original clear pad.
|
||||
if (now - Created < 20000 && position[1] < Home[1] + 30) desired = vector.Zero;
|
||||
if (Returning && distance < 3) { altitude = Home[1] + 0.15; desired = horizontal * 0.3; }
|
||||
vector ahead = position + desired * 3;
|
||||
if (!(Returning && distance < 3)) altitude = Math.Max(altitude, GetGame().GetWorld().GetSurfaceY(ahead[0], ahead[2]) + 40);
|
||||
desired[1] = Math.Clamp((altitude - position[1]) * 0.6, -2, 3);
|
||||
if (Returning && distance < 3 && position[1] < Home[1] + 0.7 && now - Created > 10000) { Finish("returned to launch site"); return; }
|
||||
if (!Returning && now - Created > 45000 && position[1] < ground + 2) { Finish("unable to take off or flight obstructed"); return; }
|
||||
Physics physics = Drone.GetPhysics();
|
||||
if (!physics) { Finish("physics unavailable"); return; }
|
||||
vector controls = ERR_DroneFlightRules.Controls(physics.GetVelocity(), desired, Drone.GetTransformAxis(2), Drone.GetTransformAxis(0));
|
||||
bool send = now >= NextPacket;
|
||||
if (send) NextPacket = now + 100;
|
||||
if (!ERR_DroneBridge.Step(Drone, controls, dt, send)) { Finish("battery exhausted or control lost"); return; }
|
||||
if (Returning) Reason = "returning";
|
||||
else Reason = "searching reported area";
|
||||
}
|
||||
void Observe(ERR_HQ hq, float now)
|
||||
{
|
||||
if (!Started || Finished || Returning || now < NextScan || now < NextReport) return;
|
||||
NextScan = now + 1000;
|
||||
if (!Link()) return;
|
||||
array<IEntity> candidates = {}; ERR_Actors.Players(candidates, true);
|
||||
if (candidates.IsEmpty()) return;
|
||||
IEntity actor = candidates[Candidate++ % candidates.Count()];
|
||||
// Mounted occupants are not visible through a vehicle's hull. A later
|
||||
// infantry sighting can still dispatch another search at its reported sector.
|
||||
if (!actor || CompartmentAccessComponent.GetVehicleIn(actor)) return;
|
||||
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(actor.FindComponent(FactionAffiliationComponent));
|
||||
if (!affiliation || affiliation.GetAffiliatedFaction() != ERR_GameMode.Get().PlayerFaction()) return;
|
||||
vector point = actor.GetOrigin() + Vector(0, 1.2, 0);
|
||||
if (!ERR_DroneFlightRules.CameraCone(Drone.GetOrigin(), Search, point)) return;
|
||||
if (!ERR_DroneFlightRules.Clear(Drone.GetOrigin(), point, Drone, actor)) return;
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.Target = actor; report.Position = point; report.ObservedAt = now; report.Source = Group;
|
||||
report.TravelMode = "on foot"; report.SeenEscapists = 1; report.DetailsObservedAt = now; report.DroneObservation = true;
|
||||
if (hq.QueueDroneObservation(Group, report, now)) NextReport = now + ERR_GameMode.Get().Settings().Runtime("reportIntervalSeconds") * 1000;
|
||||
}
|
||||
}
|
||||
class ERR_DroneRecon
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref ERR_DroneSchedule m_Schedule = new ERR_DroneSchedule();
|
||||
protected ref ERR_DroneCatalog m_Catalog = new ERR_DroneCatalog();
|
||||
protected ref array<ref ERR_DroneFlight> m_Flights = {};
|
||||
protected int m_Limit, m_Cooldown;
|
||||
protected bool m_Checked, m_Ready, m_Running;
|
||||
protected string m_Reason = "disabled";
|
||||
void ERR_DroneRecon(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode; m_Limit = mode.Settings().Get("droneReconCount"); m_Cooldown = mode.Settings().Get("droneCooldownSeconds");
|
||||
if (m_Limit > 0) m_Reason = "waiting for prison escape";
|
||||
}
|
||||
bool Ready() { return m_Ready; }
|
||||
string Status() { return string.Format("Recon drones: %1/%2 active; %3", m_Flights.Count(), m_Limit, m_Reason); }
|
||||
protected bool Prepare()
|
||||
{
|
||||
if (m_Checked) return m_Ready;
|
||||
m_Checked = true;
|
||||
if (!ERR_DroneBridge.Available()) { m_Reason = "Realistic Combat Drones missing"; return false; }
|
||||
if (!ERR_DroneBridge.Initialize()) { m_Reason = "flight adapter unavailable on this host/version"; return false; }
|
||||
if (!ERR_DroneBridge.Ready()) { m_Reason = "drone manager unavailable"; return false; }
|
||||
m_Catalog.Find();
|
||||
if (m_Catalog.Prefab.IsEmpty()) { m_Reason = "supported unarmed DJI prefab unavailable"; return false; }
|
||||
m_Ready = true; m_Reason = "waiting for a recent HQ sighting";
|
||||
return true;
|
||||
}
|
||||
void Tick(float now)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Schedule.Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) return;
|
||||
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
|
||||
for (int i = m_Flights.Count() - 1; i >= 0; i--)
|
||||
{
|
||||
ERR_DroneFlight flight = m_Flights[i];
|
||||
if (!flight.Counted && flight.Started && flight.Drone && flight.Drone.GetOrigin()[1] > flight.Home[1] + 10)
|
||||
{ flight.Counted = true; m_Schedule.Result(true); }
|
||||
if (flight.Finished)
|
||||
{
|
||||
if (!flight.Counted && flight.Reason != "player takeover; control released") m_Schedule.Result(false);
|
||||
m_Reason = flight.Reason;
|
||||
if (m_Schedule.Failures >= 3) m_Reason = "paused after three launch failures";
|
||||
m_Flights.Remove(i); continue;
|
||||
}
|
||||
flight.Observe(hq, now);
|
||||
}
|
||||
if (m_Limit == 0 || m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return;
|
||||
array<IEntity> players = {}; ERR_Actors.Players(players, true);
|
||||
if (players.IsEmpty() || !Prepare() || !m_Schedule.Due(now, m_Flights.Count(), m_Limit)) return;
|
||||
array<ref ERR_HQReport> contacts = {}; hq.SupportContacts(contacts, now);
|
||||
ERR_HQReport chosen;
|
||||
foreach (ERR_HQReport report : contacts)
|
||||
{
|
||||
if (!report || report.DroneObservation || report.Distress || !report.Source || !m_Schedule.Fresh(report.ObservedAt, now)) continue;
|
||||
bool assigned;
|
||||
foreach (ERR_DroneFlight active : m_Flights) if (active.Group == report.Source || vector.DistanceXZ(active.Search, report.Position) < 250) assigned = true;
|
||||
if (assigned || !ERR_Actors.CanFight(report.Source.GetLeaderEntity())) continue;
|
||||
if (!chosen || report.ObservedAt > chosen.ObservedAt) chosen = report;
|
||||
}
|
||||
if (!chosen) return;
|
||||
m_Schedule.Attempt(chosen.ObservedAt, now, m_Cooldown);
|
||||
bool spawned = Launch(chosen, now, players);
|
||||
if (spawned) m_Schedule.Launched++;
|
||||
else m_Schedule.Result(false);
|
||||
if (m_Schedule.Failures >= 3) m_Reason = "paused after three launch failures";
|
||||
if (m_Schedule.Launched >= 64) m_Reason = "round launch limit reached";
|
||||
}
|
||||
protected bool Launch(ERR_HQReport report, float now, array<IEntity> players)
|
||||
{
|
||||
IEntity caller = report.Source.GetLeaderEntity();
|
||||
if (!ERR_Actors.CanFight(caller) || ERR_HQRadio.Capability(caller) == 0 || vector.DistanceXZ(caller.GetOrigin(), report.Position) > 1200) { m_Reason = "reporting squad has no suitable drone operator"; return false; }
|
||||
vector pad; bool found;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
float angle = Math.PI2 * attempt / 8;
|
||||
vector point = ERR_Actors.Ground(caller.GetOrigin() + Vector(Math.Cos(angle) * 8, 0, Math.Sin(angle) * 8));
|
||||
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue;
|
||||
bool near;
|
||||
foreach (IEntity player : players) if (vector.DistanceXZ(point, player.GetOrigin()) < 100) near = true;
|
||||
if (near) continue;
|
||||
TraceBox trace = new TraceBox(); trace.Start = point + Vector(0, 0.5, 0); trace.End = point + Vector(0, 80, 0);
|
||||
trace.Mins = "-0.5 -0.25 -0.5"; trace.Maxs = "0.5 0.25 0.5"; trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
|
||||
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
|
||||
pad = trace.Start; found = true; break;
|
||||
}
|
||||
if (!found) { m_Reason = "reporting squad has no clear launch pad"; return false; }
|
||||
IEntity drone = ERR_Actors.Spawn(m_Catalog.Prefab, pad);
|
||||
if (!drone) { m_Reason = "airframe creation failed"; return false; }
|
||||
if (!ERR_Actors.SetFaction(drone, m_Mode.EnemyFaction())) { ERR_Actors.DeleteSpawned(drone); m_Reason = "airframe faction unavailable"; return false; }
|
||||
m_Mode.Own(drone);
|
||||
ERR_DroneFlight flight = new ERR_DroneFlight();
|
||||
flight.Drone = drone; flight.Operator = caller; flight.Group = report.Source; flight.Home = pad; flight.Search = report.Position; flight.Created = now;
|
||||
m_Flights.Insert(flight); m_Reason = "responding to reported sightings";
|
||||
if (!m_Running) { m_Running = true; GetGame().GetCallqueue().CallLater(Advance, 50, true); }
|
||||
ERR_HQ.Event("DRONE LAUNCH", string.Format("report M%1; launch=%2; search=%3", report.MessageId, pad, report.Position));
|
||||
return true;
|
||||
}
|
||||
protected void Advance()
|
||||
{
|
||||
if (m_Schedule.Stopped || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active()) { Stop(); return; }
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
foreach (ERR_DroneFlight flight : m_Flights) flight.Advance(now);
|
||||
if (m_Flights.IsEmpty()) { GetGame().GetCallqueue().Remove(Advance); m_Running = false; }
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Schedule.Stopped) return;
|
||||
m_Schedule.Stopped = true; m_Reason = "mission stopped";
|
||||
GetGame().GetCallqueue().Remove(Advance); m_Running = false;
|
||||
foreach (ERR_DroneFlight flight : m_Flights) flight.Finish("mission stopped");
|
||||
m_Flights.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Nearby garages are discovered incrementally. Each callback performs one
|
||||
// survey, maintenance, planning, resource-load or native-spawn step.
|
||||
class ERR_GarageVehicleRules
|
||||
{
|
||||
static bool Garage(string path)
|
||||
{
|
||||
path.ToLower();
|
||||
return path.EndsWith("/garage_e_01.et") || path.EndsWith("/houseaddon_garage_e_01.et");
|
||||
}
|
||||
static bool Envelope(vector low, vector high)
|
||||
{
|
||||
return ERR_ForceRules.FiniteVector(low) && ERR_ForceRules.FiniteVector(high)
|
||||
&& low[0] >= -1.05 && high[0] <= 1.05 && low[2] >= -2.4 && high[2] <= 2.4
|
||||
&& low[1] >= -0.3 && high[1] <= 1.9
|
||||
&& high[0] > low[0] && high[1] > low[1] && high[2] > low[2];
|
||||
}
|
||||
}
|
||||
class ERR_GarageSite
|
||||
{
|
||||
IEntity Garage;
|
||||
bool Consumed;
|
||||
bool Selected;
|
||||
float NextTry;
|
||||
}
|
||||
class ERR_ParkedVehicle
|
||||
{
|
||||
Vehicle Entity;
|
||||
ref ERR_GarageSite Site;
|
||||
vector Origin;
|
||||
float HiddenSince = -1;
|
||||
}
|
||||
class ERR_GarageVehicles
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref array<ref ERR_GarageSite> m_Sites = {};
|
||||
protected ref array<ref ERR_ParkedVehicle> m_Parked = {};
|
||||
protected ref map<string, bool> m_KnownCells = new map<string, bool>();
|
||||
protected ref ERR_MotorbikeCatalog m_Bikes = new ERR_MotorbikeCatalog();
|
||||
protected ref array<ResourceName> m_LoadedNames = {};
|
||||
protected ref array<ref Resource> m_LoadedResources = {};
|
||||
protected ref ERR_GarageSite m_Pending;
|
||||
protected vector m_Position, m_Angles;
|
||||
protected ResourceName m_Prefab;
|
||||
protected bool m_PendingBike;
|
||||
protected int m_Stage, m_Work, m_SiteCursor, m_MaintainCursor, m_PlayerCursor;
|
||||
protected int m_Requested, m_Spawned, m_Claimed, m_BikeCandidate;
|
||||
protected int m_GarageChance, m_SpawnRadius, m_SpawnInterval, m_CleanupRadius;
|
||||
protected float m_NextSpawn, m_NextStep;
|
||||
protected bool m_Stopped, m_Started;
|
||||
int Spawned() { return m_Spawned; }
|
||||
void ERR_GarageVehicles(ERR_GameMode mode) { m_Mode = mode; }
|
||||
protected float Now() { return GetGame().GetWorld().GetWorldTime(); }
|
||||
protected bool Running() { return m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active(); }
|
||||
void Start()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Stopped || m_Started) return;
|
||||
m_Started = true; m_Requested = m_Mode.Settings().Get("garageVehicleCount");
|
||||
m_GarageChance = m_Mode.Settings().Get("garageChancePercent");
|
||||
m_SpawnRadius = m_Mode.Settings().Get("garageRadiusMetres");
|
||||
m_SpawnInterval = m_Mode.Settings().Get("garageSpawnIntervalSeconds") * 1000;
|
||||
m_CleanupRadius = Math.Max(1400, m_SpawnRadius + 400);
|
||||
if (m_Requested <= 0 || m_GarageChance <= 0) { Stop(); return; }
|
||||
m_Bikes.Find();
|
||||
GetGame().GetCallqueue().CallLater(Step, 250, true);
|
||||
}
|
||||
protected bool Observers(array<vector> positions, array<IEntity> actors)
|
||||
{
|
||||
positions.Clear(); actors.Clear();
|
||||
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
|
||||
SCR_EditorManagerCore editors = SCR_EditorManagerCore.Cast(SCR_EditorManagerCore.GetInstance(SCR_EditorManagerCore));
|
||||
foreach (int id : ids)
|
||||
{
|
||||
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(id));
|
||||
if (!controller) return false;
|
||||
SCR_EditorManagerEntity editor; if (editors) editor = editors.GetEditorManager(id);
|
||||
if (editor && editor.IsOpened()) return false;
|
||||
IEntity body = controller.GetControlledEntity();
|
||||
if (body)
|
||||
{
|
||||
if (!ERR_ForceRules.FiniteVector(body.GetOrigin())) return false;
|
||||
positions.Insert(body.GetOrigin()); actors.Insert(body);
|
||||
}
|
||||
if (controller.ERR_ObserverState() != ERR_ObserverReason.NONE)
|
||||
{
|
||||
IEntity watched = controller.ERR_ObservedEntity();
|
||||
if (!watched || !ERR_ForceRules.FiniteVector(watched.GetOrigin())) return false;
|
||||
positions.Insert(watched.GetOrigin()); actors.Insert(watched);
|
||||
}
|
||||
else if (!body) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool Nearby(vector position, array<vector> observers, float radius)
|
||||
{
|
||||
foreach (vector observer : observers) if (vector.DistanceXZ(position, observer) <= radius) return true;
|
||||
return false;
|
||||
}
|
||||
// Sample both ends/sides and roof, not just the hidden centre of a garage.
|
||||
// Open doors and windows can reveal a bay; no distance alone proves it hidden.
|
||||
protected bool Hidden(vector position, vector angles, array<IEntity> actors, IEntity ignored = null)
|
||||
{
|
||||
vector matrix[4]; Math3D.AnglesToMatrix(angles, matrix);
|
||||
foreach (IEntity actor : actors)
|
||||
{
|
||||
if (!actor) return false;
|
||||
array<IEntity> excluded = {actor};
|
||||
IEntity ride = CompartmentAccessComponent.GetVehicleIn(actor); if (ride) excluded.Insert(ride);
|
||||
for (int height = 0; height < 2; height++)
|
||||
for (int x = -1; x <= 1; x++)
|
||||
for (int z = -1; z <= 1; z++)
|
||||
{
|
||||
TraceParam ray = new TraceParam();
|
||||
ray.Start = actor.GetOrigin() + "0 1.7 0";
|
||||
ray.End = position + matrix[0] * (x * 1.1) + matrix[2] * (z * 2.5) + Vector(0, 0.5 + height * 1.3, 0);
|
||||
ray.Flags = TraceFlags.WORLD | TraceFlags.ENTS; ray.LayerMask = TRACE_LAYER_CAMERA;
|
||||
ray.ExcludeArray = excluded;
|
||||
float fraction = GetGame().GetWorld().TraceMove(ray, null);
|
||||
if (!ERR_ForceRules.Finite(fraction) || fraction >= 0.995 || (ignored && ERR_ConvoyRules.PartOf(ray.TraceEnt, ignored))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool Filter(IEntity entity) { return entity && SCR_DestructibleBuildingEntity.Cast(entity); }
|
||||
protected bool Remember(IEntity entity)
|
||||
{
|
||||
if (m_Sites.Count() >= 2048) return false;
|
||||
if (!entity.GetPrefabData() || !ERR_GarageVehicleRules.Garage(entity.GetPrefabData().GetPrefabName())) return true;
|
||||
foreach (ERR_GarageSite site : m_Sites) if (site.Garage == entity) return true;
|
||||
ERR_GarageSite added = new ERR_GarageSite(); added.Garage = entity;
|
||||
added.Selected = Math.RandomInt(0, 100) < m_GarageChance;
|
||||
m_Sites.Insert(added);
|
||||
return true;
|
||||
}
|
||||
protected string CellKey(vector point) { return Math.Floor(point[0] / 500).ToString() + ":" + Math.Floor(point[2] / 500).ToString(); }
|
||||
protected void Discover(array<vector> observers, float now)
|
||||
{
|
||||
if (observers.IsEmpty() || m_Sites.Count() >= 2048 || m_KnownCells.Count() >= 16384) return;
|
||||
// Round-robin observers, nearest unvisited cell first. Each update queries
|
||||
// at most one 500m cell; teammates share already scanned cells.
|
||||
if (m_PlayerCursor >= observers.Count()) m_PlayerCursor = 0;
|
||||
vector observer = observers[m_PlayerCursor++];
|
||||
int cx = Math.Floor(observer[0] / 500), cz = Math.Floor(observer[2] / 500);
|
||||
for (int ring = 0; ring <= Math.Ceil(m_SpawnRadius / 500.0); ring++)
|
||||
for (int x = -ring; x <= ring; x++)
|
||||
for (int z = -ring; z <= ring; z++)
|
||||
{
|
||||
if (Math.Max(Math.AbsInt(x), Math.AbsInt(z)) != ring) continue;
|
||||
vector low = Vector((cx + x) * 500, 0, (cz + z) * 500);
|
||||
string key = CellKey(low);
|
||||
if (m_KnownCells.Contains(key) || !Nearby(low + "250 0 250", observers, m_CleanupRadius)) continue;
|
||||
m_KnownCells.Set(key, true);
|
||||
vector boundsLow, boundsHigh; GetGame().GetWorldEntity().GetWorldBounds(boundsLow, boundsHigh);
|
||||
low[1] = boundsLow[1] - 100; vector high = low + "500 0 500"; high[1] = boundsHigh[1] + 100;
|
||||
GetGame().GetWorld().QueryEntitiesByAABB(low, high, Remember, Filter, EQueryEntitiesFlags.ALL);
|
||||
return;
|
||||
}
|
||||
}
|
||||
protected bool InUse(ERR_ParkedVehicle parked)
|
||||
{
|
||||
if (!parked.Entity) return false;
|
||||
// Movement also protects a towed, pushed or otherwise used car, even if a
|
||||
// compartment callback was missed. Never reclaim somebody's transport.
|
||||
if (vector.Distance(parked.Entity.GetOrigin(), parked.Origin) > 3) return true;
|
||||
array<BaseCompartmentSlot> seats = {};
|
||||
if (!ERR_ConvoyRules.CollectSeats(parked.Entity, seats)) return true;
|
||||
foreach (BaseCompartmentSlot seat : seats) if (seat.GetOccupant()) return true;
|
||||
return false;
|
||||
}
|
||||
protected void Claim(int index)
|
||||
{
|
||||
ERR_ParkedVehicle parked = m_Parked[index];
|
||||
parked.Site.Consumed = true;
|
||||
if (parked.Entity) m_Mode.Own(parked.Entity);
|
||||
m_Claimed++; m_Parked.Remove(index);
|
||||
}
|
||||
void PlayerEntered(IEntity actor, IEntity entered)
|
||||
{
|
||||
if (m_Stopped || !Replication.IsServer() || !ERR_Actors.Player(actor)) return;
|
||||
for (int i = m_Parked.Count() - 1; i >= 0; i--)
|
||||
if (m_Parked[i].Entity && ERR_ConvoyRules.PartOf(entered, m_Parked[i].Entity)) Claim(i);
|
||||
}
|
||||
protected void Maintain(array<vector> observers, array<IEntity> actors, float now, bool known)
|
||||
{
|
||||
if (m_Parked.IsEmpty()) return;
|
||||
if (m_MaintainCursor >= m_Parked.Count()) m_MaintainCursor = 0;
|
||||
int index = m_MaintainCursor++; ERR_ParkedVehicle parked = m_Parked[index];
|
||||
if (!parked.Entity) { parked.Site.Consumed = true; m_Parked.Remove(index); return; }
|
||||
if (InUse(parked)) { Claim(index); return; }
|
||||
if (!known || !ERR_CivilianTrafficRules.Far(parked.Entity.GetOrigin(), observers, m_CleanupRadius)
|
||||
|| !Hidden(parked.Entity.GetOrigin(), parked.Entity.GetYawPitchRoll(), actors, parked.Entity)) { parked.HiddenSince = -1; return; }
|
||||
if (parked.HiddenSince < 0) parked.HiddenSince = now;
|
||||
if (!ERR_CivilianTrafficRules.CleanupDue(now, parked.HiddenSince)) return;
|
||||
// Recheck occupancy immediately before deletion. Claimed cars are no longer
|
||||
// in this collection and cannot pass through the retirement path.
|
||||
if (InUse(parked)) { Claim(index); return; }
|
||||
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(parked.Entity);
|
||||
if (garbage) garbage.UpdateBlacklist(parked.Entity, false);
|
||||
ERR_Actors.DeleteSpawned(parked.Entity); m_Parked.Remove(index);
|
||||
}
|
||||
protected bool Occupied(ERR_GarageSite site)
|
||||
{
|
||||
foreach (ERR_ParkedVehicle parked : m_Parked) if (parked.Site == site) return true;
|
||||
return false;
|
||||
}
|
||||
protected void Plan(array<vector> observers, array<IEntity> actors, float now)
|
||||
{
|
||||
if (observers.IsEmpty() || now < m_NextSpawn || m_Parked.Count() >= m_Requested || m_Parked.Count() + m_Claimed >= 128 || m_Sites.IsEmpty()) return;
|
||||
// Bounded planning work, even after a long tour of a large terrain.
|
||||
for (int candidate = 0; candidate < 8; candidate++)
|
||||
{
|
||||
if (m_SiteCursor >= m_Sites.Count()) m_SiteCursor = 0;
|
||||
ERR_GarageSite site = m_Sites[m_SiteCursor++];
|
||||
if (!site.Selected || site.Consumed || !site.Garage || now < site.NextTry || Occupied(site)) continue;
|
||||
vector position = site.Garage.CoordToParent("0 0 -0.5");
|
||||
if (!Nearby(position, observers, m_SpawnRadius)) continue;
|
||||
site.NextTry = now + 15000;
|
||||
if (!ERR_CivilianTrafficRules.Far(position, observers, 35) || !ERR_Actors.Alive(site.Garage)) continue;
|
||||
vector transform[4]; site.Garage.GetWorldTransform(transform);
|
||||
TraceParam floor = new TraceParam(); floor.Start = position + "0 1 0"; floor.End = position - "0 1 0";
|
||||
floor.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
|
||||
float fraction = GetGame().GetWorld().TraceMove(floor, null);
|
||||
if (!ERR_ForceRules.Finite(fraction) || fraction >= 1 || floor.TraceNorm[1] < 0.98) return;
|
||||
position = vector.Lerp(floor.Start, floor.End, fraction) + "0 0.08 0";
|
||||
vector angles = site.Garage.GetYawPitchRoll();
|
||||
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position) || !Clear(position, transform) || !Hidden(position, angles, actors)) return;
|
||||
m_Pending = site; m_Position = position; m_Angles = angles;
|
||||
m_Prefab = ERR_CivilianTraffic.CarPrefab(Math.RandomInt(0, 2));
|
||||
m_PendingBike = m_Spawned % 4 == 3 && m_BikeCandidate < m_Bikes.Prefabs.Count();
|
||||
if (m_PendingBike) m_Prefab = m_Bikes.Prefabs[m_BikeCandidate];
|
||||
m_Stage = 1; return;
|
||||
}
|
||||
}
|
||||
protected void Pending(array<vector> observers, array<IEntity> actors)
|
||||
{
|
||||
if (m_Stage == 1)
|
||||
{
|
||||
if (!m_LoadedNames.Contains(m_Prefab))
|
||||
{
|
||||
Resource resource = Resource.Load(m_Prefab);
|
||||
if (!resource || !resource.IsValid()) { if (m_PendingBike) m_BikeCandidate++; m_Pending = null; return; }
|
||||
m_LoadedNames.Insert(m_Prefab); m_LoadedResources.Insert(resource);
|
||||
}
|
||||
m_Stage = 2; return;
|
||||
}
|
||||
ERR_GarageSite site = m_Pending; m_Pending = null;
|
||||
if (!site.Garage || site.Consumed || !ERR_Actors.Alive(site.Garage) || !Nearby(m_Position, observers, m_SpawnRadius)
|
||||
|| !ERR_CivilianTrafficRules.Far(m_Position, observers, 35) || !Hidden(m_Position, m_Angles, actors)) return;
|
||||
vector transform[4]; Math3D.AnglesToMatrix(m_Angles, transform);
|
||||
if (!Clear(m_Position, transform)) return;
|
||||
m_NextSpawn = Now() + m_SpawnInterval;
|
||||
IEntity entity = ERR_Actors.Spawn(m_Prefab, m_Position, m_Angles);
|
||||
Vehicle vehicle = Vehicle.Cast(entity);
|
||||
vector low, high; if (vehicle) vehicle.GetBounds(low, high);
|
||||
array<BaseCompartmentSlot> seats = {}; bool driver; bool armed;
|
||||
if (vehicle && ERR_ConvoyRules.CollectSeats(vehicle, seats))
|
||||
foreach (BaseCompartmentSlot seat : seats)
|
||||
{
|
||||
if (seat.GetType() == ECompartmentType.TURRET) armed = true;
|
||||
if (seat.GetType() == ECompartmentType.PILOT && seat.IsCompartmentAccessible() && !seat.GetOccupant()) driver = true;
|
||||
}
|
||||
if (!driver || armed || !ERR_GarageVehicleRules.Envelope(low, high) || !Clear(m_Position, transform, entity))
|
||||
{
|
||||
ERR_Actors.DeleteSpawned(entity); if (m_PendingBike) m_BikeCandidate++; return;
|
||||
}
|
||||
Faction civilian = GetGame().GetFactionManager().GetFactionByKey("CIV");
|
||||
if (civilian) ERR_Actors.SetFaction(vehicle, civilian);
|
||||
SCR_FuelManagerComponent fuel = SCR_FuelManagerComponent.Cast(vehicle.FindComponent(SCR_FuelManagerComponent));
|
||||
if (fuel) fuel.SetTotalFuelPercentage(Math.RandomFloat(0.3, 0.8));
|
||||
ERR_ParkedVehicle parked = new ERR_ParkedVehicle(); parked.Entity = vehicle; parked.Site = site; parked.Origin = m_Position;
|
||||
m_Parked.Insert(parked); m_Spawned++;
|
||||
SCR_GarbageSystem garbage = SCR_GarbageSystem.GetByEntityWorld(vehicle);
|
||||
if (garbage) garbage.UpdateBlacklist(vehicle, true);
|
||||
}
|
||||
protected void Step()
|
||||
{
|
||||
if (m_Stopped || !Replication.IsServer() || !Running()) return;
|
||||
float now = Now();
|
||||
if (now < m_NextStep) return;
|
||||
m_NextStep = now + 250; // Do not catch up missed work in one slow frame.
|
||||
array<vector> observers = {}; array<IEntity> actors = {};
|
||||
bool known = Observers(observers, actors);
|
||||
if (!known)
|
||||
{
|
||||
m_Pending = null;
|
||||
// Unknown cameras invalidate every retirement grace period immediately.
|
||||
foreach (ERR_ParkedVehicle parked : m_Parked) parked.HiddenSince = -1;
|
||||
Maintain(observers, actors, now, false); return;
|
||||
}
|
||||
if (m_Pending) { Pending(observers, actors); return; }
|
||||
m_Work = (m_Work + 1) % 3;
|
||||
if (m_Work == 0) Maintain(observers, actors, now, true);
|
||||
else if (m_Work == 1) Discover(observers, now);
|
||||
else Plan(observers, actors, now);
|
||||
}
|
||||
// Filled oriented body volume includes furniture, doors, other vehicles and
|
||||
// players. The garage itself is deliberately NOT excluded from collision.
|
||||
protected bool Clear(vector position, vector transform[4], IEntity ignored = null)
|
||||
{
|
||||
TraceOBB body = new TraceOBB();
|
||||
body.Start = position; body.End = position + "0 0.01 0";
|
||||
body.Mins = "-1.1 0.12 -2.5"; body.Maxs = "1.1 2 2.5";
|
||||
for (int i = 0; i < 3; i++) body.Mat[i] = transform[i];
|
||||
body.Flags = TraceFlags.WORLD | TraceFlags.ENTS | TraceFlags.OCEAN;
|
||||
body.Exclude = ignored;
|
||||
float fraction = GetGame().GetWorld().TraceMove(body, null);
|
||||
return ERR_ForceRules.Finite(fraction) && fraction >= 1;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
m_Stopped = true;
|
||||
if (GetGame()) GetGame().GetCallqueue().Remove(Step);
|
||||
m_Pending = null; m_Sites.Clear();
|
||||
// No visible deletion at round end. World teardown owns remaining vehicles.
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ class ERR_HQ : ERR_CommandService
|
||||
ERR_AirAssaultSystem AirAssault() { return m_AirAssault; }
|
||||
protected ref ERR_AirReconSystem m_Recon;
|
||||
protected ref ERR_PlaneRecon m_PlaneRecon;
|
||||
protected ref ERR_DroneRecon m_DroneRecon;
|
||||
ERR_DroneRecon DroneRecon() { return m_DroneRecon; }
|
||||
ERR_PlaneRecon PlaneRecon() { return m_PlaneRecon; }
|
||||
ERR_AirReconSystem Recon() { return m_Recon; }
|
||||
ERR_ScoutSystem Scouts() { return m_Scouts; }
|
||||
@@ -173,12 +175,14 @@ class ERR_HQ : ERR_CommandService
|
||||
m_Hunters = new ERR_HunterService(mode, null);
|
||||
m_Hunters.ConfigureOpening(mode.Settings().Get("hunterSpawnDistanceMetres"));
|
||||
m_Hunters.ConfigureMotorbikes(mode.Settings().Get("motorbikeCount"));
|
||||
m_Hunters.ConfigureSarka();
|
||||
m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this);
|
||||
m_Towns = new ERR_TownGarrisons(mode);
|
||||
m_Roadblocks = new ERR_Roadblocks(mode);
|
||||
m_Scouts = new ERR_ScoutSystem(mode);
|
||||
m_Recon = new ERR_AirReconSystem(mode);
|
||||
m_PlaneRecon = new ERR_PlaneRecon(mode);
|
||||
m_DroneRecon = new ERR_DroneRecon(mode);
|
||||
m_AirAssault = new ERR_AirAssaultSystem(this, mode);
|
||||
m_Airfield = new ERR_AirfieldSupport(this, mode); m_Recovery = new ERR_BodyRecovery(this, mode);
|
||||
bool legacyFixture;
|
||||
@@ -503,6 +507,24 @@ class ERR_HQ : ERR_CommandService
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// A camera observation goes through the physical operator's existing radio
|
||||
// route and delivery queue. It never writes directly into HQ memory.
|
||||
bool QueueDroneObservation(SCR_AIGroup group, ERR_HQReport report, float now)
|
||||
{
|
||||
if (!Replication.IsServer() || !group || !report || !report.DroneObservation || m_Mode.Phase() < ERR_Phase.RADIO_SEARCH) return false;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group || unit.Uplink || !unit.Radio || !ERR_Actors.CanFight(unit.Radio)) continue;
|
||||
ERR_HQRoute route = ObservationRoute(unit);
|
||||
if (!route) return false;
|
||||
report.MessageId = m_Journal.Message(); report.Source = group;
|
||||
route.MessageId = report.MessageId; route.Payload = "drone camera; " + report.Description(now);
|
||||
unit.PendingReport = report.Snapshot(); unit.Uplink = route;
|
||||
m_Journal.Write("DRONE REPORT", route.Payload, report.MessageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void SupportContacts(out array<ref ERR_HQReport> contacts, float now)
|
||||
{
|
||||
contacts.Clear();
|
||||
@@ -536,10 +558,11 @@ class ERR_HQ : ERR_CommandService
|
||||
ERR_HQJournal Journal() { return m_Journal; }
|
||||
string Status()
|
||||
{
|
||||
return string.Format("Enemy HQ: %1 registered groups; %2 received sightings; %3 assigned responses.", m_Units.Count(), m_Contacts.Count(), Assigned())
|
||||
string status = string.Format("Enemy HQ: %1 registered groups; %2 received sightings; %3 assigned responses.", m_Units.Count(), m_Contacts.Count(), Assigned())
|
||||
+ string.Format("\nHQ memory: %1 tracked reports; %2 searched areas.\nAggression: %3 of 30, based on %4 identified bodies (one level per three bodies).", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies())
|
||||
+ "\n" + m_Hunters.Status() + "\n" + m_Mode.Forces().Status() + "\n" + m_Mortars.Status()
|
||||
+ "\n" + m_Logistics.Status() + "\n" + m_Resupply.Status() + "\n" + m_Recon.Status() + "\n" + m_PlaneRecon.Status();
|
||||
return status + "\n" + m_DroneRecon.Status();
|
||||
}
|
||||
|
||||
protected void BindPhysical(ERR_HQUnit unit)
|
||||
@@ -1741,9 +1764,11 @@ class ERR_HQ : ERR_CommandService
|
||||
|
||||
override void Maintain(float now)
|
||||
{
|
||||
m_Hunters.PauseSarkaIfEmpty();
|
||||
// Aircraft already airborne keep flying even when the server is empty.
|
||||
m_Recon.Tick(now);
|
||||
m_PlaneRecon.Tick(now);
|
||||
m_DroneRecon.Tick(now);
|
||||
m_AirAssault.Maintain(now);
|
||||
m_Airfield.Maintain(now);
|
||||
}
|
||||
@@ -1878,6 +1903,7 @@ class ERR_HQ : ERR_CommandService
|
||||
m_AirAssault.Stop();
|
||||
m_Recon.Stop();
|
||||
m_PlaneRecon.Stop();
|
||||
m_DroneRecon.Stop();
|
||||
m_Scouts.Stop();
|
||||
m_ConvoyStartup.Stop();
|
||||
if (m_SupplyConvoys) m_SupplyConvoys.Stop();
|
||||
|
||||
@@ -103,6 +103,7 @@ class ERR_HQSuppressActivity : SCR_AISuppressActivity
|
||||
}
|
||||
}
|
||||
|
||||
[BaseContainerProps()]
|
||||
modded class SCR_AIGoalReaction_Suppress
|
||||
{
|
||||
override void PerformReaction(notnull SCR_AIUtilityComponent utility, SCR_AIMessageBase message)
|
||||
|
||||
@@ -51,6 +51,7 @@ class ERR_HQReport
|
||||
bool HasVelocity;
|
||||
bool Engaged;
|
||||
bool Distress;
|
||||
bool DroneObservation;
|
||||
int SeenEscapists = -1;
|
||||
string TravelMode = "unknown";
|
||||
string RoadStatus = "road unknown";
|
||||
@@ -72,6 +73,7 @@ class ERR_HQReport
|
||||
copy.ObservedAt = ObservedAt;
|
||||
copy.Engaged = Engaged;
|
||||
copy.Distress = Distress;
|
||||
copy.DroneObservation = DroneObservation;
|
||||
copy.HasVelocity = HasVelocity;
|
||||
copy.SeenEscapists = SeenEscapists;
|
||||
copy.TravelMode = TravelMode;
|
||||
|
||||
@@ -14,6 +14,20 @@ class ERR_VehicleBehaviorCleanup
|
||||
|
||||
// Preserve original hunter actors. Reboarding uses native AI tasks, never new
|
||||
// crew, vehicle teleports, health restoration, or ammunition replacement.
|
||||
// Readiness failures share one deadline even if the reason changes or combat
|
||||
// briefly interrupts reboarding. Native combat and moving vehicles defer exit.
|
||||
class ERR_HunterRecoveryWait
|
||||
{
|
||||
float Since = -1;
|
||||
void Reset() { Since = -1; }
|
||||
bool Expired(bool registered, bool ready, bool combat, float speed, float now)
|
||||
{
|
||||
if (!registered || ready) { Reset(); return false; }
|
||||
if (Since < 0 || now < Since) Since = now;
|
||||
return now - Since >= 120000 && !combat && speed >= 0 && speed <= 3;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HunterCrewRecovery
|
||||
{
|
||||
protected ERR_HunterVehicle m_Unit;
|
||||
@@ -22,6 +36,7 @@ class ERR_HunterCrewRecovery
|
||||
protected float m_NextAttempt;
|
||||
protected string m_LastReason;
|
||||
protected bool m_Stopped;
|
||||
ref ERR_HunterRecoveryWait Wait = new ERR_HunterRecoveryWait();
|
||||
string Reason;
|
||||
void ERR_HunterCrewRecovery(ERR_HunterVehicle unit) { m_Unit = unit; }
|
||||
void SuspendBoarding()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Wave size is sampled from delivered body-driven aggression every ten minutes.
|
||||
// Failed placements remain pending in this wave, never accumulate into a backlog.
|
||||
class ERR_SarkaWaves
|
||||
{
|
||||
float NextWave = -1;
|
||||
float NextAttempt;
|
||||
int Pending;
|
||||
int Target;
|
||||
int Wave;
|
||||
int Spawned;
|
||||
int AngerPerCar = 3;
|
||||
void Configure(int points)
|
||||
{
|
||||
if (AngerPerCar == points) return;
|
||||
AngerPerCar = points;
|
||||
if (points == 0) Pause();
|
||||
}
|
||||
void Pause() { NextWave = -1; Pending = 0; Target = 0; }
|
||||
bool Due(float now, int aggression, bool playing)
|
||||
{
|
||||
if (!playing || AngerPerCar == 0) { Pause(); return false; }
|
||||
if (NextWave < 0) { NextWave = now + 600000; return false; }
|
||||
if (now >= NextWave)
|
||||
{
|
||||
NextWave = now + 600000; NextAttempt = now;
|
||||
// Whole thresholds only: five points per car means zero at four, one at five.
|
||||
Target = Math.Clamp(aggression, 0, 30) / AngerPerCar; Pending = Target; Wave++;
|
||||
}
|
||||
if (Pending == 0 || now < NextAttempt) return false;
|
||||
NextAttempt = now + 5000;
|
||||
return true;
|
||||
}
|
||||
void Commit() { if (Pending > 0) { Pending--; Spawned++; } }
|
||||
}
|
||||
|
||||
class ERR_SarkaRules
|
||||
{
|
||||
static ResourceName Prefab() { return "{AD80FB47D868C775}Prefabs/Vehicles/Wheeled/S105/S105_green.et"; }
|
||||
static bool Seats(int drivers, int turrets, int cargo, int total)
|
||||
{
|
||||
return drivers == 1 && turrets == 0 && cargo == 3 && total == 4;
|
||||
}
|
||||
static bool InBand(vector position, array<vector> players)
|
||||
{
|
||||
if (players.IsEmpty() || !ERR_ForceRules.FiniteVector(position)) return false;
|
||||
float nearest = 1000000;
|
||||
foreach (vector player : players)
|
||||
{
|
||||
if (!ERR_ForceRules.FiniteVector(player)) return false;
|
||||
nearest = Math.Min(nearest, vector.DistanceXZ(position, player));
|
||||
}
|
||||
return nearest >= 1000 && nearest <= 1250;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
|
||||
float PendingObservedAt = -1;
|
||||
bool TroopTransport;
|
||||
bool Motorbike;
|
||||
bool Sarka;
|
||||
bool OpeningPatrol;
|
||||
vector PatrolHome;
|
||||
bool RadioRequested;
|
||||
@@ -80,6 +81,7 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
|
||||
bool DeliveredContact;
|
||||
ref ERR_HunterCrewRecovery Recovery;
|
||||
ref ERR_HunterTactics Tactics;
|
||||
ref ERR_HunterApproachMemory FailedApproaches = new ERR_HunterApproachMemory();
|
||||
ref ERR_HunterCloseContact CloseContact;
|
||||
bool CloseContactHeld;
|
||||
bool CombatHeld;
|
||||
@@ -136,6 +138,98 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
protected int m_ReserveSpent;
|
||||
protected float m_NextReserve;
|
||||
protected ref ERR_HunterVehicle m_PendingReserve;
|
||||
protected ref ERR_SarkaWaves m_SarkaWaves = new ERR_SarkaWaves();
|
||||
protected string m_SarkaStatus = "waiting for first wave after prison escape";
|
||||
void ConfigureSarka()
|
||||
{
|
||||
int points = m_Mode.Settings().Get("sarkaAngerPerCar");
|
||||
bool changed = points != m_SarkaWaves.AngerPerCar;
|
||||
m_SarkaWaves.Configure(points);
|
||||
if (changed) m_SarkaStatus = "saved anger threshold applies to the next wave";
|
||||
if (points == 0) m_SarkaStatus = "new waves disabled; existing teams remain";
|
||||
}
|
||||
string SarkaStatus()
|
||||
{
|
||||
string ratio = "New waves disabled.";
|
||||
if (m_SarkaWaves.AngerPerCar > 0) ratio = string.Format("One car per %1 anger points (rounded down).", m_SarkaWaves.AngerPerCar);
|
||||
return string.Format("Sarka waves: %1 deployed, %2/%3 pending in wave %4; %5. %6", m_SarkaWaves.Spawned, m_SarkaWaves.Pending, m_SarkaWaves.Target, m_SarkaWaves.Wave, m_SarkaStatus, ratio);
|
||||
}
|
||||
// HQ maintenance runs even while tactical ticks pause in an empty round.
|
||||
void PauseSarkaIfEmpty()
|
||||
{
|
||||
if (m_Stopped || !m_Mode || !m_Mode.Active()) return;
|
||||
array<IEntity> players = {}; ERR_Actors.Players(players);
|
||||
if (players.IsEmpty()) m_SarkaWaves.Pause();
|
||||
}
|
||||
protected void TickSarka(ERR_HQ hq, float now)
|
||||
{
|
||||
ConfigureSarka();
|
||||
// Released crews keep their physical/HQ ownership; discard only wave tracking.
|
||||
for (int retired = m_Hunters.Count() - 1; retired >= 0; retired--)
|
||||
if (m_Hunters[retired].Sarka && m_Hunters[retired].Released) m_Hunters.Remove(retired);
|
||||
array<IEntity> players = {}; ERR_Actors.Players(players);
|
||||
int previousWave = m_SarkaWaves.Wave;
|
||||
bool due = m_SarkaWaves.Due(now, hq.Watch().Aggression(), !players.IsEmpty());
|
||||
if (previousWave != m_SarkaWaves.Wave)
|
||||
{
|
||||
m_SarkaStatus = "placing four-person special-forces teams on roads";
|
||||
if (m_SarkaWaves.Target == 0) m_SarkaStatus = "no aggression; waiting for the next interval";
|
||||
ERR_HQ.Event("SARKA WAVE", string.Format("wave=%1 aggression=%2 cars=%3 identified bodies=%4", m_SarkaWaves.Wave, hq.Watch().Aggression(), m_SarkaWaves.Target, hq.Watch().IdentifiedBodies()));
|
||||
}
|
||||
if (!due) return;
|
||||
int awaiting;
|
||||
foreach (ERR_HunterVehicle pending : m_Hunters) if (!pending.Released && !pending.Registered) awaiting++;
|
||||
if (m_Mode.Forces().Count() + awaiting >= 250) { m_SarkaStatus = "waiting for formation budget"; return; }
|
||||
if (!ERR_EnemyLoadoutCatalog.Available(ERR_EnemyMedicalCatalog.Crew(m_Mode, false)) || !ERR_EnemyLoadoutCatalog.Available(ERR_EnemyMedicalCatalog.Crew(m_Mode, true)))
|
||||
{ m_SarkaStatus = "selected faction lacks response crew"; return; }
|
||||
array<vector> positions = {};
|
||||
foreach (IEntity player : players) positions.Insert(player.GetOrigin());
|
||||
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
vector offset = Vector(Math.RandomFloat(-1, 1), 0, Math.RandomFloat(-1, 1));
|
||||
if (offset.Length() < 0.1) continue;
|
||||
vector seed = positions[Math.RandomInt(0, positions.Count())] + offset.Normalized() * 1125;
|
||||
vector position, angles;
|
||||
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles, 150) || !ERR_SarkaRules.InBand(position, positions) || !placement.Clear(position, angles[0])) continue;
|
||||
bool occupied;
|
||||
foreach (ERR_HunterVehicle other : m_Hunters)
|
||||
if (other.Entity && vector.DistanceXZ(position, other.Entity.GetOrigin()) < 60) occupied = true;
|
||||
if (occupied) continue;
|
||||
if (SpawnSarka(now, position, angles))
|
||||
{
|
||||
m_SarkaWaves.Commit();
|
||||
m_SarkaStatus = "four-person teams join HQ hunters; departures spaced five seconds apart";
|
||||
}
|
||||
else m_SarkaStatus = "vehicle or crew setup failed; retrying this wave";
|
||||
return;
|
||||
}
|
||||
m_SarkaStatus = "waiting for a clear road away from players";
|
||||
}
|
||||
protected bool SpawnSarka(float now, vector position, vector angles)
|
||||
{
|
||||
ERR_HunterVehicle unit = new ERR_HunterVehicle();
|
||||
unit.Sarka = true; unit.TroopTransport = true; unit.OpeningPatrol = true; unit.PatrolHome = position;
|
||||
unit.Source = "aggression-sarka-" + (m_SarkaWaves.Spawned + 1).ToString();
|
||||
IEntity spawned = ERR_Actors.Spawn(ERR_SarkaRules.Prefab(), position, angles);
|
||||
unit.Entity = Vehicle.Cast(spawned);
|
||||
if (!unit.Entity || !unit.Entity.FindComponent(AICarMovementComponent) || !unit.Entity.FindComponent(SCR_AIVehicleUsageComponent))
|
||||
{ ERR_Actors.DeleteSpawned(spawned); return false; }
|
||||
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position));
|
||||
if (!unit.Group || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
|
||||
{
|
||||
foreach (IEntity failed : unit.Members) ERR_Actors.DeleteSpawned(failed);
|
||||
ERR_Actors.DeleteSpawned(unit.Group); ERR_Actors.DeleteSpawned(unit.Entity); return false;
|
||||
}
|
||||
m_Mode.Own(unit.Entity); m_Mode.Own(unit.Group);
|
||||
ERR_Actors.SetFaction(unit.Entity, m_Mode.EnemyFaction());
|
||||
unit.LaunchedAt = now; unit.Deadline = now + 120000; m_Hunters.Insert(unit);
|
||||
unit.Recovery = new ERR_HunterCrewRecovery(unit); unit.Recovery.Update(m_Mode, now);
|
||||
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
|
||||
movement.SetCruiseSpeed(45);
|
||||
ERR_HQ.Event("SARKA SPAWN", string.Format("%1 wave=%2 crew=%3 position=%4; four original response soldiers including driver and medic", unit.Source, m_SarkaWaves.Wave, unit.Members.Count(), position));
|
||||
return true;
|
||||
}
|
||||
protected int m_OpeningDistance = 1000;
|
||||
// Called once when the round's HQ is created; menu edits affect the next HQ.
|
||||
void ConfigureOpening(int distance)
|
||||
@@ -275,7 +369,6 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
if (!m_Mode.Active() || !actor || !entered || !m_Mode.Players()) return;
|
||||
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command());
|
||||
if (!hq || hq.Hunters() != this || !m_Mode.Players().CurrentActor(actor)) return;
|
||||
if (m_Hunters.Count() > 21) return;
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters)
|
||||
{
|
||||
// Released survivors already have independent HQ ownership. Do not revoke
|
||||
@@ -304,7 +397,7 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
int Deployed()
|
||||
{
|
||||
int count;
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Motorbike) count++;
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Motorbike && !unit.Sarka) count++;
|
||||
return count;
|
||||
}
|
||||
int ActiveHunters()
|
||||
@@ -324,7 +417,7 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
{
|
||||
int count;
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters)
|
||||
if (!unit.Motorbike && !ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) count++;
|
||||
if (!unit.Motorbike && !unit.Sarka && !ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) count++;
|
||||
return count;
|
||||
}
|
||||
protected ERR_HunterVehicle ReserveSource(ERR_HQ hq, float now)
|
||||
@@ -332,7 +425,7 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
if (!ERR_HunterFleetRules.ReserveDue(Deployed(), m_Target, m_ReserveSpent, AvailableToHQ(hq, now), m_NextReserve, now)) return null;
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters)
|
||||
{
|
||||
if (unit.Motorbike || unit.ReserveCovered || now - unit.LaunchedAt < 480000) continue;
|
||||
if (unit.Motorbike || unit.Sarka || unit.ReserveCovered || now - unit.LaunchedAt < 480000) continue;
|
||||
if (ERR_HunterFleetRules.Unavailable(hq.Watch().Find(unit.Source), unit.LaunchedAt, now)) return unit;
|
||||
}
|
||||
return null;
|
||||
@@ -344,7 +437,13 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
ERR_HQ.Event("HUNTER RESERVE", string.Format("%1 replaces unavailable patrol %2; reserve=%3/3; next departure in at least 480s; silence is not a confirmed death", unit.Source, m_PendingReserve.Source, m_ReserveSpent));
|
||||
m_PendingReserve = null;
|
||||
}
|
||||
override string Status() { return string.Format("HQ hunting patrols: %1 active including bikes; %2 UAZ/trucks deployed; aggression allowance %3, reserve used %4/3 (15 total maximum). Reserve departures at least eight minutes apart. Motorbikes have a separate allowance.", ActiveHunters(), Deployed(), m_Target, m_ReserveSpent); }
|
||||
override string Status()
|
||||
{
|
||||
// Keep the formatted patrol summary before calling another formatting method.
|
||||
string patrols = string.Format("HQ hunting patrols: %1 active including bikes and Sarkas; %2 UAZ/trucks deployed; aggression allowance %3, reserve used %4/3 (15 total maximum). Reserve departures at least eight minutes apart. Motorbikes have a separate allowance.", ActiveHunters(), Deployed(), m_Target, m_ReserveSpent);
|
||||
string sarka = SarkaStatus();
|
||||
return patrols + "\n" + sarka;
|
||||
}
|
||||
protected bool StartHunter(ERR_HQ hq, float now)
|
||||
{
|
||||
if ((!m_PendingReserve && Deployed() >= m_Target + m_ReserveSpent) || Deployed() >= 15 || m_Mode.Forces().Count() >= 250) return false;
|
||||
@@ -410,6 +509,10 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
{
|
||||
if (!ERR_MotorbikeRules.Seats(drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
|
||||
}
|
||||
else if (hunter.Sarka)
|
||||
{
|
||||
if (!ERR_SarkaRules.Seats(drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
|
||||
}
|
||||
else if (!ERR_HunterFleetRules.Seats(hunter.TroopTransport, drivers.Count(), turrets.Count(), cargo.Count(), all.Count())) return false;
|
||||
unit.Seats.Insert(drivers[0]);
|
||||
foreach (BaseCompartmentSlot turret : turrets) unit.Seats.Insert(turret);
|
||||
@@ -653,6 +756,7 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
}
|
||||
m_PendingReserve = null;
|
||||
TickMotorbikes(hq, now);
|
||||
TickSarka(hq, now);
|
||||
if (!m_Coordination) m_Coordination = new ERR_HunterCoordination(m_Mode);
|
||||
m_Coordination.Tick(hq, m_Hunters, now);
|
||||
foreach (ERR_HunterVehicle unit : m_Hunters)
|
||||
@@ -667,10 +771,22 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
// A delivered manoeuvre owns dismount/reboarding. The ordinary recovery
|
||||
// must not re-add its vehicle while the team is moving on foot.
|
||||
if (!unit.CloseContact) unit.CloseContact = new ERR_HunterCloseContact(unit);
|
||||
if (unit.CloseContact.Tick(m_Mode, now)) continue;
|
||||
if (unit.Tactics && unit.Tactics.Tick(hq, now)) continue;
|
||||
if (unit.CloseContact.Tick(m_Mode, now))
|
||||
{ if (unit.Recovery) unit.Recovery.Wait.Reset(); continue; }
|
||||
if (unit.Tactics && unit.Tactics.Tick(hq, now))
|
||||
{ if (unit.Recovery) unit.Recovery.Wait.Reset(); continue; }
|
||||
UpdateCombat(unit, now);
|
||||
if (unit.Recovery) unit.Recovery.Update(m_Mode, now);
|
||||
// Crew failures bypass the route-progress observer. Give them their own
|
||||
// bounded fallback, including a casualty occupying the driver's seat.
|
||||
if (unit.Recovery)
|
||||
{
|
||||
float recoverySpeed = -1;
|
||||
Physics recoveryPhysics = unit.Entity.GetPhysics();
|
||||
if (recoveryPhysics) recoverySpeed = recoveryPhysics.GetVelocity().Length();
|
||||
if (unit.Recovery.Wait.Expired(unit.Registered, unit.CrewValid(m_Mode), unit.CombatHeld, recoverySpeed, now))
|
||||
{ AbandonStuckVehicle(unit, "driver recovery timed out after two minutes"); continue; }
|
||||
}
|
||||
if (unit.CombatHeld) continue;
|
||||
if (!unit.Registered)
|
||||
{
|
||||
@@ -757,7 +873,7 @@ 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)
|
||||
protected void AbandonStuckVehicle(ERR_HunterVehicle unit, string reason = "three route retries failed")
|
||||
{
|
||||
if (unit.Released || unit.PlayerCaptureObserved || ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) return;
|
||||
// Stop reboarding/tactics before detaching the vehicle. Preserve the original
|
||||
@@ -775,7 +891,7 @@ class ERR_HunterService : ERR_ConvoyService
|
||||
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");
|
||||
ERR_HQ.Event("HUNTER RELEASE", unit.Source + ": " + reason + "; original crew ordered out for on-foot HQ duties, vehicle retained");
|
||||
}
|
||||
override void Stop()
|
||||
{
|
||||
@@ -799,6 +915,7 @@ modded class SCR_CompartmentAccessComponent
|
||||
if (mode) hq = ERR_HQ.Cast(mode.Command());
|
||||
if (hq && hq.Hunters()) hq.Hunters().PlayerEntered(GetOwner(), targetEntity);
|
||||
if (mode && mode.CivilianTraffic()) mode.CivilianTraffic().PlayerEntered(GetOwner(), targetEntity);
|
||||
if (mode && mode.GarageVehicles()) mode.GarageVehicles().PlayerEntered(GetOwner(), targetEntity);
|
||||
}
|
||||
super.OnCompartmentEntered(targetEntity, manager, mgrID, slotID, move);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
// A bounded response by original hunter crews. Enemy positions are immutable
|
||||
// radio-delivered snapshots; native perception still owns aiming and firing.
|
||||
// Failures belong to the physical crew, not a disposable tactical assignment.
|
||||
// Bound both memory and lifetime so changed terrain/contact can be tried later.
|
||||
class ERR_HunterApproachMemory
|
||||
{
|
||||
protected ref array<vector> m_Positions = {};
|
||||
protected ref array<float> m_Expires = {};
|
||||
float RetryAfter;
|
||||
void Prune(float now)
|
||||
{
|
||||
for (int i = m_Expires.Count() - 1; i >= 0; i--)
|
||||
if (now >= m_Expires[i]) { m_Expires.Remove(i); m_Positions.Remove(i); }
|
||||
}
|
||||
void Reject(vector position, float now)
|
||||
{
|
||||
Prune(now); RetryAfter = now + 60000;
|
||||
for (int i = 0; i < m_Positions.Count(); i++)
|
||||
if (vector.DistanceXZ(position, m_Positions[i]) < 85)
|
||||
{ m_Expires[i] = now + 300000; return; }
|
||||
if (m_Positions.Count() >= 8) { m_Positions.Remove(0); m_Expires.Remove(0); }
|
||||
m_Positions.Insert(position); m_Expires.Insert(now + 300000);
|
||||
}
|
||||
void Append(array<vector> excluded, float now)
|
||||
{
|
||||
Prune(now);
|
||||
foreach (vector position : m_Positions) excluded.Insert(position);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HunterAttackRules
|
||||
{
|
||||
static float ManeuverBudget(float metres) { return Math.Clamp(30 + metres / 2, 90, 240) * 1000; }
|
||||
@@ -328,12 +356,22 @@ class ERR_HunterTactics
|
||||
bool Plan(array<vector> occupied)
|
||||
{
|
||||
if (!Living()) return false;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (!Delivered && now < Unit.FailedApproaches.RetryAfter) return false;
|
||||
array<vector> excluded = {};
|
||||
foreach (vector reservedPosition : occupied) excluded.Insert(reservedPosition);
|
||||
Unit.FailedApproaches.Append(excluded, now);
|
||||
SCR_AIGroupUtilityComponent utility = Unit.Group.GetGroupUtilityComponent();
|
||||
if (!utility) return false;
|
||||
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
|
||||
float currentRange = vector.DistanceXZ(Unit.Entity.GetOrigin(), Attack.ReportPosition);
|
||||
if (Role == 0 && currentRange >= 140 && currentRange <= 500 && VisualCover())
|
||||
{ Stage = Unit.Entity.GetOrigin(); return true; }
|
||||
{
|
||||
bool rejected;
|
||||
foreach (vector failedPosition : excluded)
|
||||
if (vector.DistanceXZ(Unit.Entity.GetOrigin(), failedPosition) < 85) rejected = true;
|
||||
if (!rejected) { Stage = Unit.Entity.GetOrigin(); return true; }
|
||||
}
|
||||
// Approach from this crew's side of the contact. A common cover axis used
|
||||
// to send the opposite-side truck around the entire fight before dismount.
|
||||
vector approachForward = ERR_MountedCover.Approach(Unit.Entity.GetOrigin(), Attack.ReportPosition, Attack.Forward);
|
||||
@@ -348,7 +386,7 @@ class ERR_HunterTactics
|
||||
if (attempt % 2 == 0) lateral = -lateral;
|
||||
vector seed = desired - approachForward * (attempt * 15) + approachLeft * lateral;
|
||||
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, point, angles, 100)) continue;
|
||||
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point) || !placement.Clear(point, angles[0], 2.5)) continue;
|
||||
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, excluded) || !utility.IsPositionAllowed(point) || !placement.Clear(point, angles[0], 2.5)) continue;
|
||||
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
|
||||
Stage = point; found = true; break;
|
||||
}
|
||||
@@ -359,7 +397,7 @@ class ERR_HunterTactics
|
||||
vector point = desired;
|
||||
if (offroad > 0) point += Vector(Math.RandomFloat(-90, 90), 0, Math.RandomFloat(-90, 90));
|
||||
point = ERR_Actors.Ground(point);
|
||||
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, occupied) || !utility.IsPositionAllowed(point)) continue;
|
||||
if (!ERR_HunterAttackRules.Stage(point, Attack.ReportPosition, approachForward, excluded) || !utility.IsPositionAllowed(point)) continue;
|
||||
if (!placement.Clear(point, ERR_ForceRules.Heading(point, Attack.ReportPosition, 0), 2.5)) continue;
|
||||
if (Role == 0 && !ERR_MountedCover.ClearLane(Unit.Entity, point, Attack.ReportPosition)) continue;
|
||||
Stage = point; found = true;
|
||||
@@ -371,7 +409,7 @@ class ERR_HunterTactics
|
||||
vector parked = Unit.Entity.GetOrigin();
|
||||
float range = vector.DistanceXZ(parked, Attack.ReportPosition);
|
||||
bool separate = true;
|
||||
foreach (vector reserved : occupied) if (vector.DistanceXZ(parked, reserved) < 85) separate = false;
|
||||
foreach (vector reserved : excluded) if (vector.DistanceXZ(parked, reserved) < 85) separate = false;
|
||||
if (range >= 140 && range <= 500 && separate) { Stage = parked; found = true; }
|
||||
}
|
||||
if (!found) { Note("no separated safe approach; keeping current orders"); return false; }
|
||||
@@ -410,6 +448,7 @@ class ERR_HunterTactics
|
||||
}
|
||||
protected bool AlternateStage(float now)
|
||||
{
|
||||
RejectStage(now);
|
||||
// Reserve failed positions as well as the other teams. Do not repeatedly
|
||||
// choose the same screened corner or send two vehicles to one firing point.
|
||||
array<vector> occupied = {};
|
||||
@@ -426,9 +465,13 @@ class ERR_HunterTactics
|
||||
Note("alternate approach assigned=" + Stage.ToString());
|
||||
return true;
|
||||
}
|
||||
protected void RejectStage(float now)
|
||||
{
|
||||
Unit.FailedApproaches.Reject(Stage, now);
|
||||
}
|
||||
protected bool RecoverApproach(float now)
|
||||
{
|
||||
if (m_ApproachRetries >= 2) { Note("approach still blocked after two recoveries; releasing assignment"); Stop(); return false; }
|
||||
if (m_ApproachRetries >= 2) { Note("approach still blocked after two recoveries; releasing assignment"); RejectStage(now); Stop(); return false; }
|
||||
m_ApproachRetries++;
|
||||
bool assigned;
|
||||
if (m_ApproachRetries == 1) assigned = Move(Stage, 35);
|
||||
@@ -444,7 +487,7 @@ class ERR_HunterTactics
|
||||
if (VisualCover()) { m_BlindSince = -1; return true; }
|
||||
if (m_BlindSince < 0) m_BlindSince = now;
|
||||
if (now - m_BlindSince < 20000) return true;
|
||||
if (m_CoverRetries >= 3) { Note("no visual cover after three alternate positions; releasing assignment"); Stop(); return false; }
|
||||
if (m_CoverRetries >= 3) { Note("no visual cover after three alternate positions; releasing assignment"); RejectStage(now); Stop(); return false; }
|
||||
m_CoverRetries++; m_BlindSince = now;
|
||||
Note("cover position has no visual contact; looking for another firing angle");
|
||||
AlternateStage(now);
|
||||
@@ -500,7 +543,7 @@ class ERR_HunterTactics
|
||||
{
|
||||
if (ERR_HunterAttackRules.ExtendApproach(m_ApproachStarted, m_ApproachProgress, now))
|
||||
{ Deadline = Math.Min(now + 30000, m_ApproachStarted + 300000); Note("approach still progressing; allowing another bounded route interval"); }
|
||||
else { Note("approach made no recent progress or exhausted five-minute budget; release this assignment"); Stop(); return false; }
|
||||
else { Note("approach made no recent progress or exhausted five-minute budget; release this assignment"); RejectStage(now); Stop(); return false; }
|
||||
}
|
||||
else if (now - m_ApproachProgress >= 30000 && !RecoverApproach(now)) return false;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ class ERR_MortarSystem
|
||||
protected bool CreateBattery(vector authoredPosition, int id)
|
||||
{
|
||||
vector position = ERR_Actors.Ground(authoredPosition);
|
||||
if (position[1] < 1) { PrintFormat("Reintroduced [mortars]: below land at %1", position); return false; }
|
||||
if (!ERR_Actors.DryGround(position)) { PrintFormat("Reintroduced [mortars]: submerged or invalid ground at %1", position); return false; }
|
||||
IEntity gun = ERR_Actors.Spawn(ERR_MortarBattery.GUN, position);
|
||||
SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn(MortarGroup(), ERR_Actors.Ground(position + "0 0 4")));
|
||||
if (!gun || !group)
|
||||
|
||||
@@ -62,10 +62,24 @@ class ERR_RecommendedAddons
|
||||
static void Snapshot(ERR_GameMode mode, array<string> values)
|
||||
{
|
||||
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
|
||||
Entry(values, "bikes", loaded, {"6503091A7B264FC3", "61B84088181A8FA4", "629B2BA37EFFD577"}, {"WCS Motorbikes", "Motorbike", "WCS Armaments"}, mode.Settings().Get("motorbikeCount") > 0);
|
||||
Entry(values, "bikes", loaded, {"6503091A7B264FC3", "61B84088181A8FA4", "629B2BA37EFFD577"}, {"WCS Motorbikes", "Motorbike", "WCS Armaments"}, mode.Settings().Get("motorbikeCount") > 0 || (mode.Settings().Get("garageVehicleCount") > 0 && mode.Settings().Get("garageChancePercent") > 0));
|
||||
Entry(values, "plane", loaded, {"69BAEE0D439966FE", "69A8A34027DA65C5"}, {"Cessna 172", "Propeller Flight Core"}, mode.Settings().Get("planeReconEnabled") == 1);
|
||||
Entry(values, "weapons", loaded, {"BFC890A6F1603EC0"}, {"Phaseline Marksman Rifles"}, mode.Settings().Get("automaticEquipmentChancePercent") != 0);
|
||||
ERR_HQ hq = ERR_HQ.Cast(mode.Command());
|
||||
if (hq) values.Insert("recommendedBikeRuntime=" + hq.Hunters().MotorbikeStatus());
|
||||
string droneState = "Missing: Realistic Combat Drones";
|
||||
if (ERR_DroneBridge.Available())
|
||||
{
|
||||
droneState = "Loaded - disabled in mission settings";
|
||||
if (mode.Settings().Get("droneReconCount") > 0) droneState = "Loaded - enabled for next round; see current-round status below";
|
||||
}
|
||||
values.Insert("recommended_drones=" + droneState);
|
||||
int droneReady;
|
||||
if (hq && hq.DroneRecon())
|
||||
{
|
||||
values.Insert("droneRuntime=" + hq.DroneRecon().Status());
|
||||
if (hq.DroneRecon().Ready()) droneReady = 1;
|
||||
}
|
||||
values.Insert("recommendedReady_drones=" + droneReady.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,47 @@ class ERR_RescueFlightComponentClass : ERR_ReconFlightComponentClass {}
|
||||
class ERR_RescueFlightComponent : ERR_ReconFlightComponent
|
||||
{
|
||||
protected bool m_Escort, m_Departing, m_LandingConfirmed;
|
||||
[RplProp(onRplName: "ApplyResultsHold")] protected bool m_ResultsHeld;
|
||||
// Contact plus real clearance excludes boarding, engine spin-up, and the
|
||||
// controller's ten-second grounded takeoff hold. Every transport must lift.
|
||||
bool AirborneAfterPickup()
|
||||
{
|
||||
if (!m_Departing || m_Lost || !m_Aircraft) return false;
|
||||
VehicleHelicopterSimulation sim = VehicleHelicopterSimulation.Cast(m_Aircraft.FindComponent(VehicleHelicopterSimulation));
|
||||
if (!sim || sim.HasAnyGroundContact()) return false;
|
||||
float ground = SCR_TerrainHelper.GetTerrainY(m_Aircraft.GetOrigin(), GetGame().GetWorld(), noUnderwater: true);
|
||||
return m_Aircraft.GetOrigin()[1] - ground >= 3;
|
||||
}
|
||||
void HoldForResults()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Lost || !ERR_Actors.Alive(GetOwner()) || m_ResultsHeld) return;
|
||||
m_ResultsHeld = true;
|
||||
ApplyResultsHold();
|
||||
Replication.BumpMe();
|
||||
}
|
||||
protected void ApplyResultsHold()
|
||||
{
|
||||
if (!m_ResultsHeld || !GetOwner()) return;
|
||||
Physics physics = GetOwner().GetPhysics();
|
||||
if (physics)
|
||||
{
|
||||
physics.SetVelocity(vector.Zero);
|
||||
physics.SetAngularVelocity(vector.Zero);
|
||||
physics.ChangeSimulationState(SimulationState.COLLISION);
|
||||
}
|
||||
if (Replication.IsServer())
|
||||
{
|
||||
DamageManagerComponent damage = DamageManagerComponent.Cast(GetOwner().FindComponent(DamageManagerComponent));
|
||||
if (damage) damage.EnableDamageHandling(false);
|
||||
}
|
||||
}
|
||||
override void Release(bool captured)
|
||||
{
|
||||
// Terminal presentation keeps the engines/rotors; physics is already held.
|
||||
// Ordinary lost/captured/aborted flights retain their original cleanup.
|
||||
super.Release(captured || m_ResultsHeld);
|
||||
}
|
||||
|
||||
protected float m_BoardingReadySince = -1;
|
||||
protected vector m_Pickup;
|
||||
protected float m_RescueDeadline, m_NextThreat;
|
||||
@@ -168,6 +209,7 @@ class ERR_RescueFlightComponent : ERR_ReconFlightComponent
|
||||
}
|
||||
override void EOnSimulate(IEntity owner, float timeSlice)
|
||||
{
|
||||
if (m_ResultsHeld) { ApplyResultsHold(); return; }
|
||||
// Initial airborne crew allocation has a fixed 45-second deadline.
|
||||
if (Replication.IsServer() && m_Initialized && m_Phase == ERR_ReconPhase.CREW)
|
||||
{
|
||||
|
||||
@@ -4,6 +4,22 @@ 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 PropCount(bool large = false)
|
||||
{
|
||||
if (large) return 14;
|
||||
return 8;
|
||||
}
|
||||
static int GuardCount(bool large)
|
||||
{
|
||||
if (large) return 8;
|
||||
return 4;
|
||||
}
|
||||
static bool LargeRoad(float width, float threshold) { return width >= threshold; }
|
||||
static bool Needed(bool large, int smallPlaced, int smallTarget, int largePlaced, int largeTarget)
|
||||
{
|
||||
if (large) return largePlaced < largeTarget;
|
||||
return smallPlaced < smallTarget;
|
||||
}
|
||||
static int SearchMilliseconds() { return 12000; }
|
||||
static int SearchAttempts() { return 300; }
|
||||
static bool Road(float width, float length, float heightDifference)
|
||||
@@ -50,6 +66,8 @@ class ERR_Roadblocks
|
||||
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 bool m_Large;
|
||||
protected int m_SmallTarget, m_LargeTarget, m_SmallPlaced, m_LargePlaced, m_MainRoadWidth;
|
||||
protected SCR_AIGroup m_BuildGroup;
|
||||
protected AIWaypoint m_BuildHold;
|
||||
protected ref array<IEntity> m_BuildSpawned = {};
|
||||
@@ -60,7 +78,6 @@ class ERR_Roadblocks
|
||||
protected ref array<ref Resource> m_Loaded = {};
|
||||
static const ResourceName COVER = "{21D83950DF36F250}Prefabs/Props/Military/Sandbags/Sandbag_01_long_burlap.et";
|
||||
static const ResourceName BARRIER = "{64C9C1D1C2C1B38D}Prefabs/Props/Infrastructure/Roadblocks/RoadBlock_01.et";
|
||||
static const ResourceName OBSTACLE = "{A46D45922F8CAD6C}Prefabs/Props/Military/Fortification/CzechHedgehog_01_painted.et";
|
||||
static const ResourceName SIGN = "{C1977A326F2AA011}Prefabs/Structures/Signs/Military/SignCheckpoint_01_stop.et";
|
||||
void ERR_Roadblocks(ERR_GameMode mode) { m_Mode = mode; m_Mining = new ERR_RoadblockMining(mode); }
|
||||
void MineTick(array<ref ERR_HQUnit> units, array<IEntity> attempted, float now) { if (!m_Stopped) m_Mining.Tick(units, attempted, now); }
|
||||
@@ -72,7 +89,7 @@ class ERR_Roadblocks
|
||||
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);
|
||||
return string.Format("Checkpoints: small %1/%2 (4 guards), large %3/%4 (8 guards). Main-road cutoff %5 m. No replacements. %6.", m_SmallPlaced, m_SmallTarget, m_LargePlaced, m_LargeTarget, m_MainRoadWidth, progress);
|
||||
}
|
||||
protected ERR_PrepareResult Finish(string reason)
|
||||
{
|
||||
@@ -90,7 +107,7 @@ class ERR_Roadblocks
|
||||
if (profile && profile.IsValid()) source = profile.GetResource().ToEntitySource();
|
||||
if (!source || !source.Get("m_aUnitPrefabSlots", m_Roster) || m_Roster.IsEmpty()) return false;
|
||||
// Preload before physical creation. A failed prefab never leaves half a post.
|
||||
array<ResourceName> resources = {COVER, BARRIER, OBSTACLE, SIGN, "{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Resources.DefendWaypoint()};
|
||||
array<ResourceName> resources = {COVER, BARRIER, SIGN, "{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Resources.DefendWaypoint()};
|
||||
foreach (ResourceName actor : m_Roster) resources.Insert(actor);
|
||||
foreach (ResourceName prefab : resources)
|
||||
{
|
||||
@@ -119,11 +136,15 @@ class ERR_Roadblocks
|
||||
if (i > 0) length += vector.DistanceXZ(points[i - 1], points[i]);
|
||||
}
|
||||
if (!ERR_RoadblockRules.Road(road.GetWidth(), length, 0)) return false;
|
||||
float distance = Math.RandomFloat(20, length - 20);
|
||||
m_Large = ERR_RoadblockRules.LargeRoad(road.GetWidth(), m_MainRoadWidth);
|
||||
if (!ERR_RoadblockRules.Needed(m_Large, m_SmallPlaced, m_SmallTarget, m_LargePlaced, m_LargeTarget)) return false;
|
||||
float margin = 20;
|
||||
if (m_Large) { if (length < 60) return false; margin = 23; }
|
||||
float distance = Math.RandomFloat(margin, length - margin);
|
||||
vector centre, before, after, beforeDirection, afterDirection;
|
||||
if (!ERR_ConvoyRoadPlan.Sample(points, distance, centre, m_Forward)
|
||||
|| !ERR_ConvoyRoadPlan.Sample(points, distance - 15, before, beforeDirection)
|
||||
|| !ERR_ConvoyRoadPlan.Sample(points, distance + 15, after, afterDirection)) return false;
|
||||
|| !ERR_ConvoyRoadPlan.Sample(points, distance - margin, before, beforeDirection)
|
||||
|| !ERR_ConvoyRoadPlan.Sample(points, distance + margin, after, afterDirection)) return false;
|
||||
if (vector.Dot(m_Forward, beforeDirection) < 0.95 || vector.Dot(m_Forward, afterDirection) < 0.95) return false;
|
||||
m_Centre = ERR_Actors.Ground(centre);
|
||||
if (!ERR_RoadblockRules.Road(road.GetWidth(), length, m_Centre[1] - centre[1])) return false;
|
||||
@@ -139,7 +160,7 @@ class ERR_Roadblocks
|
||||
m_Right = Vector(m_Forward[2], 0, -m_Forward[0]);
|
||||
// Protected guard positions sit on the shoulders; approach barriers narrow
|
||||
// wider roads while preserving a straight vehicle passage on every width.
|
||||
m_Shoulder = Math.Max(road.GetWidth() * 0.5 + 1.5, 4);
|
||||
m_Shoulder = Math.Max(road.GetWidth() * 0.5 + 0.5, 3.2);
|
||||
m_Candidates++; m_CandidateStarted = System.GetTickCount();
|
||||
return true;
|
||||
}
|
||||
@@ -184,18 +205,32 @@ class ERR_Roadblocks
|
||||
protected void Layout(out array<ref ERR_RoadblockProp> covers)
|
||||
{
|
||||
covers.Clear();
|
||||
if (m_Large)
|
||||
{
|
||||
// Four protected guard bays on each shoulder, with two barriers per approach.
|
||||
// Extend along the road instead of closing the traffic lane.
|
||||
for (int largeSide = -1; largeSide <= 1; largeSide += 2)
|
||||
{
|
||||
for (int bay = 0; bay < 4; bay++)
|
||||
AddProp(covers, COVER, largeSide * m_Shoulder, bay * 6 - 9, m_Right, 1.9, 0.7, 1);
|
||||
AddProp(covers, BARRIER, largeSide * 3.3, largeSide * 14, m_Forward, 1.2, 0.4, 1.3);
|
||||
AddProp(covers, BARRIER, largeSide * 3.3, largeSide * 18, m_Forward, 1.2, 0.4, 1.3);
|
||||
AddProp(covers, SIGN, largeSide * 3, largeSide * 21, m_Forward * (largeSide * 1.0), 0.45, 0.4, 0.8);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Footprints enclose the native model bounds, including small placement margins.
|
||||
for (int side = -1; side <= 1; side += 2)
|
||||
{
|
||||
// Two long sandbag walls shelter each pair from the road and its approach.
|
||||
AddProp(covers, COVER, side * m_Shoulder, 0, m_Right, 1.9, 0.7, 1);
|
||||
AddProp(covers, COVER, side * (m_Shoulder + 2.8), side * 2.8, m_Forward, 1.9, 0.7, 1);
|
||||
// Opposing, staggered stop lines: the inner ends leave a >=4m centre lane.
|
||||
AddProp(covers, BARRIER, side * 3.3, side * 8, m_Forward, 1.2, 0.4, 1.3);
|
||||
AddProp(covers, OBSTACLE, side * (m_Shoulder + 4), side * 8, m_Forward, 1, 1, 1.6);
|
||||
AddProp(covers, SIGN, side * 3, side * 12, m_Forward * (side * 1.0), 0.45, 0.4, 0.8);
|
||||
// A short sandbag screen protects each pair without a wide outer bay.
|
||||
AddProp(covers, COVER, side * m_Shoulder, -2, m_Right, 1.9, 0.7, 1);
|
||||
AddProp(covers, COVER, side * m_Shoulder, 2, m_Right, 1.9, 0.7, 1);
|
||||
// One barrier and sign per approach preserve a >=4m centre passage.
|
||||
AddProp(covers, BARRIER, side * 3.3, side * 6, m_Forward, 1.2, 0.4, 1.3);
|
||||
AddProp(covers, SIGN, side * 3, side * 8.5, m_Forward * (side * 1.0), 0.45, 0.4, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
protected void AddProp(array<ref ERR_RoadblockProp> covers, ResourceName prefab, float across, float along, vector forward, float halfWidth, float halfDepth, float height)
|
||||
{
|
||||
vector point = ERR_Actors.Ground(m_Centre + m_Right * across + m_Forward * along);
|
||||
@@ -209,7 +244,7 @@ class ERR_Roadblocks
|
||||
// Start a transaction; physical pieces are created separately in BuildPost.
|
||||
protected bool SpawnPost(array<vector> positions, array<ref ERR_RoadblockProp> covers)
|
||||
{
|
||||
if (!PlayersClear() || m_Mode.Forces().Count() >= 250 || m_BuildGroup || positions.Count() != 4 || covers.Count() != 10) return false;
|
||||
if (!PlayersClear() || m_Mode.Forces().Count() >= 250 || m_BuildGroup || positions.Count() != ERR_RoadblockRules.GuardCount(m_Large) || covers.Count() != ERR_RoadblockRules.PropCount(m_Large)) 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);
|
||||
@@ -225,20 +260,20 @@ class ERR_Roadblocks
|
||||
m_BuildPositions.Clear(); m_BuildCovers.Clear();
|
||||
}
|
||||
// A free Game Master camera does not force-stream bare, non-editable props
|
||||
// with the nearby editable guards. Keep this bounded bank (<=300 objects) on
|
||||
// with the nearby editable guards. Keep this bounded bank (<=660 props across both quotas) on
|
||||
// clients, including late joiners, rather than tying it to the player's body.
|
||||
// Do not report a completed post if any physical prop lacks a network node.
|
||||
protected bool ShareProps()
|
||||
{
|
||||
if (m_BuildCovers.Count() != 10 || m_BuildSpawned.Count() != 15) return false;
|
||||
for (int i = 4; i < 14; i++)
|
||||
if (m_BuildCovers.Count() != ERR_RoadblockRules.PropCount(m_Large) || m_BuildSpawned.Count() != m_BuildPositions.Count() + m_BuildCovers.Count() + 1) return false;
|
||||
for (int i = m_BuildPositions.Count(); i < m_BuildPositions.Count() + m_BuildCovers.Count(); i++)
|
||||
{
|
||||
IEntity entity = m_BuildSpawned[i];
|
||||
RplComponent component;
|
||||
if (entity) component = RplComponent.Cast(entity.FindComponent(RplComponent));
|
||||
if (!component || component.Id() == RplId.Invalid()) return false;
|
||||
}
|
||||
for (int i = 4; i < 14; i++)
|
||||
for (int i = m_BuildPositions.Count(); i < m_BuildPositions.Count() + m_BuildCovers.Count(); i++)
|
||||
{
|
||||
RplComponent component = RplComponent.Cast(m_BuildSpawned[i].FindComponent(RplComponent));
|
||||
component.EnableStreaming(false);
|
||||
@@ -283,18 +318,25 @@ class ERR_Roadblocks
|
||||
CancelPost(); 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()))
|
||||
if (!m_Mode.Forces().Register(id, ERR_ForceKind.CHECKPOINT, m_Centre, m_BuildPositions.Count(), 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);
|
||||
if (m_Large) m_LargePlaced++; else m_SmallPlaced++;
|
||||
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 props=10 networkedProps=10 propStreaming=always roadPassage=open HQ=registered", id, m_Centre);
|
||||
PrintFormat("Reintroduced [roadblocks]: %1 at %2 large=%3 guards=%4 props=%5 networkedProps=%5 propStreaming=always roadPassage=open HQ=registered", id, m_Centre, m_Large, m_BuildPositions.Count(), m_BuildCovers.Count());
|
||||
m_BuildSpawned.Clear(); m_BuildGroup = null; m_BuildHold = null;
|
||||
m_BuildPositions.Clear(); m_BuildCovers.Clear();
|
||||
m_NextSearch = System.GetTickCount() + 2000;
|
||||
}
|
||||
protected vector GuardPosition(int side, int row)
|
||||
{
|
||||
float along = side * (row * 2 - 2.5);
|
||||
if (m_Large) along = row * 6 - 9;
|
||||
return ERR_Actors.Ground(m_Centre + m_Right * (side * (m_Shoulder + 1.5)) + m_Forward * along);
|
||||
}
|
||||
// 0: reject, 1: awaiting native tile, 2: ready.
|
||||
protected int Plan(out array<vector> positions, out array<ref ERR_RoadblockProp> covers)
|
||||
{
|
||||
@@ -307,9 +349,9 @@ class ERR_Roadblocks
|
||||
map<int, vector> reserved = new map<int, vector>();
|
||||
for (int side = -1; side <= 1; side += 2)
|
||||
{
|
||||
for (int row = -1; row <= 1; row += 2)
|
||||
for (int row = 0; row < ERR_RoadblockRules.GuardCount(m_Large) / 2; row++)
|
||||
{
|
||||
vector point = ERR_Actors.Ground(m_Centre + m_Right * (side * (m_Shoulder + 2.5)) + m_Forward * (side * (row - 1.5)));
|
||||
vector point = GuardPosition(side, row);
|
||||
if (!mesh.IsTileLoaded(point)) { mesh.LoadTileIn(point); return 1; }
|
||||
vector reachable;
|
||||
if (!mesh.GetReachablePoint(point, 1, reachable) || vector.DistanceXZ(point, reachable) > 1 || !m_Placement.Clear(point, reserved)) return 0;
|
||||
@@ -324,7 +366,10 @@ class ERR_Roadblocks
|
||||
if (m_Ready) return ERR_PrepareResult.READY;
|
||||
if (!m_Begun)
|
||||
{
|
||||
m_Begun = true; m_Started = System.GetTickCount(); m_Target = m_Mode.Settings().Get("roadblockCount");
|
||||
m_Begun = true; m_Started = System.GetTickCount(); m_SmallTarget = m_Mode.Settings().Get("roadblockCount");
|
||||
m_LargeTarget = m_Mode.Settings().Get("largeRoadblockCount");
|
||||
m_MainRoadWidth = m_Mode.Settings().Get("mainRoadWidthMetres");
|
||||
m_Target = m_SmallTarget + m_LargeTarget;
|
||||
if (m_Target == 0) return Finish("disabled");
|
||||
#ifdef WORKBENCH
|
||||
// The legacy convoy/hq-memory fixtures use the original 60-body allowance and six-squad layout.
|
||||
|
||||
@@ -13,6 +13,9 @@ class ERR_IntelAdminProvider : ERR_AdminProvider
|
||||
if (arguments == "intel show")
|
||||
{
|
||||
replies.Insert("aiIntelChancePercent=" + mode.Settings().Get("aiIntelChancePercent").ToString());
|
||||
replies.Insert("radioIntelSiteCount=" + mode.Settings().Get("radioIntelSiteCount").ToString() + " (next round)");
|
||||
if (mode.Discovery()) replies.Insert(mode.Discovery().RadioIntelStatus());
|
||||
replies.Insert("showAllSites=" + mode.Settings().Get("showAllSites").ToString());
|
||||
replies.Insert("intelRevealChancePercent=" + mode.Settings().Get("intelRevealChancePercent").ToString());
|
||||
return true;
|
||||
}
|
||||
@@ -30,6 +33,8 @@ class ERR_IntelAdminProvider : ERR_AdminProvider
|
||||
override bool Help(string topic, array<string> replies)
|
||||
{
|
||||
if (topic != "intel") return false;
|
||||
replies.Insert("/esc intel radiosites show|set N|help (0-20; 0 disables); non-radio sites required before radio intel, next round.");
|
||||
replies.Insert("/esc intel sites show|set on|off; show anonymous site positions and let intel identify their types.");
|
||||
replies.Insert("/esc intel show | intel carrying show|set N (0-100; alias: intel chance)");
|
||||
replies.Insert("Carrying changes affect newly registered AI. /esc intel reroll applies to eligible living AI; intel reroll show reports progress.");
|
||||
replies.Insert("/esc intel reveal show|set N|help (0-100); newly recovered documents change, known locations and spent documents remain unchanged.");
|
||||
|
||||
@@ -7,6 +7,7 @@ modded class ERR_GameMode
|
||||
protected float m_ERRIntelMessageAt;
|
||||
protected float m_ERRIntelExhaustedAt;
|
||||
protected bool m_ERRIntelDuplicateReported, m_ERRIntelExhaustionReported;
|
||||
protected bool m_ERRRadioIntelWaitingReported;
|
||||
protected bool m_ERRIntelFeedbackStopped;
|
||||
protected int m_ERRIntelMessageSequence;
|
||||
protected int m_ERRIntelReceivedSequence;
|
||||
@@ -62,6 +63,15 @@ modded class ERR_GameMode
|
||||
m_ERRIntelReceivedSequence = sequence;
|
||||
SCR_ChatComponent.RadioProtocolMessage(message);
|
||||
}
|
||||
void ERR_RadioIntelWaiting()
|
||||
{
|
||||
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active() || m_ERRRadioIntelWaitingReported) return;
|
||||
m_ERRRadioIntelWaitingReported = true;
|
||||
string message = "Keep this radio document. Identify more non-radio sites to decipher it; it will be read automatically when ready.";
|
||||
m_ERRIntelMessageSequence++;
|
||||
ERR_ReceiveDiscoveryFeedback(m_ERRIntelMessageSequence, message);
|
||||
Rpc(ERR_ReceiveDiscoveryFeedback, m_ERRIntelMessageSequence, message);
|
||||
}
|
||||
void ERR_IntelExhausted(bool duplicate = false)
|
||||
{
|
||||
if (!Replication.IsServer() || m_ERRIntelFeedbackStopped || !Active()) return;
|
||||
|
||||
@@ -48,16 +48,18 @@ class ERR_IntelEvidence
|
||||
}
|
||||
// Spread new folders over unfinished sites. Counts persist after recovery or
|
||||
// retirement so extra camps/AI do not repeatedly roll the same early targets.
|
||||
// Existing sealed contents are never rewritten when team knowledge changes.
|
||||
// Only recovery of a progression-locked radio folder can redirect its contents.
|
||||
protected string NextTarget()
|
||||
{
|
||||
array<ERR_LocationComponent> locations = {};
|
||||
ERR_GameMode.Get().Locations(locations);
|
||||
array<ERR_LocationComponent> candidates = {};
|
||||
int least = -1;
|
||||
bool radioAllowed = ERR_GameMode.Get().Discovery() && ERR_GameMode.Get().Discovery().RadioIntelAllowed();
|
||||
foreach (ERR_LocationComponent location : locations)
|
||||
{
|
||||
if (!location || location.Knowledge() >= 2 || location.Kind() == ERR_LocationKind.PRISON || location.Kind() == ERR_LocationKind.EXTRACTION) continue;
|
||||
if (!radioAllowed && location.Kind() == ERR_LocationKind.RADIO) continue;
|
||||
int assigned = 0;
|
||||
m_TargetAssignments.Find(location.Id(), assigned);
|
||||
if (least < 0 || assigned < least) { least = assigned; candidates.Clear(); }
|
||||
@@ -99,6 +101,20 @@ class ERR_IntelEvidence
|
||||
foreach (ERR_IssuedIntel issued : record.Issued) if (issued.Folder == folder) return issued.SiteId;
|
||||
return "";
|
||||
}
|
||||
// Update physical document bookkeeping only. Inspect() has already copied
|
||||
// any queued report into its own Missing list; delivered HQ reports stay intact.
|
||||
void Retarget(IEntity folder, string target)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Stopped || !folder || target.IsEmpty()) return;
|
||||
bool changed;
|
||||
foreach (ERR_BodyEvidence record : m_Roster)
|
||||
foreach (ERR_IssuedIntel issued : record.Issued)
|
||||
if (issued.Folder == folder && issued.SiteId != target) { issued.SiteId = target; changed = true; }
|
||||
if (!changed) return;
|
||||
int assigned;
|
||||
m_TargetAssignments.Find(target, assigned);
|
||||
m_TargetAssignments.Set(target, assigned + 1);
|
||||
}
|
||||
void Tick(float now)
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
FrameWidgetClass {
|
||||
Name "ERR_PreparationFailure"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 1 OffsetLeft 0 OffsetTop 0 OffsetRight 0 OffsetBottom 0 }
|
||||
{
|
||||
ImageWidgetClass {
|
||||
Name "Background"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 1 OffsetLeft 0 OffsetTop 0 OffsetRight 0 OffsetBottom 0 }
|
||||
Color 0.025 0.035 0.04 1
|
||||
}
|
||||
FrameWidgetClass {
|
||||
Name "FailurePanel"
|
||||
Slot FrameWidgetSlot { Anchor 0.5 0.5 0.5 0.5 OffsetLeft 0 OffsetTop 0 SizeX 960 OffsetRight -960 SizeY 620 OffsetBottom -620 Alignment 0.5 0.5 }
|
||||
{
|
||||
TextWidgetClass {
|
||||
Name "FailureTitle"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 0 OffsetTop 0 SizeY 58 OffsetBottom -58 }
|
||||
Color 1 1 1 1
|
||||
Text "Mission could not start"
|
||||
"Font Size" 32
|
||||
"Min Font Size" 32
|
||||
}
|
||||
TextWidgetClass {
|
||||
Name "FailureDescription"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 0 OffsetTop 80 SizeY 400 OffsetBottom -480 }
|
||||
Color 1 1 1 1
|
||||
"Font Size" 22
|
||||
"Min Font Size" 22
|
||||
Wrap 1
|
||||
}
|
||||
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {
|
||||
Name "ReturnToMainMenu"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 0 0 OffsetLeft 0 OffsetTop 520 SizeX 400 OffsetRight -400 SizeY 56 OffsetBottom -576 }
|
||||
components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Return to main menu" } }
|
||||
}
|
||||
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" {
|
||||
Name "OpenPauseMenu"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 0 0 OffsetLeft 430 OffsetTop 520 SizeX 400 OffsetRight -830 SizeY 56 OffsetBottom -576 }
|
||||
components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Pause menu (Esc / Back)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
MetaFileClass {
|
||||
Name "{28DD8E27B1CF40C5}UI/layouts/ERR/PreparationFailure.layout"
|
||||
Configurations {
|
||||
LayoutResourceClass PC { }
|
||||
LayoutResourceClass HEADLESS : PC { }
|
||||
LayoutResourceClass XBOX_ONE : PC { }
|
||||
LayoutResourceClass XBOX_SERIES : PC { }
|
||||
LayoutResourceClass PS4 : PC { }
|
||||
LayoutResourceClass PS5 : PC { }
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ FrameWidgetClass {
|
||||
OffsetTop 0
|
||||
SizeX 900
|
||||
OffsetRight -900
|
||||
SizeY 240
|
||||
OffsetBottom -240
|
||||
SizeY 520
|
||||
OffsetBottom -520
|
||||
Alignment 0.5 0.5
|
||||
}
|
||||
"Z Order" 10
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
SizeLayoutWidgetClass {
|
||||
Name "ERR_RoundResults"
|
||||
AllowWidthOverride 1
|
||||
WidthOverride 1100
|
||||
AllowHeightOverride 1
|
||||
HeightOverride 620
|
||||
Slot OverlayWidgetSlot { HorizontalAlign 1 VerticalAlign 1 }
|
||||
components { SCR_GameOverScreenContentUIComponent "{6E81CD1BC83BE245}" { } }
|
||||
{
|
||||
FrameWidgetClass {
|
||||
Name "ResultFrame"
|
||||
Slot OverlayWidgetSlot { HorizontalAlign 3 VerticalAlign 3 }
|
||||
{
|
||||
TextWidgetClass {
|
||||
Name "GameOver_State"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 0 OffsetRight 20 SizeY 52 OffsetBottom -52 }
|
||||
Color 1 1 1 1 "Font Size" 36 "Min Font Size" 36
|
||||
Text "Round ended"
|
||||
FontProperties FontProperties { Font "{EABA4FE9D014CCEF}UI/Fonts/RobotoCondensed/RobotoCondensed_Bold.fnt" }
|
||||
}
|
||||
TextWidgetClass {
|
||||
Name "GameOver_Description"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 58 OffsetRight 20 SizeY 64 OffsetBottom -122 }
|
||||
Color 0.9 0.92 0.94 1 "Font Size" 22 "Min Font Size" 22 Wrap 1
|
||||
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
|
||||
}
|
||||
TextWidgetClass {
|
||||
Name "StatisticsHeading"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 132 OffsetRight 20 SizeY 36 OffsetBottom -168 }
|
||||
Color 0.9 0.8 0.5 1 "Font Size" 26 "Min Font Size" 26 Text "Mission statistics"
|
||||
FontProperties FontProperties { Font "{EABA4FE9D014CCEF}UI/Fonts/RobotoCondensed/RobotoCondensed_Bold.fnt" }
|
||||
}
|
||||
ScrollLayoutWidgetClass {
|
||||
Name "StatisticsScroll"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 1 OffsetLeft 20 OffsetTop 178 OffsetRight 20 OffsetBottom 12 }
|
||||
components { SCR_GamepadScrollComponent "{5707100AC31E657E}" { } }
|
||||
{
|
||||
VerticalLayoutWidgetClass {
|
||||
Name "StatisticsContent"
|
||||
Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 0 Padding 0 0 18 0 }
|
||||
{
|
||||
TextWidgetClass {
|
||||
Name "RoundStatistics"
|
||||
Slot LayoutSlot { HorizontalAlign 3 Padding 0 0 0 8 SizeMode Auto }
|
||||
Clipping Ancestor Color 0.9 0.92 0.94 1
|
||||
Text "Waiting for mission statistics..."
|
||||
"Font Size" 20 "Min Font Size" 20 Wrap 1
|
||||
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
MetaFileClass {
|
||||
Name "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
Configurations {
|
||||
LayoutResourceClass PC { }
|
||||
LayoutResourceClass HEADLESS : PC { }
|
||||
LayoutResourceClass XBOX_ONE : PC { }
|
||||
LayoutResourceClass XBOX_SERIES : PC { }
|
||||
LayoutResourceClass PS4 : PC { }
|
||||
LayoutResourceClass PS5 : PC { }
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,32 @@ Chat delegates the reveal alias to Core instead of defining a duplicate. Its `in
|
||||
`ERR_NativeAdminSettingsProbe.Run(ERR_GameMode mode)` is an opt-in WORKBENCH test awaiting central native execution. It checks actual authorized read/help dispatch and unauthorized/invalid mutation rejection. Valid changes exercise the same production setting-command handler with a separate `ERR_Settings` instance at `$profile:erri-native-admin-settings-probe.json`; they never mutate the active mission's settings. The fixture rejects already-existing fixture files, verifies native save/reload and unrelated values, then removes only its own fixture primary, backup and temporary files.
|
||||
|
||||
A policy pass does not prove native script compilation, remote RPC ownership or readable chat presentation. Run the native probe inside Core-only and companion regression fixtures, then retain real-client administration in multiplayer acceptance.
|
||||
|
||||
## Radio intel progression
|
||||
|
||||
**Intel → Radio intel** configures `radioIntelSiteCount` (0–20, default 5).
|
||||
Use `/esc intel radiosites show|set N|help` from the same authenticated command
|
||||
path. Zero disables the requirement. The discovery service snapshots the saved
|
||||
value at round initialization; edits affect the next round. Older settings files
|
||||
receive the default without changing their other values.
|
||||
|
||||
Only distinct, prepared, fully identified (`Knowledge() == 2`) non-radio sites
|
||||
count. Prison, extraction and sites marked `RevealOnEscape` are excluded. Rough
|
||||
clues and anonymous question marks do not contribute. After all background site
|
||||
preparation finishes, the effective requirement is capped at the available
|
||||
eligible site count. Once unlocked, radio intel stays unlocked for that round.
|
||||
The submenu shows this round's progress separately from the saved requirement.
|
||||
|
||||
Ordinary document assignment and recovery exclude radios while locked. An issued
|
||||
ordinary folder already targeting a radio redirects to another unfinished site;
|
||||
its issued evidence identity is updated, but already queued or delivered enemy
|
||||
reports keep their copied contents. With no alternative, the folder remains
|
||||
unspent. Fixed objective documents keep their radio targets and are automatically
|
||||
read from player inventory after unlocking. A team notice explains the wait once
|
||||
per round. Normal exhaustion/cleanup behavior resumes after unlock.
|
||||
|
||||
The gate applies only to document targeting. Physical observation, map question
|
||||
marks, radio interaction and radio defence retain their existing rules.
|
||||
|
||||
Validation: `tests/test_radio_intel.py`, settings/menu/feedback regressions and
|
||||
[the isolated native mission evidence](evidence/radio-intel-2026-10-21/README.md).
|
||||
|
||||
@@ -2,6 +2,28 @@
|
||||
|
||||
Implemented 2026-09-23 in the combined Core addon. These are server-owned mission services; clients do not request spawns or supply casualty counts.
|
||||
|
||||
## Šárka reinforcement waves (2026-10-16)
|
||||
|
||||
After prison escape, every ten minutes the current anger score sends one Šárka
|
||||
per point: anger 3 means three cars, anger 0 means none. Anger still increases by
|
||||
one for every three enemy bodies identified and reported to HQ, capped at 30.
|
||||
Each car carries four selected-faction response troops total: a driver, two
|
||||
soldiers and one medic. Vanilla factions use their special-forces profiles;
|
||||
modded factions use their configured ResponseSoldier/ResponseMedic profiles.
|
||||
|
||||
Cars spawn on checked clear roads 1–1.25 km from the nearest living player.
|
||||
Departures are spread five seconds apart; blocked placement retries within the
|
||||
current wave. Unplaced cars do not accumulate across waves. An empty round
|
||||
cancels pending departures and starts a new ten-minute wait when play resumes.
|
||||
These additional cars use the same radio orders, patrol, combat, dismount and
|
||||
capture behavior as other hunters. Player positions guide initial placement;
|
||||
subsequent pursuit still requires delivered sightings.
|
||||
|
||||
Šárkas have a separate wave allowance from ordinary UAZs, trucks and motorbikes.
|
||||
Existing formation-budget limits still apply, so a full ledger can defer a wave.
|
||||
Original cars and crew remain physical after loss or capture; waves create new
|
||||
teams and never refill casualties in old teams.
|
||||
|
||||
## Coordinated pursuit candidate (2026-10-13)
|
||||
|
||||
The new **Coordinated** doctrine organizes available formations into a shared pursuit. It is opt-in for qualification; **Current** remains the compatibility default. The first version runs entirely inside the mod. Python is used for offline replay and performance comparison, with no runtime service or language-model dependency. The intended playtest is two to four players, with a separate solo evasion check. Existing accuracy, damage and native detection settings are unchanged, so comparison can isolate the decisions and their execution.
|
||||
|
||||
@@ -5,12 +5,17 @@ crate. The remaining supply cache and medical stash provide the two reachable
|
||||
radio-intel sources. Mortar reserve/courier preparation uses the existing eastern
|
||||
outpost, preserving its finite logistics without creating a replacement site.
|
||||
|
||||
Extraction is authored at 4800.953 / 11487.412 on Everon's airfield. Before
|
||||
spawning the helicopter, `ERR_AirfieldPlacement` checks nearby physical airfield
|
||||
anchors, dry terrain, slope and the aircraft/overhead clearance volume. A failed
|
||||
check fails preparation; there is no fallback into the woods. New islands need
|
||||
their own authored pad and recognizable airfield anchors. Aircraft damage and
|
||||
physics remain normal after spawning.
|
||||
As of 28 October 2026, extraction can use suitable clear ground anywhere on the
|
||||
terrain. Original Everon randomizes its pickup with `RandomClear 1` and
|
||||
`RequireAirfield 0`; its previous airport coordinate remains only a fallback
|
||||
subject to the same landing-space and escape-boundary checks. Terrain-independent
|
||||
placement also samples the entire terrain immediately, without an airport-first
|
||||
pass or a dedicated airport-discovery wait.
|
||||
|
||||
Primary ground/overhead clearance and secondary transport space are checked
|
||||
before accepting a candidate. Site creation rechecks the generated primary pad.
|
||||
Aircraft damage and physics remain normal. Explicit authored airport-only pads
|
||||
remain supported through `RequireAirfield 1`.
|
||||
|
||||
Exact discoveries use native wrapper images and matching glow images:
|
||||
helicopter for extraction, radio for radio camps, artillery for mortar sites,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user