commit d701598350bfbd01d91e270efa21d323cbef193d Author: Kamal Tufekcic Date: Sun Jul 5 13:28:35 2026 +0300 initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9f53762 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..bc65c0c --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 /game/csgo/; see README. SQLite single-server build: compile with -p:WithSqlite=true." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0fea386 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + true + 1.0.0 + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..bb13fc1 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,11 @@ + + + + <_HostProvided Include="$(PublishDir)Microsoft.Extensions.*.dll" /> + <_HostProvided Include="$(PublishDir)CounterStrikeSharp.*.dll" /> + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/Outnumbered.Tests/BalanceInvariantTests.cs b/Outnumbered.Tests/BalanceInvariantTests.cs new file mode 100644 index 0000000..ba20343 --- /dev/null +++ b/Outnumbered.Tests/BalanceInvariantTests.cs @@ -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 + } +} diff --git a/Outnumbered.Tests/CombatAmountsTests.cs b/Outnumbered.Tests/CombatAmountsTests.cs new file mode 100644 index 0000000..47b745e --- /dev/null +++ b/Outnumbered.Tests/CombatAmountsTests.cs @@ -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)); +} diff --git a/Outnumbered.Tests/CombatChainTests.cs b/Outnumbered.Tests/CombatChainTests.cs new file mode 100644 index 0000000..c30b5de --- /dev/null +++ b/Outnumbered.Tests/CombatChainTests.cs @@ -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 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); + } +} diff --git a/Outnumbered.Tests/HandicapModelTests.cs b/Outnumbered.Tests/HandicapModelTests.cs new file mode 100644 index 0000000..b669e84 --- /dev/null +++ b/Outnumbered.Tests/HandicapModelTests.cs @@ -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 + } +} diff --git a/Outnumbered.Tests/Outnumbered.Tests.csproj b/Outnumbered.Tests/Outnumbered.Tests.csproj new file mode 100644 index 0000000..815f126 --- /dev/null +++ b/Outnumbered.Tests/Outnumbered.Tests.csproj @@ -0,0 +1,21 @@ + + + false + true + Outnumbered.Tests + + + + Linked/Domain/%(Filename)%(Extension) + + + Linked/Config/DomainConfig.cs + + + + + + + + + diff --git a/Outnumbered.Tests/ProgressionModelTests.cs b/Outnumbered.Tests/ProgressionModelTests.cs new file mode 100644 index 0000000..50aa20a --- /dev/null +++ b/Outnumbered.Tests/ProgressionModelTests.cs @@ -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 +} diff --git a/Outnumbered.Tests/StatResolverTests.cs b/Outnumbered.Tests/StatResolverTests.cs new file mode 100644 index 0000000..94e3e12 --- /dev/null +++ b/Outnumbered.Tests/StatResolverTests.cs @@ -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 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 +} diff --git a/Outnumbered.Tests/SurvivalEconomyTests.cs b/Outnumbered.Tests/SurvivalEconomyTests.cs new file mode 100644 index 0000000..00cc524 --- /dev/null +++ b/Outnumbered.Tests/SurvivalEconomyTests.cs @@ -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)); +} diff --git a/Outnumbered.Tests/TestSupport.cs b/Outnumbered.Tests/TestSupport.cs new file mode 100644 index 0000000..68c254c --- /dev/null +++ b/Outnumbered.Tests/TestSupport.cs @@ -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 StatDefs() => StatDefs(Stats()); + public static FrozenDictionary StatDefs(StatsConfig c) => new Dictionary(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? 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(), + 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 _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; +} diff --git a/Outnumbered.slnx b/Outnumbered.slnx new file mode 100644 index 0000000..b443c33 --- /dev/null +++ b/Outnumbered.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/Outnumbered/Abilities.cs b/Outnumbered/Abilities.cs new file mode 100644 index 0000000..27327ef --- /dev/null +++ b/Outnumbered/Abilities.cs @@ -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 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(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(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 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}"); + } + } +} diff --git a/Outnumbered/Admin.cs b/Outnumbered/Admin.cs new file mode 100644 index 0000000..22627c7 --- /dev/null +++ b/Outnumbered/Admin.cs @@ -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 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 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, " ", 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, " ", 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, " ", 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, " ", 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, "", 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(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"); + } + } +} diff --git a/Outnumbered/Api.cs b/Outnumbered/Api.cs new file mode 100644 index 0000000..0c9fa0f --- /dev/null +++ b/Outnumbered/Api.cs @@ -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 (/.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 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 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(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 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"); + } + } +} diff --git a/Outnumbered/Commands.cs b/Outnumbered/Commands.cs new file mode 100644 index 0000000..fd09652 --- /dev/null +++ b/Outnumbered/Commands.cs @@ -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 ", + "!og_givepoints ", + "!og_setlevel ", + "!og_setprestige ", + "!og_resetplayer — 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); }); + } + } +} diff --git a/Outnumbered/Config/DomainConfig.cs b/Outnumbered/Config/DomainConfig.cs new file mode 100644 index 0000000..c2c929b --- /dev/null +++ b/Outnumbered/Config/DomainConfig.cs @@ -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 +// 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 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 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 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) +} diff --git a/Outnumbered/Config/OutnumberedConfig.cs b/Outnumbered/Config/OutnumberedConfig.cs new file mode 100644 index 0000000..3f1e18a --- /dev/null +++ b/Outnumbered/Config/OutnumberedConfig.cs @@ -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 ` 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 (/.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 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 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 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 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 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 Rifles { get; set; } = new() + { "weapon_ak47", "weapon_m4a1_silencer", "weapon_m4a1", "weapon_aug", "weapon_sg556", "weapon_galilar", "weapon_famas" }; + [JsonPropertyName("Smgs")] + public List Smgs { get; set; } = new() + { "weapon_mp9", "weapon_mp7", "weapon_mp5sd", "weapon_ump45", "weapon_p90", "weapon_mac10", "weapon_bizon" }; + [JsonPropertyName("Snipers")] + public List Snipers { get; set; } = new() + { "weapon_awp", "weapon_ssg08", "weapon_scar20", "weapon_g3sg1" }; + [JsonPropertyName("Shotguns")] + public List Shotguns { get; set; } = new() + { "weapon_nova", "weapon_xm1014", "weapon_mag7", "weapon_sawedoff" }; + [JsonPropertyName("Heavy")] + public List Heavy { get; set; } = new() + { "weapon_m249", "weapon_negev" }; + [JsonPropertyName("Pistols")] + public List 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 BotWeapons { get; set; } = new() + { "weapon_ak47", "weapon_ak47", "weapon_awp", "weapon_nova" }; + [JsonPropertyName("BotGrenades")] + public List 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 PlayerKillsByTier { get; set; } = new() { 10, 8, 5, 2, 1 }; + [JsonPropertyName("BotKillsByTier")] public List 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 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 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), + }; +} + diff --git a/Outnumbered/Data/DapperPlayerRepository.cs b/Outnumbered/Data/DapperPlayerRepository.cs new file mode 100644 index 0000000..dced02e --- /dev/null +++ b/Outnumbered/Data/DapperPlayerRepository.cs @@ -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 OpenConnectionAsync(); + public abstract Task EnsureSchemaAsync(); + + public async Task LoadAsync(ulong steamId) + { + await using var c = await OpenConnectionAsync(); + long sid = unchecked((long)steamId); + var row = await c.QuerySingleOrDefaultAsync( + """ + 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( + "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> GetTopAsync(int count) + { + await using var c = await OpenConnectionAsync(); + var rows = await c.QueryAsync( + "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 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> GetTopWavesAsync(int count) + { + await using var c = await OpenConnectionAsync(); + var rows = await c.QueryAsync( + "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> GetTopGgAsync(int count) + { + await using var c = await OpenConnectionAsync(); + var rows = await c.QueryAsync( + "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 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 LoadMatchAsync(ulong steamId) + { + await using var c = await OpenConnectionAsync(); + long sid = unchecked((long)steamId); + var row = await c.QuerySingleOrDefaultAsync( + "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 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); +} diff --git a/Outnumbered/Data/IPlayerRepository.cs b/Outnumbered/Data/IPlayerRepository.cs new file mode 100644 index 0000000..3a72cc1 --- /dev/null +++ b/Outnumbered/Data/IPlayerRepository.cs @@ -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 LoadAsync(ulong steamId); + Task SaveAsync(PersistPlayer player); + Task SaveManyAsync(IReadOnlyList players); + Task> 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 steamIds, int wave); // higher wave wins + Task TryImproveGgBestAsync(ulong steamId, long elapsedMs); // lower time wins + Task> GetTopWavesAsync(int count); + Task> GetTopGgAsync(int count); + + // Per-match stats (RAM-first; persisted only on disconnect/shutdown, wiped on round end). + Task LoadMatchAsync(ulong steamId); + Task SaveMatchAsync(MatchState state); + Task SaveManyMatchAsync(IReadOnlyList states); + Task WipeMatchAsync(); +} diff --git a/Outnumbered/Data/Models.cs b/Outnumbered/Data/Models.cs new file mode 100644 index 0000000..aef2197 --- /dev/null +++ b/Outnumbered/Data/Models.cs @@ -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 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 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 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(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; +} diff --git a/Outnumbered/Data/NpgsqlRepository.cs b/Outnumbered/Data/NpgsqlRepository.cs new file mode 100644 index 0000000..15cc43c --- /dev/null +++ b/Outnumbered/Data/NpgsqlRepository.cs @@ -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 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; + """); + } +} diff --git a/Outnumbered/Data/SqliteRepository.cs b/Outnumbered/Data/SqliteRepository.cs new file mode 100644 index 0000000..3ad3d30 --- /dev/null +++ b/Outnumbered/Data/SqliteRepository.cs @@ -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 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( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='match_state';") > 0; + bool hasServerId = matchExists && await c.ExecuteScalarAsync( + "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 diff --git a/Outnumbered/Domain/CardKeys.cs b/Outnumbered/Domain/CardKeys.cs new file mode 100644 index 0000000..a23937a --- /dev/null +++ b/Outnumbered/Domain/CardKeys.cs @@ -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) +} diff --git a/Outnumbered/Domain/CombatResolver.cs b/Outnumbered/Domain/CombatResolver.cs new file mode 100644 index 0000000..e8fdf7e --- /dev/null +++ b/Outnumbered/Domain/CombatResolver.cs @@ -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 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 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 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; +} diff --git a/Outnumbered/Domain/HandicapModel.cs b/Outnumbered/Domain/HandicapModel.cs new file mode 100644 index 0000000..fcf9b01 --- /dev/null +++ b/Outnumbered/Domain/HandicapModel.cs @@ -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 +} diff --git a/Outnumbered/Domain/PlayerSnapshot.cs b/Outnumbered/Domain/PlayerSnapshot.cs new file mode 100644 index 0000000..65f85a5 --- /dev/null +++ b/Outnumbered/Domain/PlayerSnapshot.cs @@ -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 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 +} diff --git a/Outnumbered/Domain/ProgressionModel.cs b/Outnumbered/Domain/ProgressionModel.cs new file mode 100644 index 0000000..2300a8f --- /dev/null +++ b/Outnumbered/Domain/ProgressionModel.cs @@ -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); +} diff --git a/Outnumbered/Domain/StatKeys.cs b/Outnumbered/Domain/StatKeys.cs new file mode 100644 index 0000000..04d0606 --- /dev/null +++ b/Outnumbered/Domain/StatKeys.cs @@ -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"; +} diff --git a/Outnumbered/Domain/StatResolver.cs b/Outnumbered/Domain/StatResolver.cs new file mode 100644 index 0000000..15e5a2f --- /dev/null +++ b/Outnumbered/Domain/StatResolver.cs @@ -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 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 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 defs, int baseMax) => + baseMax + (int)EffRun(s, StatKeys.MaxHp, defs); + + public static int MaxArmor(in PlayerSnapshot s, FrozenDictionary 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); +} diff --git a/Outnumbered/Domain/SurvivalEconomy.cs b/Outnumbered/Domain/SurvivalEconomy.cs new file mode 100644 index 0000000..4cc53e7 --- /dev/null +++ b/Outnumbered/Domain/SurvivalEconomy.cs @@ -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); +} diff --git a/Outnumbered/Driver.cs b/Outnumbered/Driver.cs new file mode 100644 index 0000000..40f15b8 --- /dev/null +++ b/Outnumbered/Driver.cs @@ -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> 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(OnMapStart_Driver); + RegisterListener(_ => SyncBots()); + + RegisterEventHandler(OnRoundStart_Driver); + RegisterEventHandler(OnPlayerDeath_Driver, HookMode.Pre); + RegisterEventHandler(OnPlayerSpawn_Driver); + RegisterEventHandler(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 ` (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(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 + } +} diff --git a/Outnumbered/Effects.cs b/Outnumbered/Effects.cs new file mode 100644 index 0000000..9a48042 --- /dev/null +++ b/Outnumbered/Effects.cs @@ -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> _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 + } +} diff --git a/Outnumbered/Engine/ControllerWriter.cs b/Outnumbered/Engine/ControllerWriter.cs new file mode 100644 index 0000000..f1396d9 --- /dev/null +++ b/Outnumbered/Engine/ControllerWriter.cs @@ -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); + } +} diff --git a/Outnumbered/Engine/DamageDealer.cs b/Outnumbered/Engine/DamageDealer.cs new file mode 100644 index 0000000..14f75ec --- /dev/null +++ b/Outnumbered/Engine/DamageDealer.cs @@ -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; +} diff --git a/Outnumbered/Engine/EngineNames.cs b/Outnumbered/Engine/EngineNames.cs new file mode 100644 index 0000000..ef7c55c --- /dev/null +++ b/Outnumbered/Engine/EngineNames.cs @@ -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 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; +} diff --git a/Outnumbered/Engine/GrenadeSpawner.cs b/Outnumbered/Engine/GrenadeSpawner.cs new file mode 100644 index 0000000..0fb3a4d --- /dev/null +++ b/Outnumbered/Engine/GrenadeSpawner.cs @@ -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? _heCreate; + private static bool _init, _ok, _nativeLogged, _manualLogged; + + public static void Explode(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action 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 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 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); } + } +} diff --git a/Outnumbered/Engine/Inventory.cs b/Outnumbered/Engine/Inventory.cs new file mode 100644 index 0000000..9f7dc38 --- /dev/null +++ b/Outnumbered/Engine/Inventory.cs @@ -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 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().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; + } +} diff --git a/Outnumbered/Engine/PawnWriter.cs b/Outnumbered/Engine/PawnWriter.cs new file mode 100644 index 0000000..a5f4699 --- /dev/null +++ b/Outnumbered/Engine/PawnWriter.cs @@ -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); + } +} diff --git a/Outnumbered/Engine/Rules.cs b/Outnumbered/Engine/Rules.cs new file mode 100644 index 0000000..64f3c74 --- /dev/null +++ b/Outnumbered/Engine/Rules.cs @@ -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(EngineNames.GameRulesDesigner).FirstOrDefault()?.GameRules; + + public static void MakeRoundEndless() + { + var gr = Current; + gr?.RoundTime = 999999; // ~277h + } +} diff --git a/Outnumbered/Engine/ScreenFade.cs b/Outnumbered/Engine/ScreenFade.cs new file mode 100644 index 0000000..f6c25c3 --- /dev/null +++ b/Outnumbered/Engine/ScreenFade.cs @@ -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 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); + } + } +} diff --git a/Outnumbered/Engine/UdsServer.cs b/Outnumbered/Engine/UdsServer.cs new file mode 100644 index 0000000..12faacf --- /dev/null +++ b/Outnumbered/Engine/UdsServer.cs @@ -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> _handle; + private readonly Action _onError; + private readonly CancellationTokenSource _cts = new(); + + internal UdsServer(string path, Func> handle, Action 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(); + } +} diff --git a/Outnumbered/Engine/WorldText.cs b/Outnumbered/Engine/WorldText.cs new file mode 100644 index 0000000..470b8b0 --- /dev/null +++ b/Outnumbered/Engine/WorldText.cs @@ -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(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)); + } +} diff --git a/Outnumbered/Feel.cs b/Outnumbered/Feel.cs new file mode 100644 index 0000000..89c5d20 --- /dev/null +++ b/Outnumbered/Feel.cs @@ -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 _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 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"); } }); +} diff --git a/Outnumbered/GunGame.cs b/Outnumbered/GunGame.cs new file mode 100644 index 0000000..9e6a600 --- /dev/null +++ b/Outnumbered/GunGame.cs @@ -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 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 _botRung = []; // per-bot mode (BotSharedLadder=false): slot -> rung + private readonly Dictionary _botRungKills = []; + private readonly Dictionary _botUserId = []; + private readonly Dictionary _batchRung = []; // shared mode (default): batchId -> rung + private readonly Dictionary _batchRungKills = []; + private readonly Dictionary _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? _ladder; + private GunGameConfig? _ladderCfg; + private List 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 BuildLadder() + { + var gg = _p.Config.GunGame; + var rungs = new List(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 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 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); +} diff --git a/Outnumbered/Handicap.cs b/Outnumbered/Handicap.cs new file mode 100644 index 0000000..a90b6e8 --- /dev/null +++ b/Outnumbered/Handicap.cs @@ -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); + } + } +} diff --git a/Outnumbered/Hud.cs b/Outnumbered/Hud.cs new file mode 100644 index 0000000..2c16378 --- /dev/null +++ b/Outnumbered/Hud.cs @@ -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 _hudOff = []; // per-player !hud opt-out + private readonly Dictionary _hudEntities = []; // slot -> world-text entity (world mode) + private readonly Dictionary _hudText = []; // slot -> last text pushed (skip redundant SetMessage) + private readonly HashSet _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 _hudReap = []; // reused stale-slot scratch (world mode), avoids a per-tick Keys.ToList() + private Action? _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(OnCheckTransmit_Hud); + } + + private void Shutdown_Hud() + { + RemoveListener(OnCheckTransmit_Hud); + foreach (var slot in _hudEntities.Keys.ToList()) DestroyHud(slot); + } + + // ---- per-tick ---- + private void OnTick_Hud(List 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($"{wline}
"); + sb.Append($"Lv{pd.Level} {PrestigeHudTag(pd.Prestige, now)}"); + sb.Append($" {pd.Points} pt"); + sb.Append($" Streak {pd.Streak}"); + int xpPct = (int)(pd.Xp * 100 / Math.Max(1, next)); // progress through current level; 0-100 + sb.Append($" {xpPct}% xp
"); + sb.Append($"HS {hs:F2}"); + sb.Append($" Out {md:F2}"); + sb.Append($" In {mt:F2}"); + sb.Append($" XP {xp:F2}
"); + for (int i = 0; i < AbilityCount; i++) + { + if (i > 0) sb.Append("   "); + sb.Append(AbilityHudSegment(pd, i, now)); + } + return sb.ToString(); + } + + // Compact icon, colored by state: green ready, cyan active(+secs), orange cooldown(+secs), gray locked(+req). + private string AbilityHudSegment(PlayerData pd, int i, double now) + { + string color, suffix; + if (AbilityActive(pd, i, now)) { color = "#55ddff"; suffix = $"{pd.AbilityActiveUntil[i] - now:F0}"; } + else if (!AbilityReady(pd, i, now)) { color = "#ff8800"; suffix = $"{pd.AbilityReadyAt[i] - now:F0}"; } + else if (AbilityUnlocked(pd, i)) { color = "#66ff66"; suffix = ""; } + else { color = "#777777"; suffix = $"{AbilityCfg(i).StreakReq}"; } + return $"{AbilityIcon(i)}{suffix}"; + } + + // 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 $"{text}"; + var cols = PrestigeColorSet(prestige); + if (cols.Length == 1) + { + var (r, g, b) = cols[0]; + return $"{text}"; + } + 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($"{ch}"); + 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)); + } +} diff --git a/Outnumbered/Modes.cs b/Outnumbered/Modes.cs new file mode 100644 index 0000000..8968fa1 --- /dev/null +++ b/Outnumbered/Modes.cs @@ -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 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 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 _botBatch = new(); // slot -> stable batch + occupant UserId + private readonly Dictionary _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 => ""; +} diff --git a/Outnumbered/Outnumbered.cs b/Outnumbered/Outnumbered.cs new file mode 100644 index 0000000..7f2ae0d --- /dev/null +++ b/Outnumbered/Outnumbered.cs @@ -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 +{ + 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(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(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(); + } +} diff --git a/Outnumbered/Persistence.cs b/Outnumbered/Persistence.cs new file mode 100644 index 0000000..ded2c76 --- /dev/null +++ b/Outnumbered/Persistence.cs @@ -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 _players = new(); + private readonly ConcurrentDictionary _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(OnClientAuthorized); + RegisterListener(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? 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 (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 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}"; +} diff --git a/Outnumbered/Players.cs b/Outnumbered/Players.cs new file mode 100644 index 0000000..2255600 --- /dev/null +++ b/Outnumbered/Players.cs @@ -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(); + + 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 Humans() => Utilities.GetPlayers().Where(IsHuman); + internal static IEnumerable 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 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 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 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(Dictionary map, List scratch, Action 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(CCheckTransmitInfoList infoList, Dictionary map, Action 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); + } + } +} diff --git a/Outnumbered/Progression.cs b/Outnumbered/Progression.cs new file mode 100644 index 0000000..9f56c3a --- /dev/null +++ b/Outnumbered/Progression.cs @@ -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, 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)}!"); + } +} diff --git a/Outnumbered/Ranks.cs b/Outnumbered/Ranks.cs new file mode 100644 index 0000000..a5051e0 --- /dev/null +++ b/Outnumbered/Ranks.cs @@ -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(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// — 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(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 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}"); + } +} diff --git a/Outnumbered/Shop.Draft.cs b/Outnumbered/Shop.Draft.cs new file mode 100644 index 0000000..4e547a4 --- /dev/null +++ b/Outnumbered/Shop.Draft.cs @@ -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(); + } +} diff --git a/Outnumbered/Shop.cs b/Outnumbered/Shop.cs new file mode 100644 index 0000000..3a51782 --- /dev/null +++ b/Outnumbered/Shop.cs @@ -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 _shop = new(); + private readonly List _shopReap = new(); // reused orphan-slot scratch for the per-tick session reap + private Action? _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(OnCheckTransmit_Shop); + RegisterListener(OnClientDisconnect_Shop); + RegisterEventHandler(OnPlayerSpawn_Shop); + } + + private void Shutdown_Shop() + { + RemoveListener(OnCheckTransmit_Shop); + RemoveListener(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 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 CurrentOptions(CCSPlayerController p, ShopSession s, PlayerData pd) + { + var opts = new List(); + 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); + }); +} diff --git a/Outnumbered/SnapshotBuilder.cs b/Outnumbered/SnapshotBuilder.cs new file mode 100644 index 0000000..f1d6558 --- /dev/null +++ b/Outnumbered/SnapshotBuilder.cs @@ -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's default, so lookups are bit-identical. + private FrozenDictionary _statDefs = FrozenDictionary.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 + }; + } +} diff --git a/Outnumbered/Stats.cs b/Outnumbered/Stats.cs new file mode 100644 index 0000000..ad64000 --- /dev/null +++ b/Outnumbered/Stats.cs @@ -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 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 _dmgReadout = new(); // !dmg — players seeing the per-hit final-damage readout + private readonly Dictionary _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(OnEntityTakeDamagePre); + RegisterEventHandler(OnPlayerHurt_Stats); + RegisterEventHandler(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()); + 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)); + } + } +} diff --git a/Outnumbered/Survival.cs b/Outnumbered/Survival.cs new file mode 100644 index 0000000..f1f0f2b --- /dev/null +++ b/Outnumbered/Survival.cs @@ -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 _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 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? 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 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; +} diff --git a/Outnumbered/SurvivalRun.cs b/Outnumbered/SurvivalRun.cs new file mode 100644 index 0000000..c590bee --- /dev/null +++ b/Outnumbered/SurvivalRun.cs @@ -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 cardDef) : Domain.IStatBonusSource +{ + private readonly Func _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 Cards { get; } = []; // card key -> times picked (get-only: the ref is fixed, the contents mutate) + public List 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; + } +} diff --git a/Outnumbered/Weapons.cs b/Outnumbered/Weapons.cs new file mode 100644 index 0000000..4aa0d25 --- /dev/null +++ b/Outnumbered/Weapons.cs @@ -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 WeaponNames = new Dictionary + { + ["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? _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 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 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)."); + } + } +} diff --git a/Outnumbered/outnumbered.csproj b/Outnumbered/outnumbered.csproj new file mode 100644 index 0000000..768d96b --- /dev/null +++ b/Outnumbered/outnumbered.csproj @@ -0,0 +1,25 @@ + + + true + true + outnumbered + Outnumbered + true + + + + $(DefineConstants);WITH_SQLITE + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a43d6b --- /dev/null +++ b/README.md @@ -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: + +``` +/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 -outnumbered_mode tdm + ``` + +### Notes + +- **Mode** is `-outnumbered_mode tdm|gg|survival`. +- **Public listing** needs a **GSLT** (`+sv_setsteamaccount `, appid 730) and an open UDP + game port; without a token the server is LAN-only. Mint tokens at + 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). diff --git a/global.json b/global.json new file mode 100644 index 0000000..1e7fdfa --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor" + } +}