# App 端局域网测试服自动发现 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 安卓/iOS 的 dev 测试包开包后自动用 UDP 广播探测同一局域网内的本地测试服,发现后弹窗询问是否切换到「本地网关 + 本地小游戏资源源」,确认后整条小游戏链路改连本机内容。 **Architecture:** 沿用现有「共享源码一份、两端各编一次」(A1)与「网关 = Kestrel `/ws?pid=`」结构。新增一段 **UDP 发现协议**:客户端广播 Query(含协议版本 + nonce + 共享 token),本地测试服单播回 Reply(含 `GatewayUrl` + `ResourceBaseUrl` + 机器名)。客户端弹窗确认后把覆盖写入 `PlayerPrefs`,启动流程优先读覆盖。发现/覆盖逻辑全部用 `#if XW_DEVTEST` 包起来,**只编进 dev 包**;生产包不含这段代码、永远连生产地址(安全:避免广播切服务器变成 MITM 入口)。微信端不在本计划范围(微信沙箱无 LAN 广播能力,另走开发版直连/穿透)。 **Tech Stack:** .NET 10(服务端 Gateway + xUnit)、netstandard2.1/C# 9(Framework.Shared 共享层,手写 `PacketWriter/PacketReader` varint 编解码)、Unity 2022.3.62f2 mono(客户端 `XGame.MiniGame`,`System.Net.Sockets.UdpClient` + `UnityEngine` IMGUI/PlayerPrefs)。 **关键约定(与现有工程一致):** - 共享层源码放 `Client/Assets/Framework/Shared/**`,`Server/Framework.Shared.csproj` 用通配 `` **自动**编进服务端,Unity 侧由 `XWorld.Framework.Shared` asmdef 编译——**新增一个 .cs 即两端共用,无需改 csproj**。 - 客户端新文件放 `Client/Assets/Script/xmain/MiniGame/`(`XGame.MiniGame`,归 `XWorld.Link` asmdef,已引用 `XWorld.Framework.Shared`)。 - .NET 段全部 TDD(`dotnet test Server/Server.sln`,当前基线 **145/145** 必须只增不减)。Unity 段无法在本环境跑 Play,按子项目 3 既定方式:**csc 编译验证 0 错误 + 显式 Unity Play 人工核对清单**。 - 提交信息结尾加 `Co-Authored-By: Claude Opus 4.8 (1M context) `。 --- ## File Structure | 文件 | 责任 | 跑在哪 | 验证方式 | |---|---|---|---| | `Client/Assets/Framework/Shared/Protocol/DevDiscovery.cs`(新建) | 发现协议常量 + 两个 payload struct + `DevDiscoveryCodec` 编解码 | 客户端 + 服务端(共享) | xUnit | | `Server/Framework.Shared.Tests/DevDiscoveryCodecTests.cs`(新建) | 编解码往返/容错测试 | 测试 | — | | `Server/Gateway/DevDiscoveryResponder.cs`(新建) | UDP 应答端:收 Query→验 token→单播 Reply | 服务端 | xUnit | | `Server/Gateway.Tests/DevDiscoveryResponderTests.cs`(新建) | 真 UdpClient 环回往返测试 | 测试 | — | | `Server/Gateway/GameServerHost.cs`(改) | 按 token 选择性启动 responder(默认关,向后兼容) | 服务端 | xUnit | | `Server/Gateway.Tests/GameServerHostDiscoveryTests.cs`(新建) | 宿主带/不带 token 行为 | 测试 | — | | `Server/Gateway.Runner/Program.cs`(改) | 新增 `--devToken/--advertiseWs/--advertiseCdn/--lan` 参数 | 服务端 | 手动运行 | | `Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs`(新建) | PlayerPrefs 覆盖存储 | 客户端 | csc | | `Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs`(新建) | 后台线程广播 + 收 Reply | 客户端 | csc + Play | | `Client/Assets/Script/xmain/MiniGame/DevServerSwitchUI.cs`(新建) | 发现编排 + 弹窗 + 横幅 + 复位 | 客户端 | csc + Play | | `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs`(改) | 网关/CDN 解析优先读覆盖;挂 SwitchUI | 客户端 | csc + Play | --- ## Task 1: 共享发现协议 + 编解码(TDD) **Files:** - Create: `Client/Assets/Framework/Shared/Protocol/DevDiscovery.cs` - Test: `Server/Framework.Shared.Tests/DevDiscoveryCodecTests.cs` > 本文件纯 C#、无 `UnityEngine`(Framework.Shared 是 `noEngineReferences`),只用 `System` + 同目录的 `PacketWriter/PacketReader`。落盘后服务端通过 csproj 通配自动编译,Unity 通过 asmdef 编译。 - [ ] **Step 1: 写失败测试** Create `Server/Framework.Shared.Tests/DevDiscoveryCodecTests.cs`: ```csharp using System; using Xunit; using XWorld.Framework.Protocol; namespace XWorld.Framework.Tests { public class DevDiscoveryCodecTests { [Fact] public void Query_RoundTrips() { var q = new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = 0xABCDEF, Token = "dev-token" }; byte[] bytes = DevDiscoveryCodec.EncodeQuery(q); Assert.True(DevDiscoveryCodec.TryDecodeQuery(bytes, out var got)); Assert.Equal(q.ProtoVersion, got.ProtoVersion); Assert.Equal(q.Nonce, got.Nonce); Assert.Equal(q.Token, got.Token); } [Fact] public void Reply_RoundTrips() { var reply = new DevDiscoveryReply { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = 42, ServerName = "dev-pc", GatewayUrl = "ws://192.168.1.23:5005/ws", ResourceBaseUrl = "http://192.168.1.23:15081/minigame/", Token = "dev-token", }; byte[] bytes = DevDiscoveryCodec.EncodeReply(reply); Assert.True(DevDiscoveryCodec.TryDecodeReply(bytes, out var got)); Assert.Equal(reply.GatewayUrl, got.GatewayUrl); Assert.Equal(reply.ResourceBaseUrl, got.ResourceBaseUrl); Assert.Equal(reply.ServerName, got.ServerName); Assert.Equal(reply.Nonce, got.Nonce); Assert.Equal(reply.Token, got.Token); } [Fact] public void BadMagic_Rejected() { byte[] bytes = { 0x00, 0x01, 0x02, 0x03, DevDiscovery.TypeQuery, 1 }; Assert.False(DevDiscoveryCodec.TryDecodeQuery(bytes, out _)); } [Fact] public void Truncated_Rejected() { Assert.False(DevDiscoveryCodec.TryDecodeQuery(new byte[] { 0x58, 0x57 }, out _)); Assert.False(DevDiscoveryCodec.TryDecodeReply(Array.Empty(), out _)); } [Fact] public void WrongType_Rejected() { var q = new DevDiscoveryQuery { ProtoVersion = 1, Nonce = 1, Token = "t" }; byte[] queryBytes = DevDiscoveryCodec.EncodeQuery(q); Assert.False(DevDiscoveryCodec.TryDecodeReply(queryBytes, out _)); // 用 Reply 解码器解 Query } } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~DevDiscoveryCodecTests"` Expected: 编译失败(`DevDiscovery`/`DevDiscoveryCodec` 未定义) - [ ] **Step 3: 实现协议 + 编解码** Create `Client/Assets/Framework/Shared/Protocol/DevDiscovery.cs`: ```csharp using System; namespace XWorld.Framework.Protocol { // 局域网开发期测试服自动发现协议(仅 dev 包使用)。 // 客户端广播 Query;本地测试服单播回 Reply,携带要切换到的网关/资源地址。 public static class DevDiscovery { public const int Port = 48923; // 固定 UDP 端口(客户端广播目标 + 服务器监听) public const uint ProtoVersion = 1; // 协议版本,双方不一致则忽略 public static readonly byte[] Magic = { 0x58, 0x57, 0x44, 0x4C }; // 'X' 'W' 'D' 'L' public const byte TypeQuery = 1; public const byte TypeReply = 2; } public struct DevDiscoveryQuery { public uint ProtoVersion; public uint Nonce; // 客户端随机数,回包原样带回,过滤陈旧/串扰包 public string Token; // 共享口令;服务器只回口令匹配的查询(弱过滤,非安全边界) } public struct DevDiscoveryReply { public uint ProtoVersion; public uint Nonce; // 回显 Query.Nonce public string ServerName; // 展示用:开发机名 public string GatewayUrl; // 例如 ws://192.168.1.23:5005/ws public string ResourceBaseUrl; // 例如 http://192.168.1.23:15081/minigame/ public string Token; } public static class DevDiscoveryCodec { public static byte[] EncodeQuery(DevDiscoveryQuery q) { var w = new PacketWriter(32); WriteHeader(w, DevDiscovery.TypeQuery); w.WriteVarUInt(q.ProtoVersion); w.WriteVarUInt(q.Nonce); w.WriteString(q.Token); return w.ToArray(); } public static byte[] EncodeReply(DevDiscoveryReply r) { var w = new PacketWriter(96); WriteHeader(w, DevDiscovery.TypeReply); w.WriteVarUInt(r.ProtoVersion); w.WriteVarUInt(r.Nonce); w.WriteString(r.ServerName); w.WriteString(r.GatewayUrl); w.WriteString(r.ResourceBaseUrl); w.WriteString(r.Token); return w.ToArray(); } public static bool TryDecodeQuery(byte[] data, out DevDiscoveryQuery q) { q = default; try { var r = new PacketReader(data); if (!ReadAndCheckHeader(r, DevDiscovery.TypeQuery)) return false; q.ProtoVersion = r.ReadVarUInt(); q.Nonce = r.ReadVarUInt(); q.Token = r.ReadString(); return true; } catch (FormatException) { return false; } } public static bool TryDecodeReply(byte[] data, out DevDiscoveryReply reply) { reply = default; try { var r = new PacketReader(data); if (!ReadAndCheckHeader(r, DevDiscovery.TypeReply)) return false; reply.ProtoVersion = r.ReadVarUInt(); reply.Nonce = r.ReadVarUInt(); reply.ServerName = r.ReadString(); reply.GatewayUrl = r.ReadString(); reply.ResourceBaseUrl = r.ReadString(); reply.Token = r.ReadString(); return true; } catch (FormatException) { return false; } } private static void WriteHeader(PacketWriter w, byte type) { foreach (var b in DevDiscovery.Magic) w.WriteByte(b); w.WriteByte(type); } private static bool ReadAndCheckHeader(PacketReader r, byte expectedType) { for (int i = 0; i < DevDiscovery.Magic.Length; i++) if (r.ReadByte() != DevDiscovery.Magic[i]) return false; // 越界由 ReadByte 抛 FormatException return r.ReadByte() == expectedType; } } } ``` - [ ] **Step 4: 运行测试确认通过** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~DevDiscoveryCodecTests"` Expected: PASS(5 个) - [ ] **Step 5: 全量回归 + 提交** Run: `dotnet test Server/Server.sln` Expected: 150/150(基线 145 + 本任务 5) ```bash git add Client/Assets/Framework/Shared/Protocol/DevDiscovery.cs Server/Framework.Shared.Tests/DevDiscoveryCodecTests.cs git commit -m "feat(devtest): 局域网测试服发现协议与编解码(共享层) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: 服务端 UDP 应答端 DevDiscoveryResponder(TDD) **Files:** - Create: `Server/Gateway/DevDiscoveryResponder.cs` - Test: `Server/Gateway.Tests/DevDiscoveryResponderTests.cs` > 用真 `UdpClient` 在环回口做往返(与现有 Gateway 真 Kestrel+WS e2e 同风格)。`listenPort:0` 让系统分配临时端口,避免测试间端口冲突。 - [ ] **Step 1: 写失败测试** Create `Server/Gateway.Tests/DevDiscoveryResponderTests.cs`: ```csharp using System.Net; using System.Net.Sockets; using System.Threading.Tasks; using Xunit; using XWorld.Framework.Protocol; using XWorld.Server.Gateway; namespace XWorld.Server.Gateway.Tests { public class DevDiscoveryResponderTests { private static async Task QueryAsync(int port, string token, uint nonce, int timeoutMs) { using var client = new UdpClient(AddressFamily.InterNetwork); byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = nonce, Token = token, }); await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, port)); var recv = client.ReceiveAsync(); var done = await Task.WhenAny(recv, Task.Delay(timeoutMs)); if (done != recv) return null; if (!DevDiscoveryCodec.TryDecodeReply(recv.Result.Buffer, out var reply)) return null; return reply; } [Fact] public async Task ValidQuery_ReceivesReply_WithAdvertisedAddresses() { var responder = new DevDiscoveryResponder( "dev-pc", "ws://192.168.1.23:5005/ws", "http://192.168.1.23:15081/minigame/", token: "secret", listenPort: 0); responder.Start(); try { var reply = await QueryAsync(responder.Port, "secret", nonce: 7, timeoutMs: 2000); Assert.NotNull(reply); Assert.Equal("ws://192.168.1.23:5005/ws", reply.Value.GatewayUrl); Assert.Equal("http://192.168.1.23:15081/minigame/", reply.Value.ResourceBaseUrl); Assert.Equal("dev-pc", reply.Value.ServerName); Assert.Equal(7u, reply.Value.Nonce); } finally { await responder.StopAsync(); } } [Fact] public async Task WrongToken_NoReply() { var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0); responder.Start(); try { var reply = await QueryAsync(responder.Port, "WRONG", nonce: 1, timeoutMs: 800); Assert.Null(reply); } finally { await responder.StopAsync(); } } [Fact] public async Task GarbagePacket_Ignored_LoopStillAnswers() { var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0); responder.Start(); try { using (var raw = new UdpClient(AddressFamily.InterNetwork)) { byte[] junk = { 1, 2, 3 }; await raw.SendAsync(junk, junk.Length, new IPEndPoint(IPAddress.Loopback, responder.Port)); } var reply = await QueryAsync(responder.Port, "secret", nonce: 9, timeoutMs: 2000); Assert.NotNull(reply); Assert.Equal(9u, reply.Value.Nonce); } finally { await responder.StopAsync(); } } } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~DevDiscoveryResponderTests"` Expected: 编译失败(`DevDiscoveryResponder` 未定义) - [ ] **Step 3: 实现 responder** Create `Server/Gateway/DevDiscoveryResponder.cs`: ```csharp using System; using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using XWorld.Framework.Protocol; namespace XWorld.Server.Gateway { // 开发期局域网测试服发现的应答端:监听 UDP 查询,对口令匹配的查询单播回 Reply。 // 仅在显式配置 token 时启用(生产默认关闭)。非安全边界,只用于内网开发自助发现。 public sealed class DevDiscoveryResponder { private readonly DevDiscoveryReply _template; private readonly string _token; private readonly UdpClient _udp; private CancellationTokenSource _cts; private Task _loop; public int Port { get; } // listenPort=0 → 系统分配(测试用);生产用 DevDiscovery.Port public DevDiscoveryResponder(string serverName, string gatewayUrl, string resourceBaseUrl, string token, int listenPort = DevDiscovery.Port) { if (string.IsNullOrEmpty(token)) throw new ArgumentException("token 不能为空", nameof(token)); _token = token; _template = new DevDiscoveryReply { ProtoVersion = DevDiscovery.ProtoVersion, ServerName = serverName ?? "dev-server", GatewayUrl = gatewayUrl ?? "", ResourceBaseUrl = resourceBaseUrl ?? "", Token = token, }; _udp = new UdpClient(AddressFamily.InterNetwork); _udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort)); Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port; } public void Start() { _cts = new CancellationTokenSource(); _loop = ReceiveLoopAsync(_cts.Token); } private async Task ReceiveLoopAsync(CancellationToken ct) { using (ct.Register(() => { try { _udp.Close(); } catch { } })) // Close 唤醒阻塞中的 ReceiveAsync { while (!ct.IsCancellationRequested) { UdpReceiveResult res; try { res = await _udp.ReceiveAsync().ConfigureAwait(false); } catch (ObjectDisposedException) { break; } catch (SocketException) { if (ct.IsCancellationRequested) break; else continue; } if (!DevDiscoveryCodec.TryDecodeQuery(res.Buffer, out var q)) continue; if (q.ProtoVersion != DevDiscovery.ProtoVersion) continue; if (q.Token != _token) continue; var reply = _template; // 值类型拷贝 reply.Nonce = q.Nonce; // 回显 nonce byte[] bytes = DevDiscoveryCodec.EncodeReply(reply); try { await _udp.SendAsync(bytes, bytes.Length, res.RemoteEndPoint).ConfigureAwait(false); } catch (SocketException) { } // 回包尽力而为 } } } public async Task StopAsync() { if (_cts == null) return; _cts.Cancel(); try { _udp.Close(); } catch { } if (_loop != null) { try { await _loop.ConfigureAwait(false); } catch { } } _cts.Dispose(); _cts = null; } } } ``` - [ ] **Step 4: 运行测试确认通过** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~DevDiscoveryResponderTests"` Expected: PASS(3 个) - [ ] **Step 5: 全量回归 + 提交** Run: `dotnet test Server/Server.sln` Expected: 153/153 ```bash git add Server/Gateway/DevDiscoveryResponder.cs Server/Gateway.Tests/DevDiscoveryResponderTests.cs git commit -m "feat(devtest): 服务端 UDP 测试服发现应答端 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: 接入 GameServerHost + Runner(TDD,默认关闭向后兼容) **Files:** - Modify: `Server/Gateway/GameServerHost.cs` - Modify: `Server/Gateway.Runner/Program.cs` - Test: `Server/Gateway.Tests/GameServerHostDiscoveryTests.cs`(新建) > 关键:不传 `devDiscoveryToken` 时**不启动** responder(`DevDiscoveryPort==0`),现有 94+ 个 Gateway/e2e 测试零回归。 - [ ] **Step 1: 写失败测试** Create `Server/Gateway.Tests/GameServerHostDiscoveryTests.cs`: ```csharp using System.Net; using System.Net.Sockets; using System.Threading.Tasks; using Xunit; using XWorld.Framework.Protocol; using XWorld.Server.Gateway; namespace XWorld.Server.Gateway.Tests { public class GameServerHostDiscoveryTests { [Fact] public async Task HostWithoutToken_NoResponder() { var host = new GameServerHost(); await host.StartAsync(TestGames.Root, tickIntervalMs: 50); try { Assert.Equal(0, host.DevDiscoveryPort); } finally { await host.StopAsync(); } } [Fact] public async Task HostWithToken_AnswersDiscovery_AdvertisesWsAndCdn() { var host = new GameServerHost(); await host.StartAsync(TestGames.Root, tickIntervalMs: 50, devDiscoveryToken: "secret", advertiseResourceBaseUrl: "http://192.168.1.50:15081/minigame/", devDiscoveryPort: 0); try { Assert.NotEqual(0, host.DevDiscoveryPort); using var client = new UdpClient(AddressFamily.InterNetwork); byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = 3, Token = "secret", }); await client.SendAsync(q, q.Length, new IPEndPoint(IPAddress.Loopback, host.DevDiscoveryPort)); var recv = client.ReceiveAsync(); var done = await Task.WhenAny(recv, Task.Delay(2000)); Assert.Same(recv, done); Assert.True(DevDiscoveryCodec.TryDecodeReply(recv.Result.Buffer, out var reply)); Assert.Equal(host.WsBaseUrl + "/ws", reply.GatewayUrl); // 默认广告 = 网关地址 + /ws Assert.Equal("http://192.168.1.50:15081/minigame/", reply.ResourceBaseUrl); } finally { await host.StopAsync(); } } } } ``` - [ ] **Step 2: 运行测试确认失败** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GameServerHostDiscoveryTests"` Expected: 编译失败(`DevDiscoveryPort` 属性、新参数不存在) - [ ] **Step 3: 改 GameServerHost** 在 `Server/Gateway/GameServerHost.cs` 顶部 using 区加一行: ```csharp using XWorld.Framework.Protocol; ``` 加字段(在 `private Task _loopTask;` 下面): ```csharp private DevDiscoveryResponder _discovery; public int DevDiscoveryPort => _discovery?.Port ?? 0; ``` 把 `StartAsync` 签名(第 29-30 行那段)替换为: ```csharp public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100, long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0, bool bindAllInterfaces = false, string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null, int devDiscoveryPort = DevDiscovery.Port) { ``` 把 Kestrel 绑定那行(原 `o.Listen(IPAddress.Loopback, listenPort)`)替换为: ```csharp builder.WebHost.ConfigureKestrel(o => o.Listen(bindAllInterfaces ? IPAddress.Any : IPAddress.Loopback, listenPort)); // 0 = 临时端口;LAN 真机需 bindAllInterfaces ``` 在 `_loopTask = _loop.RunAsync(...)` 那行**之前**插入 responder 启动: ```csharp if (!string.IsNullOrEmpty(devDiscoveryToken)) { string adGw = string.IsNullOrEmpty(advertiseGatewayUrl) ? WsBaseUrl + "/ws" : advertiseGatewayUrl; _discovery = new DevDiscoveryResponder( Environment.MachineName, adGw, advertiseResourceBaseUrl ?? "", devDiscoveryToken, devDiscoveryPort); _discovery.Start(); } ``` 把 `StopAsync` 改为先停 responder: ```csharp public async Task StopAsync() { if (_discovery != null) { await _discovery.StopAsync(); _discovery = null; } _cts.Cancel(); try { await _loopTask; } catch { /* 忽略取消 */ } await _app.StopAsync(); await _app.DisposeAsync(); } ``` - [ ] **Step 4: 运行测试确认通过** Run: `dotnet test Server/Server.sln --filter "FullyQualifiedName~GameServerHostDiscoveryTests"` Expected: PASS(2 个) - [ ] **Step 5: 改 Runner 加命令行参数** 在 `Server/Gateway.Runner/Program.cs` 的 `Main` 里,`var host = new GameServerHost();` **之前**加: ```csharp string devToken = GetArg(args, "--devToken"); string advertiseWs = GetArg(args, "--advertiseWs"); string advertiseCdn = GetArg(args, "--advertiseCdn"); bool lan = Array.Exists(args, a => string.Equals(a, "--lan", StringComparison.OrdinalIgnoreCase)); ``` 把 `await host.StartAsync(...)` 那行替换为: ```csharp await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port, bindAllInterfaces: lan, devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn); ``` 在那批 `Console.WriteLine` 之后加: ```csharp if (!string.IsNullOrEmpty(devToken)) Console.WriteLine(" dev-discovery: ON (UDP " + host.DevDiscoveryPort + "), advertise ws=" + (string.IsNullOrEmpty(advertiseWs) ? host.WsBaseUrl + "/ws" : advertiseWs) + " cdn=" + advertiseCdn); ``` - [ ] **Step 6: 全量回归 + 提交** Run: `dotnet test Server/Server.sln` Expected: 155/155(全部历史用例零回归) ```bash git add Server/Gateway/GameServerHost.cs Server/Gateway.Runner/Program.cs Server/Gateway.Tests/GameServerHostDiscoveryTests.cs git commit -m "feat(devtest): GameServerHost 按 token 选择性启动发现应答端 + Runner 参数 Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: 客户端覆盖存储 MiniGameTestOverride(Unity,csc 验证) **Files:** - Create: `Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs` > 全文件 `#if XW_DEVTEST` 包裹——生产包(无此 define)编译为空,读不到任何覆盖。 - [ ] **Step 1: 写实现** Create `Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs`: ```csharp #if XW_DEVTEST using UnityEngine; namespace XGame.MiniGame { // 开发期测试服覆盖配置(PlayerPrefs 持久化)。仅 dev 包(XW_DEVTEST)编入。 // 生产包不含本类,启动流程读不到覆盖,永远连生产地址。 public static class MiniGameTestOverride { private const string KActive = "xw_devtest_active"; private const string KGateway = "xw_devtest_gateway"; private const string KCdn = "xw_devtest_cdn"; private const string KName = "xw_devtest_name"; public static bool IsActive => PlayerPrefs.GetInt(KActive, 0) == 1; public static string GatewayUrl => PlayerPrefs.GetString(KGateway, ""); public static string CdnBase => PlayerPrefs.GetString(KCdn, ""); public static string ServerName => PlayerPrefs.GetString(KName, ""); public static void Apply(string gatewayUrl, string cdnBase, string serverName) { PlayerPrefs.SetInt(KActive, 1); PlayerPrefs.SetString(KGateway, gatewayUrl ?? ""); PlayerPrefs.SetString(KCdn, cdnBase ?? ""); PlayerPrefs.SetString(KName, serverName ?? ""); PlayerPrefs.Save(); Debug.Log($"[DevTest] 已切换到本地测试服 {serverName}: {gatewayUrl}"); } public static void Clear() { PlayerPrefs.DeleteKey(KActive); PlayerPrefs.DeleteKey(KGateway); PlayerPrefs.DeleteKey(KCdn); PlayerPrefs.DeleteKey(KName); PlayerPrefs.Save(); Debug.Log("[DevTest] 已复位到生产配置"); } } } #endif ``` - [ ] **Step 2: csc 编译验证(与 Task 7 一起统一编译)** 本任务无独立可运行验证;与 Task 5/6/7 一并在 Task 7 Step 末做 csc 编译验证(带 `-define:XW_DEVTEST` 与不带各编一次)。先确认本文件语法正确即可进入下一任务。 - [ ] **Step 3: 提交** ```bash git add Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs git commit -m "feat(devtest): 客户端测试服覆盖存储(PlayerPrefs) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 5: 客户端发现 DevServerDiscovery(Unity,csc 验证 + Play 核对) **Files:** - Create: `Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs` > 复用共享层 `DevDiscoveryCodec`。后台线程做阻塞 `Receive`(带 250ms 超时轮询),结果加锁收集,主线程 `TakeResults` 读。 - [ ] **Step 1: 写实现** Create `Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs`: ```csharp #if XW_DEVTEST using System; using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading; using UnityEngine; using XWorld.Framework.Protocol; namespace XGame.MiniGame { // 局域网测试服发现:广播 Query,后台线程收 Reply,主线程取结果。仅 dev 包。 public sealed class DevServerDiscovery { private readonly string _token; private readonly object _lock = new object(); private readonly List _results = new List(); private Thread _thread; private volatile bool _running; public DevServerDiscovery(string token) { _token = token; } public bool IsRunning => _running; // 启动一次发现(durationMs 后自动停止)。线程安全收集,主线程用 TakeResults 读。 public void Begin(int durationMs = 1200) { if (_running) return; _running = true; _thread = new Thread(() => Run(durationMs)) { IsBackground = true }; _thread.Start(); } private void Run(int durationMs) { UdpClient udp = null; try { udp = new UdpClient(AddressFamily.InterNetwork); udp.EnableBroadcast = true; udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); udp.Client.Bind(new IPEndPoint(IPAddress.Any, 0)); udp.Client.ReceiveTimeout = 250; uint nonce = (uint)Guid.NewGuid().GetHashCode(); byte[] query = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery { ProtoVersion = DevDiscovery.ProtoVersion, Nonce = nonce, Token = _token, }); udp.Send(query, query.Length, new IPEndPoint(IPAddress.Broadcast, DevDiscovery.Port)); var deadline = DateTime.UtcNow.AddMilliseconds(durationMs); while (_running && DateTime.UtcNow < deadline) { IPEndPoint from = new IPEndPoint(IPAddress.Any, 0); byte[] data; try { data = udp.Receive(ref from); } catch (SocketException) { continue; } // 250ms 超时无包,继续等 if (!DevDiscoveryCodec.TryDecodeReply(data, out var reply)) continue; if (reply.ProtoVersion != DevDiscovery.ProtoVersion) continue; if (reply.Nonce != nonce) continue; if (reply.Token != _token) continue; lock (_lock) { if (!_results.Exists(r => r.GatewayUrl == reply.GatewayUrl)) _results.Add(reply); } } } catch (Exception e) { Debug.LogWarning("[DevTest] 发现线程异常: " + e.Message); } finally { try { udp?.Close(); } catch { } _running = false; } } public List TakeResults() { lock (_lock) { return new List(_results); } } public void Stop() { _running = false; } } } #endif ``` - [ ] **Step 2: 提交(编译验证在 Task 7 统一做)** ```bash git add Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs git commit -m "feat(devtest): 客户端局域网测试服发现(UDP广播) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 6: 切换 UI 组件 DevServerSwitchUI(Unity,csc 验证 + Play 核对) **Files:** - Create: `Client/Assets/Script/xmain/MiniGame/DevServerSwitchUI.cs` > 可复用 MonoBehaviour:挂到启动 GameObject 上,开包自动发现→弹窗→应用覆盖;已切换时常驻横幅 + 「复位生产」。用 IMGUI(与现有 `PcLobbySmokeLauncher.OnGUI` 一致,不引入 UI 预制依赖)。 - [ ] **Step 1: 写实现** Create `Client/Assets/Script/xmain/MiniGame/DevServerSwitchUI.cs`: ```csharp #if XW_DEVTEST using System.Collections.Generic; using UnityEngine; using XWorld.Framework.Protocol; namespace XGame.MiniGame { // 挂在启动 GameObject 上:开包时自动发现局域网测试服,弹窗询问是否切换; // 已切换时常驻横幅 + 复位按钮。仅 dev 包(XW_DEVTEST)。 public sealed class DevServerSwitchUI : MonoBehaviour { public string Token = "xw-dev"; // 与服务器 --devToken 一致 public bool DiscoverOnStart = true; private DevServerDiscovery _discovery; private List _found = new List(); private bool _prompting; private void Start() { if (DiscoverOnStart && !MiniGameTestOverride.IsActive) BeginDiscovery(); } public void BeginDiscovery() { _found.Clear(); _prompting = false; _discovery = new DevServerDiscovery(Token); _discovery.Begin(1200); } private void Update() { if (_discovery != null && !_discovery.IsRunning && !_prompting) { _found = _discovery.TakeResults(); _discovery = null; if (_found.Count > 0) _prompting = true; } } private void OnGUI() { if (MiniGameTestOverride.IsActive) { GUI.color = new Color(1f, 0.85f, 0.2f); GUILayout.BeginArea(new Rect(8, 8, 760, 30)); GUILayout.BeginHorizontal(); GUILayout.Label($"● 测试模式 → {MiniGameTestOverride.ServerName} ({MiniGameTestOverride.GatewayUrl})"); if (GUILayout.Button("复位生产", GUILayout.Width(90))) MiniGameTestOverride.Clear(); GUILayout.EndHorizontal(); GUILayout.EndArea(); GUI.color = Color.white; return; } if (!_prompting) return; GUILayout.BeginArea(new Rect(8, 8, 760, 44 + _found.Count * 34), GUI.skin.box); GUILayout.Label("发现本地测试服,是否切换?(仅本机/同 WiFi 开发用)"); foreach (var r in _found) { GUILayout.BeginHorizontal(); GUILayout.Label($"{r.ServerName} {r.GatewayUrl}"); if (GUILayout.Button("切换", GUILayout.Width(80))) { MiniGameTestOverride.Apply(r.GatewayUrl, r.ResourceBaseUrl, r.ServerName); _prompting = false; } GUILayout.EndHorizontal(); } if (GUILayout.Button("忽略", GUILayout.Width(80))) _prompting = false; GUILayout.EndArea(); } } } #endif ``` - [ ] **Step 2: 提交(编译验证在 Task 7 统一做)** ```bash git add Client/Assets/Script/xmain/MiniGame/DevServerSwitchUI.cs git commit -m "feat(devtest): 测试服切换 UI(弹窗/横幅/复位) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 7: 启动器接入覆盖 + 统一 csc 编译验证(Unity) **Files:** - Modify: `Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs` > 让大厅连接的网关/CDN 在覆盖激活时优先读 `MiniGameTestOverride`;并在 dev 包给启动对象挂上 `DevServerSwitchUI`。覆盖里的网关是 `ws://ip:port/ws`(无 `?pid=`),消费时补上 `?pid=1`。 - [ ] **Step 1: 给启动器挂发现 UI(dev 包)** 在 `PcLobbySmokeLauncher.cs` 的 `Start()` 方法体最前面插入: ```csharp #if XW_DEVTEST if (GetComponent() == null) gameObject.AddComponent(); #endif ``` - [ ] **Step 2: 加网关解析助手并改用它** 在 `PcLobbySmokeLauncher.cs` 里新增方法(放在 `ResolvedCdnBase()` 旁): ```csharp private string ResolvedGatewayUrl() { #if XW_DEVTEST if (MiniGameTestOverride.IsActive && !string.IsNullOrEmpty(MiniGameTestOverride.GatewayUrl)) { string g = MiniGameTestOverride.GatewayUrl; return g.Contains("?") ? g : g + "?pid=1"; // 覆盖广告的是 /ws 基址,补 pid } #endif return GatewayUrl; } ``` 把 `ConnectLobby()` 开头两行改为用解析后的 URL: ```csharp public void ConnectLobby() { string url = ResolvedGatewayUrl(); _status = "connecting " + url; _socket?.close(); _socket = new XWebSocket(); _socket.Connect(url, (sock, err) => { ``` (其余 `Connect` 回调体不变。) - [ ] **Step 3: CDN 解析优先读覆盖** 把现有 `ResolvedCdnBase()` 方法体替换为: ```csharp private string ResolvedCdnBase() { #if XW_DEVTEST if (MiniGameTestOverride.IsActive && !string.IsNullOrEmpty(MiniGameTestOverride.CdnBase)) return MiniGameTestOverride.CdnBase; #endif return string.IsNullOrEmpty(CdnBase) ? DefaultCdnBase() : CdnBase; } ``` - [ ] **Step 4: csc 编译验证(两种 define 各一次)** 复用子项目 3 既定的 csc 验证方式(rsp 响应文件处理 Unity 2022.3.62f2 路径空格;引用全部 `UnityEngine*.dll` + 先产出的 `XWorld.Framework.Shared.dll`)。本任务把 `Client/Assets/Script/xmain/MiniGame/` 全部 .cs(含新增 4 文件)一起编译: 1. **带 `-define:XW_DEVTEST` 编译** → 期望 **0 错误**(发现/覆盖/UI/启动器改动全部参与编译,验证类型/API 真实可用,尤其 `System.Net.Sockets.UdpClient`、`DevDiscoveryCodec` 跨 asmdef 引用、`PlayerPrefs`、IMGUI)。 2. **不带 define 编译** → 期望 **0 错误**(验证生产包里 `#if` 块整体剔除后,`PcLobbySmokeLauncher` 的非守卫代码仍自洽——即覆盖逻辑确实只在 dev 包存在)。 Run(示意,`` 换成本机 Unity 2022.3.62f2 的 `Editor/Data/Managed/UnityEngine` 目录,沿用子项目 3 的 rsp): ```bash # 先产出 Framework.Shared.dll(Task1 已含 DevDiscovery.cs) dotnet build Server/Framework.Shared/Framework.Shared.csproj -c Release # A) dev 包编译 csc -nologo -target:library -define:XW_DEVTEST @unity_refs.rsp \ -reference:Server/Framework.Shared/bin/Release/netstandard2.1/XWorld.Framework.Shared.dll \ Client/Assets/Script/xmain/MiniGame/*.cs -out:/tmp/link_dev.dll # B) 生产包编译(无 define) csc -nologo -target:library @unity_refs.rsp \ -reference:Server/Framework.Shared/bin/Release/netstandard2.1/XWorld.Framework.Shared.dll \ Client/Assets/Script/xmain/MiniGame/*.cs -out:/tmp/link_prod.dll ``` Expected: 两次均 `0 error`。(若本环境无 Unity DLL,则标记为「待人工 csc 核对」,与子项目 3 同处理;逻辑/类型一致性已由共享层 xUnit 覆盖。) - [ ] **Step 5: 提交** ```bash git add Client/Assets/Script/xmain/MiniGame/PcLobbySmokeLauncher.cs git commit -m "feat(devtest): 大厅启动器接入测试服覆盖(网关/CDN)+挂发现UI Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 8: 端到端联调 + 运营注意事项 + 文档/记忆更新(人工) **Files:** - Modify: `docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md`(在 §9 后追加「开发期测试通路」小节) - Modify: `C:\Users\Administrator\.claude\projects\D--UD-AI-AIC-Project\memory\minigame-framework-progress.md`(追加本子项目进度 + 后续待办) - [ ] **Step 1: 起本地测试服(LAN 可达)** Run(开发机,`` 换成本机局域网 IP,如 192.168.1.23): ```bash dotnet run --project Server/Gateway.Runner -- --port 5005 --lan \ --devToken xw-dev \ --advertiseWs ws://:5005/ws \ --advertiseCdn http://:15081/minigame/ ``` 并在发布产物目录起一个 LAN 可达的静态资源服务(供客户端下小游戏 DLL/AB): ```bash # 在 PublishTool 产出的 client/ 目录下 cd /client && python -m http.server 15081 --bind 0.0.0.0 ``` 控制台应打印 `dev-discovery: ON (UDP 48923)`。 - [ ] **Step 2: dev 包真机/编辑器核对清单**(无法在本环境自动化,逐项人工确认) - [ ] 在 **Player Settings → Scripting Define Symbols** 给 dev 构建加 `XW_DEVTEST`;生产构建**不加**(确认生产包不含发现/覆盖代码)。 - [ ] **同一 WiFi** 下开 dev 包 → 自动弹「发现本地测试服 …,是否切换」→ 点「切换」→ 顶部出现黄色「● 测试模式」横幅。 - [ ] 切换后进小游戏:确认 `ResolvedGatewayUrl()` 连到 `ws://:5005/ws?pid=…`、`ResolvedCdnBase()` 从 `http://:15081/minigame/` 下载(抓包或看日志),即**网关与资源源同时切到本地、版本一致**。 - [ ] 点「复位生产」→ 横幅消失,重启后连回生产地址。 - [ ] **Android**:`networkSecurityConfig` 放行明文(连 `ws://`/`http://` LAN);必要时收广播加 `WifiManager.MulticastLock`。仅 dev 包开。 - [ ] **iOS**:Info.plist 配 `NSLocalNetworkUsageDescription`,首次弹「查找本地网络设备」授权——**不授权则发现静默失败**;ATS 例外放行明文。仅 dev 包开。 - [ ] **AP 隔离/跨网段**:广播收不到回应时,验证「无回应 → 不弹窗、走生产」不崩;记录此场景需手动输 IP 兜底(本计划未做手输 UI,列入后续待办)。 - [ ] **多开发机**:两台都起测试服 → 弹窗出现**多条**可选,点其一只切那台。 - [ ] **Step 3: 运营/安全复核** - [ ] 确认 `devToken` 只在 dev 构建配置/CI 注入,不写进生产网关启动参数(生产 `DevDiscoveryPort==0`)。 - [ ] 确认 Kestrel `--lan`(`IPAddress.Any`)只用于测试服;生产网关绑定与鉴权另议(现网关 `?pid=` 明文,见既有遗留)。 - [ ] **Step 4: 更新设计文档与记忆** 在 spec §9 后追加「开发期测试通路」小节:App 端 = UDP 广播发现 + 弹窗切本地(本计划);微信端 = 开发版直连 / 体验版+内网穿透 / 公网 staging(不在本计划)。 在 `minigame-framework-progress.md` 追加:本子项目已完成项(共享发现协议 + 服务端应答端 + 宿主接入,`dotnet test` 增至 155)、Unity 段 csc 验证状态、后续待办(手输 IP 兜底 UI、iOS 本地网络权限 Play 实测、Android MulticastLock 实测、微信端测试通路单独成项)。 - [ ] **Step 5: 提交** ```bash git add docs/superpowers/specs/2026-06-18-csharp-hotfix-minigame-architecture-design.md git commit -m "docs(devtest): 设计文档补充开发期测试通路(App广播发现/微信旁路) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## 自检(写完后对照 spec/讨论) - **覆盖范围**:① UDP 发现协议(T1)② 服务端应答(T2)③ 宿主接入、默认关、向后兼容(T3)④ 覆盖存储(T4)⑤ 客户端发现(T5)⑥ 弹窗确认+横幅+复位(T6)⑦ 网关+资源源同时切+版本一致(T7)⑧ dev-only 安全、平台坑、多机、兜底(T8)——讨论中的每条要点均有对应任务。 - **类型一致性**:`DevDiscoveryQuery/Reply` 字段名在编解码、responder、host 测试、客户端发现间一致;`DevDiscovery.Port/ProtoVersion/Magic/TypeQuery/TypeReply` 全程引用同一常量;`MiniGameTestOverride.Apply/Clear/IsActive/GatewayUrl/CdnBase/ServerName` 在 UI 与启动器间一致。 - **无占位符**:每个代码步骤给出完整代码与确切命令/期望输出。 - **已知边界(非缺陷,已在 T8 显式记录)**:微信端不在范围;手输 IP 兜底 UI 留后续;Unity 段运行时行为须 Play 核对(本环境只能 csc 验证),与子项目 3 既定交付方式一致。