initial commit
All checks were successful
CI / build (push) Successful in 32s
CI / release (push) Successful in 32s
CI / lint (push) Successful in 30s

This commit is contained in:
Kamal Tufekcic 2026-07-05 13:28:35 +03:00
commit d701598350
67 changed files with 9351 additions and 0 deletions

23
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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
View 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/>.

View 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
}
}

View 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));
}

View 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);
}
}

View 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
}
}

View 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>

View 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
}

View 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
}

View 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));
}

View 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
View 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
View 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
View 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
View 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
View 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); });
}
}
}

View 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)
}

View 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),
};
}

View 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);
}

View 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();
}

View 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;
}

View 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;
""");
}
}

View 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

View 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)
}

View 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;
}

View 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
}

View 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
}

View 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);
}

View 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";
}

View 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);
}

View 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
View 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
View 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
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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); }
}
}

View 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;
}
}

View 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);
}
}

View 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
}
}

View 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);
}
}
}

View 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();
}
}

View 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
View 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
View 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
View 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
View 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(" &nbsp; ");
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
View 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 => "";
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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);
});
}

View 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
View 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
View 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;
}

View 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
View 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).");
}
}
}

View 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
View 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
View file

@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestMinor"
}
}