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