Initial commit

This commit is contained in:
Ereshkigal 2021-11-05 02:46:37 +01:00
commit c34fd5eb0d
43 changed files with 4081 additions and 0 deletions

63
.gitattributes vendored Normal file
View File

@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

363
.gitignore vendored Normal file
View File

@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

136
README.MD Normal file
View File

@ -0,0 +1,136 @@
# Smart spawn controller
Mod changes the vanilla algorithm of spawning bots. Allows you to improve the performance of the game without the tedious tuning of bot waves.
Checked on EFT version EFT 0.12.10.12192, 0.12.11.1.13487, 0.12.11.2.13615, 0.12.11.13725 / AKI 1.4.0 - 2.0.0-A8 BLEEDINGEDGE
## Features
* Limit the number of alive bots on the map
* Spawn new bots within a certain radius from the player
* Despawn bots when they too far from the player
* Setting the probability of spawn in the available zones and limiting the number of bots in the zone
* Customize the zones available for bosses
* Setting the maximum number of alive bots in the zone (does not apply to bosses and their followers)
* Setting the roles of bots that are being changed (by default, marksman-bots is not affected by the mod)
* Setting parameters separately for each map
* For now mod is using map waves settings, so you need enough scav waves if you plan using despawn mechanism (there is a solution to quick create a lot of scav waves)
## The Idea
The vanilla spawn algorithm works according to a predetermined wave config and spawns bots in time, adding a little randomness to the number of bots and the spawn zone for bosses (as far as I understand from the code, the choice of a random spawn zone from several predefined ones is available only to bosses). Plus we have a hard-coded upper bound of 40 live bots and unoptimized code for the bots themselves. Which leads to either empty maps or low performance.
For a long time I had an idea to make the bot manager that uses the player's position on the map and turn off bots that are too far from the player. Unfortunately, the implementation showed that this does not help and the bots either do not turn off or turn on)), and the performance remains the same as before. But the accidentally discovered algorithm for despawning scavs when cultists are spawned in a zone suggested to different idea. This is how the spawn and despawn manager appeared, which takes into account the distance to the player.
A couple of disadvantages that have not yet been fixed (and maybe never at all): bots take a long time to spawn. I did not measure the exact time, but from the moment when the game invokes the spawn method and until the bot appears on the map, it takes about 30 seconds, sometimes more, there is still a load of resources due to which the initial waves become queued. The second disadvantage is that bots that have been despawned are not added back to the spawn queue. It seems to me that if you return them back (when spawning, only the role / difficulty is taken into account, the state of health and equipment will be new), then you can control the number of waves a little better. Teleporting a bot to another zone will most likely not work because the bot is quite tightly tied to the spawn zone, but I have not tried it. A teleport would also solve the problem of long waiting times for spawn.
In general, the spawn algorithm in the radius is ideal for maps like customs, shoreline. On these maps, you can achieve good gameplay without looking for bots by location. Bots will be almost everywhere and distributed according to the configuration. I think you can add woods here if you configure it.
The reserve is a fairly compact map, and since a lot of time passes between the moment the wave begins to spawn and the appearance of bots on the map, it is difficult to adjust the spawn / despawn radius. But you can limit the number of bots in zones (for example, underground levels).
The interchange is also difficult for spawn / despawn as the shopping center itself is located at a long distance from the edges of the map where the player can spawn. This leads to the fact that either bots will appear in a zone with a joint exit, or in random zones throughout the map. the algorithm simply cannot find the nearest zone.
The laboratory and both factories do not use spawn / despawn by default. They are limited by the number of live bots. At the factory, my algorithm is generally useless. there is one zone and a small map. Labs has not been tested, not my map, but it is also not large and multilevel - I don't think that the algorithm will work well, except to use it for distribution into zones.
For debugging, you can use my bot monitor, which shows the distance to each bot and the zone in which it spawned.
## Installation
Copy *astealz-SmartSpawnController* into *user/mods* folder
## Configuration
All settings are contained in the *config.json* file
### Section "game"
* **DEBUG** - output of debug messages to the log *SmartSpawnController.txt* and the console in the game. Use only when mod is not working.
* **VERBOSE** - output of detailed messages to the log *SmartSpawnController.txt* and the console in the game. Use for configuring.
* **ScavWaveRetryInterval** (seconds) - if the game cannot spawn a wave of bots (resources are not loaded, the limit of alive bots is exceeded, the zone is occupied by cultists, no spawn points are available), then all or part of the bots of this wave will be queued. This parameter changes the time after which the game will try to spawn this wave of bots again. The default is 3 seconds.
* **Maps** - setting for each map, *_Id* maps are used as keys
* **MaxBotsAliveOnMap** - limit the number of alive bots on the map. By default, the game is hardcoded to 40, the value from *globals.json* is ignored.
* **EnableSpawnControl** - enables spawn control
* **MaxDistanceToSpawn** (meters) - the maximum distance from the player to the zone's center of mass (the middle point among all the spawn points in the zone). The choice of the spawn point remains with the game, so bots can spawn both closer and further than this value.
* **ForceChooseNewZone** - enable forced change of the spawn zone. It is recommended to turn it on for even spawn. When the parameter is off, the distance to the spawn zone is analyzed first, if the distance is less than *MaxDistanceToSpawn* - there will be no zone change.
* **BotsPerZoneBase** - base value of the number of bots in the zone, used to calculate the "weight" of the zone
* **ScavRoles** and **BossRoles** - the roles of scavs and bosses for which the spawn zone will change. Marksmans are not supported by the mod, standard bosses and their followers by default are not changed (they are supported, but not tested).
* **EnableUnspawn** - enable the despawn mechanism when moving away from the player. The calculation is done separately for each bot and, unlike the midpoint in the spawn manager, the actual distance to the bot is used here.
* **MaxDistanceToUnspawn** (meters) - the maximum distance after which the bot will despawn
* **RolesToUnspawn** - only these roles will be analyzed and despawned
* **Zones** - setting map zones
* **CanSpawnBoss** - boss spawn permission flag (it's not very clear how it works, but the game filters the available zones for bosses by this flag, you can change it)
* **SnipeZone** - flag of the sniper zone. This is for information only, the change will not affect the game. All sniper zones are excluded from the zones that are available to normal scavs and bosses
* **MaxPersonsOnPatrol** - the maximum number of alive bots in the zone, does not affect the spawn of the boss and followers. Can be changed (not tested)
* **MultiplicativeCoef** (float) - zone multiplicative coefficient (see zone selection algorithm)
* **AdditiveCoef** (int, may be negative) - zone additive coefficient (see zone selection algorithm)
### "server" section
JS module parameters:
* **Add_IgnoreMaxBots_ToBossWaves** - Adds *IgnoreMaxBots* parameter for all bosses in all locations. This parameter allows you to bypass the **MaxBotsAliveOnMap** limit for boss spawns, i.e. with a limit of 12, after the spawn of the boss, there will be 12 + boss + followers. For vanilla bosses, it is not particularly needed. Their spawn time is '-1' i.e. at the very beginning of the raid. Very useful for raiders and PMC bosses.
* **ChangeWaveCoefs** - allows you to change the coefficients of the number of bots in the wave. It is recommended to set *WAVE_COEF_MID* to 1. The explanation will be below.
* **ReplaceLocationsWaves** - allows you to replace standard / modified scav waves with simple ones, but in larger quantities. The main idea is to make a lot of waves consisting of 1 bot. Because the maximum number of bots is limited, new ones will queue up and wait for free space. This also allows you to fill the zones more evenly: if you use zones with a random number of bots from 1 to 4, then a situation is possible when in a zone with a low weight and a limit of 2 bots will appear 4 at a time, and in a zone with a high weight there will be 4 waves of 1 bot. This rule does not apply to bosses.
* **ScavWaveCount** - number of scav waves
* **ScavInstaWaveCount** - number of scav waves at the start of the location (unfortunately the game does not want to spawn them BEFORE entering the location, so they appear in a minutes)
* **ScavDifficultyWeight** - setting the difficulty of the scav waves. A simplified version of the weight-based probability. The higher the value, the more likely that difficulty will be selected. Zero - excludes this difficulty from the selection.
### Zone selection algorithm
1. Among the available zones (for scav - all zones except sniper, for bosses - all zones of scavs with the *CanSpawnBoss* flag) select those in which the distance to the midpoint falls within the *MaxDistanceToSpawn*
2. The weight of each zone is calculated: \
Formula: Round ((Base - Min (Base, BotCount)) * M + A), where \
*Round* - rounding to the nearest integer, \
*Base* - BotsPerZoneBase, \
*BotCount* - the number of spawned bots in the zone, not including bots from the new wave, \
*M* - MultiplicativeCoef, \
*A* - AdditiveCoef. \
If weight <= 0, then the zone is bypassed
3. If for some reason the list of zones is empty, then a random zone will be selected from all available ones and added to the list
4. The total weight of all zones is calculated
5. In the range *[1..Total_weight]* a random number is selected
6. A zone is selected in the range of which a random number falls
The multiplicative coefficient allows you to increase the weight (probability) of bots spawning in a given zone.
The additive coefficient controls the maximum number of bots in the zone.
#### Examples:
- Base = 4, M = 1, A = 1 => Empty zone weight = 5, zone weight with 4 or more bots = 1
- Base = 4, M = 1, A = 0 => Empty zone weight = 4, zone weight with 4 bots 0 (4 bots limit)
- Base = 4, M = 2, A = 1 => Weight of an empty zone = 9, weight of a zone with 4 or more bots = 1 (the zone is more likely to spawn)
- Base = 4, M = 1, A = -1 => Empty zone weight = 3, zone weight with 3 or more bots = 0 (limit for 3 bots)
- Base = 4, M = 0.5, A = 0 => Empty zone weight = 2, zone weight with 4 more bots = 0 (zone less likely to spawn)
#### Examples from logs:
For the 5th wave:
[5] [Zones] ZoneRailStrorage [1], ZonePTOR1 [6], ZonePTOR2 [11], ZoneBarrack [16], ZoneBunkerStorage [18], ZoneSubStorage [22], ZoneSubCommand [26]
[5] [Selected] 5 @ ZonePTOR1
[5] - wave number (internal total counter for scav, bosses and followers)
[Zones] - available zones for this wave and their weight
[Selected] - selected random number in the range [1..26] and the zone in the range of which this number falls
For wave 67:
[67] [Zones] ZoneRailStrorage [1], ZonePTOR1 [2], ZonePTOR2 [3], ZoneBarrack [4], ZoneSubCommand [5]
[67] [Selected] 1 @ ZoneRailStrorage
since in all zones, the limit of bots for the zone has already been exceeded, there are only zones where "eternal" spawn is possible.
### Debug messages
Debugging messages can be enabled both in the config file and through the console (*spawner-debug* command).
When loading a map, the mod shows the zones wired into the game and their parameters, as well as the changed zones from the mod's configuration. New zones cannot be added. Can be used for configuration, output is in json.
For each wave of bots, messages are displayed about the wave number, the initial zone, zone change, a mark that the wave is put in the waiting queue, or possible errors.
# Version history
* 1.0.0 - Initial release
* 1.0.1 - fixed bug when boss with inexistent zone was tried to spawn, server side fixes
* 1.0.2 - fixed broken bots bug

135
README.RU.MD Normal file
View File

@ -0,0 +1,135 @@
# Smart spawn controller
Мод меняет ванильный алгоритм спавна ботов. Позволяет повысить производительность игры без нудной настройки волн ботов.
Проверн на версиях EFT 0.12.10.12192, 0.12.11.1.13487, 0.12.11.2.13615, 0.12.11.13725 / AKI 1.4.0 - 2.0.0-A8 BLEEDINGEDGE
## Возможности
* Ограничение количества живых ботов на карте
* Спавн новых ботов в определенном радиусе от игрока
* Деспавн ботов при слишком большом расстоянии от игрока
* Настройка вероятности спавна в доступных зонах и ограничение количества ботов в зоне
* Настройка зон доступных для боссов
* Настройка максимального количества живых ботов в зоне (не относится к боссам и их свите)
* Настройка ролей ботов которые подвергаются изменениям (по умолчанию поведение спавна ботов-снайперов/marksman не затрагивается модом)
* Настройка параметров отдельно для каждой карты
* На текущий момент мод использует настройки волн из конфига карт, так что нужно досточное их количество если будет использоваться деспавн (есть решение для быстрого создания большого количества волн)
## Идея
Ванильный алгоритм спавна работает по заранее заданному конфигу волн и спавнит ботов по времени, добавляя немного рандома в количество ботов и спавн-зону у боссов (на сколько я понял из кода выбор случайной спавн зоны из нескольких заданных доступен только боссам). Плюсом мы имеем жестко заданную верхнюю границу в 40 живых ботов и неоптимизированный код самих ботов. Что приводит либо к пустым картам, либо к тормозам.
У меня давно витала мысль сделать так чтобы менеджер ботов учитывал положение игрока на карте и выключал ботов которые находятся далеко от игрока. К сожалению реализация показала что это не помогает и боты либо не выключаются, либо не включаются )), а производительность остается такой же как и раньше. Но случайно обнаруженный алгоритм деспавна диких при спавне в зоне культистов навел ну другую идею. Так появился менеджер спавна и деспавна, который учитывает расстояние до игрока.
Пара минусов которые пока (а может быть и вообще никогда) не исправлены: боты спавнятся долго. Точного времени не замерял, но от момента когда игра выполняет метод спавна и до появления бота на карте проходит около 30 секунд, иногда больше, еще есть загрузка ресурсов из-за которой начальные волны становятся в очередь. Второй минус - боты которые были деспавнены не добавляются обратно в очередь на спавн. Мне кажется что если возвращать их обратно (при спавне учитывается только роль/сложность, состояние здоровья и снаряжение будет новое), то можно чуть лучше контроллировать количество волн. Телепорт бота в другую зону скорее всего не сработает т.к. бот достаточно жестко завязан на зону спавна, но я не пробовал. Телепорт решил бы и проблему долгого ожидания спавна.
В целом алгоритм спавна в радиусе идеально подходит для вытянутых карт как таможня, неплохо работает на карте берег. На этих картах можно добиться хорошего гейм-плея без поиска ботов по локации. Боты будут практически везде и распределены согласно конфигурации. Думаю сюда можно добавить и лес, если его настроить.
Резерв достаточно компактная карта и так как проходит достаточно много времени между моментом как волна начала спавниться и появлением ботов на карте, сложно настроить радиусы спавна/деспавна. Но можно огранить количество ботов в зонах (например, подземные уровни).
Развязка так же сложна для спавна/деспавна т.к. сам ТЦ находится на большом удалении от краев карты где может быть спавн игрока. Это приводит к тому что либо боты будут появляться в зоне с совместным выходом, либо в случайных зонах по всей карте т.к. алгоритм просто не сможет найти ближайшей зоны.
Лаборатория и оба завода по умолчанию не используют спавн/деспавн. На них работает ограничение количества живых ботов. На заводе мой алгоритм вообще бесполезен т.к. там одна зона и маленькая карта. Лаба не тестировалась, не моя карта, но она тоже не большая и многоуровневая - не думаю что алгоритм будет работать хорошо, разве что использовать его для распределения по зонам.
Для отладки можно использовать мой монитор ботов, который показывает расстояние до каждого бота и зону в которой он заспавнился.
## Установка
Скопировать папку *astealz-SmartSpawnController* в папку *user/mods*
## Конфигурация
Все настройки содержатся в файле *config.json*
### Секция "game"
* **DEBUG** - вывод отладочных сообщений в лог *SmartSpawnController.txt* и консоль в игре. Использовать при проблемах с работой.
* **VERBOSE** - вывод подробных сообщений в лог *SmartSpawnController.txt* и консоль в игре. Использовать для настройки.
* **ScavWaveRetryInterval** (секунд) - в случае если игра не может заспавнить волну ботов (не загружены ресурсы, превышен лимит живых ботов, зона занята культистами, не доступных точек спавна), то все или часть ботов этой волны будут поставлены в очередь. Данный параметр изменяет время через которое игра повторит попытку спавна этой волны ботов. По умолчанию 3 секунды.
* **Maps** - настройка для каждой карты, в качестве ключей используется *_Id* карты
* **MaxBotsAliveOnMap** - ограничение количества живых ботов на карте. По умолчанию в игре захардкожено значение 40, значение из *globals.json* игнорируется.
* **EnableSpawnControl** - включает управление спавном
* **MaxDistanceToSpawn** (метры) - максимальная дистанция от игрока до цента масс зоны (средняя точка среди всех спавн-точек зоны). Выбор точки спавна остается за игрой, поэтому боты могут спавниться как ближе, так и дальше этого значения.
* **ForceChooseNewZone** - включение принудительного изменения зоны спавна. Рекомендуется включать для равномерного спавна. При выключенном параметре сначала анализируется дистанция до зоны спавна, если расстояние меньше чем *MaxDistanceToSpawn* - изменения зоны не будет.
* **BotsPerZoneBase** - базовое значение количества ботов в зоне, используется для расчета "веса" зоны
* **ScavRoles** и **BossRoles** - роли диких и боссов у которых будет изменяться зона спавна. Снайперы (marksman) не поддерживаются модом, стандартные боссы и их фолловеры по умолчанию не изменяются (они поддерживаются, но не тестировались).
* **EnableUnspawn** - включение механизма деспавна при удалении от игрока. Расчет идет отдельно для каждого бота и в отличие от средней точки в менеджере спавна, здесь используется действительное расстояние до бота.
* **MaxDistanceToUnspawn** (метры) - максимальное расстояние после превышения которого будет деспавн бота
* **RolesToUnspawn** - только эти роли будут анализироваться и деспавниться
* **Zones** - настройка зон карты
* **CanSpawnBoss** - флаг возможности спавна босса (не очень понятно как работает, но игра фильтрует доступные зоны для боссов по этому флагу, можно менять)
* **SnipeZone** - флаг снайперской зоны. Представлен для информации, изменение не повлияет на игру. Все снайперские зоны исключаются из зон которые доступных для обычных диких и боссов
* **MaxPersonsOnPatrol** - максимальное количество живых ботов в зоне, не влияет на спавн босса и свиты. Можно менять (нужно тестировать)
* **MultiplicativeCoef** (float) - мультипликативный коэффициент зоны (см. алгоритм выбора зоны)
* **AdditiveCoef** (int, может быть отрицательным) - аддитивный коэффициент зоны (см. алгоритм выбора зоны)
### Секция "server"
Параметры JS-модуля:
* **Add_IgnoreMaxBots_ToBossWaves** - Добавляет параметр *IgnoreMaxBots* для всех боссов на всех локациях. Данный параметр позволяет обойти ограничение **MaxBotsAliveOnMap** при спавне босса, т.е. при лимите 12 после спавна босса будет 12 + босс + свита. Для ванильных боссов он не особо нужен т.к. время их спавна '-1' т.е. в самом начале рейда. Очень полезен для рейдеров и ЧВК-боссов.
* **ChangeWaveCoefs** - позволяет изменить коэффициенты количества ботов в волне. Рекомендуется установить *WAVE_COEF_MID* в 1. Объяснение будет ниже.
* **ReplaceLocationsWaves** - позволяет заменить стандартные/модифицированные волны диких на простые, но в большем количестве. Основная идея - сделать много волн состоящих из 1 бота. Т.к. максимальное количество ботов ограниченно, новые будут становиться в очередь и ждать свободного места. Это так же позволяет более равномерно наполнять зоны: если использовать зоны со случайным количеством ботов от 1 до 4, то возможна ситуация когда в зоне маленьким весом и лимитом в 2 бота появятся 4 за раз, а в зоне с большим весом будет 4 волны по 1 боту. Данное правило на относится к боссам.
* **ScavWaveCount** - количество волн диких
* **ScavInstaWaveCount** - количество волн диких на старте локации (к сожалению игра не хочет спавнить их ДО захода на локацию, поэтому они появляются где-то в райноне минуты)
* **ScavDifficultyWeight** - настройка сложности волн диких. Упрощенный вариант вероятности на основе веса. Чем больше значение, тем вероятнее что будет выбрана данная сложность. Ноль - выключает данную сложность из выбора.
### Алгоритм выбора зоны
1. Среди доступных зон (для диких - все зоны кроме снайперских, для боссов - все зоны диких с флагом *CanSpawnBoss*) выбираются те, у которых расстояние до средней точки попадает в радиус *MaxDistanceToSpawn*
2. Рассчитывается вес каждой зоны: \
Формула: Round((Base - Min(Base, BotCount)) * M + A), где \
*Round* - округление до целого, \
*Base* - BotsPerZoneBase, \
*BotCount* - количество ботов в зоне включая убитых, но не включая ботов из новой волны, \
*M* - MultiplicativeCoef, \
*A* - AdditiveCoef. \
Если вес <= 0, то зона исключается
3. Если по какой-либо причине не список зон оказывается пустой, то будет выбрана случайная зона из всех доступных и добавлена в список
4. Рассчитывается суммарный вес всех зон
5. В диапазоне *[1..Суммарный_вес]* выбирается случайное число
6. Выбирается зона в диапазон которой попадает случайное число
Мультипликативный коэффициент позволяет увеличить вес (вероятность) спавна ботов в данной зоне.
Аддитивный коэффициент регулирует максимальное количество ботов в зоне.
#### Примеры:
- Base=4, M=1, A=1 => Вес пустой зоны = 5, вес зоны с 4 и более ботами = 1
- Base=4, M=1, A=0 => Вес пустой зоны = 4, вес зоны с 4 ботами 0 (ограничение на 4 ботов)
- Base=4, M=2, A=1 => Вес пустой зоны = 9, вес зоны с 4 и более ботами = 1 (зона более вероятна для спавна)
- Base=4, M=1, A=-1 => Вес пустой зоны = 3, вес зоны с 3 и более ботами = 0 (ограничение на 3 бота)
- Base=4, M=0.5, A=0 => Вес пустой зоны = 2, вес зоны с 4 более ботами = 0 (зона менее вероятна для спавна)
#### Примеры из логов:
Для 5ой волны:
[5][Zones] ZoneRailStrorage [1], ZonePTOR1 [6], ZonePTOR2 [11], ZoneBarrack [16], ZoneBunkerStorage [18], ZoneSubStorage [22], ZoneSubCommand [26]
[5][Selected] 5 @ ZonePTOR1
[5] - номер волны (внутренний общий счетчик для диких, боссов и свиты)
[Zones] - доступные зоны для данной волны и из вес
[Selected] - выбранное случайное число в диапазоне [1..26] и зона в диапазон которой попадает это число
Для 67 волны:
[67][Zones] ZoneRailStrorage [1], ZonePTOR1 [2], ZonePTOR2 [3], ZoneBarrack [4], ZoneSubCommand [5]
[67][Selected] 1 @ ZoneRailStrorage
т.к. во всех зонах уже превышен лимит ботов на зону остались только зоны где возможен "вечный" спавн.
### Отладочные сообщения
Включение вывода отладочных сообщений возможно как в конфиге, так и через консоль (команда *spawner-debug*).
При загрузке карты мод показывает зашитые в игру зоны и их параметры, а так же измененные зоны из конфигурации мода. Новые зоны добавить нельзя. Можно использовать для конфигурации, вывод сделан в json.
На каждую волну ботов выводятся сообщения о номере волны, исходной зоне, её изменении, признак того что волна поставленна в очередь ожидания, либо возможные ошибки.
# Version history
* 1.0.0 - Первый релиз
* 1.0.1 - Исправлен баг при попытке спавна босса с несуществующей в игре зоной, исправления серверой части
* 1.0.2 - Исправлен баг со сломанными ботами

View File

@ -0,0 +1,489 @@
{
"game": {
"_": "Enable debug messages to log and game console",
"DEBUG": false,
"_": "Enable verbose mode",
"VERBOSE": true,
"_": "Allows you to change the time after which an attempt will be made to spawn a delayed wave. Default value is 3",
"ScavWaveRetryInterval": 15,
"Maps": {
"56f40101d2720b2a4d8b45d6": {
"_": "bigmap",
"EnableSpawnControl": true,
"ForceChooseNewZone": true,
"MaxBotsAliveOnMap": 15,
"BotsPerZoneBase": 4,
"MaxDistanceToSpawn": 350,
"ScavRoles": [
"assault"
],
"BossRoles": [
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"EnableUnspawn": true,
"MaxDistanceToUnspawn": 400,
"RolesToUnspawn": [
"assault",
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"Zones": {
"ZoneDormitory": {
"MultiplicativeCoef": 2,
"AdditiveCoef": 2
},
"ZoneTankSquare": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneCrossRoad": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneGasStation": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneScavBase": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneBlockPost": {
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneFactorySide": {
"MultiplicativeCoef": 0.4,
"AdditiveCoef": 0
},
"ZoneFactoryCenter": {
"MultiplicativeCoef": 0.4,
"AdditiveCoef": 0
},
"ZoneCustoms": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneOldAZS": {
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneBrige": {
"MultiplicativeCoef": 0.25,
"AdditiveCoef": 0
},
"ZoneWade": {
"MultiplicativeCoef": 0.4,
"AdditiveCoef": 0
}
}
},
"5704e554d2720bac5b8b456e": {
"_": "shoreline",
"EnableSpawnControl": true,
"ForceChooseNewZone": true,
"MaxBotsAliveOnMap": 15,
"BotsPerZoneBase": 4,
"MaxDistanceToSpawn": 350,
"ScavRoles": [
"assault"
],
"BossRoles": [
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"EnableUnspawn": true,
"MaxDistanceToUnspawn": 400,
"RolesToUnspawn": [
"assault",
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"Zones": {
"ZonePowerStation": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 2,
"AdditiveCoef": 1
},
"ZoneBusStation": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneSanatorium1": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 2,
"AdditiveCoef": 1
},
"ZoneSanatorium2": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 2,
"AdditiveCoef": 1
},
"ZoneMeteoStation": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneGreenHouses": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZonePort": {
"CanSpawnBoss": true,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneIsland": {
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneForestGasStation": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneGasStation": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneBunker": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneForestTruck": {
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneForestSpawn": {
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneStartVillage": {
"MultiplicativeCoef": 0.75,
"AdditiveCoef": 0
},
"ZoneRailWays": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZonePassFar": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZonePassClose": {
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneTunnel": {
"MultiplicativeCoef": 0.75,
"AdditiveCoef": 0
}
}
},
"5704e3c2d2720bac5b8b4567": {
"_": "woods",
"EnableSpawnControl": true,
"ForceChooseNewZone": true,
"MaxBotsAliveOnMap": 15,
"BotsPerZoneBase": 4,
"MaxDistanceToSpawn": 350,
"ScavRoles": [
"assault"
],
"BossRoles": [
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"EnableUnspawn": true,
"MaxDistanceToUnspawn": 400,
"RolesToUnspawn": [
"assault",
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"Zones": {
"ZoneWoodCutter": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MaxPersonsOnPatrol": 8,
"MultiplicativeCoef": 2,
"AdditiveCoef": 2
},
"ZoneHouse": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 6,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneBigRocks": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 7,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneRoad": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 7,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneHighRocks": {
"CanSpawnBoss": false,
"SnipeZone": true,
"MaxPersonsOnPatrol": 1,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneMiniHouse": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 9,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneRedHouse": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 9,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneScavBase2": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 4,
"MultiplicativeCoef": 2,
"AdditiveCoef": 1
},
"ZoneClearVill": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 4,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneBrokenVill": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MaxPersonsOnPatrol": 2,
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
}
}
},
"5704e5fad2720bc05b8b4567": {
"_": "reservebase",
"EnableSpawnControl": true,
"ForceChooseNewZone": true,
"MaxBotsAliveOnMap": 15,
"BotsPerZoneBase": 4,
"MaxDistanceToSpawn": 250,
"ScavRoles": [
"assault"
],
"BossRoles": [
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"EnableUnspawn": true,
"MaxDistanceToUnspawn": 300,
"RolesToUnspawn": [
"assault",
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"Zones": {
"ZoneRailStrorage": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZonePTOR1": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZonePTOR2": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneBarrack": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneBunkerStorage": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 0.5,
"AdditiveCoef": 0
},
"ZoneSubStorage": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneSubCommand": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 0.75,
"AdditiveCoef": 0
}
}
},
"5714dbc024597771384a510d": {
"_": "interchange",
"EnableSpawnControl": true,
"ForceChooseNewZone": true,
"MaxBotsAliveOnMap": 12,
"BotsPerZoneBase": 4,
"MaxDistanceToSpawn": 450,
"ScavRoles": [
"assault"
],
"BossRoles": [
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"EnableUnspawn": true,
"MaxDistanceToUnspawn": 500,
"RolesToUnspawn": [
"assault",
"assaultGroup",
"cursedAssault",
"pmcBot"
],
"Zones": {
"ZoneCenterBot": {
"CanSpawnBoss": true,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneIDEA": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneCenter": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneIDEAPark": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 0.3,
"AdditiveCoef": 0
},
"ZoneTrucks": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 0
},
"ZoneRoad": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 0.3,
"AdditiveCoef": 0
},
"ZoneOLI": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneGoshan": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": 1
},
"ZoneOLIPark": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 0.3,
"AdditiveCoef": 0
},
"ZonePowerStation": {
"CanSpawnBoss": false,
"SnipeZone": false,
"MultiplicativeCoef": 1,
"AdditiveCoef": -1
}
}
},
"5b0fc42d86f7744a585f9105": {
"_": "laboratory",
"MaxBotsAliveOnMap": 20,
"EnableSpawnControl": false,
"EnableUnspawn": false
},
"55f2d3fd4bdc2d5f408b4567": {
"_": "factory4_day",
"MaxBotsAliveOnMap": 20,
"EnableSpawnControl": false,
"EnableUnspawn": false
},
"59fc81d786f774390775787e": {
"_": "factory4_night",
"MaxBotsAliveOnMap": 20,
"EnableSpawnControl": false,
"EnableUnspawn": false
}
}
},
"server": {
"DEBUG": false,
"_": "IgnoreMaxBots for boss wave allows spawn boss wave even if limit of max alive bots is reached",
"Add_IgnoreMaxBots_ToBossWaves": true,
"_": "For better control of zones population scav waves mast contains only 1 bot. By default WAVE_COEF_MID is applied, so make it equal to 1",
"ChangeWaveCoefs": {
"WAVE_COEF_LOW": 0.5,
"WAVE_COEF_MID": 1,
"WAVE_COEF_HIGH": 2,
"WAVE_COEF_HORDE": 10
},
"_": "The group of settings below allows you to replace default scav waves with generated ones (except marksman)",
"_": "They consist of 1 bot, a random zone (the zone will be chosen when spawning) and the spawn time of each gradually increases to escape_time_limit * 0.75",
"_": "You can not use this settings, but then you need a manual edit waves or use a mod like Lua-CP-SpawnReworkReborn",
"_": "You really need a lot of scav waves to use smart spawner",
"ReplaceLocationsWaves": true,
"_": "Output generated scav waves in server log",
"ShowGeneratedWaves": false,
"_": "How many scav waves will be generated",
"ScavWaveCount": 50,
"_": "How many bots will be spawned at map load",
"ScavInstaWaveCount": 10,
"_": "Difficulty weight allows to change generated waves difficulty. The higher the value, the more likely it is.",
"ScavDifficultyWeight": {
"easy": 0,
"normal": 1,
"hard": 3,
"impossible": 0
}
}
}

Binary file not shown.

View File

@ -0,0 +1,199 @@
"use strict";
const mod = require("./package.json");
const config = require("./config.json");
class ModMain {
constructor() {
this.modName = `${mod.author.toLowerCase()}-${mod.name.toLowerCase()}`;
Logger.info(`Loading: ${this.modName} : ${mod.version}`);
const response = {};
response[`${this.modName}`] = (url, info, sessionID, output) => HttpResponse.noBody(config.game);
HttpRouter.onStaticRoute[`/mods/${this.modName}/config`] = response;
this.clientLocationsHandler = HttpRouter.onStaticRoute["/client/locations"];
HttpRouter.onStaticRoute["/client/locations"] = { "aki": (url, info, sessionID, output) => {
for (const handlerId in this.clientLocationsHandler) {
const handler = this.clientLocationsHandler[handlerId];
output = handler(url, info, sessionID, output);
}
const response = JsonUtil.deserialize(output);
this.updateLocations(response.data.locations);
return HttpResponse.getBody(response.data);
}};
ModLoader.onLoad[this.modname] = this.load.bind(this);
}
debug_message(message) {
if (config.server.DEBUG)
Logger.log(`[DEBUG] ${mod.name}: ${message}`, "green", "black");
}
info_message(message) {
Logger.info(`${mod.name}: ${message}`);
}
error_messsage(message) {
Logger.error(`${mod.name}: ${message}`);
}
load() {
const db = DatabaseServer.tables;
for (const coef in config.server.ChangeWaveCoefs) {
const value = config.server.ChangeWaveCoefs[coef];
db.globals.config[coef] = value;
}
}
addIgnoreMaxBots(location) {
if (!location.BossLocationSpawn)
return false;
let isAdded = false;
for (const bossWave of location.BossLocationSpawn) {
bossWave["IgnoreMaxBots"] = true;
isAdded = true;
}
return isAdded;
}
replaceScavWaves(location, waveTemplate, difficultyArray) {
const waves = [];
const spawnPoints = [];
const escapeTimeLimitMin = location.escape_time_limit;
let waveNumber = 1;
let hasBots = false;
for (const wave of location.waves) {
if (wave.WildSpawnType == "marksman")
{
wave.number = waveNumber++;
waves.push(wave);
}
if (wave.WildSpawnType != "marksman" && wave.SpawnPoints.length > 0)
spawnPoints.push(wave.SpawnPoints);
if (wave.slots_min > 0 || wave.slots_max > 0)
hasBots = true;
}
if (!hasBots) {
this.debug_message(`No bots found on '${location.Id}'`);
return false;
}
if (spawnPoints.length == 0)
{
this.debug_message(`No spawn points found on '${location.Id}'`);
spawnPoints.push("");
}
let instaWaveCount = config.server.ScavInstaWaveCount;
for (let i = 0; i < config.server.ScavWaveCount; i++) {
const wave = JsonUtil.clone(waveTemplate);
wave.number = waveNumber++;
wave.BotPreset = RandomUtil.getArrayValue(difficultyArray);
wave.SpawnPoints = RandomUtil.getArrayValue(spawnPoints);
wave.time_min = instaWaveCount > 0
? 0
: Math.round((i - config.server.ScavInstaWaveCount + 1) * 60 * ((escapeTimeLimitMin * 0.75) / config.server.ScavWaveCount));
wave.time_max = instaWaveCount > 0
? 1
: Math.round((i - config.server.ScavInstaWaveCount + 1) * 60 * ((escapeTimeLimitMin * 0.75) / config.server.ScavWaveCount)) + 1;
if (instaWaveCount > 0)
instaWaveCount--;
waves.push(wave);
}
location.waves = waves;
return true;
}
showScavWaves(location) {
let waveNumber = 1;
this.info_message(`Location = ${location.Id}`);
for (const wave of location.waves) {
this.info_message(`[${waveNumber}] [${wave.time_min}-${wave.time_max}s] ${wave.slots_min}-${wave.slots_max} [${wave.WildSpawnType}][${wave.BotPreset}] @ [${wave.SpawnPoints}]`);
waveNumber++;
}
}
updateLocations(locations) {
for (const locationId in locations) {
const location = locations[locationId];
if (!location || location.Id == "hideout")
continue;
if (config.server.Add_IgnoreMaxBots_ToBossWaves) {
let replaceMessage = `[${location.Id}] Adding 'IgnoreMaxBots' to boss waves...`;
try {
const result = this.addIgnoreMaxBots(location);
replaceMessage += result
? "done"
: "skipped";
} catch (error) {
replaceMessage += "error!";
this.error_message(error);
} finally {
this.info_message(replaceMessage);
}
}
if (config.server.ReplaceLocationsWaves)
{
const waveTemplate = {
"number": 0,
"time_min": 0,
"time_max": 1,
"slots_min": 1,
"slots_max": 1,
"SpawnPoints": "BotZone",
"BotSide": "Savage",
"BotPreset": "easy",
"WildSpawnType": "assault",
"isPlayers": false
};
const difficultyArray = this.getWeightArray(config.server.ScavDifficultyWeight);
let replaceMessage = `[${location.Id}] Replacing scav waves...`;
try {
const result = this.replaceScavWaves(location, waveTemplate, difficultyArray);
replaceMessage += result
? "done"
: "skipped";
} catch (error) {
replaceMessage += "error!";
this.error_message(error);
} finally {
this.info_message(replaceMessage);
}
}
if (config.server.ShowGeneratedWaves)
this.showScavWaves(location);
}
}
getWeightArray(entity) {
const result = [];
for (const property in entity) {
const value = entity[property];
for (let i = 0; i < value; i++) {
result.push(property);
}
}
return result;
}
}
module.exports = new ModMain();

View File

@ -0,0 +1,8 @@
{
"name": "smartspawncontroller",
"author": "astealz",
"version": "1.0.2",
"license": "NCSA Open Source",
"main": "package.js"
}

172
src/Aki/Request.cs Normal file
View File

@ -0,0 +1,172 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
namespace Aki.Common
{
public static class HttpConstants
{
/// <summary>
/// HTML GET method.
/// </summary>
public const string Get = "GET";
/// <summary>
/// HTML HEAD method.
/// </summary>
public const string Head = "HEAD";
/// <summary>
/// HTML POST method.
/// </summary>
public const string Post = "POST";
/// <summary>
/// HTML PUT method.
/// </summary>
public const string Put = "PUT";
/// <summary>
/// HTML DELETE method.
/// </summary>
public const string Delete = "DELETE";
/// <summary>
/// HTML CONNECT method.
/// </summary>
public const string Connect = "CONNECT";
/// <summary>
/// HTML OPTIONS method.
/// </summary>
public const string Options = "OPTIONS";
/// <summary>
/// HTML TRACE method.
/// </summary>
public const string Trace = "TRACE";
/// <summary>
/// HTML MIME types.
/// </summary>
public static Dictionary<string, string> Mime { get; private set; }
static HttpConstants()
{
Mime = new Dictionary<string, string>()
{
{ ".bin", "application/octet-stream" },
{ ".txt", "text/plain" },
{ ".htm", "text/html" },
{ ".html", "text/html" },
{ ".css", "text/css" },
{ ".js", "text/javascript" },
{ ".jpeg", "image/jpeg" },
{ ".jpg", "image/jpeg" },
{ ".png", "image/png" },
{ ".ico", "image/vnd.microsoft.icon" },
{ ".json", "application/json" }
};
}
/// <summary>
/// Is HTML method valid?
/// </summary>
public static bool IsValidMethod(string method)
{
return method == Get
|| method == Head
|| method == Post
|| method == Put
|| method == Delete
|| method == Connect
|| method == Options
|| method == Trace;
}
/// <summary>
/// Is MIME type valid?
/// </summary>
public static bool IsValidMime(string mime)
{
return Mime.Any(x => x.Value == mime);
}
}
public class Request
{
/// <summary>
/// Send a request to remote endpoint and optionally receive a response body.
/// Deflate is the accepted compression format.
/// </summary>
public byte[] Send(string url, string method, byte[] data = null, bool compress = true, string mime = null, Dictionary<string, string> headers = null)
{
if (!HttpConstants.IsValidMethod(method))
{
throw new ArgumentException("request method is invalid");
}
Uri uri = new Uri(url);
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(uri);
if (uri.Scheme == "https")
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
request.ServerCertificateValidationCallback = delegate { return true; };
}
request.Timeout = 1000;
request.Method = method;
request.Headers.Add("Accept-Encoding", "deflate");
if (headers != null)
{
foreach (KeyValuePair<string, string> item in headers)
{
request.Headers.Add(item.Key, item.Value);
}
}
if (method != HttpConstants.Get && method != HttpConstants.Head && data != null)
{
byte[] body = (compress) ? Zlib.Compress(data, ZlibCompression.Maximum) : data;
request.ContentType = HttpConstants.IsValidMime(mime) ? mime : "application/octet-stream";
request.ContentLength = body.Length;
if (compress)
{
request.Headers.Add("Content-Encoding", "deflate");
}
using (Stream stream = request.GetRequestStream())
{
stream.Write(body, 0, body.Length);
}
}
using (WebResponse response = request.GetResponse())
{
using (MemoryStream ms = new MemoryStream())
{
response.GetResponseStream().CopyTo(ms);
byte[] body = ms.ToArray();
if (body.Length == 0)
{
return null;
}
if (Zlib.IsCompressed(body))
{
return Zlib.Decompress(body);
}
return body;
}
}
}
}
}

80
src/Aki/RequestHandler.cs Normal file
View File

@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Text;
using Aki.Common;
using astealz.SmartSpawnController.Utils;
namespace Aki.SinglePlayer.Utils
{
public class ServerConfig
{
public string BackendUrl { get; }
public string Version { get; }
public ServerConfig(string backendUrl, string version)
{
BackendUrl = backendUrl;
Version = version;
}
}
public static class RequestHandler
{
private static string _host;
private static string _session;
private static Request _request;
private static Dictionary<string, string> _headers;
static RequestHandler()
{
Initialize();
}
private static void Initialize()
{
_request = new Request();
var args = Environment.GetCommandLineArgs();
foreach (var arg in args)
{
if (arg.Contains("BackendUrl"))
{
var json = arg.Replace("-config=", string.Empty);
_host = Newtonsoft.Json.JsonConvert.DeserializeObject<ServerConfig>(json).BackendUrl;
}
if (arg.Contains("-token="))
{
_session = arg.Replace("-token=", string.Empty);
_headers = new Dictionary<string, string>()
{
{ "Cookie", $"PHPSESSID={_session}" },
{ "SessionId", _session }
};
}
}
}
private static void ValidateJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
{
Logger.Error($"Request failed, body is null");
}
Logger.Info($"Request was successful");
}
public static string GetJson(string path)
{
var url = _host + path;
Logger.Info($"Request GET json: {_session}:{url}");
var data = _request.Send(url, "GET", headers: _headers);
var result = Encoding.UTF8.GetString(data);
ValidateJson(result);
return result;
}
}
}

127
src/Aki/ZLib.cs Normal file
View File

@ -0,0 +1,127 @@
using System;
using System.IO;
using ComponentAce.Compression.Libs.zlib;
namespace Aki.Common
{
public enum ZlibCompression
{
Store = 0,
Fastest = 1,
Fast = 3,
Normal = 5,
Ultra = 7,
Maximum = 9
}
public static class Zlib
{
// Level | CM/CI FLG
// ----- | ---------
// 1 | 78 01
// 2 | 78 5E
// 3 | 78 5E
// 4 | 78 5E
// 5 | 78 5E
// 6 | 78 9C
// 7 | 78 DA
// 8 | 78 DA
// 9 | 78 DA
/// <summary>
/// Check if the file is ZLib compressed
/// </summary>
/// <param name="Data">Data</param>
/// <returns>If the file is Zlib compressed</returns>
public static bool IsCompressed(byte[] Data)
{
// We need the first two bytes;
// First byte: Info (CM/CINFO) Header, should always be 0x78
// Second byte: Flags (FLG) Header, should define our compression level.
if (Data == null || Data.Length < 3 || Data[0] != 0x78)
{
return false;
}
switch (Data[1])
{
case 0x01: // fastest
case 0x5E: // low
case 0x9C: // normal
case 0xDA: // max
return true;
}
return false;
}
/// <summary>
/// Deflate data.
/// </summary>
public static byte[] Compress(byte[] data, ZlibCompression level)
{
byte[] buffer = new byte[data.Length + 24];
ZStream zs = new ZStream()
{
avail_in = data.Length,
next_in = data,
next_in_index = 0,
avail_out = buffer.Length,
next_out = buffer,
next_out_index = 0
};
zs.deflateInit((int)level);
zs.deflate(zlibConst.Z_FINISH);
data = new byte[zs.next_out_index];
Array.Copy(zs.next_out, 0, data, 0, zs.next_out_index);
return data;
}
/// <summary>
/// Inflate data.
/// </summary>
public static byte[] Decompress(byte[] data)
{
byte[] buffer = new byte[4096];
ZStream zs = new ZStream()
{
avail_in = data.Length,
next_in = data,
next_in_index = 0,
avail_out = buffer.Length,
next_out = buffer,
next_out_index = 0
};
zs.inflateInit();
using (MemoryStream ms = new MemoryStream())
{
do
{
zs.avail_out = buffer.Length;
zs.next_out = buffer;
zs.next_out_index = 0;
int result = zs.inflate(0);
if (result != 0 && result != 1)
{
break;
}
ms.Write(zs.next_out, 0, zs.next_out_index);
}
while (zs.avail_in > 0 || zs.avail_out == 0);
return ms.ToArray();
}
}
}
}

View File

@ -0,0 +1,164 @@
using astealz.SmartSpawnController.Utils;
using EFT;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace astealz.SmartSpawnController.Behaviors
{
class BotUnspawnController : MonoBehaviour
{
private const int playersPool = 40;
private const int maxAttempts = 2;
private const float updateRate = 5f;
private float timer = 0;
private Player localPlayer = null;
private float maxDist = float.MaxValue;
private float sqrMaxDist = float.MaxValue;
private readonly List<Player> players = new List<Player>(playersPool);
private readonly Dictionary<string, int> playerCounters = new Dictionary<string, int>(playersPool);
private readonly List<WildSpawnType> rolesToLeave =
new List<WildSpawnType>(Enum.GetValues(typeof(WildSpawnType)).Length) {
WildSpawnType.assault,
WildSpawnType.assaultGroup,
WildSpawnType.cursedAssault,
WildSpawnType.pmcBot
};
public bool DEBUG_ENABLED => Globals.Config.Debug;
public bool VERBOSE_ENABLED => Globals.Config.Verbose;
public BotUnspawnController()
{
enabled = false;
}
public void SetRoles(WildSpawnType[] roles)
{
if (roles == null || roles.Length == 0)
return;
rolesToLeave.Clear();
foreach (var role in roles)
{
rolesToLeave.Add(role);
}
}
public void SetMaxDist(float maxDist)
{
if (maxDist < 100f || maxDist > 1000f)
{
maxDist = 500f;
Utils.Logger.Info("Maximal distance must be in range [100, 1000]");
}
this.maxDist = maxDist;
this.sqrMaxDist = maxDist * maxDist;
}
void OnEnable()
{
Utils.Logger.Info($"BotUnspawnController activated!");
Utils.Logger.Info($"Max distance to unspawn: {maxDist.ToString("F1")} / roles to unspawn: {string.Join(", ", rolesToLeave)}");
}
void OnDisable()
{
Utils.Logger.Info($"BotUnspawnController deactivated!");
}
void Update()
{
timer += Time.deltaTime;
if (timer < updateRate)
return;
timer = 0;
if (localPlayer == null)
localPlayer = Globals.LocalPlayer;
if (localPlayer == null)
return;
players.Clear();
foreach (var player in Globals.GameWorld.RegisteredPlayers)
{
if (!player.isActiveAndEnabled || !player.IsAI)
continue;
players.Add(player);
}
foreach (var player in players)
{
BotOwner botOwner = player.GetBotOwner();
// we need an active bot
if (botOwner.BotState != EBotState.Active)
continue;
string profileId = player.ProfileId;
if (string.IsNullOrWhiteSpace(profileId))
{
Utils.Logger.Error("Bot ProfileId is empty!");
continue;
}
// don't bother those who already wanna leave (game logic, especially when cultists are spawned)
if (botOwner.IsWannaLeave())
continue;
//var role = botOwner.Profile.Info.Settings.Role;
WildSpawnType role = botOwner.Profile.GetRole();
// filter by bot role
if (false == rolesToLeave.Contains(role))
continue;
// calc distance
var sqrDist = (botOwner.Transform.position - localPlayer.Transform.position).sqrMagnitude;
// bot has 'maxAttempts' attempts to get closer to local player
int playerCounter = maxAttempts;
if (!playerCounters.TryGetValue(profileId, out playerCounter))
playerCounters.Add(profileId, playerCounter);
if (sqrDist > sqrMaxDist)
// and minus one
playerCounter -= 1;
else if (playerCounter < maxAttempts)
// oh, he's trying...
playerCounter += 1;
if (playerCounter > 0)
{
playerCounters[profileId] = playerCounter;
// next
continue;
}
// he didn't make it...
BotZone botZone = botOwner.GetBotZone();
string zone = botZone != null ? botZone.NameZone : null;
if (string.IsNullOrWhiteSpace(zone))
zone = "unknown";
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{role}] '{botOwner.Profile.GetNickname().TransliterateThis()}' is leaving zone '{zone}'");
// remove bot from dictionary
playerCounters.Remove(profileId);
// despawning
player.KillMe(EBodyPart.Head, 999f);
player.PlayerBody.PlayerBones.transform.position += Vector3.down * 100f;
}
}
}
}

50
src/Config.cs Normal file
View File

@ -0,0 +1,50 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController
{
public class Config
{
public bool Debug { get; set; }
public bool Verbose { get; set; }
public float UpdateRate = 5f;
public float ScavWaveRetryInterval = 15f;
[JsonConverter(typeof(Utils.Converter1))]
public Dictionary<string, MapConfig> Maps { get; set; } = new Dictionary<string, MapConfig>();
}
public class MapConfig
{
public bool EnableSpawnControl { get; set; } = false;
public bool ForceChooseNewZone { get; set; } = true;
public int MaxBotsAliveOnMap { get; set; } = 40;
public int BotsPerZoneBase { get; set; } = 8;
public float MaxDistanceToSpawn { get; set; } = float.MaxValue;
public EFT.WildSpawnType[] ScavRoles { get; set; } = new EFT.WildSpawnType[0];
public EFT.WildSpawnType[] BossRoles { get; set; } = new EFT.WildSpawnType[0];
public bool EnableUnspawn { get; set; } = false;
public float MaxDistanceToUnspawn { get; set; } = float.MaxValue;
public EFT.WildSpawnType[] RolesToUnspawn { get; set; } = new EFT.WildSpawnType[0];
[JsonConverter(typeof(Utils.Converter2))]
public Dictionary<string, ZoneConfig> Zones { get; set; } = new Dictionary<string, ZoneConfig>();
}
public class ZoneConfig
{
public bool? CanSpawnBoss { get; set; } = null;
public int? MaxPersonsOnPatrol { get; set; } = null;
public float MultiplicativeCoef { get; set; } = 1;
public int AdditiveCoef { get; set; } = 1;
}
}

110
src/Extensions.cs Normal file
View File

@ -0,0 +1,110 @@
using astealz.SmartSpawnController.Utils;
using EFT;
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace astealz.SmartSpawnController
{
static class Extensions
{
private static readonly PropertyGetter<object> getAiData;
private static readonly PropertyGetter<BotOwner> getBotOwner;
private static readonly PropertyGetter<object> getLeaveData;
private static readonly PropertyGetter<bool> getWannaLeave;
private static readonly AccessTools.FieldRef<object, object> getInfo;
private static readonly AccessTools.FieldRef<object, object> getSettings;
private static readonly AccessTools.FieldRef<object, WildSpawnType> getRole;
private static readonly PropertyGetter<object> getBotsGroup;
private static readonly PropertyGetter<BotZone> getBotZone;
private static readonly AccessTools.FieldRef<object, object> getBotGame;
private static readonly AccessTools.FieldRef<object, string> getNickname;
private static MethodInvoker<object> botUnspawn;
static Extensions()
{
var aiDataProperty = AccessTools.Property(typeof(Player), nameof(Player.AIData));
var botOwnerProperty = AccessTools.Property(aiDataProperty.PropertyType, nameof(Player.AIData.BotOwner));
var leaveDataProperty = AccessTools.Property(typeof(BotOwner), nameof(BotOwner.LeaveData));
var wannaLeaveProperty = AccessTools.Property(leaveDataProperty.PropertyType, nameof(BotOwner.LeaveData.WannaLeave));
var infoField = AccessTools.Field(typeof(Profile), nameof(Profile.Info));
var settingsField = AccessTools.Field(infoField.FieldType, nameof(Profile.Info.Settings));
var roleField = AccessTools.Field(settingsField.FieldType, nameof(Profile.Info.Settings.Role));
var botsGroupProperty = AccessTools.Property(typeof(BotOwner), nameof(BotOwner.BotsGroup));
var botZoneProperty = AccessTools.Property(botsGroupProperty.PropertyType, nameof(BotOwner.BotsGroup.BotZone));
var botGameField = AccessTools.Field(botsGroupProperty.PropertyType, nameof(BotOwner.BotsGroup.BotGame));
var nickNameField = AccessTools.Field(infoField.FieldType, nameof(Profile.Info.Nickname));
getAiData = Emit.CreateDynamicPropertyGetter<object>(aiDataProperty);
getBotOwner = Emit.CreateDynamicPropertyGetter<BotOwner>(botOwnerProperty);
getLeaveData = Emit.CreateDynamicPropertyGetter<object>(leaveDataProperty);
getWannaLeave = Emit.CreateDynamicPropertyGetter<bool>(wannaLeaveProperty);
getInfo = AccessTools.FieldRefAccess<object>(typeof(Profile), nameof(Profile.Info));
getSettings = AccessTools.FieldRefAccess<object>(infoField.FieldType, nameof(Profile.Info.Settings));
getRole = AccessTools.FieldRefAccess<WildSpawnType>(settingsField.FieldType, nameof(Profile.Info.Settings.Role));
getBotsGroup = Emit.CreateDynamicPropertyGetter<object>(botsGroupProperty);
getBotZone = Emit.CreateDynamicPropertyGetter<BotZone>(botZoneProperty);
getBotGame = AccessTools.FieldRefAccess<object>(botsGroupProperty.PropertyType, nameof(BotOwner.BotsGroup.BotGame));
getNickname = AccessTools.FieldRefAccess<string>(infoField.FieldType, nameof(Profile.Info.Nickname));
}
public static T GetOrAddComponentToGameObject<T>(this GameObject gameObject) where T : MonoBehaviour
{
var c = gameObject.GetComponent<T>();
if (c == null)
c = gameObject.AddComponent<T>();
return c;
}
public static string GetNickname(this Profile profile)
{
var info = getInfo(profile);
return getNickname(info);
}
public static BotOwner GetBotOwner(this Player player)
{
var aiData = getAiData(player);
return getBotOwner(aiData);
}
public static bool IsWannaLeave(this BotOwner botOwner)
{
var leaveData = getLeaveData(botOwner);
return getWannaLeave(leaveData);
}
public static WildSpawnType GetRole(this Profile profile)
{
var info = getInfo(profile);
var settings = getSettings(info);
return getRole(settings);
}
public static BotZone GetBotZone(this BotOwner botOwner)
{
var botsGroup = getBotsGroup(botOwner);
return getBotZone(botsGroup);
}
public static void Unspawn(this BotOwner botOwner)
{
var botsGroup = getBotsGroup(botOwner);
var botGame = getBotGame(botsGroup);
if (botUnspawn == null)
{
// bot game is an interface, need to get realization type to create the dynamic method
var botGameTrueType = botGame.GetType();
var botUnspawnMethod = AccessTools.Method(botGameTrueType, nameof(BotOwner.BotsGroup.BotGame.BotUnspawn));
botUnspawn = Emit.CreateDynamicMethodInvoker<object>(botUnspawnMethod);
}
botUnspawn(botGame, botOwner);
}
}
}

62
src/Globals.cs Normal file
View File

@ -0,0 +1,62 @@
using Comfort.Common;
using EFT;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace astealz.SmartSpawnController
{
static class Globals
{
public static Config Config { get; set; }
public static MapConfig CurrentMapConfig {
get {
MapConfig config = null;
if (!Config.Maps.TryGetValue(LocationId, out config))
{
Utils.Logger.Error($"No configuration found for LocationId = '{LocationId}'");
return new MapConfig();
}
return config;
}
}
public static string LocationId {
get
{
var game = GameObject.Find("GAME");
if (game == null)
return string.Empty;
var abstractGame = game.GetComponent<AbstractGame>();
if (abstractGame == null)
return string.Empty;
return abstractGame.LocationObjectId ?? string.Empty;
}
}
public static Player LocalPlayer => GameWorldInitialized
? GetLocalPlayer(GameWorld.RegisteredPlayers)
: null;
public static GameObject Game => GameObject.Find("GAME");
public static bool GameWorldInitialized => Singleton<GameWorld>.Instantiated;
public static GameWorld GameWorld => Singleton<GameWorld>.Instance;
static Player GetLocalPlayer(List<Player> players)
{
foreach (var player in players)
{
if (player.IsYourPlayer)
return player;
}
return null;
}
}
}

64
src/Module.cs Normal file
View File

@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using astealz.SmartSpawnController.Utils;
using Comfort.Common;
using EFT;
using Newtonsoft.Json;
namespace astealz.SmartSpawnController
{
static class Module
{
public const string Name = "astealz-SmartSpawnController";
private static bool isApplyPatchesSuccess;
static void Main()
{
Logger.Info($"Loading: {Name}");
try
{
// start console initialization
EFT.StaticManager.Instance.StaticUpdate += Instance_StaticUpdate;
// get config from server
var json = Aki.SinglePlayer.Utils.RequestHandler.GetJson($"/mods/{Name.ToLower()}/config");
var config = JsonConvert.DeserializeObject<Config>(json);
Globals.Config = config;
// apply patches
isApplyPatchesSuccess = Patches.BotSpawnerPatches.Apply();
}
catch (Exception ex)
{
Logger.Error(ex.ToString());
}
}
private static void Instance_StaticUpdate()
{
var console = UnityEngine.GameObject.Find("Console");
if (console == null)
return;
var cs = console.GetComponent<EFT.UI.ConsoleScreen>();
if (cs == null)
return;
Utils.GameConsole.Initialize(cs);
if (isApplyPatchesSuccess)
Logger.Info("Ready!");
else
Logger.Info("An error occurred while loading!");
EFT.StaticManager.Instance.StaticUpdate -= Instance_StaticUpdate;
}
}
}

View File

@ -0,0 +1,75 @@
using EFT;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Patches
{
static class BotSpawnerPatchExtensions
{
public static string ToStr(this SortedList<int, string> list)
{
var sb = new StringBuilder();
int i = 0;
foreach (var keyValue in list)
{
if (i > 0)
sb.Append(", ");
sb.Append($"{keyValue.Value} [{keyValue.Key.ToString()}]");
i++;
}
return sb.ToString();
}
public static string ToStr(this EPlayerSide side)
{
switch (side)
{
case EPlayerSide.Bear:
return "Bear";
case EPlayerSide.Savage:
return "Savage";
case EPlayerSide.Usec:
return "Usec";
default:
throw new NotSupportedException();
}
}
public static string ToConfigJson(this BotZone[] botZones)
{
var sb = new StringBuilder();
sb.Append("{");
int index = 0;
foreach (var zone in botZones)
{
if (index > 0)
sb.Append(",");
sb.Append($"\"{zone.NameZone}\":{{\"CanSpawnBoss\":{zone.CanSpawnBoss.ToString().ToLower()},\"SnipeZone\":{zone.SnipeZone.ToString().ToLower()},\"MaxPersonsOnPatrol\":{zone.MaxPersonsOnPatrol.ToString()},\"MultiplicativeCoef\":1,\"AdditiveCoef\":1}}");
index++;
}
sb.Append("}");
return sb.ToString();
}
public static string ToConfigJson(this Dictionary<string, Tuple<float, int>> zonesDictionary, BotZone[] botZones)
{
var sb = new StringBuilder();
sb.Append("{");
int index = 0;
foreach (var zone in botZones)
{
var param = zonesDictionary[zone.NameZone];
if (index > 0)
sb.Append(",");
sb.Append($"\"{zone.NameZone}\":{{\"CanSpawnBoss\":{zone.CanSpawnBoss.ToString().ToLower()},\"SnipeZone\":{zone.SnipeZone.ToString().ToLower()},\"MaxPersonsOnPatrol\":{zone.MaxPersonsOnPatrol.ToString()},\"MultiplicativeCoef\":{param.Item1.ToString("F1")},\"AdditiveCoef\":{param.Item2.ToString()}}}");
index++;
}
sb.Append("}");
return sb.ToString();
}
}
}

View File

@ -0,0 +1,520 @@
using astealz.SmartSpawnController.Behaviors;
using EFT;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Control spawn of all bots
/// </summary>
class BotSpawnManager
{
const int poolSize = 20;
private bool enabled;
/// <summary>
/// internal spawn wave counter
/// </summary>
private int waveIndex;
private Player localPlayer = null;
private BotZone[] botZones;
private readonly CallbackClass callback;
private readonly List<BotZone> scavSpawnZones = new List<BotZone>(poolSize);
private readonly List<BotZone> bossSpawnZones = new List<BotZone>(poolSize);
private readonly SortedList<int, string> filteredZones = new SortedList<int, string>(poolSize);
private readonly Dictionary<string, Tuple<float, int>> zoneWeightCoef = new Dictionary<string, Tuple<float, int>>(poolSize);
private readonly Dictionary<string, int> zoneBotCount = new Dictionary<string, int>(poolSize);
private readonly Dictionary<string, Vector3> zoneCenterPoint = new Dictionary<string, Vector3>(poolSize);
private readonly System.Random random = new System.Random();
private bool forceChooseNewZone;
private float maxSqrDistance;
private float maxDistance;
private int botsPerZoneBase;
// only these roles will be changed
private WildSpawnType[] scavRoles = new WildSpawnType[] {
WildSpawnType.assault,
};
private WildSpawnType[] bossRoles = new WildSpawnType[] {
WildSpawnType.assaultGroup,
WildSpawnType.cursedAssault,
WildSpawnType.pmcBot
};
private List<BotZone> ScavSpawnZones
{
get
{
if (scavSpawnZones.Count == 0)
SetSimpleSpawnZones();
return scavSpawnZones;
}
}
private List<BotZone> BossSpawnZones
{
get
{
if (bossSpawnZones.Count == 0)
SetBossSpawnZones();
return bossSpawnZones;
}
}
public bool Enabled => enabled;
public BotSpawnManager()
{
this.callback = new CallbackClass(zoneBotCount);
}
public void Callback(bool isDelayed) => callback.Callback(isDelayed);
/// <summary>
/// Initialize spawn manager
/// </summary>
public void OnStart(BotZone[] botZones)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("OnStart(botZones)");
var mapConfig = Globals.CurrentMapConfig;
this.enabled = mapConfig.EnableSpawnControl;
if (mapConfig.EnableUnspawn)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("Initializing unspawner...");
var controller = Globals.GameWorld.gameObject.GetOrAddComponentToGameObject<BotUnspawnController>();
controller.SetMaxDist(mapConfig.MaxDistanceToUnspawn);
controller.SetRoles(mapConfig.RolesToUnspawn);
controller.enabled = true;
if (DEBUG_ENABLED)
Utils.Logger.Debug("Unspawner initialized");
}
if (!enabled)
{
Utils.Logger.Info($"{nameof(BotSpawnManager)} disabled");
return;
}
this.waveIndex = 0;
this.localPlayer = Globals.LocalPlayer;
this.botZones = botZones;
this.forceChooseNewZone = mapConfig.ForceChooseNewZone;
this.maxDistance = mapConfig.MaxDistanceToSpawn;
this.maxSqrDistance = maxDistance * maxDistance;
this.botsPerZoneBase = mapConfig.BotsPerZoneBase;
this.scavSpawnZones.Clear();
this.bossSpawnZones.Clear();
SetScavRoles(mapConfig.ScavRoles);
SetBossRoles(mapConfig.BossRoles);
// init center points
zoneBotCount.Clear();
zoneCenterPoint.Clear();
zoneWeightCoef.Clear();
foreach (var zone in botZones)
{
zoneCenterPoint.Add(zone.NameZone, GetZoneCenterPoint(zone));
zoneBotCount.Add(zone.NameZone, 0);
zoneWeightCoef.Add(zone.NameZone, Tuple.Create(1f, 1));
}
if (VERBOSE_ENABLED)
{
Utils.Logger.Verbose($"Original zones: {botZones.ToConfigJson()}");
}
if (mapConfig.Zones != null && mapConfig.Zones.Count > 0)
{
foreach (var zone in mapConfig.Zones)
{
if (!zoneWeightCoef.ContainsKey(zone.Key))
{
Utils.Logger.Info($"Map doesn't contain zone '{zone.Key}'.");
continue;
}
if (zone.Value.CanSpawnBoss.HasValue)
{
TryToSetCanSpawnBossToZone(zone.Key, zone.Value.CanSpawnBoss.Value, botZones);
}
if (zone.Value.MaxPersonsOnPatrol.HasValue && zone.Value.MaxPersonsOnPatrol.Value > 0)
{
TryToSetMaxPersonsOnPatrolToZone(zone.Key, zone.Value.MaxPersonsOnPatrol.Value, botZones);
}
zoneWeightCoef[zone.Key] = Tuple.Create(zone.Value.MultiplicativeCoef, zone.Value.AdditiveCoef);
}
if (VERBOSE_ENABLED)
{
Utils.Logger.Verbose($"Updated zones: {zoneWeightCoef.ToConfigJson(botZones)}");
}
}
Utils.Logger.Info($"{nameof(BotSpawnManager)} activated. Max spawn distance = {this.maxDistance.ToString()}");
}
private void TryToSetCanSpawnBossToZone(string nameZone, bool canSpawnBoss, BotZone[] botZones)
{
foreach (var zone in botZones)
{
if (zone.NameZone == nameZone)
zone.CanSpawnBoss = canSpawnBoss;
}
}
private void TryToSetMaxPersonsOnPatrolToZone(string nameZone, int maxPersonsOnPatrol, BotZone[] botZones)
{
foreach (var zone in botZones)
{
if (zone.NameZone == nameZone)
zone.MaxPersonsOnPatrol = maxPersonsOnPatrol;
}
}
private void SetBossRoles(WildSpawnType[] roles)
{
if (roles == null || roles.Length == 0)
return;
this.bossRoles = roles;
}
private void SetScavRoles(WildSpawnType[] roles)
{
if (roles == null || roles.Length == 0)
return;
this.scavRoles = roles;
}
public void OnStop()
{
botZones = null;
localPlayer = null;
zoneWeightCoef.Clear();
zoneCenterPoint.Clear();
zoneBotCount.Clear();
scavSpawnZones.Clear();
bossSpawnZones.Clear();
enabled = false;
}
public void OnActivateBoss(IWaveDataWrapper wave)
{
callback.Reset();
if (!wave.IsBoss)
return;
waveIndex++;
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}]{wave} - boss activation");
callback.Init(waveIndex, wave.Side, wave.WildSpawnType, wave.ZoneName, wave.ZoneName, wave.Count);
}
public bool OnActivateBotsByWave(IWaveDataWrapper wave)
{
waveIndex++;
callback.Reset();
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}]{wave} - searching for new spawn zone");
if (localPlayer == null)
{
Utils.Logger.Error("LocalPlayer is null somehow!");
return false;
}
// keep player position
var localPlayerPosition = localPlayer.Transform.position;
// skip if role is not suitable
if (!wave.IsBoss)
{
if (bossRoles.Contains(wave.WildSpawnType))
{
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}] Boss escort wave detected. Skipping");
callback.Init(waveIndex, wave.Side, wave.WildSpawnType, wave.ZoneName, wave.ZoneName, wave.Count);
return false;
}
else if (!scavRoles.Contains(wave.WildSpawnType))
{
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}] Scav/boss role '{wave.WildSpawnType}' is not in the list. Skipping");
callback.Init(waveIndex, wave.Side, wave.WildSpawnType, wave.ZoneName, wave.ZoneName, wave.Count);
return false;
}
}
else
{
if (!bossRoles.Contains(wave.WildSpawnType))
{
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}] Boss role '{wave.WildSpawnType}' is not in the list. Skipping");
return false;
}
}
// store old zone name
string targetZone = wave.ZoneName;
// get spawn zones list depends on wave type
var spawnZones = wave.IsBoss
? BossSpawnZones
: ScavSpawnZones;
// we need at least 2 zones
if (spawnZones.Count <= 1)
{
if (VERBOSE_ENABLED)
Utils.Logger.Verbose($"[{waveIndex.ToString()}] spawnZones.Count = {spawnZones.Count.ToString()}. Nothing to select.");
return false;
}
if (!forceChooseNewZone && zoneCenterPoint.ContainsKey(targetZone))
{
// calculate distance to target zone and check distance
float distToTargetZone = GetSqrDist(zoneCenterPoint[targetZone], localPlayerPosition);
// skip if distance OK
if (distToTargetZone < maxSqrDistance)
{
if (!wave.IsBoss)
callback.Init(waveIndex, wave.Side, wave.WildSpawnType, targetZone, targetZone, wave.Count);
return false;
}
}
else
{
targetZone = string.Empty;
}
// select new zone
string newZoneName = GetRandomZone(localPlayerPosition, targetZone, spawnZones);
string oldZoneName = wave.ZoneName;
// set new zone to bot wave
wave.ZoneName = newZoneName;
if (!wave.IsBoss)
callback.Init(waveIndex, wave.Side, wave.WildSpawnType, oldZoneName, newZoneName, wave.Count);
return true;
}
/// <summary>
/// Calculate center of mass point for BotZone
/// </summary>
private Vector3 GetZoneCenterPoint(BotZone zone)
{
var spawnPoints = zone.SpawnPoints;
Vector3 res = Vector3.zero;
foreach (var point in spawnPoints)
{
res += point.Position;
}
return res / spawnPoints.Length;
}
private float GetSqrDist(in Vector3 first, in Vector3 second)
{
return (first - second).sqrMagnitude;
}
public BotZone GetZoneByName(string name)
{
foreach (var zone in botZones)
{
if (zone.name == name)
return zone;
}
return null;
}
private void SetSimpleSpawnZones()
{
foreach (var zone in botZones)
{
if (zone.SnipeZone)
continue;
scavSpawnZones.Add(zone);
}
}
private void SetBossSpawnZones()
{
foreach (var zone in botZones)
{
if (!zone.CanSpawnBoss || zone.SnipeZone)
continue;
bossSpawnZones.Add(zone);
}
}
/// <summary>
/// Get random bot zone based on zone population weight
/// This method should give more 'flat' bot dispersion on map
/// </summary>
/// <param name="localPlayerPosition">Player position</param>
/// <param name="targetZone">Old zone name</param>
/// <param name="spawnZones">List of available zones</param>
/// <returns></returns>
private string GetRandomZone(in Vector3 localPlayerPosition, string targetZone, List<BotZone> spawnZones)
{
int totalWeight = 0;
// prepare zone list to select new one
filteredZones.Clear();
foreach (var zone in spawnZones)
{
string zoneName = zone.NameZone;
if (zoneName == targetZone)
continue;
if (GetSqrDist(zoneCenterPoint[zoneName], localPlayerPosition) > maxSqrDistance)
continue;
(float zoneMultiplicativeCoef, int zoneAdditiveCoef) = zoneWeightCoef[zoneName];
int weight = GetZoneWeight(zoneBotCount[zoneName], zoneMultiplicativeCoef, zoneAdditiveCoef);
if (weight == 0)
continue;
int currentTotal = totalWeight + weight;
totalWeight += weight;
filteredZones.Add(currentTotal, zoneName);
}
// fallback: select random zone without weight
if (filteredZones.Count == 0)
{
Utils.Logger.Info($"[{waveIndex.ToString()}] There's no zone to select, random will be used! Check distance settings or zones coefficients");
var rndZoneIndex = random.Next(spawnZones.Count);
string zone = spawnZones[rndZoneIndex].NameZone;
totalWeight = 1;
filteredZones.Add(totalWeight, zone);
}
// select random zone
int rndWeight = random.Next(1, totalWeight);
string randomZone = string.Empty;
for (int i = 0; i < filteredZones.Keys.Count; i++)
{
var key = filteredZones.Keys[i];
if (key >= rndWeight)
{
randomZone = filteredZones[key];
break;
}
}
if (VERBOSE_ENABLED)
{
Utils.Logger.Verbose($"[{waveIndex.ToString()}][Zones] {filteredZones.ToStr()}");
Utils.Logger.Verbose($"[{waveIndex.ToString()}][Selected] {rndWeight.ToString()} @ {randomZone}");
}
return randomZone;
}
/// <summary>
/// Calculates weight of bot zone based on population with additional coefficients
/// </summary>
/// <param name="botCount">current zone population</param>
/// <param name="zoneMultiplicativeCoef"></param>
/// <param name="zoneAdditiveCoef"></param>
/// <returns>Weight in range [0 .. botsPerZoneBase * Multiplicative + Additive]</returns>
private int GetZoneWeight(int botCount, float zoneMultiplicativeCoef, int zoneAdditiveCoef)
{
int normBotCount = Math.Min(botCount, botsPerZoneBase);
int weight = Mathf.RoundToInt((botsPerZoneBase - normBotCount) * zoneMultiplicativeCoef + zoneAdditiveCoef);
return Math.Max(weight, 0);
}
// the main reason of using this is that we need a late check of wave delay
class CallbackClass
{
EPlayerSide side;
WildSpawnType wildSpawnType;
string oldZoneName;
string newZoneName;
int waveBotsCount;
Dictionary<string, int> zoneBotCount;
bool initialized = false;
int waveIndex;
public CallbackClass(Dictionary<string, int> zoneBotCount)
{
this.zoneBotCount = zoneBotCount;
}
public void Reset()
{
this.initialized = false;
}
public void Init(int waveIndex,
EPlayerSide side,
WildSpawnType wildSpawnType,
string oldZoneName,
string newZoneName,
int waveCount)
{
this.initialized = true;
this.waveIndex = waveIndex;
this.side = side;
this.wildSpawnType = wildSpawnType;
this.oldZoneName = oldZoneName;
this.newZoneName = newZoneName;
this.waveBotsCount = waveCount;
}
public void Callback(bool isDelayed)
{
if (!initialized)
return;
if (VERBOSE_ENABLED)
{
if (isDelayed)
{
Utils.Logger.Verbose($"[{waveIndex.ToString()}][{side.ToStr()}][{wildSpawnType}] is delayed");
}
if (oldZoneName != newZoneName)
Utils.Logger.Verbose($"[{waveIndex.ToString()}][{side.ToStr()}][{wildSpawnType}] Spawn zone changed from '{oldZoneName}' to '{newZoneName}'");
else
Utils.Logger.Verbose($"[{waveIndex.ToString()}][{side.ToStr()}][{wildSpawnType}] Spawn zone not changed ('{newZoneName}')");
}
if (!zoneBotCount.ContainsKey(newZoneName))
{
Utils.Logger.Error($"[{waveIndex.ToString()}][{side.ToStr()}][{wildSpawnType}] Spawn zone '{newZoneName}' doesn't exist in the game! Check waves settings!");
return;
}
// add to dictionary
zoneBotCount[newZoneName] += waveBotsCount;
}
}
}
}
}

View File

@ -0,0 +1,72 @@
using HarmonyLib;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using UnityEngine;
using astealz.SmartSpawnController.Utils;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
class BotSpawnerDelayPatch : GenericPatch<BotSpawnerDelayPatch>
{
private static float scavWaveRetrySpawnDeltaTime = 15f;
private static float lastCheckTime = 0f;
public BotSpawnerDelayPatch() : base(prefix: nameof(PatchPrefix)) {
scavWaveRetrySpawnDeltaTime = Globals.Config.ScavWaveRetryInterval;
}
protected override MethodBase GetTargetMethod()
{
var type = typeof(EFT.GameWorld)
.Assembly
.GetTypes()
.Single(t => t.GetMethod("CanCheckSpawnByTime") != null);
return type.GetMethod("CanCheckSpawnByTime");
}
private static bool PatchPrefix(ref bool __result)
{
/*
* Original method:
*
* public bool CanCheckSpawnByTime()
* {
* return Time.time - this.float_1_Time > 1f;
* }
*
* It's called every 3 seconds by some sort of manager:
*
* public GClass375(Action<GClass374> checkPositionsCallback)
* {
* this.action_0 = checkPositionsCallback;
* this.ginterface10_0 = StaticManager.Instance.TimerManager.MakeTimer(TimeSpan.FromSeconds(3.0), true);
* this.ginterface10_0.OnTimer += this.method_0;
* this.int_0 = GClass320.Core.MAX_SIMPLE_BOTS_IN_DELAY;
* }
*
* if 'CanCheckSpawnByTime' returns 'true' than BotSpawnManager will try to spawn a delayed scav wave (bosses use their own retry mechanism)
* so if you have many waves in a delay list, they will try to spawn every 3 seconds, I don't think it's ok, so it can be changed here
*/
if (DEBUG_ENABLED)
Utils.Logger.Debug("BotSpawnerDelayPatch()");
if (lastCheckTime == 0)
lastCheckTime = Time.time;
if (Time.time - lastCheckTime < scavWaveRetrySpawnDeltaTime)
{
__result = false;
return false;
}
lastCheckTime = Time.time;
__result = true;
return false;
}
}
}
}

View File

@ -0,0 +1,77 @@
using astealz.SmartSpawnController.Utils;
using EFT;
using HarmonyLib;
using System.Reflection;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Patch for catching a new boss spawn
/// </summary>
class BotSpawnerOnActivateBotsByBossWavePatch : GenericPatch<BotSpawnerOnActivateBotsByBossWavePatch>
{
private static readonly BossWaveDataWrapper bossWaveDataWrapper = new BossWaveDataWrapper();
public BotSpawnerOnActivateBotsByBossWavePatch() : base(prefix: nameof(PatchPrefix)) { }
protected override MethodBase GetTargetMethod()
{
var bossSpawnerType = botSpawnerType.GetField("BossSpawner").FieldType;
return bossSpawnerType.GetMethod("Spawn", BindingFlags.Public | BindingFlags.Instance);
}
static void PatchPrefix(object __instance, BossLocationSpawn wave)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("OnActivateBotsByBossWave()");
if (!botSpawnManager.Enabled)
return;
bossWaveDataWrapper.SetInstance(wave);
botSpawnManager.OnActivateBotsByWave(bossWaveDataWrapper);
botSpawnManager.Callback(false);
bossWaveDataWrapper.SetInstance(null);
}
class BossWaveDataWrapper : IWaveDataWrapper
{
private BossLocationSpawn bossLocationSpawn;
private setBornZoneDelegate<BossLocationSpawn> setBornZone;
delegate void setBornZoneDelegate<T>(T instance, string value);
public bool IsBoss => true;
public EPlayerSide Side => EPlayerSide.Savage;
public WildSpawnType WildSpawnType => bossLocationSpawn.BossType;
public string ZoneName { get => bossLocationSpawn.BornZone; set => setBornZone(bossLocationSpawn, value); }
public int Count => 1;
public bool IsNewWave => true;
public BossWaveDataWrapper()
{
// setter is private
var bornZoneSetter = AccessTools.PropertySetter(typeof(BossLocationSpawn), nameof(BossLocationSpawn.BornZone));
// make it accessable
setBornZone = AccessTools.MethodDelegate<setBornZoneDelegate<BossLocationSpawn>>(bornZoneSetter);
}
public void SetInstance(BossLocationSpawn bossLocationSpawn)
{
this.bossLocationSpawn = bossLocationSpawn;
}
public override string ToString()
{
return $"[ BOSS ][{Side.ToStr()}][{WildSpawnType}][ NEW ] {Count.ToString()} @ {ZoneName}";
}
}
}
}
}

View File

@ -0,0 +1,113 @@
using astealz.SmartSpawnController.Utils;
using EFT;
using HarmonyLib;
using System.Linq;
using System.Reflection;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Patch for catching a new scav wave spawn
/// </summary>
class BotSpawnerOnActivateBotsByScavWavePatch : GenericPatch<BotSpawnerOnActivateBotsByScavWavePatch>
{
private static readonly ScavWaveDataWrapper scavWaveDataWraper;
static BotSpawnerOnActivateBotsByScavWavePatch()
{
scavWaveDataWraper = new ScavWaveDataWrapper();
}
public BotSpawnerOnActivateBotsByScavWavePatch() : base(prefix: nameof(PatchPrefix), postfix: nameof(PatchPostfix)) { }
protected override MethodBase GetTargetMethod()
{
return AccessTools.Method(botSpawnerType, "TryToSpawnInZoneInner");
}
// original method 'TryToSpawnInZoneInner' returns 'delay' object if cannot spawn bot wave right now
static void PatchPrefix(ref BotZone botZone, int count, object data, bool newWave)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("Prefix:OnActivateBotsByScavWave()");
if (botZone == null)
return;
if (!botSpawnManager.Enabled)
return;
scavWaveDataWraper.SetData(data, botZone.NameZone, count, newWave);
bool isNewBotZoneFound = botSpawnManager.OnActivateBotsByWave(scavWaveDataWraper);
if (isNewBotZoneFound)
botZone = botSpawnManager.GetZoneByName(scavWaveDataWraper.ZoneName);
// clean up just in case
scavWaveDataWraper.SetData(null, string.Empty, 0, false);
}
// if result is null, new bot wave will be spawned in seconds, invoke callback to store changes
static void PatchPostfix(object __result)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("Postfix:OnActivateBotsByScavWave()");
bool waveWasDelayed = __result != null;
botSpawnManager.Callback(waveWasDelayed);
}
class ScavWaveDataWrapper : IWaveDataWrapper
{
private AccessTools.FieldRef<object, WildSpawnType> _wildSpawnType;
private AccessTools.FieldRef<object, EPlayerSide?> _side;
private object _data;
private bool _initialized = false;
public bool IsBoss => false;
public EPlayerSide Side => _side(_data) ?? EPlayerSide.Savage;
public WildSpawnType WildSpawnType => _wildSpawnType(_data);
public string ZoneName { get; set; }
public int Count { get; private set; }
public bool IsNewWave { get; private set; }
internal void SetData(object data, string zoneName, int count, bool isNewWave)
{
if (!_initialized)
{
var dataType = data.GetType();
var wildSpawnTypeFieldName = dataType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Single(x => x.FieldType == typeof(WildSpawnType))
.Name;
var sideFieldName = dataType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Single(x => x.FieldType == typeof(EPlayerSide?))
.Name;
_wildSpawnType = AccessTools.FieldRefAccess<WildSpawnType>(dataType, wildSpawnTypeFieldName);
_side = AccessTools.FieldRefAccess<EPlayerSide?>(dataType, sideFieldName);
_initialized = true;
}
_data = data;
ZoneName = zoneName;
Count = count;
IsNewWave = isNewWave;
}
public override string ToString()
{
return $"[ SCAV ][{Side.ToStr()}][{WildSpawnType}][{(IsNewWave ? " NEW " : "RETRY")}] {Count.ToString()} @ {ZoneName}";
}
}
}
}
}

View File

@ -0,0 +1,108 @@
using astealz.SmartSpawnController.Utils;
using EFT;
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Patch for catching a new boss spawn
/// </summary>
class BotSpawnerOnBossActivationPatch : GenericPatch<BotSpawnerOnBossActivationPatch>
{
private static readonly BossActivationWaveDataWrapper bossWaveDataWrapper = new BossActivationWaveDataWrapper();
public BotSpawnerOnBossActivationPatch() : base(prefix: nameof(PatchPrefix)) { }
protected override MethodBase GetTargetMethod()
{
//private void method_1(BossLocationSpawn wave,
// GClass371 spawnParams,
// int followersCount,
// BotZone botZone,
// List<ISpawnPoint> openedPositions)
var methodParams = new Dictionary<string, (Type type, bool checkType, bool hasDefaultValue)> {
{"wave", (typeof(BossLocationSpawn), true, false) },
{"spawnParams", (typeof(object), false, false) },
{"followersCount", (typeof(int), true, false) },
{"botZone", (typeof(BotZone), true, false) },
{"openedPositions", (typeof(object), false, false) },
};
var bossSpawnerType = botSpawnerType.GetField("BossSpawner").FieldType;
var bossLateSpawnMethod = bossSpawnerType
.GetMethods(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Single(m => {
var mp = m.GetParameters();
if (mp.Length < methodParams.Count)
return false;
return mp.All(x => methodParams.ContainsKey(x.Name)
&& ((methodParams[x.Name].checkType && methodParams[x.Name].type == x.ParameterType) || !methodParams[x.Name].checkType)
&& (methodParams[x.Name].hasDefaultValue == x.HasDefaultValue));
});
return bossLateSpawnMethod;
}
static void PatchPrefix(object __instance, BossLocationSpawn wave, BotZone botZone)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("OnBossLateSpawn()");
if (!botSpawnManager.Enabled)
return;
bossWaveDataWrapper.SetData(wave, botZone);
botSpawnManager.OnActivateBoss(bossWaveDataWrapper);
botSpawnManager.Callback(false);
bossWaveDataWrapper.ResetData();
}
class BossActivationWaveDataWrapper : IWaveDataWrapper
{
private string nameZone;
public bool IsBoss => true;
public EPlayerSide Side => EPlayerSide.Savage;
public WildSpawnType WildSpawnType { get; private set; }
public string ZoneName {
get => nameZone;
set {
Logger.Info("Can't change boss 'ZoneName'");
}
}
public int Count => 1;
public bool IsNewWave => true;
public void SetData(BossLocationSpawn bossLocationSpawn, BotZone botZone)
{
this.WildSpawnType = bossLocationSpawn.BossType;
this.nameZone = botZone.NameZone;
}
public void ResetData()
{
this.nameZone = string.Empty;
}
public override string ToString()
{
return $"[ BOSS ][{Side.ToStr()}][{WildSpawnType}][ACTIV] {Count.ToString()} @ {ZoneName}";
}
}
}
}
}

View File

@ -0,0 +1,30 @@
using astealz.SmartSpawnController.Utils;
using System.Linq;
using System.Reflection;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Patch on creating new BotSpawner instance
/// BotSpawner is created when map is loading
/// </summary>
class BotSpawnerOnStartPatch : GenericPatch<BotSpawnerOnStartPatch>
{
public BotSpawnerOnStartPatch() : base(postfix: nameof(PatchPostfix)) { }
protected override MethodBase GetTargetMethod()
{
return botSpawnerType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Single();
}
static void PatchPostfix(object __instance, BotZone[] botZones)
{
// initialize with in-game bot zones
botSpawnManager.OnStart(botZones);
}
}
}
}

View File

@ -0,0 +1,29 @@
using astealz.SmartSpawnController.Utils;
using System.Reflection;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
/// <summary>
/// Patch on BotSpawner.Stop() to cleanup
/// </summary>
class BotSpawnerOnStopPatch : GenericPatch<BotSpawnerOnStopPatch>
{
public BotSpawnerOnStopPatch() : base(prefix: nameof(PatchPrefix)) { }
protected override MethodBase GetTargetMethod()
{
return botSpawnerType.GetMethod("Stop", BindingFlags.Public | BindingFlags.Instance);
}
static void PatchPrefix()
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("OnStop()");
// the end
botSpawnManager.OnStop();
}
}
}
}

View File

@ -0,0 +1,17 @@
using EFT;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
interface IWaveDataWrapper
{
bool IsNewWave { get; }
bool IsBoss { get; }
EPlayerSide Side { get; }
WildSpawnType WildSpawnType { get; }
string ZoneName { get; set; }
int Count { get; }
}
}
}

View File

@ -0,0 +1,40 @@
using astealz.SmartSpawnController.Utils;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
class MaxBotsAliveOnMapPatch : GenericPatch<MaxBotsAliveOnMapPatch>
{
private static string[] targetTypePublicProperties = new string[] { "BotsCountWithDelyaed", "AllTypes", "IsEnable" };
public MaxBotsAliveOnMapPatch() : base(prefix: nameof(PatchPrefix))
{
}
protected override MethodBase GetTargetMethod()
{
// searching type with properties: BotsCountWithDelyaed, AllTypes, AiTaskManager
return typeof(EFT.GameWorld).Assembly.GetTypes()
.Single(type => targetTypePublicProperties.All(p => type.GetProperties(allFlags).Any(tp => tp.Name == p)))
.GetMethod("SetSettings");
}
private static void PatchPrefix(ref int maxCount)
{
if (DEBUG_ENABLED)
Utils.Logger.Debug("MaxBotsAliveOnMapPatch()");
maxCount = Globals.CurrentMapConfig.MaxBotsAliveOnMap;
Utils.Logger.Info($"MaxBotsAliveOnMap set to {maxCount.ToString()}");
}
}
}
}

View File

@ -0,0 +1,67 @@
using astealz.SmartSpawnController.Utils;
using System;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Patches
{
partial class BotSpawnerPatches
{
const BindingFlags allFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
private static readonly Type scavWaveDataType;
private static readonly Type botSpawnerType;
private static readonly BotSpawnManager botSpawnManager = new BotSpawnManager();
static bool DEBUG_ENABLED => Globals.Config.Debug;
static bool VERBOSE_ENABLED => Globals.Config.Verbose;
// ctor
static BotSpawnerPatches()
{
var scavWaveDataTypeFieldNames = new[]
{
"ChanceGroup",
"Difficulty",
"IsPlayers",
"WildSpawnType",
"BotsCount",
"Side",
"SpawnAreaName",
"Time"
};
var types = typeof(EFT.GameWorld).Assembly.GetTypes();
scavWaveDataType = types
.Where(type => type != typeof(EFT.WildSpawnWave))
.Where(type => type.GetFields(BindingFlags.Public | BindingFlags.Instance)
.Select(x => x.Name)
.DefaultIfEmpty("")
.All(scavWaveDataTypeFieldNames.Contains))
.Single();
botSpawnerType = types
.Where(type => type.GetMethod("SpawnZones", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) != null
&& type.GetMethod("ActivateBotsByWave", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly, null, new[] { scavWaveDataType }, null) != null)
.SingleOrDefault();
}
/// <summary>
/// Apply patches
/// </summary>
public static bool Apply()
{
new MaxBotsAliveOnMapPatch().Apply();
new BotSpawnerOnStartPatch().Apply();
new BotSpawnerOnActivateBotsByScavWavePatch().Apply();
new BotSpawnerOnActivateBotsByBossWavePatch().Apply();
new BotSpawnerOnStopPatch().Apply();
new BotSpawnerDelayPatch().Apply();
new BotSpawnerOnBossActivationPatch().Apply();
return true;
}
}
}

View File

@ -0,0 +1,36 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// Общие сведения об этой сборке предоставляются следующим набором
// набора атрибутов. Измените значения этих атрибутов для изменения сведений,
// связанные со сборкой.
[assembly: AssemblyTitle("astealz.SmartSpawnController")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("astealz.SmartSpawnController")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Установка значения False для параметра ComVisible делает типы в этой сборке невидимыми
// для компонентов COM. Если необходимо обратиться к типу в этой сборке через
// COM, задайте атрибуту ComVisible значение TRUE для этого типа.
[assembly: ComVisible(false)]
// Следующий GUID служит для идентификации библиотеки типов, если этот проект будет видимым для COM
[assembly: Guid("C9D9E263-FAB0-4E00-9FB1-0CE938695BAA")]
// Сведения о версии сборки состоят из указанных ниже четырех значений:
//
// Основной номер версии
// Дополнительный номер версии
// Номер сборки
// Редакция
//
// Можно задать все значения или принять номера сборки и редакции по умолчанию
// используя "*", как показано ниже:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

Binary file not shown.

BIN
src/References/Comfort.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

156
src/Utils/Emit.cs Normal file
View File

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Utils
{
public delegate T PropertyGetter<out T>(object instance);
public delegate void PropertySetter<in T>(object instance, T value);
public delegate T MethodInvoker<in T1, out T>(object instance, T1 arg1);
public delegate void MethodInvoker<in T>(object instance, T value);
// methods in this class creates a dynamic methods (and return a delegate) for quick access to a property or method which cannot be called by a direct call
// this approach is about 5x faster than reflection
static class Emit
{
// delegate for property getter
public static PropertyGetter<T> CreateDynamicPropertyGetter<T>(
PropertyInfo propertyInfo,
BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)
{
if (propertyInfo == null)
throw new ArgumentNullException(nameof(propertyInfo));
if (!typeof(T).IsAssignableFrom(propertyInfo.PropertyType))
throw new ArgumentException($"Property type '{propertyInfo.PropertyType}' does not match return type '{typeof(T)}'");
var getterMethodInfo = propertyInfo.GetGetMethod(true);
if (getterMethodInfo == null)
throw new InvalidOperationException($"Can't find getter for property '{propertyInfo.Name}' in '{propertyInfo.DeclaringType}'");
var dynMethod = new DynamicMethod($"__get_{typeof(T).Name}_prop_{propertyInfo.Name}", typeof(T), new[] { typeof(object) }, propertyInfo.DeclaringType, true);
var ilGen = dynMethod.GetILGenerator();
if (getterMethodInfo.IsStatic)
{
ilGen.Emit(OpCodes.Call, getterMethodInfo);
}
else
{
ilGen.Emit(OpCodes.Ldarg_0);
ilGen.Emit(OpCodes.Castclass, propertyInfo.DeclaringType);
ilGen.Emit(OpCodes.Callvirt, getterMethodInfo);
}
ilGen.Emit(OpCodes.Ret);
return (PropertyGetter<T>)dynMethod.CreateDelegate(typeof(PropertyGetter<T>));
}
// delegate for property setter
public static PropertySetter<T> CreateDynamicPropertySetter<T>(
PropertyInfo propertyInfo,
BindingFlags bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)
{
if (propertyInfo == null)
throw new ArgumentNullException(nameof(propertyInfo));
if (!typeof(T).IsAssignableFrom(propertyInfo.PropertyType))
throw new ArgumentException($"Property type '{propertyInfo.PropertyType}' does not match the type of value '{typeof(T)}'");
var setterMethodInfo = propertyInfo.GetSetMethod(true);
if (setterMethodInfo == null)
throw new InvalidOperationException($"Can't find setter for property '{propertyInfo.Name}' in '{propertyInfo.DeclaringType}'");
var dynMethod = new DynamicMethod($"__set_{typeof(T).Name}_prop_{propertyInfo.Name}", null, new[] { typeof(object), typeof(T) }, propertyInfo.DeclaringType);
var ilGen = dynMethod.GetILGenerator();
if (setterMethodInfo.IsStatic)
{
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Call, setterMethodInfo);
ilGen.Emit(OpCodes.Ret);
}
else
{
ilGen.Emit(OpCodes.Ldarg_0);
ilGen.Emit(OpCodes.Castclass, propertyInfo.DeclaringType);
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Callvirt, setterMethodInfo);
}
ilGen.Emit(OpCodes.Ret);
return (PropertySetter<T>)dynMethod.CreateDelegate(typeof(PropertySetter<T>));
}
// delegate to invoke a method with one argument and return value
public static MethodInvoker<T1, TR> CreateDynamicMethodInvoker<T1, TR>(MethodInfo methodInfo)
{
if (methodInfo == null)
throw new ArgumentNullException(nameof(methodInfo));
if (!typeof(TR).IsAssignableFrom(methodInfo.ReturnType))
throw new ArgumentException($"Method return type '{methodInfo.ReturnType}' does not match return type '{typeof(TR)}'");
var dynMethod = new DynamicMethod(
$"__invoke_{typeof(TR).Name}_method_{methodInfo.Name}", // method name is only for debugging
typeof(TR),
new[] { typeof(object), typeof(T1) }, // add method arguments here (T2, T3, T4...)
methodInfo.DeclaringType,
true);
var ilGen = dynMethod.GetILGenerator();
if (methodInfo.IsStatic)
{
// if target method is static push first argument on stack
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Call, methodInfo);
}
else
{
// otherwise push instance and cast it to type where method is declared
ilGen.Emit(OpCodes.Ldarg_0);
ilGen.Emit(OpCodes.Castclass, methodInfo.DeclaringType);
// then push method arguments
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Callvirt, methodInfo);
}
ilGen.Emit(OpCodes.Ret);
return (MethodInvoker<T1, TR>)dynMethod.CreateDelegate(typeof(MethodInvoker<T1, TR>));
}
public static MethodInvoker<T> CreateDynamicMethodInvoker<T>(MethodInfo methodInfo)
{
if (methodInfo == null)
throw new ArgumentNullException(nameof(methodInfo));
if (!typeof(void).Equals(methodInfo.ReturnType))
throw new ArgumentException($"Method return type '{methodInfo.ReturnType}' does not match return type 'void'");
var dynMethod = new DynamicMethod(
$"__invoke_void_method_{methodInfo.Name}", // method name is only for debugging
typeof(void),
new[] { typeof(object), typeof(T) }, // add method arguments here (T2, T3, T4...)
methodInfo.DeclaringType,
true);
var ilGen = dynMethod.GetILGenerator();
if (methodInfo.IsStatic)
{
// if target method is static push first argument on stack
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Call, methodInfo);
}
else
{
// otherwise push instance and cast it to type where method is declared
ilGen.Emit(OpCodes.Ldarg_0);
ilGen.Emit(OpCodes.Castclass, methodInfo.DeclaringType);
// then push method arguments
ilGen.Emit(OpCodes.Ldarg_1);
ilGen.Emit(OpCodes.Callvirt, methodInfo);
}
ilGen.Emit(OpCodes.Ret);
return (MethodInvoker<T>)dynMethod.CreateDelegate(typeof(MethodInvoker<T>));
}
}
}

122
src/Utils/GameConsole.cs Normal file
View File

@ -0,0 +1,122 @@
using EFT.UI;
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using UnityEngine;
namespace astealz.SmartSpawnController.Utils
{
static class GameConsole
{
private static readonly Type consoleCommandType;
private static readonly ConstructorInfo consoleCommandConstructor;
private static readonly MethodInfo consoleCommandsAddMethod;
private static readonly FieldInfo commandsField;
private static ConsoleScreen _console = null;
private static List<string> _userCommands = new List<string>();
public static bool IsInitialized => _console != null;
static GameConsole()
{
consoleCommandType = typeof(EFT.GameWorld)
.Assembly
.GetTypes()
.Single(t => t.GetProperty("Regex") != null
&& t.GetMethod("TryExecute") != null);
consoleCommandConstructor = consoleCommandType.GetConstructor(new[] { typeof(string), typeof(Action<Match>) });
consoleCommandsAddMethod = AccessTools.Field(typeof(ConsoleScreen), nameof(ConsoleScreen.Commands))
.FieldType
.GetMethod("Add", new[] { consoleCommandType });
commandsField = AccessTools.Field(typeof(ConsoleScreen), nameof(ConsoleScreen.Commands));
}
static void AddCommand(string regular, Action<Match> onExecute)
{
var commands = commandsField.GetValue(null);
var command = consoleCommandConstructor.Invoke(new object[] { regular, onExecute });
consoleCommandsAddMethod.Invoke(commands, new[] { command });
}
public static void Initialize(ConsoleScreen consoleScreen)
{
_console = consoleScreen;
AddLog($"{nameof(astealz.SmartSpawnController)}: Console initialized", Color.green);
AddCommand("spawner-help", ShowHelp);
AddCommand("spawner-debug", ToggleDebug, "\tspawner-debug - toggle debug mode");
AddCommand("spawner-verbose", ToggleVerbose, "\tspawner-verbose - toggle verbose mode");
AddLog("type 'spawner-help' to see available commands", Color.green);
}
private static void ToggleVerbose(Match obj)
{
Globals.Config.Verbose = !Globals.Config.Verbose;
}
private static void ToggleDebug(Match obj)
{
Globals.Config.Debug = !Globals.Config.Debug;
}
private static void ShowHelp(Match obj)
{
foreach (var cmd in _userCommands)
{
AddLog(cmd, UnityEngine.Color.green);
}
}
public static void AddLog(string message, Color? color = null)
{
if (_console == null)
return;
_console.AddLog(message, SetColor(ColorToString(color ?? Color.white)));
}
static string ColorToString(UnityEngine.Color color)
{
return string.Concat(new string[]
{
"#",
FloatNormalizedToHex(color.r),
FloatNormalizedToHex(color.g),
FloatNormalizedToHex(color.b),
FloatNormalizedToHex(color.a)
});
}
static string FloatNormalizedToHex(float value)
{
return Mathf.RoundToInt(value * 255f).ToString("X2");
}
internal static string SetColor(string color)
{
return string.Concat(new string[]
{
"<color=",
color,
">",
DateTime.Now.ToString("[HH:mm:ss]"),
": </color>"
});
}
public static void AddCommand(string regular, Action<Match> onExecute, string helpMessage)
{
_userCommands.Add(helpMessage);
AddCommand(regular, onExecute);
}
}
}

View File

@ -0,0 +1,98 @@
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Utils
{
public abstract class GenericPatch<T> where T : GenericPatch<T>
{
private Harmony _harmony;
private HarmonyMethod _prefix;
private HarmonyMethod _postfix;
private HarmonyMethod _transpiler;
private HarmonyMethod _finalizer;
public GenericPatch(string name = null, string prefix = null, string postfix = null, string transpiler = null, string finalizer = null)
{
_harmony = new Harmony(name ?? typeof(T).Name);
_prefix = GetPatchMethod(prefix);
_postfix = GetPatchMethod(postfix);
_transpiler = GetPatchMethod(transpiler);
_finalizer = GetPatchMethod(finalizer);
if (_prefix == null && _postfix == null && _transpiler == null && _finalizer == null)
{
throw new Exception($"{_harmony.Id}: At least one of the patch methods must be specified");
}
}
/// <summary>
/// Get original method
/// </summary>
/// <returns>Method</returns>
protected abstract MethodBase GetTargetMethod();
/// <summary>
/// Get MethodInfo from string
/// </summary>
/// <param name="methodName">Method name</param>
/// <returns>Method</returns>
private HarmonyMethod GetPatchMethod(string methodName)
{
if (string.IsNullOrWhiteSpace(methodName))
{
return null;
}
return new HarmonyMethod(typeof(T).GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly));
}
/// <summary>
/// Apply patch to target
/// </summary>
public void Apply()
{
var targetMethod = GetTargetMethod();
if (targetMethod == null)
{
throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
}
try
{
_harmony.Patch(targetMethod, _prefix, _postfix, _transpiler, _finalizer);
}
catch (Exception ex)
{
throw new Exception($"{_harmony.Id}:", ex);
}
}
/// <summary>
/// Remove applied patch from target
/// </summary>
public void Remove()
{
var targetMethod = GetTargetMethod();
if (targetMethod == null)
{
throw new InvalidOperationException($"{_harmony.Id}: TargetMethod is null");
}
try
{
_harmony.Unpatch(targetMethod, HarmonyPatchType.All, _harmony.Id);
}
catch (Exception ex)
{
throw new Exception($"{_harmony.Id}:", ex);
}
}
}
}

View File

@ -0,0 +1,48 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Utils
{
public class Converter1 : JsonGenericDictionaryOrArrayConverter<string, MapConfig> { }
public class Converter2 : JsonGenericDictionaryOrArrayConverter<string, ZoneConfig> { }
/// <summary>
/// https://stackoverflow.com/a/28633769
/// </summary>
public class JsonGenericDictionaryOrArrayConverter<TKey, TValue> : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return typeof(Dictionary<TKey, TValue>) == objectType;
}
public override bool CanWrite { get { return false; } }
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var tokenType = reader.TokenType;
var dict = existingValue as Dictionary<TKey, TValue>;
if (dict == null)
{
dict = new Dictionary<TKey, TValue>();
}
if (tokenType == JsonToken.StartObject)
{
// Using "Populate()" avoids infinite recursion.
// https://github.com/JamesNK/Newtonsoft.Json/blob/ee170dc5510bb3ffd35fc1b0d986f34e33c51ab9/Src/Newtonsoft.Json/Converters/CustomCreationConverter.cs
serializer.Populate(reader, dict);
}
return dict;
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
}
}

47
src/Utils/Logger.cs Normal file
View File

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Utils
{
static class Logger
{
static string logFile = $"{nameof(astealz.SmartSpawnController)}.txt";
static Logger()
{
if (File.Exists(logFile))
File.Delete(logFile);
}
public static void Info(string msg)
{
string str = $"[INFO] {nameof(astealz.SmartSpawnController)}: {msg}";
File.AppendAllText(logFile, $"[{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}] {str}{Environment.NewLine}");
GameConsole.AddLog(str);
}
public static void Verbose(string msg)
{
string str = $"[VERBOSE] {nameof(astealz.SmartSpawnController)}: {msg}";
File.AppendAllText(logFile, $"[{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}] {str}{Environment.NewLine}");
GameConsole.AddLog(str, UnityEngine.Color.green);
}
internal static void Debug(string msg)
{
string str = $"[DEBUG] {nameof(astealz.SmartSpawnController)}: {msg}";
File.AppendAllText(logFile, $"[{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}] {str}{Environment.NewLine}");
GameConsole.AddLog(str, UnityEngine.Color.magenta);
}
internal static void Error(string msg)
{
string str = $"[ERROR] {nameof(astealz.SmartSpawnController)}: {msg}";
File.AppendAllText(logFile, $"[{DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff")}] {str}{Environment.NewLine}");
GameConsole.AddLog(str, UnityEngine.Color.red);
}
}
}

52
src/Utils/TextUtils.cs Normal file
View File

@ -0,0 +1,52 @@
using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading.Tasks;
namespace astealz.SmartSpawnController.Utils
{
static class TextUtils
{
private static newTransliterateDelegate transliterateNew;
private static oldTransliterateDelegate transliterateOld;
delegate string newTransliterateDelegate(string text);
delegate string oldTransliterateDelegate(string text, string locale);
static TextUtils()
{
MethodInfo GetNewTransliterateMethod(Type t)
{
return t.GetMethod("Transliterate", BindingFlags.Static | BindingFlags.Public, null, new Type[] { typeof(string) }, null);
}
MethodInfo GetOldTransliterateMethod(Type t)
{
return t.GetMethod("Transliterate", BindingFlags.Static | BindingFlags.Public, null, new Type[] { typeof(string), typeof(string) }, null);
}
var textUtilsType = typeof(EFT.GameWorld).Assembly.GetTypes()
.Single(t => GetNewTransliterateMethod(t) != null || GetOldTransliterateMethod(t) != null);
var newTransliterateMethod = GetNewTransliterateMethod(textUtilsType);
var oldTransliterateMethod = GetOldTransliterateMethod(textUtilsType);
if (newTransliterateMethod != null)
transliterateNew = AccessTools.MethodDelegate<newTransliterateDelegate>(newTransliterateMethod, null, false);
if (oldTransliterateMethod != null)
transliterateOld = AccessTools.MethodDelegate<oldTransliterateDelegate>(oldTransliterateMethod, null, false);
}
public static string TransliterateThis(this string text)
{
if (transliterateNew != null)
return transliterateNew(text);
if (transliterateOld != null)
return transliterateOld(text, "en");
return text;
}
}
}

View File

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{D355FDA8-163B-48F4-9416-884AC71C5A11}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>astealz.SmartSpawnController</RootNamespace>
<AssemblyName>astealz.SmartSpawnController</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
<TargetFrameworkProfile />
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<SignAssembly>true</SignAssembly>
</PropertyGroup>
<PropertyGroup>
<AssemblyOriginatorKeyFile>key.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>
<ItemGroup>
<Reference Include="0Harmony, Version=2.1.1.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>packages\Lib.Harmony.2.1.1\lib\net472\0Harmony.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
<Private>False</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>..\References\Assembly-CSharp.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="bsg.componentace.compression.libs.zlib, Version=1.0.3.0, Culture=neutral, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>References\bsg.componentace.compression.libs.zlib.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Comfort, Version=1.0.0.4, Culture=neutral, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>References\Comfort.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Newtonsoft.Json, Version=9.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>References\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="System">
<Private>False</Private>
</Reference>
<Reference Include="System.Core">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.CSharp">
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<HintPath>References\UnityEngine.CoreModule.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="Aki\Request.cs" />
<Compile Include="Aki\RequestHandler.cs" />
<Compile Include="Aki\ZLib.cs" />
<Compile Include="Behaviors\BotUnspawnController.cs" />
<Compile Include="Config.cs" />
<Compile Include="Extensions.cs" />
<Compile Include="Module.cs" />
<Compile Include="Globals.cs" />
<Compile Include="Patches\BotSpawnerPatch.Extensions.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerDelayPatch.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerOnActivateBotsByBossWavePatch.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerOnActivateBotsByScavWavePatch.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerOnStartPatch.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerOnStopPatch.cs" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnManager.cs" />
<Compile Include="Patches\BotSpawnerPatches.cs" />
<Compile Include="Patches\BotSpawnerPatches.IWaveDataWrapper.cs" />
<Compile Include="Patches\BotSpawnerPatches.MaxBotsAliveOnMapPatch.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Utils\Emit.cs" />
<Compile Include="Utils\GameConsole.cs" />
<Compile Include="Utils\JsonGenericDictionaryOrArrayConverter.cs" />
<Compile Include="Utils\Logger.cs" />
<Compile Include="Utils\GenericPatch`.cs" />
<Compile Include="Utils\TextUtils.cs" />
</ItemGroup>
<ItemGroup>
<Content Include="References\Assembly-CSharp.dll" />
<Content Include="References\bsg.componentace.compression.libs.zlib.dll" />
<Content Include="References\Comfort.dll" />
<Content Include="References\Newtonsoft.Json.dll" />
<Content Include="References\UnityEngine.CoreModule.dll" />
</ItemGroup>
<ItemGroup>
<None Include="key.snk" />
<None Include="packages.config" />
<Compile Include="Patches\BotSpawnerPatches.BotSpawnerOnBossActivationPatch.cs" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>
<PostBuildEvent>copy /y $(TargetPath) $(SolutionDir)\..\astealz-SmartSpawnController\module.dll</PostBuildEvent>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31515.178
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "astealz.SmartSpawnController", "astealz.SmartSpawnController.csproj", "{D355FDA8-163B-48F4-9416-884AC71C5A11}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D355FDA8-163B-48F4-9416-884AC71C5A11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D355FDA8-163B-48F4-9416-884AC71C5A11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D355FDA8-163B-48F4-9416-884AC71C5A11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D355FDA8-163B-48F4-9416-884AC71C5A11}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B7D6D417-AA89-44CF-9D1F-B505BFAAEE17}
EndGlobalSection
EndGlobal

BIN
src/key.snk Normal file

Binary file not shown.

4
src/packages.config Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Lib.Harmony" version="2.1.1" targetFramework="net472" />
</packages>