# 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 既定交付方式一致。