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

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

181
Outnumbered/Progression.cs Normal file
View file

@ -0,0 +1,181 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Menu;
using CounterStrikeSharp.API.Modules.Utils;
using Outnumbered.Data;
using Outnumbered.Domain;
namespace Outnumbered;
// XP -> levels -> points -> prestige, plus the !skills menu that spends points. The XP/level/prestige CURVES
// live in Outnumbered.Domain.ProgressionModel; these are zero-math accessors. The stateful level-up loop + its side
// effects (chat/sound/clan) stay engine-side below.
public sealed partial class OutnumberedPlugin
{
private long XpToNext(int level) => ProgressionModel.XpToNext(level, Config.Progression);
private double PrestigeXpMultiplier(PlayerData pd) => ProgressionModel.PrestigeXpMultiplier(pd.Prestige, Config.Progression);
// Award XP (per-hit damage, kill bonus, or dev). Applies the XP-Boost stat + cumulative prestige boost +
// handicap rate; handles level-ups. The fractional remainder is carried so tiny per-hit grants aren't lost.
private void GrantXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
{
if (baseAmount <= 0 || pd.Level >= Config.Progression.LevelCap) return;
double scaled = baseAmount
* (1.0 + Eff(pd, StatKeys.XpBoost) / 100.0)
* PrestigeXpMultiplier(pd)
* HandicapXpMult(pd)
+ pd.XpCarry;
long gain = (long)Math.Floor(scaled);
pd.XpCarry = scaled - gain; // keep the sub-1 remainder for the next hit
if (gain <= 0) return;
pd.Xp += gain;
pd.Dirty = true;
ApplyXpToLevel(pd, p);
}
// Resolve any pending level-ups from pd.Xp (awarding a point + firing the level-up cascade each), then clamp at
// the cap. Shared by per-hit XP (GrantXp) and the survival per-wave grant (AddConvertedXp) so both behave alike.
private void ApplyXpToLevel(PlayerData pd, CCSPlayerController? p)
{
long need;
while (pd.Level < Config.Progression.LevelCap && pd.Xp >= (need = XpToNext(pd.Level)))
{
pd.Xp -= need; // same XpToNext(pd.Level) the condition just computed (pd.Level unchanged until ++ below)
pd.Level++;
pd.Points++;
OnLevelUp(pd, p);
}
if (pd.Level >= Config.Progression.LevelCap) { pd.Xp = 0; pd.XpCarry = 0; } // clamp at cap
}
// Route combat XP. In survival it's banked RAW into the CURRENT wave's accumulator (granted at wave clear x prestige
// x waveMult; the handicap XP-mult is deliberately excluded there). In every other mode it grants now.
internal void GrantCombatXp(PlayerData pd, double baseAmount, CCSPlayerController? p)
{
if (Draft is { } d) d.AccumulateWaveXp(pd, baseAmount); // survival: banked raw into THIS wave's accumulator
else GrantXp(pd, baseAmount, p);
}
// Add a pre-computed XP lump straight to the main table and resolve level-ups — NO per-hit multipliers re-applied
// (the survival per-wave grant has already applied prestige x waveMult and deliberately excluded the handicap mult).
internal void AddConvertedXp(PlayerData pd, long lump, CCSPlayerController? p)
{
if (lump <= 0 || pd.Level >= Config.Progression.LevelCap) return;
pd.Xp += lump;
pd.Dirty = true;
ApplyXpToLevel(pd, p);
}
private void OnLevelUp(PlayerData pd, CCSPlayerController? p)
{
if (p is not { IsValid: true }) return;
if (pd.Level >= Config.Progression.LevelCap)
p.PrintToChat($"[Outnumbered] MAX LEVEL! Type !prestige to reset for a permanent boost.");
else
p.PrintToChat($"[Outnumbered] Level {pd.Level}! +1 point — !skills to spend ({pd.Points} available).");
ApplyClan(p, pd); // rank tag may have changed
PlaySound(p, Config.Sounds.LevelUp);
AnnounceUnlocks(p, pd.Level); // shout any weapon(s) that unlock at this exact level
}
// Flat kill bonus on the lethal blow (the bulk of XP comes per-hit from damage; see OnPlayerHurt_Stats).
private void GrantKillXp(CCSPlayerController attacker)
{
if (PdOf(attacker) is { } pd)
GrantCombatXp(pd, Config.Progression.KillXpBonus, attacker); // survival: into the run accumulator
}
// ---- !skills ----
// Flat numbered legend (no menu): buy any stat directly with !s<n>, repeatable, no pagination.
// (!1-!9 are reserved by CSSharp's menu key system, so skill commands use the 's' prefix.)
private void OpenSkillMenu(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
p.PrintToChat($" {ChatColors.Gold}[Outnumbered] Skills — {pd.Points} pt | L{pd.Level} P {Roman(pd.Prestige)} (buy: !s1-!s{StatList.Length})");
for (int i = 0; i < StatList.Length; i++)
p.PrintToChat($" {ChatColors.Lime} " + SkillLegendSeg(pd, i));
}
private string SkillLegendSeg(PlayerData pd, int i)
{
var (key, display) = StatList[i];
return $"!s{i + 1} {display} [{LevelOf(pd, key)}/{DefFor(key).MaxLevel}]";
}
// Registered from Load: !s1..!sN each buy the matching stat (BuyStat enforces points/cap).
private void RegisterSkillCommands()
{
for (int i = 0; i < StatList.Length; i++)
{
int idx = i;
AddCommand($"css_s{i + 1}", $"Buy skill #{i + 1}", (player, _) =>
{
if (player is { IsValid: true }) BuyStat(player, StatList[idx].Key);
});
}
}
private void BuyStat(CCSPlayerController p, string key)
{
var pd = PdOf(p);
if (pd is null) return;
int lvl = LevelOf(pd, key);
int max = DefFor(key).MaxLevel;
if (pd.Points <= 0 || lvl >= max) return;
pd.Upgrades[key] = lvl + 1;
pd.Points--;
pd.Dirty = true;
DeferReapplyCaps(p); // re-apply the new max HP/armor next frame (slot+SteamID-pinned)
p.PrintToChat($"[Outnumbered] {key} -> {lvl + 1}/{max} ({pd.Points} point(s) left).");
}
// ---- !prestige ----
private void OpenPrestige(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
if (pd.Level < Config.Progression.LevelCap)
{
p.PrintToChat($"[Outnumbered] Reach level {Config.Progression.LevelCap} to prestige (you're {pd.Level}).");
return;
}
if (pd.Prestige >= Config.Progression.PrestigeCap)
{
p.PrintToChat($"[Outnumbered] Max prestige ({Roman(pd.Prestige)}) — 100% complete!");
return;
}
var menu = new ChatMenu($"Prestige {Roman(pd.Prestige + 1)}? FULL RESET (level/points/stats)");
menu.AddMenuOption("YES — prestige now", (player, _) => DoPrestige(player));
menu.AddMenuOption("Cancel", (_, _) => { });
menu.Open(p);
}
private void DoPrestige(CCSPlayerController p)
{
var pd = PdOf(p);
if (pd is null) return;
if (pd.Level < Config.Progression.LevelCap || pd.Prestige >= Config.Progression.PrestigeCap) return;
pd.Prestige++;
pd.Level = 1;
pd.Xp = 0;
pd.Points = 1;
pd.Upgrades.Clear();
pd.Dirty = true;
// Re-apply the default loadout (level 1 re-locks the high-tier guns) and reset HP/armor to base on the live pawn.
DeferReapplyCaps(p, relockLoadout: true); // slot+SteamID-pinned so a within-frame slot-reuse can't inherit this reset
ApplyClan(p, pd); // prestige + reset to L1 changes the tag
PlaySound(p, Config.Sounds.Prestige);
Server.PrintToChatAll($"[Outnumbered] {p.PlayerName} reached Prestige {Roman(pd.Prestige)}!");
}
}