Player/RunBuffs.cs
using System;
using System.Linq;
using System.Collections.Generic;
namespace Prototype;
public sealed record ChestBuff(string Id, string Name, string Description, int Group, int Weight, int MaxRank);
public sealed class RunBuffs : Component
{
public const float ArmorPerRank = 15f;
public static readonly ChestBuff[] Catalog = {
new("power", "OVERCHARGE", "+15% weapon damage",0,35,3),
new("haste", "RAPID FIRE", "+12% fire rate",0,30,3),
new("reload", "QUICK HANDS", "15% shorter reload",0,20,3),
new("execute", "EXECUTIONER", "+25% damage to enemies below 30% HP",0,15,2),
new("vitality", "FORTIFY", "+20 max HP. Heal 50% of new maximum",1,35,3),
new("armor", "IRON SKIN", "Reduces incoming damage",1,30,3),
new("recovery", "SECOND WIND", "Heal 8% max HP after clearing a room",1,20,2),
new("lifeline", "LAST CHANCE", "+1 charge: survive lethal damage at 1 HP, invulnerable for 10s",1,15,int.MaxValue),
new("speed", "MOMENTUM", "+6% movement speed",2,35,3),
new("dash", "BLINK RHYTHM", "10% shorter dash cooldown",2,30,3),
new("focus", "FOCUS", "10% shorter ultimate cooldown",2,20,3),
new("rhythm", "COMBAT RHYTHM", "+15% damage for 2s after dash. Triggers every 8s",2,15,2),
new("power_extra", "SHARPEN", "+3% weapon damage",0,100,int.MaxValue),
new("speed_extra", "LIGHT STEP", "+1% movement speed",2,100,int.MaxValue)
};
[Sync(SyncFlags.FromHost)] public NetDictionary<string,int> Ranks {get;set;} = new();
[Sync(SyncFlags.FromHost)] public string Offers {get;set;} = "";
[Sync(SyncFlags.FromHost)] public int OfferedSeed {get;set;}
[Sync(SyncFlags.FromHost)] public int ClaimedSeed {get;set;}
[Sync] public int LifeCharges {get;private set;}
[Sync] public float RhythmUntil {get;private set;}
private float _nextRhythm, _protectedUntil;
private string _lastRoom = "", _lastGrant = "";
public int Rank(string id) => Ranks.TryGetValue(id,out var n) ? n : 0;
public IEnumerable<ChestBuff> Choices => Offers.Split(',').Select(id => Catalog.FirstOrDefault(b => b.Id == id)).Where(b => b is not null);
public float ReloadFactor => MathF.Max(.55f,1f-.15f*Rank("reload"));
public float RateFactor => 1f+.12f*Rank("haste");
public float SpeedFactor => 1f+.06f*Rank("speed")+.01f*Rank("speed_extra");
public float DamageFactor(float hpFraction) => 1f+.15f*Rank("power")+.03f*Rank("power_extra")+(hpFraction < .3f ? .25f*Rank("execute") : 0f)+(Time.Now < RhythmUntil ? .15f*Rank("rhythm") : 0f);
public void Roll(int seed)
{
if (PrototypeGame.Instance?.IsHost != true || OfferedSeed == seed) return;
var chosen = new List<string>();
for(int group=0;group<3;group++)
{
var pool=Catalog.Where(b=>b.Group==group && !b.Id.EndsWith("_extra") && Rank(b.Id)<b.MaxRank).ToArray();
if(pool.Length==0) pool=Catalog.Where(b=>b.Group==group && b.Id.EndsWith("_extra")).ToArray();
var roll=Game.Random.Int(1,pool.Sum(b=>b.Weight));
foreach(var buff in pool) { roll-=buff.Weight; if(roll<=0){chosen.Add(buff.Id);break;} }
}
Offers=string.Join(",",chosen.OrderBy(_=>Game.Random.Float(0,1))); OfferedSeed=seed;
}
public void ResetHost() { Ranks.Clear(); Offers=""; OfferedSeed=ClaimedSeed=0; }
public void ResetOwner() { LifeCharges=0; RhythmUntil=0; _nextRhythm=_protectedUntil=0; _lastRoom=_lastGrant=""; }
public void DashStarted() { if(Time.Now<_nextRhythm || Rank("rhythm")==0)return; RhythmUntil=Time.Now+2; _nextRhythm=Time.Now+8; }
public bool AbsorbLethal(float damage, HealthComponent health)
{
var hp=health.Health; var charges=LifeCharges;
var prevented=BuffRules.PreventDeath(damage,Time.Now,ref hp,ref charges,ref _protectedUntil);
LifeCharges=charges; health.Health=hp; return prevented;
}
[Rpc.Broadcast]
public void Grant(string id,int seed)
{
if(Rpc.Caller is not null && !Rpc.Caller.IsHost || IsProxy)return;
var key=$"{PrototypeGame.Instance.CoinRunId}:{seed}";
if(_lastGrant==key)return; _lastGrant=key;
var hp=Components.Get<HealthComponent>();
if(id=="lifeline")LifeCharges++;
if(id=="vitality") { hp.MaxHealth+=20; if(!hp.IsDead)hp.Health=MathF.Min(hp.MaxHealth,hp.Health+hp.MaxHealth*.5f); }
}
[Rpc.Broadcast]
public void RoomCleared(int seed,int room)
{
if(Rpc.Caller is not null && !Rpc.Caller.IsHost || IsProxy)return;
var key=$"{PrototypeGame.Instance.CoinRunId}:{seed}:{room}";
if(_lastRoom==key)return; _lastRoom=key;
var hp=Components.Get<HealthComponent>();
if(hp?.IsDead==false) hp.Health=MathF.Min(hp.MaxHealth,hp.Health+hp.MaxHealth*.08f*Rank("recovery"));
}
}