1079 lines
44 KiB
Markdown
1079 lines
44 KiB
Markdown
# 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.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: 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) <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`:
|
||
|
||
```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: 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) <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`:
|
||
|
||
```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) <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`:
|
||
|
||
```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: 客户端发现 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<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 组件 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<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: 给启动器挂发现 UI(dev 包)**
|
||
|
||
在 `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.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) <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 既定交付方式一致。
|