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