Files
AIC-Project/docs/superpowers/plans/2026-06-22-app-lan-devserver-discovery.md
T
2026-07-10 10:24:29 +08:00

44 KiB
Raw Blame History

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# 9Framework.Shared 共享层,手写 PacketWriter/PacketReader varint 编解码)、Unity 2022.3.62f2 mono(客户端 XGame.MiniGameSystem.Net.Sockets.UdpClient + UnityEngine IMGUI/PlayerPrefs)。

关键约定(与现有工程一致):

  • 共享层源码放 Client/Assets/Framework/Shared/**Server/Framework.Shared.csproj 用通配 <Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" /> 自动编进服务端,Unity 侧由 XWorld.Framework.Shared asmdef 编译——新增一个 .cs 即两端共用,无需改 csproj
  • 客户端新文件放 Client/Assets/Script/xmain/MiniGame/XGame.MiniGame,归 XWorld.Link asmdef,已引用 XWorld.Framework.Shared)。
  • .NET 段全部 TDDdotnet test Server/Server.sln,当前基线 145/145 必须只增不减)。Unity 段无法在本环境跑 Play,按子项目 3 既定方式:csc 编译验证 0 错误 + 显式 Unity Play 人工核对清单
  • 提交信息结尾加 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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#、无 UnityEngineFramework.Shared 是 noEngineReferences),只用 System + 同目录的 PacketWriter/PacketReader。落盘后服务端通过 csproj 通配自动编译,Unity 通过 asmdef 编译。

  • Step 1: 写失败测试

Create Server/Framework.Shared.Tests/DevDiscoveryCodecTests.cs:

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<byte>(), 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:

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: PASS5 个)

  • Step 5: 全量回归 + 提交

Run: dotnet test Server/Server.sln Expected: 150/150(基线 145 + 本任务 5

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) <noreply@anthropic.com>"

Task 2: 服务端 UDP 应答端 DevDiscoveryResponderTDD

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:

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<DevDiscoveryReply?> 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:

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: PASS3 个)

  • Step 5: 全量回归 + 提交

Run: dotnet test Server/Server.sln Expected: 153/153

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) <noreply@anthropic.com>"

Task 3: 接入 GameServerHost + RunnerTDD,默认关闭向后兼容)

Files:

  • Modify: Server/Gateway/GameServerHost.cs
  • Modify: Server/Gateway.Runner/Program.cs
  • Test: Server/Gateway.Tests/GameServerHostDiscoveryTests.cs(新建)

关键:不传 devDiscoveryToken不启动 responderDevDiscoveryPort==0),现有 94+ 个 Gateway/e2e 测试零回归。

  • Step 1: 写失败测试

Create Server/Gateway.Tests/GameServerHostDiscoveryTests.cs:

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 区加一行:

using XWorld.Framework.Protocol;

加字段(在 private Task _loopTask; 下面):

        private DevDiscoveryResponder _discovery;

        public int DevDiscoveryPort => _discovery?.Port ?? 0;

StartAsync 签名(第 29-30 行那段)替换为:

        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))替换为:

            builder.WebHost.ConfigureKestrel(o =>
                o.Listen(bindAllInterfaces ? IPAddress.Any : IPAddress.Loopback, listenPort)); // 0 = 临时端口;LAN 真机需 bindAllInterfaces

_loopTask = _loop.RunAsync(...) 那行之前插入 responder 启动:

            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

        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: PASS2 个)

  • Step 5: 改 Runner 加命令行参数

Server/Gateway.Runner/Program.csMain 里,var host = new GameServerHost(); 之前加:

            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(...) 那行替换为:

            await host.StartAsync(gamesRoot, tickMs, matchTimeoutTicks: matchTimeoutTicks, listenPort: port,
                bindAllInterfaces: lan,
                devDiscoveryToken: devToken, advertiseGatewayUrl: advertiseWs, advertiseResourceBaseUrl: advertiseCdn);

在那批 Console.WriteLine 之后加:

            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(全部历史用例零回归)

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) <noreply@anthropic.com>"

Task 4: 客户端覆盖存储 MiniGameTestOverrideUnitycsc 验证)

Files:

  • Create: Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs

全文件 #if XW_DEVTEST 包裹——生产包(无此 define)编译为空,读不到任何覆盖。

  • Step 1: 写实现

Create Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs:

#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: 提交
git add Client/Assets/Script/xmain/MiniGame/MiniGameTestOverride.cs
git commit -m "feat(devtest): 客户端测试服覆盖存储(PlayerPrefs)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 5: 客户端发现 DevServerDiscoveryUnitycsc 验证 + Play 核对)

Files:

  • Create: Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs

复用共享层 DevDiscoveryCodec。后台线程做阻塞 Receive(带 250ms 超时轮询),结果加锁收集,主线程 TakeResults 读。

  • Step 1: 写实现

Create Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs:

#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<DevDiscoveryReply> _results = new List<DevDiscoveryReply>();
        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<DevDiscoveryReply> TakeResults()
        {
            lock (_lock) { return new List<DevDiscoveryReply>(_results); }
        }

        public void Stop() { _running = false; }
    }
}
#endif
  • Step 2: 提交(编译验证在 Task 7 统一做)
git add Client/Assets/Script/xmain/MiniGame/DevServerDiscovery.cs
git commit -m "feat(devtest): 客户端局域网测试服发现(UDP广播)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 6: 切换 UI 组件 DevServerSwitchUIUnitycsc 验证 + 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:

#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<DevDiscoveryReply> _found = new List<DevDiscoveryReply>();
        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 统一做)
git add Client/Assets/Script/xmain/MiniGame/DevServerSwitchUI.cs
git commit -m "feat(devtest): 测试服切换 UI(弹窗/横幅/复位)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

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: 给启动器挂发现 UIdev 包)

PcLobbySmokeLauncher.csStart() 方法体最前面插入:

#if XW_DEVTEST
            if (GetComponent<DevServerSwitchUI>() == null) gameObject.AddComponent<DevServerSwitchUI>();
#endif
  • Step 2: 加网关解析助手并改用它

PcLobbySmokeLauncher.cs 里新增方法(放在 ResolvedCdnBase() 旁):

        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

        public void ConnectLobby()
        {
            string url = ResolvedGatewayUrl();
            _status = "connecting " + url;
            _socket?.close();
            _socket = new XWebSocket();
            _socket.Connect(url, (sock, err) =>
            {

(其余 Connect 回调体不变。)

  • Step 3: CDN 解析优先读覆盖

把现有 ResolvedCdnBase() 方法体替换为:

        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.UdpClientDevDiscoveryCodec 跨 asmdef 引用、PlayerPrefs、IMGUI)。
  2. 不带 define 编译 → 期望 0 错误(验证生产包里 #if 块整体剔除后,PcLobbySmokeLauncher 的非守卫代码仍自洽——即覆盖逻辑确实只在 dev 包存在)。

Run(示意,<UNITY_MANAGED> 换成本机 Unity 2022.3.62f2 的 Editor/Data/Managed/UnityEngine 目录,沿用子项目 3 的 rsp):

# 先产出 Framework.Shared.dllTask1 已含 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: 提交
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) <noreply@anthropic.com>"

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(开发机,<LAN-IP> 换成本机局域网 IP,如 192.168.1.23):

dotnet run --project Server/Gateway.Runner -- --port 5005 --lan \
  --devToken xw-dev \
  --advertiseWs ws://<LAN-IP>:5005/ws \
  --advertiseCdn http://<LAN-IP>:15081/minigame/

并在发布产物目录起一个 LAN 可达的静态资源服务(供客户端下小游戏 DLL/AB):

# 在 PublishTool 产出的 client/ 目录下
cd <publish-out>/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://<LAN-IP>:5005/ws?pid=…ResolvedCdnBase()http://<LAN-IP>:15081/minigame/ 下载(抓包或看日志),即网关与资源源同时切到本地、版本一致

  • 点「复位生产」→ 横幅消失,重启后连回生产地址。

  • AndroidnetworkSecurityConfig 放行明文(连 ws:///http:// LAN);必要时收广播加 WifiManager.MulticastLock。仅 dev 包开。

  • iOSInfo.plist 配 NSLocalNetworkUsageDescription,首次弹「查找本地网络设备」授权——不授权则发现静默失败;ATS 例外放行明文。仅 dev 包开。

  • AP 隔离/跨网段:广播收不到回应时,验证「无回应 → 不弹窗、走生产」不崩;记录此场景需手动输 IP 兜底(本计划未做手输 UI,列入后续待办)。

  • 多开发机:两台都起测试服 → 弹窗出现多条可选,点其一只切那台。

  • Step 3: 运营/安全复核

  • 确认 devToken 只在 dev 构建配置/CI 注入,不写进生产网关启动参数(生产 DevDiscoveryPort==0)。

  • 确认 Kestrel --lanIPAddress.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: 提交
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) <noreply@anthropic.com>"

自检(写完后对照 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 既定交付方式一致。