initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
23
.editorconfig
Normal file
23
.editorconfig
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{json,yml,yaml,csproj,props,targets,slnx,xml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
csharp_style_namespace_declarations = file_scoped:warning
|
||||||
|
csharp_prefer_braces = when_multiline:silent
|
||||||
|
csharp_style_var_for_built_in_types = false:silent
|
||||||
|
csharp_style_var_when_type_is_apparent = true:silent
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_diagnostic.IDE0005.severity = suggestion
|
||||||
66
.forgejo/workflows/ci.yml
Normal file
66
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: self-hosted-x86
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
- run: dotnet restore Outnumbered.slnx
|
||||||
|
- name: Format check
|
||||||
|
run: dotnet format Outnumbered.slnx --verify-no-changes --no-restore
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted-x86
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
- run: dotnet restore Outnumbered.slnx
|
||||||
|
- name: Build (SQLite dev config) + test
|
||||||
|
run: |
|
||||||
|
dotnet build Outnumbered.slnx -c Release --no-restore
|
||||||
|
dotnet test Outnumbered.slnx -c Release --no-build
|
||||||
|
- name: Build (Postgres-only release config)
|
||||||
|
run: dotnet build Outnumbered/outnumbered.csproj -c Release -p:WithSqlite=false --no-restore
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [lint, build]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: self-hosted-x86
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6.0.2
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
global-json-file: global.json
|
||||||
|
|
||||||
|
- name: Package (Postgres-only for production)
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG="outnumbered-${VERSION}"
|
||||||
|
dotnet publish Outnumbered/outnumbered.csproj -c Release -p:WithSqlite=false \
|
||||||
|
-o "${PKG}/addons/counterstrikesharp/plugins/outnumbered"
|
||||||
|
cp README.md LICENSE "${PKG}/"
|
||||||
|
mkdir -p dist
|
||||||
|
tar -czf "dist/${PKG}.tar.gz" "${PKG}"
|
||||||
|
|
||||||
|
- name: Publish Forgejo release
|
||||||
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
||||||
|
with:
|
||||||
|
direction: upload
|
||||||
|
url: ${{ github.server_url }}
|
||||||
|
repo: ${{ github.repository }}
|
||||||
|
tag: ${{ github.ref_name }}
|
||||||
|
release-dir: dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
override: true
|
||||||
|
release-notes: "Outnumbered ${{ github.ref_name }} — Postgres-only build. Extract `addons/` into <cs2>/game/csgo/; see README. SQLite single-server build: compile with -p:WithSqlite=true."
|
||||||
482
.gitignore
vendored
Normal file
482
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from `dotnet new gitignore`
|
||||||
|
|
||||||
|
# dotenv files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
[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
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Tye
|
||||||
|
.tye/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||||
|
!Directory.Build.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.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 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
##
|
||||||
|
## Visual studio for Mac
|
||||||
|
##
|
||||||
|
|
||||||
|
|
||||||
|
# globs
|
||||||
|
Makefile.in
|
||||||
|
*.userprefs
|
||||||
|
*.usertasks
|
||||||
|
config.make
|
||||||
|
config.status
|
||||||
|
aclocal.m4
|
||||||
|
install-sh
|
||||||
|
autom4te.cache/
|
||||||
|
*.tar.gz
|
||||||
|
tarballs/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# Vim temporary swap files
|
||||||
|
*.swp
|
||||||
10
Directory.Build.props
Normal file
10
Directory.Build.props
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
11
Directory.Build.targets
Normal file
11
Directory.Build.targets
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Project>
|
||||||
|
<Target Name="StripHostProvidedAssemblies" AfterTargets="Publish" Condition="'$(EnableDynamicLoading)' == 'true'">
|
||||||
|
<ItemGroup>
|
||||||
|
<_HostProvided Include="$(PublishDir)Microsoft.Extensions.*.dll" />
|
||||||
|
<_HostProvided Include="$(PublishDir)CounterStrikeSharp.*.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Delete Files="@(_HostProvided)" />
|
||||||
|
<Message Importance="high" Condition="'@(_HostProvided)' != ''"
|
||||||
|
Text="Stripped host-provided assemblies from publish: @(_HostProvided->'%(Filename)%(Extension)', ', ')" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
112
Outnumbered.Tests/BalanceInvariantTests.cs
Normal file
112
Outnumbered.Tests/BalanceInvariantTests.cs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// The DESIGN intent, encoded as magic-number-free properties — the real correctness net (the anchors only lock values).
|
||||||
|
// Headline rule: "the better you are, the harder it gets and the more XP you earn; the worse you are, the easier it gets but
|
||||||
|
// the less XP." These guard against a future tuning that silently re-inverts it.
|
||||||
|
public class BalanceInvariantTests
|
||||||
|
{
|
||||||
|
private const double Eps = 1e-12;
|
||||||
|
|
||||||
|
private static double Offense(PlayerSnapshot s, ResolvedHandicap rh) =>
|
||||||
|
CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, T.StatDefs(), rh, T.Abil(), T.BaseMaxHp);
|
||||||
|
|
||||||
|
private static void AssertBandsTrackT(PlayerSnapshot s, ResolvedHandicap rh,
|
||||||
|
ref double prevT, ref double prevDeal, ref double prevTake, ref double prevXp, string ctx)
|
||||||
|
{
|
||||||
|
double t = HandicapModel.ComputeT(s, rh);
|
||||||
|
HandicapModel.Bands(s, rh, out double deal, out double take, out double xp);
|
||||||
|
Assert.True(t >= prevT - Eps, $"{ctx}: t should not decrease ({t} < {prevT})");
|
||||||
|
Assert.True(deal <= prevDeal + Eps, $"{ctx}: deal should not increase ({deal} > {prevDeal})");
|
||||||
|
Assert.True(take >= prevTake - Eps, $"{ctx}: take should not decrease ({take} < {prevTake})");
|
||||||
|
Assert.True(xp >= prevXp - Eps, $"{ctx}: xp should not decrease ({xp} < {prevXp})");
|
||||||
|
prevT = t; prevDeal = deal; prevTake = take; prevXp = xp;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rising_kd_makes_it_harder_and_more_xp()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
|
||||||
|
for (int kills = 0; kills <= 40; kills++)
|
||||||
|
AssertBandsTrackT(T.Snap(level: 50, kills: kills, deaths: 5), rh, ref pt, ref pd, ref ptk, ref px, $"kills={kills}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rising_level_at_neutral_kd_makes_it_harder()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
|
||||||
|
for (int level = 0; level <= 100; level += 5)
|
||||||
|
AssertBandsTrackT(T.Snap(level: level, kills: 3, deaths: 2), rh, ref pt, ref pd, ref ptk, ref px, $"level={level}"); // K/D=1.5=neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rising_streak_makes_it_harder()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
double pt = -2, pd = double.MaxValue, ptk = -2, px = -2;
|
||||||
|
for (int streak = 0; streak <= 30; streak++)
|
||||||
|
AssertBandsTrackT(T.Snap(level: 10, kills: 3, deaths: 2, streak: streak), rh, ref pt, ref pd, ref ptk, ref px, $"streak={streak}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Dominant_player_is_net_nerfed_versus_a_fresh_neutral_one()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
var neutral = T.Snap(level: 0); // no stats, no record
|
||||||
|
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20,
|
||||||
|
upgrades: new(StringComparer.Ordinal) { [StatKeys.Damage] = 5 }); // even WITH maxed damage stat
|
||||||
|
|
||||||
|
// The headline retune property: the deal band (~0.1 at t=1) outweighs the stat bonus, so the dominant player's
|
||||||
|
// EFFECTIVE offense is below a fresh player's — being good is a net handicap on damage dealt.
|
||||||
|
Assert.True(Offense(dominant, rh) < Offense(neutral, rh),
|
||||||
|
$"dominant offense {Offense(dominant, rh)} should be < neutral {Offense(neutral, rh)}");
|
||||||
|
|
||||||
|
// ...but they take far more, and earn far more XP (the reward side of "harder").
|
||||||
|
Assert.True(HandicapModel.MTake(dominant, rh) > HandicapModel.MTake(neutral, rh));
|
||||||
|
Assert.True(HandicapModel.XpMult(dominant, rh) > HandicapModel.XpMult(neutral, rh));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Struggling_player_gets_easier_combat_but_less_xp()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
var neutral = T.Snap(level: 0);
|
||||||
|
var struggling = T.Snap(level: 10, kills: 1, deaths: 10);
|
||||||
|
|
||||||
|
Assert.True(HandicapModel.MDeal(struggling, rh) > HandicapModel.MDeal(neutral, rh)); // deals more
|
||||||
|
Assert.True(HandicapModel.MTake(struggling, rh) < HandicapModel.MTake(neutral, rh)); // takes less
|
||||||
|
Assert.True(HandicapModel.XpMult(struggling, rh) < HandicapModel.XpMult(neutral, rh)); // earns less (the cost of comeback help)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void T_is_always_within_unit_interval_even_at_extremes()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
foreach (int level in new[] { 0, 100, 1000 })
|
||||||
|
foreach (int kills in new[] { 0, 1, 50, 100000 })
|
||||||
|
foreach (int deaths in new[] { 0, 1, 50 })
|
||||||
|
foreach (int streak in new[] { 0, 100 })
|
||||||
|
foreach (double floor in new[] { -1.0, 0.5, 2.0 })
|
||||||
|
{
|
||||||
|
double t = HandicapModel.ComputeT(T.Snap(level: level, kills: kills, deaths: deaths, streak: streak, floor: floor), rh);
|
||||||
|
Assert.InRange(t, -1.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Per_mode_override_applies_only_named_fields()
|
||||||
|
{
|
||||||
|
// The survival override down-weights the skill factors so the WAVE FLOOR drives difficulty; everything else inherits.
|
||||||
|
var baseH = T.Hcap();
|
||||||
|
var ov = T.Surv().Handicap!; // the default survival override
|
||||||
|
var eff = ov.ApplyTo(baseH);
|
||||||
|
Assert.Equal(0.3, eff.KdWeight); // overridden
|
||||||
|
Assert.Equal(10.0, eff.MTakeCeiling); // overridden
|
||||||
|
Assert.Equal(baseH.MasterDifficulty, eff.MasterDifficulty); // inherited from base (not set in the override)
|
||||||
|
Assert.Equal(baseH.Curve, eff.Curve); // inherited
|
||||||
|
}
|
||||||
|
}
|
||||||
46
Outnumbered.Tests/CombatAmountsTests.cs
Normal file
46
Outnumbered.Tests/CombatAmountsTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// CombatResolver's pure reactive-sustain amounts (the engine does the HP/armor writes; these own the numbers). Expected
|
||||||
|
// ints are hand-known, exercising the (int)Math.Round (banker's, ToEven) rounding + the LifestealMinHeal floor.
|
||||||
|
public class CombatAmountsTests
|
||||||
|
{
|
||||||
|
// LifestealHeal = max(minHeal, (int)Round(dmgHealth * pct/100 * critMult)).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(100, 10, 1.0, 2, 10)] // clean
|
||||||
|
[InlineData(100, 10, 1.5, 2, 15)] // crit multiplier
|
||||||
|
[InlineData(10, 25, 1.0, 2, 2)] // 2.5 -> ToEven -> 2
|
||||||
|
[InlineData(30, 25, 1.0, 2, 8)] // 7.5 -> ToEven -> 8
|
||||||
|
[InlineData(4, 25, 1.0, 2, 2)] // 1.0 -> 1, but minHeal floor binds -> 2
|
||||||
|
[InlineData(0, 10, 1.0, 2, 2)] // 0 -> floor binds
|
||||||
|
public void LifestealHeal_matches(double dmgHealth, double pct, double critMult, int minHeal, int expected) =>
|
||||||
|
Assert.Equal(expected, CombatResolver.LifestealHeal(dmgHealth, pct, critMult, minHeal));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LifestealHeal_accepts_fractional_thorns_input()
|
||||||
|
{
|
||||||
|
// The thorns-reflect path passes a FLOAT dmgHealth (no pre-truncation) with critMult 1.0. 33.6*10/100 = 3.36 -> 3.
|
||||||
|
Assert.Equal(3, CombatResolver.LifestealHeal(33.6, 10, 1.0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArmorLifestealGain = (int)Round(dmgHealth * pct/100 * critMult). No floor.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(100, 10, 1.0, 10)]
|
||||||
|
[InlineData(10, 25, 1.0, 2)] // 2.5 -> 2
|
||||||
|
[InlineData(30, 25, 1.0, 8)] // 7.5 -> 8
|
||||||
|
[InlineData(0, 10, 1.0, 0)] // no floor -> 0
|
||||||
|
public void ArmorLifestealGain_matches(double dmgHealth, double pct, double critMult, int expected) =>
|
||||||
|
Assert.Equal(expected, CombatResolver.ArmorLifestealGain(dmgHealth, pct, critMult));
|
||||||
|
|
||||||
|
// ThornsReflect = (dmgHealth + dmgArmor) * pct/100 (a double). The caller deals it FLAT — no build/handicap re-applied —
|
||||||
|
// so the bot eats exactly this off the damage ACTUALLY taken (handicap already baked into dmg*).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(50, 30, 10, 8.0)]
|
||||||
|
[InlineData(100, 0, 5, 5.0)]
|
||||||
|
[InlineData(250, 0, 10, 25.0)] // the 5x-handicap example: take 250, reflect 10% = 25
|
||||||
|
[InlineData(0, 0, 10, 0.0)]
|
||||||
|
public void ThornsReflect_matches(int dmgHealth, int dmgArmor, double pct, double expected) =>
|
||||||
|
T.Close(expected, CombatResolver.ThornsReflect(dmgHealth, dmgArmor, pct));
|
||||||
|
}
|
||||||
136
Outnumbered.Tests/CombatChainTests.cs
Normal file
136
Outnumbered.Tests/CombatChainTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// CombatResolver's offense/defense multiplier chains — the single source of truth shared by the damage hook + the HUD.
|
||||||
|
// Anchors are hand-traced from the documented chain (dmgMul*hsMul*critMul*abilityMul*mDeal etc.) with mDeal/mTake passed
|
||||||
|
// explicitly so the handicap band is isolated from the stat/ability composition. Ability defaults: Overcharge.Magnitude=50,
|
||||||
|
// Adrenaline.Magnitude=35, Berserk.Magnitude=1.5/Magnitude2=1.5. crit_damage Base=100 (so a no-investment crit is x2).
|
||||||
|
public class CombatChainTests
|
||||||
|
{
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> MaxedOffense =
|
||||||
|
new(StringComparer.Ordinal) { [StatKeys.Damage] = 5, [StatKeys.HeadshotDamage] = 5, [StatKeys.CritDamage] = 10 };
|
||||||
|
|
||||||
|
// ---- OffenseMultiplier (precomputed-mDeal overload) ----
|
||||||
|
[Fact]
|
||||||
|
public void Offense_maxed_headshot_crit()
|
||||||
|
{
|
||||||
|
// dmg +50% (1.5) * hs +50% (1.5) * crit +200% (3.0) * 1 * mDeal(1) = 6.75
|
||||||
|
var s = T.Snap(upgrades: MaxedOffense);
|
||||||
|
T.Close(6.75, CombatResolver.OffenseMultiplier(s, headshot: true, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_maxed_bodyshot_no_crit_only_damage_applies()
|
||||||
|
{
|
||||||
|
var s = T.Snap(upgrades: MaxedOffense);
|
||||||
|
T.Close(1.5, CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, T.StatDefs(), T.Abil(), T.BaseMaxHp, mDeal: 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_overcharge_multiplies_by_magnitude()
|
||||||
|
{
|
||||||
|
// no stats, Overcharge active -> abilityMul 1.5
|
||||||
|
var s = T.Snap(overcharge: true);
|
||||||
|
T.Close(1.5, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_berserk_passive_card_scales_with_missing_hp()
|
||||||
|
{
|
||||||
|
// berserk_passive card 120, Health 50/maxHp 100 -> missing 0.5 -> abilityMul 1 + 0.5*120/100 = 1.6
|
||||||
|
var s = T.Snap(health: 50, cards: new Cards((CardKeys.BerserkPassive, 120)));
|
||||||
|
T.Close(1.6, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_berserk_ability_scales_with_missing_hp()
|
||||||
|
{
|
||||||
|
// Berserk ability active, Health 25 -> missing 0.75; abilityMul 1 + 0.75*1.5 = 2.125
|
||||||
|
var s = T.Snap(health: 25, berserk: true);
|
||||||
|
T.Close(2.125, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_berserk_ability_crit_adds_to_crit_damage()
|
||||||
|
{
|
||||||
|
// crit (no crit_damage stat -> base x2 = 2.0) + Berserk crit bonus missing*Magnitude2 = 0.75*1.5 -> critMul 3.125;
|
||||||
|
// abilityMul 2.125 -> 3.125 * 2.125 = 6.640625
|
||||||
|
var s = T.Snap(health: 25, berserk: true);
|
||||||
|
T.Close(6.640625, CombatResolver.OffenseMultiplier(s, false, crit: true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_abilities_disabled_ignores_active_flags()
|
||||||
|
{
|
||||||
|
var ab = T.Abil();
|
||||||
|
ab.Enabled = false;
|
||||||
|
var s = T.Snap(overcharge: true, berserk: true, health: 25); // flags set but abilities off
|
||||||
|
T.Close(1.0, CombatResolver.OffenseMultiplier(s, false, false, T.StatDefs(), ab, T.BaseMaxHp, 1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Offense_passes_mDeal_through_linearly()
|
||||||
|
{
|
||||||
|
var s = T.Snap(upgrades: MaxedOffense);
|
||||||
|
// 6.75 * 0.1 = 0.675 (a dominant player's compressed deal band)
|
||||||
|
T.Close(0.675, CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DefenseMultiplier (precomputed-mTake overload) ----
|
||||||
|
[Fact]
|
||||||
|
public void Defense_plain_is_just_mTake() =>
|
||||||
|
T.Close(1.0, CombatResolver.DefenseMultiplier(T.Snap(), headshot: false, T.Abil(), mTake: 1.0));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defense_adrenaline_reduces_take()
|
||||||
|
{
|
||||||
|
var s = T.Snap(adrenaline: true);
|
||||||
|
T.Close(0.65, CombatResolver.DefenseMultiplier(s, false, T.Abil(), 1.0)); // *(1 - 35/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defense_headshot_reduction_card_only_on_headshots()
|
||||||
|
{
|
||||||
|
var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 45)));
|
||||||
|
T.Close(1.0, CombatResolver.DefenseMultiplier(s, headshot: false, T.Abil(), 1.0)); // body: card dormant
|
||||||
|
T.Close(0.55, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // *(1 - 45/100)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defense_headshot_reduction_over_hundred_clamps_to_zero()
|
||||||
|
{
|
||||||
|
var s = T.Snap(cards: new Cards((CardKeys.HsReduction, 120)));
|
||||||
|
T.Close(0.0, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), 1.0)); // max(0, 1-1.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defense_stacks_adrenaline_and_hs_card_on_mTake()
|
||||||
|
{
|
||||||
|
var s = T.Snap(adrenaline: true, cards: new Cards((CardKeys.HsReduction, 45)));
|
||||||
|
T.Close(0.715, CombatResolver.DefenseMultiplier(s, headshot: true, T.Abil(), mTake: 2.0)); // 2 * 0.65 * 0.55
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the rh-overloads must equal "compute the band then call the precomputed overload" (same code path) ----
|
||||||
|
[Fact]
|
||||||
|
public void Offense_rh_overload_equals_precomputed_mDeal()
|
||||||
|
{
|
||||||
|
var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, upgrades: MaxedOffense);
|
||||||
|
var rh = T.Resolved();
|
||||||
|
double viaRh = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), rh, T.Abil(), T.BaseMaxHp);
|
||||||
|
double viaBand = CombatResolver.OffenseMultiplier(s, true, true, T.StatDefs(), T.Abil(), T.BaseMaxHp, HandicapModel.MDeal(s, rh));
|
||||||
|
Assert.Equal(viaBand, viaRh);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Defense_rh_overload_equals_precomputed_mTake()
|
||||||
|
{
|
||||||
|
var s = T.Snap(level: 50, kills: 10, deaths: 4, streak: 5, adrenaline: true);
|
||||||
|
var rh = T.Resolved();
|
||||||
|
double viaRh = CombatResolver.DefenseMultiplier(s, true, rh, T.Abil());
|
||||||
|
double viaBand = CombatResolver.DefenseMultiplier(s, true, T.Abil(), HandicapModel.MTake(s, rh));
|
||||||
|
Assert.Equal(viaBand, viaRh);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Outnumbered.Tests/HandicapModelTests.cs
Normal file
125
Outnumbered.Tests/HandicapModelTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// HandicapModel: the balance spine. ComputeT -> the deal/take/xp bands. Anchor t/band values were produced by an INDEPENDENT
|
||||||
|
// Python re-implementation of the documented formula against the retuned code defaults (MasterDifficulty 1.6, Curve 1.3,
|
||||||
|
// KdNeutral 1.5/MaxNerf 4.0, weights Kd2/Hs1/Streak1/Level1.5, bands deal 0.033..1.3 / take 0.85..8 / xp 0.1..10), so these
|
||||||
|
// validate the C# implementation rather than merely locking it. Fractional-t rows route through Math.Pow -> compared at 1e-9.
|
||||||
|
public class HandicapModelTests
|
||||||
|
{
|
||||||
|
// level, kills, deaths, headshotKills, streak, floor | expected t, deal, take, xp
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0, 0, 0, 0, -1.0, /**/ 0.0, 1.0, 1.0, 1.0)] // pure neutral
|
||||||
|
[InlineData(100, 40, 5, 24, 20, -1.0, /**/ 1.0, 0.03300000000000003, 8.0, 10.0)] // dominant maxed -> t=1
|
||||||
|
[InlineData(10, 1, 10, 0, 0, -1.0, /**/ -1.0, 1.3, 0.85, 0.09999999999999998)] // struggling -> t=-1 (comeback)
|
||||||
|
[InlineData(50, 10, 4, 0, 5, -1.0, /**/ 0.4312595622635058, 0.5829720032911898, 4.018816935844541, 4.881336060371552)] // mid lead
|
||||||
|
[InlineData(1, 20, 4, 0, 0, -1.0, /**/ 0.4993925356559294, 0.5170874180207163, 4.495747749591506, 5.494532820903364)] // high K/D, low level
|
||||||
|
[InlineData(100, 3, 2, 0, 0, -1.0, /**/ 0.3402539047656616, 0.6709744740916053, 3.381777333359631, 4.062285142890954)] // maxed level at neutral K/D (residual)
|
||||||
|
[InlineData(10, 1, 10, 0, 0, 0.5, /**/ 0.40612619817811774, 0.6072759663617602, 3.842883387246824, 4.655135783603059)] // struggler RAISED by survival floor 0.5
|
||||||
|
public void ComputeT_and_bands_match_oracle(int level, int kills, int deaths, int hsk, int streak, double floor,
|
||||||
|
double et, double ed, double etk, double exp)
|
||||||
|
{
|
||||||
|
var s = T.Snap(level: level, kills: kills, deaths: deaths, headshotKills: hsk, streak: streak, floor: floor);
|
||||||
|
var rh = T.Resolved();
|
||||||
|
|
||||||
|
double t = HandicapModel.ComputeT(s, rh);
|
||||||
|
T.Close(et, t);
|
||||||
|
|
||||||
|
HandicapModel.Bands(s, rh, out double deal, out double take, out double xp);
|
||||||
|
T.Close(ed, deal);
|
||||||
|
T.Close(etk, take);
|
||||||
|
T.Close(exp, xp);
|
||||||
|
|
||||||
|
// Bands must be bit-identical to the individual band functions (the HUD shares one ComputeT via Bands).
|
||||||
|
Assert.Equal(HandicapModel.MDeal(s, rh), deal);
|
||||||
|
Assert.Equal(HandicapModel.MTake(s, rh), take);
|
||||||
|
Assert.Equal(HandicapModel.XpMult(s, rh), xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BandsFromT = the balance API's curve sampler: RAW t in, the same Curve ease applied inside, team multipliers
|
||||||
|
// neutral. Equivalence with the live path is proven by routing the SAME raw t through ComputeT via the survival
|
||||||
|
// floor on an all-zero-factor snapshot (floor >= 0 forces t = floor before the shared ease).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0.0)]
|
||||||
|
[InlineData(0.25)]
|
||||||
|
[InlineData(0.5)]
|
||||||
|
[InlineData(1.0)]
|
||||||
|
public void BandsFromT_matches_live_bands_at_same_raw_t(double t)
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
var s = T.Snap(level: 0, floor: t); // level: 0 — Snap's level=1 default is a nonzero factor that would shift t off the floor
|
||||||
|
HandicapModel.BandsFromT(t, rh, out double d, out double tk, out double x);
|
||||||
|
HandicapModel.Bands(s, rh, out double liveDeal, out double liveTake, out double liveXp);
|
||||||
|
Assert.Equal(liveDeal, d);
|
||||||
|
Assert.Equal(liveTake, tk);
|
||||||
|
Assert.Equal(liveXp, x);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BandsFromT_disabled_is_flat_neutral() // mirrors ComputeT's Enabled short-circuit — sampled curves must be x1 too
|
||||||
|
{
|
||||||
|
var h = T.Hcap();
|
||||||
|
h.Enabled = false;
|
||||||
|
var rh = T.Resolved(h);
|
||||||
|
foreach (var t in new[] { -1.0, -0.4, 0.0, 0.7, 1.0 })
|
||||||
|
{
|
||||||
|
HandicapModel.BandsFromT(t, rh, out double d, out double tk, out double x);
|
||||||
|
Assert.Equal(1.0, d);
|
||||||
|
Assert.Equal(1.0, tk);
|
||||||
|
Assert.Equal(1.0, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BandsFromT_extremes_hit_the_configured_bands() // ease(+-1) = +-1 for ANY Curve, so t=+-1 pins the bands
|
||||||
|
{
|
||||||
|
// T.Close, not Assert.Equal: Lerp(1, band, 1) reconstructs the band with float dust (same reason the oracle
|
||||||
|
// rows above encode 0.03300000000000003 for the 0.033 deal floor).
|
||||||
|
var h = T.Hcap();
|
||||||
|
var rh = T.Resolved(h);
|
||||||
|
HandicapModel.BandsFromT(1.0, rh, out double d1, out double t1, out double x1);
|
||||||
|
T.Close(h.MDealFloor, d1);
|
||||||
|
T.Close(h.MTakeCeiling, t1);
|
||||||
|
T.Close(h.XpCeiling, x1);
|
||||||
|
HandicapModel.BandsFromT(-1.0, rh, out double d2, out double t2, out double x2);
|
||||||
|
T.Close(h.MDealCeiling, d2);
|
||||||
|
T.Close(h.MTakeFloor, t2);
|
||||||
|
T.Close(h.XpFloor, x2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_handicap_is_neutral()
|
||||||
|
{
|
||||||
|
var h = T.Hcap();
|
||||||
|
h.Enabled = false;
|
||||||
|
var rh = T.Resolved(h);
|
||||||
|
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20);
|
||||||
|
Assert.Equal(0.0, HandicapModel.ComputeT(dominant, rh));
|
||||||
|
HandicapModel.Bands(dominant, rh, out double deal, out double take, out double xp);
|
||||||
|
Assert.Equal(1.0, deal);
|
||||||
|
Assert.Equal(1.0, take);
|
||||||
|
Assert.Equal(1.0, xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Team_card_multipliers_ride_on_top_of_the_band()
|
||||||
|
{
|
||||||
|
// Neutral t=0 -> dealFromT=takeFromT=1, so the survival squad card shows through directly. XP is NOT team-scaled.
|
||||||
|
var rh = T.Resolved();
|
||||||
|
var s = T.Snap(level: 0, teamDeal: 1.331, teamTake: 0.729);
|
||||||
|
T.Close(1.331, HandicapModel.MDeal(s, rh));
|
||||||
|
T.Close(0.729, HandicapModel.MTake(s, rh));
|
||||||
|
T.Close(1.0, HandicapModel.XpMult(s, rh)); // xp band ignores team mults
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Floor_raises_but_never_lowers_t()
|
||||||
|
{
|
||||||
|
var rh = T.Resolved();
|
||||||
|
// A dominant player (t would be ~1) with a LOW floor is unaffected; the floor only ever pulls a low t UP.
|
||||||
|
var dominant = T.Snap(level: 100, kills: 40, deaths: 5, headshotKills: 24, streak: 20, floor: 0.2);
|
||||||
|
Assert.Equal(1.0, HandicapModel.ComputeT(dominant, rh)); // floor 0.2 < natural 1.0 -> no effect
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Outnumbered.Tests/Outnumbered.Tests.csproj
Normal file
21
Outnumbered.Tests/Outnumbered.Tests.csproj
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<RootNamespace>Outnumbered.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="../Outnumbered/Domain/*.cs">
|
||||||
|
<Link>Linked/Domain/%(Filename)%(Extension)</Link>
|
||||||
|
</Compile>
|
||||||
|
<Compile Include="../Outnumbered/Config/DomainConfig.cs">
|
||||||
|
<Link>Linked/Config/DomainConfig.cs</Link>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
54
Outnumbered.Tests/ProgressionModelTests.cs
Normal file
54
Outnumbered.Tests/ProgressionModelTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// ProgressionModel: XP curve + prestige boost. Expected XP values are INDEPENDENT literals (computed offline from the
|
||||||
|
// documented formula), so these catch an implementation typo, not just lock current output.
|
||||||
|
public class ProgressionModelTests
|
||||||
|
{
|
||||||
|
// XpToNext = LevelXpBase + round(LevelXpStep * (max(0,L-1))^LevelXpExponent); defaults Base=100, Step=8, Exp=1.7.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 100)] // (L-1)=0 -> 0 -> just the base
|
||||||
|
[InlineData(2, 108)]
|
||||||
|
[InlineData(3, 126)]
|
||||||
|
[InlineData(5, 184)]
|
||||||
|
[InlineData(10, 435)]
|
||||||
|
[InlineData(25, 1876)]
|
||||||
|
[InlineData(50, 6076)]
|
||||||
|
[InlineData(100, 19855)]
|
||||||
|
public void XpToNext_matches_curve(int level, long expected) =>
|
||||||
|
Assert.Equal(expected, ProgressionModel.XpToNext(level, T.Prog()));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-5)]
|
||||||
|
public void XpToNext_floors_negative_level_at_base(int level) =>
|
||||||
|
Assert.Equal(100L, ProgressionModel.XpToNext(level, T.Prog())); // Math.Max(0, L-1) guards the Pow base
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void XpToNext_is_strictly_increasing_above_level_one()
|
||||||
|
{
|
||||||
|
var c = T.Prog();
|
||||||
|
long prev = ProgressionModel.XpToNext(1, c);
|
||||||
|
for (int l = 2; l <= c.LevelCap; l++)
|
||||||
|
{
|
||||||
|
long cur = ProgressionModel.XpToNext(l, c);
|
||||||
|
Assert.True(cur > prev, $"XpToNext({l})={cur} should exceed XpToNext({l - 1})={prev}");
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrestigeXpMultiplier = 1 + prestige * (PrestigeXpBoostPercent/100); default 10% per prestige.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 1.00)]
|
||||||
|
[InlineData(1, 1.10)]
|
||||||
|
[InlineData(5, 1.50)]
|
||||||
|
[InlineData(10, 2.00)]
|
||||||
|
public void PrestigeXpMultiplier_matches(int prestige, double expected) =>
|
||||||
|
T.Close(expected, ProgressionModel.PrestigeXpMultiplier(prestige, T.Prog()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retune_pinned_prestige_boost_is_ten_percent() =>
|
||||||
|
Assert.Equal(10, T.Prog().PrestigeXpBoostPercent); // pins the balance-retune knob
|
||||||
|
}
|
||||||
108
Outnumbered.Tests/StatResolverTests.cs
Normal file
108
Outnumbered.Tests/StatResolverTests.cs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// StatResolver: effective stat values from invested levels + run-card bonuses, plus MaxHp/MaxArmor and the missing-HP
|
||||||
|
// fraction (Berserk driver). Stat defaults (StatsConfig): damage Base0/+10, crit_damage Base100/+10, max_hp Base0/+25.
|
||||||
|
public class StatResolverTests
|
||||||
|
{
|
||||||
|
private static Dictionary<string, int> Up(params (string key, int lvl)[] e) =>
|
||||||
|
e.ToDictionary(x => x.key, x => x.lvl, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0)] // base 0
|
||||||
|
[InlineData(3, 30)] // +10/lvl
|
||||||
|
[InlineData(5, 50)] // maxed
|
||||||
|
public void Eff_damage_is_base_plus_level(int level, double expected) =>
|
||||||
|
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.Damage, level))), StatKeys.Damage, T.StatDefs()));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 100)] // crit_damage has Base=100
|
||||||
|
[InlineData(10, 200)] // +10/lvl maxed
|
||||||
|
public void Eff_honours_base_value(int level, double expected) =>
|
||||||
|
Assert.Equal(expected, StatResolver.Eff(T.Snap(upgrades: Up((StatKeys.CritDamage, level))), StatKeys.CritDamage, T.StatDefs()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Eff_unknown_key_is_zero() =>
|
||||||
|
Assert.Equal(0.0, StatResolver.Eff(T.Snap(), "no_such_stat", T.StatDefs()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Eff_null_upgrades_yields_base_only()
|
||||||
|
{
|
||||||
|
// The pd-less / no-investment snapshot: Upgrades null -> LevelOf returns 0 -> Base only (crit_damage Base=100).
|
||||||
|
var s = T.Snap() with { Upgrades = null! };
|
||||||
|
Assert.Equal(100.0, StatResolver.Eff(s, StatKeys.CritDamage, T.StatDefs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EffRun_adds_card_bonus_on_top_of_permanent()
|
||||||
|
{
|
||||||
|
var s = T.Snap(upgrades: Up((StatKeys.Damage, 5)), cards: new Cards((StatKeys.Damage, 100)));
|
||||||
|
Assert.Equal(50.0, StatResolver.Eff(s, StatKeys.Damage, T.StatDefs())); // permanent only
|
||||||
|
Assert.Equal(150.0, StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs())); // + card
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EffRun_without_cards_equals_eff()
|
||||||
|
{
|
||||||
|
var s = T.Snap(upgrades: Up((StatKeys.Damage, 3)));
|
||||||
|
Assert.Equal(StatResolver.Eff(s, StatKeys.Damage, T.StatDefs()),
|
||||||
|
StatResolver.EffRun(s, StatKeys.Damage, T.StatDefs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 100)] // base 100 HP, no investment
|
||||||
|
[InlineData(10, 350)] // +25/lvl * 10 = +250
|
||||||
|
public void MaxHp_is_base_plus_effrun(int level, int expected) =>
|
||||||
|
Assert.Equal(expected, StatResolver.MaxHp(T.Snap(upgrades: Up((StatKeys.MaxHp, level))), T.StatDefs(), T.BaseMaxHp));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MaxHp_includes_card_points_and_truncates()
|
||||||
|
{
|
||||||
|
// max_hp level 10 (=250) + a flat +40 card -> 100 + (int)290 = 390.
|
||||||
|
var s = T.Snap(upgrades: Up((StatKeys.MaxHp, 10)), cards: new Cards((StatKeys.MaxHp, 40)));
|
||||||
|
Assert.Equal(390, StatResolver.MaxHp(s, T.StatDefs(), T.BaseMaxHp));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 100)]
|
||||||
|
[InlineData(10, 350)]
|
||||||
|
public void MaxArmor_is_base_plus_effrun(int level, int expected) =>
|
||||||
|
Assert.Equal(expected, StatResolver.MaxArmor(T.Snap(upgrades: Up((StatKeys.MaxArmor, level))), T.StatDefs(), T.BaseMaxArmor));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CritChance_reads_the_crit_chance_stat() =>
|
||||||
|
Assert.Equal(50.0, CombatResolver.CritChance(T.Snap(upgrades: Up((StatKeys.CritChance, 20))), T.StatDefs())); // +2.5/lvl * 20
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CardMag_returns_effect_card_value_or_zero()
|
||||||
|
{
|
||||||
|
var s = T.Snap(cards: new Cards((CardKeys.BerserkPassive, 120)));
|
||||||
|
Assert.Equal(120.0, StatResolver.CardMag(s, CardKeys.BerserkPassive));
|
||||||
|
Assert.Equal(0.0, StatResolver.CardMag(s, CardKeys.HsReduction)); // key not present
|
||||||
|
Assert.Equal(0.0, StatResolver.CardMag(T.Snap(), CardKeys.BerserkPassive)); // no cards at all
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MissingHpFraction: 0 at full, ->1 near death, and the §3 settled delta: 0 (not 1) for a dead / pawn-less snapshot ----
|
||||||
|
[Theory]
|
||||||
|
[InlineData(100, 100, 0.0)] // full
|
||||||
|
[InlineData(50, 100, 0.5)]
|
||||||
|
[InlineData(1, 100, 0.99)] // near death
|
||||||
|
public void MissingHpFraction_scales_with_missing_health(int health, int maxHp, double expected) =>
|
||||||
|
T.Close(expected, StatResolver.MissingHpFraction(T.Snap(health: health), maxHp));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)] // §3 DELTA #2: Health<=0 (no live pawn captured) -> 0, NOT 1. Missing-HP scaling needs a live attacker.
|
||||||
|
[InlineData(-5)]
|
||||||
|
public void MissingHpFraction_is_zero_for_dead_or_pawnless(int health) =>
|
||||||
|
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: health), 100));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingHpFraction_zero_for_nonpositive_maxhp() =>
|
||||||
|
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 50), 0));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingHpFraction_clamps_overheal_to_zero() =>
|
||||||
|
Assert.Equal(0.0, StatResolver.MissingHpFraction(T.Snap(health: 150), 100)); // 1 - 1.5 = -0.5 -> 0
|
||||||
|
}
|
||||||
113
Outnumbered.Tests/SurvivalEconomyTests.cs
Normal file
113
Outnumbered.Tests/SurvivalEconomyTests.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// SurvivalEconomy: wave population/budget/escalation + the PER-WAVE XP grant. Defaults (DomainConfig.SurvivalConfig):
|
||||||
|
// AliveBase=4, AlivePerWave=2, AliveCap=20; BudgetBase=6, BudgetPerWave=4, BudgetPerPlayer=3; MaxNerfWave=25;
|
||||||
|
// WaveCount=20, WinMult=24. XP is granted per cleared wave: rawWaveXp x prestige x WaveMult(wave) (no cap, no XpBoost,
|
||||||
|
// handicap excluded). WaveMult(w) = WinMult^((w-1)/(WaveCount-1)).
|
||||||
|
public class SurvivalEconomyTests
|
||||||
|
{
|
||||||
|
// AliveForWave = clamp(AliveBase + AlivePerWave*(wave-1), 1, AliveCap).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 4)] // base
|
||||||
|
[InlineData(2, 6)]
|
||||||
|
[InlineData(9, 20)] // 4 + 2*8 = 20 -> hits cap
|
||||||
|
[InlineData(50, 20)] // clamped to AliveCap
|
||||||
|
public void AliveForWave_ramps_then_caps(int wave, int expected) =>
|
||||||
|
Assert.Equal(expected, SurvivalEconomy.AliveForWave(wave, T.Surv()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AliveForWave_never_below_one() =>
|
||||||
|
Assert.Equal(1, SurvivalEconomy.AliveForWave(-5, T.Surv())); // lower clamp
|
||||||
|
|
||||||
|
// WaveBudget = max(1, BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumans).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 1, 9)] // 6 + 0 + 3*1
|
||||||
|
[InlineData(1, 4, 18)] // 6 + 0 + 3*4
|
||||||
|
[InlineData(5, 3, 31)] // 6 + 16 + 9
|
||||||
|
public void WaveBudget_matches(int wave, int aliveHumans, int expected) =>
|
||||||
|
Assert.Equal(expected, SurvivalEconomy.WaveBudget(wave, aliveHumans, T.Surv()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WaveBudget_never_below_one() =>
|
||||||
|
Assert.Equal(1, SurvivalEconomy.WaveBudget(-10, 0, T.Surv()));
|
||||||
|
|
||||||
|
// HandicapFloor = wave<=0 ? -1 : min(1, wave/max(1,MaxNerfWave)). Monotonic escalate-only floor in t-space.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, -1.0)] // idle / between runs
|
||||||
|
[InlineData(-3, -1.0)]
|
||||||
|
[InlineData(1, 0.04)] // 1/25
|
||||||
|
[InlineData(25, 1.0)] // reaches full nerf at MaxNerfWave
|
||||||
|
[InlineData(40, 1.0)] // clamped at 1
|
||||||
|
public void HandicapFloor_matches(int wave, double expected) =>
|
||||||
|
T.Close(expected, SurvivalEconomy.HandicapFloor(wave, T.Surv()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HandicapFloor_is_monotonic_non_decreasing()
|
||||||
|
{
|
||||||
|
var c = T.Surv();
|
||||||
|
double prev = SurvivalEconomy.HandicapFloor(1, c);
|
||||||
|
for (int w = 2; w <= c.WaveCount; w++)
|
||||||
|
{
|
||||||
|
double cur = SurvivalEconomy.HandicapFloor(w, c);
|
||||||
|
Assert.True(cur >= prev, $"floor wave {w}={cur} < wave {w - 1}={prev}");
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaveMult(w) = WinMult ^ ((w-1)/(WaveCount-1)). Use a clean config (WinMult 100, WaveCount 11) for exact landmarks.
|
||||||
|
private static SurvivalConfig Clean() => new() { WinMult = 100, WaveCount = 11 };
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1, 1.0)] // wave 1 -> x1
|
||||||
|
[InlineData(6, 10.0)] // midpoint -> 100^0.5 = 10
|
||||||
|
[InlineData(11, 100.0)] // final wave -> xWinMult
|
||||||
|
public void WaveMult_ramps_one_to_winmult(int wave, double expected) =>
|
||||||
|
T.Close(expected, SurvivalEconomy.WaveMult(wave, Clean()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WaveMult_endpoints_on_defaults()
|
||||||
|
{
|
||||||
|
var c = T.Surv();
|
||||||
|
T.Close(1.0, SurvivalEconomy.WaveMult(1, c)); // wave 1 always x1
|
||||||
|
T.Close(c.WinMult, SurvivalEconomy.WaveMult(c.WaveCount, c)); // final wave = WinMult
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Retune_pinned_winmult_default() => Assert.Equal(24.0, T.Surv().WinMult); // pins the survival XP knob
|
||||||
|
|
||||||
|
// WaveXpLump = floor(rawWaveXp * prestigeMult * WaveMult(wave)); NO cap, NO XpBoost, handicap excluded.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(1000, 6, 0, 10000)] // 1000 * 1.0 * 10
|
||||||
|
[InlineData(1000, 11, 0, 100000)] // 1000 * 1.0 * 100
|
||||||
|
[InlineData(1000, 1, 5, 1500)] // 1000 * 1.5(prestige) * 1
|
||||||
|
[InlineData(1000, 6, 10, 20000)] // 1000 * 2.0(prestige) * 10
|
||||||
|
public void WaveXpLump_matches(double rawWaveXp, int wave, int prestige, long expected) =>
|
||||||
|
Assert.Equal(expected, SurvivalEconomy.WaveXpLump(rawWaveXp, wave, prestige, Clean(), T.Prog()));
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0)]
|
||||||
|
[InlineData(-50)]
|
||||||
|
public void WaveXpLump_zero_for_nonpositive(double rawWaveXp) =>
|
||||||
|
Assert.Equal(0L, SurvivalEconomy.WaveXpLump(rawWaveXp, 6, 0, Clean(), T.Prog()));
|
||||||
|
|
||||||
|
// AccrueWaveXp = amount * (1 + xpMultCard/100).
|
||||||
|
[Theory]
|
||||||
|
[InlineData(100, 0, 100)]
|
||||||
|
[InlineData(100, 50, 150)]
|
||||||
|
public void AccrueWaveXp_matches(double amount, double cardPct, double expected) =>
|
||||||
|
T.Close(expected, SurvivalEconomy.AccrueWaveXp(amount, cardPct));
|
||||||
|
|
||||||
|
// TeamMult: compounding per-level squad card. increase -> (1+p/100)^L ; decrease -> max(0,1-p/100)^L.
|
||||||
|
[Theory]
|
||||||
|
[InlineData(3, 10, true, 1.3310000000000004)]
|
||||||
|
[InlineData(3, 10, false, 0.7290000000000001)]
|
||||||
|
[InlineData(0, 10, true, 1.0)]
|
||||||
|
[InlineData(3, 100, false, 0.0)] // 1-100% = 0, floored
|
||||||
|
[InlineData(2, 50, true, 2.25)]
|
||||||
|
public void TeamMult_matches(int level, double perPick, bool increase, double expected) =>
|
||||||
|
T.Close(expected, SurvivalEconomy.TeamMult(level, perPick, increase));
|
||||||
|
}
|
||||||
93
Outnumbered.Tests/TestSupport.cs
Normal file
93
Outnumbered.Tests/TestSupport.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Outnumbered.Tests;
|
||||||
|
|
||||||
|
// Shared fixtures for the Domain tests. The config objects' parameterless constructors already carry the SHIPPING code
|
||||||
|
// defaults (the balance-retune values in DomainConfig.cs are those defaults), so `new HandicapConfig()` etc. is exactly the
|
||||||
|
// production config — the live OP testing JSON is deliberately NOT involved (balance source of truth = code defaults).
|
||||||
|
internal static class T
|
||||||
|
{
|
||||||
|
public const int BaseMaxHp = 100; // SnapshotBuilder.BaseMaxHp
|
||||||
|
public const int BaseMaxArmor = 100; // SnapshotBuilder.BaseMaxArmor
|
||||||
|
|
||||||
|
public static HandicapConfig Hcap() => new();
|
||||||
|
public static ProgressionConfig Prog() => new();
|
||||||
|
public static StatsConfig Stats() => new();
|
||||||
|
public static SurvivalConfig Surv() => new();
|
||||||
|
public static AbilitiesConfig Abil() => new();
|
||||||
|
|
||||||
|
public static ResolvedHandicap Resolved() => new(Hcap());
|
||||||
|
public static ResolvedHandicap Resolved(HandicapConfig h) => new(h);
|
||||||
|
|
||||||
|
// Mirrors SnapshotBuilder.RebuildStatDefs + Stats.StatRegistry EXACTLY (same 12 key->StatDef rows, StringComparer.Ordinal).
|
||||||
|
// If StatRegistry changes, this must change with it — the load-time validators in the engine keep the live registry honest;
|
||||||
|
// this is the test-side twin.
|
||||||
|
public static FrozenDictionary<string, StatDef> StatDefs() => StatDefs(Stats());
|
||||||
|
public static FrozenDictionary<string, StatDef> StatDefs(StatsConfig c) => new Dictionary<string, StatDef>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
[StatKeys.Damage] = c.Damage,
|
||||||
|
[StatKeys.CritChance] = c.CritChance,
|
||||||
|
[StatKeys.CritDamage] = c.CritDamage,
|
||||||
|
[StatKeys.HeadshotDamage] = c.HeadshotDamage,
|
||||||
|
[StatKeys.MaxHp] = c.MaxHp,
|
||||||
|
[StatKeys.MaxArmor] = c.MaxArmor,
|
||||||
|
[StatKeys.Lifesteal] = c.Lifesteal,
|
||||||
|
[StatKeys.ArmorLifesteal] = c.ArmorLifesteal,
|
||||||
|
[StatKeys.HpRegen] = c.HpRegen,
|
||||||
|
[StatKeys.ArmorRegen] = c.ArmorRegen,
|
||||||
|
[StatKeys.Thorns] = c.Thorns,
|
||||||
|
[StatKeys.XpBoost] = c.XpBoost,
|
||||||
|
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// A PlayerSnapshot factory: neutral by default (kills+deaths<3 so the K/D factor is dormant, full HP, no floor/team buff,
|
||||||
|
// no abilities). Override only the fields a case cares about.
|
||||||
|
public static PlayerSnapshot Snap(
|
||||||
|
int level = 1, int prestige = 0, long xp = 0,
|
||||||
|
int kills = 0, int deaths = 0, int headshotKills = 0, int streak = 0,
|
||||||
|
int health = 100,
|
||||||
|
double progress = 0.0, double floor = -1.0,
|
||||||
|
double teamDeal = 1.0, double teamTake = 1.0,
|
||||||
|
bool overcharge = false, bool berserk = false, bool adrenaline = false,
|
||||||
|
Dictionary<string, int>? upgrades = null, IStatBonusSource? cards = null) => new()
|
||||||
|
{
|
||||||
|
Level = level,
|
||||||
|
Prestige = prestige,
|
||||||
|
Xp = xp,
|
||||||
|
Kills = kills,
|
||||||
|
Deaths = deaths,
|
||||||
|
HeadshotKills = headshotKills,
|
||||||
|
Streak = streak,
|
||||||
|
Health = health,
|
||||||
|
HandicapProgress = progress,
|
||||||
|
HandicapFloor = floor,
|
||||||
|
TeamDealMult = teamDeal,
|
||||||
|
TeamTakeMult = teamTake,
|
||||||
|
OverchargeActive = overcharge,
|
||||||
|
BerserkActive = berserk,
|
||||||
|
AdrenalineActive = adrenaline,
|
||||||
|
Upgrades = upgrades ?? new Dictionary<string, int>(),
|
||||||
|
Cards = cards,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Relative-tolerance double compare. The fractional-t bands route through Math.Pow, whose last ULP can differ from the
|
||||||
|
// independent Python oracle that produced the literals; 1e-9 is microscopic next to any real formula change (which moves a
|
||||||
|
// value by whole percent), so this still catches every regression while ignoring FP noise.
|
||||||
|
public static void Close(double expected, double actual, double rel = 1e-9)
|
||||||
|
{
|
||||||
|
double tol = rel * Math.Max(1.0, Math.Abs(expected));
|
||||||
|
Assert.True(Math.Abs(expected - actual) <= tol,
|
||||||
|
$"expected {expected:R}, got {actual:R} (|diff|={Math.Abs(expected - actual):R} > tol={tol:R})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dictionary-backed IStatBonusSource — stands in for the survival run's per-stat card accumulator. The Domain only ever calls
|
||||||
|
// Bonus(key); the engine-side level*PerPick accumulation isn't Domain logic, so a flat dictionary is the faithful test double.
|
||||||
|
internal sealed class Cards(params (string key, double bonus)[] entries) : IStatBonusSource
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, double> _b = entries.ToDictionary(e => e.key, e => e.bonus, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public double Bonus(string statKey) => _b.TryGetValue(statKey, out var v) ? v : 0.0;
|
||||||
|
}
|
||||||
6
Outnumbered.slnx
Normal file
6
Outnumbered.slnx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<Solution>
|
||||||
|
<Folder Name="/Outnumbered/">
|
||||||
|
<Project Path="Outnumbered/outnumbered.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Project Path="Outnumbered.Tests/Outnumbered.Tests.csproj" />
|
||||||
|
</Solution>
|
||||||
239
Outnumbered/Abilities.cs
Normal file
239
Outnumbered/Abilities.cs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Killstreak abilities, indexed 0..4 in streak/prestige order: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk.
|
||||||
|
// INPUT: slot6-10 command listeners DON'T fire on this build (CS2 selects grenades client-side), and a held grenade
|
||||||
|
// is still throwable via mouse-wheel/lastinv. So instead we grant the ability's grenade only while it's castable, and
|
||||||
|
// the instant that grenade becomes the ACTIVE weapon (any selection path: key, wheel, lastinv) we cast the ability,
|
||||||
|
// confiscate the grenade, and switch back to primary — so it can never be thrown.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// Internal ability index. Streak tiers and Prestige-I..V unlocks follow this order.
|
||||||
|
private const int AbNoReload = 0, AbAdrenaline = 1, AbOvercharge = 2, AbBloodthirst = 3, AbBerserk = 4;
|
||||||
|
|
||||||
|
// The ability REGISTRY — one row per killstreak ability, the single place their identity lives. Def selects
|
||||||
|
// the LIVE config block so !og_reload tunables apply. Grenade binds stay in Config.Abilities.AbilityGrenades (JSON).
|
||||||
|
// Adding/reordering an ability = one row here (+ matching grenade in the JSON list). SEE ALSO two index-coupled
|
||||||
|
// sites that must stay reconciled with this ORDER: Ranks.PrestigeColorIdx (prestige tag colours reference ability
|
||||||
|
// indices) and AbilityUnlocked's `pd.Prestige >= i+1` (Prestige I..V unlock abilities 0..4 off-streak).
|
||||||
|
internal sealed record AbilityInfo(int Index, string Name, string Short, string KeyNum, string KeyHint,
|
||||||
|
Func<AbilitiesConfig, AbilityDef> Def);
|
||||||
|
|
||||||
|
internal static readonly AbilityInfo[] AbilityRegistry =
|
||||||
|
{
|
||||||
|
new(AbNoReload, "No Reload", "NoReload", "6", "HE / key 6", c => c.NoReload),
|
||||||
|
new(AbAdrenaline, "Adrenaline", "Adren", "7", "Flash / key 7", c => c.Adrenaline),
|
||||||
|
new(AbOvercharge, "Overcharge", "Overchg", "8", "Smoke / key 8", c => c.Overcharge),
|
||||||
|
new(AbBloodthirst, "Bloodthirst", "Bloodth", "0", "Molotov / key 0", c => c.Bloodthirst),
|
||||||
|
new(AbBerserk, "Berserk", "Berserk", "9", "Decoy / key 9", c => c.Berserk),
|
||||||
|
};
|
||||||
|
internal static int AbilityCount => AbilityRegistry.Length;
|
||||||
|
|
||||||
|
private AbilityDef AbilityCfg(int i) => AbilityRegistry[i].Def(Config.Abilities);
|
||||||
|
|
||||||
|
private string AbilityGrenadeName(int i)
|
||||||
|
{
|
||||||
|
var list = Config.Abilities.AbilityGrenades;
|
||||||
|
return i >= 0 && i < list.Count ? list[i] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse of AbilityGrenadeName: which ability does this grenade weapon trigger? -1 if not an ability grenade.
|
||||||
|
private int AbilityForGrenade(string designerName)
|
||||||
|
{
|
||||||
|
var list = Config.Abilities.AbilityGrenades;
|
||||||
|
for (int i = 0; i < list.Count && i < AbilityCount; i++)
|
||||||
|
if (list[i] == designerName) return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _grenadeTimer;
|
||||||
|
|
||||||
|
private void Initialize_Abilities()
|
||||||
|
{
|
||||||
|
if (AbilityRegistry.Length > PlayerData.AbilitySlots) // the per-player state arrays must hold every ability index
|
||||||
|
Logger.LogError("Outnumbered: AbilityRegistry has {N} rows but PlayerData.AbilitySlots is {Slots} — bump AbilitySlots.", AbilityRegistry.Length, PlayerData.AbilitySlots);
|
||||||
|
// Ranks.PrestigeColorIdx is index-coupled to the ability order; a reorder/addition that references a row past the
|
||||||
|
// registry would mis-colour or crash the prestige tag. Catch it loudly at load (the registry's "one-row" promise).
|
||||||
|
foreach (var set in PrestigeColorIdx)
|
||||||
|
foreach (var i in set)
|
||||||
|
if (i >= AbilityCount)
|
||||||
|
Logger.LogError("Outnumbered: Ranks.PrestigeColorIdx references ability {I} but only {N} abilities exist.", i, AbilityCount);
|
||||||
|
// AbilityForGrenade binds grenade->ability POSITIONALLY via a first-match scan; with 8 instances sharing one JSON
|
||||||
|
// a wrong-length or duplicated list silently casts the wrong/no ability on every server. Validate at load (warn).
|
||||||
|
var grenades = Config.Abilities.AbilityGrenades;
|
||||||
|
if (grenades.Count != AbilityCount)
|
||||||
|
Logger.LogWarning("Outnumbered: AbilityGrenades has {N} entries but there are {C} abilities — extras are ignored and missing rows get no grenade key.", grenades.Count, AbilityCount);
|
||||||
|
var seenGrenades = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var g in grenades)
|
||||||
|
if (g.Length > 0 && !seenGrenades.Add(g))
|
||||||
|
Logger.LogWarning("Outnumbered: AbilityGrenades lists '{G}' more than once — AbilityForGrenade maps it to the first matching ability only.", g);
|
||||||
|
// The two other ability-indexed presentation lists: AbilityChat (prestige-tag colours) wraps, AbilityIcons falls
|
||||||
|
// back to the registry Short label — warn so a new ability doesn't silently mis-colour / lose its HUD icon.
|
||||||
|
if (AbilityChat.Length < AbilityCount)
|
||||||
|
Logger.LogWarning("Outnumbered: AbilityChat has {N} colours but there are {C} abilities — the prestige tag colour wraps for the extras.", AbilityChat.Length, AbilityCount);
|
||||||
|
if (Config.Hud.AbilityIcons.Count < AbilityCount)
|
||||||
|
Logger.LogWarning("Outnumbered: Hud.AbilityIcons has {N} entries but there are {C} abilities — missing rows fall back to the registry Short label.", Config.Hud.AbilityIcons.Count, AbilityCount);
|
||||||
|
RegisterEventHandler<EventWeaponFire>(OnWeaponFire_Abilities);
|
||||||
|
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Abilities is invoked from there.
|
||||||
|
// Reconcile held ability-grenades with castable abilities (grant when ready, remove otherwise).
|
||||||
|
_grenadeTimer = AddTimer(0.5f, ReconcileAllGrenades, CounterStrikeSharp.API.Modules.Timers.TimerFlags.REPEAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Abilities()
|
||||||
|
{
|
||||||
|
_grenadeTimer?.Kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- state queries (also read by the damage/lifesteal hooks + the HUD) ----
|
||||||
|
private static bool AbilityActive(PlayerData pd, int i) => pd.AbilityActiveUntil[i] > Server.CurrentTime;
|
||||||
|
// Overload taking a pre-read clock — lets Snapshot() read Server.CurrentTime ONCE for its 3 ability checks (the
|
||||||
|
// clock can't advance within one synchronous build, so it's bit-identical to 3 separate reads).
|
||||||
|
private static bool AbilityActive(PlayerData pd, int i, double now) => pd.AbilityActiveUntil[i] > now;
|
||||||
|
private static bool AbilityReady(PlayerData pd, int i) => Server.CurrentTime >= pd.AbilityReadyAt[i];
|
||||||
|
// Pre-read-clock overload (mirrors AbilityActive(pd,i,now)) — lets the HUD read Server.CurrentTime ONCE per build.
|
||||||
|
private static bool AbilityReady(PlayerData pd, int i, double now) => now >= pd.AbilityReadyAt[i];
|
||||||
|
private bool AbilityUnlocked(PlayerData pd, int i) =>
|
||||||
|
pd.Streak >= AbilityCfg(i).StreakReq || pd.Prestige >= i + 1; // Prestige I..V unlock 0..4 off-streak
|
||||||
|
// Castable right now = unlocked AND off cooldown. (AbilityReadyAt is set cooldown-ahead on use, so this is
|
||||||
|
// false through the active window too.) Drives both casting and which grenade the player should hold.
|
||||||
|
private bool AbilityUsable(PlayerData pd, int i) => AbilityUnlocked(pd, i) && AbilityReady(pd, i);
|
||||||
|
|
||||||
|
// ---- grenade-key lifecycle: hold the ability's grenade exactly while that ability is castable ----
|
||||||
|
private void ReconcileAllGrenades()
|
||||||
|
{
|
||||||
|
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
|
||||||
|
foreach (var p in Utilities.GetPlayers())
|
||||||
|
{
|
||||||
|
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||||
|
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
|
||||||
|
if (PdOf(p) is { } pd) ReconcileGrenades(p, pd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReconcileGrenades(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
{
|
||||||
|
string g = AbilityGrenadeName(i);
|
||||||
|
if (g.Length == 0) continue;
|
||||||
|
bool shouldHold = AbilityUsable(pd, i);
|
||||||
|
bool holds = Inventory.Holds(p, g);
|
||||||
|
if (shouldHold && !holds) p.GiveNamedItem(g); // AutoSwitchTo=false on grenades -> no weapon switch
|
||||||
|
else if (!shouldHold && holds) p.RemoveItemByDesignerName(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- input: an ability grenade becoming the active weapon = the player tried to use it ----
|
||||||
|
private void OnTick_Abilities(List<CCSPlayerController> players)
|
||||||
|
{
|
||||||
|
if (!Config.Abilities.Enabled || !Config.Abilities.GrenadeKeyInput) return;
|
||||||
|
foreach (var p in players)
|
||||||
|
{
|
||||||
|
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||||
|
if (ShopInputLocked(p.Slot)) continue; // shop owns the inputs while open (+ a grace after close)
|
||||||
|
var active = Inventory.ActiveWeapon(p);
|
||||||
|
if (active is null) continue;
|
||||||
|
int i = AbilityForGrenade(active.DesignerName);
|
||||||
|
if (i < 0) continue; // active weapon isn't an ability grenade — nothing to do
|
||||||
|
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) continue;
|
||||||
|
|
||||||
|
// Cast if castable; either way confiscate the grenade and switch to the player's real weapon so it's
|
||||||
|
// never thrown and we never leave a grenade active. MUST be the best weapon they actually hold, not a
|
||||||
|
// hardcoded slot1 — on the Gun Game knife rung there's no primary, so slot1 would no-op and the next
|
||||||
|
// grenade would stay active, casting the NEXT ability every tick.
|
||||||
|
if (AbilityUsable(pd, i)) TryActivateAbility(p, i);
|
||||||
|
p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // no-op if TryActivateAbility already removed it
|
||||||
|
p.ExecuteClientCommand(PreferredSlotCmd(p)); // primary -> pistol -> knife (whatever exists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- activation / usability ----
|
||||||
|
private void TryActivateAbility(CCSPlayerController p, int i)
|
||||||
|
{
|
||||||
|
if (!Config.Abilities.Enabled || i < 0 || i >= AbilityCount) return;
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null || !p.PawnIsAlive) return;
|
||||||
|
|
||||||
|
var def = AbilityCfg(i);
|
||||||
|
string name = AbilityRegistry[i].Name;
|
||||||
|
double now = Server.CurrentTime;
|
||||||
|
|
||||||
|
if (AbilityActive(pd, i)) { p.PrintToChat($"[Outnumbered] {name} is already active."); return; }
|
||||||
|
if (!AbilityReady(pd, i)) { p.PrintToChat($"[Outnumbered] {name} on cooldown ({pd.AbilityReadyAt[i] - now:F0}s)."); return; }
|
||||||
|
if (!AbilityUnlocked(pd, i))
|
||||||
|
{
|
||||||
|
p.PrintToChat($"[Outnumbered] {name} locked — need streak {def.StreakReq} (or Prestige {Roman(i + 1)}). Streak is {pd.Streak}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pd.AbilityActiveUntil[i] = now + def.Duration;
|
||||||
|
// survival -Ability-Cooldown card shortens the cooldown (0 outside a run / without the card). Cooldown starts on
|
||||||
|
// use and keeps ticking through death.
|
||||||
|
double cd = def.Cooldown * Math.Max(0.0, 1.0 - EffectCardMag(pd, CardKeys.AbilityCdr) / 100.0);
|
||||||
|
pd.AbilityReadyAt[i] = now + cd;
|
||||||
|
if (Config.Abilities.GrenadeKeyInput) p.RemoveItemByDesignerName(AbilityGrenadeName(i)); // key dies now; reconciles back after cooldown
|
||||||
|
OnAbilityActivated(p, i);
|
||||||
|
p.PrintToChat($"[Outnumbered] {name} ACTIVE for {def.Duration:F0}s!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAbilityActivated(CCSPlayerController p, int i)
|
||||||
|
{
|
||||||
|
if (i == AbNoReload) RefillActiveWeapon(p, fillClip: true); // top the clip immediately
|
||||||
|
PlaySound(p, Config.Sounds.AbilityActivate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- weapon ammo (No Reload + baseline infinite reserve) ----
|
||||||
|
private HookResult OnWeaponFire_Abilities(EventWeaponFire ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (p is not { IsValid: true } || p.IsBot) return HookResult.Continue;
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return HookResult.Continue;
|
||||||
|
|
||||||
|
bool noReload = Config.Abilities.Enabled && AbilityActive(pd, AbNoReload);
|
||||||
|
if (Config.Abilities.InfiniteReserve || noReload) RefillActiveWeapon(p, fillClip: noReload);
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStateChanged not needed for clip/ammo writes.
|
||||||
|
private void RefillActiveWeapon(CCSPlayerController p, bool fillClip)
|
||||||
|
{
|
||||||
|
var w = Inventory.ActiveWeapon(p);
|
||||||
|
if (w is null) return;
|
||||||
|
if (Config.Abilities.InfiniteReserve) w.ReserveAmmo[0] = Config.Abilities.InfiniteReserveAmount;
|
||||||
|
if (fillClip)
|
||||||
|
{
|
||||||
|
int max = Inventory.MaxClip(w);
|
||||||
|
if (max > 0) w.Clip1 = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- !abilities / !perks ----
|
||||||
|
private void ShowAbilities(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
double now = Server.CurrentTime;
|
||||||
|
|
||||||
|
p.PrintToChat("[Outnumbered] Killstreak abilities — select that grenade to cast, OR type !abilityN");
|
||||||
|
p.PrintToChat(" (custom binds? use !ability1..5, or bind a key: bind h css_ability1)");
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
{
|
||||||
|
var def = AbilityCfg(i);
|
||||||
|
string state =
|
||||||
|
AbilityActive(pd, i) ? $"ACTIVE {pd.AbilityActiveUntil[i] - now:F0}s" :
|
||||||
|
!AbilityReady(pd, i) ? $"cooldown {pd.AbilityReadyAt[i] - now:F0}s" :
|
||||||
|
AbilityUnlocked(pd, i) ? "READY" :
|
||||||
|
$"locked (streak {def.StreakReq} / Prestige {Roman(i + 1)})";
|
||||||
|
p.PrintToChat($" {AbilityIcon(i)} {AbilityRegistry[i].Name} [{AbilityRegistry[i].KeyHint} or !ability{i + 1}] — {state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Outnumbered/Admin.cs
Normal file
123
Outnumbered/Admin.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||||||
|
using CounterStrikeSharp.API.Modules.Admin;
|
||||||
|
using CounterStrikeSharp.API.Modules.Commands;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Admin command suite (spec §13). Gated by @outnumbered/admin — add your SteamID to
|
||||||
|
// addons/counterstrikesharp/configs/admins.json with flag "@outnumbered/admin" (or "@css/root" for everything).
|
||||||
|
// Target arg: a name substring, "@me", or "@all". Console (no caller) is always allowed by CSSharp.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// PdOf(CCSPlayerController) lives in the resolution seam (Players.cs); the admin suite just uses it.
|
||||||
|
private static List<CCSPlayerController> ResolveTargets(string pattern, CCSPlayerController? caller)
|
||||||
|
{
|
||||||
|
var humans = Humans().ToList();
|
||||||
|
if (pattern is "@all" or "@a") return humans;
|
||||||
|
if ((pattern is "@me" or "@self") && caller is { IsValid: true }) return [caller];
|
||||||
|
return humans.Where(p => p.PlayerName.Contains(pattern, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply an action to every matched target's PlayerData, then persist + refresh HP/armor + clantag.
|
||||||
|
private int ApplyToTargets(string pattern, CCSPlayerController? caller, Action<CCSPlayerController, PlayerData> action)
|
||||||
|
{
|
||||||
|
int n = 0;
|
||||||
|
foreach (var t in ResolveTargets(pattern, caller))
|
||||||
|
{
|
||||||
|
var pd = PdOf(t);
|
||||||
|
if (pd is null) continue;
|
||||||
|
action(t, pd);
|
||||||
|
pd.Dirty = true;
|
||||||
|
DeferReapplyCaps(t); // re-apply caps next frame (slot+SteamID-pinned) so a slot-reuse can't inherit this grant
|
||||||
|
ApplyClan(t, pd);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_givexp", "[admin] grant XP")]
|
||||||
|
[CommandHelper(2, "<target> <amount>", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_GiveXp(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(info.GetArg(2), out var amt)) { info.ReplyToCommand("[Outnumbered] amount must be a number."); return; }
|
||||||
|
int n = ApplyToTargets(info.GetArg(1), player, (c, pd) => GrantXp(pd, amt, c));
|
||||||
|
info.ReplyToCommand($"[Outnumbered] gave {amt} XP to {n} player(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_givepoints", "[admin] grant skill points")]
|
||||||
|
[CommandHelper(2, "<target> <amount>", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_GivePoints(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(info.GetArg(2), out var amt)) { info.ReplyToCommand("[Outnumbered] amount must be a number."); return; }
|
||||||
|
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => pd.Points = Math.Max(0, pd.Points + amt));
|
||||||
|
info.ReplyToCommand($"[Outnumbered] gave {amt} point(s) to {n} player(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_setlevel", "[admin] set level")]
|
||||||
|
[CommandHelper(2, "<target> <level>", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_SetLevel(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(info.GetArg(2), out var lvl)) { info.ReplyToCommand("[Outnumbered] level must be a number."); return; }
|
||||||
|
lvl = Math.Clamp(lvl, 1, Config.Progression.LevelCap);
|
||||||
|
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => { pd.Level = lvl; pd.Xp = 0; });
|
||||||
|
info.ReplyToCommand($"[Outnumbered] set level {lvl} on {n} player(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_setprestige", "[admin] set prestige")]
|
||||||
|
[CommandHelper(2, "<target> <prestige>", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_SetPrestige(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(info.GetArg(2), out var pres)) { info.ReplyToCommand("[Outnumbered] prestige must be a number."); return; }
|
||||||
|
pres = Math.Clamp(pres, 0, Config.Progression.PrestigeCap);
|
||||||
|
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) => pd.Prestige = pres);
|
||||||
|
info.ReplyToCommand($"[Outnumbered] set prestige {pres} on {n} player(s).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_resetplayer", "[admin] full progression reset")]
|
||||||
|
[CommandHelper(1, "<target>", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_ResetPlayer(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
int n = ApplyToTargets(info.GetArg(1), player, (_, pd) =>
|
||||||
|
{
|
||||||
|
pd.Xp = 0; pd.Level = 1; pd.Points = 1; pd.Prestige = 0; pd.Upgrades.Clear();
|
||||||
|
});
|
||||||
|
info.ReplyToCommand($"[Outnumbered] reset {n} player(s) to L1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_og_reload", "[admin] live-reload outnumbered.json + ranks.json (no session reset)")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_ReloadConfig(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string path = ConfigPath("outnumbered.json"); // assembly-name-derived (Ranks.cs) — no hardcoded folder literal
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var c = JsonSerializer.Deserialize<OutnumberedConfig>(File.ReadAllText(path),
|
||||||
|
new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip, PropertyNameCaseInsensitive = true });
|
||||||
|
if (c is not null) Config = c; // sub-sections have new() defaults, so missing keys never null out
|
||||||
|
}
|
||||||
|
RebuildEffectiveHandicap(); // re-resolve base Handicap + the active mode's override from the new config
|
||||||
|
RebuildStatDefs(); // re-resolve the key->StatDef registry from the new Config.Stats
|
||||||
|
LoadRanksConfig();
|
||||||
|
RebuildBalancePayload(); // the API's balance verb serves the EFFECTIVE config — must track every reload
|
||||||
|
info.ReplyToCommand("[Outnumbered] reloaded outnumbered.json + ranks.json.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
info.ReplyToCommand("[Outnumbered] reload failed: " + ex.Message);
|
||||||
|
Logger.LogError(ex, "Outnumbered og_reload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
Outnumbered/Api.cs
Normal file
216
Outnumbered/Api.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Modules.Cvars;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The local API: one Unix domain socket per instance (<SocketDir>/<ServerId>.sock). Three READ verbs —
|
||||||
|
// status : live server snapshot (map, counts, humans, mode extras), rebuilt on a ~2s game-thread timer
|
||||||
|
// balance : the EFFECTIVE in-memory config (mode-resolved handicap) + dense neutral curves, rebuilt at load + !og_reload
|
||||||
|
// top : the three leaderboards, queried per request (repository methods are contractually off-thread, DB-only)
|
||||||
|
// — plus ONE operator verb, drain (announce + kick all humans so a pending SIGINT "shutdown when empty" completes
|
||||||
|
// cleanly; the deploy flow's kick-before-stop). The socket's 0660 file mode is the drain verb's entire access control:
|
||||||
|
// only the cs2 user (the site + the deploy scripts) can connect. The socket thread NEVER touches game state directly:
|
||||||
|
// reads serve pre-serialized byte[]; drain marshals through Server.NextFrame. Every payload carries V + GeneratedAtMs.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private const int ApiSchemaVersion = 1;
|
||||||
|
private const float StatusRebuildSeconds = 2.0f;
|
||||||
|
private const int ApiTopN = 50;
|
||||||
|
private const int CurveSteps = 200; // t = -1..+1 inclusive -> 201 samples at 0.01
|
||||||
|
|
||||||
|
private UdsServer? _api;
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _statusTimer;
|
||||||
|
private volatile byte[] _statusJson = ApiErr("not-ready"); // served until the first game-thread rebuild lands
|
||||||
|
private volatile byte[] _balanceJson = ApiErr("not-ready");
|
||||||
|
private bool _engineReady; // set once SetupMap has run — engine statics (Server.MapName etc.) crash before that
|
||||||
|
private bool _apiErrLogged; // one socket-error log line per load, not one per request
|
||||||
|
private string _apiServerId = "";
|
||||||
|
private int _apiPort;
|
||||||
|
|
||||||
|
private static byte[] ApiErr(string code) => JsonSerializer.SerializeToUtf8Bytes(new { Err = code });
|
||||||
|
|
||||||
|
private void Initialize_Api()
|
||||||
|
{
|
||||||
|
if (!Config.Api.Enabled) return;
|
||||||
|
_apiServerId = ServerId();
|
||||||
|
var args = CommandLineArgs(); // same -port -> +hostport fallback chain ServerId() resolves with
|
||||||
|
_apiPort = int.TryParse(ArgValue(args, "port") ?? ArgValue(args, "hostport"), out int port) ? port : 0;
|
||||||
|
RebuildBalancePayload(); // config + domain math only — engine-safe at Load
|
||||||
|
|
||||||
|
string path = Path.Combine(Config.Api.SocketDir, _apiServerId + ".sock");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_api = new UdsServer(path, HandleApiRequest, ex =>
|
||||||
|
{
|
||||||
|
if (_apiErrLogged) return;
|
||||||
|
_apiErrLogged = true;
|
||||||
|
Logger.LogWarning(ex, "Outnumbered API socket error (further ones suppressed this load)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Missing/unwritable socket dir (dev box without the tmpfiles.d entry) — the API is optional, the game isn't.
|
||||||
|
Logger.LogWarning(ex, "Outnumbered API disabled: cannot bind {Path}", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_statusTimer = AddTimer(StatusRebuildSeconds, RebuildStatusPayload, TimerFlags.REPEAT);
|
||||||
|
Logger.LogInformation("Outnumbered API listening on {Path}", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Api()
|
||||||
|
{
|
||||||
|
_statusTimer?.Kill();
|
||||||
|
_api?.Dispose(); // closes the listener + unlinks the socket file, so a hot-reload can re-bind
|
||||||
|
_api = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket-thread dispatch: cached bytes for status/balance; top is the one per-request worker (DB-only).
|
||||||
|
private Task<byte[]> HandleApiRequest(string verb) => verb switch
|
||||||
|
{
|
||||||
|
"status" => Task.FromResult(_statusJson),
|
||||||
|
"balance" => Task.FromResult(_balanceJson),
|
||||||
|
"top" => BuildTopPayload(),
|
||||||
|
"drain" => Drain(),
|
||||||
|
_ => Task.FromResult(ApiErr("unknown-verb")),
|
||||||
|
};
|
||||||
|
|
||||||
|
private const float DrainKickDelaySeconds = 5.0f; // long enough to read the announce, short enough for a deploy
|
||||||
|
|
||||||
|
// Deploy-flow kick: announce, then kick every human, so the engine's SIGINT "shutdown when empty" completes with a
|
||||||
|
// CLEAN plugin unload (persistence flush) instead of the stop timeout's SIGKILL. Bots don't block shutdown.
|
||||||
|
// Replies immediately; the caller sleeps past the delay before systemctl stop.
|
||||||
|
private Task<byte[]> Drain()
|
||||||
|
{
|
||||||
|
Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
if (!_live) return;
|
||||||
|
Server.PrintToChatAll(" [Outnumbered] Server is restarting for an update — reconnect in a minute!");
|
||||||
|
AddTimer(DrainKickDelaySeconds, () =>
|
||||||
|
{
|
||||||
|
if (!_live) return;
|
||||||
|
foreach (var p in Utilities.GetPlayers())
|
||||||
|
if (IsHuman(p) && p.UserId is { } uid)
|
||||||
|
Server.ExecuteCommand($"kickid {uid} \"Server updating - back in a minute!\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(new { Ok = true, KickInSeconds = (int)DrainKickDelaySeconds }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game-thread timer (~2s): mirrors the HUD gather (materialized roster -> IsHuman -> _players). Allocation per
|
||||||
|
// rebuild is fine at this cadence. Per-player handicap bands are deliberately NOT exposed.
|
||||||
|
private void RebuildStatusPayload()
|
||||||
|
{
|
||||||
|
if (!_engineReady) return; // pre-first-map: keep serving not-ready instead of crashing on engine statics
|
||||||
|
var players = Utilities.GetPlayers();
|
||||||
|
var humans = new List<object>(players.Count);
|
||||||
|
int bots = 0;
|
||||||
|
foreach (var p in players)
|
||||||
|
{
|
||||||
|
if (IsBot(p)) { bots++; continue; }
|
||||||
|
if (!IsHuman(p)) continue;
|
||||||
|
var sid = p.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (sid is null || !_players.TryGetValue(sid.Value, out var pd)) continue;
|
||||||
|
humans.Add(new
|
||||||
|
{
|
||||||
|
pd.Name,
|
||||||
|
pd.Level,
|
||||||
|
pd.Prestige,
|
||||||
|
pd.Kills,
|
||||||
|
pd.Deaths,
|
||||||
|
pd.Streak,
|
||||||
|
Rung = pd.GgRung, // site maps Rung -> weapon via the GG StatusExtra ladder; harmless zero in other modes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_statusJson = JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
V = ApiSchemaVersion,
|
||||||
|
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
ServerId = _apiServerId,
|
||||||
|
Mode = _driver.Id,
|
||||||
|
Map = Server.MapName,
|
||||||
|
Hostname = ConVar.Find("hostname")?.StringValue ?? "",
|
||||||
|
Port = _apiPort,
|
||||||
|
MaxHumans = _driver.MaxHumansOnCt,
|
||||||
|
Bots = bots,
|
||||||
|
Humans = humans,
|
||||||
|
Extra = _driver.StatusExtra(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The effective balance: the LIVE Config (post-!og_reload) with the mode-resolved handicap, plus neutral curves
|
||||||
|
// sampled through the same compiled HandicapModel code that scales damage. EXPLICIT allowlist of blocks — never
|
||||||
|
// the root Config (Database carries credentials). Called at Initialize_Api and from Cmd_ReloadConfig.
|
||||||
|
internal void RebuildBalancePayload()
|
||||||
|
{
|
||||||
|
// Guarded end-to-end: pathological config (e.g. Curve <= 0 -> Pow(0, neg) = Infinity -> the serializer throws
|
||||||
|
// on non-finite doubles) must degrade the API verb, never the plugin — the live damage path tolerates the same
|
||||||
|
// config without throwing, and this runs unguarded inside Load.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_balanceJson = BuildBalanceJson();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Outnumbered API balance payload rebuild failed — serving 'balance-unavailable'");
|
||||||
|
_balanceJson = ApiErr("balance-unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] BuildBalanceJson()
|
||||||
|
{
|
||||||
|
var t = new double[CurveSteps + 1];
|
||||||
|
var deal = new double[CurveSteps + 1];
|
||||||
|
var take = new double[CurveSteps + 1];
|
||||||
|
var xp = new double[CurveSteps + 1];
|
||||||
|
for (int i = 0; i <= CurveSteps; i++)
|
||||||
|
{
|
||||||
|
t[i] = -1.0 + i * (2.0 / CurveSteps);
|
||||||
|
HandicapModel.BandsFromT(t[i], _hcap, out deal[i], out take[i], out xp[i]);
|
||||||
|
}
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
V = ApiSchemaVersion,
|
||||||
|
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
ServerId = _apiServerId,
|
||||||
|
Mode = _driver?.Id ?? Config.Mode, // driver not yet selected only if called before Initialize_Driver
|
||||||
|
Match = Config.Match,
|
||||||
|
Stats = Config.Stats,
|
||||||
|
Progression = Config.Progression,
|
||||||
|
Abilities = Config.Abilities,
|
||||||
|
GunGame = Config.GunGame,
|
||||||
|
Survival = Config.Survival,
|
||||||
|
EffectiveHandicap = _hcap.Config,
|
||||||
|
Curves = new { T = t, Deal = deal, Take = take, Xp = xp },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket thread: repository calls only (no game objects), mirroring the Cmd_Top precedent. A failure (boot race
|
||||||
|
// with the fire-and-forget EnsureSchema, DB down) degrades to an err payload — the site renders last-good.
|
||||||
|
private async Task<byte[]> BuildTopPayload()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var levels = await _repo.GetTopAsync(ApiTopN);
|
||||||
|
var waves = await _repo.GetTopWavesAsync(ApiTopN);
|
||||||
|
var ggTimes = await _repo.GetTopGgAsync(ApiTopN);
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(new
|
||||||
|
{
|
||||||
|
V = ApiSchemaVersion,
|
||||||
|
GeneratedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
|
Levels = levels,
|
||||||
|
Waves = waves,
|
||||||
|
GgTimes = ggTimes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!_apiErrLogged) { _apiErrLogged = true; Logger.LogWarning(ex, "Outnumbered API top query failed"); }
|
||||||
|
return ApiErr("db");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
Outnumbered/Commands.cs
Normal file
116
Outnumbered/Commands.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||||||
|
using CounterStrikeSharp.API.Modules.Admin;
|
||||||
|
using CounterStrikeSharp.API.Modules.Commands;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// Player commands ([ConsoleCommand] auto-registers; css_ maps to ! / chat triggers).
|
||||||
|
// Info commands (!rank/!me/!stats/!top/!info) live in Ranks.cs; weapon menu in Weapons.cs; !s1..!sN in Progression.cs.
|
||||||
|
|
||||||
|
private static readonly string[] PlayerCommandList =
|
||||||
|
{
|
||||||
|
"Shop: switch to the healthshot (X) — numbers select, X = back/close, crouch = quick exit",
|
||||||
|
"!skills — open the skill tree | !s1-!sN — buy a skill directly",
|
||||||
|
"!prestige — reset at level 100 for a permanent boost",
|
||||||
|
"!guns (!weapons, !loadout) — weapon menu | !rifles !smgs !snipers !shotguns !heavy !pistols",
|
||||||
|
"!abilities (!perks) — your killstreak abilities | !ability1-5 — activate one",
|
||||||
|
"!rank (!me) — rank/level/prestige | !stats — live bonuses & handicap",
|
||||||
|
"!top (!leaderboard) — top players | !info (!about, !help) — how it works",
|
||||||
|
"!hud — toggle the HUD | !dmg (!damage) — toggle the per-hit damage readout",
|
||||||
|
"!commands — this list",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] AdminCommandList =
|
||||||
|
{
|
||||||
|
"targets: a player name, @me, or @all",
|
||||||
|
"!og_givexp <target> <amount>",
|
||||||
|
"!og_givepoints <target> <amount>",
|
||||||
|
"!og_setlevel <target> <level>",
|
||||||
|
"!og_setprestige <target> <prestige>",
|
||||||
|
"!og_resetplayer <target> — full progression reset",
|
||||||
|
"!og_reload — live-reload config + ranks (no session reset)",
|
||||||
|
};
|
||||||
|
|
||||||
|
[ConsoleCommand("css_commands", "List all player commands")]
|
||||||
|
[ConsoleCommand("css_cmds", "List all player commands")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Commands(CCSPlayerController? p, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (p is not { IsValid: true }) return;
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Commands:");
|
||||||
|
foreach (var line in PlayerCommandList) p.PrintToChat($" {ChatColors.Lime}{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RequiresPermissions("@outnumbered/admin")]
|
||||||
|
[ConsoleCommand("css_admin_commands", "List admin commands")]
|
||||||
|
[ConsoleCommand("css_admincommands", "List admin commands")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_AND_SERVER)]
|
||||||
|
public void Cmd_AdminCommands(CCSPlayerController? p, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (p is not { IsValid: true }) return;
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Admin commands:");
|
||||||
|
foreach (var line in AdminCommandList) p.PrintToChat($" {ChatColors.Lime}{line}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_skills", "Open the skill tree to spend points")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Skills(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is { IsValid: true }) OpenSkillMenu(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_prestige", "Prestige (at max level) for a permanent boost")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Prestige(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is { IsValid: true }) OpenPrestige(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_abilities", "Show your killstreak abilities")]
|
||||||
|
[ConsoleCommand("css_perks", "Show your killstreak abilities")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Abilities(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is { IsValid: true }) ShowAbilities(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_dmg", "Toggle a per-hit final-damage readout (tuning aid)")]
|
||||||
|
[ConsoleCommand("css_damage", "Toggle a per-hit final-damage readout (tuning aid)")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Dmg(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
var sid = player.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (sid is null) return;
|
||||||
|
if (_dmgReadout.Remove(sid.Value)) player.PrintToChat("[Outnumbered] damage readout OFF.");
|
||||||
|
else { _dmgReadout.Add(sid.Value); player.PrintToChat("[Outnumbered] damage readout ON — final hp/armor shown per hit."); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_hud", "Toggle the on-screen HUD on/off")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Hud(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
var sid = player.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (sid is null) return;
|
||||||
|
if (_hudOff.Remove(sid.Value)) player.PrintToChat("[Outnumbered] HUD ON.");
|
||||||
|
else { _hudOff.Add(sid.Value); player.PrintToChat("[Outnumbered] HUD OFF."); DestroyHud(player.Slot); player.PrintToCenterHtml(""); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bindable alternates for the abilities (grenade keys are the primary input; these always work too).
|
||||||
|
// e.g. bind "h" "css_ability1". Registered as a loop tied to the AbilityRegistry (mirrors RegisterSkillCommands),
|
||||||
|
// so a new/reordered ability needs no per-command method. Called from Load.
|
||||||
|
private void RegisterAbilityCommands()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
{
|
||||||
|
int idx = i;
|
||||||
|
AddCommand($"css_ability{i + 1}", $"Activate ability {i + 1} ({AbilityRegistry[i].Name})",
|
||||||
|
(p, _) => { if (p is { IsValid: true }) TryActivateAbility(p, idx); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
340
Outnumbered/Config/DomainConfig.cs
Normal file
340
Outnumbered/Config/DomainConfig.cs
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Outnumbered.Config;
|
||||||
|
|
||||||
|
// The config sub-types the pure Domain layer reads (handicap / progression / stat / combat / survival economy). Split out
|
||||||
|
// of OutnumberedConfig.cs — which is CounterStrikeSharp-coupled (BasePluginConfig) — so the xUnit test project can
|
||||||
|
// <Compile Include> these alongside Domain/*.cs with NO CounterStrikeSharp reference. They're plain POCOs (System.Text.Json
|
||||||
|
// attributes only); the engine-coupled config (OutnumberedConfig + Hud/Shop/Match/GunGame/... blocks) stays in OutnumberedConfig.cs.
|
||||||
|
|
||||||
|
// One killstreak ability's tunables (spec §4). Magnitude meaning is per-ability:
|
||||||
|
// No Reload — (unused; clip-refill only)
|
||||||
|
// Adrenaline — Magnitude = % incoming-damage reduction
|
||||||
|
// Overcharge — Magnitude = % bonus outgoing damage
|
||||||
|
// Bloodthirst — Magnitude = +% HP lifesteal, Magnitude2 = +% armor lifesteal (additive, for the duration)
|
||||||
|
// Berserk — Magnitude = bonus damage per full missing-HP fraction, Magnitude2 = bonus crit-damage per missing-HP fraction
|
||||||
|
public sealed class AbilityDef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("StreakReq")] public int StreakReq { get; set; }
|
||||||
|
[JsonPropertyName("Cooldown")] public float Cooldown { get; set; }
|
||||||
|
[JsonPropertyName("Duration")] public float Duration { get; set; }
|
||||||
|
[JsonPropertyName("Magnitude")] public double Magnitude { get; set; }
|
||||||
|
[JsonPropertyName("Magnitude2")] public double Magnitude2 { get; set; }
|
||||||
|
// "Movie filter" tint shown while this ability is active (blended when several are up). Spec §7 juice.
|
||||||
|
[JsonPropertyName("TintR")] public int TintR { get; set; }
|
||||||
|
[JsonPropertyName("TintG")] public int TintG { get; set; }
|
||||||
|
[JsonPropertyName("TintB")] public int TintB { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 5 killstreak abilities + baseline infinite-reserve ammo (spec §4/§8).
|
||||||
|
public sealed class AbilitiesConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
// Active-ability screen tint ("movie filter") via the CS2 fade user message. UNVERIFIED format — toggle off
|
||||||
|
// if it misbehaves; the HUD active-banner is the reliable fallback. TintAlpha = overlay strength (0-255).
|
||||||
|
[JsonPropertyName("ScreenTint")] public bool ScreenTint { get; set; } = true;
|
||||||
|
[JsonPropertyName("TintAlpha")] public int TintAlpha { get; set; } = 50;
|
||||||
|
|
||||||
|
// sv_infinite_ammo is cheat-flagged at sv_cheats 0, so reserve is topped up in code (spec §1).
|
||||||
|
[JsonPropertyName("InfiniteReserve")] public bool InfiniteReserve { get; set; } = true;
|
||||||
|
[JsonPropertyName("InfiniteReserveAmount")] public int InfiniteReserveAmount { get; set; } = 250;
|
||||||
|
|
||||||
|
// Grenade-key input: a grenade-select key (slot6-10) only reaches the server while the player holds
|
||||||
|
// that grenade. So we GRANT the matching grenade exactly while its ability is castable and REMOVE it on
|
||||||
|
// use — the key is live only when the ability is, the grenade can never be thrown, and there's no clutter.
|
||||||
|
[JsonPropertyName("GrenadeKeyInput")] public bool GrenadeKeyInput { get; set; } = true;
|
||||||
|
// Indexed by ability (0=No Reload, 1=Adrenaline, 2=Overcharge, 3=Bloodthirst, 4=Berserk). CT uses
|
||||||
|
// weapon_incgrenade (not molotov). Must stay aligned with AbilityForGrenade in Abilities.cs.
|
||||||
|
[JsonPropertyName("AbilityGrenades")]
|
||||||
|
public List<string> AbilityGrenades { get; set; } = new()
|
||||||
|
{ "weapon_hegrenade", "weapon_flashbang", "weapon_smokegrenade", "weapon_incgrenade", "weapon_decoy" };
|
||||||
|
|
||||||
|
[JsonPropertyName("NoReload")] public AbilityDef NoReload { get; set; } = new() { StreakReq = 10, Cooldown = 40, Duration = 12, TintR = 255, TintG = 221, TintB = 85 };
|
||||||
|
[JsonPropertyName("Adrenaline")] public AbilityDef Adrenaline { get; set; } = new() { StreakReq = 20, Cooldown = 50, Duration = 8, Magnitude = 35, TintR = 90, TintG = 150, TintB = 255 };
|
||||||
|
[JsonPropertyName("Overcharge")] public AbilityDef Overcharge { get; set; } = new() { StreakReq = 30, Cooldown = 60, Duration = 8, Magnitude = 50, TintR = 255, TintG = 140, TintB = 40 };
|
||||||
|
[JsonPropertyName("Bloodthirst")] public AbilityDef Bloodthirst { get; set; } = new() { StreakReq = 40, Cooldown = 70, Duration = 8, Magnitude = 60, Magnitude2 = 40, TintR = 90, TintG = 220, TintB = 90 };
|
||||||
|
[JsonPropertyName("Berserk")] public AbilityDef Berserk { get; set; } = new() { StreakReq = 50, Cooldown = 90, Duration = 10, Magnitude = 1.5, Magnitude2 = 1.5, TintR = 255, TintG = 60, TintB = 60 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-player balance handicap (spec §5). A single signed index t in [-1,+1] drives all three
|
||||||
|
// outputs (deal / take / XP) so they move together and hit their extremes at the SAME thresholds.
|
||||||
|
// t = N - B. N (nerf, 0..1) = weighted avg of four factors each normalised to its "maxed" threshold;
|
||||||
|
// B (buff, 0..1) = how far K/D sits below neutral (comeback help).
|
||||||
|
public sealed class HandicapConfig
|
||||||
|
{
|
||||||
|
// These defaults are tuned so the 100-point stat ceiling (~x3 offense) can't outscale the reachable nerf: Kd/Level are
|
||||||
|
// up-weighted and LevelMaxNerf spans the full point-investment range, keeping "better = harder + more XP, worse =
|
||||||
|
// easier + less XP" monotonic (otherwise a leveled/statted player inverts the handicap into a net advantage).
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
// Scales the whole signed deviation from neutral. 0 = off, ~1 = mild, 1.6 = retuned designed value, >2 = brutal.
|
||||||
|
[JsonPropertyName("MasterDifficulty")] public double MasterDifficulty { get; set; } = 1.6;
|
||||||
|
// Shapes |t| -> output. 1 = linear; >1 = eases IN (mild leads barely touched, then a hard cliff at the top —
|
||||||
|
// a wide "god with base stats" band); <1 = bites EARLY (debuff ramps fast the moment you pull ahead, then
|
||||||
|
// saturates). Lower = punishes a moderate lead sooner/harder. 1.3 keeps a small mild-lead toe.
|
||||||
|
[JsonPropertyName("Curve")] public double Curve { get; set; } = 1.3;
|
||||||
|
|
||||||
|
// Each nerf factor ramps 0->1 from neutral to its threshold; ALL four at threshold -> full nerf (t=1).
|
||||||
|
[JsonPropertyName("KdNeutral")] public double KdNeutral { get; set; } = 1.5; // K/D break-even (vs-many-bots avg is >1)
|
||||||
|
[JsonPropertyName("KdMaxNerf")] public double KdMaxNerf { get; set; } = 4.0; // K/D that maxes the K/D factor (4 so realistic 3-4 K/D bites, not just 5+)
|
||||||
|
[JsonPropertyName("KdMinBuff")] public double KdMinBuff { get; set; } = 0.5; // K/D that maxes the comeback buff
|
||||||
|
[JsonPropertyName("HsMaxNerf")] public double HsMaxNerf { get; set; } = 0.60; // headshot-kill rate that maxes the HS factor
|
||||||
|
[JsonPropertyName("HsMinKills")] public int HsMinKills { get; set; } = 5; // ignore HS rate until this many kills
|
||||||
|
[JsonPropertyName("StreakMaxNerf")] public int StreakMaxNerf { get; set; } = 20; // killstreak that maxes the streak factor
|
||||||
|
[JsonPropertyName("LevelMaxNerf")] public int LevelMaxNerf { get; set; } = 100; // level that maxes the level factor (100 spans the full point-investment range)
|
||||||
|
|
||||||
|
// Relative weights of the factors in the nerf average (normalised by their sum). Kd + Level up-weighted: those are the
|
||||||
|
// axes a skilled player actually maxes, so winning without farming HS/streak still bites.
|
||||||
|
[JsonPropertyName("KdWeight")] public double KdWeight { get; set; } = 2.0;
|
||||||
|
[JsonPropertyName("HsWeight")] public double HsWeight { get; set; } = 1.0;
|
||||||
|
[JsonPropertyName("StreakWeight")] public double StreakWeight { get; set; } = 1.0;
|
||||||
|
[JsonPropertyName("LevelWeight")] public double LevelWeight { get; set; } = 1.5;
|
||||||
|
// Weight of the active mode's "progress" axis (0..1, supplied by the driver). 0 in TDM (no axis). In Gun Game
|
||||||
|
// it's ladder position, so climbing nerfs you harder and a bot-demotion eases it. Default 0 (off for TDM).
|
||||||
|
[JsonPropertyName("ProgressWeight")] public double ProgressWeight { get; set; } = 0.0;
|
||||||
|
|
||||||
|
// Output bands, linear in t. At t=+1 deal=Floor / take=Ceiling / xp=Ceiling; at t=-1 deal=Ceiling / take=Floor / xp=Floor.
|
||||||
|
[JsonPropertyName("MDealFloor")] public double MDealFloor { get; set; } = 0.033; // dominant players deal ~3.3% base; the +50% Damage stat lands the effective body-shot floor at ~0.05 (GG/Survival pin this back to 0.1, where the ladder/wave needs a viable deal)
|
||||||
|
[JsonPropertyName("MDealCeiling")] public double MDealCeiling { get; set; } = 1.3; // strugglers deal up to 130% (compressed: the buff multiplies onto stats)
|
||||||
|
[JsonPropertyName("MTakeFloor")] public double MTakeFloor { get; set; } = 0.85; // strugglers take down to 85% (compressed comeback band)
|
||||||
|
[JsonPropertyName("MTakeCeiling")] public double MTakeCeiling { get; set; } = 8.0; // dominant players take 800%
|
||||||
|
[JsonPropertyName("XpFloor")] public double XpFloor { get; set; } = 0.1; // strugglers climb at 10%
|
||||||
|
[JsonPropertyName("XpCeiling")] public double XpCeiling { get; set; } = 10.0; // dominant players climb at 1000%
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-mode handicap override — a nullable mirror of HandicapConfig. The base `Handicap` block is the DEFAULT;
|
||||||
|
// each mode may carry its own `Handicap` sub-object overriding ONLY the fields it sets, e.g.
|
||||||
|
// "GunGame": { "Handicap": { "Curve": 1.6, "MTakeCeiling": 5.0 } } — everything else inherits the base. The effective
|
||||||
|
// config is resolved per active mode (Handicap.cs RebuildEffectiveHandicap). Add new fields here AND in ApplyTo.
|
||||||
|
public sealed class HandicapOverride
|
||||||
|
{
|
||||||
|
// Every field is nullable and WhenWritingNull-ignored: an unset field is OMITTED from the generated JSON entirely
|
||||||
|
// (rather than emitted as a confusing `null`), so a mode's Handicap block lists ONLY the fields it actually overrides
|
||||||
|
// and the rest transparently inherit the base block. A missing field still deserializes to null -> inherit.
|
||||||
|
[JsonPropertyName("Enabled"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Enabled { get; set; }
|
||||||
|
[JsonPropertyName("MasterDifficulty"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MasterDifficulty { get; set; }
|
||||||
|
[JsonPropertyName("Curve"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? Curve { get; set; }
|
||||||
|
[JsonPropertyName("KdNeutral"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdNeutral { get; set; }
|
||||||
|
[JsonPropertyName("KdMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMaxNerf { get; set; }
|
||||||
|
[JsonPropertyName("KdMinBuff"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdMinBuff { get; set; }
|
||||||
|
[JsonPropertyName("HsMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsMaxNerf { get; set; }
|
||||||
|
[JsonPropertyName("HsMinKills"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? HsMinKills { get; set; }
|
||||||
|
[JsonPropertyName("StreakMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? StreakMaxNerf { get; set; }
|
||||||
|
[JsonPropertyName("LevelMaxNerf"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? LevelMaxNerf { get; set; }
|
||||||
|
[JsonPropertyName("KdWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? KdWeight { get; set; }
|
||||||
|
[JsonPropertyName("HsWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? HsWeight { get; set; }
|
||||||
|
[JsonPropertyName("StreakWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? StreakWeight { get; set; }
|
||||||
|
[JsonPropertyName("LevelWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? LevelWeight { get; set; }
|
||||||
|
[JsonPropertyName("ProgressWeight"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? ProgressWeight { get; set; }
|
||||||
|
[JsonPropertyName("MDealFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealFloor { get; set; }
|
||||||
|
[JsonPropertyName("MDealCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MDealCeiling { get; set; }
|
||||||
|
[JsonPropertyName("MTakeFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeFloor { get; set; }
|
||||||
|
[JsonPropertyName("MTakeCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? MTakeCeiling { get; set; }
|
||||||
|
[JsonPropertyName("XpFloor"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpFloor { get; set; }
|
||||||
|
[JsonPropertyName("XpCeiling"), JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public double? XpCeiling { get; set; }
|
||||||
|
|
||||||
|
// Produce a fresh effective config = base with this override's non-null fields applied.
|
||||||
|
public HandicapConfig ApplyTo(HandicapConfig b) => new()
|
||||||
|
{
|
||||||
|
Enabled = Enabled ?? b.Enabled,
|
||||||
|
MasterDifficulty = MasterDifficulty ?? b.MasterDifficulty,
|
||||||
|
Curve = Curve ?? b.Curve,
|
||||||
|
KdNeutral = KdNeutral ?? b.KdNeutral,
|
||||||
|
KdMaxNerf = KdMaxNerf ?? b.KdMaxNerf,
|
||||||
|
KdMinBuff = KdMinBuff ?? b.KdMinBuff,
|
||||||
|
HsMaxNerf = HsMaxNerf ?? b.HsMaxNerf,
|
||||||
|
HsMinKills = HsMinKills ?? b.HsMinKills,
|
||||||
|
StreakMaxNerf = StreakMaxNerf ?? b.StreakMaxNerf,
|
||||||
|
LevelMaxNerf = LevelMaxNerf ?? b.LevelMaxNerf,
|
||||||
|
KdWeight = KdWeight ?? b.KdWeight,
|
||||||
|
HsWeight = HsWeight ?? b.HsWeight,
|
||||||
|
StreakWeight = StreakWeight ?? b.StreakWeight,
|
||||||
|
LevelWeight = LevelWeight ?? b.LevelWeight,
|
||||||
|
ProgressWeight = ProgressWeight ?? b.ProgressWeight,
|
||||||
|
MDealFloor = MDealFloor ?? b.MDealFloor,
|
||||||
|
MDealCeiling = MDealCeiling ?? b.MDealCeiling,
|
||||||
|
MTakeFloor = MTakeFloor ?? b.MTakeFloor,
|
||||||
|
MTakeCeiling = MTakeCeiling ?? b.MTakeCeiling,
|
||||||
|
XpFloor = XpFloor ?? b.XpFloor,
|
||||||
|
XpCeiling = XpCeiling ?? b.XpCeiling,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// XP / level / prestige economy (spec §2). Curve: xp to go from level L to L+1
|
||||||
|
// = LevelXpBase + LevelXpStep * (L-1)^LevelXpExponent. Exponent 1.0 = linear; >1 = progressive (accelerating).
|
||||||
|
public sealed class ProgressionConfig
|
||||||
|
{
|
||||||
|
// XP is earned per-hit on damage to bots (not a flat per-kill lump): final HP damage × rate, plus flat
|
||||||
|
// bonuses for headshots/crits/the kill itself. Each component is run through the same XP multipliers
|
||||||
|
// (Xp-Boost stat × prestige × handicap) at grant time. A clean 100-HP bodyshot kill = 100×Rate + KillBonus.
|
||||||
|
[JsonPropertyName("DamageXpPerHp")] public double DamageXpPerHp { get; set; } = 0.15; // XP per point of final HP damage
|
||||||
|
[JsonPropertyName("KillXpBonus")] public double KillXpBonus { get; set; } = 10; // flat, on the lethal blow (0 = kills give no XP)
|
||||||
|
[JsonPropertyName("HeadshotXpBonus")] public double HeadshotXpBonus { get; set; } = 10; // flat, per headshot hit
|
||||||
|
[JsonPropertyName("CritXpBonus")] public double CritXpBonus { get; set; } = 10; // flat, per crit hit
|
||||||
|
[JsonPropertyName("LevelXpBase")] public long LevelXpBase { get; set; } = 100; // flat cost of level 1 -> 2
|
||||||
|
[JsonPropertyName("LevelXpStep")] public long LevelXpStep { get; set; } = 8; // coefficient on the curve term
|
||||||
|
[JsonPropertyName("LevelXpExponent")] public double LevelXpExponent { get; set; } = 1.7; // curve steepness (more convex = gentler low, steeper high)
|
||||||
|
[JsonPropertyName("LevelCap")] public int LevelCap { get; set; } = 100;
|
||||||
|
[JsonPropertyName("PrestigeCap")] public int PrestigeCap { get; set; } = 10;
|
||||||
|
[JsonPropertyName("PrestigeXpBoostPercent")] public double PrestigeXpBoostPercent { get; set; } = 10; // +% XP per prestige (25 let prestige override the struggler XP penalty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One survival draft card: a run-scoped buff that maps onto an existing stat key (so EffRun stacks it on top of the
|
||||||
|
// permanent stat, "everything stacks") with a per-pick magnitude in Eff units + a pick cap. Cards are STRONG on
|
||||||
|
// purpose — they must be able to outscale the escalating handicap floor. Bias the magnitudes UP and tune live.
|
||||||
|
public sealed class SurvivalCardDef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Key")] public string Key { get; set; } = ""; // a StatKeys.* value (damage, crit_damage, max_hp, ...) or a CardKeys.* effect key
|
||||||
|
[JsonPropertyName("Name")] public string Name { get; set; } = ""; // shown on the draft card
|
||||||
|
[JsonPropertyName("PerPick")] public double PerPick { get; set; } // added to Eff per pick (Damage 100 = +100% = x2/pick on top of base)
|
||||||
|
[JsonPropertyName("Cap")] public int Cap { get; set; } = 3; // max times this card can be drafted in a run
|
||||||
|
// ---- draft DISPLAY/COUNTING metadata (data-driven so a new card doesn't need code edits in Survival's switches) ----
|
||||||
|
[JsonPropertyName("IsTeam")] public bool IsTeam { get; set; } // squad-wide team card (shared level via MDeal/MTake) vs per-player
|
||||||
|
[JsonPropertyName("Flat")] public bool Flat { get; set; } // the draft shows "+N" flat points (HP/armor caps, flat regen) instead of "+N%"
|
||||||
|
// Optional draft detail line for EFFECT cards where a raw +N% would mislead. A string.Format template; {0} = the
|
||||||
|
// value after the next pick (level x PerPick). Empty -> the card shows the standard Now%->Next% panel. (The compounding
|
||||||
|
// team cards + Burn carry their own computed detail in code; everything else lives here as data.)
|
||||||
|
[JsonPropertyName("Detail")] public string Detail { get; set; } = "";
|
||||||
|
public SurvivalCardDef() { }
|
||||||
|
public SurvivalCardDef(string key, string name, double perPick, int cap, bool isTeam = false, bool flat = false, string detail = "")
|
||||||
|
{ Key = key; Name = name; PerPick = perPick; Cap = cap; IsTeam = isTeam; Flat = flat; Detail = detail; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wave Survival mode (Mode="survival", see Survival.cs). Co-op escalating bot waves on the small arms-race maps.
|
||||||
|
// VANILLA bots (never tougher) — escalation is MORE bots + the inherited handicap floor tightening per wave. The
|
||||||
|
// roguelite DRAFT (strong run-scoped cards) is the counter-pressure; you outscale until the floor + horde win.
|
||||||
|
// Run-XP is a SEPARATE accumulator converted to main XP once at run-end. All values live-tunable via !og_reload.
|
||||||
|
public sealed class SurvivalConfig
|
||||||
|
{
|
||||||
|
// ---- bot population (vanilla HP; only the COUNT escalates) ----
|
||||||
|
[JsonPropertyName("AliveCap")] public int AliveCap { get; set; } = 20; // hard ceiling on bots alive at once (CPU lever; 20 = BotsPerHuman 5 x 4 conceptually)
|
||||||
|
// Bots ALIVE at once this wave = AliveBase + AlivePerWave*(wave-1), clamped to [1, AliveCap]. Separate from the kill
|
||||||
|
// budget: you face this many simultaneously (they respawn as you kill) until the wave's total kills are reached.
|
||||||
|
[JsonPropertyName("AliveBase")] public int AliveBase { get; set; } = 4; // alive at once on wave 1 (gentle start)
|
||||||
|
[JsonPropertyName("AlivePerWave")] public int AlivePerWave { get; set; } = 2; // +alive/wave -> hits AliveCap around wave 9
|
||||||
|
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5; // co-op cap (EnforceHumanTeam)
|
||||||
|
// Total kills to clear a wave = BudgetBase + BudgetPerWave*(wave-1) + BudgetPerPlayer*aliveHumansAtWaveStart.
|
||||||
|
[JsonPropertyName("BudgetBase")] public int BudgetBase { get; set; } = 6;
|
||||||
|
[JsonPropertyName("BudgetPerWave")] public int BudgetPerWave { get; set; } = 4;
|
||||||
|
[JsonPropertyName("BudgetPerPlayer")] public int BudgetPerPlayer { get; set; } = 3;
|
||||||
|
|
||||||
|
// ---- wave structure ----
|
||||||
|
[JsonPropertyName("WaveCount")] public int WaveCount { get; set; } = 20; // finite campaign; clearing this = WIN. Tune by feel + XP-vs-TDM/GG.
|
||||||
|
[JsonPropertyName("WaveBreakSeconds")] public float WaveBreakSeconds { get; set; } = 15f;
|
||||||
|
[JsonPropertyName("WaveTimeoutSeconds")] public float WaveTimeoutSeconds { get; set; } = 240f; // no kill for this long (despite nudges) = genuine deadlock -> fail the run (measures time since last KILL, not wall-time)
|
||||||
|
[JsonPropertyName("StallNudgeSeconds")] public float StallNudgeSeconds { get; set; } = 20f; // no kill for this long -> teleport the straggler bots to the squad (so the wave never stalls/ends with bots alive)
|
||||||
|
[JsonPropertyName("StartGraceSeconds")] public float StartGraceSeconds { get; set; } = 6f; // wait this long after a (re)spawn-able map before starting wave 1
|
||||||
|
|
||||||
|
// ---- death / revive ----
|
||||||
|
[JsonPropertyName("ReviveOnWaveClear")] public bool ReviveOnWaveClear { get; set; } = true; // false = hardcore "you die, you spectate the rest of the run"
|
||||||
|
[JsonPropertyName("ReviveHpPercent")] public double ReviveHpPercent { get; set; } = 0.5;
|
||||||
|
|
||||||
|
// ---- XP economy (granted PER WAVE at each wave clear, NOT once at run-end). Each cleared wave grants:
|
||||||
|
// rawWaveXp (HP-damage + HS/crit, handicap-mult EXCLUDED) x prestige x waveMult(wave).
|
||||||
|
// WinMult is the ONLY knob: the FINAL-wave / "win" multiplier. The per-wave multiplier ramps exponentially from x1
|
||||||
|
// (wave 1) to xWinMult (wave WaveCount): waveMult(w) = WinMult^((w-1)/(WaveCount-1)) — early waves pay ~nothing, the
|
||||||
|
// last few pay big. NO per-run cap (depth is the gate: you must survive + contribute to reach the big back multipliers;
|
||||||
|
// an in-progress wave is forfeited on a wipe). Sized so a top CONTRIBUTOR (not a carried player) can climb toward max
|
||||||
|
// level over a DEEP run; tune with research/handicap-balance-calc.py --survival-wave. Default fits the ~50-wave
|
||||||
|
// production target — at the current WaveCount it pays proportionally less (re-solve when you raise WaveCount). ----
|
||||||
|
[JsonPropertyName("WinMult")] public double WinMult { get; set; } = 24.0;
|
||||||
|
|
||||||
|
// ---- the draft ----
|
||||||
|
[JsonPropertyName("DraftSize")] public int DraftSize { get; set; } = 3; // cards offered per pick
|
||||||
|
[JsonPropertyName("CardsPerWave")] public int CardsPerWave { get; set; } = 1; // picks granted on each wave clear
|
||||||
|
|
||||||
|
// ---- effect-card tunables (the new logic cards; all live-tunable) ----
|
||||||
|
[JsonPropertyName("BurnDamagePerSecond")] public double BurnDamagePerSecond { get; set; } = 3; // flat HP/s the Burn card deals (armor-skipping). Different attackers STACK; a re-hit just refreshes your own.
|
||||||
|
[JsonPropertyName("BurnDurationSeconds")] public float BurnDurationSeconds { get; set; } = 5f; // each hit (re)applies this much burn time
|
||||||
|
[JsonPropertyName("BurnTickSeconds")] public float BurnTickSeconds { get; set; } = 1f; // burn damage cadence (DPS held at BurnDamagePerSecond; changing this needs a reload to re-arm the timer)
|
||||||
|
[JsonPropertyName("ExplodeBaseDamage")] public double ExplodeBaseDamage { get; set; } = 100; // Explode-on-Kill HE base damage — then scaled UP by the killer's damage build + handicap on detonation
|
||||||
|
[JsonPropertyName("ExplodeRadius")] public double ExplodeRadius { get; set; } = 350; // Explode-on-Kill HE blast radius (engine distance falloff applies before the scaling)
|
||||||
|
[JsonPropertyName("ExplodeFuseSeconds")] public float ExplodeFuseSeconds { get; set; } = 0.1f; // delay before the corpse grenade detonates (0.1 = near-instant; bump for a telegraphed beep-then-boom)
|
||||||
|
|
||||||
|
// ---- handicap escalation ----
|
||||||
|
// The wave floor reaches max nerf (t=1) at MaxNerfWave (feel-tuned; cards must be able to outscale it). Monotonic.
|
||||||
|
[JsonPropertyName("MaxNerfWave")] public int MaxNerfWave { get; set; } = 25;
|
||||||
|
// Full per-mode handicap override (all bands tunable). Seeded with a higher MTakeCeiling than base (10x) because
|
||||||
|
// cards + permanent stats stack — a maxed-HP/lifesteal/regen/thorns build can be unkillable at 8x. Tune live.
|
||||||
|
// In survival the WAVE FLOOR is the difficulty driver, so the inherited factors are heavily down-weighted —
|
||||||
|
// otherwise a high-level player is pre-nerfed to near-max-take on wave 1 (level/KD maxing the factor before the
|
||||||
|
// floor even ramps). Low weights keep a mild skill component; the floor (-> max nerf at MaxNerfWave) does the work.
|
||||||
|
[JsonPropertyName("Handicap")]
|
||||||
|
public HandicapOverride? Handicap { get; set; } = new()
|
||||||
|
{
|
||||||
|
ProgressWeight = 0.0,
|
||||||
|
MTakeCeiling = 10.0,
|
||||||
|
KdWeight = 0.3,
|
||||||
|
HsWeight = 0.3,
|
||||||
|
StreakWeight = 0.3,
|
||||||
|
LevelWeight = 0.1,
|
||||||
|
MDealFloor = 0.1, // pin: the base dropped to 0.033 for TDM, but waves need a viable deal to be clearable
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map pool (share the GG arms-race maps). EMPTY -> falls back to Match.Maps.
|
||||||
|
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
|
||||||
|
|
||||||
|
// ---- the card catalog (each maps to a StatKeys.* so EffRun stacks it; STRONG on purpose, tune live) ----
|
||||||
|
[JsonPropertyName("Cards")]
|
||||||
|
public List<SurvivalCardDef> Cards { get; set; } = new()
|
||||||
|
{
|
||||||
|
new("damage", "+Damage", 100, 3), // +100%/pick -> x4 dmg from cards alone, maxed
|
||||||
|
new("hs_damage", "+Headshot Dmg", 50, 3), // +50%/pick -> +150% maxed
|
||||||
|
new("crit_chance", "+Crit Chance", 15, 3),// +15%/pick -> +45% maxed
|
||||||
|
new("crit_damage", "+Crit Dmg", 83, 3), // +83%/pick -> ~+250% maxed
|
||||||
|
new("max_hp", "+Max HP", 40, 3, flat: true), // +40 HP/pick -> +120 maxed (flat points, not %)
|
||||||
|
new("max_armor", "+Max Armor", 40, 3, flat: true),
|
||||||
|
new("lifesteal", "+Lifesteal", 8, 3), // +8%/pick -> +24% maxed
|
||||||
|
new("hp_regen", "+HP Regen", 2, 3, flat: true), // FLAT +2 HP/s/pick -> +6 HP/s maxed (stacks on the always-on regen stat)
|
||||||
|
|
||||||
|
// ---- effect-logic cards (NEW keys, NOT StatKeys; the effect hooks read them via StatBonus, never folded into EffRun) ----
|
||||||
|
// 1-pick (Cap 1): a single draft fully unlocks the effect; PerPick on the two FLAG cards is just a presence marker
|
||||||
|
// (their magnitude comes from the Burn*/Explode* knobs above), the other two carry their whole value in one pick.
|
||||||
|
// Detail = the draft line ({0} = level x PerPick); Burn + the two team cards carry computed detail in code.
|
||||||
|
new("explode_kill", "Explode on Kill", 1, 1, detail: "HE blast on kill"), // kill a bot -> real HE blast on the corpse (scales with your dmg build); chains freely
|
||||||
|
new("burn", "Burn", 1, 1), // every hit ignites the bot: flat BurnDamagePerSecond HP/s, armor-skipping, per-attacker stacking
|
||||||
|
new("ability_cdr", "-Ability Cooldown", 30, 1, detail: "-{0:0}% ability cooldown"),
|
||||||
|
new("xp_mult", "+Run XP", 50, 1, detail: "+{0:0}% run XP"),
|
||||||
|
// Leveled (Cap 3): per-player, scale per pick.
|
||||||
|
new("hs_reduction", "Headshot Armor", 15, 3, detail: "-{0:0}% headshot dmg"), // armor doesn't help vs HS; this does
|
||||||
|
new("berserk_passive", "Berserker", 40, 3, detail: "+{0:0}% dmg near death"), // scaled by missing HP (always-on, no streak gate)
|
||||||
|
// Leveled (Cap 3) but TEAM-WIDE: one shared squad level (max 3), compounding, applied to EVERY survivor via MDeal/MTake.
|
||||||
|
new("global_deal", "Team: +Damage", 10, 3, isTeam: true), // +10%/level ALL outgoing dmg, compounding -> x1.331 squad-wide maxed
|
||||||
|
new("global_take", "Team: -Damage Taken", 10, 3, isTeam: true),// -10%/level ALL incoming dmg, compounding -> x0.729 squad-wide maxed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// One tunable stat: Base value at 0 invested levels + PerLevel increment, up to MaxLevel.
|
||||||
|
public sealed class StatDef
|
||||||
|
{
|
||||||
|
[JsonPropertyName("MaxLevel")] public int MaxLevel { get; set; }
|
||||||
|
[JsonPropertyName("PerLevel")] public double PerLevel { get; set; }
|
||||||
|
[JsonPropertyName("Base")] public double Base { get; set; }
|
||||||
|
|
||||||
|
public StatDef() { }
|
||||||
|
public StatDef(int maxLevel, double perLevel, double @base = 0) { MaxLevel = maxLevel; PerLevel = perLevel; Base = @base; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The passive stat tree (spec §3). All values tunable; max levels sum to 100. Pure POCO (StatDef + primitives) so it lives
|
||||||
|
// here with the other Domain-read configs (the resolvers index it via _statDefs) — CSSharp-free, test-shareable.
|
||||||
|
public sealed class StatsConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Damage")] public StatDef Damage { get; set; } = new(5, 10); // +10% dmg / lvl -> +50% (x1.5) maxed
|
||||||
|
[JsonPropertyName("CritChance")] public StatDef CritChance { get; set; } = new(20, 2.5); // +2.5% chance / lvl -> 50% maxed (slow ramp, de-rushed)
|
||||||
|
[JsonPropertyName("CritDamage")] public StatDef CritDamage { get; set; } = new(10, 10, 100); // base +100% (x2), +10%/lvl -> +200% (x3) maxed
|
||||||
|
[JsonPropertyName("HeadshotDamage")] public StatDef HeadshotDamage { get; set; } = new(5, 10); // +10% / lvl -> +50% (x1.5) on top of engine 4x
|
||||||
|
[JsonPropertyName("MaxHp")] public StatDef MaxHp { get; set; } = new(10, 25); // +25 hp / lvl (over base 100)
|
||||||
|
[JsonPropertyName("MaxArmor")] public StatDef MaxArmor { get; set; } = new(10, 25);
|
||||||
|
[JsonPropertyName("Lifesteal")] public StatDef Lifesteal { get; set; } = new(10, 2.5); // +2.5% of dmg as HP / lvl
|
||||||
|
[JsonPropertyName("ArmorLifesteal")] public StatDef ArmorLifesteal { get; set; } = new(10, 2);
|
||||||
|
[JsonPropertyName("HpRegen")] public StatDef HpRegen { get; set; } = new(5, 1); // FLAT +1 HP / regen-tick(=1s) / lvl -> 5 HP/s maxed. ALWAYS ON (no out-of-combat gate).
|
||||||
|
[JsonPropertyName("ArmorRegen")] public StatDef ArmorRegen { get; set; } = new(5, 1); // FLAT +1 armor / regen-tick(=1s) / lvl. ALWAYS ON.
|
||||||
|
[JsonPropertyName("Thorns")] public StatDef Thorns { get; set; } = new(5, 2); // reflect +2% of damage taken / lvl -> 10% maxed (dealt FLAT back at the bot — a straight % of the hit you took, no build/handicap scaling; hard-capped 25 HP/hit; OFF while a knife/zeus is held so a GG melee finale needs a real kill)
|
||||||
|
[JsonPropertyName("XpBoost")] public StatDef XpBoost { get; set; } = new(5, 20); // +20% / lvl -> +100% (x2) maxed
|
||||||
|
|
||||||
|
[JsonPropertyName("RegenIntervalSeconds")] public float RegenIntervalSeconds { get; set; } = 1f;
|
||||||
|
[JsonPropertyName("RegenNoDamageDelaySeconds")] public float RegenNoDamageDelaySeconds { get; set; } = 5f; // DEPRECATED — regen is now ALWAYS ON (flat). Kept so existing JSON doesn't error; no longer read.
|
||||||
|
|
||||||
|
[JsonPropertyName("CritLifestealMultiplier")] public double CritLifestealMultiplier { get; set; } = 1.5; // crit hits steal +50%
|
||||||
|
[JsonPropertyName("LifestealMinHeal")] public int LifestealMinHeal { get; set; } = 2; // floor on a lifesteal heal (HP)
|
||||||
|
}
|
||||||
360
Outnumbered/Config/OutnumberedConfig.cs
Normal file
360
Outnumbered/Config/OutnumberedConfig.cs
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
|
||||||
|
namespace Outnumbered.Config;
|
||||||
|
|
||||||
|
// Auto-loaded from configs/plugins/outnumbered/outnumbered.json on first load.
|
||||||
|
// NOTE: the config sub-types the pure Domain layer reads (AbilityDef/AbilitiesConfig/HandicapConfig/HandicapOverride/
|
||||||
|
// ProgressionConfig/SurvivalCardDef/SurvivalConfig/StatDef) live in Config/DomainConfig.cs — split out so they're
|
||||||
|
// CounterStrikeSharp-free and the test project can compile them with Domain/. This file holds OutnumberedConfig (which
|
||||||
|
// extends the CSSharp BasePluginConfig) + the engine-coupled / presentation blocks.
|
||||||
|
public sealed class OutnumberedConfig : BasePluginConfig
|
||||||
|
{
|
||||||
|
// Which match driver runs this instance: "tdm" (default), "gungame", or "survival" (Driver.NormalizeMode also accepts
|
||||||
|
// 0/1/2 and aliases gg/wave). A per-instance launch decision — the whole RPG core is shared; only the match ruleset
|
||||||
|
// changes. Source of truth for the accepted values: Driver.ModeRegistry + NormalizeMode. (Mode-driver split, see Modes.cs.)
|
||||||
|
[JsonPropertyName("Mode")]
|
||||||
|
public string Mode { get; set; } = "tdm";
|
||||||
|
|
||||||
|
[JsonPropertyName("Database")]
|
||||||
|
public DatabaseConfig Database { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Api")]
|
||||||
|
public ApiConfig Api { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Website")]
|
||||||
|
public WebsiteConfig Website { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Match")]
|
||||||
|
public MatchConfig Match { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("GunGame")]
|
||||||
|
public GunGameConfig GunGame { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Survival")]
|
||||||
|
public SurvivalConfig Survival { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Stats")]
|
||||||
|
public StatsConfig Stats { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Progression")]
|
||||||
|
public ProgressionConfig Progression { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Handicap")]
|
||||||
|
public HandicapConfig Handicap { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Abilities")]
|
||||||
|
public AbilitiesConfig Abilities { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Hud")]
|
||||||
|
public HudConfig Hud { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Shop")]
|
||||||
|
public ShopConfig Shop { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("Sounds")]
|
||||||
|
public SoundsConfig Sounds { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("FlushIntervalSeconds")]
|
||||||
|
public float FlushIntervalSeconds { get; set; } = 90f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-player sound cues (spec §7), played via the client `play` command. Paths are built-in CS2 sounds
|
||||||
|
// (same style GG2 uses). Empty string = no cue. Swap freely; `play <path>` resolves under csgo/.
|
||||||
|
public sealed class SoundsConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
[JsonPropertyName("LevelUp")] public string LevelUp { get; set; } = "sounds/ui/armsrace_level_up.wav";
|
||||||
|
[JsonPropertyName("Prestige")] public string Prestige { get; set; } = "sounds/ui/armsrace_level_up.wav";
|
||||||
|
[JsonPropertyName("AbilityReady")] public string AbilityReady { get; set; } = "sounds/ambient/office/tech_oneshot_08.wav";
|
||||||
|
[JsonPropertyName("AbilityActivate")] public string AbilityActivate { get; set; } = "sounds/training/pointscored.wav";
|
||||||
|
[JsonPropertyName("Crit")] public string Crit { get; set; } = ""; // off by default — per-crit can get spammy
|
||||||
|
}
|
||||||
|
|
||||||
|
// The local read-only status/balance/top API, served over a per-instance Unix domain socket (Api.cs). The companion
|
||||||
|
// website is the consumer; there is no network exposure — the socket is a filesystem path, access = file permissions.
|
||||||
|
public sealed class ApiConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
// Directory holding the per-instance sockets (<SocketDir>/<ServerId>.sock). Created externally (systemd tmpfiles.d);
|
||||||
|
// when it's missing or unwritable the API disables itself with one log line instead of failing the plugin load.
|
||||||
|
[JsonPropertyName("SocketDir")] public string SocketDir { get; set; } = "/run/outnumbered";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The companion-website plug: a periodic chat line + an !about line pointing players at the site. Url empty = the
|
||||||
|
// whole feature is OFF (the default — an operator without a site gets no dangling advert). Url is read live per
|
||||||
|
// announce (!og_reload applies); changing the interval needs a plugin reload to re-arm the timer.
|
||||||
|
public sealed class WebsiteConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Url")] public string Url { get; set; } = "";
|
||||||
|
[JsonPropertyName("AnnounceIntervalSeconds")] public float AnnounceIntervalSeconds { get; set; } = 300f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The always-on HUD (spec §7): handicap multipliers + streak + the 5 ability states.
|
||||||
|
public sealed class HudConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
// "center" = center HTML panel (crisp, multi-color, 2D, no lag — fixed upper-center + width). DEFAULT.
|
||||||
|
// "world" = CPointWorldText entity (positionable/wide/sized, but single-color, 3D parallax, lags on movement
|
||||||
|
// since the viewmodel isn't accessible to parent to — kept as an option, not recommended).
|
||||||
|
[JsonPropertyName("Mode")] public string Mode { get; set; } = "center";
|
||||||
|
// Refresh cadence in ticks. Center HTML fades only after ~5s, so it does NOT need per-tick resends: 4 (=16 Hz at
|
||||||
|
// 64 tick) keeps countdowns smooth at a quarter of the per-human message + string-build cost of 1. Every refresh
|
||||||
|
// sends a reliable usermessage per human, so this dial is also the HUD's client/network footprint.
|
||||||
|
[JsonPropertyName("RefreshEveryTicks")] public int RefreshEveryTicks { get; set; } = 4;
|
||||||
|
|
||||||
|
// Ability icons for the center HUD (indexed 0..4: No Reload, Adrenaline, Overcharge, Bloodthirst, Berserk).
|
||||||
|
// Glyph rendering depends on the client font — if any show as a box, swap it here and `css_plugins reload`.
|
||||||
|
// Defaults kept in BMP symbol blocks that render on the CS2 panel font (astral emoji like 🔥💧 show as boxes).
|
||||||
|
[JsonPropertyName("AbilityIcons")]
|
||||||
|
public List<string> AbilityIcons { get; set; } = new()
|
||||||
|
{ "∞", "⛨", "⇈", "♥", "☠" };
|
||||||
|
|
||||||
|
// ---- world-text placement — LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) ----
|
||||||
|
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye (keep small so walls don't clip it)
|
||||||
|
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // + = right, - = left
|
||||||
|
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = -2.6f; // - = lower on screen
|
||||||
|
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 26f;
|
||||||
|
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.0075f; // smaller = smaller/finer text
|
||||||
|
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
|
||||||
|
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
|
||||||
|
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
|
||||||
|
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
|
||||||
|
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The quick-buy shop world-text panel. Opened by switching to the healthshot (X), frozen while open.
|
||||||
|
// Placement is LIVE-TUNABLE: edit JSON, then `css_plugins reload outnumbered` (no rebuild) to reposition.
|
||||||
|
public sealed class ShopConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
[JsonPropertyName("ForwardOffset")] public float ForwardOffset { get; set; } = 7f; // units in front of the eye
|
||||||
|
[JsonPropertyName("RightOffset")] public float RightOffset { get; set; } = 0f; // centre anchor: + = right, - = left
|
||||||
|
[JsonPropertyName("SplitOffset")] public float SplitOffset { get; set; } = 3f; // half-gap: left panel sits -this, right panel +this
|
||||||
|
[JsonPropertyName("CardSpread")] public float CardSpread { get; set; } = 6f; // survival draft: horizontal gap between the 3 cards (centre card at RightOffset)
|
||||||
|
[JsonPropertyName("UpOffset")] public float UpOffset { get; set; } = 0f; // + = higher, - = lower
|
||||||
|
[JsonPropertyName("FontSize")] public float FontSize { get; set; } = 40f;
|
||||||
|
[JsonPropertyName("WorldUnitsPerPx")] public float WorldUnitsPerPx { get; set; } = 0.011f; // smaller = finer
|
||||||
|
[JsonPropertyName("InfoFontSize")] public float InfoFontSize { get; set; } = 34f; // right (info) panel: smaller than shop, but readable
|
||||||
|
[JsonPropertyName("InfoWorldUnitsPerPx")] public float InfoWorldUnitsPerPx { get; set; } = 0.0085f;
|
||||||
|
// Server/about text shown atop the info panel (the !info / !about blurb) — edit per-server. Keep lines SHORT
|
||||||
|
// (this panel is narrow/tall by design); long lines run off-screen at lower resolutions.
|
||||||
|
[JsonPropertyName("InfoLines")]
|
||||||
|
public List<string> InfoLines { get; set; } = new()
|
||||||
|
{
|
||||||
|
"=== OUTNUMBERED ===",
|
||||||
|
"Kill bots -> XP -> level.",
|
||||||
|
"Spend points in Skills.",
|
||||||
|
"L100: !prestige resets you",
|
||||||
|
"for a permanent boost.",
|
||||||
|
"Streaks unlock abilities",
|
||||||
|
"(grenade keys 6-0).",
|
||||||
|
"Handicap scales bots",
|
||||||
|
"to your power.",
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
[JsonPropertyName("FontName")] public string FontName { get; set; } = "Arial Bold";
|
||||||
|
[JsonPropertyName("DrawBackground")] public bool DrawBackground { get; set; } = true;
|
||||||
|
[JsonPropertyName("ColorR")] public int ColorR { get; set; } = 255;
|
||||||
|
[JsonPropertyName("ColorG")] public int ColorG { get; set; } = 255;
|
||||||
|
[JsonPropertyName("ColorB")] public int ColorB { get; set; } = 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate file outnumbered.ranks.json (NOT auto-loaded by CSSharp — loaded manually, see Ranks.cs). Spec §9.
|
||||||
|
public sealed class RanksConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Enabled")] public bool Enabled { get; set; } = true;
|
||||||
|
[JsonPropertyName("ShowClanTag")] public bool ShowClanTag { get; set; } = true; // scoreboard tag via m_szClan
|
||||||
|
[JsonPropertyName("ChatTag")] public bool ChatTag { get; set; } = true; // coloured rank tag in chat (reformats chat)
|
||||||
|
// Prestige tag colouring: I-V = the unlocked perk's tint; VI-VII 2-colour, VIII-IX 3-colour, X all-5 (HUD animated, chat static).
|
||||||
|
[JsonPropertyName("PrestigeColors")] public bool PrestigeColors { get; set; } = true;
|
||||||
|
[JsonPropertyName("TopCount")] public int TopCount { get; set; } = 10; // !top size
|
||||||
|
// {0} = Roman prestige; only shown when prestige > 0. e.g. "P {0}" -> "P III".
|
||||||
|
[JsonPropertyName("PrestigeTagFormat")] public string PrestigeTagFormat { get; set; } = "P{0}";
|
||||||
|
// Highest MinLevel that is <= the player's level wins. Levels are within a prestige (cap 100).
|
||||||
|
[JsonPropertyName("LevelRanks")]
|
||||||
|
public List<RankTier> LevelRanks { get; set; } = new()
|
||||||
|
{
|
||||||
|
new(1, "Recruit"), new(26, "Soldier"), new(51, "Veteran"), new(76, "Elite"), new(100, "Apex"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RankTier
|
||||||
|
{
|
||||||
|
[JsonPropertyName("MinLevel")] public int MinLevel { get; set; }
|
||||||
|
[JsonPropertyName("Name")] public string Name { get; set; } = "";
|
||||||
|
public RankTier() { }
|
||||||
|
public RankTier(int minLevel, string name) { MinLevel = minLevel; Name = name; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DatabaseConfig
|
||||||
|
{
|
||||||
|
// "sqlite" for dev; "postgres" is the final-step swap (no logic change — repository interface).
|
||||||
|
[JsonPropertyName("Provider")]
|
||||||
|
public string Provider { get; set; } = "sqlite";
|
||||||
|
|
||||||
|
// Relative to csgo/ . Defaults next to the plugin's own config.
|
||||||
|
[JsonPropertyName("SqliteFile")]
|
||||||
|
public string SqliteFile { get; set; } = "addons/counterstrikesharp/configs/plugins/outnumbered/outnumbered.db";
|
||||||
|
|
||||||
|
// Unused while Provider == "sqlite"; filled in for the Postgres phase.
|
||||||
|
[JsonPropertyName("PostgresConnectionString")]
|
||||||
|
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=outnumbered;Username=outnumbered;Password=";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The TDM match driver (spec §1). Humans on CT vs bots on T, continuous respawn, no objectives.
|
||||||
|
public sealed class MatchConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("BotsPerHuman")] public int BotsPerHuman { get; set; } = 4;
|
||||||
|
[JsonPropertyName("MaxBots")] public int MaxBots { get; set; } = 12;
|
||||||
|
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 3;
|
||||||
|
[JsonPropertyName("RespawnDelaySeconds")] public float RespawnDelaySeconds { get; set; } = 1.0f; // DEPRECATED — native DM respawn handles timing; no longer read (kept so existing JSON doesn't error).
|
||||||
|
// 5s so you can spend skill points on respawn; it breaks the instant you fire, so it's not abusable (PvE anyway).
|
||||||
|
[JsonPropertyName("SpawnProtectionSeconds")] public float SpawnProtectionSeconds { get; set; } = 5.0f;
|
||||||
|
|
||||||
|
// Map ends when ANY single player reaches this many kills (spec §1).
|
||||||
|
[JsonPropertyName("KillGoal")] public int KillGoal { get; set; } = 250;
|
||||||
|
[JsonPropertyName("MapChangeDelaySeconds")] public float MapChangeDelaySeconds { get; set; } = 6.0f;
|
||||||
|
[JsonPropertyName("Maps")]
|
||||||
|
public List<string> Maps { get; set; } = new()
|
||||||
|
{ "cs_italy", "de_dust2", "de_vertigo", "de_nuke", "de_inferno" };
|
||||||
|
|
||||||
|
// bot_difficulty caps at 4 on this build (+ custom_bot_difficulty) — cookbook.
|
||||||
|
[JsonPropertyName("BotDifficulty")] public int BotDifficulty { get; set; } = 4;
|
||||||
|
|
||||||
|
// Base loadout = the only weapons free from level 1; everything else is level-gated (WeaponUnlockLevel).
|
||||||
|
// Prestige resets level → re-locks everything back to these. Persisted per-player choice falls back here when locked.
|
||||||
|
[JsonPropertyName("HumanPrimary")] public string HumanPrimary { get; set; } = "weapon_mp9";
|
||||||
|
[JsonPropertyName("HumanSecondary")] public string HumanSecondary { get; set; } = "weapon_usp_silencer";
|
||||||
|
|
||||||
|
// weapon -> level required to select it (absent/0 = free). Weak/cheap early, the cheese (AK/AWP/Negev/autos) late.
|
||||||
|
[JsonPropertyName("WeaponUnlockLevel")]
|
||||||
|
public Dictionary<string, int> WeaponUnlockLevel { get; set; } = new()
|
||||||
|
{
|
||||||
|
["weapon_glock"] = 2,
|
||||||
|
["weapon_p250"] = 4,
|
||||||
|
["weapon_mac10"] = 6,
|
||||||
|
["weapon_nova"] = 8,
|
||||||
|
["weapon_hkp2000"] = 10,
|
||||||
|
["weapon_bizon"] = 12,
|
||||||
|
["weapon_mp7"] = 14,
|
||||||
|
["weapon_galilar"] = 16,
|
||||||
|
["weapon_sawedoff"] = 18,
|
||||||
|
["weapon_elite"] = 20,
|
||||||
|
["weapon_famas"] = 22,
|
||||||
|
["weapon_ump45"] = 24,
|
||||||
|
["weapon_fiveseven"] = 27,
|
||||||
|
["weapon_tec9"] = 30,
|
||||||
|
["weapon_mp5sd"] = 33,
|
||||||
|
["weapon_mag7"] = 36,
|
||||||
|
["weapon_ssg08"] = 39,
|
||||||
|
["weapon_p90"] = 42,
|
||||||
|
["weapon_aug"] = 45,
|
||||||
|
["weapon_cz75a"] = 48,
|
||||||
|
["weapon_xm1014"] = 51,
|
||||||
|
["weapon_sg556"] = 55,
|
||||||
|
["weapon_deagle"] = 59,
|
||||||
|
["weapon_revolver"] = 63,
|
||||||
|
["weapon_m4a1"] = 67,
|
||||||
|
["weapon_m4a1_silencer"] = 71,
|
||||||
|
["weapon_ak47"] = 75,
|
||||||
|
["weapon_m249"] = 80,
|
||||||
|
["weapon_scar20"] = 85,
|
||||||
|
["weapon_g3sg1"] = 90,
|
||||||
|
["weapon_negev"] = 95,
|
||||||
|
["weapon_awp"] = 100,
|
||||||
|
};
|
||||||
|
// Free-selection weapon menu (!guns), split into categories (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols).
|
||||||
|
// Edit to taste; entries must be valid weapon_ item names. "Pistols" are the secondary slot; the rest are primary.
|
||||||
|
[JsonPropertyName("Rifles")]
|
||||||
|
public List<string> Rifles { get; set; } = new()
|
||||||
|
{ "weapon_ak47", "weapon_m4a1_silencer", "weapon_m4a1", "weapon_aug", "weapon_sg556", "weapon_galilar", "weapon_famas" };
|
||||||
|
[JsonPropertyName("Smgs")]
|
||||||
|
public List<string> Smgs { get; set; } = new()
|
||||||
|
{ "weapon_mp9", "weapon_mp7", "weapon_mp5sd", "weapon_ump45", "weapon_p90", "weapon_mac10", "weapon_bizon" };
|
||||||
|
[JsonPropertyName("Snipers")]
|
||||||
|
public List<string> Snipers { get; set; } = new()
|
||||||
|
{ "weapon_awp", "weapon_ssg08", "weapon_scar20", "weapon_g3sg1" };
|
||||||
|
[JsonPropertyName("Shotguns")]
|
||||||
|
public List<string> Shotguns { get; set; } = new()
|
||||||
|
{ "weapon_nova", "weapon_xm1014", "weapon_mag7", "weapon_sawedoff" };
|
||||||
|
[JsonPropertyName("Heavy")]
|
||||||
|
public List<string> Heavy { get; set; } = new()
|
||||||
|
{ "weapon_m249", "weapon_negev" };
|
||||||
|
[JsonPropertyName("Pistols")]
|
||||||
|
public List<string> Pistols { get; set; } = new()
|
||||||
|
{ "weapon_deagle", "weapon_revolver", "weapon_glock", "weapon_usp_silencer", "weapon_hkp2000",
|
||||||
|
"weapon_p250", "weapon_fiveseven", "weapon_tec9", "weapon_cz75a", "weapon_elite" };
|
||||||
|
// Bot squad pool — cycled per bot spawn for a mixed T side (full squad templates come later).
|
||||||
|
[JsonPropertyName("BotWeapons")]
|
||||||
|
public List<string> BotWeapons { get; set; } = new()
|
||||||
|
{ "weapon_ak47", "weapon_ak47", "weapon_awp", "weapon_nova" };
|
||||||
|
[JsonPropertyName("BotGrenades")]
|
||||||
|
public List<string> BotGrenades { get; set; } = new()
|
||||||
|
{ "weapon_hegrenade" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// One Gun Game ladder rung: a weapon + its strength tier (1=weak/hard-to-use .. 5=cheese). The tier indexes the
|
||||||
|
// per-tier kill curves below — it does NOT affect the climb ORDER (that's this list's order, shared by players+bots).
|
||||||
|
public sealed class GunGameRung
|
||||||
|
{
|
||||||
|
[JsonPropertyName("Weapon")] public string Weapon { get; set; } = "";
|
||||||
|
[JsonPropertyName("Tier")] public int Tier { get; set; } = 1;
|
||||||
|
public GunGameRung() { }
|
||||||
|
public GunGameRung(string weapon, int tier) { Weapon = weapon; Tier = tier; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gun Game mode (Mode="gungame", see Modes.cs). Climb a weapon ladder by kills; the full RPG kit rides along.
|
||||||
|
// DUAL pacing: kills-to-advance a rung is driven by the weapon's TIER, and players vs bots use INVERSE curves —
|
||||||
|
// players grind weak guns + breeze strong ones (so an HS-demotion into the grind stings); bots skip weak guns
|
||||||
|
// fast + linger on strong ones (so they reach dangerous weapons instead of being stuck on pistols). The ORDER is
|
||||||
|
// shared (bots have no muscle memory) and zig-zags weapon types so muscle memory never settles.
|
||||||
|
public sealed class GunGameConfig
|
||||||
|
{
|
||||||
|
// Kills to clear a rung, indexed by tier-1 (tier 1..5). The player curve grinds weak guns / breezes strong ones.
|
||||||
|
// Bots default to a FLAT 1 kill/rung — with BotSharedLadder (per-batch pooling) a batch then needs ~ladder-length
|
||||||
|
// collective kills to win, which only lands once a player's K/D falls into handicap territory.
|
||||||
|
[JsonPropertyName("PlayerKillsByTier")] public List<int> PlayerKillsByTier { get; set; } = new() { 10, 8, 5, 2, 1 };
|
||||||
|
[JsonPropertyName("BotKillsByTier")] public List<int> BotKillsByTier { get; set; } = new() { 1, 1, 1, 1, 1 };
|
||||||
|
|
||||||
|
// Pool bot ladder progress per BATCH of BotsPerHuman (true, default): bots are grouped by slot rank into batches
|
||||||
|
// of BotsPerHuman, and each batch shares ONE rung that advances on ANY of its members' kills — so a batch (≈ one
|
||||||
|
// player's worth of bots) climbs ~BotsPerHuman× faster and can actually win, and the horde scales with player
|
||||||
|
// count (1 player -> 1 batch, 2 -> 2, ... up to MaxBots). false = each bot climbs its own rung (rarely tops).
|
||||||
|
[JsonPropertyName("BotSharedLadder")] public bool BotSharedLadder { get; set; } = true;
|
||||||
|
|
||||||
|
// Max humans on CT for THIS mode (GG wants more than the "outnumbered" TDM default of 3). Per-mode so ONE shared
|
||||||
|
// config can run both; the driver feeds it to EnforceHumanTeam.
|
||||||
|
[JsonPropertyName("MaxHumansOnCt")] public int MaxHumansOnCt { get; set; } = 5;
|
||||||
|
|
||||||
|
// Append a final knife rung — the classic Gun Game capstone (the win is a knife kill; 1 kill for both sides).
|
||||||
|
[JsonPropertyName("KnifeFinale")] public bool KnifeFinale { get; set; } = true;
|
||||||
|
|
||||||
|
// Map pool for this mode (changelevel rotation on a win). EMPTY = fall back to Match.Maps. Put the small
|
||||||
|
// arms-race maps here once you've confirmed the exact names installed on your server (e.g. ar_shoots, ar_baggage).
|
||||||
|
[JsonPropertyName("Maps")] public List<string> Maps { get; set; } = new();
|
||||||
|
|
||||||
|
// Per-mode handicap override (null = inherit the base Handicap unchanged). GG: weights the ladder-position
|
||||||
|
// progress axis (climbing nerfs you; a bot-demotion eases it), runs a rougher sub-1 Curve (bites from the start),
|
||||||
|
// and DOUBLES MasterDifficulty so the nerf cap (0.1x deal / 8x taken) is actually reachable on small GG maps —
|
||||||
|
// it's hit at n≈0.5 (half-maxed factors) instead of needing K/D+HS+streak+level+ladder ALL maxed, so a good
|
||||||
|
// climber gets driven to the floor and can no longer headshot-suppress the horde. All live-tunable via !og_reload.
|
||||||
|
// (Heads-up: MasterDifficulty scales both sides, so a struggling low-K/D player also gets a stronger comeback buff.)
|
||||||
|
[JsonPropertyName("Handicap")] public HandicapOverride? Handicap { get; set; } = new() { MasterDifficulty = 2.0, ProgressWeight = 3.0, Curve = 0.8, MDealFloor = 0.1 };
|
||||||
|
|
||||||
|
// The ladder: weapon order (shared) + per-weapon tier. EMPTY -> code falls back to a knife-only ladder (safety).
|
||||||
|
// Order zig-zags categories (no two consecutive the same; cheese scattered, not clustered).
|
||||||
|
[JsonPropertyName("Ladder")]
|
||||||
|
public List<GunGameRung> Ladder { get; set; } = new()
|
||||||
|
{
|
||||||
|
new("weapon_usp_silencer", 1), new("weapon_famas", 4), new("weapon_mac10", 2), new("weapon_mag7", 4),
|
||||||
|
new("weapon_aug", 5), new("weapon_fiveseven", 3), new("weapon_ump45", 4), new("weapon_scar20", 5),
|
||||||
|
new("weapon_nova", 3), new("weapon_m4a1", 5), new("weapon_p250", 2), new("weapon_mp5sd", 4),
|
||||||
|
new("weapon_ssg08", 4), new("weapon_galilar", 4), new("weapon_mp7", 3), new("weapon_xm1014", 4),
|
||||||
|
new("weapon_deagle", 5), new("weapon_bizon", 2), new("weapon_sg556", 5), new("weapon_tec9", 4),
|
||||||
|
new("weapon_m249", 4), new("weapon_mp9", 1), new("weapon_g3sg1", 5), new("weapon_sawedoff", 4),
|
||||||
|
new("weapon_revolver", 4), new("weapon_m4a1_silencer", 5), new("weapon_p90", 3), new("weapon_negev", 4),
|
||||||
|
new("weapon_elite", 4), new("weapon_ak47", 5), new("weapon_hkp2000", 1), new("weapon_awp", 5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal file
187
Outnumbered/Data/DapperPlayerRepository.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using Dapper;
|
||||||
|
|
||||||
|
namespace Outnumbered.Data;
|
||||||
|
|
||||||
|
// Shared Dapper implementation for both backends. SQLite (dev) and Postgres (prod) differ ONLY in how a connection is
|
||||||
|
// opened and in the schema DDL (INTEGER vs BIGINT, the SQLite-only drop-migration); every query and the row records are
|
||||||
|
// identical and live here once, so a new persisted column is a one-place change. Subclasses supply OpenConnectionAsync +
|
||||||
|
// EnsureSchemaAsync. Steam IDs are stored signed via unchecked((long)id), round-tripping identically on both. All methods
|
||||||
|
// run OFF the game thread. Permanent progression (players/upgrades) is GLOBAL; match_state is PER-SERVER (composite key)
|
||||||
|
// so many instances share one DB without clobbering each other's round.
|
||||||
|
public abstract class DapperPlayerRepository(string serverId) : IPlayerRepository
|
||||||
|
{
|
||||||
|
protected readonly string ServerId = string.IsNullOrEmpty(serverId) ? "default" : serverId; // scopes the per-round match_state
|
||||||
|
|
||||||
|
protected abstract Task<DbConnection> OpenConnectionAsync();
|
||||||
|
public abstract Task EnsureSchemaAsync();
|
||||||
|
|
||||||
|
public async Task<LoadedPlayer?> LoadAsync(ulong steamId)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
long sid = unchecked((long)steamId);
|
||||||
|
var row = await c.QuerySingleOrDefaultAsync<PlayerRow>(
|
||||||
|
"""
|
||||||
|
SELECT name, xp, level, prestige, points,
|
||||||
|
primary_weapon AS PrimaryWeapon, secondary_weapon AS SecondaryWeapon
|
||||||
|
FROM players WHERE steamid = @sid
|
||||||
|
""", new { sid });
|
||||||
|
if (row is null) return null;
|
||||||
|
|
||||||
|
var ups = await c.QueryAsync<UpgradeRow>(
|
||||||
|
"SELECT stat_key, level FROM upgrades WHERE steamid = @sid", new { sid });
|
||||||
|
|
||||||
|
return new LoadedPlayer(row.Name, row.Xp, (int)row.Level, (int)row.Prestige, (int)row.Points,
|
||||||
|
ups.ToDictionary(u => u.Stat_Key, u => (int)u.Level), row.PrimaryWeapon, row.SecondaryWeapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
var rows = await c.QueryAsync<TopRow>(
|
||||||
|
"SELECT name, level, prestige, xp FROM players ORDER BY prestige DESC, level DESC, xp DESC LIMIT @n",
|
||||||
|
new { n = count });
|
||||||
|
return rows.Select(r => new TopPlayer(r.Name, (int)r.Level, (int)r.Prestige, r.Xp)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records: improve-only upserts. The bare INSERT relies on the players-table defaults for a first-ever row (the
|
||||||
|
// name backfills on the player's next regular save); the CASE keeps the better value on conflict. Existing-row
|
||||||
|
// references MUST be table-qualified: in DO UPDATE both the target row and `excluded` are in scope, and Postgres
|
||||||
|
// rejects the unqualified form as ambiguous (42702). SQLite accepts the qualified form too, so the SQL stays shared.
|
||||||
|
public async Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave)
|
||||||
|
{
|
||||||
|
if (steamIds.Count == 0) return;
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await using var tx = await c.BeginTransactionAsync();
|
||||||
|
foreach (var id in steamIds.Order()) // ascending steamid = the global row-lock order (see SaveManyAsync)
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players(steamid, best_wave) VALUES(@sid, @w)
|
||||||
|
ON CONFLICT(steamid) DO UPDATE SET best_wave =
|
||||||
|
CASE WHEN players.best_wave IS NULL OR players.best_wave < excluded.best_wave
|
||||||
|
THEN excluded.best_wave ELSE players.best_wave END;
|
||||||
|
""", new { sid = unchecked((long)id), w = wave }, tx);
|
||||||
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TryImproveGgBestAsync(ulong steamId, long elapsedMs)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players(steamid, gg_best_ms) VALUES(@sid, @ms)
|
||||||
|
ON CONFLICT(steamid) DO UPDATE SET gg_best_ms =
|
||||||
|
CASE WHEN players.gg_best_ms IS NULL OR players.gg_best_ms > excluded.gg_best_ms
|
||||||
|
THEN excluded.gg_best_ms ELSE players.gg_best_ms END;
|
||||||
|
""", new { sid = unchecked((long)steamId), ms = elapsedMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
var rows = await c.QueryAsync<WaveRow>(
|
||||||
|
"SELECT name, best_wave AS BestWave FROM players WHERE best_wave IS NOT NULL ORDER BY best_wave DESC, xp DESC LIMIT @n",
|
||||||
|
new { n = count });
|
||||||
|
return rows.Select(r => new TopWave(r.Name, (int)r.BestWave)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
var rows = await c.QueryAsync<GgTimeRow>(
|
||||||
|
"SELECT name, gg_best_ms AS BestMs FROM players WHERE gg_best_ms IS NOT NULL ORDER BY gg_best_ms ASC, xp DESC LIMIT @n",
|
||||||
|
new { n = count });
|
||||||
|
return rows.Select(r => new TopGgTime(r.Name, r.BestMs)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveAsync(PersistPlayer player) => SaveManyAsync(new[] { player });
|
||||||
|
|
||||||
|
public async Task SaveManyAsync(IReadOnlyList<PersistPlayer> players)
|
||||||
|
{
|
||||||
|
if (players.Count == 0) return;
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await using var tx = await c.BeginTransactionAsync();
|
||||||
|
// Ascending steamid in EVERY multi-row transaction (here, the match twin, the records improves) = one global
|
||||||
|
// row-lock order, so a wave-clear improve and the periodic flush can't deadlock each other on Postgres.
|
||||||
|
foreach (var p in players.OrderBy(x => x.SteamId))
|
||||||
|
{
|
||||||
|
long sid = unchecked((long)p.SteamId);
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players(steamid, name, xp, level, prestige, points, primary_weapon, secondary_weapon, last_seen)
|
||||||
|
VALUES(@sid, @name, @xp, @level, @prestige, @points, @primary, @secondary, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(steamid) DO UPDATE SET
|
||||||
|
xp = @xp, name = @name, level = @level, prestige = @prestige, points = @points,
|
||||||
|
primary_weapon = @primary, secondary_weapon = @secondary, last_seen = CURRENT_TIMESTAMP;
|
||||||
|
""",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
sid,
|
||||||
|
name = p.Name,
|
||||||
|
xp = p.Xp,
|
||||||
|
level = p.Level,
|
||||||
|
prestige = p.Prestige,
|
||||||
|
points = p.Points,
|
||||||
|
primary = p.PrimaryWeapon,
|
||||||
|
secondary = p.SecondaryWeapon
|
||||||
|
}, tx);
|
||||||
|
|
||||||
|
// Full-replace the upgrade set so a prestige reset's removals actually clear (a plain UPSERT leaves stale rows
|
||||||
|
// that reload as if the reset never happened).
|
||||||
|
await c.ExecuteAsync("DELETE FROM upgrades WHERE steamid = @sid;", new { sid }, tx);
|
||||||
|
foreach (var kv in p.Upgrades)
|
||||||
|
await c.ExecuteAsync("INSERT INTO upgrades(steamid, stat_key, level) VALUES(@sid, @k, @l);",
|
||||||
|
new { sid, k = kv.Key, l = kv.Value }, tx);
|
||||||
|
}
|
||||||
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MatchState?> LoadMatchAsync(ulong steamId)
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
long sid = unchecked((long)steamId);
|
||||||
|
var row = await c.QuerySingleOrDefaultAsync<MatchRow>(
|
||||||
|
"SELECT kills, deaths, streak, headshot_kills AS HeadshotKills, gg_run_started_at AS GgRunStartedAtMs FROM match_state WHERE server_id = @srv AND steamid = @sid",
|
||||||
|
new { srv = ServerId, sid });
|
||||||
|
return row is null ? null
|
||||||
|
: new MatchState(steamId, (int)row.Kills, (int)row.Deaths, (int)row.Streak, (int)row.HeadshotKills, row.GgRunStartedAtMs ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SaveMatchAsync(MatchState state) => SaveManyMatchAsync(new[] { state });
|
||||||
|
|
||||||
|
public async Task SaveManyMatchAsync(IReadOnlyList<MatchState> states)
|
||||||
|
{
|
||||||
|
if (states.Count == 0) return;
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await using var tx = await c.BeginTransactionAsync();
|
||||||
|
foreach (var s in states.OrderBy(x => x.SteamId)) // global row-lock order (see SaveManyAsync)
|
||||||
|
{
|
||||||
|
long sid = unchecked((long)s.SteamId);
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO match_state(server_id, steamid, kills, deaths, streak, headshot_kills, gg_run_started_at)
|
||||||
|
VALUES(@srv, @sid, @kills, @deaths, @streak, @hs, @ggStart)
|
||||||
|
ON CONFLICT(server_id, steamid) DO UPDATE SET
|
||||||
|
kills = @kills, deaths = @deaths, streak = @streak, headshot_kills = @hs, gg_run_started_at = @ggStart;
|
||||||
|
""",
|
||||||
|
new { srv = ServerId, sid, kills = s.Kills, deaths = s.Deaths, streak = s.Streak, hs = s.HeadshotKills, ggStart = s.GgRunStartedAtMs }, tx);
|
||||||
|
}
|
||||||
|
await tx.CommitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WipeMatchAsync()
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await c.ExecuteAsync("DELETE FROM match_state WHERE server_id = @srv;", new { srv = ServerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dapper maps columns by name (case-insensitive). Integer columns -> Int64, so these are `long` (cast to int at the
|
||||||
|
// call site). The aliases + the Stat_Key param name map the snake_case columns (Postgres folds unquoted to lowercase).
|
||||||
|
protected sealed record PlayerRow(string Name, long Xp, long Level, long Prestige, long Points,
|
||||||
|
string? PrimaryWeapon, string? SecondaryWeapon);
|
||||||
|
protected sealed record UpgradeRow(string Stat_Key, long Level);
|
||||||
|
protected sealed record TopRow(string Name, long Level, long Prestige, long Xp);
|
||||||
|
protected sealed record WaveRow(string Name, long BestWave);
|
||||||
|
protected sealed record GgTimeRow(string Name, long BestMs);
|
||||||
|
protected sealed record MatchRow(long Kills, long Deaths, long Streak, long HeadshotKills, long? GgRunStartedAtMs);
|
||||||
|
}
|
||||||
25
Outnumbered/Data/IPlayerRepository.cs
Normal file
25
Outnumbered/Data/IPlayerRepository.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
namespace Outnumbered.Data;
|
||||||
|
|
||||||
|
// Hides the SQL dialect so SQLite (dev) -> PostgreSQL (prod) is a provider swap,
|
||||||
|
// not a logic change (spec §10). All methods run OFF the game thread.
|
||||||
|
public interface IPlayerRepository
|
||||||
|
{
|
||||||
|
Task EnsureSchemaAsync();
|
||||||
|
Task<LoadedPlayer?> LoadAsync(ulong steamId);
|
||||||
|
Task SaveAsync(PersistPlayer player);
|
||||||
|
Task SaveManyAsync(IReadOnlyList<PersistPlayer> players);
|
||||||
|
Task<IReadOnlyList<TopPlayer>> GetTopAsync(int count);
|
||||||
|
|
||||||
|
// Records (site leaderboards): improve-only writes kept OUT of the SaveMany upsert's column list, so a normal
|
||||||
|
// save can never clobber them and they need no LoadedPlayer/PlayerData round-trip (write-only from the plugin).
|
||||||
|
Task TryImproveBestWavesAsync(IReadOnlyList<ulong> steamIds, int wave); // higher wave wins
|
||||||
|
Task TryImproveGgBestAsync(ulong steamId, long elapsedMs); // lower time wins
|
||||||
|
Task<IReadOnlyList<TopWave>> GetTopWavesAsync(int count);
|
||||||
|
Task<IReadOnlyList<TopGgTime>> GetTopGgAsync(int count);
|
||||||
|
|
||||||
|
// Per-match stats (RAM-first; persisted only on disconnect/shutdown, wiped on round end).
|
||||||
|
Task<MatchState?> LoadMatchAsync(ulong steamId);
|
||||||
|
Task SaveMatchAsync(MatchState state);
|
||||||
|
Task SaveManyMatchAsync(IReadOnlyList<MatchState> states);
|
||||||
|
Task WipeMatchAsync();
|
||||||
|
}
|
||||||
78
Outnumbered/Data/Models.cs
Normal file
78
Outnumbered/Data/Models.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
namespace Outnumbered.Data;
|
||||||
|
|
||||||
|
// DTO for the !top leaderboard.
|
||||||
|
public sealed record TopPlayer(string Name, int Level, int Prestige, long Xp);
|
||||||
|
|
||||||
|
// DTOs for the records leaderboards (survival best wave / fastest Gun Game ladder). Names only — no SteamIDs on the wire.
|
||||||
|
public sealed record TopWave(string Name, int BestWave);
|
||||||
|
public sealed record TopGgTime(string Name, long BestMs);
|
||||||
|
|
||||||
|
// DTO returned by a load.
|
||||||
|
public sealed record LoadedPlayer(
|
||||||
|
string Name, long Xp, int Level, int Prestige, int Points, Dictionary<string, int> Upgrades,
|
||||||
|
string? PrimaryWeapon, string? SecondaryWeapon);
|
||||||
|
|
||||||
|
// DTO handed to a save (absolute values; last-write-wins).
|
||||||
|
public sealed record PersistPlayer(
|
||||||
|
ulong SteamId, string Name, long Xp, int Level, int Prestige, int Points,
|
||||||
|
IReadOnlyDictionary<string, int> Upgrades, string? PrimaryWeapon, string? SecondaryWeapon);
|
||||||
|
|
||||||
|
// Per-match stats (RAM-first: written only on disconnect/shutdown, wiped on round end). Restored on rejoin mid-match.
|
||||||
|
public sealed record MatchState(ulong SteamId, int Kills, int Deaths, int Streak, int HeadshotKills, long GgRunStartedAtMs = 0);
|
||||||
|
|
||||||
|
// In-memory cache row. Mutated ONLY on the main game thread.
|
||||||
|
public sealed class PlayerData
|
||||||
|
{
|
||||||
|
public required ulong SteamId { get; init; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public long Xp { get; set; } // progress toward the next level (within the current prestige)
|
||||||
|
public int Level { get; set; } = 1;
|
||||||
|
public int Prestige { get; set; }
|
||||||
|
public int Points { get; set; } = 1; // start with one spendable point (spec §2)
|
||||||
|
public Dictionary<string, int> Upgrades { get; } = new();
|
||||||
|
|
||||||
|
// Chosen loadout (null = use the config default). Persisted. Free selection, validated against the allowed lists.
|
||||||
|
public string? PrimaryWeapon { get; set; }
|
||||||
|
public string? SecondaryWeapon { get; set; }
|
||||||
|
|
||||||
|
public bool Dirty { get; set; }
|
||||||
|
|
||||||
|
// Runtime-only: sub-1 XP remainder carried between per-hit grants so fractional damage-XP isn't lost to rounding.
|
||||||
|
public double XpCarry { get; set; }
|
||||||
|
|
||||||
|
// Runtime-only: combat tracking for the handicap (and killstreak abilities).
|
||||||
|
public int Kills { get; set; }
|
||||||
|
public int Deaths { get; set; }
|
||||||
|
public int Streak { get; set; } // current killstreak; resets on death
|
||||||
|
public int HeadshotKills { get; set; } // headshot kills this match — feeds the handicap's HS-rate factor
|
||||||
|
|
||||||
|
// Runtime-only (Gun Game mode): current weapon-ladder rung + kills banked toward clearing it. Persist across
|
||||||
|
// deaths within a match, reset on map start. NOT round-tripped in match_state — a mid-match reconnect
|
||||||
|
// restarts the climb at rung 0.
|
||||||
|
public int GgRung { get; set; }
|
||||||
|
public int GgRungKills { get; set; }
|
||||||
|
|
||||||
|
// Gun Game speedrun clock: wall-clock unix ms of the run's first damage dealt-or-taken (0 = not armed; reset on map
|
||||||
|
// start). Wall clock, not Server.CurrentTime, because it round-trips through match_state and must stay comparable
|
||||||
|
// across a changelevel/restart (the game clock restarts at ~0 there). Unlike GgRung, this DOES round-trip — a
|
||||||
|
// mid-match reconnect restarts the climb but never restarts the clock (the once-per-match anti-abuse rule).
|
||||||
|
public long GgRunStartedAtMs { get; set; }
|
||||||
|
|
||||||
|
// Runtime-only: killstreak ability state, indexed by AbilityRegistry index (0..N-1).
|
||||||
|
// AbilityReadyAt = Server.CurrentTime at which the ability comes off cooldown (survives death).
|
||||||
|
// AbilityActiveUntil = Server.CurrentTime until which the ability's effect is live (cleared on death).
|
||||||
|
// Sized to AbilitySlots (headroom over the current 5) so adding an AbilityRegistry row can't IndexOutOfRange; a
|
||||||
|
// startup guard (Initialize_Abilities) errors loudly if the registry ever exceeds this.
|
||||||
|
public const int AbilitySlots = 8;
|
||||||
|
public double[] AbilityReadyAt { get; } = new double[AbilitySlots];
|
||||||
|
public double[] AbilityActiveUntil { get; } = new double[AbilitySlots];
|
||||||
|
|
||||||
|
public PersistPlayer ToPersist() =>
|
||||||
|
new(SteamId, Name, Xp, Level, Prestige, Points, new Dictionary<string, int>(Upgrades),
|
||||||
|
PrimaryWeapon, SecondaryWeapon);
|
||||||
|
|
||||||
|
public MatchState ToMatchState() => new(SteamId, Kills, Deaths, Streak, HeadshotKills, GgRunStartedAtMs);
|
||||||
|
// GgRunStartedAtMs counts as activity: an armed-but-zero-kill speedrun clock must still reach match_state,
|
||||||
|
// or a disconnect before the first kill silently discards it (this predicate gates every match-state save).
|
||||||
|
public bool HasMatchActivity => Kills > 0 || Deaths > 0 || Streak > 0 || HeadshotKills > 0 || GgRunStartedAtMs > 0;
|
||||||
|
}
|
||||||
58
Outnumbered/Data/NpgsqlRepository.cs
Normal file
58
Outnumbered/Data/NpgsqlRepository.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Data.Common;
|
||||||
|
using Dapper;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace Outnumbered.Data;
|
||||||
|
|
||||||
|
// PostgreSQL backend — the PRODUCTION store (Provider="postgres"). Shares all queries with DapperPlayerRepository;
|
||||||
|
// supplies only the connection (Npgsql pooling per connection string; MVCC, no WAL/busy_timeout pragmas) and the schema
|
||||||
|
// DDL (BIGINT columns -> Dapper Int64 -> the same `long` row records). No drop-migration; later columns reach
|
||||||
|
// existing prod tables via the ADD COLUMN IF NOT EXISTS block (CREATE TABLE IF NOT EXISTS alone never alters them).
|
||||||
|
public sealed class NpgsqlRepository(string connectionString, string serverId) : DapperPlayerRepository(serverId)
|
||||||
|
{
|
||||||
|
private readonly string _connectionString = connectionString;
|
||||||
|
|
||||||
|
protected override async Task<DbConnection> OpenConnectionAsync()
|
||||||
|
{
|
||||||
|
var c = new NpgsqlConnection(_connectionString);
|
||||||
|
await c.OpenAsync();
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task EnsureSchemaAsync()
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS players(
|
||||||
|
steamid BIGINT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
xp BIGINT NOT NULL DEFAULT 0,
|
||||||
|
level BIGINT NOT NULL DEFAULT 1,
|
||||||
|
prestige BIGINT NOT NULL DEFAULT 0,
|
||||||
|
points BIGINT NOT NULL DEFAULT 1,
|
||||||
|
primary_weapon TEXT,
|
||||||
|
secondary_weapon TEXT,
|
||||||
|
best_wave BIGINT,
|
||||||
|
gg_best_ms BIGINT,
|
||||||
|
last_seen TIMESTAMPTZ);
|
||||||
|
CREATE TABLE IF NOT EXISTS upgrades(
|
||||||
|
steamid BIGINT NOT NULL,
|
||||||
|
stat_key TEXT NOT NULL,
|
||||||
|
level BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY(steamid, stat_key));
|
||||||
|
CREATE TABLE IF NOT EXISTS match_state(
|
||||||
|
server_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
steamid BIGINT NOT NULL,
|
||||||
|
kills BIGINT NOT NULL DEFAULT 0,
|
||||||
|
deaths BIGINT NOT NULL DEFAULT 0,
|
||||||
|
streak BIGINT NOT NULL DEFAULT 0,
|
||||||
|
headshot_kills BIGINT NOT NULL DEFAULT 0,
|
||||||
|
gg_run_started_at BIGINT,
|
||||||
|
PRIMARY KEY(server_id, steamid));
|
||||||
|
ALTER TABLE players ADD COLUMN IF NOT EXISTS best_wave BIGINT;
|
||||||
|
ALTER TABLE players ADD COLUMN IF NOT EXISTS gg_best_ms BIGINT;
|
||||||
|
ALTER TABLE match_state ADD COLUMN IF NOT EXISTS gg_run_started_at BIGINT;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
82
Outnumbered/Data/SqliteRepository.cs
Normal file
82
Outnumbered/Data/SqliteRepository.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#if WITH_SQLITE
|
||||||
|
using System.Data.Common;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace Outnumbered.Data;
|
||||||
|
|
||||||
|
// SQLite backend — the DEV store (Provider="sqlite"). Shares all queries with DapperPlayerRepository; supplies only the
|
||||||
|
// connection (WAL + busy_timeout so periodic flush / disconnect saves don't trip over each other) and the schema DDL.
|
||||||
|
public sealed class SqliteRepository : DapperPlayerRepository
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqliteRepository(string dbFilePath, string serverId) : base(serverId) =>
|
||||||
|
_connectionString = new SqliteConnectionStringBuilder { DataSource = dbFilePath }.ToString();
|
||||||
|
|
||||||
|
protected override async Task<DbConnection> OpenConnectionAsync()
|
||||||
|
{
|
||||||
|
var c = new SqliteConnection(_connectionString);
|
||||||
|
await c.OpenAsync();
|
||||||
|
await c.ExecuteAsync("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;");
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task EnsureSchemaAsync()
|
||||||
|
{
|
||||||
|
await using var c = await OpenConnectionAsync();
|
||||||
|
// Permanent progression — GLOBAL across all servers (shared leaderboard).
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS players(
|
||||||
|
steamid INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
prestige INTEGER NOT NULL DEFAULT 0,
|
||||||
|
points INTEGER NOT NULL DEFAULT 1,
|
||||||
|
primary_weapon TEXT,
|
||||||
|
secondary_weapon TEXT,
|
||||||
|
best_wave INTEGER,
|
||||||
|
gg_best_ms INTEGER,
|
||||||
|
last_seen TEXT);
|
||||||
|
CREATE TABLE IF NOT EXISTS upgrades(
|
||||||
|
steamid INTEGER NOT NULL,
|
||||||
|
stat_key TEXT NOT NULL,
|
||||||
|
level INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(steamid, stat_key));
|
||||||
|
""");
|
||||||
|
|
||||||
|
// match_state is PER-SERVER (composite key) + ephemeral; a one-time drop migrates off the old single-PK schema.
|
||||||
|
bool matchExists = await c.ExecuteScalarAsync<long>(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='match_state';") > 0;
|
||||||
|
bool hasServerId = matchExists && await c.ExecuteScalarAsync<long>(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('match_state') WHERE name='server_id';") > 0;
|
||||||
|
if (matchExists && !hasServerId) await c.ExecuteAsync("DROP TABLE match_state;");
|
||||||
|
await c.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS match_state(
|
||||||
|
server_id TEXT NOT NULL DEFAULT 'default',
|
||||||
|
steamid INTEGER NOT NULL,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deaths INTEGER NOT NULL DEFAULT 0,
|
||||||
|
streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
headshot_kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gg_run_started_at INTEGER,
|
||||||
|
PRIMARY KEY(server_id, steamid));
|
||||||
|
""");
|
||||||
|
|
||||||
|
await TryAddColumnAsync(c, "players", "primary_weapon", "TEXT");
|
||||||
|
await TryAddColumnAsync(c, "players", "secondary_weapon", "TEXT");
|
||||||
|
await TryAddColumnAsync(c, "players", "best_wave", "INTEGER");
|
||||||
|
await TryAddColumnAsync(c, "players", "gg_best_ms", "INTEGER");
|
||||||
|
await TryAddColumnAsync(c, "match_state", "gg_run_started_at", "INTEGER");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task TryAddColumnAsync(DbConnection c, string table, string col, string type)
|
||||||
|
{
|
||||||
|
try { await c.ExecuteAsync($"ALTER TABLE {table} ADD COLUMN {col} {type};"); }
|
||||||
|
catch (SqliteException) { /* column already exists — fine */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
16
Outnumbered/Domain/CardKeys.cs
Normal file
16
Outnumbered/Domain/CardKeys.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Keys for the survival "effect" cards — distinct from StatKeys so EffRun never folds them into a stat; only the effect
|
||||||
|
// logic reads them (per-player via the run's IStatBonusSource, or as the two TEAM cards via the driver's team
|
||||||
|
// multipliers). CSSharp-free so Domain + the test project can reference them without the engine.
|
||||||
|
public static class CardKeys
|
||||||
|
{
|
||||||
|
public const string ExplodeKill = "explode_kill"; // 1-pick flag: HE blast on every bot-kill (chains)
|
||||||
|
public const string Burn = "burn"; // 1-pick flag: on-hit DoT (flat, armor-skipping, per-attacker)
|
||||||
|
public const string AbilityCdr = "ability_cdr"; // -% killstreak-ability cooldown
|
||||||
|
public const string XpMult = "xp_mult"; // +% run-XP earned
|
||||||
|
public const string HsReduction = "hs_reduction"; // -% incoming headshot damage (per-player, leveled)
|
||||||
|
public const string BerserkPassive = "berserk_passive"; // +dmg scaled by missing HP (per-player, leveled)
|
||||||
|
public const string GlobalDeal = "global_deal"; // TEAM: +dmg dealt, squad-wide, compounding (into MDeal)
|
||||||
|
public const string GlobalTake = "global_take"; // TEAM: -dmg taken, squad-wide, compounding (into MTake)
|
||||||
|
}
|
||||||
78
Outnumbered/Domain/CombatResolver.cs
Normal file
78
Outnumbered/Domain/CombatResolver.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// The offense + defense multiplier chains — the SINGLE source of truth shared by the damage hook (applies them) and the
|
||||||
|
// HUD readout (shows them). Pure: the crit decision (which has engine side effects: a sound + the crit-XP marker) is made
|
||||||
|
// by the caller and passed in as `crit`; the HUD passes crit:false. Lifesteal/thorns amounts are here too so the reactive
|
||||||
|
// sustain glue applies identical numbers.
|
||||||
|
public static class CombatResolver
|
||||||
|
{
|
||||||
|
public static double CritChance(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs) =>
|
||||||
|
StatResolver.EffRun(s, StatKeys.CritChance, defs);
|
||||||
|
|
||||||
|
// The damage hook path: computes its own MDeal (one ComputeT per hit). Delegates to the mDeal-taking overload.
|
||||||
|
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
|
||||||
|
FrozenDictionary<string, StatDef> defs, in ResolvedHandicap rh, AbilitiesConfig ab, int baseMaxHp) =>
|
||||||
|
OffenseMultiplier(s, headshot, crit, defs, ab, baseMaxHp, HandicapModel.MDeal(s, rh));
|
||||||
|
|
||||||
|
// Overload taking a PRECOMPUTED MDeal — lets the HUD share ONE ComputeT across deal/take/xp (via HandicapModel.Bands).
|
||||||
|
// The missing-HP fraction (an EffRun lookup + a divide) is computed only when a Berserk consumer is actually active.
|
||||||
|
public static double OffenseMultiplier(in PlayerSnapshot s, bool headshot, bool crit,
|
||||||
|
FrozenDictionary<string, StatDef> defs, AbilitiesConfig ab, int baseMaxHp, double mDeal)
|
||||||
|
{
|
||||||
|
double dmgMul = 1.0 + StatResolver.EffRun(s, StatKeys.Damage, defs) / 100.0;
|
||||||
|
double hsMul = headshot ? 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, defs) / 100.0 : 1.0;
|
||||||
|
double critMul = crit ? 1.0 + StatResolver.EffRun(s, StatKeys.CritDamage, defs) / 100.0 : 1.0;
|
||||||
|
|
||||||
|
double abilityMul = 1.0;
|
||||||
|
double berserkCard = StatResolver.CardMag(s, CardKeys.BerserkPassive); // always-on passive card
|
||||||
|
bool needMissing = berserkCard > 0 || (ab.Enabled && s.BerserkActive); // the only consumers of `missing`
|
||||||
|
double missing = needMissing ? StatResolver.MissingHpFraction(s, StatResolver.MaxHp(s, defs, baseMaxHp)) : 0.0;
|
||||||
|
if (ab.Enabled)
|
||||||
|
{
|
||||||
|
if (s.OverchargeActive) abilityMul *= 1.0 + ab.Overcharge.Magnitude / 100.0;
|
||||||
|
if (s.BerserkActive)
|
||||||
|
{
|
||||||
|
abilityMul *= 1.0 + missing * ab.Berserk.Magnitude;
|
||||||
|
if (crit) critMul += missing * ab.Berserk.Magnitude2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (berserkCard > 0) abilityMul *= 1.0 + missing * berserkCard / 100.0;
|
||||||
|
|
||||||
|
return dmgMul * hsMul * critMul * abilityMul * mDeal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The damage hook path: computes its own MTake. Delegates to the mTake-taking overload.
|
||||||
|
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, in ResolvedHandicap rh, AbilitiesConfig ab) =>
|
||||||
|
DefenseMultiplier(s, headshot, ab, HandicapModel.MTake(s, rh));
|
||||||
|
|
||||||
|
// Overload taking a PRECOMPUTED MTake (the global_take team card is already folded in by HandicapModel).
|
||||||
|
public static double DefenseMultiplier(in PlayerSnapshot s, bool headshot, AbilitiesConfig ab, double mTake)
|
||||||
|
{
|
||||||
|
double take = mTake;
|
||||||
|
if (ab.Enabled && s.AdrenalineActive) take *= 1.0 - ab.Adrenaline.Magnitude / 100.0;
|
||||||
|
if (headshot)
|
||||||
|
{
|
||||||
|
double hsCut = StatResolver.CardMag(s, CardKeys.HsReduction); // armor doesn't help vs HS; this does
|
||||||
|
if (hsCut > 0) take *= Math.Max(0.0, 1.0 - hsCut / 100.0);
|
||||||
|
}
|
||||||
|
return take;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pure amounts the reactive-sustain glue applies (it still does the engine HP/armor writes) ----
|
||||||
|
// dmgHealth is a double so BOTH callers share one formula bit-identically: the on-hit path passes the int
|
||||||
|
// ev.DmgHealth (widens exactly) with the crit multiplier; the thorns-reflect path passes the FLOAT `dealt`
|
||||||
|
// (the offense-scaled reflected damage — keeps full precision, no pre-truncation) with critMult=1.0 (×1.0 is exact).
|
||||||
|
public static int LifestealHeal(double dmgHealth, double lifestealPct, double critMult, int minHeal) =>
|
||||||
|
Math.Max(minHeal, (int)Math.Round(dmgHealth * lifestealPct / 100.0 * critMult));
|
||||||
|
|
||||||
|
public static int ArmorLifestealGain(double dmgHealth, double armorLifestealPct, double critMult) =>
|
||||||
|
(int)Math.Round(dmgHealth * armorLifestealPct / 100.0 * critMult);
|
||||||
|
|
||||||
|
// % of the damage ACTUALLY taken (health + armor, so it already includes the MTake handicap that sized the hit — a 5x
|
||||||
|
// handicap 250 HP hit at 10% is 25). The caller deals this FLAT (DamageDealer flat) so the bot eats exactly this: no
|
||||||
|
// build and no MDeal are re-applied on the way out.
|
||||||
|
public static double ThornsReflect(int dmgHealth, int dmgArmor, double thornsPct) => (dmgHealth + dmgArmor) * thornsPct / 100.0;
|
||||||
|
}
|
||||||
102
Outnumbered/Domain/HandicapModel.cs
Normal file
102
Outnumbered/Domain/HandicapModel.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// The balance spine. A single signed index t in [-1,+1] from four normalised factors (K/D, headshot-kill rate,
|
||||||
|
// killstreak, level) plus the mode progress axis, then raised by the mode floor and eased by Curve. Deal/take/XP all
|
||||||
|
// lerp from the same t so they reach their extremes together:
|
||||||
|
// t = +1 -> deal MDealFloor, take MTakeCeiling, XP XpCeiling (a dominant player)
|
||||||
|
// t = 0 -> x1 across the board
|
||||||
|
// t = -1 -> deal MDealCeiling, take MTakeFloor, XP XpFloor (a struggling player)
|
||||||
|
// t = MasterDifficulty*(N - B): N = weighted nerf average, B = how far K/D sits below neutral (comeback help).
|
||||||
|
public static class HandicapModel
|
||||||
|
{
|
||||||
|
// The four nerf factors (K/D, headshot-rate, streak, level) + the mode progress axis are kept INLINE rather than a
|
||||||
|
// HandicapFactor registry: adding a factor is rare and would need a new weight in HandicapConfig + its override mirror
|
||||||
|
// regardless, so a list buys little. The config-invariant guard denominators + weight-sum are precomputed once per
|
||||||
|
// reload in ResolvedHandicap (below) so this per-hit/per-tick kernel doesn't redo 5 Math.Max + 4 adds each call.
|
||||||
|
public static double ComputeT(in PlayerSnapshot s, in ResolvedHandicap rh)
|
||||||
|
{
|
||||||
|
var h = rh.Config;
|
||||||
|
if (!h.Enabled) return 0.0;
|
||||||
|
|
||||||
|
double kdN = 0.0, buff = 0.0;
|
||||||
|
if (s.Kills + s.Deaths >= 3)
|
||||||
|
{
|
||||||
|
double r = s.Kills / (double)Math.Max(1, s.Deaths);
|
||||||
|
kdN = Clamp01((r - h.KdNeutral) / rh.KdNerfDenom);
|
||||||
|
buff = Clamp01((h.KdNeutral - r) / rh.KdBuffDenom);
|
||||||
|
}
|
||||||
|
double hsRate = s.Kills >= h.HsMinKills ? s.HeadshotKills / (double)Math.Max(1, s.Kills) : 0.0;
|
||||||
|
double hsN = Clamp01(hsRate / rh.HsDenom);
|
||||||
|
double streakN = Clamp01(s.Streak / rh.StreakDenom);
|
||||||
|
double levelN = Clamp01(s.Level / rh.LevelDenom);
|
||||||
|
double progN = Clamp01(s.HandicapProgress);
|
||||||
|
|
||||||
|
double n = rh.WSum <= 0 ? 0.0
|
||||||
|
: (h.KdWeight * kdN + h.HsWeight * hsN + h.StreakWeight * streakN + h.LevelWeight * levelN
|
||||||
|
+ h.ProgressWeight * progN) / rh.WSum;
|
||||||
|
|
||||||
|
double t = Math.Clamp(h.MasterDifficulty * (n - buff), -1.0, 1.0);
|
||||||
|
if (s.HandicapFloor > t) t = Math.Min(s.HandicapFloor, 1.0); // monotonic floor: never eases below the mode's escalation
|
||||||
|
return Ease(t, h.Curve);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Curve easing, shared by the live path (ComputeT) and the API curve sampler (BandsFromT) — one definition, no drift.
|
||||||
|
private static double Ease(double t, double curve) => t >= 0 ? Math.Pow(t, curve) : -Math.Pow(-t, curve);
|
||||||
|
|
||||||
|
// The three bands as pure functions of an already-computed t (so callers that need several can share ONE ComputeT).
|
||||||
|
private static double DealFromT(double t, HandicapConfig h, double teamDeal) =>
|
||||||
|
(t >= 0 ? Lerp(1.0, h.MDealFloor, t) : Lerp(1.0, h.MDealCeiling, -t)) * teamDeal;
|
||||||
|
private static double TakeFromT(double t, HandicapConfig h, double teamTake) =>
|
||||||
|
(t >= 0 ? Lerp(1.0, h.MTakeCeiling, t) : Lerp(1.0, h.MTakeFloor, -t)) * teamTake;
|
||||||
|
private static double XpFromT(double t, HandicapConfig h) =>
|
||||||
|
t >= 0 ? Lerp(1.0, h.XpCeiling, t) : Lerp(1.0, h.XpFloor, -t);
|
||||||
|
|
||||||
|
// Damage the player DEALS (x1 neutral). The survival team buff rides on top so it reaches every survivor + path.
|
||||||
|
public static double MDeal(in PlayerSnapshot s, in ResolvedHandicap rh) => DealFromT(ComputeT(s, rh), rh.Config, s.TeamDealMult);
|
||||||
|
// Damage the player TAKES (x1 neutral). The survival team buff multiplies it down for every survivor.
|
||||||
|
public static double MTake(in PlayerSnapshot s, in ResolvedHandicap rh) => TakeFromT(ComputeT(s, rh), rh.Config, s.TeamTakeMult);
|
||||||
|
public static double XpMult(in PlayerSnapshot s, in ResolvedHandicap rh) => XpFromT(ComputeT(s, rh), rh.Config);
|
||||||
|
|
||||||
|
// Deal + take + XP from ONE ComputeT — for the HUD, which shows all three per player per tick (the per-hit damage
|
||||||
|
// hook needs only one band, so it keeps calling MDeal/MTake directly). Bit-identical to calling all three.
|
||||||
|
public static void Bands(in PlayerSnapshot s, in ResolvedHandicap rh, out double deal, out double take, out double xp)
|
||||||
|
{
|
||||||
|
double t = ComputeT(s, rh);
|
||||||
|
deal = DealFromT(t, rh.Config, s.TeamDealMult);
|
||||||
|
take = TakeFromT(t, rh.Config, s.TeamTakeMult);
|
||||||
|
xp = XpFromT(t, rh.Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutral curve sample for a RAW index t in [-1,+1] (the ease is applied here; team multipliers stay 1): the balance
|
||||||
|
// API serializes dense arrays of these, so the site's theorycrafting curves come from the SAME compiled band code
|
||||||
|
// that scales live damage — they cannot drift from what players experience.
|
||||||
|
public static void BandsFromT(double t, in ResolvedHandicap rh, out double deal, out double take, out double xp)
|
||||||
|
{
|
||||||
|
// Same short-circuit as ComputeT: a disabled handicap IS flat x1 — sampled curves must say so too.
|
||||||
|
if (!rh.Config.Enabled) { deal = 1.0; take = 1.0; xp = 1.0; return; }
|
||||||
|
double e = Ease(Math.Clamp(t, -1.0, 1.0), rh.Config.Curve);
|
||||||
|
deal = DealFromT(e, rh.Config, 1.0);
|
||||||
|
take = TakeFromT(e, rh.Config, 1.0);
|
||||||
|
xp = XpFromT(e, rh.Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Clamp01(double v) => v < 0 ? 0 : v > 1 ? 1 : v;
|
||||||
|
private static double Lerp(double a, double b, double t) => a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandicapConfig + its config-invariant guard denominators / weight-sum, precomputed ONCE per reload so ComputeT —
|
||||||
|
// reached per damage hit + per HUD tick — doesn't redo 5 Math.Max + 4 adds each call. Two load-bearing constraints:
|
||||||
|
// - Keep `n = numerator / WSum` a DIVIDE, never `numerator * (1/WSum)` — the reciprocal differs in the last ULP.
|
||||||
|
// - StreakDenom/LevelDenom hold the EXACT (double)Math.Max(1, intField) value (int->double is exact here).
|
||||||
|
public readonly struct ResolvedHandicap(HandicapConfig c)
|
||||||
|
{
|
||||||
|
public readonly HandicapConfig Config = c;
|
||||||
|
public readonly double KdNerfDenom = Math.Max(0.01, c.KdMaxNerf - c.KdNeutral); // Max(0.01, KdMaxNerf - KdNeutral)
|
||||||
|
public readonly double KdBuffDenom = Math.Max(0.01, c.KdNeutral - c.KdMinBuff); // Max(0.01, KdNeutral - KdMinBuff)
|
||||||
|
public readonly double HsDenom = Math.Max(0.01, c.HsMaxNerf); // Max(0.01, HsMaxNerf)
|
||||||
|
public readonly double StreakDenom = Math.Max(1, c.StreakMaxNerf); // Max(1, StreakMaxNerf)
|
||||||
|
public readonly double LevelDenom = Math.Max(1, c.LevelMaxNerf); // Max(1, LevelMaxNerf)
|
||||||
|
public readonly double WSum = c.KdWeight + c.HsWeight + c.StreakWeight + c.LevelWeight + c.ProgressWeight; // KdWeight + HsWeight + StreakWeight + LevelWeight + ProgressWeight
|
||||||
|
}
|
||||||
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal file
37
Outnumbered/Domain/PlayerSnapshot.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// Per-stat run-scoped bonus (the survival cards). Implemented engine-side by the active driver; the domain only sees
|
||||||
|
// this interface, so it stays pure. Returns 0 for any key without a bonus (and outside a survival run).
|
||||||
|
public interface IStatBonusSource
|
||||||
|
{
|
||||||
|
double Bonus(string statKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An immutable read of everything the pure domain math needs about one player, built at the single engine site
|
||||||
|
// (SnapshotBuilder). The domain never touches a pawn/controller — only this. Upgrades is held by reference (not copied)
|
||||||
|
// so building a snapshot per hit/tick stays allocation-free; the dictionary is treated as read-only here.
|
||||||
|
public readonly struct PlayerSnapshot
|
||||||
|
{
|
||||||
|
public int Level { get; init; }
|
||||||
|
public int Prestige { get; init; }
|
||||||
|
public long Xp { get; init; }
|
||||||
|
|
||||||
|
public int Kills { get; init; }
|
||||||
|
public int Deaths { get; init; }
|
||||||
|
public int HeadshotKills { get; init; }
|
||||||
|
public int Streak { get; init; }
|
||||||
|
|
||||||
|
public int Health { get; init; } // current HP (0 = no live pawn captured) — drives missing-HP (Berserk)
|
||||||
|
|
||||||
|
public double HandicapProgress { get; init; } // mode progress axis, 0..1 (Gun Game ladder; 0 in TDM)
|
||||||
|
public double HandicapFloor { get; init; } // monotonic escalation floor in t-space; -1 = no floor
|
||||||
|
public double TeamDealMult { get; init; } // survival team card (squad-wide); 1.0 = none
|
||||||
|
public double TeamTakeMult { get; init; } // survival team card (squad-wide); 1.0 = none
|
||||||
|
|
||||||
|
public bool OverchargeActive { get; init; } // killstreak abilities that bend the damage chains
|
||||||
|
public bool BerserkActive { get; init; }
|
||||||
|
public bool AdrenalineActive { get; init; }
|
||||||
|
|
||||||
|
public Dictionary<string, int> Upgrades { get; init; } // permanent stat levels, by key (the PlayerData instance, by ref)
|
||||||
|
public IStatBonusSource? Cards { get; init; } // run-card bonus per stat key; null outside survival
|
||||||
|
}
|
||||||
16
Outnumbered/Domain/ProgressionModel.cs
Normal file
16
Outnumbered/Domain/ProgressionModel.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// XP/level/prestige curves (pure). The stateful level-up loop + its side effects (chat/sound/clan) stay engine-side;
|
||||||
|
// this owns only the formulas it walks.
|
||||||
|
public static class ProgressionModel
|
||||||
|
{
|
||||||
|
// XP required to go from `level` to `level+1`. LevelXpExponent shapes the curve (1 = linear, >1 = accelerating).
|
||||||
|
public static long XpToNext(int level, ProgressionConfig c) =>
|
||||||
|
c.LevelXpBase + (long)Math.Round(c.LevelXpStep * Math.Pow(Math.Max(0, level - 1), c.LevelXpExponent));
|
||||||
|
|
||||||
|
// Cumulative prestige XP boost (prestige never lowers difficulty; it only speeds the climb).
|
||||||
|
public static double PrestigeXpMultiplier(int prestige, ProgressionConfig c) =>
|
||||||
|
1.0 + prestige * (c.PrestigeXpBoostPercent / 100.0);
|
||||||
|
}
|
||||||
20
Outnumbered/Domain/StatKeys.cs
Normal file
20
Outnumbered/Domain/StatKeys.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Canonical stat keys (upgrade dictionary keys + JSON stat ids). CSSharp-free so the Domain layer + the test project
|
||||||
|
// can reference them without the engine. Kept in the root Outnumbered namespace (not Domain) for back-compat with the
|
||||||
|
// many engine-side call sites that use the unqualified name.
|
||||||
|
public static class StatKeys
|
||||||
|
{
|
||||||
|
public const string Damage = "damage";
|
||||||
|
public const string CritChance = "crit_chance";
|
||||||
|
public const string CritDamage = "crit_damage";
|
||||||
|
public const string HeadshotDamage = "hs_damage";
|
||||||
|
public const string MaxHp = "max_hp";
|
||||||
|
public const string MaxArmor = "max_armor";
|
||||||
|
public const string Lifesteal = "lifesteal";
|
||||||
|
public const string ArmorLifesteal = "armor_lifesteal";
|
||||||
|
public const string HpRegen = "hp_regen";
|
||||||
|
public const string ArmorRegen = "armor_regen";
|
||||||
|
public const string Thorns = "thorns";
|
||||||
|
public const string XpBoost = "xp_boost";
|
||||||
|
}
|
||||||
41
Outnumbered/Domain/StatResolver.cs
Normal file
41
Outnumbered/Domain/StatResolver.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// Resolves a player's effective stat values from their invested levels + run-card bonuses. Takes a stat-def lookup
|
||||||
|
// (key -> StatDef) rather than the named-field config, so it's already shaped for the stat registry — the engine builds
|
||||||
|
// the dictionary once and passes it. `Eff` = permanent only; `EffRun` = permanent + survival card; `CardMag` = the
|
||||||
|
// effect-card magnitude (a non-stat key, cards only).
|
||||||
|
public static class StatResolver
|
||||||
|
{
|
||||||
|
private static readonly StatDef Zero = new(0, 0);
|
||||||
|
|
||||||
|
private static int LevelOf(in PlayerSnapshot s, string key) =>
|
||||||
|
s.Upgrades is not null && s.Upgrades.TryGetValue(key, out var l) ? l : 0;
|
||||||
|
|
||||||
|
public static double Eff(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs)
|
||||||
|
{
|
||||||
|
var d = defs.TryGetValue(key, out var def) ? def : Zero;
|
||||||
|
return d.Base + LevelOf(s, key) * d.PerLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double EffRun(in PlayerSnapshot s, string key, FrozenDictionary<string, StatDef> defs) =>
|
||||||
|
Eff(s, key, defs) + (s.Cards?.Bonus(key) ?? 0.0);
|
||||||
|
|
||||||
|
public static double CardMag(in PlayerSnapshot s, string key) => s.Cards?.Bonus(key) ?? 0.0;
|
||||||
|
|
||||||
|
public static int MaxHp(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
|
||||||
|
baseMax + (int)EffRun(s, StatKeys.MaxHp, defs);
|
||||||
|
|
||||||
|
public static int MaxArmor(in PlayerSnapshot s, FrozenDictionary<string, StatDef> defs, int baseMax) =>
|
||||||
|
baseMax + (int)EffRun(s, StatKeys.MaxArmor, defs);
|
||||||
|
|
||||||
|
// 0 at full HP, 1 near death — drives Berserk (ability + passive card). Health<=0 (a snapshot built with no live
|
||||||
|
// pawn, or a downed pawn) yields 0, NOT 1: missing-HP scaling only applies to a live attacker, so "no pawn" must
|
||||||
|
// mean "no bonus", not "max bonus" (guards the pd-less Snapshot path where Health defaults to 0).
|
||||||
|
public static double MissingHpFraction(in PlayerSnapshot s, int maxHp) =>
|
||||||
|
// Health>=1 (guarded) and maxHp>=1 => 1 - Health/maxHp < 1, so the upper clamp can't bind; only the lower
|
||||||
|
// (overheal -> negative) is live. Math.Max(0, x) is bit-identical to Math.Clamp(x, 0, 1) here.
|
||||||
|
maxHp <= 0 || s.Health <= 0 ? 0.0 : Math.Max(0.0, 1.0 - s.Health / (double)maxHp);
|
||||||
|
}
|
||||||
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal file
48
Outnumbered/Domain/SurvivalEconomy.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered.Domain;
|
||||||
|
|
||||||
|
// Pure survival run-economy + escalation curves. The wave state machine + run banking stay engine-side; this owns the
|
||||||
|
// numbers they read.
|
||||||
|
public static class SurvivalEconomy
|
||||||
|
{
|
||||||
|
// Bots ALIVE at once this wave (simultaneous pressure), ramping from AliveBase to AliveCap.
|
||||||
|
public static int AliveForWave(int wave, SurvivalConfig c) =>
|
||||||
|
Math.Clamp(c.AliveBase + c.AlivePerWave * (wave - 1), 1, c.AliveCap);
|
||||||
|
|
||||||
|
// Total kills to clear a wave.
|
||||||
|
public static int WaveBudget(int wave, int aliveHumans, SurvivalConfig c) =>
|
||||||
|
Math.Max(1, c.BudgetBase + c.BudgetPerWave * (wave - 1) + c.BudgetPerPlayer * aliveHumans);
|
||||||
|
|
||||||
|
// Monotonic escalate-only handicap floor in t-space; -1 = no floor (idle / between runs).
|
||||||
|
public static double HandicapFloor(int wave, SurvivalConfig c) =>
|
||||||
|
// wave>=1 and Max(1,MaxNerfWave)>=1 => quotient>=0, so the lower clamp can never bind; Min(1,x) == Clamp(x,0,1) here.
|
||||||
|
wave <= 0 ? -1.0 : Math.Min(1.0, wave / (double)Math.Max(1, c.MaxNerfWave));
|
||||||
|
|
||||||
|
// Per-wave XP multiplier: ramps exponentially from x1 (wave 1) to xWinMult (the final wave). WinMult is the single
|
||||||
|
// knob (the FINAL-wave / "win" multiplier). Back-loaded by design so early waves pay ~nothing and the last few pay big.
|
||||||
|
// waveMult(w) = WinMult ^ ((w-1)/(WaveCount-1))
|
||||||
|
public static double WaveMult(int wave, SurvivalConfig c) =>
|
||||||
|
Math.Pow(c.WinMult, (wave - 1) / (double)Math.Max(1, c.WaveCount - 1));
|
||||||
|
|
||||||
|
// XP granted at the END of ONE cleared wave: raw wave XP (HP-damage + HS/crit, already xp_mult-card-scaled when banked)
|
||||||
|
// x prestige x the wave multiplier. NO per-run cap, NO XpBoost stat, NO handicap mult here — depth is the gate (you must
|
||||||
|
// survive + contribute to reach the big back-wave multipliers), and the handicap mult stays excluded so run XP can't
|
||||||
|
// re-couple to gameable K/D. 0 for a non-positive wave.
|
||||||
|
public static long WaveXpLump(double rawWaveXp, int wave, int prestige, SurvivalConfig c, ProgressionConfig p)
|
||||||
|
{
|
||||||
|
if (rawWaveXp <= 0) return 0;
|
||||||
|
double lump = rawWaveXp * ProgressionModel.PrestigeXpMultiplier(prestige, p) * WaveMult(wave, c);
|
||||||
|
return (long)Math.Floor(lump);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw combat XP banked into the CURRENT wave's accumulator, scaled by the xp_mult card (so it compounds with the
|
||||||
|
// per-wave prestige x waveMult chain applied at grant time).
|
||||||
|
public static double AccrueWaveXp(double amount, double xpMultCardPct) =>
|
||||||
|
amount * (1.0 + xpMultCardPct / 100.0);
|
||||||
|
|
||||||
|
// Team-card squad multiplier (compounding): global_deal increases, global_take decreases.
|
||||||
|
public static double TeamMult(int level, double perPickPct, bool increase) =>
|
||||||
|
increase ? Math.Pow(1.0 + perPickPct / 100.0, level)
|
||||||
|
: Math.Pow(Math.Max(0.0, 1.0 - perPickPct / 100.0), level);
|
||||||
|
}
|
||||||
419
Outnumbered/Driver.cs
Normal file
419
Outnumbered/Driver.cs
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Shared match plumbing. Runs on the Deathmatch gamemode base (game_type 1 game_mode 2): native DM handles
|
||||||
|
// respawn + weapon deployment (so bots don't get stuck trying to buy); we layer on forced teams (humans CT vs
|
||||||
|
// bots T), squad loadouts, the human cap, objective removal, and kill-goal map rotation.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private bool _mapEnding;
|
||||||
|
internal bool MapEnding => _mapEnding; // drivers gate match-result writes on this (a win landing in the map-end grace window isn't a result)
|
||||||
|
private bool _seenMapStart; // false only until the FIRST map start after plugin load — the restart-restore window OnMapStart_Driver protects
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _botTimer;
|
||||||
|
private IMatchDriver _driver = null!; // the active mode driver, chosen from the launch flag / Config.Mode
|
||||||
|
private IDraftDriver? Draft => _driver as IDraftDriver; // survival's draft/run capability; null in TDM/GG (no concrete casts)
|
||||||
|
private bool _ggTimerActive; // GG speedrun clock arms on first damage — a plain bool because the damage hook checks it per hit
|
||||||
|
|
||||||
|
// NOTE: we deliberately DON'T remove func_buyzone — bots' buy AI loops "trying to buy outside
|
||||||
|
// buy zone" if it's gone. Buying is already dead via mp_buytime 0 / mp_maxmoney 0.
|
||||||
|
private static readonly string[] ObjectiveEntities =
|
||||||
|
{ "func_bomb_target", "func_hostage_rescue", "hostage_entity", "weapon_c4", "planted_c4" };
|
||||||
|
|
||||||
|
// Mode REGISTRY — canonical id -> driver factory (the single place modes are wired). ResolveMode normalizes the
|
||||||
|
// launch flag / JSON to one of these ids; an unrecognised id -> UnbuiltMode (TDM + a warning). Add a mode = one row.
|
||||||
|
private static readonly Dictionary<string, Func<OutnumberedPlugin, IMatchDriver>> ModeRegistry = new()
|
||||||
|
{
|
||||||
|
["tdm"] = p => new TdmDriver(p),
|
||||||
|
["gungame"] = p => new GunGameDriver(p),
|
||||||
|
["survival"] = p => new SurvivalDriver(p),
|
||||||
|
};
|
||||||
|
|
||||||
|
private void Initialize_Driver()
|
||||||
|
{
|
||||||
|
string mode = ResolveMode();
|
||||||
|
_driver = ModeRegistry.TryGetValue(mode, out var make) ? make(this) : UnbuiltMode(mode); // unknown id -> warn + TDM
|
||||||
|
_ggTimerActive = _driver is GunGameDriver;
|
||||||
|
RebuildEffectiveHandicap();
|
||||||
|
_driver.OnActivated(); // survival starts its wave state machine here; no-op for TDM/GG
|
||||||
|
Logger.LogInformation("Outnumbered match driver: {Mode}", _driver.Id);
|
||||||
|
|
||||||
|
// Every ModeRegistry id must round-trip through NormalizeMode — else a mode whose canonical id is itself an alias for
|
||||||
|
// a DIFFERENT mode would be permanently unreachable (selection runs through NormalizeMode first). Adding a mode is one
|
||||||
|
// ModeRegistry row + its numeric/short aliases in NormalizeMode; this catches the "added the row, forgot the alias collides" slip.
|
||||||
|
foreach (var id in ModeRegistry.Keys)
|
||||||
|
if (NormalizeMode(id) is var mapped && mapped != id)
|
||||||
|
Logger.LogError("Outnumbered: ModeRegistry id '{Id}' does not round-trip through NormalizeMode (maps to '{Mapped}') — that mode is unreachable.", id, mapped);
|
||||||
|
|
||||||
|
RegisterListener<Listeners.OnMapStart>(OnMapStart_Driver);
|
||||||
|
RegisterListener<Listeners.OnClientPutInServer>(_ => SyncBots());
|
||||||
|
|
||||||
|
RegisterEventHandler<EventRoundStart>(OnRoundStart_Driver);
|
||||||
|
RegisterEventHandler<EventPlayerDeath>(OnPlayerDeath_Driver, HookMode.Pre);
|
||||||
|
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Driver);
|
||||||
|
RegisterEventHandler<EventPlayerTeam>(OnPlayerTeam_Driver);
|
||||||
|
|
||||||
|
_botTimer = AddTimer(5.0f, SyncBots, TimerFlags.REPEAT);
|
||||||
|
AddTimer(1.0f, () => SetupMap(Server.MapName)); // covers a hot-reload mid-map
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Driver()
|
||||||
|
{
|
||||||
|
_botTimer?.Kill();
|
||||||
|
_driver?.OnDeactivated(); // tear down driver-owned timers (survival's wave heartbeat) so they don't leak on hot-reload
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode selection: a launch flag wins (so ONE shared outnumbered.json can serve every instance — just vary
|
||||||
|
// the flag per server), else the JSON "Mode", else tdm. Flag: `-outnumbered_mode <id>` (also accepts `+`),
|
||||||
|
// parsed straight from the process command line. The id may be a NAME or a NUMBER (whichever you prefer):
|
||||||
|
// 0|tdm, 1|gg|gungame, 2|survival.
|
||||||
|
// NOTE on game_type/game_mode: we deliberately DON'T select off those. All our modes run on the SAME
|
||||||
|
// Deathmatch base (game_type 1 game_mode 2) — they'd be identical — and launching CS2's native slots (e.g.
|
||||||
|
// game_mode 0 = Arms Race) would boot the engine's own ruleset, which fights our custom one. So the base
|
||||||
|
// stays DM for every mode and this dedicated flag does the picking.
|
||||||
|
private string ResolveMode()
|
||||||
|
{
|
||||||
|
string raw = "tdm", src = "default";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tag = ArgValue(CommandLineArgs(), "outnumbered_mode");
|
||||||
|
if (!string.IsNullOrWhiteSpace(tag)) { raw = tag; src = "launch flag"; }
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: reading launch args failed; using JSON Mode"); }
|
||||||
|
|
||||||
|
if (src == "default" && !string.IsNullOrWhiteSpace(Config.Mode)) { raw = Config.Mode; src = "config"; }
|
||||||
|
string mode = NormalizeMode(raw);
|
||||||
|
Logger.LogInformation("Outnumbered mode: {Mode} (from {Src}: '{Raw}')", mode, src, raw);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical mode id from a name or a number (so the flag/JSON can use either form). Unknown passes through
|
||||||
|
// (Initialize_Driver's ModeRegistry lookup misses it -> UnbuiltMode -> TDM with a warning).
|
||||||
|
private static string NormalizeMode(string s) => s.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"0" or "tdm" => "tdm",
|
||||||
|
"1" or "gg" or "gungame" => "gungame",
|
||||||
|
"2" or "survival" or "wave" => "survival",
|
||||||
|
var other => other,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A recognised-but-not-yet-built (or unrecognised) mode: log loudly and run TDM so the server still comes up.
|
||||||
|
private IMatchDriver UnbuiltMode(string requested)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("Outnumbered: mode '{Mode}' is not available yet — running TDM instead.", requested);
|
||||||
|
return new TdmDriver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- map / round setup ----
|
||||||
|
|
||||||
|
private void OnMapStart_Driver(string mapName)
|
||||||
|
{
|
||||||
|
bool roundEnded = _mapEnding; // true only if a kill-goal map change brought us here (the flag persists across changelevel)
|
||||||
|
_mapEnding = false;
|
||||||
|
// K/D + streak + ability state are per-match — reset everyone in memory across the changelevel.
|
||||||
|
foreach (var pd in _players.Values)
|
||||||
|
{
|
||||||
|
pd.Kills = 0; pd.Deaths = 0; pd.Streak = 0; pd.HeadshotKills = 0; pd.GgRung = 0; pd.GgRungKills = 0;
|
||||||
|
pd.GgRunStartedAtMs = 0; // new match, new speedrun clock
|
||||||
|
Array.Clear(pd.AbilityReadyAt); Array.Clear(pd.AbilityActiveUntil);
|
||||||
|
}
|
||||||
|
_driver.OnMatchReset(); // clear per-match driver state (e.g. Gun Game bot ladder progress)
|
||||||
|
// Every map change is a match boundary -> wipe the DB match table. The ONLY protected window is the first map
|
||||||
|
// start after plugin load: that's the restart-restore case (a graceful shutdown dumped mid-match state for
|
||||||
|
// reconnecting players). Without the _seenMapStart arm, a manual/external changelevel left leavers' stale rows
|
||||||
|
// behind — restorable next match, complete with a cross-match GG speedrun clock.
|
||||||
|
if (roundEnded || _seenMapStart) WipeMatch();
|
||||||
|
_seenMapStart = true;
|
||||||
|
AddTimer(3.0f, () => SetupMap(mapName)); // entities/gamerules aren't ready at the instant of map start
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetupMap(string mapName)
|
||||||
|
{
|
||||||
|
if (!_live) return; // scheduled 1-3s out; skip if the plugin was unloaded/hot-reloaded in the meantime
|
||||||
|
ApplyCvars();
|
||||||
|
RemoveObjectives();
|
||||||
|
SyncBots();
|
||||||
|
Rules.MakeRoundEndless();
|
||||||
|
_engineReady = true; // engine statics are safe from here on — opens the status-payload rebuild (Api.cs)
|
||||||
|
_driver.OnMapSetup(); // map is up + simulating -> safe for the driver to touch the engine (survival arms its wave machine)
|
||||||
|
Logger.LogInformation("Outnumbered {Mode} driver active on {Map}", _driver.Id, mapName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HookResult OnRoundStart_Driver(EventRoundStart ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
ApplyCvars(); // some cvars reset per round
|
||||||
|
RemoveObjectives(); // objective entities respawn per round
|
||||||
|
SyncBots();
|
||||||
|
Server.NextFrame(Rules.MakeRoundEndless); // after the game has set the round timer from the cvar, override it
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCvars()
|
||||||
|
{
|
||||||
|
Server.ExecuteCommand(
|
||||||
|
// Team deathmatch, never FFA; no friendly fire
|
||||||
|
"mp_dm_teammode 1;mp_teammates_are_enemies 0;mp_friendlyfire 0;" +
|
||||||
|
"ff_damage_reduction_bullets 0;ff_damage_reduction_grenade 0;ff_damage_reduction_other 0;" +
|
||||||
|
"mp_autoteambalance 0;mp_limitteams 0;" +
|
||||||
|
// solo human on CT = "team wiped" on death -> suppress round-win so play is continuous
|
||||||
|
"mp_ignore_round_win_conditions 1;mp_roundtime 60;mp_roundtime_deployment 0;" +
|
||||||
|
// native DM respawn handles respawning AND weapon deployment (so bots don't try to buy)
|
||||||
|
$"mp_respawn_on_death_ct 1;mp_respawn_on_death_t 1;mp_respawn_immunitytime {Config.Match.SpawnProtectionSeconds};" +
|
||||||
|
"mp_freezetime 0;mp_timelimit 0;mp_warmuptime 3;" +
|
||||||
|
// no economy / no objectives / no dropped clutter / no DM bonus-weapon prompt
|
||||||
|
// mp_buy_anywhere 1: whole map counts as a buy zone, so bots' buy AI never loops on
|
||||||
|
// "outside buy zone" (they have $0 via maxmoney/startmoney, so they buy nothing anyway).
|
||||||
|
"mp_buytime 0;mp_buy_during_immunity 0;mp_maxmoney 0;mp_startmoney 0;mp_buy_anywhere 1;mp_buy_allow_grenades 0;mp_free_armor 0;" +
|
||||||
|
// raise the grenade carry cap (default 4) so all 5 ability-key grenades can be held at once
|
||||||
|
"ammo_grenade_limit_total 5;" +
|
||||||
|
"mp_give_player_c4 0;mp_hostages_max 0;mp_death_drop_gun 0;mp_death_drop_grenade 0;mp_death_drop_defuser 0;" +
|
||||||
|
"mp_dm_bonus_length_max 0;mp_dm_bonus_length_min 0;mp_dm_time_between_bonus_max 9999;mp_dm_time_between_bonus_min 9999;" +
|
||||||
|
// bots: forced to T, fixed difficulty, rogues allowed (lone-wolf instead of squadding up)
|
||||||
|
"bot_quota_mode normal;bot_join_team t;sv_auto_adjust_bot_difficulty 0;bot_chatter off;bot_allow_rogues 1;" +
|
||||||
|
$"bot_difficulty {Config.Match.BotDifficulty};custom_bot_difficulty {Config.Match.BotDifficulty};" +
|
||||||
|
_driver.ExtraCvars); // mode-specific cvars (e.g. GG mp_randomspawn to scatter the horde)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveObjectives()
|
||||||
|
{
|
||||||
|
foreach (var name in ObjectiveEntities)
|
||||||
|
foreach (var ent in Utilities.FindAllEntitiesByDesignerName<CEntityInstance>(name))
|
||||||
|
if (ent is { IsValid: true }) ent.Remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bots ----
|
||||||
|
|
||||||
|
private void SyncBots()
|
||||||
|
{
|
||||||
|
// Survival owns bot population (wave spawning) — the steady-state quota model steps aside.
|
||||||
|
if (_driver.OwnsBotPopulation) { _driver.ManageBots(); return; }
|
||||||
|
|
||||||
|
var players = Utilities.GetPlayers();
|
||||||
|
int humans = players.Count(IsHuman);
|
||||||
|
int desired = Math.Clamp(humans * Config.Match.BotsPerHuman, 0, Config.Match.MaxBots);
|
||||||
|
Server.ExecuteCommand($"bot_quota {desired}");
|
||||||
|
ForceBotsToTerrorist(players); // force any bots that landed on CT back to T (bot_join_team only affects new bots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- kill tracking for the map-end goal (respawn itself is native DM) ----
|
||||||
|
|
||||||
|
private HookResult OnPlayerDeath_Driver(EventPlayerDeath ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var victim = ev.Userid;
|
||||||
|
var attacker = ev.Attacker;
|
||||||
|
// a "real" kill = a valid attacker killing a different player (not suicide / world death)
|
||||||
|
bool realKill = attacker is { IsValid: true } && victim is { IsValid: true } && attacker.Slot != victim.Slot;
|
||||||
|
|
||||||
|
// a bot died -> drop its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire
|
||||||
|
if (IsBot(victim)) ClearBurnsForBot(victim.Slot);
|
||||||
|
// drop any crit flag the offense hook set but EventPlayerHurt never consumed (overkill collapse / 0-HP-damage hit)
|
||||||
|
if (victim is { IsValid: true }) _critPending.Remove(victim.Slot);
|
||||||
|
|
||||||
|
// victim death (human): deaths++, killstreak reset
|
||||||
|
if (victim is { IsValid: true } && !victim.IsBot && PdOf(victim) is { } vpd)
|
||||||
|
{
|
||||||
|
vpd.Deaths++; vpd.Streak = 0;
|
||||||
|
// streak-earned availability is lost on death; active effects end. Cooldowns keep ticking (spec §4).
|
||||||
|
Array.Clear(vpd.AbilityActiveUntil);
|
||||||
|
_driver.OnHumanDeath(victim, vpd); // survival: run wipe detection (no respawn; revived at wave-clear). No-op elsewhere.
|
||||||
|
}
|
||||||
|
|
||||||
|
// attacker kill: human -> kills++/streak/XP + mode result; bot -> mode result only (bots have no PlayerData)
|
||||||
|
if (realKill && !attacker!.IsBot)
|
||||||
|
{
|
||||||
|
if (PdOf(attacker) is { } apd) // the cold death path resolves via the seam (the hot damage hook is the perf-exempt one)
|
||||||
|
{
|
||||||
|
apd.Kills++; apd.Streak++;
|
||||||
|
if (ev.Headshot) apd.HeadshotKills++; // feeds the handicap's headshot-rate factor
|
||||||
|
// ding when this kill makes an ability newly castable (streak hit its exact threshold)
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
if (apd.Streak == AbilityCfg(i).StreakReq) { PlaySound(attacker, Config.Sounds.AbilityReady); break; }
|
||||||
|
_driver.OnHumanKill(attacker, apd); // mode result: TDM kill-goal / Gun Game rung-advance + win
|
||||||
|
|
||||||
|
// Explode-on-Kill card (survival): drop a real HE blast on the bot's corpse, deferred a frame OUT of
|
||||||
|
// this death hook. Chains for free — a blast kill fires its own death event -> back here -> re-explodes.
|
||||||
|
if (IsBot(victim) && EffectCardMag(apd, CardKeys.ExplodeKill) > 0
|
||||||
|
&& victim.PlayerPawn.Value?.AbsOrigin is { } corpse
|
||||||
|
&& attacker.AuthorizedSteamID?.SteamId64 is { } sid) // pinned id for the deferred identity re-validation
|
||||||
|
{
|
||||||
|
var pos = new Vector(corpse.X, corpse.Y, corpse.Z);
|
||||||
|
// defer a frame OUT of this death hook; re-validate the attacker's identity (slot reuse within the frame)
|
||||||
|
NextFrameForSlot(attacker.Slot, sid, a => ExplodeAt(a, pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GrantKillXp(attacker); // step 4 progression (XP scaled by the handicap)
|
||||||
|
}
|
||||||
|
else if (realKill && attacker!.IsBot)
|
||||||
|
{
|
||||||
|
_driver.OnBotKill(attacker); // Gun Game: bots climb the ladder too (so players can lose); no-op in TDM
|
||||||
|
}
|
||||||
|
|
||||||
|
// headshot demotion: the victim loses a kill of ladder progress (Gun Game; no-op in TDM)
|
||||||
|
if (realKill && ev.Headshot && victim is { IsValid: true })
|
||||||
|
_driver.OnHeadshotDeath(victim);
|
||||||
|
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loadout (override DM's deployment with our loadout) ----
|
||||||
|
|
||||||
|
private HookResult OnPlayerSpawn_Driver(EventPlayerSpawn ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (p is not { IsValid: true } || p.IsHLTV) return HookResult.Continue;
|
||||||
|
bool isBot = p.IsBot;
|
||||||
|
NextFrameForSlot(p.Slot, pl => ApplyLoadout(pl, isBot), requireAlive: true);
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn loadout. The bot branch + the knife/armor tail are shared across modes; the human weapons are the
|
||||||
|
// mode driver's call (TDM: chosen primary+secondary+shop carriers; Gun Game: the current ladder rung).
|
||||||
|
// Also reused mid-life by Gun Game to hand over the next rung's gun on a kill.
|
||||||
|
// Ability-key grenades are NOT given here — they're granted only while an ability is castable
|
||||||
|
// (see Abilities.cs ReconcileGrenades) so the key is live exactly when the ability is, and never throwable.
|
||||||
|
internal void ApplyLoadout(CCSPlayerController p, bool isBot)
|
||||||
|
{
|
||||||
|
p.RemoveWeapons();
|
||||||
|
if (isBot) _driver.GiveBotLoadout(p); // TDM: fixed squad; Gun Game: the bot's current ladder rung
|
||||||
|
else _driver.GiveHumanLoadout(p);
|
||||||
|
p.GiveNamedItem(EngineNames.WeaponKnife);
|
||||||
|
p.GiveNamedItem(EngineNames.ItemAssaultSuit); // kevlar + helmet — grants exactly 100 armor
|
||||||
|
// item_assaultsuit forces armor to 100, so a player whose MaxArmor stat exceeds that must be topped back up to
|
||||||
|
// their real cap right HERE, after the suit. (The separate spawn cap-applier races the suit and loses if it runs
|
||||||
|
// first; doing it inline makes this the deterministic last writer.) Bots keep the suit's flat 100.
|
||||||
|
if (!isBot && p.PlayerPawn.Value is { Health: > 0 } pawn && PdOf(p) is { } pd)
|
||||||
|
PawnWriter.SetArmor(pawn, MaxArmorOf(pd));
|
||||||
|
|
||||||
|
// Vanilla-parity fire gate. The rebuild deploys a FRESH weapon entity whose initial-deploy gate opens later
|
||||||
|
// than a native switch — and bots fire on the first legal tick, so a human loses every same-window draw race
|
||||||
|
// (worst at a GG rank-up, where the kill triggers regives on both sides). One frame after the deploy settles,
|
||||||
|
// open the gate: the draw still animates, but firing is allowed immediately (the cs2-gungame / CS2-Deathmatch
|
||||||
|
// FastWeaponEquip pattern). Humans only — bots already fire at the gate.
|
||||||
|
if (!isBot) NextFrameForSlot(p.Slot, OpenFireGate, requireAlive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Melee/zeus/carrier keep native timing: the gate only matters for guns, and the knife finale should stay a fair
|
||||||
|
// knife fight. (Ability grenades can't be in hand here — they're granted later, only while castable.)
|
||||||
|
private static void OpenFireGate(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var w = Inventory.ActiveWeapon(p);
|
||||||
|
if (w is null) return;
|
||||||
|
string name = w.DesignerName ?? "";
|
||||||
|
if (Inventory.IsMeleeOrZeus(name) || name == EngineNames.ShopCarrier) return;
|
||||||
|
w.NextPrimaryAttackTick = Server.TickCount + 1;
|
||||||
|
Utilities.SetStateChanged(w, EngineNames.CBasePlayerWeapon, EngineNames.NextPrimaryAttackTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two shop items every human spawns with when the quick-buy is on: the healthshot carrier (X = open menu) and the
|
||||||
|
// zeus (key 3 + the rest anchor that stops grenades auto-deploying over the knife). One definition; each driver's human
|
||||||
|
// loadout calls it at the same spot (so it stays bit-identical — Gun Game still skips it on an empty ladder via its own
|
||||||
|
// earlier return). No-op when the shop is disabled.
|
||||||
|
internal void GiveShopCarriers(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
if (!Config.Shop.Enabled) return;
|
||||||
|
p.GiveNamedItem(EngineNames.ShopCarrier); // healthshot (X) toggles the quick-buy shop / the survival draft
|
||||||
|
p.GiveNamedItem(EngineNames.ShopMelee); // zeus — CT-legal, serves as shop key 3 + the grenade-rest anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chosen-loadout human spawn shared by every WeaponShopEnabled mode (TDM + Survival): the saved !guns primary/secondary
|
||||||
|
// + the shop carriers. Gun Game overrides with its rung weapon instead, so it keeps its own GiveHumanLoadout.
|
||||||
|
internal void GiveChosenLoadout(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var (primary, secondary) = ResolveLoadout(p); // saved !guns choice if unlocked, else config default
|
||||||
|
if (!string.IsNullOrEmpty(primary)) p.GiveNamedItem(primary);
|
||||||
|
if (!string.IsNullOrEmpty(secondary)) p.GiveNamedItem(secondary);
|
||||||
|
GiveShopCarriers(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The standard bot loadout (TDM + Survival): one pool weapon by stable per-bot index + the configured grenades. (Gun
|
||||||
|
// Game bots use the ladder rung instead, so GunGameDriver keeps its own GiveBotLoadout.) One definition for the two that share it.
|
||||||
|
internal void GiveStandardBotLoadout(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
var pool = Config.Match.BotWeapons;
|
||||||
|
bot.GiveNamedItem(pool.Count > 0 ? pool[BotLoadoutIndex(bot) % pool.Count] : EngineNames.WeaponAk47);
|
||||||
|
foreach (var g in Config.Match.BotGrenades) bot.GiveNamedItem(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable per-bot weapon slot: a bot's rank in slot order (count of bots with a lower slot). Each bot keeps
|
||||||
|
// its index across respawns -> always the same weapon. Guarantees the squad composition mirrors BotWeapons
|
||||||
|
// exactly (e.g. 2 AR / 1 shotgun / 1 AWP per 4).
|
||||||
|
internal static int BotLoadoutIndex(CCSPlayerController bot) =>
|
||||||
|
Utilities.GetPlayers().Count(b => IsBot(b) && b.Slot < bot.Slot);
|
||||||
|
|
||||||
|
// ---- team enforcement: humans on CT (cap), bots forced to T ----
|
||||||
|
|
||||||
|
private HookResult OnPlayerTeam_Driver(EventPlayerTeam ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (!IsHuman(p)) return HookResult.Continue;
|
||||||
|
int slot = p.Slot;
|
||||||
|
Server.NextFrame(() => EnforceHumanTeam(slot));
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnforceHumanTeam(int slot)
|
||||||
|
{
|
||||||
|
var p = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
if (p is not { IsValid: true } || p.IsBot) return;
|
||||||
|
|
||||||
|
// Survival fail-closed: no mid-run joins. While a run is in progress, a joining human (lands on T/None first)
|
||||||
|
// is parked in spectator; existing CT participants (alive or downed-awaiting-revive) are left untouched.
|
||||||
|
if (Draft is { RunInProgress: true })
|
||||||
|
{
|
||||||
|
if (p.Team is CsTeam.Terrorist or CsTeam.None)
|
||||||
|
{
|
||||||
|
p.SwitchTeam(CsTeam.Spectator);
|
||||||
|
p.PrintToChat("[Outnumbered] A survival run is in progress — you'll join the next one.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cap = _driver.MaxHumansOnCt; // per-mode (GG = 5, TDM = Match.MaxHumansOnCt)
|
||||||
|
int ctHumans = Utilities.GetPlayers()
|
||||||
|
.Count(x => IsHuman(x) && x.Team == CsTeam.CounterTerrorist && x.Slot != slot);
|
||||||
|
|
||||||
|
switch (p.Team)
|
||||||
|
{
|
||||||
|
// an over-cap CT, or a T/None arrival when CT is already full -> spectator (both land on the same outcome)
|
||||||
|
case CsTeam.CounterTerrorist when ctHumans >= cap:
|
||||||
|
case CsTeam.Terrorist or CsTeam.None when ctHumans >= cap:
|
||||||
|
p.SwitchTeam(CsTeam.Spectator);
|
||||||
|
p.PrintToChat($"[Outnumbered] CT is full (max {cap}) — moved to spectator.");
|
||||||
|
break;
|
||||||
|
// a T/None arrival with room -> put them on CT (native DM spawns them on the new team)
|
||||||
|
case CsTeam.Terrorist or CsTeam.None:
|
||||||
|
p.SwitchTeam(CsTeam.CounterTerrorist);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- map rotation on kill goal ----
|
||||||
|
|
||||||
|
// Ends the match and rotates the map. reason = the chat headline (TDM kill goal / Gun Game win); null = default.
|
||||||
|
internal void TriggerMapEnd(string? reason = null)
|
||||||
|
{
|
||||||
|
if (_mapEnding) return;
|
||||||
|
_mapEnding = true;
|
||||||
|
string next = NextMap();
|
||||||
|
Server.PrintToChatAll($"[Outnumbered] {reason ?? "Kill goal reached"} — next map: {next}");
|
||||||
|
Logger.LogInformation("Outnumbered match end on {Cur} ({Reason}); switching to {Next}",
|
||||||
|
Server.MapName, reason ?? "kill goal", next);
|
||||||
|
AddTimer(Config.Match.MapChangeDelaySeconds, () => { if (_live) Server.ExecuteCommand($"changelevel {next}"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private string NextMap()
|
||||||
|
{
|
||||||
|
var maps = _driver.Maps; // mode map pool (falls back to Match.Maps when empty)
|
||||||
|
if (maps.Count == 0) return Server.MapName;
|
||||||
|
int i = -1;
|
||||||
|
for (int k = 0; k < maps.Count; k++)
|
||||||
|
if (string.Equals(maps[k], Server.MapName, StringComparison.OrdinalIgnoreCase)) { i = k; break; }
|
||||||
|
return maps[(i + 1) % maps.Count]; // i == -1 -> first
|
||||||
|
}
|
||||||
|
}
|
||||||
103
Outnumbered/Effects.cs
Normal file
103
Outnumbered/Effects.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Survival "effect" card subsystems that don't belong to a single hook: the Burn DoT registry + tick, and the
|
||||||
|
// Explode-on-Kill real-grenade spawn. Both are survival-only by gating (the cards only exist there) but the code is
|
||||||
|
// mode-agnostic. The native projectile-create + the raw attributed hit live in Engine (GrenadeSpawner / DamageDealer);
|
||||||
|
// this owns only the burn registry + per-tick bookkeeping. Wired from Outnumbered.cs (Initialize_Effects / Shutdown_Effects).
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// Burn DoT: keyed by (bot slot, attacker slot) so each attacker burns a bot INDEPENDENTLY (different players STACK;
|
||||||
|
// a player's own re-hit just refreshes their entry). Value = (when this attacker's burn expires, attacker SteamID for
|
||||||
|
// identity-revalidation against slot reuse). Flat DPS from config; the tick deals it via a RAW (armor-skipping) hit.
|
||||||
|
private readonly Dictionary<(int bot, int atk), (double until, ulong sid)> _burns = new();
|
||||||
|
private readonly List<KeyValuePair<(int bot, int atk), (double until, ulong sid)>> _burnScratch = new(); // reused snapshot for BurnTickAll
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _burnTimer;
|
||||||
|
|
||||||
|
private void Initialize_Effects()
|
||||||
|
{
|
||||||
|
// Every effect-card key must have a catalog entry in Config.Survival.Cards, or the draft can never offer it
|
||||||
|
// (a typo'd/renamed key on the shared JSON silently disables that card on all 8 servers). Reflect over CardKeys
|
||||||
|
// so a newly-added constant is covered automatically.
|
||||||
|
var cardKeys = Config.Survival.Cards.Select(c => c.Key).ToHashSet(StringComparer.Ordinal);
|
||||||
|
foreach (var f in typeof(CardKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||||
|
if (f.IsLiteral && f.GetRawConstantValue() is string key && !cardKeys.Contains(key))
|
||||||
|
Logger.LogError("Outnumbered: CardKeys.{Name} ('{Key}') has no entry in Config.Survival.Cards — that effect card can never be drafted.", f.Name, key);
|
||||||
|
|
||||||
|
// One steady tick (no-op when the registry is empty, i.e. in TDM/GG or a survival run with no Burn cards).
|
||||||
|
_burnTimer = AddTimer(Math.Max(0.1f, Config.Survival.BurnTickSeconds), BurnTickAll, TimerFlags.REPEAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Effects()
|
||||||
|
{
|
||||||
|
_burnTimer?.Kill();
|
||||||
|
_burns.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A human with the Burn card hit a bot — (re)apply this attacker's burn on that bot.
|
||||||
|
private void RegisterBurn(int botSlot, int atkSlot, ulong atkSid)
|
||||||
|
{
|
||||||
|
_burns[(botSlot, atkSlot)] = (Server.CurrentTime + Config.Survival.BurnDurationSeconds, atkSid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bot died — drop all its burns so a fast slot-reuse (suicide/respawn within the burn window) can't inherit fire.
|
||||||
|
private void ClearBurnsForBot(int botSlot)
|
||||||
|
{
|
||||||
|
if (_burns.Count == 0) return;
|
||||||
|
List<(int, int)>? gone = null;
|
||||||
|
foreach (var k in _burns.Keys) if (k.bot == botSlot) (gone ??= new()).Add(k);
|
||||||
|
if (gone is not null) foreach (var k in gone) _burns.Remove(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply one tick of burn to every live burning bot, per attacker (so 5 shooters = 5x the DPS). Flat, armor-skipping,
|
||||||
|
// still attributed (a burn KILL credits the player -> wave count / XP / explode-on-kill chaining). Prune expired or
|
||||||
|
// now-invalid entries (bot dead/gone, attacker gone or slot reused).
|
||||||
|
private void BurnTickAll()
|
||||||
|
{
|
||||||
|
if (_burns.Count == 0) return;
|
||||||
|
double now = Server.CurrentTime;
|
||||||
|
float dmg = (float)(Config.Survival.BurnDamagePerSecond * Config.Survival.BurnTickSeconds);
|
||||||
|
|
||||||
|
// Iterate a SNAPSHOT: a LETHAL raw tick fires player_death synchronously inside DamageDealer.Deal's Invoke ->
|
||||||
|
// OnPlayerDeath_Driver (Pre, inline) -> ClearBurnsForBot -> _burns.Remove, which would corrupt a live foreach
|
||||||
|
// ("Collection was modified"). The snapshot makes that mid-loop mutation harmless; ContainsKey skips any entry
|
||||||
|
// already cleared this tick (e.g. a co-attacker's burn on a bot an earlier entry just killed). Reused scratch
|
||||||
|
// (BurnTickAll isn't re-entrant + game-thread) avoids a per-tick List alloc.
|
||||||
|
_burnScratch.Clear();
|
||||||
|
foreach (var kv in _burns) _burnScratch.Add(kv);
|
||||||
|
foreach (var kv in _burnScratch)
|
||||||
|
{
|
||||||
|
if (!_burns.ContainsKey(kv.Key)) continue;
|
||||||
|
var (botSlot, atkSlot) = kv.Key;
|
||||||
|
var (until, sid) = kv.Value;
|
||||||
|
|
||||||
|
var bot = Utilities.GetPlayerFromSlot(botSlot);
|
||||||
|
if (now >= until || !IsLiveBot(bot))
|
||||||
|
{ _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot
|
||||||
|
|
||||||
|
var atk = Utilities.GetPlayerFromSlot(atkSlot);
|
||||||
|
if (atk is not { IsValid: true } || atk.AuthorizedSteamID?.SteamId64 != sid) // attacker left / slot reused
|
||||||
|
{ _burns.Remove(kv.Key); continue; } // safe to mutate _burns here: we're iterating the _burnScratch snapshot
|
||||||
|
|
||||||
|
if (dmg > 0) DamageDealer.Deal(bot, atk, dmg, raw: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explode-on-Kill: a REAL HE blast at the corpse, attributed to `attacker` so its kills credit them (wave count +
|
||||||
|
// chaining: a blast kill fires its own death event -> re-triggers this). The blast re-enters our offense hook (attacker
|
||||||
|
// = the thrower) so it SCALES with the killer's build + handicap (owner's call); CT team means it never hurts survivors
|
||||||
|
// (ff off). The native projectile-create + the manual blast fallback both live in Engine.GrenadeSpawner.
|
||||||
|
internal void ExplodeAt(CCSPlayerController attacker, Vector pos)
|
||||||
|
{
|
||||||
|
var cfg = Config.Survival;
|
||||||
|
GrenadeSpawner.Explode(attacker, pos, (float)cfg.ExplodeBaseDamage, (float)cfg.ExplodeRadius, cfg.ExplodeFuseSeconds,
|
||||||
|
(msg, ex) => { if (ex is null) LogSurvival(msg); else Logger.LogWarning(ex, "[Survival] {Msg}", msg); }); // sig/invoke breaks log full stack at WARNING
|
||||||
|
}
|
||||||
|
}
|
||||||
15
Outnumbered/Engine/ControllerWriter.cs
Normal file
15
Outnumbered/Engine/ControllerWriter.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// Controller-side schema writes (the pawn-side equivalents live in PawnWriter). Currently just the scoreboard clan tag —
|
||||||
|
// kept here so the field name + the SetStateChanged notify contract live together, like every other engine write.
|
||||||
|
internal static class ControllerWriter
|
||||||
|
{
|
||||||
|
public static void SetClan(CCSPlayerController p, string tag)
|
||||||
|
{
|
||||||
|
p.Clan = tag;
|
||||||
|
Utilities.SetStateChanged(p, EngineNames.CCSPlayerController, EngineNames.Clan);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
Outnumbered/Engine/DamageDealer.cs
Normal file
99
Outnumbered/Engine/DamageDealer.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Memory;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// Deals real, attributed damage as if `source` dealt it to `target`, returning the actual damage dealt. By default the hit
|
||||||
|
// re-enters the offense hook so it scales with the source's build + handicap (Explode-on-Kill relies on this) and fires
|
||||||
|
// EventPlayerDeath on a kill (proper credit / GG-rung / killstreak). A TakeDamageOld invoke does NOT raise player_hurt, so
|
||||||
|
// EventPlayerHurt effects (lifesteal, per-hit XP) don't trigger — callers apply those on the returned amount. Mirrors the
|
||||||
|
// CSSharp reference: a zeroed CTakeDamageInfo with AttackerInfo_t at +0x88, plus a CTakeDamageResult, fed to TakeDamageOld.
|
||||||
|
// raw = a FLAT, armor-skipping BURN hit (DoT): DFLAG_IGNORE_ARMOR + DMG_BURN.
|
||||||
|
// flat = a normal bullet that respects armor but is NOT scaled (thorns: the bot eats exactly what was computed).
|
||||||
|
// Both set Unscaled so the offense hook applies `damage` verbatim; attribution is still kept so a kill credits the source.
|
||||||
|
internal static class DamageDealer
|
||||||
|
{
|
||||||
|
// TRUE around an unscaled invoke (raw burn DoT or flat thorns reflect): the offense hook (OnEntityTakeDamagePre) returns
|
||||||
|
// immediately when set, so `damage` lands verbatim — no build/handicap scaling, no crit roll. Toggled inside Deal only.
|
||||||
|
internal static bool Unscaled;
|
||||||
|
|
||||||
|
// Byte offset of AttackerInfo_t inside CTakeDamageInfo (the CSSharp Tests.Native layout). A build-volatile fixup point
|
||||||
|
// — if the struct layout shifts on a CS2 update, this is the one-line change (kept here with the marshalling it serves).
|
||||||
|
private const int AttackerInfoOffset = 0x88;
|
||||||
|
|
||||||
|
// Cached all-zero source for bulk-clearing the two unmanaged buffers (one memcpy each vs N per-byte WriteByte interop
|
||||||
|
// crossings). Grows once to the largest class size; never written so it stays zero. Game-thread only, Deal not re-entrant.
|
||||||
|
private static byte[] _zero = [];
|
||||||
|
|
||||||
|
// The two unmanaged buffers, REUSED across calls instead of AllocHGlobal/FreeHGlobal per Deal (hot in burn waves: per
|
||||||
|
// burn-tick-per-bot, per thorns hit, per explode). Safe because Deal is game-thread + NOT re-entrant — the synchronous
|
||||||
|
// player_death its Invoke can fire only REMOVES burns (ClearBurnsForBot), and explode-on-kill is deferred a frame; no
|
||||||
|
// path re-enters Deal before it returns. Grow-only to the class size; never freed (process-lifetime); re-zeroed each call.
|
||||||
|
private static IntPtr _infoBuf, _resBuf;
|
||||||
|
private static int _infoCap, _resCap;
|
||||||
|
|
||||||
|
internal static float Deal(CCSPlayerController target, CCSPlayerController source, float damage, bool raw = false, bool flat = false)
|
||||||
|
{
|
||||||
|
if (damage <= 0 || !target.IsValid || !source.IsValid) return 0f;
|
||||||
|
var targetPawn = target.PlayerPawn.Value;
|
||||||
|
if (targetPawn is null || targetPawn.Health <= 0) return 0f;
|
||||||
|
|
||||||
|
int infoSize = Schema.GetClassSize(EngineNames.CTakeDamageInfo);
|
||||||
|
int resSize = Schema.GetClassSize(EngineNames.CTakeDamageResult);
|
||||||
|
if (_zero.Length < infoSize || _zero.Length < resSize) _zero = new byte[Math.Max(infoSize, resSize)];
|
||||||
|
// Grow the reused buffers if needed (class sizes are fixed per build, so this fires only on the first call), then
|
||||||
|
// re-zero them — no per-call AllocHGlobal/FreeHGlobal.
|
||||||
|
if (_infoCap < infoSize) { _infoBuf = _infoBuf == IntPtr.Zero ? Marshal.AllocHGlobal(infoSize) : Marshal.ReAllocHGlobal(_infoBuf, (IntPtr)infoSize); _infoCap = infoSize; }
|
||||||
|
if (_resCap < resSize) { _resBuf = _resBuf == IntPtr.Zero ? Marshal.AllocHGlobal(resSize) : Marshal.ReAllocHGlobal(_resBuf, (IntPtr)resSize); _resCap = resSize; }
|
||||||
|
IntPtr infoPtr = _infoBuf, resPtr = _resBuf;
|
||||||
|
Marshal.Copy(_zero, 0, infoPtr, infoSize); // bulk-zero the reused buffers (memcpy) — restore the clean-slate contract
|
||||||
|
Marshal.Copy(_zero, 0, resPtr, resSize);
|
||||||
|
|
||||||
|
var info = new CTakeDamageInfo(infoPtr);
|
||||||
|
var ai = new AttackerInfo_t
|
||||||
|
{
|
||||||
|
NeedInit = true,
|
||||||
|
IsPawn = true,
|
||||||
|
AttackerPawn = source.Pawn.Raw,
|
||||||
|
AttackerPlayerSlot = source.Slot,
|
||||||
|
};
|
||||||
|
Marshal.StructureToPtr(ai, new IntPtr(infoPtr.ToInt64() + AttackerInfoOffset), false);
|
||||||
|
|
||||||
|
uint inflictor = source.PawnIsAlive ? source.Pawn.Raw : source.PlayerPawn.Raw;
|
||||||
|
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Inflictor, inflictor);
|
||||||
|
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Attacker, source.Pawn.Raw);
|
||||||
|
info.Damage = damage;
|
||||||
|
info.BitsDamageType = raw ? DamageTypes_t.DMG_BURN : DamageTypes_t.DMG_BULLET;
|
||||||
|
if (raw) info.DamageFlags |= TakeDamageFlags_t.DFLAG_IGNORE_ARMOR;
|
||||||
|
|
||||||
|
var result = new CTakeDamageResult(resPtr);
|
||||||
|
Schema.SetSchemaValue(result.Handle, EngineNames.CTakeDamageResult, EngineNames.OriginatingInfo, info.Handle);
|
||||||
|
result.HealthBefore = targetPawn.Health;
|
||||||
|
result.HealthLost = (int)damage;
|
||||||
|
result.DamageDealt = damage;
|
||||||
|
result.PreModifiedDamage = damage;
|
||||||
|
|
||||||
|
bool unscaled = raw || flat;
|
||||||
|
#pragma warning disable CS0618
|
||||||
|
if (unscaled) Unscaled = true;
|
||||||
|
try { VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Invoke(targetPawn, info, result); }
|
||||||
|
finally { if (unscaled) Unscaled = false; }
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
return info.Damage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribution payload the engine reads out of CTakeDamageInfo at +0x88 (kill credit / assists). Byte-laid-out to match
|
||||||
|
// the CSSharp reference (Tests.Native); written via Marshal in Deal.
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct AttackerInfo_t
|
||||||
|
{
|
||||||
|
public bool NeedInit;
|
||||||
|
public bool IsPawn;
|
||||||
|
public bool IsWorld;
|
||||||
|
public uint AttackerPawn;
|
||||||
|
public int AttackerPlayerSlot;
|
||||||
|
public int TeamChecked;
|
||||||
|
public int Team;
|
||||||
|
}
|
||||||
47
Outnumbered/Engine/EngineNames.cs
Normal file
47
Outnumbered/Engine/EngineNames.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// Every engine string/identifier in one place: schema classes + fields, designer names, entity classnames, item-def
|
||||||
|
// indices. A CS2/CSSharp rename becomes a one-line fix here instead of a hunt across files (engine churn is when, not if).
|
||||||
|
internal static class EngineNames
|
||||||
|
{
|
||||||
|
// schema classes
|
||||||
|
public const string CBaseEntity = "CBaseEntity";
|
||||||
|
public const string CCSPlayerPawn = "CCSPlayerPawn";
|
||||||
|
public const string CCSPlayerController = "CCSPlayerController";
|
||||||
|
public const string CBasePlayerWeapon = "CBasePlayerWeapon";
|
||||||
|
public const string CTakeDamageInfo = "CTakeDamageInfo";
|
||||||
|
public const string CTakeDamageResult = "CTakeDamageResult";
|
||||||
|
|
||||||
|
// schema fields
|
||||||
|
public const string Health = "m_iHealth";
|
||||||
|
public const string MaxHealth = "m_iMaxHealth";
|
||||||
|
public const string ArmorValue = "m_ArmorValue";
|
||||||
|
public const string MoveType = "m_MoveType";
|
||||||
|
public const string Clan = "m_szClan";
|
||||||
|
public const string Inflictor = "m_hInflictor";
|
||||||
|
public const string Attacker = "m_hAttacker";
|
||||||
|
public const string OriginatingInfo = "m_pOriginatingInfo";
|
||||||
|
public const string NextPrimaryAttackTick = "m_nNextPrimaryAttackTick";
|
||||||
|
|
||||||
|
// pawn designer-name varies by build — FrozenSet for the per-pellet membership check in the damage hook
|
||||||
|
public static readonly FrozenSet<string> PlayerPawnDesigners =
|
||||||
|
new[] { "cs_player_pawn", "player" }.ToFrozenSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// entity classnames / designer names
|
||||||
|
public const string PointWorldText = "point_worldtext";
|
||||||
|
public const string GameRulesDesigner = "cs_gamerules";
|
||||||
|
// (the HE projectile is spawned via the native create func in GrenadeSpawner, not by classname)
|
||||||
|
|
||||||
|
// common item designer-names given/compared in loadouts (the knife rung, the spawn armor, the default-pool fallback)
|
||||||
|
public const string WeaponKnife = "weapon_knife";
|
||||||
|
public const string ItemAssaultSuit = "item_assaultsuit";
|
||||||
|
public const string WeaponAk47 = "weapon_ak47";
|
||||||
|
// the two shop "carrier" items every human spawns with: the healthshot (X toggles the menu) + the zeus (shop key 3)
|
||||||
|
public const string ShopCarrier = "weapon_healthshot";
|
||||||
|
public const string ShopMelee = "weapon_taser";
|
||||||
|
|
||||||
|
// item-def index for the HE grenade (native projectile create)
|
||||||
|
public const int HeGrenadeItemDef = 44;
|
||||||
|
}
|
||||||
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal file
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// A real HE blast at a point, attributed to a player. Spawns via the game's NATIVE projectile-create (the same call used
|
||||||
|
// when a player throws) because Valve removed the think-arming from the InitializeSpawnFromWorld input, so a
|
||||||
|
// CreateEntityByName grenade is inert on current CS2. Signature from MatchZy / cs2-executes. Lazy-resolved; if it doesn't
|
||||||
|
// match this build, falls back to a manual radius blast (no engine VFX). Config-agnostic: damage/radius/fuse are passed in;
|
||||||
|
// `log` receives one-time path notes. Args: (position, angle, velocity, velocity, IntPtr.Zero, itemDefIndex).
|
||||||
|
internal static class GrenadeSpawner
|
||||||
|
{
|
||||||
|
private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||||
|
private static MemoryFunctionWithReturn<nint, nint, nint, nint, nint, int, CHEGrenadeProjectile>? _heCreate;
|
||||||
|
private static bool _init, _ok, _nativeLogged, _manualLogged;
|
||||||
|
|
||||||
|
public static void Explode(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
|
||||||
|
{
|
||||||
|
if (attacker is not { IsValid: true } || attacker.PlayerPawn.Value is null) return;
|
||||||
|
if (!TrySpawnHe(attacker, pos, baseDamage, radius, fuseSeconds, log))
|
||||||
|
ManualBlast(attacker, pos, baseDamage, radius, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TrySpawnHe(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
|
||||||
|
{
|
||||||
|
if (!_init)
|
||||||
|
{
|
||||||
|
_init = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_heCreate = new(IsLinux
|
||||||
|
? "55 4C 89 C1 48 89 E5 41 57 49 89 D7"
|
||||||
|
: "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 50 48 8B AC 24 80 00 00 00 49 8B F8");
|
||||||
|
_ok = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log("explode-on-kill: HE native-create signature not found — manual blast fallback", ex);
|
||||||
|
_ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!_ok || _heCreate is null) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var apawn = attacker.PlayerPawn.Value!;
|
||||||
|
var spawn = new Vector(pos.X, pos.Y, pos.Z + 12f);
|
||||||
|
var ang = new QAngle();
|
||||||
|
var vel = new Vector(0, 0, -10f);
|
||||||
|
var nade = _heCreate.Invoke(spawn.Handle, ang.Handle, vel.Handle, vel.Handle, IntPtr.Zero, EngineNames.HeGrenadeItemDef);
|
||||||
|
if (nade is null || !nade.IsValid) return false;
|
||||||
|
|
||||||
|
nade.Teleport(spawn, ang, vel);
|
||||||
|
nade.Globalname = "custom";
|
||||||
|
nade.TeamNum = apawn.TeamNum; // CT -> only damages bots; survivors safe (ff off)
|
||||||
|
nade.Thrower.Raw = attacker.PlayerPawn.Raw; // kill attribution + killfeed
|
||||||
|
nade.OriginalThrower.Raw = attacker.PlayerPawn.Raw;
|
||||||
|
nade.OwnerEntity.Raw = attacker.PlayerPawn.Raw;
|
||||||
|
nade.Damage = baseDamage; // scaled UP by the offense hook on detonation
|
||||||
|
nade.DmgRadius = radius;
|
||||||
|
nade.DetonateTime = Server.CurrentTime + Math.Max(0f, fuseSeconds); // think is scheduled now -> respected
|
||||||
|
if (!_nativeLogged) { _nativeLogged = true; log("explode-on-kill: native HE grenade spawned + armed OK", null); }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
log("explode-on-kill: native HE invoke failed — manual blast fallback", ex);
|
||||||
|
_ok = false; // don't keep retrying a bad signature this session
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the list — a lethal Deal fires player_death synchronously, which mutates bot state mid-loop.
|
||||||
|
private static void ManualBlast(CCSPlayerController attacker, Vector center, float baseDamage, float radius, Action<string, Exception?> log)
|
||||||
|
{
|
||||||
|
double r = Math.Max(1.0, radius);
|
||||||
|
foreach (var bot in Utilities.GetPlayers().ToList())
|
||||||
|
{
|
||||||
|
if (bot is not { IsValid: true, IsBot: true, IsHLTV: false } || !bot.PawnIsAlive) continue;
|
||||||
|
var bpos = bot.PlayerPawn.Value?.AbsOrigin;
|
||||||
|
if (bpos is null) continue;
|
||||||
|
double dx = bpos.X - center.X, dy = bpos.Y - center.Y, dz = bpos.Z - center.Z;
|
||||||
|
double dist = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
|
if (dist > r) continue;
|
||||||
|
float dmg = (float)(baseDamage * (1.0 - dist / r)); // linear falloff
|
||||||
|
if (dmg >= 1f) DamageDealer.Deal(bot, attacker, dmg); // non-raw -> scales + attributed + chains
|
||||||
|
}
|
||||||
|
if (!_manualLogged) { _manualLogged = true; log("explode-on-kill: native HE unavailable -> MANUAL radius blast (no engine VFX)", null); }
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Outnumbered/Engine/Inventory.cs
Normal file
67
Outnumbered/Engine/Inventory.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// The weapon-inventory read surface (WeaponServices: MyWeapons / ActiveWeapon) plus the slot-select client commands.
|
||||||
|
// Centralizes the null-safe "walk / read the player's weapons" idiom shared by Abilities + Shop, so a
|
||||||
|
// CSSharp rename of WeaponServices/MyWeapons/ActiveWeapon — or a change to slot-select semantics — is one edit here.
|
||||||
|
// (The bare GiveNamedItem/RemoveItemByDesignerName loadout calls stay at their call sites: single-call, nothing to share.)
|
||||||
|
internal static class Inventory
|
||||||
|
{
|
||||||
|
// Slot-select client commands (engine INPUT, not schema). slot3 (the melee slot) is the neutral "rest" anchor; the zeus
|
||||||
|
// is deployed by name because the engine auto-deploys a granted grenade over the knife but not over a real weapon.
|
||||||
|
public const string SlotPrimary = "slot1";
|
||||||
|
public const string SlotSecondary = "slot2";
|
||||||
|
public const string SlotMelee = "slot3";
|
||||||
|
public const string UseZeus = "use weapon_taser";
|
||||||
|
|
||||||
|
// The player's currently-deployed weapon, or null (null-safe through pawn -> WeaponServices -> handle, IsValid-checked).
|
||||||
|
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerPawn? pawn)
|
||||||
|
{
|
||||||
|
var w = pawn?.WeaponServices?.ActiveWeapon.Value;
|
||||||
|
return w is { IsValid: true } ? w : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerController p) => ActiveWeapon(p.PlayerPawn.Value);
|
||||||
|
|
||||||
|
// The active weapon's designer name, or "" when none — the common "what is this player holding?" probe.
|
||||||
|
public static string ActiveWeaponName(CCSPlayerPawn? pawn) => ActiveWeapon(pawn)?.DesignerName ?? "";
|
||||||
|
|
||||||
|
// TRUE if the designer name is a knife (any skin — weapon_knife, weapon_knife_t, weapon_bayonet, …) or the zeus. Lets the
|
||||||
|
// thorns abuse-guard tell "real melee in hand" from a gun (mirrors Shop's knife-neutral test, plus the zeus).
|
||||||
|
public static bool IsMeleeOrZeus(string designerName) =>
|
||||||
|
designerName == EngineNames.ShopMelee || designerName.Contains("knife") || designerName.Contains("bayonet");
|
||||||
|
|
||||||
|
// Every valid weapon the player holds (null-safe walk of WeaponServices.MyWeapons). Empty if no pawn / no services.
|
||||||
|
// Lazy (iterator) — fine for the cold classify-all walk; the hot exact-match check uses Holds (no enumerator alloc).
|
||||||
|
public static IEnumerable<CBasePlayerWeapon> Weapons(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
|
||||||
|
if (weapons is null) yield break;
|
||||||
|
foreach (var h in weapons)
|
||||||
|
{
|
||||||
|
var w = h.Value;
|
||||||
|
if (w is { IsValid: true }) yield return w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The weapon's full magazine size from its VData, or -1 if unavailable (the No-Reload / infinite-clip top-up reads it).
|
||||||
|
public static int MaxClip(CBasePlayerWeapon w) => w.As<CCSWeaponBase>().VData?.MaxClip1 ?? -1;
|
||||||
|
|
||||||
|
// Whether the pawn's weapon inventory is readable at all (pawn + WeaponServices + MyWeapons present) — distinguishes
|
||||||
|
// "no inventory" from "an empty inventory" for callers that branch on it (e.g. PreferredSlotCmd's no-services fallback).
|
||||||
|
public static bool HasWeaponServices(CCSPlayerController p) => p.PlayerPawn.Value?.WeaponServices?.MyWeapons is not null;
|
||||||
|
|
||||||
|
// True if the player holds a weapon with this designer name. Direct walk (no iterator alloc) for the per-reconcile path.
|
||||||
|
public static bool Holds(CCSPlayerController p, string designerName)
|
||||||
|
{
|
||||||
|
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
|
||||||
|
if (weapons is null) return false;
|
||||||
|
foreach (var h in weapons)
|
||||||
|
{
|
||||||
|
var w = h.Value;
|
||||||
|
if (w is { IsValid: true } && w.DesignerName == designerName) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Outnumbered/Engine/PawnWriter.cs
Normal file
54
Outnumbered/Engine/PawnWriter.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// The one place pawn HP/armor are written: every write is paired with the required SetStateChanged so the schema-field
|
||||||
|
// names and the change-notification contract live together. Callers resolve the pawn; these never read config or pd.
|
||||||
|
internal static class PawnWriter
|
||||||
|
{
|
||||||
|
// MaxHealth MUST be set before Health, else Health clamps to 100. Sets both the pawn and the controller mirror.
|
||||||
|
public static void SetMaxHealth(CCSPlayerController p, CCSPlayerPawn pawn, int max)
|
||||||
|
{
|
||||||
|
pawn.MaxHealth = max;
|
||||||
|
p.MaxHealth = max;
|
||||||
|
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MaxHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetHealth(CCSPlayerPawn pawn, int hp)
|
||||||
|
{
|
||||||
|
pawn.Health = hp;
|
||||||
|
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.Health);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetArmor(CCSPlayerPawn pawn, int armor)
|
||||||
|
{
|
||||||
|
pawn.ArmorValue = armor;
|
||||||
|
Utilities.SetStateChanged(pawn, EngineNames.CCSPlayerPawn, EngineNames.ArmorValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pawn movement-state write + its change-notification (the shop freeze/unfreeze). Caller owns any velocity reset.
|
||||||
|
public static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType)
|
||||||
|
{
|
||||||
|
pawn.MoveType = moveType;
|
||||||
|
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MoveType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A capped ADD never REDUCES: if the pawn is already at/over the cap (e.g. an !og_reload lowered Max-HP below the
|
||||||
|
// live value) we no-op rather than snapping it down — so regen/lifesteal preserve an over-cap surplus.
|
||||||
|
public static void AddHealthCapped(CCSPlayerPawn pawn, int amount, int cap)
|
||||||
|
{
|
||||||
|
if (amount <= 0 || pawn.Health <= 0) return;
|
||||||
|
int next = Math.Min(cap, pawn.Health + amount);
|
||||||
|
if (next <= pawn.Health) return;
|
||||||
|
SetHealth(pawn, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void AddArmorCapped(CCSPlayerPawn pawn, int amount, int cap)
|
||||||
|
{
|
||||||
|
if (amount <= 0 || pawn.Health <= 0) return;
|
||||||
|
int next = Math.Min(cap, pawn.ArmorValue + amount);
|
||||||
|
if (next <= pawn.ArmorValue) return;
|
||||||
|
SetArmor(pawn, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Outnumbered/Engine/Rules.cs
Normal file
18
Outnumbered/Engine/Rules.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// Game-rules access. mp_roundtime caps at 60 min so the round timer would expire and hang; the map ends only on the kill
|
||||||
|
// goal, so we override the round duration directly on the rules each round.
|
||||||
|
internal static class Rules
|
||||||
|
{
|
||||||
|
public static CCSGameRules? Current =>
|
||||||
|
Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>(EngineNames.GameRulesDesigner).FirstOrDefault()?.GameRules;
|
||||||
|
|
||||||
|
public static void MakeRoundEndless()
|
||||||
|
{
|
||||||
|
var gr = Current;
|
||||||
|
gr?.RoundTime = 999999; // ~277h
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Outnumbered/Engine/ScreenFade.cs
Normal file
30
Outnumbered/Engine/ScreenFade.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.UserMessages;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// CS2 screen fade via the Fade user message. The exact message format is UNVERIFIED (hard to debug blind), so the send is
|
||||||
|
// wrapped: a wrong field name no-ops via onError instead of crashing. The blend/decision logic stays plugin-side.
|
||||||
|
internal static class ScreenFade
|
||||||
|
{
|
||||||
|
public static int Pack(int r, int g, int b, int a) => r | (g << 8) | (b << 16) | (a << 24);
|
||||||
|
|
||||||
|
public static void Send(CCSPlayerController p, int r, int g, int b, int a, bool clear, Action<Exception> onError)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// CUserMessageFade [106]: duration, hold_time, flags, color — all INT; color is one packed value (not nested clr).
|
||||||
|
var msg = UserMessage.FromPartialName("Fade");
|
||||||
|
msg.SetInt("duration", (int)(0.3f * 512)); // Q7.9 fixed point (seconds * 512); int, not float
|
||||||
|
msg.SetInt("hold_time", 0); // FFADE_STAYOUT holds it until the next fade
|
||||||
|
// hold a tint: FFADE_OUT|FFADE_STAYOUT. clear it: FFADE_IN|FFADE_PURGE (fades the held colour out + drops stayout).
|
||||||
|
msg.SetInt("flags", clear ? (0x1 | 0x10) : (0x2 | 0x8));
|
||||||
|
msg.SetInt("color", Pack(r, g, b, a)); // packed color32, R in the low byte
|
||||||
|
msg.Send(p);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
onError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Outnumbered/Engine/UdsServer.cs
Normal file
106
Outnumbered/Engine/UdsServer.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// Minimal request/response server on a Unix domain socket: one verb line in, one payload out, connection closes.
|
||||||
|
// Runs entirely on background threads — the handler must NEVER touch game state (Api.cs serves pre-serialized bytes,
|
||||||
|
// and the one async verb is DB-only). Lifecycle: the ctor binds (delete-before-bind clears a crash leftover; bind
|
||||||
|
// failures throw to the caller, who degrades gracefully), Dispose cancels the accept loop, closes the listener and
|
||||||
|
// unlinks the socket file — a hot-reload (Unload then Load in-process) can then re-bind the same path.
|
||||||
|
internal sealed class UdsServer : IDisposable
|
||||||
|
{
|
||||||
|
private const int MaxRequestBytes = 256; // a verb line; anything bigger is not our client
|
||||||
|
private static readonly TimeSpan IoDeadline = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly Socket _listener;
|
||||||
|
private readonly string _path;
|
||||||
|
private readonly Func<string, Task<byte[]>> _handle;
|
||||||
|
private readonly Action<Exception> _onError;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
|
||||||
|
internal UdsServer(string path, Func<string, Task<byte[]>> handle, Action<Exception> onError)
|
||||||
|
{
|
||||||
|
_path = path;
|
||||||
|
_handle = handle;
|
||||||
|
_onError = onError;
|
||||||
|
File.Delete(path); // a crash leaves the old socket file behind, and bind fails on an existing path
|
||||||
|
_listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||||
|
_listener.Bind(new UnixDomainSocketEndPoint(path));
|
||||||
|
_listener.Listen(16);
|
||||||
|
// 0660: owner (the game user) + group (shared with the site user) — never world-accessible. Unix-only API,
|
||||||
|
// guarded so a Windows dev build compiles clean (there the socket just keeps default ACLs).
|
||||||
|
if (!OperatingSystem.IsWindows())
|
||||||
|
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
|
||||||
|
_ = Task.Run(AcceptLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcceptLoop()
|
||||||
|
{
|
||||||
|
while (!_cts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Socket conn;
|
||||||
|
try { conn = await _listener.AcceptAsync(_cts.Token); }
|
||||||
|
catch (OperationCanceledException) { return; } // Dispose
|
||||||
|
catch (ObjectDisposedException) { return; } // Dispose raced the accept
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A persistent accept fault (fd exhaustion) would otherwise re-throw instantly forever — one retry per
|
||||||
|
// second turns a pegged core on the game host into a single syscall/s until the fault clears.
|
||||||
|
_onError(ex);
|
||||||
|
try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token); }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_ = Task.Run(() => Serve(conn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One connection = one verb line -> one payload. Deadlined so a stuck client can never pin resources; errors are
|
||||||
|
// reported (throttled by the caller) and the connection just drops — a game server must never care.
|
||||||
|
private async Task Serve(Socket conn)
|
||||||
|
{
|
||||||
|
using (conn)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Inside the try: _cts.Token throws ObjectDisposedException when Dispose() wins the race against a
|
||||||
|
// queued Serve task (hot-reload while a client connects) — treated like the AcceptLoop's same race.
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
|
||||||
|
deadline.CancelAfter(IoDeadline);
|
||||||
|
var buf = new byte[MaxRequestBytes];
|
||||||
|
int len = 0;
|
||||||
|
while (len < MaxRequestBytes)
|
||||||
|
{
|
||||||
|
int n = await conn.ReceiveAsync(buf.AsMemory(len, MaxRequestBytes - len), SocketFlags.None, deadline.Token);
|
||||||
|
if (n == 0) break; // peer closed without a newline
|
||||||
|
len += n;
|
||||||
|
if (Array.IndexOf(buf, (byte)'\n', 0, len) >= 0) break;
|
||||||
|
}
|
||||||
|
int nl = Array.IndexOf(buf, (byte)'\n', 0, len);
|
||||||
|
if (nl < 0) return; // no complete verb line within the cap — not our client, drop silently
|
||||||
|
|
||||||
|
byte[] payload = await _handle(Encoding.ASCII.GetString(buf, 0, nl).Trim());
|
||||||
|
|
||||||
|
int sent = 0;
|
||||||
|
while (sent < payload.Length)
|
||||||
|
{
|
||||||
|
int n = await conn.SendAsync(payload.AsMemory(sent), SocketFlags.None, deadline.Token);
|
||||||
|
if (n <= 0) break;
|
||||||
|
sent += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* deadline hit or server shutting down — drop the connection */ }
|
||||||
|
catch (ObjectDisposedException) { /* Dispose raced a queued connection — drop it */ }
|
||||||
|
catch (Exception ex) { _onError(ex); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
try { _listener.Dispose(); } catch (Exception) { /* already closed */ }
|
||||||
|
try { File.Delete(_path); } catch (Exception) { /* unlink is best-effort; delete-before-bind covers leftovers */ }
|
||||||
|
_cts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
Outnumbered/Engine/WorldText.cs
Normal file
81
Outnumbered/Engine/WorldText.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
|
||||||
|
namespace Outnumbered.Engine;
|
||||||
|
|
||||||
|
// The one place a point_worldtext entity is created + configured (HUD panel, the 2 shop panels, the draft cards all
|
||||||
|
// share it). CreateEntityByName returns a NON-null wrapper even on failure (e.g. the entity limit) with a Zero Handle,
|
||||||
|
// so the guard checks the raw handle (no memory deref) before touching schema members. Callers supply the per-use
|
||||||
|
// font/justify/colour/border and own the last-text caching; the volatile "SetMessage" input + the Teleport placement
|
||||||
|
// go through SetText/Place so every engine touchpoint for this entity lives here.
|
||||||
|
internal static class WorldText
|
||||||
|
{
|
||||||
|
public static CPointWorldText? Create(float fontSize, float worldUnitsPerPx, string fontName, Color color,
|
||||||
|
bool drawBackground, float border, PointWorldTextJustifyHorizontal_t justify)
|
||||||
|
{
|
||||||
|
var e = Utilities.CreateEntityByName<CPointWorldText>(EngineNames.PointWorldText);
|
||||||
|
if (e is null || e.Handle == nint.Zero) return null;
|
||||||
|
e.MessageText = " ";
|
||||||
|
e.Enabled = true;
|
||||||
|
e.Fullbright = true;
|
||||||
|
e.FontSize = fontSize;
|
||||||
|
e.WorldUnitsPerPx = worldUnitsPerPx;
|
||||||
|
if (!string.IsNullOrEmpty(fontName)) e.FontName = fontName;
|
||||||
|
e.Color = color;
|
||||||
|
e.JustifyHorizontal = justify;
|
||||||
|
e.JustifyVertical = PointWorldTextJustifyVertical_t.POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER;
|
||||||
|
e.ReorientMode = PointWorldTextReorientMode_t.POINT_WORLD_TEXT_REORIENT_NONE;
|
||||||
|
e.DrawBackground = drawBackground;
|
||||||
|
e.BackgroundBorderHeight = border;
|
||||||
|
e.BackgroundBorderWidth = border;
|
||||||
|
e.DispatchSpawn();
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push new text via the point_worldtext "SetMessage" input (the volatile input-name literal lives here, not at the 4
|
||||||
|
// call sites). Callers keep their own last-text cache and only call this on a change.
|
||||||
|
public static void SetText(CPointWorldText ent, string text) => ent.AcceptInput("SetMessage", ent, ent, text);
|
||||||
|
|
||||||
|
// Position + orient a panel. point_worldtext is moved via Teleport (no velocity); callers compute the eye-relative frame.
|
||||||
|
public static void Place(CPointWorldText ent, Vector pos, QAngle ang) => ent.Teleport(pos, ang, null);
|
||||||
|
|
||||||
|
// Tear a panel down: Remove the entity if it's still live, then null the caller's reference. Symmetric with Create —
|
||||||
|
// every point_worldtext create + destroy goes through this file.
|
||||||
|
public static void Destroy(ref CPointWorldText? ent)
|
||||||
|
{
|
||||||
|
if (ent is { IsValid: true }) ent.Remove();
|
||||||
|
ent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const float EyeZFallback = 64f; // ViewOffset.Z fallback when the pawn doesn't report one
|
||||||
|
|
||||||
|
// The eye-relative frame for placing a panel in front of a pawn's view: eye position + forward/right/up basis + the
|
||||||
|
// panel orientation (yaw+270 / 90-pitch = worldtext faces the player, upright across pitch/yaw). false if AbsOrigin is
|
||||||
|
// null. Callers add only their own per-panel offsets. Shared by the HUD + the shop/draft panels.
|
||||||
|
public static bool TryEyeFrame(CCSPlayerPawn pawn, out Vector eye, out Vector fwd, out Vector right, out Vector up, out QAngle ang)
|
||||||
|
{
|
||||||
|
eye = null!; fwd = null!; right = null!; up = null!; ang = null!;
|
||||||
|
var origin = pawn.AbsOrigin;
|
||||||
|
if (origin is null) return false;
|
||||||
|
var ea = pawn.EyeAngles;
|
||||||
|
float eyeZ = pawn.ViewOffset?.Z ?? EyeZFallback;
|
||||||
|
(fwd, right, up) = AngleVectors(ea);
|
||||||
|
eye = new Vector(origin.X, origin.Y, origin.Z + eyeZ);
|
||||||
|
ang = new QAngle(0f, ea.Y + 270f, 90f - ea.X);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
|
||||||
|
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
|
||||||
|
{
|
||||||
|
const double d2r = Math.PI / 180.0;
|
||||||
|
double p = a.X * d2r, y = a.Y * d2r;
|
||||||
|
double sp = Math.Sin(p), cp = Math.Cos(p), sy = Math.Sin(y), cy = Math.Cos(y);
|
||||||
|
return (
|
||||||
|
new Vector((float)(cp * cy), (float)(cp * sy), (float)(-sp)),
|
||||||
|
new Vector((float)sy, (float)(-cy), 0f),
|
||||||
|
new Vector((float)(sp * cy), (float)(sp * sy), (float)cp));
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Outnumbered/Feel.cs
Normal file
76
Outnumbered/Feel.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// "Ability is active" feedback. Two layers, sharing one blended colour:
|
||||||
|
// 1. HUD recolour (RELIABLE): while any ability is active the whole HUD tints to the blended perk colour.
|
||||||
|
// 2. Screen tint (EXPERIMENTAL): a faint full-screen "movie filter" via the CS2 fade user message — the exact
|
||||||
|
// message format is unverified and hard to debug blind, so it's toggleable (Abilities.ScreenTint) and the
|
||||||
|
// HUD recolour is the guaranteed fallback.
|
||||||
|
// Per-perk colours come from AbilityDef.Tint{R,G,B}; when several are up the colours are averaged.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private readonly Dictionary<int, int> _tintState = new(); // slot -> last packed RGBA applied via fade (0 = none)
|
||||||
|
private int _feelTick;
|
||||||
|
private bool _fadeWarned;
|
||||||
|
|
||||||
|
// Per-player sound cue via the client `play` command (built-in CS2 sounds; empty = none).
|
||||||
|
private void PlaySound(CCSPlayerController? p, string path)
|
||||||
|
{
|
||||||
|
if (!Config.Sounds.Enabled || string.IsNullOrEmpty(path) || p is not { IsValid: true } || p.IsBot) return;
|
||||||
|
p.ExecuteClientCommand($"play {path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize_Feel() { /* nothing to initialize — OnTick runs via OnTick_All (shared roster walk); teardown is in Shutdown_Feel */ }
|
||||||
|
|
||||||
|
private void Shutdown_Feel()
|
||||||
|
{
|
||||||
|
foreach (var p in Utilities.GetPlayers()) // don't leave a stuck tint on reload
|
||||||
|
if (p is { IsValid: true } && !p.IsBot) SendFade(p, 0, 0, 0, 0, clear: true);
|
||||||
|
_tintState.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Averaged colour of the player's currently-active abilities.
|
||||||
|
private (bool any, int r, int g, int b) BlendActiveTint(PlayerData pd)
|
||||||
|
{
|
||||||
|
if (!Config.Abilities.Enabled) return (false, 0, 0, 0);
|
||||||
|
long r = 0, g = 0, b = 0; int n = 0;
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
if (AbilityActive(pd, i)) { var d = AbilityCfg(i); r += d.TintR; g += d.TintG; b += d.TintB; n++; }
|
||||||
|
return n == 0 ? (false, 0, 0, 0) : (true, (int)(r / n), (int)(g / n), (int)(b / n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- screen tint (fade) ----
|
||||||
|
private void OnTick_Feel(List<CCSPlayerController> players)
|
||||||
|
{
|
||||||
|
if (!Config.Abilities.Enabled || !Config.Abilities.ScreenTint) return;
|
||||||
|
if (++_feelTick % 8 != 0) return; // ~8 Hz; only sends on change anyway
|
||||||
|
foreach (var p in players)
|
||||||
|
{
|
||||||
|
if (!IsHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) continue;
|
||||||
|
|
||||||
|
var (any, r, g, b) = BlendActiveTint(pd);
|
||||||
|
int a = any ? Math.Clamp(Config.Abilities.TintAlpha, 0, 255) : 0;
|
||||||
|
int packed = any ? ScreenFade.Pack(r, g, b, a) : 0;
|
||||||
|
if (!_tintState.TryGetValue(p.Slot, out var last)) last = 0;
|
||||||
|
if (last == packed) continue;
|
||||||
|
_tintState[p.Slot] = packed;
|
||||||
|
|
||||||
|
if (any) SendFade(p, r, g, b, a, clear: false);
|
||||||
|
// clear by fading the PREVIOUS colour back out (FFADE_IN needs a colour to fade from) + purge the stayout
|
||||||
|
else SendFade(p, last & 0xff, (last >> 8) & 0xff, (last >> 16) & 0xff, (last >> 24) & 0xff, clear: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CS2 screen fade via user message (the message format + send live in Engine.ScreenFade); a wrong field name no-ops
|
||||||
|
// via onError instead of crashing. The blend/decision logic stays here in OnTick_Feel.
|
||||||
|
private void SendFade(CCSPlayerController p, int r, int g, int b, int a, bool clear) =>
|
||||||
|
ScreenFade.Send(p, r, g, b, a, clear,
|
||||||
|
ex => { if (!_fadeWarned) { _fadeWarned = true; Logger.LogWarning(ex, "Outnumbered fade send failed"); } });
|
||||||
|
}
|
||||||
292
Outnumbered/GunGame.cs
Normal file
292
Outnumbered/GunGame.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Gun Game: climb a weapon ladder by kills; the FULL RPG kit (stats, abilities, handicap, XP) rides along.
|
||||||
|
// BOTS climb the same ladder, so players can actually lose (a bot topping the ladder ends the map). A headshot
|
||||||
|
// kill demotes the victim by one kill of ladder progress (whoever it is) — the arms-race knife-steal, generalised.
|
||||||
|
// Your gun is dictated by your rung, so weapon selection is off and the shop is skills-only. Top rung = the knife
|
||||||
|
// finale. The handicap keeps a steamrolling player honest (a dominant climber deals ~0.1x / takes ~8x).
|
||||||
|
// (IMatchDriver/IDraftDriver/CardView + TdmDriver live in Modes.cs.)
|
||||||
|
public sealed class GunGameDriver(OutnumberedPlugin p) : IMatchDriver
|
||||||
|
{
|
||||||
|
private readonly OutnumberedPlugin _p = p;
|
||||||
|
|
||||||
|
public string Id => "gungame";
|
||||||
|
public IReadOnlyList<string> Maps =>
|
||||||
|
_p.Config.GunGame.Maps.Count > 0 ? _p.Config.GunGame.Maps : _p.Config.Match.Maps;
|
||||||
|
public bool WeaponShopEnabled => false;
|
||||||
|
public HandicapOverride? Handicap => _p.Config.GunGame.Handicap; // GunGame.Handicap overrides the base, if set
|
||||||
|
public int MaxHumansOnCt => _p.Config.GunGame.MaxHumansOnCt;
|
||||||
|
public string ExtraCvars => "mp_randomspawn 1;"; // scatter spawns on the small arms-race maps -> the horde clumps less
|
||||||
|
|
||||||
|
// Per-bot ladder progress (humans carry theirs on PlayerData.GgRung/GgRungKills). Keyed by slot, but CS2
|
||||||
|
// reuses slots when a bot is kicked (bot_quota drops as humans leave) and a new one is added — so we also
|
||||||
|
// stamp the occupant's UserId and reset the slot's progress when a different bot takes it (EnsureBot). Without
|
||||||
|
// that, a fresh bot could inherit a near-top rung and win spuriously. All cleared on match reset.
|
||||||
|
private readonly Dictionary<int, int> _botRung = []; // per-bot mode (BotSharedLadder=false): slot -> rung
|
||||||
|
private readonly Dictionary<int, int> _botRungKills = [];
|
||||||
|
private readonly Dictionary<int, int> _botUserId = [];
|
||||||
|
private readonly Dictionary<int, int> _batchRung = []; // shared mode (default): batchId -> rung
|
||||||
|
private readonly Dictionary<int, int> _batchRungKills = [];
|
||||||
|
private readonly Dictionary<int, (int batch, int uid)> _botBatch = []; // slot -> STABLE batch assignment (+ occupant UserId)
|
||||||
|
public void OnMatchReset() { _botRung.Clear(); _botRungKills.Clear(); _botUserId.Clear(); _batchRung.Clear(); _batchRungKills.Clear(); _botBatch.Clear(); _ladder = null; }
|
||||||
|
|
||||||
|
private bool Pooled => _p.Config.GunGame.BotSharedLadder;
|
||||||
|
|
||||||
|
// A bot's batch, assigned ONCE (to the smallest current batch) and cached by slot+UserId, so a bot KEEPS its
|
||||||
|
// batch for the whole match across respawns/roster churn — a slot-rank formula would jitter as bots respawn/leave,
|
||||||
|
// hopping batches and showing mismatched weapons. Batches stay ≈BotsPerHuman-sized; solo (4 bots) = one stable
|
||||||
|
// batch. Cleared on map start.
|
||||||
|
private int BotBatch(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
int slot = bot.Slot, uid = bot.UserId ?? -1;
|
||||||
|
if (_botBatch.TryGetValue(slot, out var e) && e.uid == uid) return e.batch; // same bot -> same batch, always
|
||||||
|
|
||||||
|
int per = Math.Max(1, _p.Config.Match.BotsPerHuman);
|
||||||
|
int botCount = Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
|
||||||
|
int batches = Math.Max(1, (botCount + per - 1) / per); // ≈ one batch per player's worth of bots
|
||||||
|
var counts = new int[batches];
|
||||||
|
foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++;
|
||||||
|
int pick = 0; // assign the new bot to the smallest batch
|
||||||
|
for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i;
|
||||||
|
_botBatch[slot] = (pick, uid);
|
||||||
|
return pick;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-equip EVERY alive bot IN A BATCH to that batch's current rung, so the batch is ALWAYS in sync (same weapon).
|
||||||
|
// Called on any batch rung change. Without it only the killer upgrades and its batch-mates lag a rung behind until
|
||||||
|
// respawn — so a lagging bot could land the winning/visible kill on the wrong gun.
|
||||||
|
private void RegiveBatch(int batch) => Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
foreach (var p in Utilities.GetPlayers())
|
||||||
|
if (OutnumberedPlugin.IsLiveBot(p) && BotBatch(p) == batch) _p.ApplyLoadout(p, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ladder position in 0..1 (0 = bottom rung, 1 = top), fed to the handicap so climbing nerfs the player harder
|
||||||
|
// and a headshot-demotion eases it. Counts rungs directly (no full BuildLadder) since this runs per damage/tick.
|
||||||
|
public double HandicapProgress(PlayerData pd)
|
||||||
|
{
|
||||||
|
int count = Ladder().Count; // cached; == the old non-whitespace-rungs + knife count for every meaningful ladder
|
||||||
|
if (count <= 1) return 0.0;
|
||||||
|
return Math.Clamp(pd.GgRung / (double)(count - 1), 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shop info-panel ladder line: current rung / total + kills toward advancing + the rung weapon. null = no ladder.
|
||||||
|
public string? LadderStatusLine(PlayerData pd)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return null;
|
||||||
|
int r = Math.Clamp(pd.GgRung, 0, ladder.Count - 1);
|
||||||
|
return $"Rung {r + 1}/{ladder.Count} ({pd.GgRungKills}/{ladder[r].PlayerKills}): {OutnumberedPlugin.WeaponDisplayName(ladder[r].Weapon)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect a new bot occupying a (possibly reused) slot and zero its ladder progress. UserId is stable across a
|
||||||
|
// bot's respawns but differs for a freshly-added bot, so same-bot respawns keep progress; slot reuse resets it.
|
||||||
|
private void EnsureBot(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
int slot = bot.Slot, uid = bot.UserId ?? -1;
|
||||||
|
if (_botUserId.GetValueOrDefault(slot, int.MinValue) != uid)
|
||||||
|
{
|
||||||
|
_botUserId[slot] = uid;
|
||||||
|
_botRung[slot] = 0;
|
||||||
|
_botRungKills[slot] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ladder rung: the weapon you hold + how many kills clear it (advance / win), separately for players and bots.
|
||||||
|
public readonly record struct Rung(string Weapon, int PlayerKills, int BotKills);
|
||||||
|
|
||||||
|
// Cached ladder — BuildLadder allocates a List + per-rung records, and it's read per kill/headshot/loadout/HUD call.
|
||||||
|
// Rebuild only when the GunGame config INSTANCE changes (!og_reload swaps the whole Config, flipping the ref)
|
||||||
|
// or on match reset. CONTRACT: an in-place edit to GunGame.Ladder would NOT invalidate this — only a Config swap does.
|
||||||
|
// Callers read the list read-only, so sharing the cached instance is safe.
|
||||||
|
private List<Rung>? _ladder;
|
||||||
|
private GunGameConfig? _ladderCfg;
|
||||||
|
private List<Rung> Ladder()
|
||||||
|
{
|
||||||
|
var gg = _p.Config.GunGame;
|
||||||
|
if (_ladder is null || !ReferenceEquals(_ladderCfg, gg)) { _ladder = BuildLadder(); _ladderCfg = gg; }
|
||||||
|
return _ladder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built (and cached by Ladder()) so live config edits (!og_reload) take effect. The weapon ORDER is shared; the kills
|
||||||
|
// to clear a rung come from the weapon's TIER via the two inverse curves (players grind weak / breeze strong; bots
|
||||||
|
// the reverse). The knife finale (if enabled) wins on 1 kill for both sides.
|
||||||
|
public List<Rung> BuildLadder()
|
||||||
|
{
|
||||||
|
var gg = _p.Config.GunGame;
|
||||||
|
var rungs = new List<Rung>(gg.Ladder.Count + 1);
|
||||||
|
foreach (var e in gg.Ladder)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(e.Weapon)) continue;
|
||||||
|
rungs.Add(new Rung(e.Weapon, KillsForTier(gg.PlayerKillsByTier, e.Tier), KillsForTier(gg.BotKillsByTier, e.Tier)));
|
||||||
|
}
|
||||||
|
if (gg.KnifeFinale) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1));
|
||||||
|
if (rungs.Count == 0) rungs.Add(new Rung(EngineNames.WeaponKnife, 1, 1)); // safety: always a reachable win
|
||||||
|
return rungs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status API extras: the ladder as display names, so the site maps each player's Rung -> current weapon without a
|
||||||
|
// per-player driver call (players carry their rung in the shared payload). Rebuilt per status refresh (~2s) — cold.
|
||||||
|
public object? StatusExtra()
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
var names = new string[ladder.Count];
|
||||||
|
for (int i = 0; i < ladder.Count; i++) names[i] = OutnumberedPlugin.WeaponDisplayName(ladder[i].Weapon);
|
||||||
|
return new { Ladder = names };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kills for a tier (1-based) from a curve list; clamps to the curve's bounds and floors at 1.
|
||||||
|
private static int KillsForTier(List<int> curve, int tier)
|
||||||
|
{
|
||||||
|
if (curve is null || curve.Count == 0) return 1;
|
||||||
|
return Math.Max(1, curve[Math.Clamp(tier - 1, 0, curve.Count - 1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GiveHumanLoadout(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return;
|
||||||
|
int rung = Math.Clamp(_p.PdOf(p)?.GgRung ?? 0, 0, ladder.Count - 1);
|
||||||
|
GiveRungWeapon(p, ladder[rung].Weapon);
|
||||||
|
_p.GiveShopCarriers(p); // healthshot + zeus (the zeus rest anchor also fixes the knife-rung grenade-auto-deploy bug)
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GiveBotLoadout(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return;
|
||||||
|
int rung;
|
||||||
|
if (Pooled) rung = Math.Clamp(_batchRung.GetValueOrDefault(BotBatch(bot)), 0, ladder.Count - 1);
|
||||||
|
else { EnsureBot(bot); rung = Math.Clamp(_botRung.GetValueOrDefault(bot.Slot), 0, ladder.Count - 1); }
|
||||||
|
GiveRungWeapon(bot, ladder[rung].Weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The knife rung adds nothing — the core always gives the knife, so the finale is a pure knife fight.
|
||||||
|
private static void GiveRungWeapon(CCSPlayerController p, string weapon)
|
||||||
|
{
|
||||||
|
if (!OutnumberedPlugin.WeapEq(weapon, EngineNames.WeaponKnife)) p.GiveNamedItem(weapon);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return;
|
||||||
|
int rung = Math.Clamp(apd.GgRung, 0, ladder.Count - 1);
|
||||||
|
|
||||||
|
if (++apd.GgRungKills < ladder[rung].PlayerKills) return; // still climbing this rung
|
||||||
|
|
||||||
|
apd.GgRungKills = 0;
|
||||||
|
apd.GgRung = rung + 1;
|
||||||
|
if (apd.GgRung >= ladder.Count) // cleared the final (knife) rung -> win
|
||||||
|
{
|
||||||
|
apd.GgRung = ladder.Count - 1;
|
||||||
|
// Kills keep processing through the map-end grace window (changelevel is delayed a few seconds). A ladder
|
||||||
|
// topped in that window — after a bot win or an earlier human winner — is NOT a match result: no record.
|
||||||
|
if (_p.MapEnding) return;
|
||||||
|
long? runMs = _p.RecordGgWin(apd); // improve-only leaderboard write; null if the clock never armed
|
||||||
|
_p.TriggerMapEnd(runMs is { } t
|
||||||
|
? $"{attacker.PlayerName} climbed the whole ladder in {OutnumberedPlugin.FormatRunTime(t)} — GUN GAME!"
|
||||||
|
: $"{attacker.PlayerName} climbed the whole ladder — GUN GAME!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RegiveLive(attacker.Slot, isBot: false); // hand over the next rung's gun live
|
||||||
|
var next = ladder[apd.GgRung];
|
||||||
|
attacker.PrintToChat($" {ChatColors.Gold}[Gun Game] {ChatColors.Default}Rung {apd.GgRung + 1}/{ladder.Count}: " +
|
||||||
|
$"{ChatColors.Lime}{OutnumberedPlugin.WeaponDisplayName(next.Weapon)} {ChatColors.Default}({next.PlayerKills} to advance)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnBotKill(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return;
|
||||||
|
|
||||||
|
if (Pooled) // the bot's BATCH shares one rung; any member's kill advances it and the WHOLE batch re-equips
|
||||||
|
{
|
||||||
|
int batch = BotBatch(bot);
|
||||||
|
int rung = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1);
|
||||||
|
int kills = _batchRungKills.GetValueOrDefault(batch) + 1;
|
||||||
|
if (kills < ladder[rung].BotKills) { _batchRungKills[batch] = kills; return; }
|
||||||
|
_batchRungKills[batch] = 0;
|
||||||
|
rung++;
|
||||||
|
if (rung >= ladder.Count) // a batch topped the ladder -> the humans lose
|
||||||
|
{
|
||||||
|
_batchRung[batch] = ladder.Count - 1;
|
||||||
|
_p.TriggerMapEnd("The bot horde climbed the whole ladder — bots win!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_batchRung[batch] = rung;
|
||||||
|
RegiveBatch(batch); // the whole batch jumps to the new rung weapon together (no lagging-bot desync)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureBot(bot); // per-bot mode (BotSharedLadder=false): each bot climbs alone
|
||||||
|
int slot = bot.Slot;
|
||||||
|
int r = Math.Clamp(_botRung.GetValueOrDefault(slot), 0, ladder.Count - 1);
|
||||||
|
int k = _botRungKills.GetValueOrDefault(slot) + 1;
|
||||||
|
if (k < ladder[r].BotKills) { _botRungKills[slot] = k; return; }
|
||||||
|
_botRungKills[slot] = 0;
|
||||||
|
r++;
|
||||||
|
if (r >= ladder.Count)
|
||||||
|
{
|
||||||
|
_botRung[slot] = ladder.Count - 1;
|
||||||
|
_p.TriggerMapEnd($"The bots won — {bot.PlayerName} topped the ladder!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_botRung[slot] = r;
|
||||||
|
RegiveLive(slot, isBot: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headshot death = lose one kill of ladder progress; underflow drops a rung (landing one kill from re-advancing).
|
||||||
|
public void OnHeadshotDeath(CCSPlayerController victim)
|
||||||
|
{
|
||||||
|
var ladder = Ladder();
|
||||||
|
if (ladder.Count == 0) return;
|
||||||
|
if (victim.IsBot)
|
||||||
|
{
|
||||||
|
if (Pooled) // a headshot on any bot knocks its WHOLE batch down a weapon (your suppression tool)
|
||||||
|
{
|
||||||
|
int batch = BotBatch(victim);
|
||||||
|
int cur = Math.Clamp(_batchRung.GetValueOrDefault(batch), 0, ladder.Count - 1);
|
||||||
|
var (br, bk) = Demote(cur, _batchRungKills.GetValueOrDefault(batch), ladder, bot: true);
|
||||||
|
bool changed = br != cur;
|
||||||
|
_batchRung[batch] = br; _batchRungKills[batch] = bk;
|
||||||
|
if (changed) RegiveBatch(batch); // every bot in the batch drops to the lower rung weapon together
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
EnsureBot(victim);
|
||||||
|
int slot = victim.Slot;
|
||||||
|
var (nr, nk) = Demote(_botRung.GetValueOrDefault(slot), _botRungKills.GetValueOrDefault(slot), ladder, bot: true);
|
||||||
|
_botRung[slot] = nr; _botRungKills[slot] = nk; // respawn gives the demoted weapon (GiveBotLoadout reads it)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pd = _p.PdOf(victim);
|
||||||
|
if (pd is null) return;
|
||||||
|
var (nr, nk) = Demote(pd.GgRung, pd.GgRungKills, ladder, bot: false);
|
||||||
|
bool droppedRung = nr != Math.Clamp(pd.GgRung, 0, ladder.Count - 1);
|
||||||
|
pd.GgRung = nr; pd.GgRungKills = nk; // victim is dead; respawn gives the demoted weapon
|
||||||
|
if (droppedRung)
|
||||||
|
victim.PrintToChat($" {ChatColors.Red}[Gun Game] Headshot! {ChatColors.Default}Dropped to " +
|
||||||
|
$"{ChatColors.LightYellow}{OutnumberedPlugin.WeaponDisplayName(ladder[nr].Weapon)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lose one kill of progress; underflow drops a rung, landing one kill short of re-advancing (per-side kills).
|
||||||
|
private static (int rung, int kills) Demote(int rung, int kills, List<Rung> ladder, bool bot)
|
||||||
|
{
|
||||||
|
rung = Math.Clamp(rung, 0, ladder.Count - 1);
|
||||||
|
if (kills > 0) return (rung, kills - 1);
|
||||||
|
if (rung > 0) { int need = bot ? ladder[rung - 1].BotKills : ladder[rung - 1].PlayerKills; return (rung - 1, Math.Max(0, need - 1)); }
|
||||||
|
return (0, 0); // already rock bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegiveLive(int slot, bool isBot) =>
|
||||||
|
OutnumberedPlugin.NextFrameForSlot(slot, pl => { if (pl.IsBot == isBot) _p.ApplyLoadout(pl, isBot); }, requireAlive: true);
|
||||||
|
}
|
||||||
51
Outnumbered/Handicap.cs
Normal file
51
Outnumbered/Handicap.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The per-player handicap (the balance spine; the HUD shows each player their live deal/take/XP bands). The MATH lives in Outnumbered.Domain.HandicapModel — a single
|
||||||
|
// signed index t in [-1,+1] driving deal/take/XP together so they reach their extremes at the SAME thresholds. These are
|
||||||
|
// zero-math accessors that snapshot the player and call it. The hot damage hook reaches HandicapModel through
|
||||||
|
// CombatResolver (one snapshot); the HUD + progression use these adapters.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// The effective handicap for the active mode = base Config.Handicap with the mode's overrides applied, wrapped in a
|
||||||
|
// ResolvedHandicap (config + precomputed guard denominators). Rebuilt at driver-select (Initialize_Driver) and on
|
||||||
|
// !og_reload, so it stays live-tunable AND ComputeT never recomputes the config-invariant terms per hit/tick.
|
||||||
|
private ResolvedHandicap _hcap = new(new HandicapConfig());
|
||||||
|
internal void RebuildEffectiveHandicap() =>
|
||||||
|
_hcap = new ResolvedHandicap(_driver?.Handicap is { } o ? o.ApplyTo(Config.Handicap) : Config.Handicap);
|
||||||
|
|
||||||
|
// The XP-rate bridge (used by GrantXp + the HUD). The deal/take chains go straight through CombatResolver ->
|
||||||
|
// HandicapModel.MDeal/MTake on a snapshot, so there are no MDeal(pd)/MTake(pd) bridge methods here.
|
||||||
|
private double HandicapXpMult(PlayerData pd) => HandicapModel.XpMult(Snapshot(pd), _hcap);
|
||||||
|
|
||||||
|
// One-time structural guard that HandicapOverride stays a faithful mirror of HandicapConfig: every base field needs a
|
||||||
|
// matching nullable override AND ApplyTo must actually wire it. Catches the documented footgun — add a field to both
|
||||||
|
// POCOs but forget the `X ?? b.X` line in ApplyTo, and that field's per-mode override is silently ignored (a tuner sees
|
||||||
|
// "the GunGame/Survival override doesn't work"). Probes ApplyTo functionally via reflection. Structural, not value-
|
||||||
|
// dependent, so it runs once at load (not per !og_reload). All current HandicapConfig fields are bool/int/double.
|
||||||
|
private void Initialize_Handicap()
|
||||||
|
{
|
||||||
|
var baseCfg = new HandicapConfig();
|
||||||
|
foreach (var bp in typeof(HandicapConfig).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||||
|
{
|
||||||
|
if (!bp.CanRead || !bp.CanWrite) continue;
|
||||||
|
var op = typeof(HandicapOverride).GetProperty(bp.Name);
|
||||||
|
if (op is null)
|
||||||
|
{
|
||||||
|
Logger.LogError("Outnumbered: HandicapOverride is missing field '{Field}' that HandicapConfig defines (add a nullable mirror + an ApplyTo line).", bp.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
object? sentinel = bp.GetValue(baseCfg) switch { bool b => !b, int i => i + 1, double d => d + 1.0, _ => null };
|
||||||
|
if (sentinel is null) continue; // non-bool/int/double field: can't auto-probe ApplyTo wiring here
|
||||||
|
var ov = new HandicapOverride();
|
||||||
|
op.SetValue(ov, sentinel);
|
||||||
|
if (!Equals(bp.GetValue(ov.ApplyTo(baseCfg)), sentinel))
|
||||||
|
Logger.LogError("Outnumbered: HandicapOverride.ApplyTo does not propagate '{Field}' — its per-mode override is silently ignored (add the matching `?? b.` line).", bp.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
268
Outnumbered/Hud.cs
Normal file
268
Outnumbered/Hud.cs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
using System.Text;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The always-on HUD: handicap multipliers (Dmg Out/In, XP), level/prestige/streak, and the 5 ability states.
|
||||||
|
//
|
||||||
|
// Mode "world": a per-player CPointWorldText entity placed in front of the camera (positionable, font-sized).
|
||||||
|
// It's a real world entity, so CheckTransmit must hide each player's HUD from everyone else. Single-color text.
|
||||||
|
// Mode "center": a PrintToCenterHtml panel (multi-color, but fixed center + fixed width).
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private readonly HashSet<ulong> _hudOff = []; // per-player !hud opt-out
|
||||||
|
private readonly Dictionary<int, CPointWorldText> _hudEntities = []; // slot -> world-text entity (world mode)
|
||||||
|
private readonly Dictionary<int, string> _hudText = []; // slot -> last text pushed (skip redundant SetMessage)
|
||||||
|
private readonly HashSet<int> _centerShown = []; // center mode: slots currently showing the panel
|
||||||
|
private int _hudTick;
|
||||||
|
private readonly StringBuilder _hudSb = new(); // reused across HUD builds (game-thread, sequential)
|
||||||
|
private readonly List<int> _hudReap = []; // reused stale-slot scratch (world mode), avoids a per-tick Keys.ToList()
|
||||||
|
private Action<int>? _hudReapAction; // cached DestroyHud delegate (so ReapOrphanSlots adds no per-tick closure)
|
||||||
|
|
||||||
|
private static readonly (int v, string s)[] RomanMap =
|
||||||
|
[ (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
|
||||||
|
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I") ];
|
||||||
|
|
||||||
|
// Prestige rendered as a Roman numeral (0 = no prestige).
|
||||||
|
public static string Roman(int n)
|
||||||
|
{
|
||||||
|
if (n <= 0) return "0";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var (v, s) in RomanMap) while (n >= v) { sb.Append(s); n -= v; }
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Initialize_Hud()
|
||||||
|
{
|
||||||
|
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Hud is invoked from there.
|
||||||
|
RegisterListener<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Hud()
|
||||||
|
{
|
||||||
|
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Hud);
|
||||||
|
foreach (var slot in _hudEntities.Keys.ToList()) DestroyHud(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-tick ----
|
||||||
|
private void OnTick_Hud(List<CCSPlayerController> players)
|
||||||
|
{
|
||||||
|
if (!Config.Hud.Enabled) { return; }
|
||||||
|
int every = Math.Max(1, Config.Hud.RefreshEveryTicks);
|
||||||
|
if (++_hudTick % every != 0) return;
|
||||||
|
|
||||||
|
bool world = !string.Equals(Config.Hud.Mode, "center", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Reap entities for players who disconnected (world mode) — shared collect-then-act helper (DestroyHud mutates
|
||||||
|
// _hudEntities), reused scratch + cached delegate so there's no per-tick alloc.
|
||||||
|
if (world && _hudEntities.Count > 0)
|
||||||
|
ReapOrphanSlots(_hudEntities, _hudReap, _hudReapAction ??= DestroyHud);
|
||||||
|
|
||||||
|
foreach (var p in players)
|
||||||
|
{
|
||||||
|
if (!IsHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||||
|
var sid = p.AuthorizedSteamID?.SteamId64; // drives the per-player !hud opt-out (_hudOff)
|
||||||
|
bool show = sid is not null && !_hudOff.Contains(sid.Value) && p.PawnIsAlive
|
||||||
|
&& _players.TryGetValue(sid.Value, out _);
|
||||||
|
|
||||||
|
if (!world)
|
||||||
|
{
|
||||||
|
if (show && _players.TryGetValue(sid!.Value, out var cpd))
|
||||||
|
{
|
||||||
|
p.PrintToCenterHtml(BuildHudHtml(p, cpd));
|
||||||
|
_centerShown.Add(p.Slot);
|
||||||
|
}
|
||||||
|
else if (_centerShown.Remove(p.Slot)) // was showing, now hidden -> clear the stale panel once (it doesn't self-clear)
|
||||||
|
{
|
||||||
|
p.PrintToCenterHtml("");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!show) { DestroyHud(p.Slot); continue; }
|
||||||
|
_players.TryGetValue(sid!.Value, out var pd);
|
||||||
|
var ent = EnsureHud(p);
|
||||||
|
if (ent is null || pd is null) continue;
|
||||||
|
UpdateWorldHud(p, ent, pd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// each player sees only their own HUD entity
|
||||||
|
private void OnCheckTransmit_Hud(CCheckTransmitInfoList infoList) =>
|
||||||
|
HideForeignSlotEntities(infoList, _hudEntities,
|
||||||
|
static (info, ent) => { if (ent is { IsValid: true }) info.TransmitEntities.Remove(ent); });
|
||||||
|
|
||||||
|
// ---- world-text entity ----
|
||||||
|
private CPointWorldText? EnsureHud(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
if (_hudEntities.TryGetValue(p.Slot, out var ent) && ent is { IsValid: true }) return ent;
|
||||||
|
_hudEntities.Remove(p.Slot);
|
||||||
|
_hudText.Remove(p.Slot);
|
||||||
|
var created = CreateHud();
|
||||||
|
if (created is not null) _hudEntities[p.Slot] = created;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CPointWorldText? CreateHud()
|
||||||
|
{
|
||||||
|
var h = Config.Hud;
|
||||||
|
return WorldText.Create(h.FontSize, h.WorldUnitsPerPx, h.FontName,
|
||||||
|
System.Drawing.Color.FromArgb(255, h.ColorR, h.ColorG, h.ColorB), h.DrawBackground, 0.1f,
|
||||||
|
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DestroyHud(int slot)
|
||||||
|
{
|
||||||
|
if (_hudEntities.Remove(slot, out CPointWorldText? ent)) WorldText.Destroy(ref ent);
|
||||||
|
_hudText.Remove(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateWorldHud(CCSPlayerController p, CPointWorldText ent, PlayerData pd)
|
||||||
|
{
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pawn is null || !WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return;
|
||||||
|
var h = Config.Hud;
|
||||||
|
var pos = eye + fwd * h.ForwardOffset + right * h.RightOffset + up * h.UpOffset;
|
||||||
|
|
||||||
|
string text = BuildHudText(p, pd);
|
||||||
|
if (!_hudText.TryGetValue(p.Slot, out var prev) || prev != text)
|
||||||
|
{
|
||||||
|
WorldText.SetText(ent, text);
|
||||||
|
_hudText[p.Slot] = text;
|
||||||
|
}
|
||||||
|
WorldText.Place(ent, pos, ang);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- effective multipliers shown on the HUD / shop / !stats ----
|
||||||
|
// The SINGLE source for the four readouts: build ONE snapshot + run ONE ComputeT (via HandicapModel.Bands) and derive
|
||||||
|
// HS / Out / In / XP from it. Out/In come from the SAME shared CombatResolver chains the damage hook applies
|
||||||
|
// (headshot:false, crit:false = the base readout), so a readout can never drift from real damage. Used by the HUD
|
||||||
|
// (per tick) and the cold shop/!stats paths.
|
||||||
|
private (double hs, double md, double mt, double xp) EffectiveMultipliers(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
var s = Snapshot(pd, p);
|
||||||
|
HandicapModel.Bands(s, _hcap, out double deal, out double take, out double xpBand);
|
||||||
|
double md = CombatResolver.OffenseMultiplier(s, headshot: false, crit: false, _statDefs, Config.Abilities, BaseMaxHp, deal);
|
||||||
|
double mt = CombatResolver.DefenseMultiplier(s, headshot: false, Config.Abilities, take);
|
||||||
|
double hs = 1.0 + StatResolver.EffRun(s, StatKeys.HeadshotDamage, _statDefs) / 100.0;
|
||||||
|
double xp = (1.0 + StatResolver.Eff(s, StatKeys.XpBoost, _statDefs) / 100.0)
|
||||||
|
* ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression) * xpBand;
|
||||||
|
return (hs, md, mt, xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- content ----
|
||||||
|
private string BuildHudText(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
var (hs, md, mt, xp) = EffectiveMultipliers(p, pd);
|
||||||
|
long next = XpToNext(pd.Level);
|
||||||
|
double now = Server.CurrentTime; // read once for all ability segments (clock can't advance within this build)
|
||||||
|
var sb = _hudSb; sb.Clear();
|
||||||
|
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append(wline).Append('\n');
|
||||||
|
sb.Append($"Lvl {pd.Level} ({pd.Xp}/{next}) Prestige {Roman(pd.Prestige)} {pd.Points} pt Streak {pd.Streak}\n");
|
||||||
|
sb.Append($"HS x{hs:F2} Out x{md:F2} In x{mt:F2} XP x{xp:F2}\n");
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(" ");
|
||||||
|
sb.Append(AbilityTextSegment(pd, i, now));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string AbilityTextSegment(PlayerData pd, int i, double now)
|
||||||
|
{
|
||||||
|
string suffix =
|
||||||
|
AbilityActive(pd, i, now) ? $" ON{pd.AbilityActiveUntil[i] - now:F0}" :
|
||||||
|
!AbilityReady(pd, i, now) ? $" {pd.AbilityReadyAt[i] - now:F0}s" :
|
||||||
|
AbilityUnlocked(pd, i) ? "" :
|
||||||
|
$" L{AbilityCfg(i).StreakReq}";
|
||||||
|
return $"{AbilityRegistry[i].KeyNum}:{AbilityRegistry[i].Short}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- center-HTML mode (crisp, multi-color, default) ----
|
||||||
|
private string AbilityIcon(int i)
|
||||||
|
{
|
||||||
|
var list = Config.Hud.AbilityIcons;
|
||||||
|
return i >= 0 && i < list.Count && !string.IsNullOrEmpty(list[i]) ? list[i] : AbilityRegistry[i].Short;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildHudHtml(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
var (hs, md, mt, xp) = EffectiveMultipliers(p, pd);
|
||||||
|
long next = XpToNext(pd.Level);
|
||||||
|
double now = Server.CurrentTime; // read once for the ability segments + the prestige animation phase
|
||||||
|
string streakColor = pd.Streak > 0 ? "#ffd000" : "#bbbbbb";
|
||||||
|
|
||||||
|
string ptsColor = pd.Points > 0 ? "#66ff66" : "#888888";
|
||||||
|
var sb = _hudSb; sb.Clear();
|
||||||
|
if (_driver.HudStatusLine(pd) is { Length: > 0 } wline) sb.Append($"<font color='#ff5a5a'>{wline}</font><br>");
|
||||||
|
sb.Append($"<font color='#ffae00'>Lv{pd.Level}</font> {PrestigeHudTag(pd.Prestige, now)}");
|
||||||
|
sb.Append($" <font color='{ptsColor}'>{pd.Points} pt</font>");
|
||||||
|
sb.Append($" <font color='{streakColor}'>Streak {pd.Streak}</font>");
|
||||||
|
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, next)); // progress through current level; 0-100
|
||||||
|
sb.Append($" <font color='#cccccc'>{xpPct}% xp</font><br>");
|
||||||
|
sb.Append($"<font color='#ffffff'>HS </font><font color='#ffcf6b'>{hs:F2}</font>");
|
||||||
|
sb.Append($" <font color='#ffffff'>Out </font><font color='#ff9a9a'>{md:F2}</font>");
|
||||||
|
sb.Append($" <font color='#ffffff'>In </font><font color='#ff9a9a'>{mt:F2}</font>");
|
||||||
|
sb.Append($" <font color='#ffffff'>XP </font><font color='#7dff7d'>{xp:F2}</font><br>");
|
||||||
|
for (int i = 0; i < AbilityCount; i++)
|
||||||
|
{
|
||||||
|
if (i > 0) sb.Append(" ");
|
||||||
|
sb.Append(AbilityHudSegment(pd, i, now));
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact icon, colored by state: green ready, cyan active(+secs), orange cooldown(+secs), gray locked(+req).
|
||||||
|
private string AbilityHudSegment(PlayerData pd, int i, double now)
|
||||||
|
{
|
||||||
|
string color, suffix;
|
||||||
|
if (AbilityActive(pd, i, now)) { color = "#55ddff"; suffix = $"{pd.AbilityActiveUntil[i] - now:F0}"; }
|
||||||
|
else if (!AbilityReady(pd, i, now)) { color = "#ff8800"; suffix = $"{pd.AbilityReadyAt[i] - now:F0}"; }
|
||||||
|
else if (AbilityUnlocked(pd, i)) { color = "#66ff66"; suffix = ""; }
|
||||||
|
else { color = "#777777"; suffix = $"{AbilityCfg(i).StreakReq}"; }
|
||||||
|
return $"<font color='{color}'>{AbilityIcon(i)}{suffix}</font>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prestige tag for the HUD: solid (I-V = perk colour) or an animated flowing gradient (VI-X).
|
||||||
|
private string PrestigeHudTag(int prestige, double now)
|
||||||
|
{
|
||||||
|
string text = $"P{Roman(prestige)}";
|
||||||
|
if (!_ranks.PrestigeColors || prestige <= 0) return $"<font color='#cc88ff'>{text}</font>";
|
||||||
|
var cols = PrestigeColorSet(prestige);
|
||||||
|
if (cols.Length == 1)
|
||||||
|
{
|
||||||
|
var (r, g, b) = cols[0];
|
||||||
|
return $"<font color='#{r:x2}{g:x2}{b:x2}'>{text}</font>";
|
||||||
|
}
|
||||||
|
double phase = now * 0.30; // flowing animation for VI-X
|
||||||
|
int letters = 0; foreach (char ch in text) if (ch != ' ') letters++;
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int li = 0;
|
||||||
|
foreach (char ch in text)
|
||||||
|
{
|
||||||
|
if (ch == ' ') { sb.Append(' '); continue; }
|
||||||
|
double t = (letters <= 1 ? 0.0 : (double)li / letters) + phase;
|
||||||
|
var (r, g, b) = SampleRing(cols, t);
|
||||||
|
sb.Append($"<font color='#{r:x2}{g:x2}{b:x2}'>{ch}</font>");
|
||||||
|
li++;
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample a looping colour ring at position t (wraps), linear-interpolated between adjacent stops.
|
||||||
|
private static (int r, int g, int b) SampleRing((int r, int g, int b)[] c, double t)
|
||||||
|
{
|
||||||
|
int n = c.Length;
|
||||||
|
t = (t % 1.0 + 1.0) % 1.0;
|
||||||
|
double scaled = t * n;
|
||||||
|
double fl = Math.Floor(scaled);
|
||||||
|
int i0 = (int)fl % n, i1 = (i0 + 1) % n;
|
||||||
|
double f = scaled - fl;
|
||||||
|
var a = c[i0]; var b2 = c[i1];
|
||||||
|
return ((int)(a.r + (b2.r - a.r) * f), (int)(a.g + (b2.g - a.g) * f), (int)(a.b + (b2.b - a.b) * f));
|
||||||
|
}
|
||||||
|
}
|
||||||
159
Outnumbered/Modes.cs
Normal file
159
Outnumbered/Modes.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The match-driver seam (the mode split). The whole RPG core — stats, progression, handicap, abilities, the
|
||||||
|
// shop UI, persistence — is mode-agnostic. Only the match RULESET differs per mode. ResolveMode (Driver.cs)
|
||||||
|
// picks the driver at Load. The shared match plumbing (cvars, bots, team enforcement, map setup, the endless
|
||||||
|
// round timer, per-kill bookkeeping, map rotation) stays on OutnumberedPlugin (Driver.cs); a driver supplies
|
||||||
|
// only the variant points below. Adding a mode = a new IMatchDriver, not a core rewrite.
|
||||||
|
public interface IMatchDriver
|
||||||
|
{
|
||||||
|
string Id { get; }
|
||||||
|
// Map pool for changelevel rotation. Empty -> the core falls back to Match.Maps.
|
||||||
|
IReadOnlyList<string> Maps { get; }
|
||||||
|
// Whether weapon selection (the shop's Weapons screen + !guns) is offered. Off in modes that dictate your gun.
|
||||||
|
bool WeaponShopEnabled { get; }
|
||||||
|
// Per-mode handicap override (null = use the base Handicap block unchanged). Resolved by RebuildEffectiveHandicap.
|
||||||
|
HandicapOverride? Handicap { get; }
|
||||||
|
// The mode's per-player "progress" axis in 0..1, fed into the handicap nerf (weighted by Handicap.ProgressWeight).
|
||||||
|
// 0 in TDM (no axis); in Gun Game it's ladder position so climbing nerfs you and a demotion eases it.
|
||||||
|
double HandicapProgress(PlayerData pd);
|
||||||
|
// Max humans allowed on CT for this mode (EnforceHumanTeam cap).
|
||||||
|
int MaxHumansOnCt { get; }
|
||||||
|
// Extra cvars appended to the shared ApplyCvars batch (mode-specific tweaks, e.g. GG's mp_randomspawn). "" = none.
|
||||||
|
string ExtraCvars { get; }
|
||||||
|
// Give a human's / a bot's per-spawn weapons. The shared core adds the knife + armor afterwards.
|
||||||
|
void GiveHumanLoadout(CCSPlayerController p);
|
||||||
|
void GiveBotLoadout(CCSPlayerController bot);
|
||||||
|
// Mode result, run AFTER the shared per-kill bookkeeping (kills/streak/headshots/ability-ding) and BEFORE kill XP.
|
||||||
|
void OnHumanKill(CCSPlayerController attacker, PlayerData apd);
|
||||||
|
// A bot got a kill (bots have no PlayerData). No-op in TDM; in Gun Game the bot climbs the ladder.
|
||||||
|
void OnBotKill(CCSPlayerController bot);
|
||||||
|
// The victim died to a headshot (attacker != victim). No-op in TDM; in Gun Game the victim loses a kill of progress.
|
||||||
|
void OnHeadshotDeath(CCSPlayerController victim);
|
||||||
|
// New match (map start) — clear any per-match driver state (e.g. bot ladder progress).
|
||||||
|
void OnMatchReset();
|
||||||
|
|
||||||
|
// ---- survival-mode seams (default no-ops; only SurvivalDriver overrides them) ----
|
||||||
|
// Called once right after the driver is selected (Initialize_Driver), during plugin Load — the engine isn't ready
|
||||||
|
// yet, so don't touch it here (Server.CurrentTime etc. will crash). Survival only SCHEDULES its heartbeat here.
|
||||||
|
void OnActivated() { }
|
||||||
|
// Called from SetupMap once the map is up and the server is simulating (normal start AND hot-reload) — safe to touch
|
||||||
|
// the engine. Survival arms its wave machine here. No-op for TDM/GG.
|
||||||
|
void OnMapSetup() { }
|
||||||
|
// Called from Shutdown_Driver (plugin Unload / hot-reload) — tear down any long-lived driver timers so they don't
|
||||||
|
// leak or double-fire against a torn-down instance. Survival kills its wave heartbeat here. No-op for TDM/GG.
|
||||||
|
void OnDeactivated() { }
|
||||||
|
// True if the driver owns bot population (wave spawning) — the core's quota-based SyncBots then steps aside to ManageBots.
|
||||||
|
bool OwnsBotPopulation => false;
|
||||||
|
// Drive bot population (called from SyncBots when OwnsBotPopulation): survival sets bot_quota from the wave kill-budget.
|
||||||
|
void ManageBots() { }
|
||||||
|
// A human died (victim). Survival: move to spectator + run wipe detection. No-op elsewhere (native DM respawn handles it).
|
||||||
|
void OnHumanDeath(CCSPlayerController victim, PlayerData pd) { }
|
||||||
|
// A human left mid-run. Survival: bank their accumulated run-XP into the main table before the pd is dropped.
|
||||||
|
void OnHumanDisconnect(ulong steamId, PlayerData pd) { }
|
||||||
|
// Extra run-scoped stat bonus (survival cards) added on top of Eff() at every stat site via EffRun. 0 in TDM/GG.
|
||||||
|
double StatBonus(PlayerData pd, string key) => 0.0;
|
||||||
|
// The per-player run-card bonus source fed into PlayerSnapshot.Cards (so the pure Domain reads cards without a
|
||||||
|
// pawn). null in TDM/GG and outside a survival run; the survival run itself implements IStatBonusSource.
|
||||||
|
IStatBonusSource? CardSource(PlayerData pd) => null;
|
||||||
|
// A monotonic, escalate-only handicap floor in t-space [0..1] for the active mode; -1 = "no floor" (TDM/GG, so buffs work).
|
||||||
|
double HandicapFloor(PlayerData pd) => -1.0;
|
||||||
|
// Team-wide survival card multipliers folded into MDeal / MTake (so they apply to every survivor and every damage
|
||||||
|
// path). 1.0 = no team buff (TDM/GG, and survival before any global_deal/global_take is drafted).
|
||||||
|
double TeamDealMult() => 1.0;
|
||||||
|
double TeamTakeMult() => 1.0;
|
||||||
|
// Optional mode status line for the HUD (survival shows the wave / bots-left line). "" = no line.
|
||||||
|
string HudStatusLine(PlayerData pd) => "";
|
||||||
|
// Optional weapon-ladder status line for the shop info panel (Gun Game shows the current rung). null = N/A.
|
||||||
|
string? LadderStatusLine(PlayerData pd) => null;
|
||||||
|
// Mode-specific block for the local status API (Api.cs), serialized as-is into the payload's "Extra" field.
|
||||||
|
// Called on the game thread against live driver state (the ~2s status rebuild). null = no extras.
|
||||||
|
object? StatusExtra() => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability: a mode that runs the survival between-wave card DRAFT + the per-player run-XP accumulator. ONLY
|
||||||
|
// SurvivalDriver implements it; the core reaches it via `Draft` (= _driver as IDraftDriver) so it never type-checks the
|
||||||
|
// concrete driver. null everywhere else, so TDM/GG transparently skip every draft/run path.
|
||||||
|
public interface IDraftDriver
|
||||||
|
{
|
||||||
|
bool RunInProgress { get; } // a run is live -> EnforceHumanTeam blocks mid-run joins
|
||||||
|
void AccumulateWaveXp(PlayerData pd, double amount); // bank raw combat XP into THIS wave's accumulator (granted at wave clear)
|
||||||
|
bool DraftPending(CCSPlayerController p); // an unspent, spendable pick is waiting (break + draftable)
|
||||||
|
int PendingCards(CCSPlayerController p); // unspent banked picks (menu header)
|
||||||
|
List<(string key, string label)> CurrentDraw(CCSPlayerController p); // the offered hand (stable within a break)
|
||||||
|
CardView? CardInfo(CCSPlayerController p, string key); // one card's view data (name/have/cap/values/detail)
|
||||||
|
void PickCard(CCSPlayerController p, string key); // spend a pick on a drawn card
|
||||||
|
}
|
||||||
|
|
||||||
|
// View data for one draft card (the 3-card overlay): name, current/cap picks, and either the current->next % value
|
||||||
|
// (the stat cards) OR a custom Detail line (the effect cards, where a raw "+N%" would mislead).
|
||||||
|
public sealed record CardView(string Name, int Have, int Cap, double Now, double Next, bool Flat, string? Detail);
|
||||||
|
|
||||||
|
// TDM (the original mode): per-player chosen loadout, fixed bot squad; the map ends when any one player reaches the
|
||||||
|
// kill goal — OR when a bot BATCH does. Bots have no ladder, but their kills POOL per player-sized batch toward the same
|
||||||
|
// KillGoal (mirroring Gun Game's batch sharing) so the horde can actually win. Headshots don't demote.
|
||||||
|
public sealed class TdmDriver(OutnumberedPlugin p) : IMatchDriver
|
||||||
|
{
|
||||||
|
private readonly OutnumberedPlugin _p = p;
|
||||||
|
|
||||||
|
public string Id => "tdm";
|
||||||
|
public IReadOnlyList<string> Maps => _p.Config.Match.Maps;
|
||||||
|
public bool WeaponShopEnabled => true;
|
||||||
|
public HandicapOverride? Handicap => null; // TDM uses the base handicap as-is
|
||||||
|
|
||||||
|
public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p);
|
||||||
|
|
||||||
|
public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot);
|
||||||
|
|
||||||
|
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
|
||||||
|
{
|
||||||
|
if (apd.Kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bot horde wins by reaching the SAME KillGoal, but pooled PER BATCH (≈BotsPerHuman bots), not per individual bot
|
||||||
|
// and not globally: each batch races one human's worth of kills, so the pace scales with player count (N humans ↔ N
|
||||||
|
// batches) and a lone player faces exactly one batch. Pooling globally would let a 3v12 horde hit the goal ~4x too
|
||||||
|
// fast. (Batch assignment mirrors Gun Game's; kept self-contained here so it can't perturb the working GG path.)
|
||||||
|
private readonly Dictionary<int, (int batch, int uid)> _botBatch = new(); // slot -> stable batch + occupant UserId
|
||||||
|
private readonly Dictionary<int, int> _batchKills = new(); // batch -> kills pooled toward KillGoal
|
||||||
|
|
||||||
|
public void OnBotKill(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
int batch = BotBatch(bot);
|
||||||
|
int kills = _batchKills.GetValueOrDefault(batch) + 1;
|
||||||
|
_batchKills[batch] = kills;
|
||||||
|
if (kills >= _p.Config.Match.KillGoal) _p.TriggerMapEnd("The bot horde reached the kill goal — bots win!");
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? StatusExtra() => new { KillGoal = _p.Config.Match.KillGoal };
|
||||||
|
|
||||||
|
// A bot's batch: assigned ONCE to the smallest current batch and cached by slot+UserId, so it stays put across
|
||||||
|
// respawns/roster churn (a slot-rank formula would jitter as bots come and go). Batches stay ≈BotsPerHuman-sized;
|
||||||
|
// solo (4 bots) = one batch. Cleared on match reset.
|
||||||
|
private int BotBatch(CCSPlayerController bot)
|
||||||
|
{
|
||||||
|
int slot = bot.Slot, uid = bot.UserId ?? -1;
|
||||||
|
if (_botBatch.TryGetValue(slot, out var e) && e.uid == uid) return e.batch;
|
||||||
|
int per = Math.Max(1, _p.Config.Match.BotsPerHuman);
|
||||||
|
int botCount = Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
|
||||||
|
int batches = Math.Max(1, (botCount + per - 1) / per);
|
||||||
|
var counts = new int[batches];
|
||||||
|
foreach (var v in _botBatch.Values) if (v.batch < batches) counts[v.batch]++;
|
||||||
|
int pick = 0;
|
||||||
|
for (int i = 1; i < batches; i++) if (counts[i] < counts[pick]) pick = i;
|
||||||
|
_botBatch[slot] = (pick, uid);
|
||||||
|
return pick;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnHeadshotDeath(CCSPlayerController victim) { }
|
||||||
|
public void OnMatchReset() { _botBatch.Clear(); _batchKills.Clear(); }
|
||||||
|
public double HandicapProgress(PlayerData pd) => 0.0; // TDM has no progress axis
|
||||||
|
public int MaxHumansOnCt => _p.Config.Match.MaxHumansOnCt;
|
||||||
|
public string ExtraCvars => "";
|
||||||
|
}
|
||||||
72
Outnumbered/Outnumbered.cs
Normal file
72
Outnumbered/Outnumbered.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Entry point. The plugin is a sealed partial class split by concern across files
|
||||||
|
// (Persistence.cs, Commands.cs, ...) — cookbook §0.
|
||||||
|
public sealed partial class OutnumberedPlugin : BasePlugin, IPluginConfig<OutnumberedConfig>
|
||||||
|
{
|
||||||
|
public override string ModuleName => "Outnumbered";
|
||||||
|
public override string ModuleVersion => "1.0.0-modes";
|
||||||
|
public override string ModuleAuthor => "snake";
|
||||||
|
public override string ModuleDescription => "Players-vs-bots RPG / perk mod.";
|
||||||
|
|
||||||
|
public OutnumberedConfig Config { get; set; } = new();
|
||||||
|
public void OnConfigParsed(OutnumberedConfig config) => Config = config; // MUST assign (cookbook §0)
|
||||||
|
|
||||||
|
// True between Load and Unload. Untracked one-shot AddTimer/NextFrame callbacks (SetupMap, the draft auto-pop, the
|
||||||
|
// changelevel) check this so a hot-reload/unload landing inside their delay window no-ops instead of firing against a
|
||||||
|
// torn-down instance. The REPEAT timers are killed explicitly in their Shutdown_*; this guards the deferred one-shots.
|
||||||
|
private bool _live;
|
||||||
|
|
||||||
|
public override void Load(bool hotReload)
|
||||||
|
{
|
||||||
|
Initialize_Database(hotReload);
|
||||||
|
Initialize_Driver();
|
||||||
|
Initialize_Handicap(); // structural guard: HandicapOverride mirrors HandicapConfig (fail-loud at load)
|
||||||
|
Initialize_Stats();
|
||||||
|
Initialize_Abilities();
|
||||||
|
Initialize_Effects(); // survival burn-DoT tick (no-op outside survival)
|
||||||
|
Initialize_Hud();
|
||||||
|
Initialize_Shop();
|
||||||
|
Initialize_Ranks();
|
||||||
|
Initialize_Feel();
|
||||||
|
Initialize_Api(); // the local UDS status/balance/top API — last: it reads the driver + effective handicap
|
||||||
|
RegisterListener<Listeners.OnTick>(OnTick_All); // ONE per-tick roster walk fanning out to the subsystems
|
||||||
|
RegisterSkillCommands(); // !s1..!sN direct skill-buy
|
||||||
|
RegisterAbilityCommands(); // !ability1..!abilityN bindable casts
|
||||||
|
_live = true;
|
||||||
|
Logger.LogInformation("Outnumbered loaded (mode driver: {Mode}) — persistence + stats + progression + handicap + abilities + HUD + shop.", _driver.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE per-tick roster walk shared by all per-tick subsystems: Utilities.GetPlayers() allocates a List, so materializing
|
||||||
|
// it once and fanning out (each subsystem keeps its own Enabled gate + throttle counter) avoids 3 extra full-roster
|
||||||
|
// scans + List allocs per tick.
|
||||||
|
private void OnTick_All()
|
||||||
|
{
|
||||||
|
var players = Utilities.GetPlayers();
|
||||||
|
OnTick_Abilities(players);
|
||||||
|
OnTick_Hud(players);
|
||||||
|
OnTick_Shop(players);
|
||||||
|
OnTick_Feel(players);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Unload(bool hotReload)
|
||||||
|
{
|
||||||
|
_live = false; // make any in-flight one-shot timer/NextFrame callback a no-op
|
||||||
|
RemoveListener<Listeners.OnTick>(OnTick_All);
|
||||||
|
Shutdown_Api(); // reverse init order: stop serving (and unlink the socket) before the subsystems it reads tear down
|
||||||
|
Shutdown_Feel();
|
||||||
|
Shutdown_Ranks();
|
||||||
|
Shutdown_Shop();
|
||||||
|
Shutdown_Hud();
|
||||||
|
Shutdown_Abilities();
|
||||||
|
Shutdown_Effects();
|
||||||
|
Shutdown_Stats();
|
||||||
|
Shutdown_Driver();
|
||||||
|
Shutdown_Database();
|
||||||
|
}
|
||||||
|
}
|
||||||
293
Outnumbered/Persistence.cs
Normal file
293
Outnumbered/Persistence.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Entities;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private IPlayerRepository _repo = null!;
|
||||||
|
private readonly ConcurrentDictionary<ulong, PlayerData> _players = new();
|
||||||
|
private readonly ConcurrentDictionary<int, ulong> _slotToSteam = new();
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _flushTimer;
|
||||||
|
|
||||||
|
private void Initialize_Database(bool hotReload)
|
||||||
|
{
|
||||||
|
string serverId = ServerId();
|
||||||
|
// Backend by Config.Database.Provider: Postgres for production (shared remote DB), SQLite for dev. Both
|
||||||
|
// implement IPlayerRepository + the per-server match scoping; the rest of the plugin is provider-agnostic.
|
||||||
|
string provider = (Config.Database.Provider ?? "sqlite").Trim().ToLowerInvariant();
|
||||||
|
if (provider is "postgres" or "postgresql" or "pg")
|
||||||
|
{
|
||||||
|
_repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId);
|
||||||
|
Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId);
|
||||||
|
}
|
||||||
|
#if WITH_SQLITE
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string dbPath = Path.Combine(Server.GameDirectory, "csgo", Config.Database.SqliteFile);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
|
||||||
|
_repo = new SqliteRepository(dbPath, serverId);
|
||||||
|
Logger.LogInformation("Outnumbered DB: SQLite {Path} (match scope: {Srv})", dbPath, serverId);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Postgres-only build (WithSqlite=false): SQLite isn't compiled in, so fall back to Postgres regardless.
|
||||||
|
Logger.LogWarning("Outnumbered: Database.Provider '{P}' requested but this is a Postgres-only build — using PostgreSQL.", provider);
|
||||||
|
_repo = new NpgsqlRepository(Config.Database.PostgresConnectionString, serverId);
|
||||||
|
Logger.LogInformation("Outnumbered DB: PostgreSQL (match scope: {Srv})", serverId);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _repo.EnsureSchemaAsync(); Logger.LogInformation("Outnumbered DB schema ready"); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered DB init failed"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
|
||||||
|
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
|
||||||
|
|
||||||
|
// Periodic flush = crash insurance for permanent progression (a few dirty rows; negligible even on remote PG).
|
||||||
|
// FlushIntervalSeconds <= 0 -> pure RAM-first (write only on disconnect/shutdown). Match state is RAM-first regardless.
|
||||||
|
if (Config.FlushIntervalSeconds > 0)
|
||||||
|
_flushTimer = AddTimer(Config.FlushIntervalSeconds, FlushDirty, TimerFlags.REPEAT);
|
||||||
|
|
||||||
|
if (hotReload) SeedConnectedPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// On hot-reload, OnClientAuthorized won't re-fire for already-connected players (cookbook §0).
|
||||||
|
private void SeedConnectedPlayers()
|
||||||
|
{
|
||||||
|
foreach (var p in Humans())
|
||||||
|
{
|
||||||
|
var sid = p.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (sid is not null) LoadPlayer(p.Slot, sid.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClientAuthorized(int slot, SteamID id) => LoadPlayer(slot, id.SteamId64);
|
||||||
|
|
||||||
|
private void LoadPlayer(int slot, ulong sid)
|
||||||
|
{
|
||||||
|
_slotToSteam[slot] = sid;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
LoadedPlayer? loaded; MatchState? match;
|
||||||
|
try { loaded = await _repo.LoadAsync(sid); match = await _repo.LoadMatchAsync(sid); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered load failed for {Sid}", sid); return; }
|
||||||
|
|
||||||
|
// Entity/cache mutations back on the game thread.
|
||||||
|
Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
var pd = new PlayerData { SteamId = sid };
|
||||||
|
if (loaded is not null)
|
||||||
|
{
|
||||||
|
pd.Name = loaded.Name;
|
||||||
|
pd.Xp = loaded.Xp;
|
||||||
|
pd.Level = loaded.Level;
|
||||||
|
pd.Prestige = loaded.Prestige;
|
||||||
|
pd.Points = loaded.Points;
|
||||||
|
pd.PrimaryWeapon = loaded.PrimaryWeapon;
|
||||||
|
pd.SecondaryWeapon = loaded.SecondaryWeapon;
|
||||||
|
foreach (var kv in loaded.Upgrades) pd.Upgrades[kv.Key] = kv.Value;
|
||||||
|
}
|
||||||
|
if (match is not null) // an in-progress match (rejoin); a wiped/absent row loads as fresh zeros
|
||||||
|
{
|
||||||
|
pd.Kills = match.Kills; pd.Deaths = match.Deaths;
|
||||||
|
pd.Streak = match.Streak; pd.HeadshotKills = match.HeadshotKills;
|
||||||
|
pd.GgRunStartedAtMs = match.GgRunStartedAtMs; // restored, never re-armed: a rejoin can't restart the GG clock
|
||||||
|
}
|
||||||
|
var name = Utilities.GetPlayerFromSlot(slot)?.PlayerName;
|
||||||
|
if (!string.IsNullOrEmpty(name)) pd.Name = name;
|
||||||
|
|
||||||
|
_players[sid] = pd;
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Outnumbered loaded {Sid} '{Name}': L{Lvl} P{Pre} xp={Xp} pts={Pts} ({N} upgrades){New}",
|
||||||
|
sid, pd.Name, pd.Level, pd.Prestige, pd.Xp, pd.Points, pd.Upgrades.Count, loaded is null ? " [new]" : "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnClientDisconnect(int slot)
|
||||||
|
{
|
||||||
|
if (!_slotToSteam.TryRemove(slot, out var sid)) return;
|
||||||
|
if (!_players.TryRemove(sid, out var pd)) return;
|
||||||
|
|
||||||
|
// survival: bank this player's accumulated run-XP into the main table BEFORE we snapshot (progress-based; run
|
||||||
|
// ends on disconnect — no mid-run reconnect-restore). Sets pd.Dirty so the converted XP is captured below.
|
||||||
|
_driver.OnHumanDisconnect(sid, pd);
|
||||||
|
|
||||||
|
// If this was the LAST human, the match is OVER (no players = not ongoing) -> wipe the per-server match_state so the
|
||||||
|
// next session starts FRESH (no ghost kills/deaths/streak). While other humans remain, the match is still ongoing,
|
||||||
|
// so we persist this leaver's state for a mid-match rejoin-restore. A graceful shutdown/restart saves via
|
||||||
|
// Shutdown_Database (which does NOT route through OnClientDisconnect — _players is still populated there), so a
|
||||||
|
// restart-while-players-connected still restores; only a genuinely-emptied server resets.
|
||||||
|
bool matchOver = _players.IsEmpty;
|
||||||
|
|
||||||
|
// capture on the game thread; player is leaving (no rollback needed).
|
||||||
|
var perm = pd.Dirty ? pd.ToPersist() : null;
|
||||||
|
var match = (!matchOver && pd.HasMatchActivity) ? pd.ToMatchState() : null;
|
||||||
|
if (perm is null && match is null && !matchOver) return;
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (perm is not null) await _repo.SaveAsync(perm); // permanent progression always persists
|
||||||
|
if (match is not null) await _repo.SaveMatchAsync(match); // match still ongoing -> save for a rejoin-restore
|
||||||
|
if (matchOver) await _repo.WipeMatchAsync(); // server empty -> end the match (fresh next session)
|
||||||
|
Logger.LogInformation("Outnumbered saved {Sid} on disconnect{Over}", sid, matchOver ? " (last player left -> match ended, state wiped)" : "");
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered save-on-disconnect failed for {Sid}", sid); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main-thread timer. Snapshot dirty rows, clear optimistically, save async, re-mark on failure.
|
||||||
|
private void FlushDirty()
|
||||||
|
{
|
||||||
|
List<PlayerData>? dirty = null;
|
||||||
|
foreach (var pd in _players.Values)
|
||||||
|
if (pd.Dirty) (dirty ??= new()).Add(pd);
|
||||||
|
if (dirty is null) return;
|
||||||
|
|
||||||
|
var snaps = dirty.Select(pd => pd.ToPersist()).ToList();
|
||||||
|
foreach (var pd in dirty) pd.Dirty = false; // optimistic; a mutation during the save re-sets it
|
||||||
|
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _repo.SaveManyAsync(snaps); Logger.LogInformation("Outnumbered flushed {N} player(s)", snaps.Count); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Outnumbered flush failed; re-marking dirty");
|
||||||
|
Server.NextFrame(() => { foreach (var pd in dirty) pd.Dirty = true; });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Database()
|
||||||
|
{
|
||||||
|
_flushTimer?.Kill();
|
||||||
|
var snaps = _players.Values.Where(p => p.Dirty).Select(p => p.ToPersist()).ToList();
|
||||||
|
var matchSnaps = _players.Values.Where(p => p.HasMatchActivity).Select(p => p.ToMatchState()).ToList();
|
||||||
|
_players.Clear();
|
||||||
|
_slotToSteam.Clear();
|
||||||
|
if (snaps.Count == 0 && matchSnaps.Count == 0) return;
|
||||||
|
|
||||||
|
// Bounded blocking flush — fire-and-forget here races the changelevel/unload (cookbook §1).
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (snaps.Count > 0) _repo.SaveManyAsync(snaps).GetAwaiter().GetResult();
|
||||||
|
if (matchSnaps.Count > 0) _repo.SaveManyMatchAsync(matchSnaps).GetAwaiter().GetResult();
|
||||||
|
Logger.LogInformation("Outnumbered flushed {N} player(s) + {M} match row(s) on unload", snaps.Count, matchSnaps.Count);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered unload flush failed"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-instance tag that scopes the ephemeral match_state so MANY servers (even sharing one DB / one install
|
||||||
|
// dir) don't clobber each other's round. Permanent progression / the leaderboard stays GLOBAL — only the round
|
||||||
|
// table is per-server. Read from the process command line (machine-independent, available immediately):
|
||||||
|
// -outnumbered_server <tag> (recommended; any parse-able value, like -outnumbered_mode) ->
|
||||||
|
// the game port (-port/+hostport; unique per instance on ONE box, not across boxes) -> "default".
|
||||||
|
internal string ServerId()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var args = CommandLineArgs();
|
||||||
|
string? tag = ArgValue(args, "outnumbered_server");
|
||||||
|
if (!string.IsNullOrWhiteSpace(tag)) return Sanitize(tag);
|
||||||
|
|
||||||
|
string? port = ArgValue(args, "port") ?? ArgValue(args, "hostport");
|
||||||
|
if (!string.IsNullOrWhiteSpace(port)) return Sanitize("port-" + port);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Logger.LogWarning(ex, "Outnumbered: server-tag resolution failed; using 'default'"); }
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's REAL launch command line. Environment.GetCommandLineArgs() is unreliable inside the CS2/CSSharp
|
||||||
|
// embedded .NET host (it often returns only the module path, NOT the cs2 process argv), so launch flags like
|
||||||
|
// -outnumbered_mode / -outnumbered_server are invisible through it. On Linux /proc/self/cmdline is the
|
||||||
|
// authoritative NUL-separated argv of the actual cs2 process and always carries the full launch line. Falls back
|
||||||
|
// to the managed args if /proc is unavailable (non-Linux / sandboxed).
|
||||||
|
private static string[] CommandLineArgs()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string p = "/proc/self/cmdline";
|
||||||
|
if (File.Exists(p))
|
||||||
|
{
|
||||||
|
var parts = File.ReadAllText(p).Split('\0', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length > 1) return parts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fall through to the managed args */ }
|
||||||
|
return Environment.GetCommandLineArgs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// First value for `-name` / `+name` on the command line (both `-name value` and `-name=value` forms), or null.
|
||||||
|
// A value-less space form (the next token is itself a flag, e.g. `-outnumbered_server -port 27015`) returns null so
|
||||||
|
// callers fall through to their next source instead of swallowing the following flag as the value.
|
||||||
|
private static string? ArgValue(string[] args, string name)
|
||||||
|
{
|
||||||
|
string dash = "-" + name, plus = "+" + name;
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
string a = args[i];
|
||||||
|
if (a.StartsWith(dash + "=", StringComparison.OrdinalIgnoreCase)) return a[(dash.Length + 1)..];
|
||||||
|
if (a.StartsWith(plus + "=", StringComparison.OrdinalIgnoreCase)) return a[(plus.Length + 1)..];
|
||||||
|
if ((a.Equals(dash, StringComparison.OrdinalIgnoreCase) || a.Equals(plus, StringComparison.OrdinalIgnoreCase)) && i + 1 < args.Length)
|
||||||
|
{
|
||||||
|
string v = args[i + 1];
|
||||||
|
return v.StartsWith('-') || v.StartsWith('+') ? null : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Sanitize(string s)
|
||||||
|
{
|
||||||
|
string r = s.Trim().Replace(' ', '_');
|
||||||
|
return r.Length > 64 ? r[..64] : r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the match table — called at match boundaries (kill-goal rotation + any map start after the first). The
|
||||||
|
// first map start after plugin load does NOT call this, so a match dumped on shutdown survives to be restored on rejoin.
|
||||||
|
private void WipeMatch() => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _repo.WipeMatchAsync(); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered match-state wipe failed"); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- records (site leaderboards): improve-only, fire-and-forget. Deliberately outside the Dirty/flush machinery —
|
||||||
|
// the repo upserts are clobber-safe on their own, so nothing here round-trips through PlayerData. ----
|
||||||
|
|
||||||
|
// Survival: every participant of a cleared wave. Higher wave wins.
|
||||||
|
internal void RecordBestWaves(List<ulong> participants, int wave) => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _repo.TryImproveBestWavesAsync(participants, wave); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered best-wave record failed (wave {Wave})", wave); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gun Game: stop the speedrun clock at the human ladder win; lower time wins. Returns the elapsed ms for the win
|
||||||
|
// banner, or null when there's nothing sane to record (clock never armed, or skewed across a restart-restore).
|
||||||
|
internal long? RecordGgWin(PlayerData apd)
|
||||||
|
{
|
||||||
|
if (apd.GgRunStartedAtMs <= 0) return null;
|
||||||
|
long ms = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - apd.GgRunStartedAtMs;
|
||||||
|
if (ms <= 0) return null;
|
||||||
|
ulong sid = apd.SteamId;
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await _repo.TryImproveGgBestAsync(sid, ms); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered gg-best record failed for {Sid}", sid); }
|
||||||
|
});
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string FormatRunTime(long ms) => $"{ms / 60000}:{ms / 1000 % 60:D2}.{ms % 1000:D3}";
|
||||||
|
}
|
||||||
111
Outnumbered/Players.cs
Normal file
111
Outnumbered/Players.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The one front door from a controller/slot to PlayerData, plus the shared player predicates/enumerators and the
|
||||||
|
// slot-deferral helper. Handlers route through these so the engine's controller/SteamID surface (the part most likely
|
||||||
|
// to shift across CSSharp builds) and the slot-reuse-after-NextFrame contract live in one place. Deliberate exceptions:
|
||||||
|
// the hot OnEntityTakeDamagePre resolves inline (perf), and a few sites keep the raw SteamID where it's the actual key
|
||||||
|
// (the _hudOff/_dmgReadout HashSets, LoadPlayer, identity re-validation) rather than a PlayerData lookup.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// The canonical controller -> PlayerData lookup (by SteamID). Every other resolver routes through it.
|
||||||
|
internal PlayerData? PdOf(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var sid = p.AuthorizedSteamID?.SteamId64;
|
||||||
|
return sid is not null && _players.TryGetValue(sid.Value, out var pd) ? pd : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PlayerData? PdOf(int slot)
|
||||||
|
{
|
||||||
|
var p = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
return p is { IsValid: true } ? PdOf(p) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [NotNullWhen(true)] lets `if (!IsHuman(p)) return;` flow non-null through to the body.
|
||||||
|
// Resolve a pawn back to its owning controller (the schema dance the damage hook does for attacker + victim).
|
||||||
|
internal static CCSPlayerController? ControllerOfPawn(CCSPlayerPawn? pawn) => pawn?.Controller.Value?.As<CCSPlayerController>();
|
||||||
|
|
||||||
|
internal static bool IsHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false };
|
||||||
|
internal static bool IsLiveHuman([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: false, IsHLTV: false } && p.PawnIsAlive;
|
||||||
|
// Bot-side mirror. IsHLTV:false excludes a SourceTV/GOTV proxy (the engine marks it IsBot too) — so a relay can never
|
||||||
|
// be treated as a combat bot.
|
||||||
|
internal static bool IsBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false };
|
||||||
|
internal static bool IsLiveBot([NotNullWhen(true)] CCSPlayerController? p) => p is { IsValid: true, IsBot: true, IsHLTV: false } && p.PawnIsAlive;
|
||||||
|
|
||||||
|
// Per-tick callers iterate Utilities.GetPlayers() with the IsHuman/IsLiveHuman/IsBot predicates inline (no Where-iterator
|
||||||
|
// alloc); Humans()/Bots() are the allocating convenience for cold paths (e.g. the hot-reload seed, the bot pool walk).
|
||||||
|
internal static IEnumerable<CCSPlayerController> Humans() => Utilities.GetPlayers().Where(IsHuman);
|
||||||
|
internal static IEnumerable<CCSPlayerController> Bots() => Utilities.GetPlayers().Where(IsBot);
|
||||||
|
|
||||||
|
// Force every CT-side bot back to T (bot_join_team only affects NEWLY-added bots). Shared by the steady-state SyncBots
|
||||||
|
// and survival's ManageBots; the caller passes its already-materialized roster so there's no extra GetPlayers() walk.
|
||||||
|
internal static void ForceBotsToTerrorist(IEnumerable<CCSPlayerController> players)
|
||||||
|
{
|
||||||
|
foreach (var b in players)
|
||||||
|
if (IsBot(b) && b.Team == CsTeam.CounterTerrorist) b.SwitchTeam(CsTeam.Terrorist);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run `body` next frame for the player still occupying `slot`, re-resolved and re-validated (the slot may have been
|
||||||
|
// reused). The overload also pins the SteamID so a within-frame disconnect+reuse by a different person can't inherit.
|
||||||
|
internal static void NextFrameForSlot(int slot, Action<CCSPlayerController> body, bool requireAlive = false) =>
|
||||||
|
Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
var p = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
if (p is { IsValid: true } && (!requireAlive || p.PawnIsAlive)) body(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
internal static void NextFrameForSlot(int slot, ulong expectSid, Action<CCSPlayerController> body, bool requireAlive = false) =>
|
||||||
|
Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
var p = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
if (p is { IsValid: true } && p.AuthorizedSteamID?.SteamId64 == expectSid && (!requireAlive || p.PawnIsAlive)) body(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Defer a HP/armor cap re-apply (optionally re-locking the default loadout) to next frame — re-resolved by slot and
|
||||||
|
// SteamID-pinned so a within-frame disconnect+slot-reuse can't apply this player's caps to whoever inherits the slot.
|
||||||
|
// No-op if dead now / no SteamID / not alive next frame. Used after a stat buy / prestige reset / admin grant.
|
||||||
|
internal void DeferReapplyCaps(CCSPlayerController p, bool relockLoadout = false)
|
||||||
|
{
|
||||||
|
if (!p.PawnIsAlive || p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
||||||
|
NextFrameForSlot(p.Slot, sid, pl =>
|
||||||
|
{
|
||||||
|
if (PdOf(pl) is not { } pd) return;
|
||||||
|
if (relockLoadout) ApplyLoadout(pl, false);
|
||||||
|
ApplyMaxHpArmor(pl, pd);
|
||||||
|
}, requireAlive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reap entries keyed by player slot whose controller has vanished WITHOUT a clean disconnect (map change /
|
||||||
|
// bot-replaces-human / kick race). Collect-then-invoke because onOrphan typically mutates `map`. The caller owns the
|
||||||
|
// reused `scratch` list and passes the concrete Dictionary (struct enumerator) + a CACHED onOrphan delegate, so the
|
||||||
|
// per-tick walk allocates nothing. Used by the Hud (world-text) and Shop (session) per-tick reaps.
|
||||||
|
internal static void ReapOrphanSlots<T>(Dictionary<int, T> map, List<int> scratch, Action<int> onOrphan)
|
||||||
|
{
|
||||||
|
scratch.Clear();
|
||||||
|
foreach (var slot in map.Keys)
|
||||||
|
{
|
||||||
|
var pl = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
if (pl is null || !pl.IsValid || pl.IsBot) scratch.Add(slot);
|
||||||
|
}
|
||||||
|
foreach (var slot in scratch) onOrphan(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTransmit shared shape: a per-player world-text panel keyed by slot must be hidden from EVERY OTHER player (each
|
||||||
|
// sees only their own). The Hud + Shop transmit handlers differ only in which entities a slot owns — pass a STATIC
|
||||||
|
// `remove` (no captures, so it's cached, no per-call alloc on this hot listener) that strips one slot's entities.
|
||||||
|
internal static void HideForeignSlotEntities<T>(CCheckTransmitInfoList infoList, Dictionary<int, T> map, Action<CCheckTransmitInfo, T> remove)
|
||||||
|
{
|
||||||
|
if (map.Count == 0) return;
|
||||||
|
foreach (var (info, receiver) in infoList)
|
||||||
|
{
|
||||||
|
if (receiver is null) continue;
|
||||||
|
foreach (var (slot, entry) in map)
|
||||||
|
if (slot != receiver.Slot) remove(info, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
181
Outnumbered/Progression.cs
Normal file
181
Outnumbered/Progression.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Menu;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// XP -> levels -> points -> prestige, plus the !skills menu that spends points. The XP/level/prestige CURVES
|
||||||
|
// live in Outnumbered.Domain.ProgressionModel; these are zero-math accessors. The stateful level-up loop + its side
|
||||||
|
// effects (chat/sound/clan) stay engine-side below.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private long XpToNext(int level) => ProgressionModel.XpToNext(level, Config.Progression);
|
||||||
|
|
||||||
|
private double PrestigeXpMultiplier(PlayerData pd) => ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression);
|
||||||
|
|
||||||
|
// Award XP (per-hit damage, kill bonus, or dev). Applies the XP-Boost stat + cumulative prestige boost +
|
||||||
|
// handicap rate; handles level-ups. The fractional remainder is carried so tiny per-hit grants aren't lost.
|
||||||
|
private void GrantXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
if (baseAmount <= 0 || pd.Level >= Config.Progression.LevelCap) return;
|
||||||
|
|
||||||
|
double scaled = baseAmount
|
||||||
|
* (1.0 + Eff(pd, StatKeys.XpBoost) / 100.0)
|
||||||
|
* PrestigeXpMultiplier(pd)
|
||||||
|
* HandicapXpMult(pd)
|
||||||
|
+ pd.XpCarry;
|
||||||
|
long gain = (long)Math.Floor(scaled);
|
||||||
|
pd.XpCarry = scaled - gain; // keep the sub-1 remainder for the next hit
|
||||||
|
if (gain <= 0) return;
|
||||||
|
|
||||||
|
pd.Xp += gain;
|
||||||
|
pd.Dirty = true;
|
||||||
|
ApplyXpToLevel(pd, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve any pending level-ups from pd.Xp (awarding a point + firing the level-up cascade each), then clamp at
|
||||||
|
// the cap. Shared by per-hit XP (GrantXp) and the survival per-wave grant (AddConvertedXp) so both behave alike.
|
||||||
|
private void ApplyXpToLevel(PlayerData pd, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
long need;
|
||||||
|
while (pd.Level < Config.Progression.LevelCap && pd.Xp >= (need = XpToNext(pd.Level)))
|
||||||
|
{
|
||||||
|
pd.Xp -= need; // same XpToNext(pd.Level) the condition just computed (pd.Level unchanged until ++ below)
|
||||||
|
pd.Level++;
|
||||||
|
pd.Points++;
|
||||||
|
OnLevelUp(pd, p);
|
||||||
|
}
|
||||||
|
if (pd.Level >= Config.Progression.LevelCap) { pd.Xp = 0; pd.XpCarry = 0; } // clamp at cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route combat XP. In survival it's banked RAW into the CURRENT wave's accumulator (granted at wave clear x prestige
|
||||||
|
// x waveMult; the handicap XP-mult is deliberately excluded there). In every other mode it grants now.
|
||||||
|
internal void GrantCombatXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
if (Draft is { } d) d.AccumulateWaveXp(pd, baseAmount); // survival: banked raw into THIS wave's accumulator
|
||||||
|
else GrantXp(pd, baseAmount, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a pre-computed XP lump straight to the main table and resolve level-ups — NO per-hit multipliers re-applied
|
||||||
|
// (the survival per-wave grant has already applied prestige x waveMult and deliberately excluded the handicap mult).
|
||||||
|
internal void AddConvertedXp(PlayerData pd, long lump, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
if (lump <= 0 || pd.Level >= Config.Progression.LevelCap) return;
|
||||||
|
pd.Xp += lump;
|
||||||
|
pd.Dirty = true;
|
||||||
|
ApplyXpToLevel(pd, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLevelUp(PlayerData pd, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
if (p is not { IsValid: true }) return;
|
||||||
|
if (pd.Level >= Config.Progression.LevelCap)
|
||||||
|
p.PrintToChat($"[Outnumbered] MAX LEVEL! Type !prestige to reset for a permanent boost.");
|
||||||
|
else
|
||||||
|
p.PrintToChat($"[Outnumbered] Level {pd.Level}! +1 point — !skills to spend ({pd.Points} available).");
|
||||||
|
ApplyClan(p, pd); // rank tag may have changed
|
||||||
|
PlaySound(p, Config.Sounds.LevelUp);
|
||||||
|
AnnounceUnlocks(p, pd.Level); // shout any weapon(s) that unlock at this exact level
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat kill bonus on the lethal blow (the bulk of XP comes per-hit from damage; see OnPlayerHurt_Stats).
|
||||||
|
private void GrantKillXp(CCSPlayerController attacker)
|
||||||
|
{
|
||||||
|
if (PdOf(attacker) is { } pd)
|
||||||
|
GrantCombatXp(pd, Config.Progression.KillXpBonus, attacker); // survival: into the run accumulator
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- !skills ----
|
||||||
|
// Flat numbered legend (no menu): buy any stat directly with !s<n>, repeatable, no pagination.
|
||||||
|
// (!1-!9 are reserved by CSSharp's menu key system, so skill commands use the 's' prefix.)
|
||||||
|
private void OpenSkillMenu(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Skills — {pd.Points} pt | L{pd.Level} P {Roman(pd.Prestige)} (buy: !s1-!s{StatList.Length})");
|
||||||
|
for (int i = 0; i < StatList.Length; i++)
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} " + SkillLegendSeg(pd, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
private string SkillLegendSeg(PlayerData pd, int i)
|
||||||
|
{
|
||||||
|
var (key, display) = StatList[i];
|
||||||
|
return $"!s{i + 1} {display} [{LevelOf(pd, key)}/{DefFor(key).MaxLevel}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registered from Load: !s1..!sN each buy the matching stat (BuyStat enforces points/cap).
|
||||||
|
private void RegisterSkillCommands()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < StatList.Length; i++)
|
||||||
|
{
|
||||||
|
int idx = i;
|
||||||
|
AddCommand($"css_s{i + 1}", $"Buy skill #{i + 1}", (player, _) =>
|
||||||
|
{
|
||||||
|
if (player is { IsValid: true }) BuyStat(player, StatList[idx].Key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuyStat(CCSPlayerController p, string key)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
|
||||||
|
int lvl = LevelOf(pd, key);
|
||||||
|
int max = DefFor(key).MaxLevel;
|
||||||
|
if (pd.Points <= 0 || lvl >= max) return;
|
||||||
|
|
||||||
|
pd.Upgrades[key] = lvl + 1;
|
||||||
|
pd.Points--;
|
||||||
|
pd.Dirty = true;
|
||||||
|
DeferReapplyCaps(p); // re-apply the new max HP/armor next frame (slot+SteamID-pinned)
|
||||||
|
p.PrintToChat($"[Outnumbered] {key} -> {lvl + 1}/{max} ({pd.Points} point(s) left).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- !prestige ----
|
||||||
|
private void OpenPrestige(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
|
||||||
|
if (pd.Level < Config.Progression.LevelCap)
|
||||||
|
{
|
||||||
|
p.PrintToChat($"[Outnumbered] Reach level {Config.Progression.LevelCap} to prestige (you're {pd.Level}).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pd.Prestige >= Config.Progression.PrestigeCap)
|
||||||
|
{
|
||||||
|
p.PrintToChat($"[Outnumbered] Max prestige ({Roman(pd.Prestige)}) — 100% complete!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var menu = new ChatMenu($"Prestige {Roman(pd.Prestige + 1)}? FULL RESET (level/points/stats)");
|
||||||
|
menu.AddMenuOption("YES — prestige now", (player, _) => DoPrestige(player));
|
||||||
|
menu.AddMenuOption("Cancel", (_, _) => { });
|
||||||
|
menu.Open(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DoPrestige(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
if (pd.Level < Config.Progression.LevelCap || pd.Prestige >= Config.Progression.PrestigeCap) return;
|
||||||
|
|
||||||
|
pd.Prestige++;
|
||||||
|
pd.Level = 1;
|
||||||
|
pd.Xp = 0;
|
||||||
|
pd.Points = 1;
|
||||||
|
pd.Upgrades.Clear();
|
||||||
|
pd.Dirty = true;
|
||||||
|
|
||||||
|
// Re-apply the default loadout (level 1 re-locks the high-tier guns) and reset HP/armor to base on the live pawn.
|
||||||
|
DeferReapplyCaps(p, relockLoadout: true); // slot+SteamID-pinned so a within-frame slot-reuse can't inherit this reset
|
||||||
|
ApplyClan(p, pd); // prestige + reset to L1 changes the tag
|
||||||
|
PlaySound(p, Config.Sounds.Prestige);
|
||||||
|
Server.PrintToChatAll($"[Outnumbered] {p.PlayerName} reached Prestige {Roman(pd.Prestige)}!");
|
||||||
|
}
|
||||||
|
}
|
||||||
267
Outnumbered/Ranks.cs
Normal file
267
Outnumbered/Ranks.cs
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||||||
|
using CounterStrikeSharp.API.Modules.Commands;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Ranks/titles + the player info commands.
|
||||||
|
// Ranks live in a SEPARATE outnumbered.ranks.json that CSSharp does NOT auto-load — we load it manually and write
|
||||||
|
// defaults if missing. Rank shows to others via the scoreboard clantag (m_szClan, 32 bytes; refresh/colour support
|
||||||
|
// is unverified in CS2, so we set it and degrade gracefully).
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private RanksConfig _ranks = new();
|
||||||
|
|
||||||
|
private void Initialize_Ranks()
|
||||||
|
{
|
||||||
|
LoadRanksConfig();
|
||||||
|
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Ranks);
|
||||||
|
AddCommandListener("say", OnSay);
|
||||||
|
AddCommandListener("say_team", OnSayTeam);
|
||||||
|
if (Config.Website.AnnounceIntervalSeconds > 0)
|
||||||
|
_websiteTimer = AddTimer(Config.Website.AnnounceIntervalSeconds, AnnounceWebsite, TimerFlags.REPEAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Ranks()
|
||||||
|
{
|
||||||
|
_websiteTimer?.Kill();
|
||||||
|
RemoveCommandListener("say", OnSay, HookMode.Pre);
|
||||||
|
RemoveCommandListener("say_team", OnSayTeam, HookMode.Pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _websiteTimer;
|
||||||
|
|
||||||
|
// The periodic website plug. Url is read per fire (live-tunable); empty = silent, and an empty server stays quiet.
|
||||||
|
private void AnnounceWebsite()
|
||||||
|
{
|
||||||
|
string url = Config.Website.Url;
|
||||||
|
if (string.IsNullOrEmpty(url) || !Humans().Any()) return;
|
||||||
|
Server.PrintToChatAll($" {ChatColors.Gold}[Outnumbered]{ChatColors.Default} Leaderboards, guides & the damage simulator: {ChatColors.Lime}{url}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CS2 never shows m_szClan in chat, so we prepend the coloured rank tag ourselves: reformat the message and
|
||||||
|
// swallow the default. Crucially we DON'T touch !/ /-prefixed messages, or we'd eat chat commands like !rank.
|
||||||
|
private HookResult OnSay(CCSPlayerController? p, CommandInfo i) => OnSayCommand(p, i, teamOnly: false);
|
||||||
|
private HookResult OnSayTeam(CCSPlayerController? p, CommandInfo i) => OnSayCommand(p, i, teamOnly: true);
|
||||||
|
|
||||||
|
private HookResult OnSayCommand(CCSPlayerController? player, CommandInfo info, bool teamOnly)
|
||||||
|
{
|
||||||
|
if (!_ranks.Enabled || !_ranks.ChatTag) return HookResult.Continue;
|
||||||
|
if (player is not { IsValid: true } || player.IsBot) return HookResult.Continue;
|
||||||
|
|
||||||
|
string msg = info.GetArg(1).Trim();
|
||||||
|
if (msg.Length == 0 || msg[0] is '!' or '/') return HookResult.Continue; // let commands + empties pass through
|
||||||
|
var pd = PdOf(player);
|
||||||
|
if (pd is null) return HookResult.Continue;
|
||||||
|
|
||||||
|
string dead = player.PawnIsAlive ? "" : $"{ChatColors.Grey}* ";
|
||||||
|
string line = $" {dead}{ClanTagChat(pd)} {ChatColors.ForPlayer(player)}{player.PlayerName}{ChatColors.Default}: {msg}";
|
||||||
|
if (teamOnly)
|
||||||
|
{
|
||||||
|
var team = player.Team;
|
||||||
|
foreach (var t in Utilities.GetPlayers())
|
||||||
|
if (t is { IsValid: true } && !t.IsBot && t.Team == team) t.PrintToChat(line);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Server.PrintToChatAll(line);
|
||||||
|
}
|
||||||
|
return HookResult.Handled; // swallow the default chat line (we printed our own)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- manual config load ----
|
||||||
|
// The plugin's config dir is …/configs/plugins/<assembly-name>/ — derive the folder from the assembly name (not a
|
||||||
|
// hardcoded "outnumbered") so a rename can't desync it. Shared by the ranks load + the !og_reload path (Admin.cs).
|
||||||
|
internal static string ConfigPath(string fileName) =>
|
||||||
|
Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", "configs", "plugins",
|
||||||
|
Assembly.GetExecutingAssembly().GetName().Name!, fileName);
|
||||||
|
|
||||||
|
private static string RanksConfigPath() => ConfigPath("outnumbered.ranks.json");
|
||||||
|
|
||||||
|
private void LoadRanksConfig()
|
||||||
|
{
|
||||||
|
string path = RanksConfigPath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
_ranks = JsonSerializer.Deserialize<RanksConfig>(File.ReadAllText(path),
|
||||||
|
new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip }) ?? new();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_ranks = new RanksConfig();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, JsonSerializer.Serialize(_ranks, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
Logger.LogInformation("Outnumbered wrote default ranks config: {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Outnumbered ranks config load failed; using defaults");
|
||||||
|
_ranks = new RanksConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hand-edited PrestigeTagFormat could be a malformed composite format string (e.g. "P {1}") that
|
||||||
|
// throws in string.Format only once someone prestiges — validate now and fall back if bad.
|
||||||
|
try { _ = string.Format(_ranks.PrestigeTagFormat ?? "", "I"); }
|
||||||
|
catch { _ranks.PrestigeTagFormat = "P{0}"; }
|
||||||
|
if (string.IsNullOrEmpty(_ranks.PrestigeTagFormat)) _ranks.PrestigeTagFormat = "P{0}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- rank / clantag ----
|
||||||
|
private string RankNameFor(int level)
|
||||||
|
{
|
||||||
|
RankTier? best = null;
|
||||||
|
foreach (var t in _ranks.LevelRanks)
|
||||||
|
if (level >= t.MinLevel && (best is null || t.MinLevel > best.MinLevel)) best = t;
|
||||||
|
return best?.Name ?? "Unranked";
|
||||||
|
}
|
||||||
|
|
||||||
|
private string RankName(PlayerData pd) => RankNameFor(pd.Level);
|
||||||
|
|
||||||
|
private string ClanTag(PlayerData pd)
|
||||||
|
{
|
||||||
|
string rank = RankNameFor(pd.Level);
|
||||||
|
string tag = pd.Prestige > 0
|
||||||
|
? $"[{string.Format(_ranks.PrestigeTagFormat, Roman(pd.Prestige))} {rank}]"
|
||||||
|
: $"[{rank}]";
|
||||||
|
return tag.Length > 31 ? tag[..31] : tag; // m_szClan is 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prestige -> ability indices whose tint colours make up its tag. I-V = the unlocked perk; VI-X = mixes.
|
||||||
|
// Ability index colours: 0 NoReload=yellow, 1 Adrenaline=blue, 2 Overcharge=orange, 3 Bloodthirst=green, 4 Berserk=red.
|
||||||
|
private static readonly int[][] PrestigeColorIdx =
|
||||||
|
{
|
||||||
|
new[] { 0 }, new[] { 1 }, new[] { 2 }, new[] { 3 }, new[] { 4 }, // I-V solid
|
||||||
|
new[] { 4, 2 }, new[] { 1, 3 }, // VI red+orange, VII blue+green
|
||||||
|
new[] { 4, 2, 0 }, new[] { 1, 3, 0 }, // VIII warm trio, IX cool trio
|
||||||
|
new[] { 4, 2, 0, 3, 1 }, // X full spectrum (R-O-Y-G-B)
|
||||||
|
};
|
||||||
|
private static readonly char[] AbilityChat = { ChatColors.Yellow, ChatColors.Blue, ChatColors.Gold, ChatColors.Green, ChatColors.Red };
|
||||||
|
|
||||||
|
// RGB colour set for a prestige's tag (pulled live from the perks' tints) — used by the HUD gradient.
|
||||||
|
private (int r, int g, int b)[] PrestigeColorSet(int prestige)
|
||||||
|
{
|
||||||
|
var idx = PrestigeColorIdx[Math.Clamp(prestige, 1, 10) - 1];
|
||||||
|
return idx.Select(i => (AbilityCfg(i).TintR, AbilityCfg(i).TintG, AbilityCfg(i).TintB)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat-coloured "P {roman}" (palette per char; solid for I-V, static multi-colour for VI-X).
|
||||||
|
private string PrestigeChatTag(int prestige)
|
||||||
|
{
|
||||||
|
string text = $"P {Roman(prestige)}";
|
||||||
|
if (!_ranks.PrestigeColors || prestige <= 0) return $"{ChatColors.LightPurple}{text}";
|
||||||
|
var idx = PrestigeColorIdx[Math.Clamp(prestige, 1, 10) - 1];
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
int li = 0;
|
||||||
|
foreach (char c in text)
|
||||||
|
{
|
||||||
|
if (c == ' ') { sb.Append(' '); continue; }
|
||||||
|
sb.Append(AbilityChat[idx[li++ % idx.Length] % AbilityChat.Length]).Append(c); // clamp: AbilityChat may lag a new ability
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fully chat-coloured clantag (gold brackets, coloured prestige, lime rank) — used by the chat reformatter.
|
||||||
|
private string ClanTagChat(PlayerData pd)
|
||||||
|
{
|
||||||
|
string rank = RankNameFor(pd.Level);
|
||||||
|
return pd.Prestige > 0
|
||||||
|
? $"{ChatColors.Gold}[{PrestigeChatTag(pd.Prestige)} {ChatColors.Lime}{rank}{ChatColors.Gold}]"
|
||||||
|
: $"{ChatColors.Gold}[{ChatColors.Lime}{rank}{ChatColors.Gold}]";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyClan(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
if (!_ranks.Enabled || !_ranks.ShowClanTag || p is not { IsValid: true } || p.IsBot) return;
|
||||||
|
ControllerWriter.SetClan(p, ClanTag(pd));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HookResult OnPlayerSpawn_Ranks(EventPlayerSpawn ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (!IsHuman(p)) return HookResult.Continue;
|
||||||
|
NextFrameForSlot(p.Slot, pl => { if (PdOf(pl) is { } pd) ApplyClan(pl, pd); });
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- info commands ----
|
||||||
|
private static void Msg(CCSPlayerController p, string text) =>
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Outnumbered]{ChatColors.Default} {text}");
|
||||||
|
|
||||||
|
[ConsoleCommand("css_rank", "Show your rank, level and prestige")]
|
||||||
|
[ConsoleCommand("css_me", "Show your rank, level and prestige")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Rank(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
var pd = PdOf(player);
|
||||||
|
if (pd is null) return;
|
||||||
|
string xp = pd.Level >= Config.Progression.LevelCap ? "MAX" : $"{pd.Xp}/{XpToNext(pd.Level)}";
|
||||||
|
Msg(player, $"{ChatColors.Lime}{RankName(pd)}{ChatColors.Default} | L{pd.Level} P {Roman(pd.Prestige)} | XP {xp} | {pd.Points} pt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_stats", "Show your live bonuses and handicap")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Stats(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
var pd = PdOf(player);
|
||||||
|
if (pd is null) return;
|
||||||
|
var (_, effOut, effIn, effXp) = EffectiveMultipliers(player, pd);
|
||||||
|
Msg(player, $"Dmg Out x{effOut:F2} | In x{effIn:F2} | XP x{effXp:F2} | Streak {pd.Streak} | K/D {pd.Kills}/{pd.Deaths}");
|
||||||
|
var owned = StatList.Where(s => LevelOf(pd, s.Key) > 0)
|
||||||
|
.Select(s => $"{s.Display} {LevelOf(pd, s.Key)}/{DefFor(s.Key).MaxLevel}");
|
||||||
|
Msg(player, "Stats: " + (owned.Any() ? string.Join(", ", owned) : "none yet — spend points with !skills"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_top", "Show the top players")]
|
||||||
|
[ConsoleCommand("css_leaderboard", "Show the top players")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Top(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
int slot = player.Slot;
|
||||||
|
int n = Math.Clamp(_ranks.TopCount, 1, 25);
|
||||||
|
Task.Run(async () =>
|
||||||
|
{
|
||||||
|
IReadOnlyList<TopPlayer> top;
|
||||||
|
try { top = await _repo.GetTopAsync(n); }
|
||||||
|
catch (Exception ex) { Logger.LogError(ex, "Outnumbered !top query failed"); return; }
|
||||||
|
NextFrameForSlot(slot, pl =>
|
||||||
|
{
|
||||||
|
Msg(pl, $"Top {top.Count} players:");
|
||||||
|
int i = 1;
|
||||||
|
foreach (var t in top)
|
||||||
|
pl.PrintToChat($" {ChatColors.Lime}{i++}. {ChatColors.Default}{t.Name} — {RankNameFor(t.Level)} L{t.Level} P {Roman(t.Prestige)}");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_info", "How the Outnumbered RPG works")]
|
||||||
|
[ConsoleCommand("css_about", "How the Outnumbered RPG works")]
|
||||||
|
[ConsoleCommand("css_help", "How the Outnumbered RPG works")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Info(CCSPlayerController? player, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (player is not { IsValid: true }) return;
|
||||||
|
Msg(player, "Outnumbered RPG — kill bots for XP, level up, spend points in !skills.");
|
||||||
|
Msg(player, "At level 100, !prestige resets you for a permanent boost + an unlocked ability.");
|
||||||
|
Msg(player, $"Killstreaks unlock {AbilityCount} abilities — when ready, press that grenade key (6-0) to cast. See !abilities.");
|
||||||
|
Msg(player, "A handicap scales difficulty to your power (stronger = tougher bots, faster XP). See !stats.");
|
||||||
|
Msg(player, $"{ChatColors.Lime}Commands: !rank !stats !skills !prestige !abilities !guns !top !hud");
|
||||||
|
if (Config.Website.Url is { Length: > 0 } site)
|
||||||
|
Msg(player, $"Full guides, leaderboards & theorycrafting: {ChatColors.Lime}{site}");
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Outnumbered/Shop.Draft.cs
Normal file
57
Outnumbered/Shop.Draft.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
using System.Text;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The survival between-wave card DRAFT overlay — the 3-card world-text render.
|
||||||
|
// The draft's input + screen dispatch (ShopScreen.CardDraft, ExecuteOption, the menu-key handling) stays in Shop.cs; this
|
||||||
|
// partial owns only the panel rendering (create / update / build-text). All state is the shared ShopSession + Config.Shop.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// A single survival-draft card panel — centre-justified, chunkier background so each reads as its own "card".
|
||||||
|
private CPointWorldText? CreateCardPanel()
|
||||||
|
{
|
||||||
|
var s = Config.Shop;
|
||||||
|
return WorldText.Create(s.FontSize, s.WorldUnitsPerPx, s.FontName,
|
||||||
|
System.Drawing.Color.FromArgb(255, s.ColorR, s.ColorG, s.ColorB), s.DrawBackground, 0.35f,
|
||||||
|
PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The survival between-wave draft: up to 3 cards side by side (centre card at RightOffset, the others ± CardSpread).
|
||||||
|
private void UpdateDraftPanels(CCSPlayerController p, ShopSession s, Vector eye, Vector fwd, Vector right, Vector up, QAngle ang)
|
||||||
|
{
|
||||||
|
var cfg = Config.Shop;
|
||||||
|
var draw = Draft is { } sd ? sd.CurrentDraw(p) : new();
|
||||||
|
int n = Math.Min(draw.Count, s.Cards.Length); // cards actually shown (1..3)
|
||||||
|
for (int i = 0; i < s.Cards.Length; i++)
|
||||||
|
{
|
||||||
|
if (i >= n) // fewer than 3 cards left (pool nearly exhausted) -> drop the spare panel
|
||||||
|
{
|
||||||
|
WorldText.Destroy(ref s.Cards[i]); s.CardText[i] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s.Cards[i] is null || !s.Cards[i]!.IsValid) { s.Cards[i] = CreateCardPanel(); s.CardText[i] = null; }
|
||||||
|
var card = s.Cards[i];
|
||||||
|
if (card is null) continue;
|
||||||
|
string txt = BuildCardPanel(p, draw[i].key, GrenadeMenuKeys[i]); // grenade keys 6/7/8 (-> [HE]/[Flash]/[Smoke] labels)
|
||||||
|
if (s.CardText[i] != txt) { WorldText.SetText(card, txt); s.CardText[i] = txt; }
|
||||||
|
float x = (i - (n - 1) / 2f) * cfg.CardSpread; // centre the row regardless of card count (1/2/3)
|
||||||
|
WorldText.Place(card, eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset + x) + up * cfg.UpOffset, ang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One card's text: the key to press, the card name + count, and the current -> next value so you know what you get.
|
||||||
|
private string BuildCardPanel(CCSPlayerController p, string key, char menuKey)
|
||||||
|
{
|
||||||
|
if (Draft is not { } sd || sd.CardInfo(p, key) is not { } v) return " ";
|
||||||
|
string u = v.Flat ? "" : "%";
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"[ {KeyItemLabel(menuKey)} ]\n");
|
||||||
|
sb.Append($"{v.Name} [{v.Have}/{v.Cap}]\n");
|
||||||
|
if (v.Detail is not null) sb.Append(v.Detail); // effect cards: a single descriptive line
|
||||||
|
else { sb.Append($"Now: +{v.Now:0}{u}\n"); sb.Append($"Next: +{v.Next:0}{u}"); } // stat cards: current -> next
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
590
Outnumbered/Shop.cs
Normal file
590
Outnumbered/Shop.cs
Normal file
|
|
@ -0,0 +1,590 @@
|
||||||
|
using System.Text;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The quick-buy shop. Opened by switching to the healthshot carrier (X); while open the player is
|
||||||
|
// frozen (MoveType NONE) and the menu is two world-text panels in front of the camera (left = shop options,
|
||||||
|
// right = info), each hugging the centre. Inputs are weapon-slot / grenade key presses, detected as
|
||||||
|
// active-weapon changes (the same trick the abilities use):
|
||||||
|
// * keys 1/2 = primary / secondary slots
|
||||||
|
// * key 3 = the zeus (shares the melee slot with the knife; pressing 3 flips knife->zeus = a detectable switch)
|
||||||
|
// * keys 6/7/8/9/0 = the five grenades (GIVEN on open so the slots are populated and the presses register)
|
||||||
|
// * X (healthshot) = back one screen, or close at root
|
||||||
|
// Between presses the player rests on the KNIFE (the neutral, slot 3) so repeated keys (e.g. X-1-6-6) each
|
||||||
|
// produce a fresh switch event. Resetting to the knife keeps a grenade from being held long enough to throw,
|
||||||
|
// and the healthshot from being held long enough to heal.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private const string ShopRestCmd = Inventory.SlotMelee; // reset between presses = back to the neutral (melee slot)
|
||||||
|
// (the shop "carrier" item names — weapon_healthshot / weapon_taser — live in EngineNames.ShopCarrier/ShopMelee)
|
||||||
|
// The melee slot holds knife + zeus, so ShopRestCmd toggles between them, and the client's switch is observed a network
|
||||||
|
// round-trip later — firing it every tick piles up a toggle backlog. Debounce it so each switch settles (lands on the
|
||||||
|
// knife, where no further rest fires) before the next.
|
||||||
|
private const double RestDebounceSeconds = 0.20;
|
||||||
|
|
||||||
|
// The grenade tail of Weapons.MenuKeys ({1,2,3,6,7,8,9,0}) — the 6/7/8/9/0 keys. Used by BOTH the skills-only
|
||||||
|
// (Gun Game) shop, where OpenShop always populates these so the menu works even when the player holds only one
|
||||||
|
// ladder gun (slots 1/2 may be empty by rung), AND the survival card draft. Not Gun-Game-specific; keep aligned.
|
||||||
|
private static readonly char[] GrenadeMenuKeys = { '6', '7', '8', '9', '0' };
|
||||||
|
private char ShopOptKey(int pos) => _driver.WeaponShopEnabled
|
||||||
|
? MenuKey(pos)
|
||||||
|
: (pos >= 0 && pos < GrenadeMenuKeys.Length ? GrenadeMenuKeys[pos] : '?');
|
||||||
|
|
||||||
|
// The grenade quick-buy keys: menu key -> (grenade weapon used to populate the slot, display label). ONE source for
|
||||||
|
// the slot-population (OpenShop/CloseShop), the WeaponToKey reverse lookup, and the [HE]/[Flash]/... labels — so the
|
||||||
|
// 6=HE alignment isn't re-typed across the file. (Distinct from the JSON-tunable Abilities.AbilityGrenades.)
|
||||||
|
private static readonly (char Key, string Weapon, string Label)[] GrenadeMenu =
|
||||||
|
{
|
||||||
|
('6', "weapon_hegrenade", "HE"),
|
||||||
|
('7', "weapon_flashbang", "Flash"),
|
||||||
|
('8', "weapon_smokegrenade", "Smoke"),
|
||||||
|
('9', "weapon_decoy", "Decoy"),
|
||||||
|
('0', "weapon_incgrenade", "Molotov"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] SkillGroupNames = { "Damage", "Defense", "Utility" };
|
||||||
|
// SkillGroupStats (the canonical key->group projection) lives in Stats.cs next to StatRegistry; SkillGroupNames[i] labels group i.
|
||||||
|
|
||||||
|
private enum ShopScreen { Root, WeaponCats, Category, SkillGroups, SkillStats, CardDraft }
|
||||||
|
|
||||||
|
private sealed record ShopOption(char Key, string Label, bool Enabled, Action Act);
|
||||||
|
|
||||||
|
private sealed class ShopSession
|
||||||
|
{
|
||||||
|
public bool Open;
|
||||||
|
public ShopScreen Screen;
|
||||||
|
public int CatIndex; // which weapon category (Category screen)
|
||||||
|
public int GroupIndex; // which skill group (SkillStats screen)
|
||||||
|
public bool CarrierWasActive; // rising-edge guard: one X press = one back/close
|
||||||
|
public bool SawNeutral; // have we rested on the knife since opening? (gates the first input)
|
||||||
|
public string LastActive = ""; // last option weapon acted on (de-dupes the deploy-lag ticks)
|
||||||
|
public CPointWorldText? OptionsEntity; // left panel: shop options (mirrors InfoEntity)
|
||||||
|
public CPointWorldText? InfoEntity; // right panel: info
|
||||||
|
public string? LastText;
|
||||||
|
public string? LastInfoText;
|
||||||
|
public readonly CPointWorldText?[] Cards = new CPointWorldText?[3]; // survival draft: the 3 card panels
|
||||||
|
public readonly string?[] CardText = new string?[3]; // last text per card (skip redundant SetMessage)
|
||||||
|
public MoveType_t SavedMoveType = MoveType_t.MOVETYPE_WALK;
|
||||||
|
public double ClosedAt = -999; // Server.CurrentTime at last close (post-close input grace)
|
||||||
|
public double RestAt = -999; // Server.CurrentTime of the last rest command (debounces the slot-select + gates picks until the weapon settles)
|
||||||
|
|
||||||
|
// Remove + null every world-text panel this session owns (the 2 shop panels + the 3 draft cards) and clear their
|
||||||
|
// last-text caches. One teardown for CloseShop + OnClientDisconnect (panels + draft) so no site forgets a panel.
|
||||||
|
public void DestroyEntities()
|
||||||
|
{
|
||||||
|
WorldText.Destroy(ref OptionsEntity);
|
||||||
|
WorldText.Destroy(ref InfoEntity);
|
||||||
|
LastText = null; LastInfoText = null;
|
||||||
|
for (int i = 0; i < Cards.Length; i++) { WorldText.Destroy(ref Cards[i]); CardText[i] = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Dictionary<int, ShopSession> _shop = new();
|
||||||
|
private readonly List<int> _shopReap = new(); // reused orphan-slot scratch for the per-tick session reap
|
||||||
|
private Action<int>? _shopReapAction; // cached OnClientDisconnect_Shop delegate (so ReapOrphanSlots adds no per-tick closure)
|
||||||
|
|
||||||
|
private const double ShopCloseGrace = 0.35; // suppress ability input this long after close (trailing key-spam)
|
||||||
|
|
||||||
|
// Read by the abilities loop: true while the shop is open OR within the post-close grace, so a 6-spam to max a
|
||||||
|
// skill (key 6 is also a grenade) can't leak into a No Reload cast the instant the menu closes.
|
||||||
|
public bool ShopInputLocked(int slot) =>
|
||||||
|
_shop.TryGetValue(slot, out var s) && (s.Open || Server.CurrentTime - s.ClosedAt < ShopCloseGrace);
|
||||||
|
|
||||||
|
private ShopSession ShopOf(int slot)
|
||||||
|
{
|
||||||
|
if (!_shop.TryGetValue(slot, out var s)) { s = new ShopSession(); _shop[slot] = s; }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsNeutralMelee(string n) => n == EngineNames.WeaponKnife || n.Contains("knife") || n.Contains("bayonet");
|
||||||
|
|
||||||
|
private void Initialize_Shop()
|
||||||
|
{
|
||||||
|
// OnTick is driven by OnTick_All (shared roster walk); OnTick_Shop is invoked from there.
|
||||||
|
RegisterListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
|
||||||
|
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
|
||||||
|
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Shop);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Shop()
|
||||||
|
{
|
||||||
|
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
|
||||||
|
RemoveListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
|
||||||
|
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: false);
|
||||||
|
_shop.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect can leave a frozen pawn + open session through the reconnect grace window — clear it outright.
|
||||||
|
private void OnClientDisconnect_Shop(int slot)
|
||||||
|
{
|
||||||
|
if (!_shop.TryGetValue(slot, out var s)) return;
|
||||||
|
s.DestroyEntities();
|
||||||
|
var pawn = Utilities.GetPlayerFromSlot(slot)?.PlayerPawn.Value;
|
||||||
|
if (s.Open && pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
|
||||||
|
_shop.Remove(slot); // a reconnecting player on this slot starts fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force the menu closed on (re)spawn so a reconnect never resumes mid-menu.
|
||||||
|
private HookResult OnPlayerSpawn_Shop(EventPlayerSpawn ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (p is { IsValid: true } && !p.IsBot) CloseShop(p.Slot, switchAway: false);
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-tick input + render ----
|
||||||
|
private void OnTick_Shop(List<CCSPlayerController> players)
|
||||||
|
{
|
||||||
|
if (!Config.Shop.Enabled)
|
||||||
|
{
|
||||||
|
foreach (var (slot, s) in _shop) if (s.Open) CloseShop(slot, switchAway: true); // close out if disabled live
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reap sessions whose controller vanished WITHOUT a clean OnClientDisconnect (map change / bot-replaces-human /
|
||||||
|
// kick race) — full cleanup (panels + unfreeze + drop session), not just the open ones. Shared collect-then-act
|
||||||
|
// helper (OnClientDisconnect_Shop mutates _shop). Normal path: no orphans -> no-op.
|
||||||
|
ReapOrphanSlots(_shop, _shopReap, _shopReapAction ??= OnClientDisconnect_Shop);
|
||||||
|
|
||||||
|
foreach (var p in players) HandleShopTick(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to the neutral, debounced (see RestDebounceSeconds) so the slot-select toggle settles between fires.
|
||||||
|
private static void Rest(CCSPlayerController p, ShopSession s)
|
||||||
|
{
|
||||||
|
if (Server.CurrentTime - s.RestAt < RestDebounceSeconds) return;
|
||||||
|
p.ExecuteClientCommand(ShopRestCmd);
|
||||||
|
s.RestAt = Server.CurrentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-player shop tick: the carrier/menu input state machine (open/back/close, the knife-neutral rest, key->option,
|
||||||
|
// panel render). Split out of OnTick_Shop so that method stays orchestration (disabled-gate + orphan reap + fan-out);
|
||||||
|
// the loop's `continue` becomes `return` here.
|
||||||
|
private void HandleShopTick(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
if (!IsHuman(p)) return; // inline predicate (no Where-iterator alloc on this per-tick path)
|
||||||
|
var s = ShopOf(p.Slot);
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pawn is null || !p.PawnIsAlive)
|
||||||
|
{
|
||||||
|
if (s.Open) CloseShop(p.Slot, switchAway: false);
|
||||||
|
s.CarrierWasActive = false; s.LastActive = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string activeName = Inventory.ActiveWeaponName(pawn);
|
||||||
|
bool carrierActive = activeName == EngineNames.ShopCarrier;
|
||||||
|
|
||||||
|
// X (healthshot) — rising edge: open, or back-one-screen / close at root
|
||||||
|
if (carrierActive && !s.CarrierWasActive)
|
||||||
|
{
|
||||||
|
if (s.Open) Back(p, s);
|
||||||
|
else OpenShop(p, pawn);
|
||||||
|
}
|
||||||
|
s.CarrierWasActive = carrierActive;
|
||||||
|
|
||||||
|
if (!s.Open) return;
|
||||||
|
|
||||||
|
var vel = pawn.AbsVelocity; vel.X = vel.Y = vel.Z = 0f; // hold the freeze
|
||||||
|
|
||||||
|
if ((p.Buttons & PlayerButtons.Duck) != 0) { CloseShop(p.Slot, switchAway: true); return; } // crouch = quick exit
|
||||||
|
|
||||||
|
bool neutralMelee = IsNeutralMelee(activeName);
|
||||||
|
if (neutralMelee) s.SawNeutral = true; // rested on the knife -> input is now live
|
||||||
|
if (carrierActive || neutralMelee)
|
||||||
|
{
|
||||||
|
s.LastActive = ""; // resting (on the carrier mid-toggle, or the knife neutral) — ready for the next press
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
char key = WeaponToKey(activeName);
|
||||||
|
// Accept a pick only once the weapon has SETTLED back on the neutral: not yet rested on the knife since
|
||||||
|
// opening (!SawNeutral), or a rest fired within the debounce window (the slot-select toggle / a re-deploying
|
||||||
|
// weapon is still in flight), means input isn't trustworthy yet — rest and wait. No fixed time grace; the
|
||||||
|
// settle itself is the spacing, so picks register as fast as the weapon can return to neutral (instabuy).
|
||||||
|
if (key == '\0' || !s.SawNeutral || Server.CurrentTime - s.RestAt < RestDebounceSeconds)
|
||||||
|
{
|
||||||
|
Rest(p, s);
|
||||||
|
}
|
||||||
|
else if (activeName != s.LastActive)
|
||||||
|
{
|
||||||
|
s.LastActive = activeName;
|
||||||
|
ExecuteOption(p, s, key);
|
||||||
|
// Rest to the neutral; the settle gate above re-suppresses input until that rest lands, so the
|
||||||
|
// re-deploying weapon can't register as the next pick.
|
||||||
|
if (s.Open) Rest(p, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.Open) UpdateShopPanels(p, pawn, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// active weapon -> the menu key it represents ('\0' = not an option / it's the knife neutral or the carrier)
|
||||||
|
private char WeaponToKey(string active)
|
||||||
|
{
|
||||||
|
if (active.Length == 0) return '\0';
|
||||||
|
if (active == EngineNames.ShopMelee) return '3'; // zeus = key 3 (co-melee with the knife)
|
||||||
|
if (IsNeutralMelee(active)) return '\0'; // knife = neutral rest, not an option
|
||||||
|
if (IsPistolWeapon(active)) return '2';
|
||||||
|
if (IsPrimaryWeapon(active)) return '1';
|
||||||
|
foreach (var g in GrenadeMenu) if (g.Weapon == active) return g.Key;
|
||||||
|
return '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- open / close / back ----
|
||||||
|
private void OpenShop(CCSPlayerController p, CCSPlayerPawn pawn)
|
||||||
|
{
|
||||||
|
var s = ShopOf(p.Slot);
|
||||||
|
// Survival between-wave draft takes priority; otherwise skills-only modes skip the root and open on the groups.
|
||||||
|
ShopScreen initial = (Draft is { } sd && sd.DraftPending(p)) ? ShopScreen.CardDraft
|
||||||
|
: _driver.WeaponShopEnabled ? ShopScreen.Root : ShopScreen.SkillGroups;
|
||||||
|
s.Open = true; s.Screen = initial; s.LastActive = "";
|
||||||
|
s.SawNeutral = false; // ignore key input until the player has rested on the knife once (kills the on-open misfire)
|
||||||
|
s.SavedMoveType = pawn.MoveType;
|
||||||
|
SetFrozen(pawn, true);
|
||||||
|
foreach (var g in GrenadeMenu) p.GiveNamedItem(g.Weapon); // populate 6-0 so those keys fire
|
||||||
|
Rest(p, s); // rest on the knife (neutral)
|
||||||
|
s.LastText = null; s.LastInfoText = null;
|
||||||
|
// The draft is a distinct 3-card overlay (panels created lazily in UpdateDraftPanels); the normal shop is the 2-panel layout.
|
||||||
|
if (initial == ShopScreen.CardDraft)
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Draft] pick a card — switch to the shown item, X / crouch to close (unspent picks carry over).");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
s.OptionsEntity = CreateShopPanel(true, Config.Shop.FontSize, Config.Shop.WorldUnitsPerPx); // left: shop options
|
||||||
|
s.InfoEntity = CreateShopPanel(false, Config.Shop.InfoFontSize, Config.Shop.InfoWorldUnitsPerPx); // right: info (smaller)
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Shop] open — numbers select, X = back / close.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close every open shop/draft (used by survival at wave start so nobody is left frozen in a menu).
|
||||||
|
internal void CloseAllShops()
|
||||||
|
{
|
||||||
|
foreach (var slot in _shop.Keys.ToList()) CloseShop(slot, switchAway: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-pop the survival draft for everyone alive on CT with pending picks. Called shortly AFTER wave-clear (so the
|
||||||
|
// revive-spawns have already fired their menu-close), opening straight into the 3-card overlay. Closeable via X / crouch.
|
||||||
|
internal void OpenDraftForAll()
|
||||||
|
{
|
||||||
|
if (!_live) return; // auto-pop is scheduled ~0.75s after wave-clear; skip if unloaded since
|
||||||
|
if (Draft is not { } sd) return;
|
||||||
|
foreach (var p in Utilities.GetPlayers())
|
||||||
|
{
|
||||||
|
if (!IsLiveHuman(p)) continue;
|
||||||
|
if (!sd.DraftPending(p)) continue;
|
||||||
|
var s = ShopOf(p.Slot);
|
||||||
|
if (s.Open) continue;
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pawn is not null) OpenShop(p, pawn); // DraftPending -> OpenShop selects the CardDraft screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseShop(int slot, bool switchAway)
|
||||||
|
{
|
||||||
|
if (!_shop.TryGetValue(slot, out var s)) return;
|
||||||
|
bool wasOpen = s.Open;
|
||||||
|
s.Open = false; s.LastActive = "";
|
||||||
|
if (wasOpen) s.ClosedAt = Server.CurrentTime; // start the post-close input grace
|
||||||
|
s.DestroyEntities();
|
||||||
|
if (!wasOpen) return;
|
||||||
|
|
||||||
|
var p = Utilities.GetPlayerFromSlot(slot);
|
||||||
|
if (p is { IsValid: true })
|
||||||
|
foreach (var g in GrenadeMenu) p.RemoveItemByDesignerName(g.Weapon); // ability reconcile re-grants real ones
|
||||||
|
var pawn = p?.PlayerPawn.Value;
|
||||||
|
if (pawn is not null && pawn.Health > 0) SetFrozen(pawn, false, s.SavedMoveType);
|
||||||
|
// Switch to the gun the player actually has, not a hardcoded slot1 — in Gun Game the rung weapon may be a
|
||||||
|
// pistol (slot2) or, on the knife finale, only the knife (slot3); slot1 would otherwise strand them on the knife.
|
||||||
|
if (switchAway && p is { IsValid: true }) p.ExecuteClientCommand(PreferredSlotCmd(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client command to rest on the best NON-grenade weapon the player holds: primary, else pistol, else the
|
||||||
|
// zeus, else the knife. The zeus is preferred over the knife as the rest because the engine auto-deploys granted
|
||||||
|
// ability grenades over the KNIFE (the GG knife-rung perk bug) but not over a real weapon like the zeus.
|
||||||
|
private string PreferredSlotCmd(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
// Classify all held weapons in ONE pass; keep the no-WeaponServices -> slot1 fallback (distinct from
|
||||||
|
// "has weapons but no primary" -> slot3). The inner valid-weapon walk is shared via Inventory.Weapons.
|
||||||
|
if (!Inventory.HasWeaponServices(p)) return Inventory.SlotPrimary;
|
||||||
|
bool primary = false, secondary = false, zeus = false;
|
||||||
|
foreach (var w in Inventory.Weapons(p))
|
||||||
|
{
|
||||||
|
string n = w.DesignerName;
|
||||||
|
if (n == EngineNames.ShopMelee) zeus = true;
|
||||||
|
else if (IsPrimaryWeapon(n)) primary = true;
|
||||||
|
else if (IsPistolWeapon(n)) secondary = true;
|
||||||
|
}
|
||||||
|
return primary ? Inventory.SlotPrimary : secondary ? Inventory.SlotSecondary : zeus ? Inventory.UseZeus : Inventory.SlotMelee;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Back(CCSPlayerController p, ShopSession s)
|
||||||
|
{
|
||||||
|
switch (s.Screen)
|
||||||
|
{
|
||||||
|
case ShopScreen.WeaponCats:
|
||||||
|
s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); break;
|
||||||
|
case ShopScreen.SkillGroups:
|
||||||
|
if (_driver.WeaponShopEnabled) { s.Screen = ShopScreen.Root; s.LastActive = ""; Rest(p, s); }
|
||||||
|
else CloseShop(p.Slot, switchAway: true); // skills-only: SkillGroups is home -> back = close
|
||||||
|
break;
|
||||||
|
case ShopScreen.Category:
|
||||||
|
s.Screen = ShopScreen.WeaponCats; s.LastActive = ""; Rest(p, s); break;
|
||||||
|
case ShopScreen.SkillStats:
|
||||||
|
s.Screen = ShopScreen.SkillGroups; s.LastActive = ""; Rest(p, s); break;
|
||||||
|
default: // Root -> close
|
||||||
|
CloseShop(p.Slot, switchAway: true); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetFrozen(CCSPlayerPawn pawn, bool frozen, MoveType_t restore = MoveType_t.MOVETYPE_WALK)
|
||||||
|
{
|
||||||
|
PawnWriter.SetMoveType(pawn, frozen ? MoveType_t.MOVETYPE_NONE : restore); // schema write + notify owned by PawnWriter
|
||||||
|
if (frozen) { var v = pawn.AbsVelocity; v.X = v.Y = v.Z = 0f; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- screen contents ----
|
||||||
|
private List<ShopOption> CurrentOptions(CCSPlayerController p, ShopSession s, PlayerData pd)
|
||||||
|
{
|
||||||
|
var opts = new List<ShopOption>();
|
||||||
|
switch (s.Screen)
|
||||||
|
{
|
||||||
|
case ShopScreen.Root:
|
||||||
|
if (_driver.WeaponShopEnabled)
|
||||||
|
opts.Add(new('1', "Weapons", true, () => s.Screen = ShopScreen.WeaponCats));
|
||||||
|
opts.Add(new('2', "Skills", true, () => s.Screen = ShopScreen.SkillGroups));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ShopScreen.WeaponCats:
|
||||||
|
for (int c = 0; c < CategoryOrder.Length; c++)
|
||||||
|
{
|
||||||
|
int ci = c;
|
||||||
|
var list = CategoryList(CategoryOrder[c]);
|
||||||
|
opts.Add(new(MenuKey(c), $"{CategoryDisplay(CategoryOrder[c])} ({list.Count})", true,
|
||||||
|
() => { s.CatIndex = ci; s.Screen = ShopScreen.Category; }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ShopScreen.Category:
|
||||||
|
{
|
||||||
|
var name = CategoryOrder[s.CatIndex];
|
||||||
|
bool primary = name != "Pistols";
|
||||||
|
var list = CategoryList(name);
|
||||||
|
for (int i = 0; i < list.Count && i < 8; i++)
|
||||||
|
{
|
||||||
|
string item = list[i];
|
||||||
|
int req = UnlockLevel(item);
|
||||||
|
bool unlocked = pd.Level >= req;
|
||||||
|
string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [L{req}]";
|
||||||
|
opts.Add(new(MenuKey(i), label, unlocked,
|
||||||
|
() => { SetWeapon(p, primary, item); CloseShop(p.Slot, switchAway: true); }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ShopScreen.SkillGroups:
|
||||||
|
for (int g = 0; g < SkillGroupNames.Length; g++)
|
||||||
|
{
|
||||||
|
int gi = g;
|
||||||
|
opts.Add(new(ShopOptKey(g), SkillGroupNames[g], true,
|
||||||
|
() => { s.GroupIndex = gi; s.Screen = ShopScreen.SkillStats; }));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ShopScreen.SkillStats:
|
||||||
|
{
|
||||||
|
var keys = SkillGroupStats[s.GroupIndex];
|
||||||
|
for (int i = 0; i < keys.Length && i < 8; i++)
|
||||||
|
{
|
||||||
|
string key = keys[i];
|
||||||
|
int lvl = LevelOf(pd, key), max = DefFor(key).MaxLevel;
|
||||||
|
bool canBuy = pd.Points > 0 && lvl < max;
|
||||||
|
string label = $"{StatDisplay(key)} [{lvl}/{max}]";
|
||||||
|
opts.Add(new(ShopOptKey(i), label, canBuy, () =>
|
||||||
|
{
|
||||||
|
BuyStat(p, key);
|
||||||
|
if (pd.Points <= 0) CloseShop(p.Slot, switchAway: true); // spent your last point -> auto-exit
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ShopScreen.CardDraft when Draft is { } sd:
|
||||||
|
{
|
||||||
|
var draw = sd.CurrentDraw(p); // (cardKey, label), stable across reopen within a break
|
||||||
|
// Cards sit on the GRENADE keys (6/7/8), NOT weapon slots 1/2/3: the player never passively holds a
|
||||||
|
// grenade and the knife/zeus neutral-rest never produces 6-0, so the engine's weapon re-deploys can't
|
||||||
|
// auto-pick. (Weapon slots collide — key 3 = the zeus, which shares slot3 with the knife rest.)
|
||||||
|
for (int i = 0; i < draw.Count && i < GrenadeMenuKeys.Length; i++)
|
||||||
|
{
|
||||||
|
var (ckey, label) = draw[i];
|
||||||
|
opts.Add(new(GrenadeMenuKeys[i], label, true, () =>
|
||||||
|
{
|
||||||
|
sd.PickCard(p, ckey);
|
||||||
|
if (!sd.DraftPending(p)) CloseShop(p.Slot, switchAway: true); // spent your last pick -> exit, free to prep
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExecuteOption(CCSPlayerController p, ShopSession s, char key)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
foreach (var opt in CurrentOptions(p, s, pd))
|
||||||
|
if (opt.Key == key)
|
||||||
|
{
|
||||||
|
if (opt.Enabled) opt.Act();
|
||||||
|
return; // matched the key — act if enabled, then stop regardless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ITEM a menu key maps to — shown in the menu instead of the default slot number so it reads correctly even
|
||||||
|
// when the player has rebound their slot keys (we can't read client binds, but we CAN name the item to switch to).
|
||||||
|
// 1/2/3 = primary/pistol/zeus slots; 6-0 = the grenades (per the in-game grenade-select order).
|
||||||
|
private static string KeyItemLabel(char key) => key switch
|
||||||
|
{
|
||||||
|
'1' => "Primary",
|
||||||
|
'2' => "Pistol",
|
||||||
|
'3' => "Zeus",
|
||||||
|
_ => GrenadeLabel(key), // 6-0 grenade labels come from the GrenadeMenu table (one source)
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string GrenadeLabel(char key)
|
||||||
|
{
|
||||||
|
foreach (var g in GrenadeMenu) if (g.Key == key) return g.Label;
|
||||||
|
return key.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CategoryDisplay(string internalName) => internalName switch
|
||||||
|
{
|
||||||
|
"Smgs" => "SMGs",
|
||||||
|
"Heavy" => "Machine Guns",
|
||||||
|
_ => internalName,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string StatDisplay(string key)
|
||||||
|
{
|
||||||
|
foreach (var (k, d) in StatList) if (k == key) return d;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ScreenTitle(ShopSession s) => s.Screen switch
|
||||||
|
{
|
||||||
|
ShopScreen.Root => "SHOP",
|
||||||
|
ShopScreen.WeaponCats => "WEAPONS",
|
||||||
|
ShopScreen.Category => CategoryDisplay(CategoryOrder[s.CatIndex]).ToUpperInvariant(),
|
||||||
|
ShopScreen.SkillGroups => "SKILLS",
|
||||||
|
ShopScreen.SkillStats => SkillGroupNames[s.GroupIndex].ToUpperInvariant(),
|
||||||
|
ShopScreen.CardDraft => "DRAFT A CARD",
|
||||||
|
_ => "SHOP",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- world-text panels (eye-relative placement via Engine.WorldText.TryEyeFrame) ----
|
||||||
|
private CPointWorldText? CreateShopPanel(bool rightJustify, float fontSize, float unitsPerPx)
|
||||||
|
{
|
||||||
|
var s = Config.Shop;
|
||||||
|
return WorldText.Create(fontSize, unitsPerPx, s.FontName,
|
||||||
|
System.Drawing.Color.FromArgb(255, s.ColorR, s.ColorG, s.ColorB), s.DrawBackground, 0.15f,
|
||||||
|
rightJustify ? PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_RIGHT
|
||||||
|
: PointWorldTextJustifyHorizontal_t.POINT_WORLD_TEXT_JUSTIFY_HORIZONTAL_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateShopPanels(CCSPlayerController p, CCSPlayerPawn pawn, ShopSession s)
|
||||||
|
{
|
||||||
|
if (!WorldText.TryEyeFrame(pawn, out var eye, out var fwd, out var right, out var up, out var ang)) return;
|
||||||
|
var cfg = Config.Shop;
|
||||||
|
|
||||||
|
// Survival draft = a distinct 3-card overlay, not the 2-panel shop.
|
||||||
|
if (s.Screen == ShopScreen.CardDraft) { UpdateDraftPanels(p, s, eye, fwd, right, up, ang); return; }
|
||||||
|
|
||||||
|
// left panel (shop options) — right-justified, sits left of centre
|
||||||
|
if (s.OptionsEntity is null || !s.OptionsEntity.IsValid) { s.OptionsEntity = CreateShopPanel(true, cfg.FontSize, cfg.WorldUnitsPerPx); s.LastText = null; }
|
||||||
|
if (s.OptionsEntity is not null)
|
||||||
|
{
|
||||||
|
var lpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset - cfg.SplitOffset) + up * cfg.UpOffset;
|
||||||
|
string lt = BuildShopLeft(p, s);
|
||||||
|
if (s.LastText != lt) { WorldText.SetText(s.OptionsEntity, lt); s.LastText = lt; }
|
||||||
|
WorldText.Place(s.OptionsEntity, lpos, ang);
|
||||||
|
}
|
||||||
|
|
||||||
|
// right panel (info) — left-justified, sits right of centre
|
||||||
|
if (s.InfoEntity is null || !s.InfoEntity.IsValid) { s.InfoEntity = CreateShopPanel(false, cfg.InfoFontSize, cfg.InfoWorldUnitsPerPx); s.LastInfoText = null; }
|
||||||
|
if (s.InfoEntity is not null)
|
||||||
|
{
|
||||||
|
var rpos = eye + fwd * cfg.ForwardOffset + right * (cfg.RightOffset + cfg.SplitOffset) + up * cfg.UpOffset;
|
||||||
|
string rt = BuildShopRight(p);
|
||||||
|
if (s.LastInfoText != rt) { WorldText.SetText(s.InfoEntity, rt); s.LastInfoText = rt; }
|
||||||
|
WorldText.Place(s.InfoEntity, rpos, ang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// left panel — the current screen's selectable options
|
||||||
|
private string BuildShopLeft(CCSPlayerController p, ShopSession s)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append($"===== {ScreenTitle(s)} =====\n");
|
||||||
|
if (s.Screen == ShopScreen.CardDraft && Draft is { } sd)
|
||||||
|
sb.Append($"Picks left: {sd.PendingCards(p)} (unspent carry over)\n");
|
||||||
|
if (pd is not null)
|
||||||
|
foreach (var opt in CurrentOptions(p, s, pd))
|
||||||
|
sb.Append($"[{KeyItemLabel(opt.Key)}] {opt.Label}\n"); // show the ITEM to switch to, not the default key#
|
||||||
|
// home = Root in TDM, SkillGroups in a skills-only mode, or the survival draft -> close; deeper screens -> back
|
||||||
|
bool atHome = s.Screen is ShopScreen.Root or ShopScreen.CardDraft
|
||||||
|
|| (!_driver.WeaponShopEnabled && s.Screen == ShopScreen.SkillGroups);
|
||||||
|
sb.Append(atHome ? "[Health] / [Crouch] close\n" : "[Health] back\n");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// right panel — server/about blurb (top) then the player's own stats (bottom), in a much smaller font
|
||||||
|
private string BuildShopRight(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (var line in Config.Shop.InfoLines) sb.Append(line).Append('\n'); // server / about (!info, !about)
|
||||||
|
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return sb.ToString();
|
||||||
|
|
||||||
|
double kd = pd.Deaths > 0 ? (double)pd.Kills / pd.Deaths : pd.Kills;
|
||||||
|
int xpPct = (int)(pd.Xp * 100 / Math.Max(1, XpToNext(pd.Level)));
|
||||||
|
sb.Append("----- YOU -----\n"); // player (!me, !stats)
|
||||||
|
sb.Append($"{RankName(pd)}\n");
|
||||||
|
sb.Append($"Lv {pd.Level} P{Roman(pd.Prestige)} XP {xpPct}% {pd.Points}pt\n");
|
||||||
|
sb.Append($"K {pd.Kills} D {pd.Deaths} KD {kd:F2} Streak {pd.Streak}\n");
|
||||||
|
var (effHs, effOut, effIn, effXp) = EffectiveMultipliers(p, pd);
|
||||||
|
sb.Append($"Out x{effOut:F2} In x{effIn:F2} HS x{effHs:F2} XP x{effXp:F2}\n");
|
||||||
|
if (_driver.WeaponShopEnabled)
|
||||||
|
{
|
||||||
|
var (pri, sec) = ResolveLoadout(p);
|
||||||
|
sb.Append($"{WeaponDisplayName(pri)} / {WeaponDisplayName(sec)}\n");
|
||||||
|
}
|
||||||
|
else if (_driver.LadderStatusLine(pd) is { } rungLine) // weapon dictated by the ladder
|
||||||
|
sb.Append(rungLine).Append('\n');
|
||||||
|
var owned = StatList.Where(st => LevelOf(pd, st.Key) > 0).ToList();
|
||||||
|
if (owned.Count > 0)
|
||||||
|
{
|
||||||
|
string Cell(int j) => $"{StatDisplay(owned[j].Key)} {LevelOf(pd, owned[j].Key)}/{DefFor(owned[j].Key).MaxLevel}";
|
||||||
|
sb.Append("- Stats -\n");
|
||||||
|
for (int i = 0; i < owned.Count; i += 2) // two columns -> wider, fewer rows
|
||||||
|
sb.Append(i + 1 < owned.Count ? $"{Cell(i)} {Cell(i + 1)}\n" : $"{Cell(i)}\n");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// each player sees only their own shop panels
|
||||||
|
private void OnCheckTransmit_Shop(CCheckTransmitInfoList infoList) =>
|
||||||
|
HideForeignSlotEntities(infoList, _shop, static (info, s) =>
|
||||||
|
{
|
||||||
|
if (s.OptionsEntity is { IsValid: true }) info.TransmitEntities.Remove(s.OptionsEntity);
|
||||||
|
if (s.InfoEntity is { IsValid: true }) info.TransmitEntities.Remove(s.InfoEntity);
|
||||||
|
foreach (var c in s.Cards) if (c is { IsValid: true }) info.TransmitEntities.Remove(c);
|
||||||
|
});
|
||||||
|
}
|
||||||
56
Outnumbered/SnapshotBuilder.cs
Normal file
56
Outnumbered/SnapshotBuilder.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The single engine site that reads a player into an immutable PlayerSnapshot for the pure Domain math, plus the stat
|
||||||
|
// registry (_statDefs) the Domain resolvers consume. Everything pure (handicap / combat / progression / stat resolution)
|
||||||
|
// takes a snapshot + _statDefs and never touches a pawn/controller — so the CS2/CSSharp surface lives only here.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private const int BaseMaxHp = 100;
|
||||||
|
private const int BaseMaxArmor = 100;
|
||||||
|
|
||||||
|
// key -> StatDef, rebuilt from Config.Stats on Load + every !og_reload. This is the stat registry the Domain resolvers
|
||||||
|
// index (StatResolver/CombatResolver take it) — the key->def view of the named Config.Stats fields (which stay, for JSON
|
||||||
|
// back-compat). Projected straight from StatRegistry (Stats.cs), so a new stat is one registry row, not a line here.
|
||||||
|
// FrozenDictionary: built once per load/reload, read per-hit (OffenseMultiplier alone does 3+ lookups/hit) — frozen
|
||||||
|
// has faster reads than Dictionary. Ordinal matches Dictionary<string,_>'s default, so lookups are bit-identical.
|
||||||
|
private FrozenDictionary<string, StatDef> _statDefs = FrozenDictionary<string, StatDef>.Empty;
|
||||||
|
|
||||||
|
private void RebuildStatDefs() =>
|
||||||
|
_statDefs = StatRegistry.ToFrozenDictionary(r => r.Key, r => r.Def(Config.Stats), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Build the immutable Domain input for one player. `p` is optional: it supplies the live pawn Health (drives the
|
||||||
|
// missing-HP fraction for Berserk) — null is fine for paths that don't read Health (XP rate, where t ignores HP).
|
||||||
|
// Upgrades + Cards are held by reference, so building a snapshot per hit/tick is allocation-free.
|
||||||
|
internal PlayerSnapshot Snapshot(PlayerData pd, CCSPlayerController? p = null)
|
||||||
|
{
|
||||||
|
var pawn = p?.PlayerPawn.Value;
|
||||||
|
double now = Server.CurrentTime; // read once for the 3 ability checks below (can't advance within this build)
|
||||||
|
return new PlayerSnapshot
|
||||||
|
{
|
||||||
|
Level = pd.Level,
|
||||||
|
Prestige = pd.Prestige,
|
||||||
|
Xp = pd.Xp,
|
||||||
|
Kills = pd.Kills,
|
||||||
|
Deaths = pd.Deaths,
|
||||||
|
HeadshotKills = pd.HeadshotKills,
|
||||||
|
Streak = pd.Streak,
|
||||||
|
Health = pawn?.Health ?? 0,
|
||||||
|
HandicapProgress = _driver?.HandicapProgress(pd) ?? 0.0,
|
||||||
|
HandicapFloor = _driver?.HandicapFloor(pd) ?? -1.0,
|
||||||
|
TeamDealMult = _driver?.TeamDealMult() ?? 1.0,
|
||||||
|
TeamTakeMult = _driver?.TeamTakeMult() ?? 1.0,
|
||||||
|
OverchargeActive = AbilityActive(pd, AbOvercharge, now), // CombatResolver gates these on Abilities.Enabled
|
||||||
|
BerserkActive = AbilityActive(pd, AbBerserk, now),
|
||||||
|
AdrenalineActive = AbilityActive(pd, AbAdrenaline, now),
|
||||||
|
Upgrades = pd.Upgrades,
|
||||||
|
Cards = _driver?.CardSource(pd), // survival run (IStatBonusSource); null in TDM/GG
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
300
Outnumbered/Stats.cs
Normal file
300
Outnumbered/Stats.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// The stat core. Offense (Damage/Crit/Headshot) goes through the central damage hook; reactive sustain (lifesteal) on
|
||||||
|
// EventPlayerHurt; HP/Armor caps per spawn; regen on a timer. All values come from the player's upgrade levels.
|
||||||
|
// (StatKeys lives in Domain/StatKeys.cs — CSSharp-free + test-shared.)
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// The passive-stat REGISTRY — one row per stat, the single place a stat's identity lives: display name, the shop SKILL
|
||||||
|
// GROUP it sits in (0/1/2), and a selector picking its LIVE StatDef out of Config.Stats (so !og_reload tunables apply).
|
||||||
|
// StatList, the _statDefs key->def registry (SnapshotBuilder.RebuildStatDefs), and SkillGroupStats all PROJECT from this,
|
||||||
|
// so adding/reordering a stat is one row here (+ a StatKeys const + the named Config.Stats field kept for JSON back-compat).
|
||||||
|
// Mirrors AbilityRegistry/AbilityInfo.Def. Order = StatList order; within a group = shop key order.
|
||||||
|
internal sealed record StatInfo(string Key, string Display, int Group, Func<StatsConfig, StatDef> Def);
|
||||||
|
|
||||||
|
internal static readonly StatInfo[] StatRegistry =
|
||||||
|
[
|
||||||
|
new(StatKeys.Damage, "Damage", 0, c => c.Damage),
|
||||||
|
new(StatKeys.CritChance, "Crit Chance", 0, c => c.CritChance),
|
||||||
|
new(StatKeys.CritDamage, "Crit Damage", 0, c => c.CritDamage),
|
||||||
|
new(StatKeys.HeadshotDamage, "Headshot Damage", 0, c => c.HeadshotDamage),
|
||||||
|
new(StatKeys.MaxHp, "Max HP", 1, c => c.MaxHp),
|
||||||
|
new(StatKeys.MaxArmor, "Max Armor", 1, c => c.MaxArmor),
|
||||||
|
new(StatKeys.Lifesteal, "Lifesteal", 2, c => c.Lifesteal),
|
||||||
|
new(StatKeys.ArmorLifesteal, "Armor Lifesteal", 2, c => c.ArmorLifesteal),
|
||||||
|
new(StatKeys.HpRegen, "HP Regen", 1, c => c.HpRegen),
|
||||||
|
new(StatKeys.ArmorRegen, "Armor Regen", 1, c => c.ArmorRegen),
|
||||||
|
new(StatKeys.Thorns, "Thorns", 2, c => c.Thorns),
|
||||||
|
new(StatKeys.XpBoost, "XP Boost", 2, c => c.XpBoost),
|
||||||
|
];
|
||||||
|
|
||||||
|
// key -> display, projected from the registry (UI + the load-time validation read it). Order = StatRegistry order.
|
||||||
|
private static readonly (string Key, string Display)[] StatList =
|
||||||
|
StatRegistry.Select(r => (r.Key, r.Display)).ToArray();
|
||||||
|
|
||||||
|
// Shop skill groups (each group rendered on the grenade-key tail). Grouped from the registry in group-index order,
|
||||||
|
// within-group in registry order — lives here (the stat hub), the canonical stat grouping, not in Shop.cs.
|
||||||
|
private static readonly string[][] SkillGroupStats =
|
||||||
|
StatRegistry.GroupBy(r => r.Group).OrderBy(g => g.Key).Select(g => g.Select(r => r.Key).ToArray()).ToArray();
|
||||||
|
|
||||||
|
private const int HitgroupHead = 1; // BaseMaxHp/BaseMaxArmor live in SnapshotBuilder.cs (same partial class)
|
||||||
|
private const double ThornsReflectCapHp = 25.0; // hard, UNCONFIGURABLE cap on a single thorns reflect — a high thorns % can't trivialize waves
|
||||||
|
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _regenTimer;
|
||||||
|
private readonly HashSet<ulong> _dmgReadout = new(); // !dmg — players seeing the per-hit final-damage readout
|
||||||
|
private readonly Dictionary<int, bool> _critPending = new(); // victim slot -> was this hit a crit (damage hook -> EventPlayerHurt, for crit XP)
|
||||||
|
|
||||||
|
private static string HitgroupName(int h) => h switch
|
||||||
|
{
|
||||||
|
1 => "HEAD",
|
||||||
|
2 => "chest",
|
||||||
|
3 => "stomach",
|
||||||
|
4 or 5 => "arm",
|
||||||
|
6 or 7 => "leg",
|
||||||
|
_ => "body",
|
||||||
|
};
|
||||||
|
|
||||||
|
private void Initialize_Stats()
|
||||||
|
{
|
||||||
|
RebuildStatDefs(); // build the key->StatDef registry the Domain resolvers index (rebuilt on !og_reload)
|
||||||
|
// StatList, _statDefs and SkillGroupStats all PROJECT from StatRegistry, so their agreement is guaranteed by
|
||||||
|
// construction. The one thing the registry can't prevent: a skill group with more stats than the shop can address
|
||||||
|
// (each group renders on the grenade-key tail), which would leave the extras unreachable in the menu.
|
||||||
|
foreach (var g in SkillGroupStats)
|
||||||
|
if (g.Length > GrenadeMenuKeys.Length)
|
||||||
|
Logger.LogError("Outnumbered: a SkillGroupStats group has {N} stats but only {K} shop keys are addressable — the extras can't be selected.", g.Length, GrenadeMenuKeys.Length);
|
||||||
|
// SkillGroupStats is derived from StatRegistry's distinct Group values; SkillGroupNames[i] labels group i. The one
|
||||||
|
// coupling the registry can't enforce: a new group (a stat with a higher Group index) needs a matching name, else
|
||||||
|
// the shop would index past SkillGroupNames when rendering that group's header.
|
||||||
|
if (SkillGroupStats.Length != SkillGroupNames.Length)
|
||||||
|
Logger.LogError("Outnumbered: {N} skill groups but {M} SkillGroupNames labels — add a name for each group.", SkillGroupStats.Length, SkillGroupNames.Length);
|
||||||
|
RegisterListener<Listeners.OnEntityTakeDamagePre>(OnEntityTakeDamagePre);
|
||||||
|
RegisterEventHandler<EventPlayerHurt>(OnPlayerHurt_Stats);
|
||||||
|
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Stats);
|
||||||
|
_regenTimer = AddTimer(Config.Stats.RegenIntervalSeconds, RegenTick, TimerFlags.REPEAT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Shutdown_Stats() => _regenTimer?.Kill();
|
||||||
|
|
||||||
|
// ---- stat-resolution bridge: pd -> Domain. The FORMULAS live in Outnumbered.Domain.StatResolver; these are
|
||||||
|
// zero-math accessors that snapshot the player and call it, so cold/UI/cross-file sites stay ergonomic. The hot
|
||||||
|
// damage hook + HUD skip these and call CombatResolver with ONE snapshot (the shared offense/defense chain). ----
|
||||||
|
private StatDef DefFor(string key) => _statDefs.TryGetValue(key, out var d) ? d : new StatDef(0, 0);
|
||||||
|
|
||||||
|
private static int LevelOf(PlayerData pd, string key) => pd.Upgrades.TryGetValue(key, out var l) ? l : 0;
|
||||||
|
private double Eff(PlayerData pd, string key) => StatResolver.Eff(Snapshot(pd), key, _statDefs);
|
||||||
|
// Magnitude of a survival EFFECT card (a CardKeys.* key, NOT a stat) = picks x PerPick via the driver; 0 outside a run.
|
||||||
|
// The engine/live-path accessor for the non-snapshot presence checks (burn/explode/cdr) — distinct from the
|
||||||
|
// snapshot-pure Domain StatResolver.CardMag used inside CombatResolver.
|
||||||
|
private double EffectCardMag(PlayerData pd, string key) => _driver?.StatBonus(pd, key) ?? 0.0;
|
||||||
|
private int MaxHpOf(PlayerData pd) => StatResolver.MaxHp(Snapshot(pd), _statDefs, BaseMaxHp);
|
||||||
|
private int MaxArmorOf(PlayerData pd) => StatResolver.MaxArmor(Snapshot(pd), _statDefs, BaseMaxArmor);
|
||||||
|
|
||||||
|
// Snapshot-level overloads: when a caller already built ONE snapshot (the hot reactive-sustain + regen paths), reuse it
|
||||||
|
// across several stat reads instead of the pd-overloads rebuilding Snapshot(pd) per accessor. Identical result (the
|
||||||
|
// pd-overloads just call these on a fresh Snapshot(pd)), computed once.
|
||||||
|
private double EffRun(in PlayerSnapshot s, string key) => StatResolver.EffRun(s, key, _statDefs);
|
||||||
|
private int MaxHpOf(in PlayerSnapshot s) => StatResolver.MaxHp(s, _statDefs, BaseMaxHp);
|
||||||
|
private int MaxArmorOf(in PlayerSnapshot s) => StatResolver.MaxArmor(s, _statDefs, BaseMaxArmor);
|
||||||
|
|
||||||
|
// ---- damage hook (offense + defense). Both multiplier chains come from the SHARED Domain CombatResolver (so the
|
||||||
|
// HUD readout can never drift from what actually applies); the hook keeps only the engine bits — pawn->controller
|
||||||
|
// resolution, the crit roll plus its side effects (sound + crit-XP marker), and writing info.Damage. ----
|
||||||
|
private HookResult OnEntityTakeDamagePre(CBaseEntity entity, CTakeDamageInfo info)
|
||||||
|
{
|
||||||
|
// An unscaled plugin hit is in flight (raw burn DoT, or a flat thorns reflect): apply the damage exactly as set —
|
||||||
|
// no offense scaling, no crit roll. Re-entrancy guard, reset in DamageDealer.Deal's finally.
|
||||||
|
if (DamageDealer.Unscaled) return HookResult.Continue;
|
||||||
|
|
||||||
|
// pawn designer-name varies by build ("cs_player_pawn" or "player") — catalog in EngineNames
|
||||||
|
if (entity is not { IsValid: true } || !EngineNames.PlayerPawnDesigners.Contains(entity.DesignerName))
|
||||||
|
return HookResult.Continue;
|
||||||
|
|
||||||
|
var attacker = ControllerOfPawn(info.Attacker.Value?.As<CCSPlayerPawn>());
|
||||||
|
var victim = ControllerOfPawn(new CCSPlayerPawn(entity.Handle));
|
||||||
|
|
||||||
|
// OFFENSE — a human dealing damage: build x crit x abilities x M_deal (the whole chain in CombatResolver).
|
||||||
|
if (attacker is { IsValid: true } && !attacker.IsBot)
|
||||||
|
{
|
||||||
|
var asid = attacker.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (asid is not null && _players.TryGetValue(asid.Value, out var apd))
|
||||||
|
{
|
||||||
|
// GG speedrun clock: arms on the run's FIRST damage dealt-or-taken (self-damage included — arming early
|
||||||
|
// can only lengthen your time, so it's abuse-proof). Hot path: one bool + one field check, nothing else.
|
||||||
|
if (_ggTimerActive && apd.GgRunStartedAtMs == 0)
|
||||||
|
apd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
if (victim is { IsValid: true } && victim.Slot == attacker.Slot) return HookResult.Continue; // no self-scaling
|
||||||
|
|
||||||
|
bool headshot = (int)info.GetHitGroup() == HitgroupHead; // pre-engine-HS; engine applies its ~4x afterward
|
||||||
|
var s = Snapshot(apd, attacker);
|
||||||
|
double critChance = CombatResolver.CritChance(s, _statDefs);
|
||||||
|
bool crit = critChance > 0 && Random.Shared.NextDouble() * 100.0 < critChance;
|
||||||
|
if (crit) PlaySound(attacker, Config.Sounds.Crit); // no-op unless a Crit sound is configured
|
||||||
|
if (victim is { IsValid: true }) _critPending[victim.Slot] = crit; // remembered for crit XP in EventPlayerHurt
|
||||||
|
|
||||||
|
info.Damage = (float)(info.Damage *
|
||||||
|
CombatResolver.OffenseMultiplier(s, headshot, crit, _statDefs, _hcap, Config.Abilities, BaseMaxHp));
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEFENSE — a human taking damage (from a bot/world): x M_take x Adrenaline x headshot-armor card.
|
||||||
|
if (victim is { IsValid: true } && !victim.IsBot)
|
||||||
|
{
|
||||||
|
var vsid = victim.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (vsid is not null && _players.TryGetValue(vsid.Value, out var vpd))
|
||||||
|
{
|
||||||
|
// GG speedrun clock, taken-damage side (covers bot AND world/fall damage — the attacker branch never sees those).
|
||||||
|
if (_ggTimerActive && vpd.GgRunStartedAtMs == 0)
|
||||||
|
vpd.GgRunStartedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
bool headshot = (int)info.GetHitGroup() == HitgroupHead;
|
||||||
|
info.Damage = (float)(info.Damage *
|
||||||
|
CombatResolver.DefenseMultiplier(Snapshot(vpd, victim), headshot, _hcap, Config.Abilities));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reactive sustain + last-damage tracking ----
|
||||||
|
private HookResult OnPlayerHurt_Stats(EventPlayerHurt ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var victim = ev.Userid;
|
||||||
|
var attacker = ev.Attacker;
|
||||||
|
|
||||||
|
// THORNS — a human hit by a bot reflects a % of the damage taken back onto that bot. The reflect is dealt as
|
||||||
|
// real, attributed damage (so it can kill and credits the player for the kill/XP/GG-rung) and re-enters the
|
||||||
|
// offense hook in OnEntityTakeDamagePre, so it scales with the player's damage build AND the handicap
|
||||||
|
// (a nerfed steamroller's thorns are nerfed too — no bypass; a buffed weak player's thorns hit harder).
|
||||||
|
if (victim is { IsValid: true } && !victim.IsBot
|
||||||
|
&& attacker is { IsValid: true } && attacker.IsBot && attacker.Slot != victim.Slot
|
||||||
|
&& PdOf(victim) is { } tpd)
|
||||||
|
{
|
||||||
|
var st = Snapshot(tpd); // ONE snapshot: thorns % + both steal reads
|
||||||
|
double thorns = EffRun(st, StatKeys.Thorns);
|
||||||
|
// Thorns is OFF while the reflector holds a knife or the zeus: a GG knife/zeus finale must be won by a REAL melee
|
||||||
|
// kill, not by tanking hits and letting the reflect kill the bot. (The active-weapon probe runs only once thorns is
|
||||||
|
// actually in play — the cheap thorns/damage checks short-circuit first.)
|
||||||
|
if (thorns > 0 && ev.DmgHealth + ev.DmgArmor > 0
|
||||||
|
&& !Inventory.IsMeleeOrZeus(Inventory.ActiveWeaponName(victim.PlayerPawn.Value)))
|
||||||
|
{
|
||||||
|
// Thorns reflects a straight % of the damage ACTUALLY taken (already includes the MTake handicap that sized the
|
||||||
|
// hit — a 5x-handicap 250 HP hit at 10% sends 25 back), dealt FLAT so the offense hook neither scales it by the
|
||||||
|
// build nor lets the handicap nerf it. Clamp to [1, ThornsReflectCapHp]: floor so a tiny hit still reflects,
|
||||||
|
// hard cap so a high thorns % can't trivialize waves.
|
||||||
|
double reflect = Math.Clamp(CombatResolver.ThornsReflect(ev.DmgHealth, ev.DmgArmor, thorns), 1.0, ThornsReflectCapHp);
|
||||||
|
float dealt = DamageDealer.Deal(attacker, victim, (float)reflect, flat: true); // target = the bot, source = the human
|
||||||
|
// The TakeDamageOld invoke doesn't raise player_hurt, so the lifesteal block below never sees the reflected
|
||||||
|
// hit — apply the reflector's lifesteal/armor-lifesteal here, on the actual damage dealt.
|
||||||
|
if (dealt > 0 && victim.PlayerPawn.Value is { } vpawn)
|
||||||
|
{
|
||||||
|
// No crit on the reflect's steal, so critMult=1.0 — same formula as the on-hit path (CombatResolver).
|
||||||
|
double ls = EffRun(st, StatKeys.Lifesteal);
|
||||||
|
if (ls > 0) PawnWriter.AddHealthCapped(vpawn, CombatResolver.LifestealHeal(dealt, ls, 1.0, Config.Stats.LifestealMinHeal), MaxHpOf(st));
|
||||||
|
double als = EffRun(st, StatKeys.ArmorLifesteal);
|
||||||
|
if (als > 0) PawnWriter.AddArmorCapped(vpawn, CombatResolver.ArmorLifestealGain(dealt, als, 1.0), MaxArmorOf(st));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attacker is { IsValid: true } && !attacker.IsBot && victim is { IsValid: true } && attacker.Slot != victim.Slot)
|
||||||
|
{
|
||||||
|
var asid = attacker.AuthorizedSteamID?.SteamId64;
|
||||||
|
if (asid is not null)
|
||||||
|
{
|
||||||
|
if (_dmgReadout.Contains(asid.Value)) // !dmg — true final damage applied (post engine-HS + armor)
|
||||||
|
attacker.PrintToChat($" {ChatColors.Gold}[dmg]{ChatColors.Default} {HitgroupName(ev.Hitgroup)} | hp {ev.DmgHealth} + armor {ev.DmgArmor} = {ev.DmgHealth + ev.DmgArmor}");
|
||||||
|
|
||||||
|
// Burn card (survival): EVERY hit on a bot (re)applies the DoT — flat, armor-skipping, per-attacker; the
|
||||||
|
// tick timer (Effects.cs) deals it. Independent of DmgHealth so even an armor-only hit still ignites.
|
||||||
|
if (victim.IsBot && _players.TryGetValue(asid.Value, out var burnPd) && EffectCardMag(burnPd, CardKeys.Burn) > 0)
|
||||||
|
RegisterBurn(victim.Slot, attacker.Slot, asid.Value);
|
||||||
|
|
||||||
|
if (_players.TryGetValue(asid.Value, out var apd) && ev.DmgHealth > 0)
|
||||||
|
{
|
||||||
|
bool wasCrit = _critPending.Remove(victim.Slot, out bool c) && c; // this hit's crit (set in the damage hook), shared by XP + lifesteal
|
||||||
|
|
||||||
|
// XP on damage: final HP damage × rate + flat headshot/crit bonuses (only for damage to bots).
|
||||||
|
// The kill bonus is granted separately on EventPlayerDeath. Multipliers apply inside GrantXp.
|
||||||
|
if (victim.IsBot)
|
||||||
|
{
|
||||||
|
double xpBase = ev.DmgHealth * Config.Progression.DamageXpPerHp;
|
||||||
|
if (ev.Hitgroup == HitgroupHead) xpBase += Config.Progression.HeadshotXpBonus;
|
||||||
|
if (wasCrit) xpBase += Config.Progression.CritXpBonus;
|
||||||
|
GrantCombatXp(apd, xpBase, attacker); // survival: banked raw into the run accumulator; else GrantXp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloodthirst adds flat lifesteal % for its duration, on top of the stat.
|
||||||
|
double lsBonus = 0.0, alsBonus = 0.0;
|
||||||
|
if (Config.Abilities.Enabled && AbilityActive(apd, AbBloodthirst))
|
||||||
|
{
|
||||||
|
lsBonus = Config.Abilities.Bloodthirst.Magnitude;
|
||||||
|
alsBonus = Config.Abilities.Bloodthirst.Magnitude2;
|
||||||
|
}
|
||||||
|
double critLs = wasCrit ? Config.Stats.CritLifestealMultiplier : 1.0; // crit hits steal extra (default +50%)
|
||||||
|
if (attacker.PlayerPawn.Value is { } apawn)
|
||||||
|
{
|
||||||
|
var sa = Snapshot(apd); // ONE snapshot shared by both lifesteal reads + their caps
|
||||||
|
double ls = EffRun(sa, StatKeys.Lifesteal) + lsBonus;
|
||||||
|
if (ls > 0) PawnWriter.AddHealthCapped(apawn, CombatResolver.LifestealHeal(ev.DmgHealth, ls, critLs, Config.Stats.LifestealMinHeal), MaxHpOf(sa));
|
||||||
|
double als = EffRun(sa, StatKeys.ArmorLifesteal) + alsBonus;
|
||||||
|
if (als > 0) PawnWriter.AddArmorCapped(apawn, CombatResolver.ArmorLifestealGain(ev.DmgHealth, als, critLs), MaxArmorOf(sa));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-spawn HP/Armor caps (humans; bots get fixed HP from the driver) ----
|
||||||
|
private HookResult OnPlayerSpawn_Stats(EventPlayerSpawn ev, GameEventInfo info)
|
||||||
|
{
|
||||||
|
var p = ev.Userid;
|
||||||
|
if (!IsHuman(p)) return HookResult.Continue;
|
||||||
|
NextFrameForSlot(p.Slot, pl => { if (PdOf(pl) is { } pd) ApplyMaxHpArmor(pl, pd); }, requireAlive: true);
|
||||||
|
return HookResult.Continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyMaxHpArmor(CCSPlayerController p, PlayerData pd)
|
||||||
|
{
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pawn is null || pawn.Health <= 0) return;
|
||||||
|
int maxHp = MaxHpOf(pd);
|
||||||
|
PawnWriter.SetMaxHealth(p, pawn, maxHp); // max BEFORE health, else health clamps to 100
|
||||||
|
PawnWriter.SetHealth(pawn, maxHp);
|
||||||
|
PawnWriter.SetArmor(pawn, MaxArmorOf(pd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regen is ALWAYS ON (no out-of-combat gate — so it's useful mid-fight/clutch) and FLAT, not %-of-max (so it doesn't
|
||||||
|
// balloon on a maxed-HP tank). HP/armor restored per tick = the stat's flat value (EffRun) — i.e. 1/sec/level at the
|
||||||
|
// default 1s interval, + any survival regen card. Deliberately small vs a big HP pool: a slow rescue, not a fountain.
|
||||||
|
private void RegenTick()
|
||||||
|
{
|
||||||
|
foreach (var p in Utilities.GetPlayers())
|
||||||
|
{
|
||||||
|
if (!IsLiveHuman(p)) continue; // inline predicate (no Where-iterator alloc on this per-tick timer)
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) continue;
|
||||||
|
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pawn is null || pawn.Health <= 0) continue;
|
||||||
|
|
||||||
|
// FLAT HP/armor per tick (always on), capped at the build's max — the writes live in PawnWriter.
|
||||||
|
var s = Snapshot(pd); // ONE snapshot shared by the HP + armor regen reads + their caps
|
||||||
|
PawnWriter.AddHealthCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.HpRegen)), MaxHpOf(s));
|
||||||
|
PawnWriter.AddArmorCapped(pawn, (int)Math.Round(EffRun(s, StatKeys.ArmorRegen)), MaxArmorOf(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
Outnumbered/Survival.cs
Normal file
594
Outnumbered/Survival.cs
Normal file
|
|
@ -0,0 +1,594 @@
|
||||||
|
using CounterStrikeSharp.API;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Modules.Timers;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Domain;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Wave Survival ("Last Stand") — co-op, escalating bot waves on the small arms-race maps. The whole RPG core rides
|
||||||
|
// along unchanged. Design (research/survival-mode-design.md): VANILLA bots (100hp/100armor, never tougher) — the
|
||||||
|
// difficulty curve is (a) MORE bots up to AliveCap, and (b) the inherited handicap floor tightening every wave; the
|
||||||
|
// counter-pressure is the roguelite DRAFT (strong run-scoped cards via EffRun). You outscale until the floor + horde
|
||||||
|
// finally win. Combat XP is banked RAW per wave and granted to the main table at EACH wave clear (x prestige x waveMult),
|
||||||
|
// so players level + buy skills mid-run; an uncleared wave is forfeited on a wipe.
|
||||||
|
//
|
||||||
|
// State machine: Idle -> (humans present) -> Fighting wave N -> (kills == budget) -> ClearWave (grant wave XP) -> Break
|
||||||
|
// (revive + draft) -> wave N+1 -> ... -> clear WaveCount = WIN; all humans dead / wave timeout = LOSE -> map end.
|
||||||
|
public sealed class SurvivalDriver : IMatchDriver, IDraftDriver
|
||||||
|
{
|
||||||
|
private readonly OutnumberedPlugin _p;
|
||||||
|
public SurvivalDriver(OutnumberedPlugin p) => _p = p;
|
||||||
|
|
||||||
|
private enum WavePhase { Idle, Fighting, Break }
|
||||||
|
|
||||||
|
private readonly Dictionary<ulong, SurvivalRun> _runs = []; // per-player run state (RAM-only; run ends on disconnect)
|
||||||
|
private WavePhase _phase = WavePhase.Idle;
|
||||||
|
private int _wave; // current wave (0 = not started)
|
||||||
|
private int _killsThisWave;
|
||||||
|
private int _waveBudget; // kills needed to clear the current wave (locked at wave start)
|
||||||
|
private int _highestWaveCleared;
|
||||||
|
private bool _runActive;
|
||||||
|
private bool _ready; // the server is up + a map is loaded (set from OnMatchReset/OnMapSetup, NOT Load) — gates WaveTick
|
||||||
|
private bool _runEnded; // terminal latch: a run finished on THIS map -> no auto-restart until the next map
|
||||||
|
private double _phaseUntil; // Server.CurrentTime the break ends
|
||||||
|
private double _lastKillAt; // last credited kill — the stall-nudge + timeout measure PROGRESS, not wall-time
|
||||||
|
private double _lastNudgeAt; // last stall-nudge, so stalled bots aren't re-pulled every tick
|
||||||
|
private double _mapReadyAt; // don't start a run until the map has settled + players can spawn
|
||||||
|
private CounterStrikeSharp.API.Modules.Timers.Timer? _tick;
|
||||||
|
|
||||||
|
// TEAM cards (global_deal / global_take): ONE shared squad level each (0..Cap), incremented by ANY survivor's pick,
|
||||||
|
// applied to EVERY survivor via the cached multipliers (read per-hit from Handicap.MDeal/MTake -> kept as fields, not
|
||||||
|
// recomputed each call). Reset per run. RecomputeTeamMults runs on pick / run start (live PerPick edits take on next pick).
|
||||||
|
private int _teamDealLevel;
|
||||||
|
private int _teamTakeLevel;
|
||||||
|
private double _teamDealMult = 1.0;
|
||||||
|
private double _teamTakeMult = 1.0;
|
||||||
|
|
||||||
|
// ---- IMatchDriver: identity ----
|
||||||
|
public string Id => "survival";
|
||||||
|
public IReadOnlyList<string> Maps =>
|
||||||
|
_p.Config.Survival.Maps.Count > 0 ? _p.Config.Survival.Maps : _p.Config.Match.Maps;
|
||||||
|
public bool WeaponShopEnabled => true; // players choose their guns; cards are a separate draft
|
||||||
|
public HandicapOverride? Handicap => _p.Config.Survival.Handicap;
|
||||||
|
public int MaxHumansOnCt => _p.Config.Survival.MaxHumansOnCt;
|
||||||
|
// Nobody auto-respawns: humans are revived at wave-clear (ReviveSurvivor); bots are spawned/streamed entirely by the
|
||||||
|
// wave machine (force-Respawn in UpdateBotQuota). Engine auto-respawn for bots is OFF so it can't fight the wave drain
|
||||||
|
// AND so we don't depend on it spawning bots (which it refuses after repeated T-side wipes — the wave-3 stall).
|
||||||
|
public string ExtraCvars => "mp_randomspawn 1;mp_respawn_on_death_ct 0;mp_respawn_on_death_t 0;";
|
||||||
|
public double HandicapProgress(PlayerData pd) => 0.0; // survival escalates via the wave FLOOR, not the progress axis
|
||||||
|
public bool OwnsBotPopulation => true; // the wave machine drives bot_quota, not the core's SyncBots
|
||||||
|
public bool RunInProgress => _runActive; // EnforceHumanTeam fail-closed: no mid-run joins
|
||||||
|
|
||||||
|
// Status API extras: the wave machine at a glance (site server cards show "Wave X/Y").
|
||||||
|
public object? StatusExtra() =>
|
||||||
|
new { Wave = _wave, WaveCount = _p.Config.Survival.WaveCount, Phase = _phase.ToString(), RunActive = _runActive };
|
||||||
|
|
||||||
|
// ---- loadout ----
|
||||||
|
public void GiveHumanLoadout(CCSPlayerController p) => _p.GiveChosenLoadout(p);
|
||||||
|
|
||||||
|
public void GiveBotLoadout(CCSPlayerController bot) => _p.GiveStandardBotLoadout(bot); // vanilla bots, vanilla HP — only the COUNT escalates
|
||||||
|
|
||||||
|
// ---- IMatchDriver: per-kill / death results ----
|
||||||
|
// A human killed a bot — wave progress. (PvE: a human's victim is always a bot.)
|
||||||
|
public void OnHumanKill(CCSPlayerController attacker, PlayerData apd)
|
||||||
|
{
|
||||||
|
if (!_runActive || _phase != WavePhase.Fighting) return;
|
||||||
|
_killsThisWave++;
|
||||||
|
_lastKillAt = Server.CurrentTime; // progress made -> reset the stall-nudge / timeout window
|
||||||
|
UpdateBotQuota(); // shrink the quota immediately so drain-zone kills aren't spuriously re-spawned (WaveTick clears the wave)
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnBotKill(CCSPlayerController bot) { } // a bot killed a human -> no wave progress; wipe handled in OnHumanDeath
|
||||||
|
public void OnHeadshotDeath(CCSPlayerController victim) { } // no ladder in survival
|
||||||
|
|
||||||
|
// A human died: no team switch (mp_respawn_on_death_ct 0 keeps them dead = auto death-spectate; revived at wave-clear).
|
||||||
|
// Run wipe detection next frame (count after the pawn is actually down).
|
||||||
|
public void OnHumanDeath(CCSPlayerController victim, PlayerData pd)
|
||||||
|
{
|
||||||
|
if (!_runActive) return;
|
||||||
|
Server.NextFrame(() =>
|
||||||
|
{
|
||||||
|
if (_runActive && _phase == WavePhase.Fighting && AliveCtHumans() == 0)
|
||||||
|
EndRun("the squad was wiped out", win: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A human left mid-run: every CLEARED wave's XP is already in their main table (granted at each wave clear), so there's
|
||||||
|
// nothing to bank here — the in-progress (uncleared) wave is forfeited. Just forget the run.
|
||||||
|
public void OnHumanDisconnect(ulong steamId, PlayerData pd) => _runs.Remove(steamId);
|
||||||
|
|
||||||
|
// ---- IMatchDriver: cards (EffRun overlay) ----
|
||||||
|
// The active run is the snapshot's card source; null outside a run (Snapshot.Cards then null -> Domain reads 0).
|
||||||
|
public IStatBonusSource? CardSource(PlayerData pd) =>
|
||||||
|
_runActive && _runs.TryGetValue(pd.SteamId, out var run) ? run : null;
|
||||||
|
// Per-player card magnitude for the non-snapshot effect checks (burn/explode/cdr presence). Mirrors CardSource.
|
||||||
|
public double StatBonus(PlayerData pd, string key) => CardSource(pd)?.Bonus(key) ?? 0.0;
|
||||||
|
|
||||||
|
// ---- TEAM cards (global_deal / global_take): squad-wide, folded into MDeal / MTake (Handicap.cs) ----
|
||||||
|
private bool IsTeamCard(string key) => CardDef(key)?.IsTeam == true; // data-driven (SurvivalCardDef.IsTeam)
|
||||||
|
public double TeamDealMult() => _runActive ? _teamDealMult : 1.0; // +dmg dealt, compounding (>=1)
|
||||||
|
public double TeamTakeMult() => _runActive ? _teamTakeMult : 1.0; // -dmg taken, compounding (<=1)
|
||||||
|
|
||||||
|
// Current level of a card: team cards use the shared squad counter; everything else is the per-player pick count.
|
||||||
|
private int CardCount(SurvivalRun run, string key) =>
|
||||||
|
key == CardKeys.GlobalDeal ? _teamDealLevel :
|
||||||
|
key == CardKeys.GlobalTake ? _teamTakeLevel :
|
||||||
|
run.Cards.GetValueOrDefault(key);
|
||||||
|
|
||||||
|
// Recompute the cached team multipliers from the shared levels + the cards' live PerPick (compounding per level).
|
||||||
|
private void RecomputeTeamMults()
|
||||||
|
{
|
||||||
|
_teamDealMult = SurvivalEconomy.TeamMult(_teamDealLevel, CardDef(CardKeys.GlobalDeal)?.PerPick ?? 0.0, increase: true);
|
||||||
|
_teamTakeMult = SurvivalEconomy.TeamMult(_teamTakeLevel, CardDef(CardKeys.GlobalTake)?.PerPick ?? 0.0, increase: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetTeamBuffs() { _teamDealLevel = 0; _teamTakeLevel = 0; _teamDealMult = 1.0; _teamTakeMult = 1.0; }
|
||||||
|
|
||||||
|
// ---- IMatchDriver: the monotonic, escalate-only handicap floor (in t-space) ----
|
||||||
|
public double HandicapFloor(PlayerData pd) => // -1 = no floor (idle / between runs); monotonic in wave
|
||||||
|
_runActive ? SurvivalEconomy.HandicapFloor(_wave, _p.Config.Survival) : -1.0;
|
||||||
|
|
||||||
|
// ---- IMatchDriver: lifecycle ----
|
||||||
|
public void OnActivated()
|
||||||
|
{
|
||||||
|
// Runs during plugin LOAD — the engine globals aren't ready yet, so touching the engine here (e.g.
|
||||||
|
// Server.CurrentTime) SIGSEGVs the server. Only SCHEDULE the heartbeat (AddTimer is safe at Load); it stays
|
||||||
|
// gated by _ready until the first map setup arms it. _ready/_mapReadyAt are set in OnMatchReset + OnMapSetup.
|
||||||
|
_tick ??= _p.AddTimer(0.5f, WaveTick, TimerFlags.REPEAT); // the wave state machine heartbeat (also drives bot_quota)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin Unload / hot-reload: kill the heartbeat so it can't leak or double-fire against a torn-down instance
|
||||||
|
// (a fresh driver schedules its own _tick on the next Load). The framework doesn't reliably auto-kill plugin timers.
|
||||||
|
public void OnDeactivated() { _tick?.Kill(); _tick = null; }
|
||||||
|
|
||||||
|
// Called once the map is set up (Driver.SetupMap — covers both normal map start and a hot-reload mid-map), i.e. the
|
||||||
|
// server is simulating and engine access is safe. This is what actually arms the wave machine.
|
||||||
|
public void OnMapSetup()
|
||||||
|
{
|
||||||
|
_ready = true;
|
||||||
|
_mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnMatchReset()
|
||||||
|
{
|
||||||
|
_runs.Clear(); ResetTeamBuffs();
|
||||||
|
_phase = WavePhase.Idle; _wave = 0; _killsThisWave = 0; _waveBudget = 0;
|
||||||
|
_highestWaveCleared = 0; _runActive = false; _runEnded = false; _phaseUntil = 0;
|
||||||
|
_ready = true; // OnMatchReset only runs on a real map start (server up) -> safe to read the clock + arm
|
||||||
|
_mapReadyAt = Server.CurrentTime + _p.Config.Survival.StartGraceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the wave state machine ----
|
||||||
|
private void WaveTick()
|
||||||
|
{
|
||||||
|
if (!_ready) return; // don't touch the engine until a map is set up (the heartbeat can fire during boot)
|
||||||
|
ManageBots(); // keep the quota in sync with the current phase (also the SyncBots delegate target)
|
||||||
|
double now = Server.CurrentTime;
|
||||||
|
|
||||||
|
if (!_runActive)
|
||||||
|
{
|
||||||
|
// _runEnded gates the restart: after a run ends, TriggerMapEnd only schedules the changelevel ~6s later, and
|
||||||
|
// this heartbeat keeps firing on the dying map — without the latch a still-alive squad (a WIN) would spawn a
|
||||||
|
// phantom wave 1 (and a fresh, re-bankable run accumulator). Cleared on the next map (OnMatchReset).
|
||||||
|
if (!_runEnded && now >= _mapReadyAt && AliveCtHumans() > 0) StartRun();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_phase)
|
||||||
|
{
|
||||||
|
case WavePhase.Fighting:
|
||||||
|
if (AliveCtHumans() == 0) { EndRun("the squad was wiped out", win: false); break; }
|
||||||
|
if (_killsThisWave >= _waveBudget) { ClearWave(); break; }
|
||||||
|
// No KILLS for a while = the last bot(s) can't reach the squad (or everyone's standing still). Pull the
|
||||||
|
// stragglers to the players instead of failing the run. Only the genuine deadlock (still no kills
|
||||||
|
// WaveTimeoutSeconds after the last one, despite repeated nudges) fails out. Both windows measure
|
||||||
|
// time-since-last-KILL, not wall-clock.
|
||||||
|
if (now - _lastKillAt > _p.Config.Survival.StallNudgeSeconds && now - _lastNudgeAt > _p.Config.Survival.StallNudgeSeconds)
|
||||||
|
NudgeStalledBots(now);
|
||||||
|
if (now - _lastKillAt > _p.Config.Survival.WaveTimeoutSeconds) EndRun($"wave {_wave} stalled out", win: false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case WavePhase.Break:
|
||||||
|
// Unspent draft picks BANK to future breaks (RunPoints persists) — a missed break isn't a lost card;
|
||||||
|
// the player can spend the backlog in any later break. No forced auto-pick.
|
||||||
|
if (now >= _phaseUntil) StartWave(_wave + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartRun()
|
||||||
|
{
|
||||||
|
_runActive = true; _highestWaveCleared = 0; _runs.Clear(); ResetTeamBuffs();
|
||||||
|
Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Default}LAST STAND — clear {ChatColors.Lime}{_p.Config.Survival.WaveCount}{ChatColors.Default} waves to win. Good luck.");
|
||||||
|
StartWave(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartWave(int n)
|
||||||
|
{
|
||||||
|
var cfg = _p.Config.Survival;
|
||||||
|
_wave = n; _phase = WavePhase.Fighting; _killsThisWave = 0;
|
||||||
|
_lastKillAt = _lastNudgeAt = Server.CurrentTime;
|
||||||
|
int humans = Math.Max(1, AliveCtHumans());
|
||||||
|
_waveBudget = SurvivalEconomy.WaveBudget(n, humans, cfg);
|
||||||
|
_p.LogSurvival($"StartWave {n}: aliveTarget={AliveForWave(n)} budget={_waveBudget} curBots={BotCount()} humans={humans}");
|
||||||
|
_p.CloseAllShops(); // nobody frozen in a menu when the wave starts
|
||||||
|
UpdateBotQuota();
|
||||||
|
Server.PrintToChatAll($" {ChatColors.Red}[Survival] WAVE {n}/{cfg.WaveCount} {ChatColors.Default}— {ChatColors.LightYellow}{_waveBudget}{ChatColors.Default} hostiles inbound. Hold the line!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearWave()
|
||||||
|
{
|
||||||
|
var cfg = _p.Config.Survival;
|
||||||
|
_highestWaveCleared = _wave;
|
||||||
|
_p.LogSurvival($"ClearWave {_wave} (kills={_killsThisWave}/{_waveBudget}); break={cfg.WaveBreakSeconds}s -> next StartWave {_wave + 1}");
|
||||||
|
|
||||||
|
// Grant THIS cleared wave's XP now (per-wave): raw x prestige x waveMult(wave), straight to the main table, then
|
||||||
|
// reset each accumulator. Players spend the resulting points in the break (!skills). A wipe/leave mid-wave forfeits
|
||||||
|
// the in-progress wave — only a CLEARED wave banks. Iterate _runs by SteamId (not just CtHumans) so a participant
|
||||||
|
// who slipped to spectator/T isn't dropped.
|
||||||
|
List<ulong>? cleared = null;
|
||||||
|
foreach (var kv in _runs)
|
||||||
|
{
|
||||||
|
var run = kv.Value;
|
||||||
|
if (run.WaveXp <= 0) continue;
|
||||||
|
// Best-wave latch: same bound as the XP bank — you contributed damage THIS wave, you get wave credit.
|
||||||
|
// (A spectator idling in _runs from earlier waves earns nothing; improve-only keeps their real peak.)
|
||||||
|
(cleared ??= new(_runs.Count)).Add(kv.Key);
|
||||||
|
if (_p.PdBySteamId(kv.Key) is { } pd) _p.BankWaveXp(pd, run.WaveXp, _wave, ControllerFor(kv.Key));
|
||||||
|
run.WaveXp = 0;
|
||||||
|
}
|
||||||
|
// Before the victory early-return, or the final wave's clear would never reach the leaderboard.
|
||||||
|
if (cleared is not null) _p.RecordBestWaves(cleared, _wave);
|
||||||
|
|
||||||
|
if (_wave >= cfg.WaveCount) { EndRun($"VICTORY — all {cfg.WaveCount} waves cleared!", win: true); return; }
|
||||||
|
|
||||||
|
_phase = WavePhase.Break; _phaseUntil = Server.CurrentTime + cfg.WaveBreakSeconds;
|
||||||
|
UpdateBotQuota(); // Break -> cull the field for the break (steady pool, no kick)
|
||||||
|
ReviveDead();
|
||||||
|
GrantCards();
|
||||||
|
Server.PrintToChatAll($" {ChatColors.Green}[Survival] Wave {_wave} cleared! {ChatColors.Default}{cfg.WaveBreakSeconds:0}s to prep — the draft pops up ({ChatColors.Gold}X{ChatColors.Default}/crouch to close).");
|
||||||
|
_p.AddTimer(0.75f, _p.OpenDraftForAll); // auto-pop the card overlay once the revive-spawns have settled
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndRun(string reason, bool win)
|
||||||
|
{
|
||||||
|
_p.LogSurvival($"EndRun: {reason} (win={win}, highestWaveCleared={_highestWaveCleared}, wave={_wave}, kills={_killsThisWave}/{_waveBudget})");
|
||||||
|
// Per-wave XP was already granted into the main table at each ClearWave; an in-progress (uncleared) wave is
|
||||||
|
// forfeited. So there's nothing to bank at run-end — just drop the runs.
|
||||||
|
_runs.Clear();
|
||||||
|
_runActive = false; _runEnded = true; _phase = WavePhase.Idle; _wave = 0;
|
||||||
|
UpdateBotQuota(); // quota 0
|
||||||
|
Server.PrintToChatAll($" {(win ? ChatColors.Gold : ChatColors.Red)}[Survival] {ChatColors.Default}{reason} (reached wave {_highestWaveCleared}).");
|
||||||
|
_p.TriggerMapEnd(win ? "Survival: VICTORY" : "Survival: run over");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bots: vanilla, count-throttled via bot_quota ----
|
||||||
|
public void ManageBots()
|
||||||
|
{
|
||||||
|
OutnumberedPlugin.ForceBotsToTerrorist(Utilities.GetPlayers()); // any bot on CT -> back to T (bot_join_team t only affects new bots)
|
||||||
|
UpdateBotQuota();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bots ALIVE at once this wave (the simultaneous pressure) — ramps from AliveBase up to AliveCap. Distinct from the
|
||||||
|
// kill budget: you face this many at a time (they respawn) until the wave's total kills are reached.
|
||||||
|
private int AliveForWave(int wave) => SurvivalEconomy.AliveForWave(wave, _p.Config.Survival);
|
||||||
|
|
||||||
|
// Keep a STEADY connected pool for the whole run: bot_quota stays at AliveCap and is NEVER toggled back to 0 between
|
||||||
|
// waves. Repeatedly kicking (quota 0) + re-adding bots churns the engine's fake-client slots, which run out after a
|
||||||
|
// few waves and then wedge ALL further adds. The pool stays connected (mostly dead at low waves); the per-wave ALIVE
|
||||||
|
// count is set by Respawn (refill) / CommitSuicide (cull). bot_add_t only bootstraps the pool ONCE at run start
|
||||||
|
// (0 -> AliveCap, near round start). DEADLOCK-FREE: _killsThisWave advances only on credited human kills,
|
||||||
|
// suicides/respawns don't, and ClearWave fires on kills >= budget.
|
||||||
|
private void UpdateBotQuota()
|
||||||
|
{
|
||||||
|
int cap = _p.Config.Survival.AliveCap;
|
||||||
|
Server.ExecuteCommand($"bot_quota {(_runActive ? cap : 0)}");
|
||||||
|
if (!_runActive) return;
|
||||||
|
|
||||||
|
var bots = OutnumberedPlugin.Bots().ToList();
|
||||||
|
if (bots.Count < cap) // bootstrap / replace any lost pool members — should fire ~once per run, not per wave
|
||||||
|
{
|
||||||
|
_p.LogSurvival($"pool: connected={bots.Count} cap={cap} -> +{cap - bots.Count} bot_add_t");
|
||||||
|
for (int i = bots.Count; i < cap; i++) Server.ExecuteCommand("bot_add_t");
|
||||||
|
}
|
||||||
|
|
||||||
|
int want = _phase == WavePhase.Fighting
|
||||||
|
? Math.Clamp(Math.Min(AliveForWave(_wave), _waveBudget - _killsThisWave), 0, cap)
|
||||||
|
: 0; // break / idle: clear the field
|
||||||
|
int alive = bots.Count(p => p.PawnIsAlive);
|
||||||
|
if (alive < want) // refill: spawn dead pool bots up to `want` (also the in-wave streaming respawn)
|
||||||
|
foreach (var b in bots)
|
||||||
|
{
|
||||||
|
if (alive >= want) break;
|
||||||
|
if (!b.PawnIsAlive) { b.Respawn(); alive++; }
|
||||||
|
}
|
||||||
|
else if (alive > want) // cull: kill excess down to `want` (break clear / over-count). Suicide => no kill credit
|
||||||
|
foreach (var b in bots)
|
||||||
|
{
|
||||||
|
if (alive <= want) break;
|
||||||
|
if (b.PawnIsAlive) { b.CommitSuicide(false, true); alive--; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The wave stalled (no kills for a while) — teleport the alive bots next to a random alive CT human so the wave can
|
||||||
|
// always be finished without the squad having to chase the last stragglers. Avoids "the match ended but bots are alive".
|
||||||
|
private void NudgeStalledBots(double now)
|
||||||
|
{
|
||||||
|
_lastNudgeAt = now;
|
||||||
|
var targets = CtHumans().Where(h => h.PawnIsAlive && h.PlayerPawn.Value?.AbsOrigin is not null).ToList();
|
||||||
|
if (targets.Count == 0) return;
|
||||||
|
int moved = 0;
|
||||||
|
foreach (var b in Utilities.GetPlayers())
|
||||||
|
{
|
||||||
|
if (!OutnumberedPlugin.IsLiveBot(b)) continue;
|
||||||
|
var botPawn = b.PlayerPawn.Value;
|
||||||
|
var dest = targets[Random.Shared.Next(targets.Count)].PlayerPawn.Value?.AbsOrigin;
|
||||||
|
if (botPawn is null || dest is null) continue;
|
||||||
|
// a modest offset around the player so they don't all telefrag the same point (small maps are open enough)
|
||||||
|
var pos = new Vector(dest.X + Random.Shared.Next(-128, 128), dest.Y + Random.Shared.Next(-128, 128), dest.Z + 24);
|
||||||
|
botPawn.Teleport(pos, null, new Vector(0, 0, 0));
|
||||||
|
moved++;
|
||||||
|
}
|
||||||
|
if (moved > 0) _p.LogSurvival($"wave {_wave} stalled (no kill {_p.Config.Survival.StallNudgeSeconds:0}s) — pulled {moved} bot(s) to the squad");
|
||||||
|
}
|
||||||
|
|
||||||
|
// HUD line for the active run: wave number + bots remaining to clear it (or the break state). "" when no run.
|
||||||
|
// Surfaced to the core via IMatchDriver.HudStatusLine so the HUD never type-checks the concrete driver.
|
||||||
|
public string HudStatusLine(PlayerData pd) => WaveHud();
|
||||||
|
private string WaveHud()
|
||||||
|
{
|
||||||
|
if (!_runActive) return "";
|
||||||
|
int cap = _p.Config.Survival.WaveCount;
|
||||||
|
if (_phase == WavePhase.Break) return $"WAVE {_highestWaveCleared}/{cap} CLEARED — break";
|
||||||
|
return $"WAVE {_wave}/{cap} — {Math.Max(0, _waveBudget - _killsThisWave)} bots left";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- revive ----
|
||||||
|
private void ReviveDead()
|
||||||
|
{
|
||||||
|
if (!_p.Config.Survival.ReviveOnWaveClear) return;
|
||||||
|
foreach (var p in CtHumans())
|
||||||
|
if (!p.PawnIsAlive) _p.ReviveSurvivor(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the draft ----
|
||||||
|
private void GrantCards()
|
||||||
|
{
|
||||||
|
int per = Math.Max(1, _p.Config.Survival.CardsPerWave);
|
||||||
|
bool reviveOn = _p.Config.Survival.ReviveOnWaveClear;
|
||||||
|
foreach (var p in CtHumans())
|
||||||
|
{
|
||||||
|
// Hardcore (no revive): a permanently-dead player can never open/spend a pick — don't bank it or spam the chat.
|
||||||
|
// (In revive mode ReviveDead ran just above, so the dead are revived; gating on reviveOn covers the respawn lag.)
|
||||||
|
if (!p.PawnIsAlive && !reviveOn) continue;
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
if (pd is null) continue;
|
||||||
|
var run = Run(pd);
|
||||||
|
if (!HasDraftableCard(run)) continue; // every card already maxed -> don't bank a pick that can never be spent
|
||||||
|
run.RunPoints += per;
|
||||||
|
EnsureDraw(run); // keep the existing offer if still valid (anti-cheese: skipping a wave must NOT reroll the draft)
|
||||||
|
// Show the running total (incl. any banked from missed breaks) so the player knows what's waiting.
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{run.RunPoints}{ChatColors.Default} card pick(s) available — press {ChatColors.Gold}X{ChatColors.Default} to draft (unspent picks carry over).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any card still under its cap (per-player OR the shared team level) — gates the draft so it never auto-pops a
|
||||||
|
// frozen, empty overlay once everything's maxed.
|
||||||
|
private bool HasDraftableCard(SurvivalRun run) =>
|
||||||
|
_p.Config.Survival.Cards.Any(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap);
|
||||||
|
|
||||||
|
private void Redraw(SurvivalRun run)
|
||||||
|
{
|
||||||
|
var cfg = _p.Config.Survival;
|
||||||
|
var pool = cfg.Cards.Where(c => !string.IsNullOrWhiteSpace(c.Key) && CardCount(run, c.Key) < c.Cap).ToList();
|
||||||
|
int n = Math.Min(Math.Min(Math.Max(1, cfg.DraftSize), 3), pool.Count); // cap at 3 — the overlay only renders 3 panels
|
||||||
|
run.DrawnThisBreak = pool.OrderBy(_ => Random.Shared.Next()).Take(n).Select(c => c.Key).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the player has a draftable hand WITHOUT rerolling a still-valid one. ANTI-CHEESE: banking a pick across a
|
||||||
|
// wave must NOT reroll the offer (else a player skips waves to fish for the best cards). So only (re)draw when the
|
||||||
|
// current hand has no still-draftable card left — empty, or every drawn card is now maxed (e.g. a team card others
|
||||||
|
// maxed). The drawn hand persists on SurvivalRun across breaks, alongside the banked RunPoints.
|
||||||
|
private void EnsureDraw(SurvivalRun run)
|
||||||
|
{
|
||||||
|
if (run.RunPoints <= 0) return;
|
||||||
|
bool hasValid = run.DrawnThisBreak.Any(k => CardDef(k) is { } d && CardCount(run, k) < d.Cap);
|
||||||
|
if (!hasValid && HasDraftableCard(run)) Redraw(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- shop-facing draft API ----
|
||||||
|
public bool DraftPending(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
if (!_runActive || _phase != WavePhase.Break) return false;
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) && run.RunPoints > 0 && HasDraftableCard(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unspent draft picks the player has banked (spendable in any break) — surfaced in the draft menu header.
|
||||||
|
public int PendingCards(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
return pd is not null && _runs.TryGetValue(pd.SteamId, out var run) ? run.RunPoints : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View data for one draft card (CardView lives in Modes.cs alongside IDraftDriver): name, current/cap picks, and
|
||||||
|
// either the current->next % value (the original stat cards) OR a custom Detail line (the effect cards).
|
||||||
|
public CardView? CardInfo(CCSPlayerController p, string key)
|
||||||
|
{
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return null;
|
||||||
|
var def = CardDef(key);
|
||||||
|
if (def is null) return null;
|
||||||
|
int have = CardCount(run, key); // per-player picks, or the shared team level
|
||||||
|
bool flat = def.Flat; // flat points (HP/armor caps + flat regen) vs % — data-driven
|
||||||
|
double next = (have + 1) * def.PerPick;
|
||||||
|
string? detail = EffectCardDetail(key, def, have); // null for the original stat cards -> Now/Next % shown
|
||||||
|
return new CardView(def.Name, have, def.Cap, have * def.PerPick, next, flat, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A human-readable effect line for the new logic cards (the Now/Next % display is meaningless for these). Values are
|
||||||
|
// shown AFTER the next pick (level have+1). Team cards COMPOUND (match RecomputeTeamMults), so their detail shows the
|
||||||
|
// real compounded total, not the additive sum. Returns null for the 12 stat cards (they keep the +Now% -> +Next% panel).
|
||||||
|
private string? EffectCardDetail(string key, SurvivalCardDef def, int have)
|
||||||
|
{
|
||||||
|
int lvl = have + 1; // value after the next pick
|
||||||
|
double add = lvl * def.PerPick; // linear per-pick total (the per-player leveled cards)
|
||||||
|
// Team cards COMPOUND, so show the real compounded total (not the additive sum); the deal/take direction is the
|
||||||
|
// only inherently-2-card distinction left.
|
||||||
|
if (def.IsTeam)
|
||||||
|
return key == CardKeys.GlobalTake
|
||||||
|
? $"squad -{(1.0 - SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: false)) * 100.0:0}% dmg taken"
|
||||||
|
: $"squad +{(SurvivalEconomy.TeamMult(lvl, def.PerPick, increase: true) - 1.0) * 100.0:0}% dmg dealt";
|
||||||
|
// Burn's magnitude comes from the Burn* knobs (not the card PerPick), so it can't be a {0} template.
|
||||||
|
if (key == CardKeys.Burn)
|
||||||
|
{
|
||||||
|
var cfg = _p.Config.Survival;
|
||||||
|
return $"{cfg.BurnDamagePerSecond:0} HP/s for {cfg.BurnDurationSeconds:0}s";
|
||||||
|
}
|
||||||
|
// Everything else: the data-driven Detail template ({0} = level x PerPick). Empty -> stat card -> Now/Next % panel.
|
||||||
|
return string.IsNullOrEmpty(def.Detail) ? null : string.Format(def.Detail, add);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current offered draw as (cardKey, displayLabel) — stable across a reopen within the same break (anti-reroll).
|
||||||
|
public List<(string key, string label)> CurrentDraw(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
var res = new List<(string, string)>();
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run)) return res;
|
||||||
|
foreach (var key in run.DrawnThisBreak)
|
||||||
|
{
|
||||||
|
var def = CardDef(key);
|
||||||
|
if (def is null) continue;
|
||||||
|
res.Add((key, $"{def.Name} [{CardCount(run, key)}/{def.Cap}]"));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void PickCard(CCSPlayerController p, string key)
|
||||||
|
{
|
||||||
|
var pd = _p.PdOf(p);
|
||||||
|
if (pd is null || !_runs.TryGetValue(pd.SteamId, out var run) || run.RunPoints <= 0) return;
|
||||||
|
if (!run.DrawnThisBreak.Contains(key)) return;
|
||||||
|
var def = CardDef(key);
|
||||||
|
if (def is null || CardCount(run, key) >= def.Cap) return; // already maxed (per-player OR the shared team level)
|
||||||
|
run.RunPoints--;
|
||||||
|
|
||||||
|
if (IsTeamCard(key)) // squad-wide: bump the shared level, recompute the team multiplier, tell everyone
|
||||||
|
{
|
||||||
|
if (key == CardKeys.GlobalDeal) _teamDealLevel++; else _teamTakeLevel++;
|
||||||
|
RecomputeTeamMults();
|
||||||
|
int lvl = CardCount(run, key);
|
||||||
|
Server.PrintToChatAll($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{p.PlayerName} {ChatColors.Default}raised {ChatColors.Lime}{def.Name} {ChatColors.Default}-> team {lvl}/{def.Cap}");
|
||||||
|
if (lvl >= def.Cap) foreach (var r in _runs.Values) r.DrawnThisBreak.RemoveAll(k => k == key); // pull the now-maxed team card from every hand
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
run.Cards[key] = run.Cards.GetValueOrDefault(key) + 1;
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Survival] {ChatColors.Lime}{def.Name} {ChatColors.Default}-> {run.Cards[key]}/{def.Cap} ({run.RunPoints} pick(s) left)");
|
||||||
|
// ONLY a Max-HP/Max-Armor card touches the live pawn: raise the cap + top up by the card's bonus. NO full heal
|
||||||
|
// on any pick (that would erase the 50% revive penalty and reset the squad to full HP every wave).
|
||||||
|
if (key == StatKeys.MaxHp || key == StatKeys.MaxArmor) _p.GrantCardCapBonus(p, key, (int)def.PerPick);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (run.RunPoints > 0) Redraw(run); else run.DrawnThisBreak.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
private SurvivalRun Run(PlayerData pd)
|
||||||
|
{
|
||||||
|
if (!_runs.TryGetValue(pd.SteamId, out var r)) { r = new SurvivalRun(CardDef); _runs[pd.SteamId] = r; }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bank raw combat XP into the player's CURRENT-wave accumulator (granted at wave clear). Called from GrantCombatXp.
|
||||||
|
// The xp_mult card (+% run-XP, per-player) scales the RAW accumulation here, so it compounds with the per-wave
|
||||||
|
// prestige x waveMult chain applied at grant time.
|
||||||
|
public void AccumulateWaveXp(PlayerData pd, double amount)
|
||||||
|
{
|
||||||
|
if (!_runActive || amount <= 0) return;
|
||||||
|
Run(pd).WaveXp += SurvivalEconomy.AccrueWaveXp(amount, StatBonus(pd, CardKeys.XpMult));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SurvivalCardDef? CardDef(string key)
|
||||||
|
{
|
||||||
|
foreach (var c in _p.Config.Survival.Cards) if (c.Key == key) return c;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<CCSPlayerController> CtHumans() =>
|
||||||
|
Utilities.GetPlayers().Where(p => OutnumberedPlugin.IsHuman(p) && p.Team == CsTeam.CounterTerrorist);
|
||||||
|
private static int AliveCtHumans() => CtHumans().Count(p => p.PawnIsAlive);
|
||||||
|
private static int BotCount() => Utilities.GetPlayers().Count(OutnumberedPlugin.IsBot);
|
||||||
|
|
||||||
|
// The connected controller for a SteamId (any team) — for the run-end chat line; null if they've left.
|
||||||
|
private static CCSPlayerController? ControllerFor(ulong sid) =>
|
||||||
|
Utilities.GetPlayers().FirstOrDefault(p => OutnumberedPlugin.IsHuman(p) && p.AuthorizedSteamID?.SteamId64 == sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin-side survival helpers (per-wave XP grant, revive, HP reapply) — here so they can reach the private XP/stat math.
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
// Diagnostic logging for the survival wave machine (console: "[Survival] ...").
|
||||||
|
internal void LogSurvival(string msg) => Logger.LogInformation("[Survival] {Msg}", msg);
|
||||||
|
|
||||||
|
// Grant ONE cleared wave's XP into the main table: rawWaveXp x prestige x waveMult(wave), NO cap, handicap-mult
|
||||||
|
// EXCLUDED (anti-launder: run XP must not re-couple to gameable K/D; anti-carry is automatic since waveXp is each
|
||||||
|
// player's OWN attributed damage). Per-wave so players level + earn skill points mid-run, and the XP lands in the main
|
||||||
|
// table immediately (a disconnect keeps every CLEARED wave — there's no separate run accumulator to restore).
|
||||||
|
internal void BankWaveXp(PlayerData pd, double waveXp, int wave, CCSPlayerController? p)
|
||||||
|
{
|
||||||
|
if (waveXp <= 0) return;
|
||||||
|
var cfg = Config.Survival;
|
||||||
|
long lump = SurvivalEconomy.WaveXpLump(waveXp, wave, pd.Prestige, cfg, Config.Progression);
|
||||||
|
if (lump <= 0) return;
|
||||||
|
AddConvertedXp(pd, lump, p);
|
||||||
|
p?.PrintToChat($" {ChatColors.Gold}[Survival] Wave {wave} XP: {ChatColors.Lime}+{lump:n0} {ChatColors.Default}(x{SurvivalEconomy.WaveMult(wave, cfg):F1}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revive a downed survivor at the configured HP% (used between waves). mp_respawn_on_death_ct 0 keeps them down,
|
||||||
|
// so this is a manual Respawn; they're still on CT (never moved to spectator), so no team churn.
|
||||||
|
internal void ReviveSurvivor(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
if (p is not { IsValid: true } || p.IsBot || p.PawnIsAlive || p.Team != CsTeam.CounterTerrorist) return;
|
||||||
|
if (p.AuthorizedSteamID?.SteamId64 is not { } sid) return;
|
||||||
|
double pct = Config.Survival.ReviveHpPercent;
|
||||||
|
p.Respawn();
|
||||||
|
// Defer through the seam: re-resolve by slot + pin SteamID so a within-frame slot-reuse can't revive a different player.
|
||||||
|
NextFrameForSlot(p.Slot, sid, pl =>
|
||||||
|
{
|
||||||
|
var pd = PdOf(pl); if (pd is null) return;
|
||||||
|
ApplyMaxHpArmor(pl, pd);
|
||||||
|
var pawn = pl.PlayerPawn.Value;
|
||||||
|
if (pawn is not null) PawnWriter.SetHealth(pawn, Math.Max(1, (int)(MaxHpOf(pd) * pct)));
|
||||||
|
}, requireAlive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A survival Max-HP / Max-Armor card just took effect: raise the live cap and top current HP/armor up by the card's
|
||||||
|
// bonus ONLY — never a full heal (a full heal on any pick would erase the 50% revive penalty and reset the squad to
|
||||||
|
// full HP every wave). Other cards never touch current HP/armor.
|
||||||
|
internal void GrantCardCapBonus(CCSPlayerController p, string statKey, int bonus)
|
||||||
|
{
|
||||||
|
if (bonus <= 0) return;
|
||||||
|
var pd = PdOf(p);
|
||||||
|
var pawn = p.PlayerPawn.Value;
|
||||||
|
if (pd is null || pawn is null || !p.PawnIsAlive) return;
|
||||||
|
if (statKey == StatKeys.MaxHp)
|
||||||
|
{
|
||||||
|
int maxHp = MaxHpOf(pd); // already includes the just-picked card (run.Cards incremented first)
|
||||||
|
PawnWriter.SetMaxHealth(p, pawn, maxHp);
|
||||||
|
PawnWriter.SetHealth(pawn, Math.Min(maxHp, pawn.Health + bonus));
|
||||||
|
}
|
||||||
|
else if (statKey == StatKeys.MaxArmor)
|
||||||
|
{
|
||||||
|
PawnWriter.SetArmor(pawn, Math.Min(MaxArmorOf(pd), pawn.ArmorValue + bonus));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerData for a SteamId (the survival run-end bank resolves participants by id, not current team).
|
||||||
|
internal PlayerData? PdBySteamId(ulong sid) => _players.TryGetValue(sid, out var pd) ? pd : null;
|
||||||
|
}
|
||||||
23
Outnumbered/SurvivalRun.cs
Normal file
23
Outnumbered/SurvivalRun.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Per-player survival run state. RAM-only, never persisted; cleared on run end / disconnect (no mid-run reconnect-restore).
|
||||||
|
// Implements IStatBonusSource so it IS the PlayerSnapshot.Cards source (no per-hit adapter allocation). Bonus resolves the
|
||||||
|
// card def LIVE via the injected lookup, so !og_reload PerPick edits take effect immediately. (The wave state machine
|
||||||
|
// [SurvivalDriver] + the plugin-side survival helpers live in Survival.cs.)
|
||||||
|
public sealed class SurvivalRun(Func<string, Config.SurvivalCardDef?> cardDef) : Domain.IStatBonusSource
|
||||||
|
{
|
||||||
|
private readonly Func<string, Config.SurvivalCardDef?> _cardDef = cardDef;
|
||||||
|
public double WaveXp { get; set; } // raw combat XP banked THIS wave (granted + reset at wave clear; forfeited on a wipe/leave mid-wave)
|
||||||
|
public int RunPoints { get; set; } // unspent draft picks
|
||||||
|
public Dictionary<string, int> Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate)
|
||||||
|
public List<string> DrawnThisBreak { get; set; } = []; // the current offered draw (stable across reopen within a break)
|
||||||
|
|
||||||
|
// Per-stat run bonus = picks x PerPick (live def). 0 for any key without picks. (Team cards are squad-wide and folded
|
||||||
|
// into MDeal/MTake, NOT per-player here — they're keyed on the driver's shared counters, not run.Cards.)
|
||||||
|
public double Bonus(string statKey)
|
||||||
|
{
|
||||||
|
if (!Cards.TryGetValue(statKey, out int picks) || picks <= 0) return 0.0;
|
||||||
|
var def = _cardDef(statKey);
|
||||||
|
return def is null ? 0.0 : picks * def.PerPick;
|
||||||
|
}
|
||||||
|
}
|
||||||
249
Outnumbered/Weapons.cs
Normal file
249
Outnumbered/Weapons.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
using System.Collections.Frozen;
|
||||||
|
using CounterStrikeSharp.API.Core;
|
||||||
|
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||||||
|
using CounterStrikeSharp.API.Modules.Commands;
|
||||||
|
using CounterStrikeSharp.API.Modules.Menu;
|
||||||
|
using CounterStrikeSharp.API.Modules.Utils;
|
||||||
|
using Outnumbered.Config;
|
||||||
|
using Outnumbered.Data;
|
||||||
|
using Outnumbered.Engine;
|
||||||
|
|
||||||
|
namespace Outnumbered;
|
||||||
|
|
||||||
|
// Free weapon selection (no economy), split into categories. !guns lists the categories; the direct
|
||||||
|
// commands (!rifles/!smgs/!snipers/!shotguns/!heavy/!pistols) jump straight to a category. Choice persists
|
||||||
|
// per-SteamID (DB) and is applied on spawn by ApplyLoadout (default until chosen / if a saved gun is delisted).
|
||||||
|
public sealed partial class OutnumberedPlugin
|
||||||
|
{
|
||||||
|
private static readonly FrozenDictionary<string, string> WeaponNames = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["weapon_ak47"] = "AK-47",
|
||||||
|
["weapon_m4a1_silencer"] = "M4A1-S",
|
||||||
|
["weapon_m4a1"] = "M4A4",
|
||||||
|
["weapon_aug"] = "AUG",
|
||||||
|
["weapon_sg556"] = "SG 553",
|
||||||
|
["weapon_galilar"] = "Galil AR",
|
||||||
|
["weapon_famas"] = "FAMAS",
|
||||||
|
["weapon_awp"] = "AWP",
|
||||||
|
["weapon_ssg08"] = "SSG 08",
|
||||||
|
["weapon_scar20"] = "SCAR-20",
|
||||||
|
["weapon_g3sg1"] = "G3SG1",
|
||||||
|
["weapon_mp9"] = "MP9",
|
||||||
|
["weapon_mp7"] = "MP7",
|
||||||
|
["weapon_mp5sd"] = "MP5-SD",
|
||||||
|
["weapon_ump45"] = "UMP-45",
|
||||||
|
["weapon_p90"] = "P90",
|
||||||
|
["weapon_mac10"] = "MAC-10",
|
||||||
|
["weapon_bizon"] = "PP-Bizon",
|
||||||
|
["weapon_nova"] = "Nova",
|
||||||
|
["weapon_xm1014"] = "XM1014",
|
||||||
|
["weapon_mag7"] = "MAG-7",
|
||||||
|
["weapon_sawedoff"] = "Sawed-Off",
|
||||||
|
["weapon_m249"] = "M249",
|
||||||
|
["weapon_negev"] = "Negev",
|
||||||
|
["weapon_deagle"] = "Desert Eagle",
|
||||||
|
["weapon_revolver"] = "R8 Revolver",
|
||||||
|
["weapon_glock"] = "Glock-18",
|
||||||
|
["weapon_usp_silencer"] = "USP-S",
|
||||||
|
["weapon_hkp2000"] = "P2000",
|
||||||
|
["weapon_p250"] = "P250",
|
||||||
|
["weapon_fiveseven"] = "Five-SeveN",
|
||||||
|
["weapon_tec9"] = "Tec-9",
|
||||||
|
["weapon_cz75a"] = "CZ75-Auto",
|
||||||
|
["weapon_elite"] = "Dual Berettas",
|
||||||
|
["weapon_knife"] = "Knife", // the GG finale rung — otherwise the prefix-strip fallback renders lowercase "knife"
|
||||||
|
}.ToFrozenDictionary(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
internal static string WeaponDisplayName(string item) =>
|
||||||
|
WeaponNames.TryGetValue(item, out var n) ? n
|
||||||
|
: item.StartsWith("weapon_", StringComparison.Ordinal) ? item[7..].Replace('_', ' ') : item;
|
||||||
|
|
||||||
|
internal static bool WeapEq(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Cached weapon-category sets (OrdinalIgnoreCase == WeapEq), so membership is O(1) instead of Concat-5-lists + ToList
|
||||||
|
// + a linear .Any(WeapEq) per call. Rebuilt only when the Match config INSTANCE changes (!og_reload swaps the whole
|
||||||
|
// Config object). CONTRACT: an in-place edit to Match.Rifles/Pistols/... would NOT invalidate this — only a Config swap does.
|
||||||
|
private FrozenSet<string>? _primarySet, _pistolSet;
|
||||||
|
private MatchConfig? _weaponSetCfg;
|
||||||
|
private void EnsureWeaponSets()
|
||||||
|
{
|
||||||
|
var m = Config.Match;
|
||||||
|
if (_primarySet is not null && ReferenceEquals(_weaponSetCfg, m)) return;
|
||||||
|
_primarySet = m.Rifles.Concat(m.Smgs).Concat(m.Snipers).Concat(m.Shotguns).Concat(m.Heavy).ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_pistolSet = m.Pistols.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
_weaponSetCfg = m;
|
||||||
|
}
|
||||||
|
private bool IsPrimaryWeapon(string w) { EnsureWeaponSets(); return _primarySet!.Contains(w); }
|
||||||
|
private bool IsPistolWeapon(string w) { EnsureWeaponSets(); return _pistolSet!.Contains(w); }
|
||||||
|
|
||||||
|
// Level required to use a weapon (absent/0 = free). Prestige resets level, so this re-locks on prestige.
|
||||||
|
private int UnlockLevel(string w) => Config.Match.WeaponUnlockLevel.TryGetValue(w, out var lvl) ? lvl : 0;
|
||||||
|
|
||||||
|
// Allowed = in the right category set AND the player has reached its unlock level.
|
||||||
|
private bool IsAllowedPrimary(PlayerData pd, string? w) =>
|
||||||
|
!string.IsNullOrEmpty(w) && IsPrimaryWeapon(w!) && pd.Level >= UnlockLevel(w!);
|
||||||
|
private bool IsAllowedSecondary(PlayerData pd, string? w) =>
|
||||||
|
!string.IsNullOrEmpty(w) && IsPistolWeapon(w!) && pd.Level >= UnlockLevel(w!);
|
||||||
|
|
||||||
|
// What the driver hands this player on spawn: saved choice if still allowed (unlocked), else config default.
|
||||||
|
internal (string primary, string secondary) ResolveLoadout(CCSPlayerController p)
|
||||||
|
{
|
||||||
|
string primary = Config.Match.HumanPrimary, secondary = Config.Match.HumanSecondary;
|
||||||
|
if (PdOf(p) is { } pd)
|
||||||
|
{
|
||||||
|
if (IsAllowedPrimary(pd, pd.PrimaryWeapon)) primary = pd.PrimaryWeapon!;
|
||||||
|
if (IsAllowedSecondary(pd, pd.SecondaryWeapon)) secondary = pd.SecondaryWeapon!;
|
||||||
|
}
|
||||||
|
return (primary, secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which category command equips this weapon (for the unlock announcement).
|
||||||
|
private string WeaponCategoryCmd(string w)
|
||||||
|
{
|
||||||
|
var m = Config.Match;
|
||||||
|
if (m.Rifles.Any(x => WeapEq(x, w))) return "rifles";
|
||||||
|
if (m.Smgs.Any(x => WeapEq(x, w))) return "smgs";
|
||||||
|
if (m.Snipers.Any(x => WeapEq(x, w))) return "snipers";
|
||||||
|
if (m.Shotguns.Any(x => WeapEq(x, w))) return "shotguns";
|
||||||
|
if (m.Heavy.Any(x => WeapEq(x, w))) return "heavy";
|
||||||
|
return "pistols";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- quick-buy combo: the keystrokes that reach a weapon in the shop (X -> Weapons -> category -> item) ----
|
||||||
|
// The 8 reliable menu keys in slot order (key 4 = grenade slot and 5 = C4 can't be clean selectors).
|
||||||
|
private static readonly char[] MenuKeys = ['1', '2', '3', '6', '7', '8', '9', '0'];
|
||||||
|
// Category order on the Weapons screen — fixes each category's key. Item keys come from the JSON list order.
|
||||||
|
public static readonly string[] CategoryOrder = ["Pistols", "Smgs", "Shotguns", "Rifles", "Heavy", "Snipers"];
|
||||||
|
|
||||||
|
private List<string> CategoryList(string name) => name switch
|
||||||
|
{
|
||||||
|
"Rifles" => Config.Match.Rifles,
|
||||||
|
"Smgs" => Config.Match.Smgs,
|
||||||
|
"Snipers" => Config.Match.Snipers,
|
||||||
|
"Shotguns" => Config.Match.Shotguns,
|
||||||
|
"Heavy" => Config.Match.Heavy,
|
||||||
|
"Pistols" => Config.Match.Pistols,
|
||||||
|
_ => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
public static char MenuKey(int pos) => pos >= 0 && pos < MenuKeys.Length ? MenuKeys[pos] : '?';
|
||||||
|
|
||||||
|
// Derived, never stored: '1' = Weapons branch at root, then the category's key, then the weapon's key.
|
||||||
|
// Falls out of the JSON list order automatically, so it can never drift from the menu. "" if not found, OR if the item
|
||||||
|
// sits past the world-menu's key range (idx >= MenuKeys.Length) — those overflow guns have no quick-buy combo and are
|
||||||
|
// reached via the paginated !category chat menu, so we omit the combo rather than print an unreachable key ('?').
|
||||||
|
private string QuickBuySequence(string weapon)
|
||||||
|
{
|
||||||
|
for (int c = 0; c < CategoryOrder.Length; c++)
|
||||||
|
{
|
||||||
|
int idx = CategoryList(CategoryOrder[c]).FindIndex(x => WeapEq(x, weapon));
|
||||||
|
if (idx >= 0) return idx < MenuKeys.Length ? $"X{MenuKey(0)}{MenuKey(c)}{MenuKey(idx)}" : "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loud, distinct chat shout for any weapon(s) that unlock exactly at this level (called on level-up).
|
||||||
|
private void AnnounceUnlocks(CCSPlayerController p, int level)
|
||||||
|
{
|
||||||
|
foreach (var kv in Config.Match.WeaponUnlockLevel)
|
||||||
|
if (kv.Value == level)
|
||||||
|
{
|
||||||
|
string combo = QuickBuySequence(kv.Key);
|
||||||
|
string tail = combo.Length > 0 ? $" or quick-buy {ChatColors.Gold}{combo}{ChatColors.Default}" : "";
|
||||||
|
p.PrintToChat($" {ChatColors.Magenta}★ WEAPON UNLOCKED: {WeaponDisplayName(kv.Key)} {ChatColors.Default}— !{WeaponCategoryCmd(kv.Key)} to equip{tail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- top menu + category commands ----
|
||||||
|
[ConsoleCommand("css_guns", "Open the weapon selection menu")]
|
||||||
|
[ConsoleCommand("css_weapons", "Open the weapon selection menu")]
|
||||||
|
[ConsoleCommand("css_loadout", "Open the weapon selection menu")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Guns(CCSPlayerController? p, CommandInfo info)
|
||||||
|
{
|
||||||
|
if (p is not { IsValid: true }) return;
|
||||||
|
if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Your weapon is set by the Gun Game ladder — climb it!"); return; }
|
||||||
|
var m = Config.Match;
|
||||||
|
var (cur1, cur2) = ResolveLoadout(p);
|
||||||
|
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Loadout — {WeaponDisplayName(cur1)} / {WeaponDisplayName(cur2)} (pick a category)");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !rifles — Rifles ({m.Rifles.Count})");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !smgs — SMGs ({m.Smgs.Count})");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !snipers — Snipers ({m.Snipers.Count})");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !shotguns — Shotguns ({m.Shotguns.Count})");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !heavy — Machine Guns ({m.Heavy.Count})");
|
||||||
|
p.PrintToChat($" {ChatColors.Lime} !pistols — Pistols ({m.Pistols.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ConsoleCommand("css_rifles", "Choose a rifle")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Rifles(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Rifles", Config.Match.Rifles, true); }
|
||||||
|
|
||||||
|
[ConsoleCommand("css_smgs", "Choose an SMG")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Smgs(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "SMGs", Config.Match.Smgs, true); }
|
||||||
|
|
||||||
|
[ConsoleCommand("css_snipers", "Choose a sniper")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Snipers(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Snipers", Config.Match.Snipers, true); }
|
||||||
|
|
||||||
|
[ConsoleCommand("css_shotguns", "Choose a shotgun")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Shotguns(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Shotguns", Config.Match.Shotguns, true); }
|
||||||
|
|
||||||
|
[ConsoleCommand("css_heavy", "Choose a machine gun")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Heavy(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Heavy", Config.Match.Heavy, true); }
|
||||||
|
|
||||||
|
[ConsoleCommand("css_pistols", "Choose a pistol")]
|
||||||
|
[ConsoleCommand("css_secondaries", "Choose a pistol")]
|
||||||
|
[CommandHelper(0, "", CommandUsage.CLIENT_ONLY)]
|
||||||
|
public void Cmd_Pistols(CCSPlayerController? p, CommandInfo i) { if (p is { IsValid: true }) OpenCategory(p, "Pistols", Config.Match.Pistols, false); }
|
||||||
|
|
||||||
|
private void OpenCategory(CCSPlayerController p, string title, List<string> list, bool primary)
|
||||||
|
{
|
||||||
|
if (!_driver.WeaponShopEnabled) { p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Weapon selection is disabled in Gun Game."); return; }
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
|
||||||
|
var menu = new ChatMenu($"Choose {title}");
|
||||||
|
foreach (var item in list)
|
||||||
|
{
|
||||||
|
int req = UnlockLevel(item);
|
||||||
|
bool unlocked = pd.Level >= req;
|
||||||
|
string label = unlocked ? WeaponDisplayName(item) : $"{WeaponDisplayName(item)} [Lv {req}]";
|
||||||
|
menu.AddMenuOption(label, (pl, _) => SetWeapon(pl, primary, item), disabled: !unlocked);
|
||||||
|
}
|
||||||
|
menu.Open(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetWeapon(CCSPlayerController p, bool primary, string item)
|
||||||
|
{
|
||||||
|
var pd = PdOf(p);
|
||||||
|
if (pd is null) return;
|
||||||
|
|
||||||
|
int req = UnlockLevel(item);
|
||||||
|
if (pd.Level < req) { p.PrintToChat($"[Outnumbered] {WeaponDisplayName(item)} unlocks at level {req}."); return; }
|
||||||
|
|
||||||
|
if (primary) pd.PrimaryWeapon = item; else pd.SecondaryWeapon = item;
|
||||||
|
pd.Dirty = true;
|
||||||
|
|
||||||
|
string slotName = primary ? "Primary" : "Secondary";
|
||||||
|
if (p.PawnIsAlive)
|
||||||
|
{
|
||||||
|
// Rebuild both slots synchronously. RemoveItemBySlot's kill is DEFERRED 0.1s (would double-occupy the
|
||||||
|
// slot / leave the player unarmed); RemoveWeapons() is synchronous (mirrors ApplyLoadout). Armor isn't
|
||||||
|
// a weapon so it survives; an ability grenade (if any) reconciles back.
|
||||||
|
var (pri, sec) = ResolveLoadout(p);
|
||||||
|
p.RemoveWeapons();
|
||||||
|
p.GiveNamedItem(EngineNames.WeaponKnife);
|
||||||
|
if (!string.IsNullOrEmpty(pri)) p.GiveNamedItem(pri);
|
||||||
|
if (!string.IsNullOrEmpty(sec)) p.GiveNamedItem(sec);
|
||||||
|
GiveShopCarriers(p); // re-give the healthshot + zeus after the rebuild (armor isn't a weapon, so RemoveWeapons left it)
|
||||||
|
p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)}.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
p.PrintToChat($"[Outnumbered] {slotName}: {WeaponDisplayName(item)} (applies on respawn).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Outnumbered/outnumbered.csproj
Normal file
25
Outnumbered/outnumbered.csproj
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
<AssemblyName>outnumbered</AssemblyName>
|
||||||
|
<RootNamespace>Outnumbered</RootNamespace>
|
||||||
|
<WithSqlite Condition="'$(WithSqlite)'==''">true</WithSqlite>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(WithSqlite)'=='true'">
|
||||||
|
<DefineConstants>$(DefineConstants);WITH_SQLITE</DefineConstants>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.369" ExcludeAssets="runtime" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" ExcludeAssets="runtime" />
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.72" />
|
||||||
|
<PackageReference Include="Npgsql" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(WithSqlite)'=='true'">
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
300
README.md
Normal file
300
README.md
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
# Outnumbered
|
||||||
|
|
||||||
|
**A players-vs-bots RPG mod for Counter-Strike 2** — a small human squad fights badly
|
||||||
|
outnumbered against bot hordes while a full RPG layer (persistent levels, a passive perk
|
||||||
|
tree, killstreak abilities, a quick-buy shop, and a hidden rubber-band balancer) rides on
|
||||||
|
top of fast deathmatch combat.
|
||||||
|
|
||||||
|
Because every enemy is a bot, the mod is **PvE by design** — there are no human opponents to
|
||||||
|
cheat against, and everything works at `sv_cheats 0`. One shared RPG core runs under three
|
||||||
|
match rulesets — **Team Deathmatch**, **Gun Game**, and **Wave Survival** — picked per server
|
||||||
|
instance, so a single install + single config can power a whole fleet of differently-flavored
|
||||||
|
servers.
|
||||||
|
|
||||||
|
> Built on [CounterStrikeSharp](https://docs.cssharp.dev/). Linux-only. Licensed under **AGPL-3.0** — see [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A CS2 dedicated server with **Metamod:Source** and **CounterStrikeSharp** installed
|
||||||
|
(compiled against CounterStrikeSharp API **1.0.369**; use a matching or newer server build).
|
||||||
|
- **.NET 10** — provided by the CounterStrikeSharp runtime; only needed as an SDK if you build from source.
|
||||||
|
- **Linux** — the damage path, native grenade spawning, and launch-flag parsing are Linux-specific.
|
||||||
|
- A database: **PostgreSQL** for production (recommended, required for multi-instance), or **SQLite** for single-server/dev.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running a server
|
||||||
|
|
||||||
|
### Install from a release (no build required)
|
||||||
|
|
||||||
|
Every tagged version is published to this repo's
|
||||||
|
[**Releases**](https://git.lo.sh/kamal/cs2-outnumbered/releases). Grab the latest tarball and
|
||||||
|
extract its `addons/` folder into your server's `game/csgo/` — that's the whole install, nothing to
|
||||||
|
compile:
|
||||||
|
|
||||||
|
```
|
||||||
|
<cs2>/game/csgo/addons/counterstrikesharp/plugins/outnumbered/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
1. Ensure **Metamod:Source + CounterStrikeSharp** are installed and the Metamod entry is present in
|
||||||
|
`game/csgo/gameinfo.gi`. CS2 updates periodically strip that line, which silently disables every
|
||||||
|
plugin — re-add it if plugins stop loading. Confirm with `meta list` and `css_plugins list`.
|
||||||
|
2. The release is **Postgres-only**, so set a **PostgreSQL** connection string in `outnumbered.json`
|
||||||
|
(written on first load, in the CounterStrikeSharp configs folder). Want the zero-setup SQLite
|
||||||
|
database instead? It isn't in the release — build from source with `-p:WithSqlite=true` (below).
|
||||||
|
3. Launch with the mode, and a GSLT for a public server — e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./cs2 -dedicated -insecure +game_type 1 +game_mode 2 -maxplayers 25 +map ar_shoots \
|
||||||
|
-port 27015 +sv_setsteamaccount <your-token> -outnumbered_mode tdm
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Mode** is `-outnumbered_mode tdm|gg|survival`.
|
||||||
|
- **Public listing** needs a **GSLT** (`+sv_setsteamaccount <token>`, appid 730) and an open UDP
|
||||||
|
game port; without a token the server is LAN-only. Mint tokens at
|
||||||
|
<https://steamcommunity.com/dev/managegameservers> with a **dedicated** Steam account (a
|
||||||
|
game-server ban is account-wide).
|
||||||
|
- The mod runs `-insecure` (VAC off — CounterStrikeSharp's memory writes would trip VAC); that's
|
||||||
|
independent of being publicly listed.
|
||||||
|
- Run it however you prefer — directly, under systemd, or any process supervisor; just pass the
|
||||||
|
launch flags above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building from source
|
||||||
|
|
||||||
|
You need the **.NET 10 SDK**. CounterStrikeSharp is pulled from NuGet, so no local engine path
|
||||||
|
is required.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://git.lo.sh/kamal/cs2-outnumbered && cd cs2-outnumbered
|
||||||
|
|
||||||
|
dotnet build Outnumbered.slnx -c Release # build everything
|
||||||
|
dotnet test Outnumbered.slnx # run the Domain test suite
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQLite is a dev-only convenience
|
||||||
|
|
||||||
|
The default build bundles **SQLite** (a zero-setup local file database) so you can get running
|
||||||
|
without standing up Postgres. **It is for development / single-server use only.** Production —
|
||||||
|
and especially any multi-instance fleet sharing one database — should use **PostgreSQL**.
|
||||||
|
|
||||||
|
Pass `-p:WithSqlite=false` to produce the **Postgres-only** build. This is what ships: it drops
|
||||||
|
the SQLite packages and their native library entirely (and with them a known dev-only SQLite
|
||||||
|
advisory), leaving a lean plugin.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# The shippable, Postgres-only artifact:
|
||||||
|
dotnet publish Outnumbered/outnumbered.csproj -c Release -p:WithSqlite=false -o ./out
|
||||||
|
```
|
||||||
|
|
||||||
|
`dotnet publish` emits a ready-to-install plugin folder — host-provided assemblies are stripped
|
||||||
|
automatically, so the output is just the plugin and its real dependencies. Drop it into your
|
||||||
|
server at:
|
||||||
|
|
||||||
|
```
|
||||||
|
game/csgo/addons/counterstrikesharp/plugins/outnumbered/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
On first load the plugin writes `outnumbered.json` (in the CounterStrikeSharp configs folder)
|
||||||
|
from its built-in defaults; edit it to tune nearly everything. Set the database provider
|
||||||
|
(`sqlite` / `postgres`) and connection string there. Almost all tuning — abilities, stats, the
|
||||||
|
handicap, the shop, HUD, ranks, and the full Survival economy — **hot-reloads at runtime** via
|
||||||
|
the admin reload command, with no need to restart the match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core: a PvE RPG shooter
|
||||||
|
|
||||||
|
- **Heavily outnumbered, players-vs-bots.** Humans are forced onto CT and bots onto T at several
|
||||||
|
bots per human, so you're always fighting outnumbered.
|
||||||
|
- **One RPG core, three rulesets.** Shared persistence, stats, progression, handicap, abilities,
|
||||||
|
and the shop ride under three distinct match modes selected per instance.
|
||||||
|
- **Built on the Deathmatch base.** Every mode runs on the engine's deathmatch base for native
|
||||||
|
respawn and weapon deployment, deliberately bypassing the built-in mode rules. Bomb sites,
|
||||||
|
hostages, C4, money/buying, dropped weapons, and the deathmatch weapon prompt are all disabled —
|
||||||
|
it's pure combat.
|
||||||
|
- **Endless play.** Round-win conditions and the round timer are suppressed; a map ends only on a
|
||||||
|
mode's kill goal or win/lose condition.
|
||||||
|
- **Spawn protection** that grants brief immunity (to spend skill points) and drops the instant
|
||||||
|
you fire. Everyone spawns with a knife and armor on top of their loadout.
|
||||||
|
- **Configurable, fixed bot difficulty** (auto-adjust and chatter off).
|
||||||
|
|
||||||
|
### Game modes
|
||||||
|
|
||||||
|
- **Team Deathmatch** — spawn with your chosen primary/secondary; the map ends and rotates when
|
||||||
|
any one player hits the kill goal.
|
||||||
|
- **Gun Game** — climb a shared weapon ladder by getting kills, with an optional knife finale.
|
||||||
|
- *Dual inverse pacing:* kills-per-rung scale off each weapon's strength tier via inverse curves,
|
||||||
|
so you grind the weak guns and breeze the strong ones — while bots do the opposite — and the
|
||||||
|
ladder order zig-zags weapon types.
|
||||||
|
- *Bots climb too,* in stable player-sized **batches** that share a rung and re-equip together, so
|
||||||
|
the horde scales with player count and can actually reach the top and **win**.
|
||||||
|
- *Headshot demotion:* a headshot kill knocks the victim back a step — the classic knife-steal,
|
||||||
|
generalized — and rung weapons are handed over live, mid-life.
|
||||||
|
- **Wave Survival** — a co-op campaign where a CT squad holds against escalating waves of vanilla
|
||||||
|
bots; clear every wave to win, wipe or stall to lose (detailed below).
|
||||||
|
- Shared mode plumbing: bot population scales with humans (clamped to a max), CT is kept
|
||||||
|
human-only, the human cap is per-mode, and each mode can define its own map pool.
|
||||||
|
|
||||||
|
### Progression, prestige & ranks
|
||||||
|
|
||||||
|
- **XP → levels → skill points** along a tunable, accelerating curve up to a level cap.
|
||||||
|
- XP is earned **per hit, scaled by actual damage dealt**, plus flat bonuses for headshots, crits,
|
||||||
|
and the killing blow; sub-point remainders carry over so nothing is lost.
|
||||||
|
- **Prestige** at the cap: a full reset for a permanent, cumulative XP boost (it speeds the climb,
|
||||||
|
never lowers difficulty), confirmed through a warning menu. Prestige tiers permanently unlock
|
||||||
|
killstreak abilities (no streak needed) and re-lock level-gated weapons for the new climb.
|
||||||
|
- **Ranks**: named titles by level, shown to others via the **scoreboard clan tag** and a **colored
|
||||||
|
chat tag** (with a dead-player marker). High prestiges render as a flowing animated multi-color
|
||||||
|
tag. Level-ups announce in chat, play a sound, and call out any weapons unlocking.
|
||||||
|
|
||||||
|
### Passive stat tree
|
||||||
|
|
||||||
|
Skill points buy levels in a registry of passive perks, all resolved through one shared combat
|
||||||
|
math core:
|
||||||
|
|
||||||
|
- **Offense** — bonus weapon damage, bonus headshot damage (above the engine multiplier), crit
|
||||||
|
chance, and crit damage.
|
||||||
|
- **Survivability** — max HP and max armor, lifesteal and armor lifesteal (extra on crits, with a
|
||||||
|
minimum-heal floor), and **always-on flat HP/armor regen** with no out-of-combat gate.
|
||||||
|
- **Thorns** — reflects a portion of damage taken back at bots as *real, attributed* damage that can
|
||||||
|
kill and credit you, scaling with your build and handicap.
|
||||||
|
- **XP boost** — increases all XP gains.
|
||||||
|
|
||||||
|
### Killstreak abilities
|
||||||
|
|
||||||
|
Five timed abilities unlock as your streak grows:
|
||||||
|
|
||||||
|
- **No Reload** — refills your clip on every shot.
|
||||||
|
- **Adrenaline** — reduces damage taken.
|
||||||
|
- **Overcharge** — boosts damage dealt.
|
||||||
|
- **Bloodthirst** — bonus HP + armor lifesteal.
|
||||||
|
- **Berserk** — scales your damage and crit damage up as your health nears death.
|
||||||
|
|
||||||
|
Abilities are cast with a clever **grenade-key trick** (the matching grenade is granted only while
|
||||||
|
the ability is castable and instantly confiscated, so it can never be thrown), or via chat
|
||||||
|
commands / custom key binds. Each has its own duration and cooldown (which keeps ticking through
|
||||||
|
death), a ready sound cue, and lives in a fully configurable registry. Reserve ammo is topped up
|
||||||
|
in code so you effectively never run dry.
|
||||||
|
|
||||||
|
### Hidden handicap (the rubber-band balancer)
|
||||||
|
|
||||||
|
A silent per-player system keeps matches competitive without anyone seeing numbers:
|
||||||
|
|
||||||
|
- A single hidden index — blended from your **K/D, headshot rate, killstreak, level, and
|
||||||
|
mode-progress** — scales your **damage dealt, damage taken, and XP rate together**, so they hit
|
||||||
|
their extremes at the same thresholds.
|
||||||
|
- **Dominate** and you deal less, take more, but earn far more XP; **struggle** and you get a
|
||||||
|
**comeback buff** (deal more, take less) for less XP.
|
||||||
|
- A **monotonic escalation floor** lets a mode tighten difficulty one-way (Survival uses it to
|
||||||
|
escalate via the handicap rather than tougher bots); the **mode-progress axis** feeds in things
|
||||||
|
like Gun Game ladder position. Each mode can **override** only the handicap fields it wants, and
|
||||||
|
an easing curve shapes how sharply it bites.
|
||||||
|
|
||||||
|
### Quick-buy shop & weapons
|
||||||
|
|
||||||
|
- An **in-world, two-panel shop** opens by pressing the healthshot key and freezes you while
|
||||||
|
browsing. Because CS2 gives the server no raw key input, the menu is driven entirely by
|
||||||
|
**polling your active weapon** — a knife-neutral state machine between presses, the zeus used as a
|
||||||
|
detectable third menu key, and grenade keys for the rest. Options are labeled by *item*, so they
|
||||||
|
stay correct no matter how you've bound your slots.
|
||||||
|
- The **skills browser** spends points on stats grouped into damage / defense / utility; the
|
||||||
|
**weapon browser** (in weapon-enabled modes — Gun Game's shop is skills-only) picks a level-gated
|
||||||
|
primary/secondary; an **info panel** shows your live stats, rank, K/D, and multipliers. A flat
|
||||||
|
**numbered chat menu** is available too.
|
||||||
|
- **Weapons** are level-gated, with the strongest unlocking at the highest levels; your chosen
|
||||||
|
loadout is **saved per account** and reapplied on spawn (falling back to defaults if a pick is
|
||||||
|
locked). Level-up messages even spell out the exact menu keystrokes to reach a newly unlocked gun.
|
||||||
|
- The shop cleans up safely on death, respawn, disconnect, or map change so no one is left frozen.
|
||||||
|
|
||||||
|
### Wave Survival: draft & effect cards
|
||||||
|
|
||||||
|
- A **finite campaign** of escalating waves; clear them all to win, wipe or genuinely stall to lose.
|
||||||
|
- **Bots never get tougher** — they keep stock health; difficulty rises purely through *more bots*
|
||||||
|
and the tightening handicap floor. Each wave sets both a live bot count (ramping to a
|
||||||
|
CPU-protecting ceiling) and a separate kill budget scaled by wave and squad size.
|
||||||
|
- Between waves: a **break** to revive (downed survivors come back at reduced HP — an optional
|
||||||
|
hardcore mode makes death permanent for the run) and to **draft**.
|
||||||
|
- A **roguelite draft** offers strong, run-scoped cards that **stack on your permanent stats** as
|
||||||
|
deliberate counter-pressure to the rising floor. Picks **bank** (never wasted), the hand is
|
||||||
|
**fixed** so you can't re-roll by stalling, and each card has a pick cap.
|
||||||
|
- *Stat cards:* damage, headshot, crit chance/damage, max HP/armor, lifesteal, HP regen.
|
||||||
|
- *Effect cards:* **Burn** (armor-skipping stacking damage-over-time), **Explode-on-Kill** (real
|
||||||
|
HE blasts that chain), **squad-wide team buffs** (compounding damage up / damage-taken down),
|
||||||
|
**Berserker** (always-on near-death damage), **ability cooldown reduction**, **run-XP boost**,
|
||||||
|
and **headshot armor**. The whole catalog is data-driven — new cards need no code.
|
||||||
|
- **Run XP** banks separately and converts once at run-end, scaled by depth under a depth-growing
|
||||||
|
cap. It's deliberately **anti-launder**: it excludes the gameable handicap multiplier and counts
|
||||||
|
only your own attributed damage, so leeching earns almost nothing and a win pays the same as a
|
||||||
|
wipe at equal depth.
|
||||||
|
- Stall nudges teleport straggler bots to the squad so waves never hang; mid-run joiners wait in
|
||||||
|
spectator for the next run; run state is RAM-only (no mid-run reconnect restore).
|
||||||
|
|
||||||
|
### HUD, feel & feedback
|
||||||
|
|
||||||
|
- An always-on per-player **HUD** shows level, prestige, points, streak, XP progress, and your live
|
||||||
|
combat multipliers, plus a color-coded **state icon per ability** (ready / active+timer /
|
||||||
|
cooling+timer / locked). It renders as a crisp center panel or a positionable 3D world-text entity,
|
||||||
|
and is hidden from other players so everyone sees only their own.
|
||||||
|
- While an ability is active the HUD recolors and an optional faint full-screen "movie filter" tint
|
||||||
|
plays (blending when several are up). **Sound cues** fire for level-up, prestige, ability
|
||||||
|
ready/activate, and crits.
|
||||||
|
- Toggles for the HUD and for a **per-hit damage readout** (true final damage by hitgroup) as a
|
||||||
|
tuning aid. Modes contribute their own status lines (Survival's wave/bots-left, Gun Game's rung).
|
||||||
|
|
||||||
|
### Persistence & leaderboard
|
||||||
|
|
||||||
|
- A **pluggable database** behind one repository interface: SQLite (dev) or PostgreSQL (prod).
|
||||||
|
- **Permanent progression and the leaderboard are global** across all servers, while **in-progress
|
||||||
|
round state is scoped per server**, so many instances share one database cleanly.
|
||||||
|
- **RAM-first** with crash insurance: state is cached and flushed periodically plus on
|
||||||
|
disconnect/shutdown. A leaver's round stats are kept for a mid-match rejoin; when the last player
|
||||||
|
leaves, round state is wiped so the next session starts fresh. A `top` command lists the highest
|
||||||
|
players.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
- **Players:** skills, prestige, abilities, weapon selection, rank/live-stats, leaderboard,
|
||||||
|
help/about (which explains the hidden handicap), and HUD/damage toggles.
|
||||||
|
- **Admins:** grant XP/points, set level/prestige, full player reset, and live config reload —
|
||||||
|
targetable by name, `@me`, or `@all`.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
A few things that make it robust and unusual:
|
||||||
|
|
||||||
|
- A **pure, CounterStrikeSharp-free domain layer** holds all progression/stat/combat/handicap math,
|
||||||
|
so it's **unit-tested without the engine** and a **centralized combat resolver** guarantees the
|
||||||
|
HUD/shop readouts never drift from the damage that actually applies. Hot per-hit/per-tick math runs
|
||||||
|
off immutable, allocation-free snapshots and a single shared per-tick roster walk.
|
||||||
|
- A **pluggable mode-driver architecture** — each mode supplies only its ruleset variant points, so
|
||||||
|
adding a mode needs no core rewrite — and a **centralized engine-name/offset/write layer** that
|
||||||
|
makes a CS2 update a one-line fix.
|
||||||
|
- **Real, attributed engine damage at `sv_cheats 0`:** thorns, burn, and explosions are dealt as
|
||||||
|
genuine engine damage via direct memory writes, so they credit kills properly; HE blasts use the
|
||||||
|
game's **native grenade-spawn** so they arm and chain like thrown nades (with a manual-blast
|
||||||
|
fallback).
|
||||||
|
- **Launch flags** (`-outnumbered_mode`, `-outnumbered_server`) are read from `/proc/self/cmdline`
|
||||||
|
because the embedded .NET host hides process arguments.
|
||||||
|
- Pervasive **slot + SteamID pinning** defends against within-frame slot reuse (a disconnect handing
|
||||||
|
a bot a human's slot, etc.), and **load-time self-checks** validate mode aliases, card keys,
|
||||||
|
handicap overrides, the stat registry, and ability/icon alignment so misconfigurations fail loud.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Outnumbered is free software under the **GNU Affero General Public License v3.0**. If you run a
|
||||||
|
modified version on a network server, the AGPL requires you to offer your users the source of your
|
||||||
|
modifications. See [LICENSE](LICENSE).
|
||||||
6
global.json
Normal file
6
global.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestMinor"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue