181 lines
7.6 KiB
C#
181 lines
7.6 KiB
C#
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)}!");
|
|
}
|
|
}
|