diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c76c54eb..3ab77dc2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ * 架构分析器守卫三项收口(C429/C430,GFX-1170,GitHub #916):GFX0016 候选集从仅 Class 放宽为全部命名类型种类——struct/interface/record 声明面引用 `MongoDbService`/`PostgreSqlDbService`/`MultiDbRegistry` 不再漏报(自行注册 NamedType 符号分析,实现层白名单与豁免不变);GFX0001 补抽象类豁免(与 GFX0005/0008/0009 一致,非 Apps 程序集的抽象中间状态类不再被误拦);生成 Wrapper 豁免改为「`GeneratedCodeAttribute` 标记(tool 名精确匹配)+ `.g.cs` 文件路径 + 仅 Hotfix 内名称后缀兜底」三段判定,Hotfix 之外手写 `ComponentAgentWrapper` 后缀类不再同时逃过 GFX0005/GFX0014;WrapperGenerator 生成类头追加 `GeneratedCodeAttribute` 标记(与分析器 `WrapperGeneratorToolName` 同值契约)。 * 入队与定时撮合解耦(C406,GFX-1104):删除每次入队的全量撮合及最新票据扫描,复用 `matchmaker-run` 按宿主调度节奏批量处理队列(默认 1000ms)。单轮撮合算法保持不变;桥测试覆盖四名玩家单批次形成两组及批次前取消不参与分配。 -* Hotfix Wrapper 生成器诊断体验、增量管道与低风险清理(C432,GFX-1172,GitHub #918,CODE_REVIEW CR-M06/M07/L03/L04/L05):①诊断改为静态描述符 + 稳定 ID `GFW0001`(契约违规 Error)/ `GFW0002`(契约警示 Warning),携带违规方法 `GetLocation()` 位置(错误列表可点击定位、可经 `.editorconfig` `dotnet_diagnostic_GFW000x.severity` 按 ID 抑制),消息统一引用 `Service`/`ThreadSafe`/`Discard`/`TimeOut` 真实特性常量(消灭不存在的【Api】/【AsyncApi】幻觉名);②增量管道按「代理类声明 → 输出」粒度注册(`WithComparer` 文本等值缓存 + `WithTrackingName`),替代 `Collect()` 全量折叠——单个代理类编辑只重算该代理 wrapper(生产 25 个代理全量生成产物逐字符不变,非 partial hintName 逐字节不变),partial 部件 hintName 序号改声明位置稳定键(FNV-1a 64,跨进程确定);语义调整:无基类列表的 partial 类不再按简单名并入生成面(生产零实例,需要拆分的代理应每部件自带相同基类列表);③`AgentTemplate` 删 `[ThreadStatic]` 静态 `StringBuilder`(局部实例参数化传递,消除矛盾注释);④`ComponentAgentBaseNames` 删除全仓不存在的 `QueryComponentAgent` 死条目。 +* Hotfix Wrapper 生成器诊断体验、增量管道与低风险清理(C432,GFX-1172,GitHub #918,CODE_REVIEW CR-M06/M07/L03/L04/L05):①诊断改为静态描述符 + 稳定 ID `GFW0001`(契约违规 Error)/ `GFW0002`(契约警示 Warning),携带违规方法 `GetLocation()` 位置(错误列表可点击定位、可经 `.editorconfig` `dotnet_diagnostic_GFW000x.severity` 按 ID 抑制),消息统一引用 `Service`/`ThreadSafe`/`Discard`/`TimeOut` 真实特性常量(消灭不存在的【Api】/【AsyncApi】幻觉名);②增量管道按「代理类声明 → 输出」粒度注册(`WithComparer` 文本等值缓存 + `WithTrackingName`),替代 `Collect()` 全量折叠——单个代理类编辑只重算该代理 wrapper(生产 25 个代理全量生成产物逐字符不变,非 partial hintName 逐字节不变),partial 部件 hintName 序号改声明位置稳定键(FNV-1a 64,跨进程确定);语义调整:无基类列表的 partial 类不再按简单名并入生成面(生产零实例,需要拆分的代理应每部件自带相同基类列表);③`AgentTemplate` 删 `[ThreadStatic]` 静态 `StringBuilder`(局部实例参数化传递,消除矛盾注释);④`ComponentAgentBaseNames` 删除全仓不存在的 `QueryComponentAgent` 死条目。与 C430 合并调停:`GeneratedCodeAttribute` 标记仅由非 partial 单体声明发射(逐声明独立缓存无法稳定选举唯一携带者,partial 分部一律省略标记以杜绝 CS0579,分析器豁免由 `.g.cs` 路径段与名称后缀兜底),标记常量移至 `HotfixWrapperGenerator`(`AgentTemplate` 保持零静态字段契约)。 * 撮合通知异步执行与停用退订(C407,GFX-1105,GitHub #733):`ICapabilityEventBus.Subscribe` 返回幂等 `IDisposable` 退订句柄(void → IDisposable 为二进制破坏性变更——包族消费方需重新编译;源码侧忽略返回值的既有调用不受影响);online-match 模块撮合分配事件回调改为纯入队(移除同步总线回调内 `ResolveAssignmentAsync` / `session.WriteAsync` 两处 `GetAwaiter().GetResult()` 死锁面),新增 `match-assignment-notification-tick` 每轮有限批次(64)异步解析并推送(条目级失败不阻断同批其余条目,异常交调度器既有日志路径);`DeactivateAsync` 停用收口——关闭入队、退订、清空待处理并等待在途批次完成,热更/停机后旧模块零通知。回归 13 例:总线退订契约 5 + 模块生命周期 8(含对旧同步实现确定性红的发布不同步解析 / 停用退订 / 停用清空 / 容错批次)。 * 举报入口频控与未结案去重(C405,GFX-1115):举报提交接入双固定窗口限流(`OnlineSocialOptions.Report.MaximumSubmissionsPerMinute=5` / `MaximumSubmissionsPerDay=50`,键=租户/App/举报人,超限 7001 且不落案件不发事件,分钟窗惰性清扫仅删双窗均过期的桶);`IOnlineReportStore.SaveIfAbsentAsync` 升级为两层原子唯一(案件标识重放 + 未结案组合键「举报人+被举报人+场景」,Submitted/Reviewing 占位、终态释放),重复举报返回原案件原证据且仅首建发 `ReportChanged`;`ReplaceAsync` 身份三要素不可改、终态释放与新占位归属在 CAS 同锁维护;`OnlineSocialDecisionService` 构造新增必填 `OnlineReportOptions`,宿主自定义举报存储必须遵循同一原子唯一性语义 diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs index fe61f2d23..80c6e5b19 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/AgentTemplate.cs @@ -39,23 +39,6 @@ namespace GameFrameX.Hotfix.WrapperGenerator.Agent; /// public static class AgentTemplate { - /// - /// 生成器标识:写入生成类 GeneratedCodeAttribute 的 tool 名。 - /// - /// - /// 架构分析器按此值精确匹配豁免生成产物(与 Architecture.Analyzers 侧 - /// ArchitectureAnalyzerConstants.WrapperGeneratorToolName 为同值契约,改名须双侧同步)。 - /// The tool name written into the GeneratedCodeAttribute on generated wrapper classes. - /// - private const string GeneratedCodeToolName = "GameFrameX.Hotfix.WrapperGenerator"; - - /// - /// 生成器版本:写入生成类 GeneratedCodeAttribute 的 version 参数。 - /// - /// - /// The version argument written into the GeneratedCodeAttribute on generated wrapper classes. - /// - private const string GeneratedCodeVersion = "1.0"; /// /// 根据代理信息生成包装器分部类的源代码。 /// @@ -81,7 +64,7 @@ public static string Run(AgentInfo info) templateStringBuilder.AppendLine("{"); if (info.EmitGeneratedCodeMarker) { - templateStringBuilder.AppendLine("\t[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"" + GeneratedCodeToolName + "\", \"" + GeneratedCodeVersion + "\")]"); + templateStringBuilder.AppendLine("\t[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"" + HotfixWrapperGenerator.GeneratedCodeToolName + "\", \"" + HotfixWrapperGenerator.GeneratedCodeVersion + "\")]"); } templateStringBuilder.AppendLine("\tpublic partial class " + info.Name + " : " + info.Super); templateStringBuilder.AppendLine("\t{"); diff --git a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs index 8ca69006a..580bbb94b 100644 --- a/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs +++ b/GameFrameX.Hotfix.WrapperGenerator/Agent/HotfixWrapperGenerator.cs @@ -104,6 +104,25 @@ public class HotfixWrapperGenerator : IIncrementalGenerator /// private const string TimeOutAttributeName = "TimeOut"; + /// + /// 生成器标识:写入生成类 GeneratedCodeAttribute 的 tool 名。 + /// + /// + /// 架构分析器按此值精确匹配豁免生成产物(与 Architecture.Analyzers 侧 + /// ArchitectureAnalyzerConstants.WrapperGeneratorToolName 为同值契约,改名须双侧同步)。 + /// 放在生成器而非 AgentTemplate:模板类契约「无任何静态字段」(见 HotfixWrapperGeneratorDiagnosticReportingTests)。 + /// The tool name written into the GeneratedCodeAttribute on generated wrapper classes. + /// + internal const string GeneratedCodeToolName = "GameFrameX.Hotfix.WrapperGenerator"; + + /// + /// 生成器版本:写入生成类 GeneratedCodeAttribute 的 version 参数。 + /// + /// + /// The version argument written into the GeneratedCodeAttribute on generated wrapper classes. + /// + internal const string GeneratedCodeVersion = "1.0"; + /// /// 增量管线输出步骤的跟踪名:测试与诊断经 TrackedSteps[该名] 断言逐声明的缓存/重算行为。 /// diff --git a/GameFrameX.Localization/Keys.Utility.cs b/GameFrameX.Localization/Keys.Utility.cs index d9ce57897..cba0d8444 100644 --- a/GameFrameX.Localization/Keys.Utility.cs +++ b/GameFrameX.Localization/Keys.Utility.cs @@ -386,6 +386,56 @@ public static class RandomHelper /// 参数: {0} - 需求的数量, {1} - 数组长度 /// public const string CantRepeatRandomArgError = "Utility.RandomHelper.CantRepeatRandomArgError"; + + /// + /// 权重为负数 + /// + /// + /// 键名: Utility.RandomHelper.NegativeWeight + /// 用途: 权重随机入参包含负权重时抛出异常 + /// 参数: {0} - 非法权重值 + /// + public const string NegativeWeight = "Utility.RandomHelper.NegativeWeight"; + + /// + /// 全部权重为 0 + /// + /// + /// 键名: Utility.RandomHelper.ZeroTotalWeight + /// 用途: 参与随机的权重总和为 0(无可选目标)时抛出异常 + /// 参数: 无 + /// + public const string ZeroTotalWeight = "Utility.RandomHelper.ZeroTotalWeight"; + + /// + /// 权重总和超出 int 上限 + /// + /// + /// 键名: Utility.RandomHelper.WeightSumOverflow + /// 用途: 权重求和超出随机源可表达的 32 位范围时抛出异常 + /// 参数: {0} - 实际总权重 + /// + public const string WeightSumOverflow = "Utility.RandomHelper.WeightSumOverflow"; + + /// + /// 权重索引越界 + /// + /// + /// 键名: Utility.RandomHelper.WeightIndexOutOfRange + /// 用途: weightIndex 超出权重条目长度时抛出异常 + /// 参数: {0} - 权重索引, {1} - 条目长度 + /// + public const string WeightIndexOutOfRange = "Utility.RandomHelper.WeightIndexOutOfRange"; + + /// + /// 不可重复抽样时正权重条目不足 + /// + /// + /// 键名: Utility.RandomHelper.InsufficientPositiveWeights + /// 用途: 不可重复抽取的目标数大于权重为正的条目数时抛出异常 + /// 参数: {0} - 需求数量, {1} - 正权重条目数 + /// + public const string InsufficientPositiveWeights = "Utility.RandomHelper.InsufficientPositiveWeights"; } diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index dd45e1d34..3a8a65feb 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -646,6 +646,26 @@ Cannot repeat random argument error, requested count: {0} is greater than ID count: {1} + + Weight must not be negative, actual weight: {0} + + + + Total weight must be greater than zero + + + + Total weight {0} exceeds the supported 32-bit range + + + + Weight index {0} is out of range for an entry with {1} element(s) + + + + Cannot pick {0} distinct weighted items, only {1} item(s) have a positive weight + + a = {0} diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index 4e85df89e..984828b44 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -667,6 +667,26 @@ 不能重复随机参数错误,需求数量:{0} 大于ID数量:{1} + + 权重不能为负数,实际权重:{0} + + + + 权重总和必须大于 0 + + + + 权重总和 {0} 超出支持的 32 位范围 + + + + 权重索引 {0} 超出条目长度 {1} + + + + 无法不重复抽取 {0} 个权重目标,只有 {1} 个条目的权重为正 + + a = {0} diff --git a/GameFrameX.Utility/AssemblyHelper.cs b/GameFrameX.Utility/AssemblyHelper.cs index 5d21ca107..180883e76 100644 --- a/GameFrameX.Utility/AssemblyHelper.cs +++ b/GameFrameX.Utility/AssemblyHelper.cs @@ -80,12 +80,19 @@ namespace GameFrameX.Utility; /// public static class AssemblyHelper { + /// + /// 类型缓存的最大条目数;达到上限后只返回解析结果、不再写入缓存。 + /// + private const int MaxCachedTypeCount = 1024; + /// /// Type cache dictionary using type full name as key, thread-safe - /// Uses StringComparer.Ordinal for optimal string comparison performance + /// Only successful resolutions are cached: a miss is never cached so that types + /// from assemblies loaded later (hotfix) can still be found. /// /// /// 类型缓存字典,使用类型全名作为键,线程安全 + /// 仅缓存命中结果:未命中不缓存,热更程序集后续加载后仍可解析 /// 使用 StringComparer.Ordinal 提供最佳性能的字符串比较 /// private static readonly ConcurrentDictionary CachedTypes = new(StringComparer.Ordinal); @@ -219,6 +226,9 @@ public static void GetTypes(List results) /// /// 获取已加载的程序集中的指定类型 /// 支持完全限定名和简单类型名,使用缓存机制提高查找性能 + /// 仅缓存命中结果,未命中(miss)不缓存,热更程序集后续加载后仍可解析。 + /// 安全约束:类型名必须是受信任的配置/代码常量;含逗号的程序集限定名会触发程序集加载, + /// 不得直接使用外部/玩家输入。 /// /// Type name to get, supports fully qualified names (e.g., "System.String") / 要获取的类型名,支持完全限定名(如 "System.String") /// Specified type from loaded assemblies, returns null if not found / 已加载的程序集中的指定类型,如果未找到则返回 null @@ -227,40 +237,65 @@ public static Type GetType(string typeName) { ArgumentException.ThrowIfNullOrWhiteSpace(typeName); - return CachedTypes.GetOrAdd(typeName, name => + if (CachedTypes.TryGetValue(typeName, out var cachedType)) { - // 首先尝试直接获取类型(适用于系统类型和当前程序集中的类型) - var type = Type.GetType(name); - if (type != null) - { - return type; - } + return cachedType; + } + + var resolvedType = ResolveType(typeName); + if (resolvedType == null) + { + // 未命中不入缓存:热更程序集可能在后续运行期加载,miss 缓存会给出永久错误答案。 + return null; + } + + if (CachedTypes.Count < MaxCachedTypeCount) + { + CachedTypes.TryAdd(typeName, resolvedType); + } + + return resolvedType; + } + + /// + /// Resolve a type by name from the loaded assemblies without touching the cache. + /// + /// + /// 不经过缓存解析类型:先按直接名称,再遍历已加载程序集。 + /// + /// Type name to resolve / 要解析的类型名 + /// Resolved type, or null if not found / 解析到的类型,未找到返回 null + private static Type ResolveType(string typeName) + { + // 首先尝试直接获取类型(适用于系统类型和当前程序集中的类型) + var type = Type.GetType(typeName); + if (type != null) + { + return type; + } - // 如果直接获取失败,遍历所有程序集查找类型 - // 这种方式适用于跨程序集的类型查找 - var assemblies = GetCurrentAssemblies(); - foreach (var assembly in assemblies) + // 如果直接获取失败,遍历所有程序集查找类型 + // 这种方式适用于跨程序集的类型查找 + var assemblies = GetCurrentAssemblies(); + foreach (var assembly in assemblies) + { + try { - try - { - // 使用程序集限定名称查找类型 - type = Type.GetType($"{name}, {assembly.FullName}"); - if (type != null) - { - return type; - } - } - catch (Exception) + // 使用程序集限定名称查找类型 + type = Type.GetType($"{typeName}, {assembly.FullName}"); + if (type != null) { - // 忽略异常(如类型不存在、安全异常等) - // 继续查找下一个程序集 - continue; + return type; } } + catch (Exception) + { + // 忽略异常(如类型不存在、安全异常等) + // 继续查找下一个程序集 + } + } - // 如果在所有程序集中都找不到该类型,返回 null - return null; - }); + return null; } /// diff --git a/GameFrameX.Utility/GameFrameX.Utility.csproj b/GameFrameX.Utility/GameFrameX.Utility.csproj index db2e606f8..3fcaf2b66 100644 --- a/GameFrameX.Utility/GameFrameX.Utility.csproj +++ b/GameFrameX.Utility/GameFrameX.Utility.csproj @@ -14,6 +14,15 @@ TRACE;FP + + + + + diff --git a/GameFrameX.Utility/LNumber.cs b/GameFrameX.Utility/LNumber.cs index b968fa3a5..f7016982d 100644 --- a/GameFrameX.Utility/LNumber.cs +++ b/GameFrameX.Utility/LNumber.cs @@ -304,7 +304,8 @@ public string ToString(string str) if (c > long.MinValue && c < long.MaxValue) { - r.Raw = long.Parse(c.ToString()); //未越界 + // 上方范围检查已保证 c 在 long 范围内;LongValue 直接取低 64 位补码,零分配。 + r.Raw = c.LongValue(); //未越界 } else if ((lhs > 0 && rhs > 0) || (lhs < 0 && rhs < 0)) { @@ -362,7 +363,8 @@ public string ToString(string str) if (c > long.MinValue && c < long.MaxValue) { - r.Raw = long.Parse(c.ToString()); //未越界 + // 上方范围检查已保证 c 在 long 范围内;LongValue 直接取低 64 位补码,零分配。 + r.Raw = c.LongValue(); //未越界 } else if ((lhs > 0 && rhs > 0) || (lhs < 0 && rhs < 0)) { diff --git a/GameFrameX.Utility/Math/FP.Float.cs b/GameFrameX.Utility/Math/FP.Float.cs index edce51c57..7513ea7a4 100644 --- a/GameFrameX.Utility/Math/FP.Float.cs +++ b/GameFrameX.Utility/Math/FP.Float.cs @@ -27,6 +27,14 @@ // Official Documentation: https://gameframex.doc.alianblank.com/ // ========================================================================================== +// 警告:本文件是浮点版 FP 实现,整文件由 #if !FP 包裹,且已在 GameFrameX.Utility.csproj 中 +// 通过 显式排除——本仓(Debug/Release 均定义 FP) +// 永不编译本文件,在此处的修改不会进入 GameFrameX.Utility 程序集。 +// 该实现供客户端共享工程(不定义 FP)使用;修改必须同步客户端仓并接受客户端编译验证。 +// WARNING: Float-based FP implementation. Wrapped in #if !FP and excluded from +// GameFrameX.Utility.csproj; never compiled in this repository. Shared with +// client-side projects that do not define FP - keep changes in sync there. + #if !FP using System; using System.IO; diff --git a/GameFrameX.Utility/Math/FP.cs b/GameFrameX.Utility/Math/FP.cs index f03719dd7..2e67c88dc 100644 --- a/GameFrameX.Utility/Math/FP.cs +++ b/GameFrameX.Utility/Math/FP.cs @@ -920,7 +920,7 @@ public static FP Sqrt(FP x) { // 我们无法像 Single 和 Double 一样表示无穷大,且 Sqrt 在 x < 0 时 // 数学上是未定义的。因此我们抛出异常。 - throw new ArgumentOutOfRangeException("x", LocalizationService.GetString(Keys.Utility.ExceptionMessages.SqrtValueNegative)); + throw new ArgumentOutOfRangeException(nameof(x), LocalizationService.GetString(Keys.Utility.ExceptionMessages.SqrtValueNegative)); } var num = (ulong)xl; @@ -1005,10 +1005,20 @@ public static FP Sin(FP x) // 这就是该函数在 x86 上性能下降的原因 - x64 是可以的 var rawIndex = FastMul(clamped, LutInterval); var roundedIndex = Round(rawIndex); - var indexError = 0; + // 与 Tan 同构:indexError ∈ [-0.5, 0.5](量化前),用于线性插值。 + // SinLut 长度 == LUT_SIZE;ClampSinValue 保证 clamped ≤ PI_OVER_2 - 1, + // 故 rawIndex ≤ LUT_SIZE - 1 - ε,顶格时 indexError 必为负。 + // 第二个下标仍显式夹取,避免未来改 LUT_SIZE / Round / LutInterval 后越界。 + var indexError = FastSub(rawIndex, roundedIndex); + + var nearestIndex = System.Math.Clamp(flipHorizontal ? SinLut.Length - 1 - (int)roundedIndex : (int)roundedIndex, 0, SinLut.Length - 1); + var secondNearestIndex = flipHorizontal + ? SinLut.Length - 1 - (int)roundedIndex - Sign(indexError) + : (int)roundedIndex + Sign(indexError); + secondNearestIndex = System.Math.Clamp(secondNearestIndex, 0, SinLut.Length - 1); - var nearestValue = new FP(SinLut[flipHorizontal ? SinLut.Length - 1 - (int)roundedIndex : (int)roundedIndex]); - var secondNearestValue = new FP(SinLut[flipHorizontal ? SinLut.Length - 1 - (int)roundedIndex - Sign(indexError) : (int)roundedIndex + Sign(indexError)]); + var nearestValue = new FP(SinLut[nearestIndex]); + var secondNearestValue = new FP(SinLut[secondNearestIndex]); var delta = FastMul(indexError, FastAbs(FastSub(nearestValue, secondNearestValue)))._serializedValue; var interpolatedValue = nearestValue._serializedValue + (flipHorizontal ? -delta : delta); @@ -1280,7 +1290,7 @@ public static FP Acos(FP x) { if (x < -One || x > One) { - throw new ArgumentOutOfRangeException("x", LocalizationService.GetString(Keys.Utility.ExceptionMessages.ValueOutOfRange)); + throw new ArgumentOutOfRangeException(nameof(x), LocalizationService.GetString(Keys.Utility.ExceptionMessages.ValueOutOfRange)); } if (x.RawValue == 0) diff --git a/GameFrameX.Utility/Math/FPMatrix.cs b/GameFrameX.Utility/Math/FPMatrix.cs index 755341f29..a637553de 100644 --- a/GameFrameX.Utility/Math/FPMatrix.cs +++ b/GameFrameX.Utility/Math/FPMatrix.cs @@ -399,14 +399,12 @@ public static void Add(ref FPMatrix matrix1, ref FPMatrix matrix2, out FPMatrix /// 计算给定矩阵的逆矩阵。 /// /// 要计算逆的矩阵。 - /// 逆矩阵。 - - #region public static JMatrix Inverse(JMatrix matrix) - + /// 逆矩阵;矩阵奇异(行列式为 0)时返回全部元素为 的哨兵矩阵。 + [Obsolete("请使用 Invert。Inverse 的 1024 缩放会损失 10 位精度;本方法现委托 Invert,下个主版本将移除。")] public static FPMatrix Inverse(FPMatrix matrix) { FPMatrix result; - Inverse(ref matrix, out result); + Invert(ref matrix, out result); return result; } @@ -424,10 +422,27 @@ public FP Determinant() /// 计算给定矩阵的逆矩阵。 /// /// 要计算逆的矩阵。 - /// 逆矩阵。 + /// 逆矩阵;矩阵奇异(行列式为 0)时填 哨兵。 public static void Invert(ref FPMatrix matrix, out FPMatrix result) { - var determinantInverse = 1 / matrix.Determinant(); + var determinant = matrix.Determinant(); + if (determinant == FP.Zero) + { + // 定点数没有 NaN/Inf 体系;与 FPMatrix4x4.Inverse 口径一致, + // 奇异矩阵输出全 PositiveInfinity 哨兵,避免 1/0 静默产出垃圾值。 + result.M11 = FP.PositiveInfinity; + result.M12 = FP.PositiveInfinity; + result.M13 = FP.PositiveInfinity; + result.M21 = FP.PositiveInfinity; + result.M22 = FP.PositiveInfinity; + result.M23 = FP.PositiveInfinity; + result.M31 = FP.PositiveInfinity; + result.M32 = FP.PositiveInfinity; + result.M33 = FP.PositiveInfinity; + return; + } + + var determinantInverse = FP.One / determinant; var m11 = (matrix.M22 * matrix.M33 - matrix.M23 * matrix.M32) * determinantInverse; var m12 = (matrix.M13 * matrix.M32 - matrix.M33 * matrix.M12) * determinantInverse; var m13 = (matrix.M12 * matrix.M23 - matrix.M22 * matrix.M13) * determinantInverse; @@ -458,55 +473,12 @@ public static void Invert(ref FPMatrix matrix, out FPMatrix result) /// /// The matrix to invert. /// The inverted JMatrix. + [Obsolete("请使用 Invert。Inverse 的 1024 缩放会损失 10 位精度;本方法现委托 Invert,下个主版本将移除。")] public static void Inverse(ref FPMatrix matrix, out FPMatrix result) { - var det = 1024 * matrix.M11 * matrix.M22 * matrix.M33 - - 1024 * matrix.M11 * matrix.M23 * matrix.M32 - - 1024 * matrix.M12 * matrix.M21 * matrix.M33 + - 1024 * matrix.M12 * matrix.M23 * matrix.M31 + - 1024 * matrix.M13 * matrix.M21 * matrix.M32 - - 1024 * matrix.M13 * matrix.M22 * matrix.M31; - - var num11 = 1024 * matrix.M22 * matrix.M33 - 1024 * matrix.M23 * matrix.M32; - var num12 = 1024 * matrix.M13 * matrix.M32 - 1024 * matrix.M12 * matrix.M33; - var num13 = 1024 * matrix.M12 * matrix.M23 - 1024 * matrix.M22 * matrix.M13; - - var num21 = 1024 * matrix.M23 * matrix.M31 - 1024 * matrix.M33 * matrix.M21; - var num22 = 1024 * matrix.M11 * matrix.M33 - 1024 * matrix.M31 * matrix.M13; - var num23 = 1024 * matrix.M13 * matrix.M21 - 1024 * matrix.M23 * matrix.M11; - - var num31 = 1024 * matrix.M21 * matrix.M32 - 1024 * matrix.M31 * matrix.M22; - var num32 = 1024 * matrix.M12 * matrix.M31 - 1024 * matrix.M32 * matrix.M11; - var num33 = 1024 * matrix.M11 * matrix.M22 - 1024 * matrix.M21 * matrix.M12; - - if (det == 0) - { - result.M11 = FP.PositiveInfinity; - result.M12 = FP.PositiveInfinity; - result.M13 = FP.PositiveInfinity; - result.M21 = FP.PositiveInfinity; - result.M22 = FP.PositiveInfinity; - result.M23 = FP.PositiveInfinity; - result.M31 = FP.PositiveInfinity; - result.M32 = FP.PositiveInfinity; - result.M33 = FP.PositiveInfinity; - } - else - { - result.M11 = num11 / det; - result.M12 = num12 / det; - result.M13 = num13 / det; - result.M21 = num21 / det; - result.M22 = num22 / det; - result.M23 = num23 / det; - result.M31 = num31 / det; - result.M32 = num32 / det; - result.M33 = num33 / det; - } + Invert(ref matrix, out result); } - #endregion - /// /// 将矩阵乘以一个缩放因子。 /// @@ -806,15 +778,20 @@ public override bool Equals(object obj) /// 当前矩阵的哈希代码。 public override int GetHashCode() { - return M11.GetHashCode() ^ - M12.GetHashCode() ^ - M13.GetHashCode() ^ - M21.GetHashCode() ^ - M22.GetHashCode() ^ - M23.GetHashCode() ^ - M31.GetHashCode() ^ - M32.GetHashCode() ^ - M33.GetHashCode(); + unchecked + { + // 顺序混合(乘 397)而非 XOR:分量置换不再同哈希。 + var hash = M11.RawValue.GetHashCode(); + hash = (hash * 397) ^ M12.RawValue.GetHashCode(); + hash = (hash * 397) ^ M13.RawValue.GetHashCode(); + hash = (hash * 397) ^ M21.RawValue.GetHashCode(); + hash = (hash * 397) ^ M22.RawValue.GetHashCode(); + hash = (hash * 397) ^ M23.RawValue.GetHashCode(); + hash = (hash * 397) ^ M31.RawValue.GetHashCode(); + hash = (hash * 397) ^ M32.RawValue.GetHashCode(); + hash = (hash * 397) ^ M33.RawValue.GetHashCode(); + return hash; + } } /// diff --git a/GameFrameX.Utility/Math/FPMatrix4x4.cs b/GameFrameX.Utility/Math/FPMatrix4x4.cs index 9f2750b1c..e7af51e53 100644 --- a/GameFrameX.Utility/Math/FPMatrix4x4.cs +++ b/GameFrameX.Utility/Math/FPMatrix4x4.cs @@ -830,22 +830,26 @@ public override bool Equals(object obj) /// 当前矩阵的哈希代码。 public override int GetHashCode() { - return M11.GetHashCode() ^ - M12.GetHashCode() ^ - M13.GetHashCode() ^ - M14.GetHashCode() ^ - M21.GetHashCode() ^ - M22.GetHashCode() ^ - M23.GetHashCode() ^ - M24.GetHashCode() ^ - M31.GetHashCode() ^ - M32.GetHashCode() ^ - M33.GetHashCode() ^ - M34.GetHashCode() ^ - M41.GetHashCode() ^ - M42.GetHashCode() ^ - M43.GetHashCode() ^ - M44.GetHashCode(); + unchecked + { + var hash = M11.RawValue.GetHashCode(); + hash = (hash * 397) ^ M12.RawValue.GetHashCode(); + hash = (hash * 397) ^ M13.RawValue.GetHashCode(); + hash = (hash * 397) ^ M14.RawValue.GetHashCode(); + hash = (hash * 397) ^ M21.RawValue.GetHashCode(); + hash = (hash * 397) ^ M22.RawValue.GetHashCode(); + hash = (hash * 397) ^ M23.RawValue.GetHashCode(); + hash = (hash * 397) ^ M24.RawValue.GetHashCode(); + hash = (hash * 397) ^ M31.RawValue.GetHashCode(); + hash = (hash * 397) ^ M32.RawValue.GetHashCode(); + hash = (hash * 397) ^ M33.RawValue.GetHashCode(); + hash = (hash * 397) ^ M34.RawValue.GetHashCode(); + hash = (hash * 397) ^ M41.RawValue.GetHashCode(); + hash = (hash * 397) ^ M42.RawValue.GetHashCode(); + hash = (hash * 397) ^ M43.RawValue.GetHashCode(); + hash = (hash * 397) ^ M44.RawValue.GetHashCode(); + return hash; + } } /// diff --git a/GameFrameX.Utility/Math/FPQuaternion.cs b/GameFrameX.Utility/Math/FPQuaternion.cs index bd47aef38..58370ed56 100644 --- a/GameFrameX.Utility/Math/FPQuaternion.cs +++ b/GameFrameX.Utility/Math/FPQuaternion.cs @@ -791,6 +791,13 @@ public override bool Equals(object obj) /// 当前四元数的哈希代码。 public override int GetHashCode() { - return x.GetHashCode() ^ y.GetHashCode() ^ z.GetHashCode() ^ w.GetHashCode(); + unchecked + { + var hash = x.RawValue.GetHashCode(); + hash = (hash * 397) ^ y.RawValue.GetHashCode(); + hash = (hash * 397) ^ z.RawValue.GetHashCode(); + hash = (hash * 397) ^ w.RawValue.GetHashCode(); + return hash; + } } } \ No newline at end of file diff --git a/GameFrameX.Utility/Math/FPRandom.cs b/GameFrameX.Utility/Math/FPRandom.cs index 185215397..7d0a93485 100644 --- a/GameFrameX.Utility/Math/FPRandom.cs +++ b/GameFrameX.Utility/Math/FPRandom.cs @@ -32,6 +32,13 @@ namespace GameFrameX.Utility.Math; /// /// 生成基于确定性方法的随机数。 /// +/// +/// 梅森旋转(MT19937)伪随机:确定性可复现,但**非密码学安全**, +/// 不得用于令牌、验证码、防作弊抽奖等安全决策;安全场景请使用 +/// System.Security.Cryptography.RandomNumberGenerator。 +/// 静态共享实例不保证线程安全(状态推进无锁);需要并发隔离时用 +/// 创建独立实例。 +/// public sealed class FPRandom { // 来源:http://www.codeproject.com/Articles/164087/Random-Number-Generation @@ -46,20 +53,33 @@ public sealed class FPRandom /// /// 使用种子 1 的 {@link TSRandom} 的静态实例。 /// + /// + /// 首次访问静态成员时懒初始化(种子 1,确定性序列),无需调用方先调用 。 + /// private static FPRandom instance; - private readonly uint[] mag01 = { 0x0U, MATRIX_A, }; // 变换矩阵 - private readonly uint[] mt = new uint[N]; // 梅森旋转状态向量 - private int mti = N + 1; // 当前状态向量索引 - /// - /// 初始化一个新的实例,使用当前时间的毫秒数作为种子。 + /// 静态成员使用的确定性实例;未初始化时按种子 1 创建(并发首次访问只发布一个实例)。 /// - private FPRandom() + private static FPRandom Instance { - init_genrand((uint)DateTime.UtcNow.Millisecond); + get + { + var current = Volatile.Read(ref instance); + if (current != null) + { + return current; + } + + var created = New(1); + return Interlocked.CompareExchange(ref instance, created, null) ?? created; + } } + private readonly uint[] mag01 = { 0x0U, MATRIX_A, }; // 变换矩阵 + private readonly uint[] mt = new uint[N]; // 梅森旋转状态向量 + private int mti = N + 1; // 当前状态向量索引 + /// /// 使用指定的种子初始化一个新的实例。 /// @@ -97,20 +117,37 @@ public static int MaxRandomInt /// public static FP value { - get { return instance.NextFP(); } + get { return Instance.NextFP(); } } /// /// 返回一个随机的 {@link TSVector},表示半径为 1 的球体内的一个点。 /// + /// + /// 单位立方体拒绝采样:三个分量独立取 [-1, 1],模长超 1 则重采, + /// 结果在球体内均匀分布(旧实现返回的是立方体内的点)。 + /// public static FPVector3 insideUnitSphere { - get { return new FPVector3(value, value, value); } + get + { + while (true) + { + var candidate = new FPVector3(value * 2 - FP.One, value * 2 - FP.One, value * 2 - FP.One); + if (candidate.magnitude <= FP.One) + { + return candidate; + } + } + } } + /// + /// 显式重置静态实例为种子 1 的新序列(测试用)。 + /// internal static void Init() { - instance = New(1); + Interlocked.Exchange(ref instance, New(1)); } /// @@ -143,7 +180,7 @@ public int Next() /// 生成的随机整数。 public static int CallNext() { - return instance.Next(); + return Instance.Next(); } /// @@ -151,7 +188,7 @@ public static int CallNext() /// /// 最小值。 /// 最大值。 - /// 生成的随机整数。 + /// 生成的随机整数。minValue == maxValue 时返回 minValue。 public int Next(int minValue, int maxValue) { if (minValue > maxValue) @@ -159,9 +196,28 @@ public int Next(int minValue, int maxValue) (maxValue, minValue) = (minValue, maxValue); } - var range = maxValue - minValue; + var range = (ulong)((long)maxValue - minValue); + if (range == 0) + { + return minValue; + } + + // 拒绝采样消除模偏:Next() ∈ [0, 2^31 - 1],只接受落在 range 整数倍内的样本。 + var drawCount = (ulong)MaxRandomInt + 1; + if (range < drawCount) + { + var limit = drawCount - drawCount % range; + int draw; + do + { + draw = Next(); + } while ((ulong)draw >= limit); + + return (int)(minValue + (long)((ulong)draw % range)); + } - return minValue + Next() % range; + // 跨度超过 31 位随机域(如 int.MinValue..int.MaxValue)时用 [0,1) 双精度缩放。 + return (int)(minValue + (long)(NextDouble() * range)); } /// @@ -191,7 +247,7 @@ public FP Next(float minValue, float maxValue) /// 生成的随机整数。 public static int Range(int minValue, int maxValue) { - return instance.Next(minValue, maxValue); + return Instance.Next(minValue, maxValue); } /// @@ -202,7 +258,7 @@ public static int Range(int minValue, int maxValue) /// 生成的随机 FP 值。 public static FP Range(float minValue, float maxValue) { - return instance.Next(minValue, maxValue); + return Instance.Next(minValue, maxValue); } /// diff --git a/GameFrameX.Utility/Math/FPVector2.cs b/GameFrameX.Utility/Math/FPVector2.cs index 6462eb898..7fe540a6a 100644 --- a/GameFrameX.Utility/Math/FPVector2.cs +++ b/GameFrameX.Utility/Math/FPVector2.cs @@ -497,7 +497,11 @@ public bool Equals(FPVector2 other) /// 当前 FPVector2 实例的哈希代码。 public override int GetHashCode() { - return (int)(x + y); + unchecked + { + // 分量和相同(如 (1,2)/(2,1)/(3,0))不再同桶:乘 397 顺序混合。 + return (x.RawValue.GetHashCode() * 397) ^ y.RawValue.GetHashCode(); + } } /// diff --git a/GameFrameX.Utility/Math/FPVector3.cs b/GameFrameX.Utility/Math/FPVector3.cs index 2d77d842e..d2e0fc800 100644 --- a/GameFrameX.Utility/Math/FPVector3.cs +++ b/GameFrameX.Utility/Math/FPVector3.cs @@ -688,7 +688,13 @@ public static void Cross(ref FPVector3 vector1, ref FPVector3 vector2, out FPVec /// 返回向量的哈希码。 public override int GetHashCode() { - return x.GetHashCode() ^ y.GetHashCode() ^ z.GetHashCode(); + unchecked + { + var hash = x.RawValue.GetHashCode(); + hash = (hash * 397) ^ y.RawValue.GetHashCode(); + hash = (hash * 397) ^ z.RawValue.GetHashCode(); + return hash; + } } /// diff --git a/GameFrameX.Utility/Math/FPVector4.cs b/GameFrameX.Utility/Math/FPVector4.cs index 5ad517e6e..d64660f0a 100644 --- a/GameFrameX.Utility/Math/FPVector4.cs +++ b/GameFrameX.Utility/Math/FPVector4.cs @@ -588,7 +588,14 @@ public static void Subtract(ref FPVector4 value1, ref FPVector4 value2, out FPVe /// 返回向量的哈希码。 public override int GetHashCode() { - return x.GetHashCode() ^ y.GetHashCode() ^ z.GetHashCode() ^ w.GetHashCode(); + unchecked + { + var hash = x.RawValue.GetHashCode(); + hash = (hash * 397) ^ y.RawValue.GetHashCode(); + hash = (hash * 397) ^ z.RawValue.GetHashCode(); + hash = (hash * 397) ^ w.RawValue.GetHashCode(); + return hash; + } } /// diff --git a/GameFrameX.Utility/NetHelper.cs b/GameFrameX.Utility/NetHelper.cs index 02d63f108..6a9ecec39 100644 --- a/GameFrameX.Utility/NetHelper.cs +++ b/GameFrameX.Utility/NetHelper.cs @@ -59,6 +59,16 @@ public static class NetHelper /// private const int DefaultDnsTimeoutMilliseconds = 5000; + /// + /// 端口诊断子进程(lsof / netstat)的退出等待上限(毫秒)。 + /// + private const int PortDiagnosticProcessTimeoutMilliseconds = 1500; + + /// + /// 强制结束诊断子进程后,等待 stdout/stderr 读取完成的有界预算(毫秒)。 + /// + private const int ProcessKillDrainTimeoutMilliseconds = 500; + /// /// 获取指定端口的占用进程信息(仅监听中的 TCP)。 /// @@ -85,6 +95,75 @@ public static List GetPortOccupyingProcesses(int port, int maxCount = 5) return new List(); } + /// + /// 启动外部进程并读取其标准输出,超时后强制结束进程。 + /// + /// + /// stdout 与 stderr 必须并发读取:先同步 ReadToEnd() 再 WaitForExit, + /// 在 RedirectStandardError 为 true 且子进程写满 stderr 管道(4-64KB)时, + /// 子进程阻塞在写 stderr、父进程阻塞在读 stdout,双方互等形成死锁。 + /// + /// 进程启动参数,必须重定向 stdout 与 stderr + /// 等待进程退出的上限(毫秒) + /// 标准输出文本;进程无法启动时返回空串 + private static string ReadProcessStandardOutput(ProcessStartInfo startInfo, int timeoutMilliseconds) + { + using (var process = Process.Start(startInfo)) + { + if (process == null) + { + return string.Empty; + } + + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + + if (process.WaitForExit(timeoutMilliseconds)) + { + Task.WhenAll(outputTask, errorTask).GetAwaiter().GetResult(); + return outputTask.Result; + } + + try + { + process.Kill(true); + } + catch (Exception) + { + // 进程已退出或无权结束:仍需按有界预算读取,绝不让调用线程无限阻塞。 + } + + // 强制结束后再给管道读取一个有界预算;kill 被权限/策略拒绝且管道未关闭时, + // 超时即返回(最多丢失诊断输出),保证 timeoutMilliseconds 的契约成立。 + try + { + if (Task.WaitAll(new Task[] { outputTask, errorTask }, ProcessKillDrainTimeoutMilliseconds)) + { + return outputTask.Result; + } + } + catch (Exception) + { + // 强制结束导致读取任务失败:退化为返回已成功读取的部分。 + } + + // 超时返回后 using 会释放 process,仍在进行的读取任务可能抛异常; + // 挂 OnlyOnFaulted 续体观察,避免 UnobservedTaskException 噪声。 + ObserveFault(outputTask); + ObserveFault(errorTask); + return outputTask.IsCompletedSuccessfully ? outputTask.Result : string.Empty; + } + } + + /// + /// 观察后台任务的异常,避免被回收时产生 。 + /// + /// 待观察任务 + private static void ObserveFault(Task task) + { + task.ContinueWith(static completed => _ = completed.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + /// /// 在 macOS/Linux 下通过 lsof 获取端口占用进程信息。 /// @@ -107,27 +186,18 @@ private static List GetPortOccupyingProcessesByLsof(int port, int maxCou CreateNoWindow = true, }; - using (var process = Process.Start(startInfo)) - { - if (process == null) - { - return new List(); - } + var output = ReadProcessStandardOutput(startInfo, PortDiagnosticProcessTimeoutMilliseconds); - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(1500); - - if (string.IsNullOrWhiteSpace(output)) - { - return new List(); - } - - return output - .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(line => !line.StartsWith("COMMAND", StringComparison.OrdinalIgnoreCase)) - .Take(maxCount) - .ToList(); + if (string.IsNullOrWhiteSpace(output)) + { + return new List(); } + + return output + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(line => !line.StartsWith("COMMAND", StringComparison.OrdinalIgnoreCase)) + .Take(maxCount) + .ToList(); } catch { @@ -155,45 +225,36 @@ private static List GetPortOccupyingProcessesByNetstat(int port, int max CreateNoWindow = true, }; - using (var process = Process.Start(startInfo)) + var output = ReadProcessStandardOutput(startInfo, PortDiagnosticProcessTimeoutMilliseconds); + if (string.IsNullOrWhiteSpace(output)) + { + return new List(); + } + + var result = new List(); + var deDuplicate = new HashSet(StringComparer.OrdinalIgnoreCase); + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var line in lines) { - if (process == null) + var item = TryBuildNetstatPortItem(line, port); + if (item == null) { - return new List(); + continue; } - var output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(1500); - if (string.IsNullOrWhiteSpace(output)) + if (!deDuplicate.Add(item)) { - return new List(); + continue; } - var result = new List(); - var deDuplicate = new HashSet(StringComparer.OrdinalIgnoreCase); - var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var line in lines) + result.Add(item); + if (result.Count >= maxCount) { - var item = TryBuildNetstatPortItem(line, port); - if (item == null) - { - continue; - } - - if (!deDuplicate.Add(item)) - { - continue; - } - - result.Add(item); - if (result.Count >= maxCount) - { - break; - } + break; } - - return result; } + + return result; } catch { @@ -364,6 +425,11 @@ public static bool IsValidIpAddress(string ipAddress, out IPAddress value) /// /// 获取第一个可用的端口号 /// + /// + /// 一次性取本机连接快照()后逐候选做真实 bind 探测, + /// 避免对每个端口重复枚举全表。快照在循环期间可能过期;返回端口到调用方实际 bind + /// 之间仍存在竞态,调用方必须处理 bind 失败。 + /// /// 起始端口号,默认为667 /// 结束端口号,默认为65535 /// 第一个可用的端口号,如果没有可用端口号则返回-1 @@ -378,9 +444,16 @@ public static int GetFirstAvailablePort(int startPort = 667, int maxPort = 65535 ArgumentOutOfRangeException.ThrowIfGreaterThan(maxPort, 65535); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(startPort, maxPort); + // 一次性快照 + 逐候选真实 bind:避免对每个端口都重新枚举本机连接表。 + var usedPorts = PortIsUsed().ToHashSet(); for (var i = startPort; i < maxPort; i++) { - if (PortIsAvailable(i)) + if (usedPorts.Contains(i)) + { + continue; + } + + if (!IsPortBindConflict(i)) { return i; } @@ -429,6 +502,15 @@ public static List PortIsUsed() /// /// 检查指定端口是否可用 /// + /// + /// 双重判定:先查本机 TCP/UDP 监听与活动连接快照,未命中再用 TcpListener + /// 对目标端口做真实 bind 试探(覆盖「其他进程正在 bind 但尚未出现在快照」的窗口)。 + /// bind 探测为 TCP/IPv4 通配():UDP 与仅 IPv6 的占用 + /// 由快照兜底。无权限(如 Unix 上 1024 以下端口)等无法判定的 bind 失败视为可用, + /// 由调用方实际 bind 时决定。因此返回 true 只表示「未检测到冲突」, + /// 不保证随后的 bind 一定成功;快照与 bind 之间仍存在 TOCTOU,真正可靠的占用判定 + /// 是直接 bind。 + /// /// 要检查的端口号 /// 如果端口未被使用,返回true;否则返回false /// 当port小于1或大于65535时抛出此异常 @@ -437,22 +519,43 @@ public static bool PortIsAvailable(int port) ArgumentOutOfRangeException.ThrowIfLessThan(port, 1); ArgumentOutOfRangeException.ThrowIfGreaterThan(port, 65535); - var isAvailable = true; + if (PortIsUsed().Contains(port)) + { + return false; + } - var portUsed = PortIsUsed(); + return !IsPortBindConflict(port); + } - foreach (var p in portUsed) + /// + /// 用真实 bind 试探目标端口是否被其他进程占用。 + /// + /// + /// 仅把 AddressAlreadyInUse 视为占用;无权限、地址族不可用等其他 SocketException + /// 无法证明占用,返回 false(可用),避免把「本进程无权 bind」误报成「端口被占」。 + /// + /// 目标端口号 + /// 真实 bind 命中占用时返回 true + private static bool IsPortBindConflict(int port) + { + try { - if (p != port) + using (var listener = new TcpListener(IPAddress.Any, port)) { - continue; + listener.Start(); + listener.Stop(); } - isAvailable = false; - break; + return false; + } + catch (SocketException e) when (e.SocketErrorCode == SocketError.AddressAlreadyInUse) + { + return true; + } + catch (SocketException) + { + return false; } - - return isAvailable; } /// @@ -593,10 +696,7 @@ public static List GetMacAddresses() if (!string.IsNullOrEmpty(macAddress)) { // 格式化MAC地址为标准格式 (XX:XX:XX:XX:XX:XX) - var formattedMac = string.Join(":", - Enumerable.Range(0, macAddress.Length / 2) - .Select(i => macAddress.Substring(i * 2, 2))); - macAddresses.Add(formattedMac); + macAddresses.Add(FormatMacAddress(macAddress)); } } } @@ -608,6 +708,29 @@ public static List GetMacAddresses() return macAddresses; } + /// + /// 将物理地址字符串格式化为 XX:XX:XX:XX:XX:XX 形式。 + /// + /// + /// 奇数长度(部分虚拟/蓝牙网卡返回 5.5 字节形态)左侧补一个 0, + /// 保证最后一个字节不被静默丢弃。 + /// + /// 物理地址原始十六进制字符串 + /// 以冒号分隔的字节序列 + internal static string FormatMacAddress(string macAddress) + { + ArgumentException.ThrowIfNullOrEmpty(macAddress); + + var paddedMacAddress = macAddress.Length % 2 == 0 ? macAddress : "0" + macAddress; + var groups = new string[paddedMacAddress.Length / 2]; + for (var i = 0; i < groups.Length; i++) + { + groups[i] = paddedMacAddress.Substring(i * 2, 2); + } + + return string.Join(":", groups); + } + /// /// 验证IP地址是否在指定的子网内 /// diff --git a/GameFrameX.Utility/RandomHelper.cs b/GameFrameX.Utility/RandomHelper.cs index aba8b18c7..5ea5c3ae2 100644 --- a/GameFrameX.Utility/RandomHelper.cs +++ b/GameFrameX.Utility/RandomHelper.cs @@ -99,9 +99,9 @@ public static void SetSeed(int seed) /// 一个大于等于零且小于 UInt32.MaxValue 的 32 位无符号整数。 public static uint NextUInt32() { - var bytes = new byte[4]; + Span bytes = stackalloc byte[4]; RandomShared.NextBytes(bytes); - return BitConverter.ToUInt32(bytes, 0); + return BitConverter.ToUInt32(bytes); } /// @@ -178,9 +178,9 @@ public static long NextInt64(long minValue, long maxValue) /// 一个大于等于零且小于 UInt64.MaxValue 的 64 位无符号整数。 public static ulong NextUInt64() { - var bytes = new byte[8]; + Span bytes = stackalloc byte[8]; RandomShared.NextBytes(bytes); - return BitConverter.ToUInt64(bytes, 0); + return BitConverter.ToUInt64(bytes); } /// @@ -511,28 +511,29 @@ public static HashSet RandomSelect(int m, int n) ArgumentOutOfRangeException.ThrowIfNegative(n); ArgumentOutOfRangeException.ThrowIfGreaterThan(m, n); - if (m == 0) + // 经典迭代抽样(Floyd 算法):从 n-m 递增到 n-1,每次在 [0, i] 取候选, + // 命中已选项则改取 i。与旧递归实现同分布,但无 m 层递归(大 m 不再栈溢出)。 + var selected = new HashSet(m); + for (var i = n - m; i < n; i++) { - return new HashSet(); + var candidate = ConcurrentCryptoRandom.Next(0, i + 1); + selected.Add(selected.Contains(candidate) ? i : candidate); } - var s = RandomSelect(m - 1, n - 1); - var i = ConcurrentCryptoRandom.Next(0, n); - s.Add(s.Contains(i) ? n - 1 : i); - return s; + return selected; } /// /// 根据权重随机选取,如果需求数量超过权重和(除以权重最大公约数后的),那么按照权重比例加入id,剩余数量再进行随机 /// 不可重复随机num一定小于等于id数量 /// - /// 权重字符串,格式为"id1+weight1;id2+weight2;..." + /// 权重字符串;行内字段数以 weightIndex 为准(Ids 为 id+weight,Items 为 id+中间字段+weight) /// 需要选取的数量 /// 权重索引 /// 是否允许重复选取 /// 包含随机选取的结果数组列表 /// 当weightStr为null时抛出此异常 - /// 当weightStr为空或格式错误,或不可重复选取且需求数量大于id数量时抛出此异常 + /// 当weightStr为空或格式错误、权重非法,或不可重复抽取的正权重条目不足时抛出此异常 /// 当num或weightIndex小于0时抛出此异常 private static List RandomSelect(string weightStr, int num, int weightIndex, bool canRepeat = true) { @@ -554,7 +555,7 @@ private static List RandomSelect(string weightStr, int num, int weightInd /// 是否允许重复选取 /// 包含随机选取的结果数组列表 /// 当array为null时抛出此异常 - /// 当array为空数组,或不可重复选取且需求数量大于id数量时抛出此异常 + /// 当array为空数组、权重非法,或不可重复选取且需求数量大于正权重条目数时抛出此异常 /// 当num或weightIndex小于0时抛出此异常 private static List RandomSelect(IReadOnlyList array, int num, int weightIndex, bool canRepeat = true) { @@ -578,6 +579,110 @@ private static List RandomSelect(IReadOnlyList array, int num, int return NoRepeatRandom(num, weightIndex, array); } + /// + /// 读取并校验条目中的权重值。 + /// + /// 权重条目。 + /// 权重索引。 + /// 校验通过的权重值。 + /// 当条目为空、索引越界或权重为负数时抛出。 + private static int ReadWeight(int[] entry, int weightIndex) + { + if (entry == null || weightIndex >= entry.Length) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.WeightIndexOutOfRange, weightIndex, entry == null ? 0 : entry.Length)); + } + + var weight = entry[weightIndex]; + if (weight < 0) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.NegativeWeight, weight)); + } + + return weight; + } + + /// + /// 校验权重数组并返回总和(拒绝负权重、全 0、超大总和与越界索引)。 + /// + /// 权重数组,每个元素为[id, weight]。 + /// 权重索引。 + /// 校验通过的总权重。 + /// 权重非法时抛出。 + private static int ValidateAndSumWeights(IReadOnlyList array, int weightIndex) + { + long totalWeight = 0; + foreach (var entry in array) + { + totalWeight += ReadWeight(entry, weightIndex); + } + + return EnsureTotalWeight(totalWeight); + } + + /// + /// 校验纯权重数组并返回总和。 + /// + /// 权重数组。 + /// 校验通过的总权重。 + /// 权重非法时抛出。 + private static int ValidateAndSumWeightValues(int[] weights) + { + long totalWeight = 0; + foreach (var weight in weights) + { + if (weight < 0) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.NegativeWeight, weight)); + } + + totalWeight += weight; + } + + return EnsureTotalWeight(totalWeight); + } + + /// + /// 校验总权重:不能为 0,且必须落在 int 随机域内。 + /// + /// 累加后的总权重。 + /// int 范围内的总权重。 + /// 总权重为 0 或超过 int 上限时抛出。 + private static int EnsureTotalWeight(long totalWeight) + { + if (totalWeight == 0) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.ZeroTotalWeight)); + } + + if (totalWeight > int.MaxValue) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.WeightSumOverflow, totalWeight)); + } + + return (int)totalWeight; + } + + /// + /// 统计权重为正的条目数量(不可重复抽样最多能选出这么多个目标)。 + /// + /// 权重数组,每个元素为[id, weight]。 + /// 权重索引。 + /// 正权重条目数。 + private static int CountPositiveWeights(IReadOnlyList array, int weightIndex) + { + var count = 0; + foreach (var entry in array) + { + if (ReadWeight(entry, weightIndex) > 0) + { + count++; + } + } + + return count; + } + /// /// 不可重复随机选取 /// @@ -585,8 +690,20 @@ private static List RandomSelect(IReadOnlyList array, int num, int /// 权重索引 /// 权重数组,每个元素为[id, weight] /// 包含随机选取的结果数组列表 + /// 权重非法,或正权重条目数少于 num 时抛出。 private static List NoRepeatRandom(int num, int weightIndex, IReadOnlyList array) { + // 先整表校验,避免非法权重在逐次抽样中被隐式放大成晦涩的库异常。 + ValidateAndSumWeights(array, weightIndex); + + // 0 权重条目永不被选中:不可重复抽样要求目标数不超过正权重条目数, + // 否则后续轮次的子集权重和会耗尽(旧实现会返回重复项,属静默错误)。 + var positiveWeightCount = CountPositiveWeights(array, weightIndex); + if (num > positiveWeightCount) + { + throw new ArgumentException(LocalizationService.GetString(Localization.Keys.Utility.RandomHelper.InsufficientPositiveWeights, num, positiveWeightCount)); + } + var results = new List(); var idxSet = new HashSet(); for (var i = 0; i < num; i++) @@ -600,6 +717,7 @@ private static List NoRepeatRandom(int num, int weightIndex, IReadOnlyLis } } + // positiveWeightCount 预检保证每一轮仍有正权重可选,子集和恒 > 0。 var r = ConcurrentCryptoRandom.Next(totalWeight); var temp = 0; var idx = 0; @@ -632,11 +750,7 @@ private static List NoRepeatRandom(int num, int weightIndex, IReadOnlyLis /// 包含随机选取的结果数组列表 private static List CanRepeatRandom(IReadOnlyList array, int num, int weightIndex) { - var totalWeight = 0; - foreach (var arr in array) - { - totalWeight += arr[weightIndex]; - } + var totalWeight = ValidateAndSumWeights(array, weightIndex); var results = new List(num); for (var i = 0; i < num; i++) @@ -673,7 +787,7 @@ private static int[] SingleRandom(IReadOnlyList array, int totalWeight, i var temp = 0; foreach (var arr in array) { - temp += arr[weightIndex]; + temp += ReadWeight(arr, weightIndex); if (temp > r) { return arr; @@ -689,13 +803,13 @@ private static int[] SingleRandom(IReadOnlyList array, int totalWeight, i /// 权重数组 /// 随机选取的id索引 /// 当weights为null时抛出此异常 - /// 当weights为空数组时抛出此异常 + /// 当weights为空数组、含负权重或权重和非法时抛出此异常 public static int Idx(int[] weights) { ArgumentNullException.ThrowIfNull(weights); ArgumentOutOfRangeException.ThrowIfZero(weights.Length, nameof(weights)); - var totalWight = weights.Sum(); + var totalWight = ValidateAndSumWeightValues(weights); var r = ConcurrentCryptoRandom.Next(totalWight); var temp = 0; for (var i = 0; i < weights.Length; i++) @@ -717,7 +831,7 @@ public static int Idx(int[] weights) /// 权重索引 /// 随机选取的id索引 /// 当array为null时抛出此异常 - /// 当array为空数组时抛出此异常 + /// 当array为空数组、权重非法或 weightIndex 越界时抛出此异常 /// 当weightIndex小于0时抛出此异常 public static int Idx(IReadOnlyList array, int weightIndex = 1) { @@ -725,18 +839,14 @@ public static int Idx(IReadOnlyList array, int weightIndex = 1) ArgumentOutOfRangeException.ThrowIfZero(array.Count, nameof(array)); ArgumentOutOfRangeException.ThrowIfNegative(weightIndex); - var totalWeight = 0; - foreach (var arr in array) - { - totalWeight += arr[weightIndex]; - } + var totalWeight = ValidateAndSumWeights(array, weightIndex); var r = ConcurrentCryptoRandom.Next(totalWeight); var temp = 0; for (var i = 0; i < array.Count; i++) { var arr = array[i]; - temp += arr[weightIndex]; + temp += ReadWeight(arr, weightIndex); if (temp > r) { return i; @@ -754,7 +864,7 @@ public static int Idx(IReadOnlyList array, int weightIndex = 1) /// 是否允许重复选取 /// 包含随机选取的id列表 /// 当array为null时抛出此异常 - /// 当array为空数组时抛出此异常 + /// 当array为空数组、权重非法(负权重/全 0/总和溢出/索引越界),或不可重复抽取的正权重条目不足时抛出此异常 /// 当num小于0时抛出此异常 public static List Ids(IReadOnlyList array, int num, bool isCanRepeat = true) { @@ -773,7 +883,7 @@ public static List Ids(IReadOnlyList array, int num, bool isCanRepea /// 是否允许重复选取 /// 包含随机选取的id列表 /// 当str为null时抛出此异常 - /// 当str为空或格式错误时抛出此异常 + /// 当str为空或格式错误、权重非法(负权重/全 0/总和溢出),或不可重复抽取的正权重条目不足时抛出此异常 /// 当num小于0时抛出此异常 public static List Ids(string str, int num, bool isCanRepeat = true) { @@ -786,12 +896,12 @@ public static List Ids(string str, int num, bool isCanRepeat = true) /// /// 随机获取指定数量的项目 /// - /// 权重字符串,格式为"id1+weight1;id2+weight2;..." + /// 权重字符串,Items 格式为"id1+中间字段1+weight1;id2+中间字段2+weight2;..." /// 需要选取的数量 /// 是否允许重复选取 /// 包含随机选取的项目列表 /// 当str为null时抛出此异常 - /// 当str为空或格式错误时抛出此异常 + /// 当str为空或格式错误、权重非法(负权重/全 0/总和溢出),或不可重复抽取的正权重条目不足时抛出此异常 /// 当num小于0时抛出此异常 public static List Items(string str, int num, bool isCanRepeat = true) { @@ -804,12 +914,12 @@ public static List Items(string str, int num, bool isCanRepeat = true) /// /// 随机获取指定数量的项目 /// - /// 数据列表,每个元素为[id, weight] + /// 数据列表,每个元素为[id, 中间字段, weight](权重取索引 2) /// 需要选取的数量 /// 是否允许重复选取 /// 包含随机选取的项目列表 /// 当array为null时抛出此异常 - /// 当array为空数组时抛出此异常 + /// 当array为空数组、权重非法(负权重/全 0/总和溢出/索引越界),或不可重复抽取的正权重条目不足时抛出此异常 /// 当num小于0时抛出此异常 public static List Items(IReadOnlyList array, int num, bool isCanRepeat = true) { diff --git a/GameFrameX.Utility/Setting/GameServerConst.cs b/GameFrameX.Utility/Setting/GameServerConst.cs index d4cbb38e4..75c12e3d2 100644 --- a/GameFrameX.Utility/Setting/GameServerConst.cs +++ b/GameFrameX.Utility/Setting/GameServerConst.cs @@ -33,7 +33,10 @@ namespace GameFrameX.Utility.Setting; /// 游戏服务器常量定义(名称 + 服务器ID)。 /// /// -/// Centralized service constants binding Name and Id together. +/// 职责边界(CR-L04):本类只登记「服务角色」的 Name / Id 成对常量(按 ID 段分组), +/// 不承载 ID 位布局、键名或运行期配置——前者归 , +/// 后者归 / 。 +/// 新增服务角色时按既有 ID 段归组,禁止把业务常量塞进本类。 /// public static class GameServerConst { diff --git a/GameFrameX.Utility/Setting/GlobalConst.cs b/GameFrameX.Utility/Setting/GlobalConst.cs index e133255a1..e0b37429c 100644 --- a/GameFrameX.Utility/Setting/GlobalConst.cs +++ b/GameFrameX.Utility/Setting/GlobalConst.cs @@ -31,10 +31,16 @@ namespace GameFrameX.Utility.Setting; /// -/// 全局常量类。 +/// 全局常量类:ID 位布局、Actor 类型、会话/唯一ID 键名与进程级固定值。 /// /// -/// Global constants class containing server IDs, actor types, and system configuration values. +/// 职责边界(CR-L04): +/// - 本类只放「与具体服务无关、编译期固定」的协议式常量:ID 位掩码与取值范围、ActorType、 +/// 键名后缀、全局计时器间隔。 +/// - 服务名称/服务ID 组合常量归 。 +/// - 运行期可配置项(端口、密钥、语言、数据库连接等)归 , +/// 由其仓储 装载。 +/// - 新增常量前先确认上述三类都不合适;无名意义的「魔法数字」不得入库。 /// public static class GlobalConst { @@ -281,13 +287,5 @@ public static class GlobalConst /// internal const int SaveIntervalInMilliSeconds = 300_000; //300_000; - /// - /// 魔法数字 - /// - /// - /// Magic number constant (60). - /// - public const int Magic = 60; - #endregion } diff --git a/GameFrameX.Utility/Setting/GlobalSettings.cs b/GameFrameX.Utility/Setting/GlobalSettings.cs index 17bb9f031..5499882f9 100644 --- a/GameFrameX.Utility/Setting/GlobalSettings.cs +++ b/GameFrameX.Utility/Setting/GlobalSettings.cs @@ -37,10 +37,12 @@ namespace GameFrameX.Utility.Setting; /// -/// 全局设置 +/// 全局设置( 的进程级仓储)。 /// /// -/// Global settings class that manages application configuration. +/// 职责边界(CR-L04):本类是配置「装载 / 当前实例 / 进程级字段同步」的唯一入口, +/// 不承载常量(归 / ), +/// 也不承载业务状态。改名为 AppSettingStore 属破坏性变更,留待主版本统一处理。 /// public static class GlobalSettings { diff --git a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorGeneratedCodeMarkerTests.cs b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorGeneratedCodeMarkerTests.cs index c22221048..8767c14bf 100644 --- a/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorGeneratedCodeMarkerTests.cs +++ b/Tests/GameFrameX.Tests/Hotfix/HotfixWrapperGeneratorGeneratedCodeMarkerTests.cs @@ -71,7 +71,7 @@ public virtual Task QueryAsync() } } - public partial class PartialComponentAgent + public partial class PartialComponentAgent : BaseComponentAgent { [Service] public virtual Task LoadAsync() @@ -93,11 +93,13 @@ public virtual Task LoadAsync() .Skip(1) .Where(tree => tree.ToString().Contains("public partial class PartialComponentAgentWrapper")) .ToArray(); - // 分部代理的两个落盘分部都生成 Wrapper 声明,但标记只允许出现一次: - // GeneratedCodeAttribute 的 AllowMultiple 为 false,重复特性会令宿主编译失败(CS0579) + // C432 逐声明管线:partial 代理须每个部件自带基类列表才会逐部件生成 Wrapper 分部; + // GeneratedCodeAttribute 的 AllowMultiple 为 false,逐声明独立缓存无法稳定选举唯一携带者, + // 故 partial 分部一律不发射标记(CS0579 绝不发生),分析器豁免由 .g.cs 路径段与 + // ComponentAgentWrapper 名称后缀兜底(三段判定)。标记由非 partial 单体声明的测试另行钉住。 Assert.Equal(2, wrapperTrees.Length); var markerCount = wrapperTrees.Sum(tree => CountMarkerOccurrences(tree.ToString())); - Assert.Equal(1, markerCount); + Assert.Equal(0, markerCount); } [Fact] @@ -114,7 +116,7 @@ public virtual Task UnmarkedAsync() } } - public partial class PartialComponentAgent + public partial class PartialComponentAgent : BaseComponentAgent { [Service] public virtual Task QueryAsync() @@ -136,9 +138,10 @@ public virtual Task QueryAsync() .Skip(1) .Where(tree => tree.ToString().Contains("public partial class PartialComponentAgentWrapper")) .ToArray(); - // 首个声明分部无可包装方法不落盘:标记必须随唯一落盘分部出现(按落盘计数而非声明序号) + // 首个声明分部无可包装方法不落盘:唯一落盘分部为 partial 部件,同样不携带标记(CS0579 防线), + // 豁免由 .g.cs 路径段兜底。 Assert.Single(wrapperTrees); - Assert.Equal(1, CountMarkerOccurrences(wrapperTrees[0].ToString())); + Assert.Equal(0, CountMarkerOccurrences(wrapperTrees[0].ToString())); } /// diff --git a/Tests/GameFrameX.Tests/Utility/AssemblyHelperTests.cs b/Tests/GameFrameX.Tests/Utility/AssemblyHelperTests.cs index d49827a3e..5de2d477b 100644 --- a/Tests/GameFrameX.Tests/Utility/AssemblyHelperTests.cs +++ b/Tests/GameFrameX.Tests/Utility/AssemblyHelperTests.cs @@ -400,4 +400,27 @@ public void GetTypes_Performance_ShouldBeReasonablyFast() Assert.True(stopwatch.ElapsedMilliseconds < 1000, $"Performance test failed: {stopwatch.ElapsedMilliseconds}ms for {iterations} iterations"); } + + /// + /// 测试 GetType 方法 - 未命中结果不得进入缓存(热更程序集后续加载后仍可解析) + /// + [Fact] + public void GetType_WithUnknownName_DoesNotCacheMiss() + { + // Arrange + const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Static; + var cacheField = typeof(AssemblyHelper).GetField("CachedTypes", flags); + Assert.NotNull(cacheField); + var cache = (System.Collections.Concurrent.ConcurrentDictionary)cacheField.GetValue(null); + var typeName = "GameFrameX.Tests.NoSuchType_" + Guid.NewGuid().ToString("N"); + + // Act + var firstLookup = AssemblyHelper.GetType(typeName); + var secondLookup = AssemblyHelper.GetType(typeName); + + // Assert + Assert.Null(firstLookup); + Assert.Null(secondLookup); + Assert.False(cache.ContainsKey(typeName), "未命中的类型名不应被缓存"); + } } \ No newline at end of file diff --git a/Tests/GameFrameX.Tests/Utility/LNumberTests.cs b/Tests/GameFrameX.Tests/Utility/LNumberTests.cs index 49d338079..3360a72c0 100644 --- a/Tests/GameFrameX.Tests/Utility/LNumberTests.cs +++ b/Tests/GameFrameX.Tests/Utility/LNumberTests.cs @@ -644,5 +644,29 @@ public void ContinuousOperations_ShouldMaintainPrecision() } #endregion + + #region 大数路径测试(CR-L03) + + /// + /// 超出 int 权重范围后走 BigInteger 路径的乘法:结果必须由数值转换得到, + /// 不得依赖 ToString/Parse 往返(正负对称)。 + /// + [Fact] + public void Multiply_LargeValues_BigIntegerPathKeepsExactResult() + { + // Arrange + var large = LNumber.Create(40000, 0); // Raw = 2,621,440,000 > int.MaxValue + var factor = LNumber.Create(3, 0); + + // Act + var positive = large * factor; + var negative = (large * LNumber.Create(-1, 0)) * factor; + + // Assert + Assert.Equal(LNumber.Create(120000, 0).Raw, positive.Raw); + Assert.Equal(LNumber.Create(-120000, 0).Raw, negative.Raw); + } + + #endregion } } \ No newline at end of file diff --git a/Tests/GameFrameX.Tests/Utility/Math/FPHashCodeTests.cs b/Tests/GameFrameX.Tests/Utility/Math/FPHashCodeTests.cs new file mode 100644 index 000000000..508de031e --- /dev/null +++ b/Tests/GameFrameX.Tests/Utility/Math/FPHashCodeTests.cs @@ -0,0 +1,110 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System.Collections.Generic; +using GameFrameX.Utility.Math; + +namespace GameFrameX.Tests.Utility.Math; + +/// +/// 定点向量/矩阵哈希质量回归测试(CR-M13)。 +/// +public class FPHashCodeTests +{ + /// + /// 分量和相同的向量必须散列到不同桶(旧实现 (int)(x + y) 全碰撞)。 + /// + [Fact] + public void Vector2_ComponentOrder_AffectsHash() + { + Assert.NotEqual(new FPVector2(1, 2).GetHashCode(), new FPVector2(2, 1).GetHashCode()); + Assert.NotEqual(new FPVector2(1, 2).GetHashCode(), new FPVector2(3, 0).GetHashCode()); + } + + /// + /// XOR 交换不变:分量置换必须散列到不同桶。 + /// + [Fact] + public void Vector3_ComponentPermutation_AffectsHash() + { + Assert.NotEqual(new FPVector3(1, 2, 3).GetHashCode(), new FPVector3(3, 2, 1).GetHashCode()); + } + + /// + /// 四维向量与四元数的分量置换必须改变哈希。 + /// + [Fact] + public void Vector4AndQuaternion_ComponentPermutation_AffectsHash() + { + Assert.NotEqual(new FPVector4(1, 2, 3, 4).GetHashCode(), new FPVector4(4, 3, 2, 1).GetHashCode()); + Assert.NotEqual(new FPQuaternion(1, 2, 3, 4).GetHashCode(), new FPQuaternion(4, 3, 2, 1).GetHashCode()); + } + + /// + /// 矩阵元素置换必须改变哈希。 + /// + [Fact] + public void Matrix_Permutation_AffectsHash() + { + Assert.NotEqual(new FPMatrix(1, 2, 3, 4, 5, 6, 7, 8, 9).GetHashCode(), + new FPMatrix(9, 8, 7, 6, 5, 4, 3, 2, 1).GetHashCode()); + Assert.NotEqual(new FPMatrix4x4(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16).GetHashCode(), + new FPMatrix4x4(16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1).GetHashCode()); + } + + /// + /// 相等对象必须同哈希(Equals/GetHashCode 契约)。 + /// + [Fact] + public void EqualValues_ShareHashCode() + { + Assert.Equal(new FPVector2(3, 4).GetHashCode(), new FPVector2(3, 4).GetHashCode()); + Assert.Equal(new FPVector3(3, 4, 5).GetHashCode(), new FPVector3(3, 4, 5).GetHashCode()); + Assert.Equal(new FPQuaternion(3, 4, 5, 6).GetHashCode(), new FPQuaternion(3, 4, 5, 6).GetHashCode()); + Assert.Equal(new FPMatrix(1, 2, 3, 4, 5, 6, 7, 8, 9).GetHashCode(), new FPMatrix(1, 2, 3, 4, 5, 6, 7, 8, 9).GetHashCode()); + } + + /// + /// 1 万个随机 V2 的冲突率应低于 5%(旧实现按分量和分桶,冲突率远超该值)。 + /// + [Fact] + public void Vector2_RandomSamples_CollisionRateBelowFivePercent() + { + const int sampleCount = 10000; + var random = new System.Random(1234); + var hashes = new HashSet(sampleCount); + + for (var i = 0; i < sampleCount; i++) + { + hashes.Add(new FPVector2(random.Next(-1000, 1000), random.Next(-1000, 1000)).GetHashCode()); + } + + Assert.True(hashes.Count >= sampleCount * 95 / 100, $"哈希冲突过多:仅 {hashes.Count} 个不同哈希"); + } +} diff --git a/Tests/GameFrameX.Tests/Utility/Math/FPMathPrecisionTests.cs b/Tests/GameFrameX.Tests/Utility/Math/FPMathPrecisionTests.cs new file mode 100644 index 000000000..712fc6d29 --- /dev/null +++ b/Tests/GameFrameX.Tests/Utility/Math/FPMathPrecisionTests.cs @@ -0,0 +1,130 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Utility.Math; + +namespace GameFrameX.Tests.Utility.Math; + +/// +/// FP 三角函数精度回归测试(CR-M08:Sin 线性插值)。 +/// +public class FPMathPrecisionTests +{ + /// + /// 插值后的精度应远优于最近邻查表(最近邻误差约 4e-6,插值误差约 1e-9)。 + /// + private static readonly FP Tolerance = (FP)0.000001m; + + /// + /// Sin(π/6) 应精确逼近 0.5;最近邻查表实现会偏差约 2e-6。 + /// + [Fact] + public void Sin_AtThirtyDegrees_MatchesHalfWithinInterpolationTolerance() + { + var angle = FP.Pi / (FP)6; + + var actual = FP.Sin(angle); + + Assert.True(FP.Abs(actual - (FP)0.5m) <= Tolerance, $"Sin(π/6) = {(decimal)actual},期望 0.5"); + } + + /// + /// 第一、二象限抽样对照 double 参考值,确认插值分支未破坏镜像路径。 + /// + [Fact] + public void Sin_AcrossHalfPeriod_StaysWithinToleranceOfDoubleReference() + { + for (var i = 0; i <= 3141; i++) + { + var radians = i / 1000.0; + var actual = (double)(decimal)FP.Sin((FP)(decimal)radians); + var expected = System.Math.Sin(radians); + + Assert.True(System.Math.Abs(actual - expected) <= 1e-6, $"角度 {radians}:actual={actual} expected={expected}"); + } + } + + /// + /// Sin 的翻转分支(横向镜像)应保持对称性与单调性。 + /// + [Fact] + public void Sin_IsAntisymmetricAndMonotonicOnFirstQuadrant() + { + var previous = FP.Zero; + for (var i = 1; i <= 100; i++) + { + var angle = FP.PiOver2 / (FP)(101 * 2) * (FP)i; + var value = FP.Sin(angle); + Assert.True(value >= previous, $"Sin 在 {i} 步非单调:{ (decimal)value } < { (decimal)previous }"); + Assert.True(FP.Abs(FP.Sin(-angle) + value) <= Tolerance, $"Sin 在 {i} 步不满足奇对称"); + previous = value; + } + } + + /// + /// 全周期角度扫描:Sin 不越界、不抛异常,结果恒在 [-1, 1]。 + /// 覆盖 LUT 顶格、翻转边界与负角度镜像路径。 + /// + [Fact] + public void Sin_OverFullPeriod_StaysBoundedAndDoesNotThrow() + { + for (var i = -20000; i <= 20000; i++) + { + var angle = (FP)(decimal)(i / 1000.0); + + var value = FP.Sin(angle); + Assert.True(value >= -FP.One && value <= FP.One, $"Sin({i / 1000.0}) = {(decimal)value} 越界"); + _ = FP.Cos(angle); + } + } + + /// + /// Sqrt 负数入参:本地化消息必须进 Message、ParamName 必须是 "x"(CR-L05)。 + /// + [Fact] + public void Sqrt_WithNegativeValue_ReportsParamNameAndMessage() + { + var exception = Assert.Throws(() => FP.Sqrt((FP)(-1))); + + Assert.Equal("x", exception.ParamName); + Assert.DoesNotContain("{", exception.Message); + } + + /// + /// Acos 越界入参:本地化消息必须进 Message、ParamName 必须是 "x"(CR-L05)。 + /// + [Fact] + public void Acos_WithOutOfRangeValue_ReportsParamNameAndMessage() + { + var exception = Assert.Throws(() => FP.Acos((FP)2)); + + Assert.Equal("x", exception.ParamName); + Assert.DoesNotContain("{", exception.Message); + } +} diff --git a/Tests/GameFrameX.Tests/Utility/Math/FPMatrixInverseTests.cs b/Tests/GameFrameX.Tests/Utility/Math/FPMatrixInverseTests.cs new file mode 100644 index 000000000..bbcfd48e8 --- /dev/null +++ b/Tests/GameFrameX.Tests/Utility/Math/FPMatrixInverseTests.cs @@ -0,0 +1,140 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Utility.Math; + +namespace GameFrameX.Tests.Utility.Math; + +/// +/// FPMatrix 求逆回归测试(CR-M09:单求逆实现 + 奇异哨兵)。 +/// +public class FPMatrixInverseTests +{ + /// + /// 单位矩阵求逆仍为单位矩阵。 + /// + [Fact] + public void Invert_Identity_ReturnsIdentity() + { + var identity = FPMatrix.Identity; + + FPMatrix.Invert(ref identity, out var inverse); + + Assert.Equal(FP.One, inverse.M11); + Assert.Equal(FP.One, inverse.M22); + Assert.Equal(FP.One, inverse.M33); + Assert.Equal(FP.Zero, inverse.M12); + Assert.Equal(FP.Zero, inverse.M13); + Assert.Equal(FP.Zero, inverse.M21); + Assert.Equal(FP.Zero, inverse.M23); + Assert.Equal(FP.Zero, inverse.M31); + Assert.Equal(FP.Zero, inverse.M32); + } + + /// + /// 对角矩阵求逆得到对角倒数(旧 Invert 无奇异检查,此处同时验证直除精度)。 + /// + [Fact] + public void Invert_DiagonalMatrix_ReturnsReciprocals() + { + var matrix = new FPMatrix(2, 0, 0, 0, 4, 0, 0, 0, 8); + + FPMatrix.Invert(ref matrix, out var inverse); + + Assert.Equal((FP)0.5m, inverse.M11); + Assert.Equal((FP)0.25m, inverse.M22); + Assert.Equal((FP)0.125m, inverse.M33); + Assert.Equal(FP.Zero, inverse.M21); + } + + /// + /// 奇异矩阵(第三行为前两行线性组合)应输出 PositiveInfinity 哨兵,而不是 1/0 产生的垃圾值。 + /// + [Fact] + public void Invert_SingularMatrix_FillsPositiveInfinitySentinel() + { + var matrix = new FPMatrix(1, 2, 3, 2, 4, 6, 7, 8, 9); + + FPMatrix.Invert(ref matrix, out var inverse); + + Assert.Equal(FP.PositiveInfinity, inverse.M11); + Assert.Equal(FP.PositiveInfinity, inverse.M12); + Assert.Equal(FP.PositiveInfinity, inverse.M13); + Assert.Equal(FP.PositiveInfinity, inverse.M21); + Assert.Equal(FP.PositiveInfinity, inverse.M22); + Assert.Equal(FP.PositiveInfinity, inverse.M23); + Assert.Equal(FP.PositiveInfinity, inverse.M31); + Assert.Equal(FP.PositiveInfinity, inverse.M32); + Assert.Equal(FP.PositiveInfinity, inverse.M33); + } + + /// + /// 已过时的 Inverse 与 Invert 必须是同一实现(不再有 1024 缩放的第二套真相)。 + /// + [Fact] + public void Inverse_DelegatesToInvert() + { + var matrix = new FPMatrix(2, 1, 0, 1, 3, 1, 0, 1, 4); + + FPMatrix.Invert(ref matrix, out var viaInvert); + +#pragma warning disable CS0618 // 本用例专门验证过时 API 的委托语义 + var viaInverse = FPMatrix.Inverse(matrix); + FPMatrix.Inverse(ref matrix, out var viaInverseRef); +#pragma warning restore CS0618 + + Assert.Equal(viaInvert.M11, viaInverse.M11); + Assert.Equal(viaInvert.M23, viaInverse.M23); + Assert.Equal(viaInvert.M33, viaInverse.M33); + Assert.Equal(viaInvert.M11, viaInverseRef.M11); + Assert.Equal(viaInvert.M33, viaInverseRef.M33); + } + + /// + /// 非奇异矩阵求逆后与原矩阵相乘应逼近单位矩阵。 + /// + [Fact] + public void Invert_MultipliedByOriginal_ApproximatesIdentity() + { + var matrix = new FPMatrix(4, 7, 2, 3, 6, 1, 2, 5, 3); + + FPMatrix.Invert(ref matrix, out var inverse); + var product = FPMatrix.Multiply(matrix, inverse); + + Assert.True(FP.Abs(product.M11 - FP.One) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M22 - FP.One) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M33 - FP.One) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M12) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M13) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M21) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M23) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M31) <= (FP)0.00001m); + Assert.True(FP.Abs(product.M32) <= (FP)0.00001m); + } +} diff --git a/Tests/GameFrameX.Tests/Utility/Math/FPRandomTests.cs b/Tests/GameFrameX.Tests/Utility/Math/FPRandomTests.cs new file mode 100644 index 000000000..18f81994b --- /dev/null +++ b/Tests/GameFrameX.Tests/Utility/Math/FPRandomTests.cs @@ -0,0 +1,145 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using GameFrameX.Utility.Math; + +namespace GameFrameX.Tests.Utility.Math; + +/// +/// FPRandom 回归测试(CR-M10:懒初始化、球内采样、Next 边界与模偏)。 +/// +public class FPRandomTests +{ + /// + /// 未显式调用 Init 时静态成员也必须可用(旧实现直接 NRE)。 + /// + [Fact] + public void Value_BeforeExplicitInit_DoesNotThrow() + { + var value = (decimal)FPRandom.value; + + Assert.InRange(value, 0m, 1m); + } + + /// + /// 球内采样:全部样本模长不超过 1(旧实现返回立方体点,模长可达 √3)。 + /// + [Fact] + public void InsideUnitSphere_AllSamplesStayWithinUnitBall() + { + for (var i = 0; i < 2000; i++) + { + var point = FPRandom.insideUnitSphere; + var magnitude = (decimal)point.magnitude; + + Assert.True(magnitude <= 1.0001m, $"第 {i} 个样本模长 {magnitude} 超出单位球"); + } + } + + /// + /// 球内采样不应退化为原点或单点:应同时存在靠近中心与外层壳的样本。 + /// + [Fact] + public void InsideUnitSphere_CoversInnerAndOuterRegions() + { + var hasInner = false; + var hasOuter = false; + + for (var i = 0; i < 2000 && !(hasInner && hasOuter); i++) + { + var magnitude = (decimal)FPRandom.insideUnitSphere.magnitude; + hasInner |= magnitude < 0.25m; + hasOuter |= magnitude > 0.75m; + } + + Assert.True(hasInner, "未采到靠近球心的样本"); + Assert.True(hasOuter, "未采到靠近球壳的样本"); + } + + /// + /// min == max 时返回 min,而不是抛 DivideByZeroException。 + /// + [Fact] + public void Next_WithEqualBounds_ReturnsBoundary() + { + var random = FPRandom.New(1); + + Assert.Equal(7, random.Next(7, 7)); + Assert.Equal(-3, random.Next(-3, -3)); + } + + /// + /// 三桶抽样应接近均匀(拒绝采样消除模偏);旧实现的模偏在 6 万次下可被稳定检出。 + /// + [Fact] + public void Next_ThreeBuckets_AreEvenlyDistributed() + { + const int sampleCount = 60000; + var random = FPRandom.New(42); + var buckets = new int[3]; + + for (var i = 0; i < sampleCount; i++) + { + buckets[random.Next(0, 3)]++; + } + + const int expected = sampleCount / 3; + const int tolerance = expected / 50; // 2% + foreach (var count in buckets) + { + Assert.InRange(count, expected - tolerance, expected + tolerance); + } + } + + /// + /// 跨度超过 31 位随机域时不应溢出或抛异常(旧实现 range 溢出为负数)。 + /// + [Fact] + public void Next_WithFullIntRange_ReturnsValueInRange() + { + var random = FPRandom.New(7); + + for (var i = 0; i < 100; i++) + { + var value = random.Next(int.MinValue, int.MaxValue); + Assert.InRange(value, int.MinValue, int.MaxValue - 1); + } + } + + /// + /// 静态 Range 在未显式 Init 时也可用。 + /// + [Fact] + public void Range_WithoutExplicitInit_ReturnsValueInRange() + { + var value = FPRandom.Range(10, 20); + + Assert.InRange(value, 10, 19); + } +} diff --git a/Tests/GameFrameX.Tests/Utility/NetHelperTests.cs b/Tests/GameFrameX.Tests/Utility/NetHelperTests.cs index 35f78d614..04c5f89d7 100644 --- a/Tests/GameFrameX.Tests/Utility/NetHelperTests.cs +++ b/Tests/GameFrameX.Tests/Utility/NetHelperTests.cs @@ -804,5 +804,61 @@ public void GetAvailablePorts_ShouldReturnActuallyAvailablePorts() } #endregion + + #region MAC 地址格式化与端口 bind 探测 + + /// + /// 偶数长度的物理地址按字节分组。 + /// + [Fact] + public void FormatMacAddress_WithEvenLength_GroupsByByte() + { + Assert.Equal("A1:B2:C3:D4:E5:F6", NetHelper.FormatMacAddress("A1B2C3D4E5F6")); + } + + /// + /// 奇数长度的物理地址左补 0,最后一位不得静默丢失。 + /// + [Fact] + public void FormatMacAddress_WithOddLength_PreservesAllNibbles() + { + var formatted = NetHelper.FormatMacAddress("A1B2C3D4E"); + + Assert.Equal("0A:1B:2C:3D:4E", formatted); + Assert.Equal("0A1B2C3D4E", formatted.Replace(":", string.Empty)); + } + + /// + /// 已被监听占用的端口必须判定为不可用。 + /// + [Fact] + public void PortIsAvailable_WithBoundPort_ReturnsFalse() + { + using (var listener = new TcpListener(IPAddress.Any, 0)) + { + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + Assert.False(NetHelper.PortIsAvailable(port)); + } + } + + /// + /// 已释放的端口必须判定为可用。 + /// + [Fact] + public void PortIsAvailable_WithReleasedPort_ReturnsTrue() + { + int port; + using (var listener = new TcpListener(IPAddress.Any, 0)) + { + listener.Start(); + port = ((IPEndPoint)listener.LocalEndpoint).Port; + } + + Assert.True(NetHelper.PortIsAvailable(port)); + } + + #endregion } } \ No newline at end of file diff --git a/Tests/GameFrameX.Tests/Utility/RandomHelperWeightTests.cs b/Tests/GameFrameX.Tests/Utility/RandomHelperWeightTests.cs new file mode 100644 index 000000000..0c8eef65a --- /dev/null +++ b/Tests/GameFrameX.Tests/Utility/RandomHelperWeightTests.cs @@ -0,0 +1,183 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents, and related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相应法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录下的 LICENSE 文件。 +// please refer to the LICENSE file in the root directory of the source code for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止的行为! +// or infringe upon the legitimate rights and interests of others, as prohibited by laws and regulations! +// 因基于本项目二次开发所产生的一切法律纠纷与责任, +// Any legal disputes and liabilities arising from secondary development based on this project +// 本项目组织与贡献者概不承担。 +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/GameFrameX +// CNB 仓库:https://cnb.cool/GameFrameX +// CNB Repository: https://cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + +using System; +using System.Collections.Generic; +using GameFrameX.Utility; + +namespace GameFrameX.Tests.Utility; + +/// +/// RandomHelper 权重随机与无分配随机数回归测试(CR-M12 / CR-L07)。 +/// +public class RandomHelperWeightTests +{ + /// + /// 负权重必须在入口被拒绝,并指出非法权重值。 + /// + [Fact] + public void Idx_WithNegativeWeight_ThrowsArgumentException() + { + var exception = Assert.Throws(() => RandomHelper.Idx(new[] { 1, -2, 3 })); + + Assert.Contains("-2", exception.Message); + } + + /// + /// 全 0 权重无可选目标,必须显式报错而非抛随机库内部异常。 + /// + [Fact] + public void Idx_WithAllZeroWeights_ThrowsArgumentException() + { + Assert.Throws(() => RandomHelper.Idx(new[] { 0, 0, 0 })); + } + + /// + /// 权重求和超出 int 上限时必须拒绝(旧实现静默回绕成错误分布)。 + /// + [Fact] + public void Idx_WithWeightSumOverflow_ThrowsArgumentException() + { + Assert.Throws(() => RandomHelper.Idx(new[] { int.MaxValue, int.MaxValue })); + } + + /// + /// weightIndex 超出条目长度时给出带索引与长度的明确异常。 + /// + [Fact] + public void Idx_WithWeightIndexOutOfRange_ThrowsArgumentException() + { + var array = new[] { new[] { 1, 2 }, new[] { 3, 4 } }; + + var exception = Assert.Throws(() => RandomHelper.Idx(array, 5)); + + Assert.Contains("5", exception.Message); + } + + /// + /// Ids 的权重重载同样受权重校验保护。 + /// + [Fact] + public void Ids_WithNegativeWeight_ThrowsArgumentException() + { + var array = new[] { new[] { 1, 1 }, new[] { 2, -1 } }; + + // 显式转成 IReadOnlyList,避免命中泛型 Ids(T[], int) 的「按普通元素抽样」重载。 + Assert.Throws(() => RandomHelper.Ids((IReadOnlyList)array, 1)); + } + + /// + /// 合法权重返回合法索引。 + /// + [Fact] + public void Idx_WithValidWeights_ReturnsIndexInRange() + { + var weights = new[] { 1, 2, 3 }; + + for (var i = 0; i < 1000; i++) + { + Assert.InRange(RandomHelper.Idx(weights), 0, weights.Length - 1); + } + } + + /// + /// 不可重复抽取的正权重条目不足时必须 fail fast(0 权重条目永不被选中)。 + /// + [Fact] + public void Items_NoRepeat_WithInsufficientPositiveWeights_ThrowsArgumentException() + { + var array = new[] { new[] { 1, 0, 5 }, new[] { 2, 0, 0 } }; + + var exception = Assert.Throws(() => RandomHelper.Items((IReadOnlyList)array, 2, false)); + + Assert.Contains("1", exception.Message); + } + + /// + /// 正权重条目足够时,0 权重条目不会被选中。 + /// + [Fact] + public void Items_NoRepeat_WithZeroWeightEntry_SelectsOnlyPositiveWeights() + { + var array = new[] { new[] { 1, 0, 5 }, new[] { 2, 0, 0 }, new[] { 3, 0, 5 } }; + + var selected = RandomHelper.Items((IReadOnlyList)array, 2, false); + + Assert.Equal(2, selected.Count); + Assert.DoesNotContain(selected, entry => entry[0] == 2); + Assert.NotEqual(selected[0][0], selected[1][0]); + } + + /// + /// 大 m 抽样不再线性递归,不触发栈溢出。 + /// + [Fact] + public void RandomSelect_LargeCount_DoesNotOverflowStack() + { + var selected = RandomHelper.RandomSelect(100_000, 100_001); + + Assert.Equal(100_000, selected.Count); + Assert.All(selected, value => Assert.InRange(value, 0, 100_000)); + } + + /// + /// 小规模抽样仍保持互异且落在 [0, n) 内。 + /// + [Fact] + public void RandomSelect_ReturnsDistinctValuesInRange() + { + var selected = RandomHelper.RandomSelect(5, 10); + + Assert.Equal(5, selected.Count); + Assert.All(selected, value => Assert.InRange(value, 0, 9)); + } + + /// + /// NextUInt32/NextUInt64 必须零堆分配(旧实现每次 new byte[])。 + /// + [Fact] + public void NextUInt32AndNextUInt64_DoNotAllocatePerCall() + { + for (var i = 0; i < 2000; i++) + { + _ = RandomHelper.NextUInt32(); + _ = RandomHelper.NextUInt64(); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < 1000; i++) + { + _ = RandomHelper.NextUInt32(); + _ = RandomHelper.NextUInt64(); + } + + var allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(allocated < 1024, $"1000 次无符号随机分配了 {allocated} 字节"); + } +}