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

590
Outnumbered/Shop.cs Normal file
View file

@ -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<int, ShopSession> _shop = new();
private readonly List<int> _shopReap = new(); // reused orphan-slot scratch for the per-tick session reap
private Action<int>? _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<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect_Shop);
RegisterEventHandler<EventPlayerSpawn>(OnPlayerSpawn_Shop);
}
private void Shutdown_Shop()
{
RemoveListener<Listeners.CheckTransmit>(OnCheckTransmit_Shop);
RemoveListener<Listeners.OnClientDisconnect>(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<CCSPlayerController> 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<ShopOption> CurrentOptions(CCSPlayerController p, ShopSession s, PlayerData pd)
{
var opts = new List<ShopOption>();
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);
});
}