initial commit
This commit is contained in:
commit
d701598350
67 changed files with 9351 additions and 0 deletions
15
Outnumbered/Engine/ControllerWriter.cs
Normal file
15
Outnumbered/Engine/ControllerWriter.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// Controller-side schema writes (the pawn-side equivalents live in PawnWriter). Currently just the scoreboard clan tag —
|
||||
// kept here so the field name + the SetStateChanged notify contract live together, like every other engine write.
|
||||
internal static class ControllerWriter
|
||||
{
|
||||
public static void SetClan(CCSPlayerController p, string tag)
|
||||
{
|
||||
p.Clan = tag;
|
||||
Utilities.SetStateChanged(p, EngineNames.CCSPlayerController, EngineNames.Clan);
|
||||
}
|
||||
}
|
||||
99
Outnumbered/Engine/DamageDealer.cs
Normal file
99
Outnumbered/Engine/DamageDealer.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.Memory;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// Deals real, attributed damage as if `source` dealt it to `target`, returning the actual damage dealt. By default the hit
|
||||
// re-enters the offense hook so it scales with the source's build + handicap (Explode-on-Kill relies on this) and fires
|
||||
// EventPlayerDeath on a kill (proper credit / GG-rung / killstreak). A TakeDamageOld invoke does NOT raise player_hurt, so
|
||||
// EventPlayerHurt effects (lifesteal, per-hit XP) don't trigger — callers apply those on the returned amount. Mirrors the
|
||||
// CSSharp reference: a zeroed CTakeDamageInfo with AttackerInfo_t at +0x88, plus a CTakeDamageResult, fed to TakeDamageOld.
|
||||
// raw = a FLAT, armor-skipping BURN hit (DoT): DFLAG_IGNORE_ARMOR + DMG_BURN.
|
||||
// flat = a normal bullet that respects armor but is NOT scaled (thorns: the bot eats exactly what was computed).
|
||||
// Both set Unscaled so the offense hook applies `damage` verbatim; attribution is still kept so a kill credits the source.
|
||||
internal static class DamageDealer
|
||||
{
|
||||
// TRUE around an unscaled invoke (raw burn DoT or flat thorns reflect): the offense hook (OnEntityTakeDamagePre) returns
|
||||
// immediately when set, so `damage` lands verbatim — no build/handicap scaling, no crit roll. Toggled inside Deal only.
|
||||
internal static bool Unscaled;
|
||||
|
||||
// Byte offset of AttackerInfo_t inside CTakeDamageInfo (the CSSharp Tests.Native layout). A build-volatile fixup point
|
||||
// — if the struct layout shifts on a CS2 update, this is the one-line change (kept here with the marshalling it serves).
|
||||
private const int AttackerInfoOffset = 0x88;
|
||||
|
||||
// Cached all-zero source for bulk-clearing the two unmanaged buffers (one memcpy each vs N per-byte WriteByte interop
|
||||
// crossings). Grows once to the largest class size; never written so it stays zero. Game-thread only, Deal not re-entrant.
|
||||
private static byte[] _zero = [];
|
||||
|
||||
// The two unmanaged buffers, REUSED across calls instead of AllocHGlobal/FreeHGlobal per Deal (hot in burn waves: per
|
||||
// burn-tick-per-bot, per thorns hit, per explode). Safe because Deal is game-thread + NOT re-entrant — the synchronous
|
||||
// player_death its Invoke can fire only REMOVES burns (ClearBurnsForBot), and explode-on-kill is deferred a frame; no
|
||||
// path re-enters Deal before it returns. Grow-only to the class size; never freed (process-lifetime); re-zeroed each call.
|
||||
private static IntPtr _infoBuf, _resBuf;
|
||||
private static int _infoCap, _resCap;
|
||||
|
||||
internal static float Deal(CCSPlayerController target, CCSPlayerController source, float damage, bool raw = false, bool flat = false)
|
||||
{
|
||||
if (damage <= 0 || !target.IsValid || !source.IsValid) return 0f;
|
||||
var targetPawn = target.PlayerPawn.Value;
|
||||
if (targetPawn is null || targetPawn.Health <= 0) return 0f;
|
||||
|
||||
int infoSize = Schema.GetClassSize(EngineNames.CTakeDamageInfo);
|
||||
int resSize = Schema.GetClassSize(EngineNames.CTakeDamageResult);
|
||||
if (_zero.Length < infoSize || _zero.Length < resSize) _zero = new byte[Math.Max(infoSize, resSize)];
|
||||
// Grow the reused buffers if needed (class sizes are fixed per build, so this fires only on the first call), then
|
||||
// re-zero them — no per-call AllocHGlobal/FreeHGlobal.
|
||||
if (_infoCap < infoSize) { _infoBuf = _infoBuf == IntPtr.Zero ? Marshal.AllocHGlobal(infoSize) : Marshal.ReAllocHGlobal(_infoBuf, (IntPtr)infoSize); _infoCap = infoSize; }
|
||||
if (_resCap < resSize) { _resBuf = _resBuf == IntPtr.Zero ? Marshal.AllocHGlobal(resSize) : Marshal.ReAllocHGlobal(_resBuf, (IntPtr)resSize); _resCap = resSize; }
|
||||
IntPtr infoPtr = _infoBuf, resPtr = _resBuf;
|
||||
Marshal.Copy(_zero, 0, infoPtr, infoSize); // bulk-zero the reused buffers (memcpy) — restore the clean-slate contract
|
||||
Marshal.Copy(_zero, 0, resPtr, resSize);
|
||||
|
||||
var info = new CTakeDamageInfo(infoPtr);
|
||||
var ai = new AttackerInfo_t
|
||||
{
|
||||
NeedInit = true,
|
||||
IsPawn = true,
|
||||
AttackerPawn = source.Pawn.Raw,
|
||||
AttackerPlayerSlot = source.Slot,
|
||||
};
|
||||
Marshal.StructureToPtr(ai, new IntPtr(infoPtr.ToInt64() + AttackerInfoOffset), false);
|
||||
|
||||
uint inflictor = source.PawnIsAlive ? source.Pawn.Raw : source.PlayerPawn.Raw;
|
||||
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Inflictor, inflictor);
|
||||
Schema.SetSchemaValue(info.Handle, EngineNames.CTakeDamageInfo, EngineNames.Attacker, source.Pawn.Raw);
|
||||
info.Damage = damage;
|
||||
info.BitsDamageType = raw ? DamageTypes_t.DMG_BURN : DamageTypes_t.DMG_BULLET;
|
||||
if (raw) info.DamageFlags |= TakeDamageFlags_t.DFLAG_IGNORE_ARMOR;
|
||||
|
||||
var result = new CTakeDamageResult(resPtr);
|
||||
Schema.SetSchemaValue(result.Handle, EngineNames.CTakeDamageResult, EngineNames.OriginatingInfo, info.Handle);
|
||||
result.HealthBefore = targetPawn.Health;
|
||||
result.HealthLost = (int)damage;
|
||||
result.DamageDealt = damage;
|
||||
result.PreModifiedDamage = damage;
|
||||
|
||||
bool unscaled = raw || flat;
|
||||
#pragma warning disable CS0618
|
||||
if (unscaled) Unscaled = true;
|
||||
try { VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Invoke(targetPawn, info, result); }
|
||||
finally { if (unscaled) Unscaled = false; }
|
||||
#pragma warning restore CS0618
|
||||
return info.Damage;
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution payload the engine reads out of CTakeDamageInfo at +0x88 (kill credit / assists). Byte-laid-out to match
|
||||
// the CSSharp reference (Tests.Native); written via Marshal in Deal.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct AttackerInfo_t
|
||||
{
|
||||
public bool NeedInit;
|
||||
public bool IsPawn;
|
||||
public bool IsWorld;
|
||||
public uint AttackerPawn;
|
||||
public int AttackerPlayerSlot;
|
||||
public int TeamChecked;
|
||||
public int Team;
|
||||
}
|
||||
47
Outnumbered/Engine/EngineNames.cs
Normal file
47
Outnumbered/Engine/EngineNames.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Frozen;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// Every engine string/identifier in one place: schema classes + fields, designer names, entity classnames, item-def
|
||||
// indices. A CS2/CSSharp rename becomes a one-line fix here instead of a hunt across files (engine churn is when, not if).
|
||||
internal static class EngineNames
|
||||
{
|
||||
// schema classes
|
||||
public const string CBaseEntity = "CBaseEntity";
|
||||
public const string CCSPlayerPawn = "CCSPlayerPawn";
|
||||
public const string CCSPlayerController = "CCSPlayerController";
|
||||
public const string CBasePlayerWeapon = "CBasePlayerWeapon";
|
||||
public const string CTakeDamageInfo = "CTakeDamageInfo";
|
||||
public const string CTakeDamageResult = "CTakeDamageResult";
|
||||
|
||||
// schema fields
|
||||
public const string Health = "m_iHealth";
|
||||
public const string MaxHealth = "m_iMaxHealth";
|
||||
public const string ArmorValue = "m_ArmorValue";
|
||||
public const string MoveType = "m_MoveType";
|
||||
public const string Clan = "m_szClan";
|
||||
public const string Inflictor = "m_hInflictor";
|
||||
public const string Attacker = "m_hAttacker";
|
||||
public const string OriginatingInfo = "m_pOriginatingInfo";
|
||||
public const string NextPrimaryAttackTick = "m_nNextPrimaryAttackTick";
|
||||
|
||||
// pawn designer-name varies by build — FrozenSet for the per-pellet membership check in the damage hook
|
||||
public static readonly FrozenSet<string> PlayerPawnDesigners =
|
||||
new[] { "cs_player_pawn", "player" }.ToFrozenSet(StringComparer.Ordinal);
|
||||
|
||||
// entity classnames / designer names
|
||||
public const string PointWorldText = "point_worldtext";
|
||||
public const string GameRulesDesigner = "cs_gamerules";
|
||||
// (the HE projectile is spawned via the native create func in GrenadeSpawner, not by classname)
|
||||
|
||||
// common item designer-names given/compared in loadouts (the knife rung, the spawn armor, the default-pool fallback)
|
||||
public const string WeaponKnife = "weapon_knife";
|
||||
public const string ItemAssaultSuit = "item_assaultsuit";
|
||||
public const string WeaponAk47 = "weapon_ak47";
|
||||
// the two shop "carrier" items every human spawns with: the healthshot (X toggles the menu) + the zeus (shop key 3)
|
||||
public const string ShopCarrier = "weapon_healthshot";
|
||||
public const string ShopMelee = "weapon_taser";
|
||||
|
||||
// item-def index for the HE grenade (native projectile create)
|
||||
public const int HeGrenadeItemDef = 44;
|
||||
}
|
||||
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal file
93
Outnumbered/Engine/GrenadeSpawner.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
|
||||
using CounterStrikeSharp.API.Modules.Utils;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// A real HE blast at a point, attributed to a player. Spawns via the game's NATIVE projectile-create (the same call used
|
||||
// when a player throws) because Valve removed the think-arming from the InitializeSpawnFromWorld input, so a
|
||||
// CreateEntityByName grenade is inert on current CS2. Signature from MatchZy / cs2-executes. Lazy-resolved; if it doesn't
|
||||
// match this build, falls back to a manual radius blast (no engine VFX). Config-agnostic: damage/radius/fuse are passed in;
|
||||
// `log` receives one-time path notes. Args: (position, angle, velocity, velocity, IntPtr.Zero, itemDefIndex).
|
||||
internal static class GrenadeSpawner
|
||||
{
|
||||
private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||
private static MemoryFunctionWithReturn<nint, nint, nint, nint, nint, int, CHEGrenadeProjectile>? _heCreate;
|
||||
private static bool _init, _ok, _nativeLogged, _manualLogged;
|
||||
|
||||
public static void Explode(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
|
||||
{
|
||||
if (attacker is not { IsValid: true } || attacker.PlayerPawn.Value is null) return;
|
||||
if (!TrySpawnHe(attacker, pos, baseDamage, radius, fuseSeconds, log))
|
||||
ManualBlast(attacker, pos, baseDamage, radius, log);
|
||||
}
|
||||
|
||||
private static bool TrySpawnHe(CCSPlayerController attacker, Vector pos, float baseDamage, float radius, float fuseSeconds, Action<string, Exception?> log)
|
||||
{
|
||||
if (!_init)
|
||||
{
|
||||
_init = true;
|
||||
try
|
||||
{
|
||||
_heCreate = new(IsLinux
|
||||
? "55 4C 89 C1 48 89 E5 41 57 49 89 D7"
|
||||
: "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 50 48 8B AC 24 80 00 00 00 49 8B F8");
|
||||
_ok = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log("explode-on-kill: HE native-create signature not found — manual blast fallback", ex);
|
||||
_ok = false;
|
||||
}
|
||||
}
|
||||
if (!_ok || _heCreate is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var apawn = attacker.PlayerPawn.Value!;
|
||||
var spawn = new Vector(pos.X, pos.Y, pos.Z + 12f);
|
||||
var ang = new QAngle();
|
||||
var vel = new Vector(0, 0, -10f);
|
||||
var nade = _heCreate.Invoke(spawn.Handle, ang.Handle, vel.Handle, vel.Handle, IntPtr.Zero, EngineNames.HeGrenadeItemDef);
|
||||
if (nade is null || !nade.IsValid) return false;
|
||||
|
||||
nade.Teleport(spawn, ang, vel);
|
||||
nade.Globalname = "custom";
|
||||
nade.TeamNum = apawn.TeamNum; // CT -> only damages bots; survivors safe (ff off)
|
||||
nade.Thrower.Raw = attacker.PlayerPawn.Raw; // kill attribution + killfeed
|
||||
nade.OriginalThrower.Raw = attacker.PlayerPawn.Raw;
|
||||
nade.OwnerEntity.Raw = attacker.PlayerPawn.Raw;
|
||||
nade.Damage = baseDamage; // scaled UP by the offense hook on detonation
|
||||
nade.DmgRadius = radius;
|
||||
nade.DetonateTime = Server.CurrentTime + Math.Max(0f, fuseSeconds); // think is scheduled now -> respected
|
||||
if (!_nativeLogged) { _nativeLogged = true; log("explode-on-kill: native HE grenade spawned + armed OK", null); }
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log("explode-on-kill: native HE invoke failed — manual blast fallback", ex);
|
||||
_ok = false; // don't keep retrying a bad signature this session
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the list — a lethal Deal fires player_death synchronously, which mutates bot state mid-loop.
|
||||
private static void ManualBlast(CCSPlayerController attacker, Vector center, float baseDamage, float radius, Action<string, Exception?> log)
|
||||
{
|
||||
double r = Math.Max(1.0, radius);
|
||||
foreach (var bot in Utilities.GetPlayers().ToList())
|
||||
{
|
||||
if (bot is not { IsValid: true, IsBot: true, IsHLTV: false } || !bot.PawnIsAlive) continue;
|
||||
var bpos = bot.PlayerPawn.Value?.AbsOrigin;
|
||||
if (bpos is null) continue;
|
||||
double dx = bpos.X - center.X, dy = bpos.Y - center.Y, dz = bpos.Z - center.Z;
|
||||
double dist = Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
if (dist > r) continue;
|
||||
float dmg = (float)(baseDamage * (1.0 - dist / r)); // linear falloff
|
||||
if (dmg >= 1f) DamageDealer.Deal(bot, attacker, dmg); // non-raw -> scales + attributed + chains
|
||||
}
|
||||
if (!_manualLogged) { _manualLogged = true; log("explode-on-kill: native HE unavailable -> MANUAL radius blast (no engine VFX)", null); }
|
||||
}
|
||||
}
|
||||
67
Outnumbered/Engine/Inventory.cs
Normal file
67
Outnumbered/Engine/Inventory.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using CounterStrikeSharp.API.Core;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// The weapon-inventory read surface (WeaponServices: MyWeapons / ActiveWeapon) plus the slot-select client commands.
|
||||
// Centralizes the null-safe "walk / read the player's weapons" idiom shared by Abilities + Shop, so a
|
||||
// CSSharp rename of WeaponServices/MyWeapons/ActiveWeapon — or a change to slot-select semantics — is one edit here.
|
||||
// (The bare GiveNamedItem/RemoveItemByDesignerName loadout calls stay at their call sites: single-call, nothing to share.)
|
||||
internal static class Inventory
|
||||
{
|
||||
// Slot-select client commands (engine INPUT, not schema). slot3 (the melee slot) is the neutral "rest" anchor; the zeus
|
||||
// is deployed by name because the engine auto-deploys a granted grenade over the knife but not over a real weapon.
|
||||
public const string SlotPrimary = "slot1";
|
||||
public const string SlotSecondary = "slot2";
|
||||
public const string SlotMelee = "slot3";
|
||||
public const string UseZeus = "use weapon_taser";
|
||||
|
||||
// The player's currently-deployed weapon, or null (null-safe through pawn -> WeaponServices -> handle, IsValid-checked).
|
||||
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerPawn? pawn)
|
||||
{
|
||||
var w = pawn?.WeaponServices?.ActiveWeapon.Value;
|
||||
return w is { IsValid: true } ? w : null;
|
||||
}
|
||||
|
||||
public static CBasePlayerWeapon? ActiveWeapon(CCSPlayerController p) => ActiveWeapon(p.PlayerPawn.Value);
|
||||
|
||||
// The active weapon's designer name, or "" when none — the common "what is this player holding?" probe.
|
||||
public static string ActiveWeaponName(CCSPlayerPawn? pawn) => ActiveWeapon(pawn)?.DesignerName ?? "";
|
||||
|
||||
// TRUE if the designer name is a knife (any skin — weapon_knife, weapon_knife_t, weapon_bayonet, …) or the zeus. Lets the
|
||||
// thorns abuse-guard tell "real melee in hand" from a gun (mirrors Shop's knife-neutral test, plus the zeus).
|
||||
public static bool IsMeleeOrZeus(string designerName) =>
|
||||
designerName == EngineNames.ShopMelee || designerName.Contains("knife") || designerName.Contains("bayonet");
|
||||
|
||||
// Every valid weapon the player holds (null-safe walk of WeaponServices.MyWeapons). Empty if no pawn / no services.
|
||||
// Lazy (iterator) — fine for the cold classify-all walk; the hot exact-match check uses Holds (no enumerator alloc).
|
||||
public static IEnumerable<CBasePlayerWeapon> Weapons(CCSPlayerController p)
|
||||
{
|
||||
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
|
||||
if (weapons is null) yield break;
|
||||
foreach (var h in weapons)
|
||||
{
|
||||
var w = h.Value;
|
||||
if (w is { IsValid: true }) yield return w;
|
||||
}
|
||||
}
|
||||
|
||||
// The weapon's full magazine size from its VData, or -1 if unavailable (the No-Reload / infinite-clip top-up reads it).
|
||||
public static int MaxClip(CBasePlayerWeapon w) => w.As<CCSWeaponBase>().VData?.MaxClip1 ?? -1;
|
||||
|
||||
// Whether the pawn's weapon inventory is readable at all (pawn + WeaponServices + MyWeapons present) — distinguishes
|
||||
// "no inventory" from "an empty inventory" for callers that branch on it (e.g. PreferredSlotCmd's no-services fallback).
|
||||
public static bool HasWeaponServices(CCSPlayerController p) => p.PlayerPawn.Value?.WeaponServices?.MyWeapons is not null;
|
||||
|
||||
// True if the player holds a weapon with this designer name. Direct walk (no iterator alloc) for the per-reconcile path.
|
||||
public static bool Holds(CCSPlayerController p, string designerName)
|
||||
{
|
||||
var weapons = p.PlayerPawn.Value?.WeaponServices?.MyWeapons;
|
||||
if (weapons is null) return false;
|
||||
foreach (var h in weapons)
|
||||
{
|
||||
var w = h.Value;
|
||||
if (w is { IsValid: true } && w.DesignerName == designerName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
54
Outnumbered/Engine/PawnWriter.cs
Normal file
54
Outnumbered/Engine/PawnWriter.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// The one place pawn HP/armor are written: every write is paired with the required SetStateChanged so the schema-field
|
||||
// names and the change-notification contract live together. Callers resolve the pawn; these never read config or pd.
|
||||
internal static class PawnWriter
|
||||
{
|
||||
// MaxHealth MUST be set before Health, else Health clamps to 100. Sets both the pawn and the controller mirror.
|
||||
public static void SetMaxHealth(CCSPlayerController p, CCSPlayerPawn pawn, int max)
|
||||
{
|
||||
pawn.MaxHealth = max;
|
||||
p.MaxHealth = max;
|
||||
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MaxHealth);
|
||||
}
|
||||
|
||||
public static void SetHealth(CCSPlayerPawn pawn, int hp)
|
||||
{
|
||||
pawn.Health = hp;
|
||||
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.Health);
|
||||
}
|
||||
|
||||
public static void SetArmor(CCSPlayerPawn pawn, int armor)
|
||||
{
|
||||
pawn.ArmorValue = armor;
|
||||
Utilities.SetStateChanged(pawn, EngineNames.CCSPlayerPawn, EngineNames.ArmorValue);
|
||||
}
|
||||
|
||||
// The pawn movement-state write + its change-notification (the shop freeze/unfreeze). Caller owns any velocity reset.
|
||||
public static void SetMoveType(CCSPlayerPawn pawn, MoveType_t moveType)
|
||||
{
|
||||
pawn.MoveType = moveType;
|
||||
Utilities.SetStateChanged(pawn, EngineNames.CBaseEntity, EngineNames.MoveType);
|
||||
}
|
||||
|
||||
// A capped ADD never REDUCES: if the pawn is already at/over the cap (e.g. an !og_reload lowered Max-HP below the
|
||||
// live value) we no-op rather than snapping it down — so regen/lifesteal preserve an over-cap surplus.
|
||||
public static void AddHealthCapped(CCSPlayerPawn pawn, int amount, int cap)
|
||||
{
|
||||
if (amount <= 0 || pawn.Health <= 0) return;
|
||||
int next = Math.Min(cap, pawn.Health + amount);
|
||||
if (next <= pawn.Health) return;
|
||||
SetHealth(pawn, next);
|
||||
}
|
||||
|
||||
public static void AddArmorCapped(CCSPlayerPawn pawn, int amount, int cap)
|
||||
{
|
||||
if (amount <= 0 || pawn.Health <= 0) return;
|
||||
int next = Math.Min(cap, pawn.ArmorValue + amount);
|
||||
if (next <= pawn.ArmorValue) return;
|
||||
SetArmor(pawn, next);
|
||||
}
|
||||
}
|
||||
18
Outnumbered/Engine/Rules.cs
Normal file
18
Outnumbered/Engine/Rules.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// Game-rules access. mp_roundtime caps at 60 min so the round timer would expire and hang; the map ends only on the kill
|
||||
// goal, so we override the round duration directly on the rules each round.
|
||||
internal static class Rules
|
||||
{
|
||||
public static CCSGameRules? Current =>
|
||||
Utilities.FindAllEntitiesByDesignerName<CCSGameRulesProxy>(EngineNames.GameRulesDesigner).FirstOrDefault()?.GameRules;
|
||||
|
||||
public static void MakeRoundEndless()
|
||||
{
|
||||
var gr = Current;
|
||||
gr?.RoundTime = 999999; // ~277h
|
||||
}
|
||||
}
|
||||
30
Outnumbered/Engine/ScreenFade.cs
Normal file
30
Outnumbered/Engine/ScreenFade.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.UserMessages;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// CS2 screen fade via the Fade user message. The exact message format is UNVERIFIED (hard to debug blind), so the send is
|
||||
// wrapped: a wrong field name no-ops via onError instead of crashing. The blend/decision logic stays plugin-side.
|
||||
internal static class ScreenFade
|
||||
{
|
||||
public static int Pack(int r, int g, int b, int a) => r | (g << 8) | (b << 16) | (a << 24);
|
||||
|
||||
public static void Send(CCSPlayerController p, int r, int g, int b, int a, bool clear, Action<Exception> onError)
|
||||
{
|
||||
try
|
||||
{
|
||||
// CUserMessageFade [106]: duration, hold_time, flags, color — all INT; color is one packed value (not nested clr).
|
||||
var msg = UserMessage.FromPartialName("Fade");
|
||||
msg.SetInt("duration", (int)(0.3f * 512)); // Q7.9 fixed point (seconds * 512); int, not float
|
||||
msg.SetInt("hold_time", 0); // FFADE_STAYOUT holds it until the next fade
|
||||
// hold a tint: FFADE_OUT|FFADE_STAYOUT. clear it: FFADE_IN|FFADE_PURGE (fades the held colour out + drops stayout).
|
||||
msg.SetInt("flags", clear ? (0x1 | 0x10) : (0x2 | 0x8));
|
||||
msg.SetInt("color", Pack(r, g, b, a)); // packed color32, R in the low byte
|
||||
msg.Send(p);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onError(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Outnumbered/Engine/UdsServer.cs
Normal file
106
Outnumbered/Engine/UdsServer.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// Minimal request/response server on a Unix domain socket: one verb line in, one payload out, connection closes.
|
||||
// Runs entirely on background threads — the handler must NEVER touch game state (Api.cs serves pre-serialized bytes,
|
||||
// and the one async verb is DB-only). Lifecycle: the ctor binds (delete-before-bind clears a crash leftover; bind
|
||||
// failures throw to the caller, who degrades gracefully), Dispose cancels the accept loop, closes the listener and
|
||||
// unlinks the socket file — a hot-reload (Unload then Load in-process) can then re-bind the same path.
|
||||
internal sealed class UdsServer : IDisposable
|
||||
{
|
||||
private const int MaxRequestBytes = 256; // a verb line; anything bigger is not our client
|
||||
private static readonly TimeSpan IoDeadline = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Socket _listener;
|
||||
private readonly string _path;
|
||||
private readonly Func<string, Task<byte[]>> _handle;
|
||||
private readonly Action<Exception> _onError;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
internal UdsServer(string path, Func<string, Task<byte[]>> handle, Action<Exception> onError)
|
||||
{
|
||||
_path = path;
|
||||
_handle = handle;
|
||||
_onError = onError;
|
||||
File.Delete(path); // a crash leaves the old socket file behind, and bind fails on an existing path
|
||||
_listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
|
||||
_listener.Bind(new UnixDomainSocketEndPoint(path));
|
||||
_listener.Listen(16);
|
||||
// 0660: owner (the game user) + group (shared with the site user) — never world-accessible. Unix-only API,
|
||||
// guarded so a Windows dev build compiles clean (there the socket just keeps default ACLs).
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite);
|
||||
_ = Task.Run(AcceptLoop);
|
||||
}
|
||||
|
||||
private async Task AcceptLoop()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
Socket conn;
|
||||
try { conn = await _listener.AcceptAsync(_cts.Token); }
|
||||
catch (OperationCanceledException) { return; } // Dispose
|
||||
catch (ObjectDisposedException) { return; } // Dispose raced the accept
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A persistent accept fault (fd exhaustion) would otherwise re-throw instantly forever — one retry per
|
||||
// second turns a pegged core on the game host into a single syscall/s until the fault clears.
|
||||
_onError(ex);
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(1), _cts.Token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
continue;
|
||||
}
|
||||
_ = Task.Run(() => Serve(conn));
|
||||
}
|
||||
}
|
||||
|
||||
// One connection = one verb line -> one payload. Deadlined so a stuck client can never pin resources; errors are
|
||||
// reported (throttled by the caller) and the connection just drops — a game server must never care.
|
||||
private async Task Serve(Socket conn)
|
||||
{
|
||||
using (conn)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Inside the try: _cts.Token throws ObjectDisposedException when Dispose() wins the race against a
|
||||
// queued Serve task (hot-reload while a client connects) — treated like the AcceptLoop's same race.
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
|
||||
deadline.CancelAfter(IoDeadline);
|
||||
var buf = new byte[MaxRequestBytes];
|
||||
int len = 0;
|
||||
while (len < MaxRequestBytes)
|
||||
{
|
||||
int n = await conn.ReceiveAsync(buf.AsMemory(len, MaxRequestBytes - len), SocketFlags.None, deadline.Token);
|
||||
if (n == 0) break; // peer closed without a newline
|
||||
len += n;
|
||||
if (Array.IndexOf(buf, (byte)'\n', 0, len) >= 0) break;
|
||||
}
|
||||
int nl = Array.IndexOf(buf, (byte)'\n', 0, len);
|
||||
if (nl < 0) return; // no complete verb line within the cap — not our client, drop silently
|
||||
|
||||
byte[] payload = await _handle(Encoding.ASCII.GetString(buf, 0, nl).Trim());
|
||||
|
||||
int sent = 0;
|
||||
while (sent < payload.Length)
|
||||
{
|
||||
int n = await conn.SendAsync(payload.AsMemory(sent), SocketFlags.None, deadline.Token);
|
||||
if (n <= 0) break;
|
||||
sent += n;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { /* deadline hit or server shutting down — drop the connection */ }
|
||||
catch (ObjectDisposedException) { /* Dispose raced a queued connection — drop it */ }
|
||||
catch (Exception ex) { _onError(ex); }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Dispose(); } catch (Exception) { /* already closed */ }
|
||||
try { File.Delete(_path); } catch (Exception) { /* unlink is best-effort; delete-before-bind covers leftovers */ }
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
81
Outnumbered/Engine/WorldText.cs
Normal file
81
Outnumbered/Engine/WorldText.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
using System.Drawing;
|
||||
using CounterStrikeSharp.API;
|
||||
using CounterStrikeSharp.API.Core;
|
||||
using CounterStrikeSharp.API.Modules.Utils;
|
||||
|
||||
namespace Outnumbered.Engine;
|
||||
|
||||
// The one place a point_worldtext entity is created + configured (HUD panel, the 2 shop panels, the draft cards all
|
||||
// share it). CreateEntityByName returns a NON-null wrapper even on failure (e.g. the entity limit) with a Zero Handle,
|
||||
// so the guard checks the raw handle (no memory deref) before touching schema members. Callers supply the per-use
|
||||
// font/justify/colour/border and own the last-text caching; the volatile "SetMessage" input + the Teleport placement
|
||||
// go through SetText/Place so every engine touchpoint for this entity lives here.
|
||||
internal static class WorldText
|
||||
{
|
||||
public static CPointWorldText? Create(float fontSize, float worldUnitsPerPx, string fontName, Color color,
|
||||
bool drawBackground, float border, PointWorldTextJustifyHorizontal_t justify)
|
||||
{
|
||||
var e = Utilities.CreateEntityByName<CPointWorldText>(EngineNames.PointWorldText);
|
||||
if (e is null || e.Handle == nint.Zero) return null;
|
||||
e.MessageText = " ";
|
||||
e.Enabled = true;
|
||||
e.Fullbright = true;
|
||||
e.FontSize = fontSize;
|
||||
e.WorldUnitsPerPx = worldUnitsPerPx;
|
||||
if (!string.IsNullOrEmpty(fontName)) e.FontName = fontName;
|
||||
e.Color = color;
|
||||
e.JustifyHorizontal = justify;
|
||||
e.JustifyVertical = PointWorldTextJustifyVertical_t.POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER;
|
||||
e.ReorientMode = PointWorldTextReorientMode_t.POINT_WORLD_TEXT_REORIENT_NONE;
|
||||
e.DrawBackground = drawBackground;
|
||||
e.BackgroundBorderHeight = border;
|
||||
e.BackgroundBorderWidth = border;
|
||||
e.DispatchSpawn();
|
||||
return e;
|
||||
}
|
||||
|
||||
// Push new text via the point_worldtext "SetMessage" input (the volatile input-name literal lives here, not at the 4
|
||||
// call sites). Callers keep their own last-text cache and only call this on a change.
|
||||
public static void SetText(CPointWorldText ent, string text) => ent.AcceptInput("SetMessage", ent, ent, text);
|
||||
|
||||
// Position + orient a panel. point_worldtext is moved via Teleport (no velocity); callers compute the eye-relative frame.
|
||||
public static void Place(CPointWorldText ent, Vector pos, QAngle ang) => ent.Teleport(pos, ang, null);
|
||||
|
||||
// Tear a panel down: Remove the entity if it's still live, then null the caller's reference. Symmetric with Create —
|
||||
// every point_worldtext create + destroy goes through this file.
|
||||
public static void Destroy(ref CPointWorldText? ent)
|
||||
{
|
||||
if (ent is { IsValid: true }) ent.Remove();
|
||||
ent = null;
|
||||
}
|
||||
|
||||
public const float EyeZFallback = 64f; // ViewOffset.Z fallback when the pawn doesn't report one
|
||||
|
||||
// The eye-relative frame for placing a panel in front of a pawn's view: eye position + forward/right/up basis + the
|
||||
// panel orientation (yaw+270 / 90-pitch = worldtext faces the player, upright across pitch/yaw). false if AbsOrigin is
|
||||
// null. Callers add only their own per-panel offsets. Shared by the HUD + the shop/draft panels.
|
||||
public static bool TryEyeFrame(CCSPlayerPawn pawn, out Vector eye, out Vector fwd, out Vector right, out Vector up, out QAngle ang)
|
||||
{
|
||||
eye = null!; fwd = null!; right = null!; up = null!; ang = null!;
|
||||
var origin = pawn.AbsOrigin;
|
||||
if (origin is null) return false;
|
||||
var ea = pawn.EyeAngles;
|
||||
float eyeZ = pawn.ViewOffset?.Z ?? EyeZFallback;
|
||||
(fwd, right, up) = AngleVectors(ea);
|
||||
eye = new Vector(origin.X, origin.Y, origin.Z + eyeZ);
|
||||
ang = new QAngle(0f, ea.Y + 270f, 90f - ea.X);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Source-engine AngleVectors with roll assumed 0 (HUD/shop panels never roll).
|
||||
private static (Vector forward, Vector right, Vector up) AngleVectors(QAngle a)
|
||||
{
|
||||
const double d2r = Math.PI / 180.0;
|
||||
double p = a.X * d2r, y = a.Y * d2r;
|
||||
double sp = Math.Sin(p), cp = Math.Cos(p), sy = Math.Sin(y), cy = Math.Cos(y);
|
||||
return (
|
||||
new Vector((float)(cp * cy), (float)(cp * sy), (float)(-sp)),
|
||||
new Vector((float)sy, (float)(-cy), 0f),
|
||||
new Vector((float)(sp * cy), (float)(sp * sy), (float)cp));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue