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

1079 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.MiniGame``System.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 段全部 TDD`dotnet 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#、无 `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<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`:
```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: PASS5 个)
- [ ] **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) <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`:
```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<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`:
```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: PASS3 个)
- [ ] **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) <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` 时**不启动** 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: PASS2 个)
- [ ] **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) <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`:
```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) <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`:
```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<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 统一做)**
```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) <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`:
```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<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 统一做)**
```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) <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.cs``Start()` 方法体最前面插入:
```csharp
#if XW_DEVTEST
if (GetComponent<DevServerSwitchUI>() == null) gameObject.AddComponent<DevServerSwitchUI>();
#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_MANAGED>` 换成本机 Unity 2022.3.62f2 的 `Editor/Data/Managed/UnityEngine` 目录,沿用子项目 3 的 rsp):
```bash
# 先产出 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: 提交**
```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) <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):
```bash
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):
```bash
# 在 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/` 下载(抓包或看日志),即**网关与资源源同时切到本地、版本一致**。
- [ ] 点「复位生产」→ 横幅消失,重启后连回生产地址。
- [ ] **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) <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 既定交付方式一致。