44 KiB
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用通配<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />自动编进服务端,Unity 侧由XWorld.Framework.Sharedasmdef 编译——新增一个 .cs 即两端共用,无需改 csproj。 - 客户端新文件放
Client/Assets/Script/xmain/MiniGame/(XGame.MiniGame,归XWorld.Linkasmdef,已引用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:
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: PASS(5 个)
- 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 应答端 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:
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: PASS(3 个)
- 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 + 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:
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: PASS(2 个)
- Step 5: 改 Runner 加命令行参数
在 Server/Gateway.Runner/Program.cs 的 Main 里,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: 客户端覆盖存储 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:
#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: 客户端发现 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:
#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 组件 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:
#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: 给启动器挂发现 UI(dev 包)
在 PcLobbySmokeLauncher.cs 的 Start() 方法体最前面插入:
#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 文件)一起编译:
- 带
-define:XW_DEVTEST编译 → 期望 0 错误(发现/覆盖/UI/启动器改动全部参与编译,验证类型/API 真实可用,尤其System.Net.Sockets.UdpClient、DevDiscoveryCodec跨 asmdef 引用、PlayerPrefs、IMGUI)。 - 不带 define 编译 → 期望 0 错误(验证生产包里
#if块整体剔除后,PcLobbySmokeLauncher的非守卫代码仍自洽——即覆盖逻辑确实只在 dev 包存在)。
Run(示意,<UNITY_MANAGED> 换成本机 Unity 2022.3.62f2 的 Editor/Data/Managed/UnityEngine 目录,沿用子项目 3 的 rsp):
# 先产出 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: 提交
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/下载(抓包或看日志),即网关与资源源同时切到本地、版本一致。 -
点「复位生产」→ 横幅消失,重启后连回生产地址。
-
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: 提交
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 既定交付方式一致。