From 8308c278c1019719f5d05b13e24e5a35ad839f43 Mon Sep 17 00:00:00 2001 From: Cui Date: Wed, 2 Sep 2026 06:20:27 +0800 Subject: [PATCH] fix(entity-chat): admit chat via frozen InputCommand envelope GameRoomHost and the 101-entity suite consume lumio.gameplay-envelope.v1 chat.input CommandBlocks (mappingId, LumioBinV1 payload, payloadSha256). ChatInput stays text-only after decode. --- .../features/gameplay/entity-chat-harness.md | 2 +- integration/entity-chat/verify-evidence.mjs | 30 +++ .../EntityChatSuite.cs | 22 +- .../Chat/ChatErrorCodes.cs | 15 ++ .../Chat/InputCommandEnvelope.cs | 245 ++++++++++++++++++ .../EntityChat/GameRoomHost.cs | 16 +- .../EntityChatAcceptanceTests.cs | 22 +- .../InputCommandEnvelopeTests.cs | 121 +++++++++ 8 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/InputCommandEnvelope.cs create mode 100644 modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/InputCommandEnvelopeTests.cs diff --git a/.spec/knowledge/features/gameplay/entity-chat-harness.md b/.spec/knowledge/features/gameplay/entity-chat-harness.md index 2e0dab0..8a47fa9 100644 --- a/.spec/knowledge/features/gameplay/entity-chat-harness.md +++ b/.spec/knowledge/features/gameplay/entity-chat-harness.md @@ -17,7 +17,7 @@ metadata: ## 设计 -- **Gameplay 宿主**:`GameRoomHost` 只接受 C-3 已验证准入载荷,从不收用户名/口令。 +- **Gameplay 宿主**:`GameRoomHost` 只接受 C-3 已验证准入载荷,从不收用户名/口令。Chat 上行必须是冻结 `InputCommand`(`mappingId=chat.input` + LumioBinV1 `payload` + `payloadSha256`);宿主解码后再交给 text-only `ChatInput`。 - **Bot 启动器**:`Lumio.Game.EntityChat.Suite` 持有 Bot 工具私钥,向 Account Server 提交 `123456` 测试口令与工具凭证。 - **证据**:每轮 `evidence.json` 含 11 个场景、census、eventOrder、appliedTick;`integration/entity-chat/launcher.mjs` 跑两轮并对比。 - **BLOCKED**:Account Server 进程起不来时写 blocked 日志,不伪造 101 实体。 diff --git a/integration/entity-chat/verify-evidence.mjs b/integration/entity-chat/verify-evidence.mjs index 05f7d27..6b851c9 100644 --- a/integration/entity-chat/verify-evidence.mjs +++ b/integration/entity-chat/verify-evidence.mjs @@ -146,6 +146,20 @@ export function verifyRun(evidence, auditText = '') { } } + const s6 = scenario(evidence, 6) + if (s6.messageType !== 'InputCommand') { + failures.push({ check: 's6:messageType', message: `scenario 6 messageType=${s6.messageType}, expected InputCommand` }) + } + if (s6.mappingId !== 'chat.input') { + failures.push({ check: 's6:mappingId', message: `scenario 6 mappingId=${s6.mappingId}, expected chat.input` }) + } + if (!/^[0-9a-f]{64}$/.test(String(s6.payloadSha256 ?? ''))) { + failures.push({ check: 's6:payloadSha256', message: 'scenario 6 payloadSha256 must be lowercase sha256 hex' }) + } + if (!/^[0-9a-f]+$/.test(String(s6.payload ?? '')) || String(s6.payload ?? '').length < 8) { + failures.push({ check: 's6:payload', message: 'scenario 6 payload must be lowercase LumioBinV1 hex' }) + } + const s7 = scenario(evidence, 7) if (Number(s7.historyCountMax ?? 0) !== 0) { failures.push({ check: 's7:history', message: `snapshot historyCount=${s7.historyCountMax}` }) @@ -280,6 +294,13 @@ function goodEvidence() { for (let i = 1; i <= 11; i++) scenarios[String(i)] = { ok: true } scenarios['1'] = { ok: true, wrongPasswordCode: 'wrong_password' } scenarios['5'] = { ok: true, unauthorized: 'Unauthorized', invisible: 'Invisible', stale: 'StaleGeneration' } + scenarios['6'] = { + ok: true, + messageType: 'InputCommand', + mappingId: 'chat.input', + payload: '020000006767', + payloadSha256: '5dbd584f1718b8bcd0dab4abeea83169f4a990defab81a8316ed845798d92dab', + } scenarios['7'] = { ok: true, historyCountMax: 0, restoredWindow: 0 } scenarios['8'] = { ok: true } scenarios['9'] = { ok: true, tombstoned: true, staleARejected: true, entityA: '99' } @@ -346,6 +367,15 @@ test('好包:101 计数来自 host audit 去重而非常数', () => { assert.equal(report.census.total, 101) }) +test('好包缺 InputCommand envelope 字段必须 FAIL', () => { + const ev = goodEvidence() + delete ev.scenarios['6'].mappingId + delete ev.scenarios['6'].payloadSha256 + const report = verifyRun(ev, goodAudit()) + assert.equal(report.ok, false) + assert.ok(report.failures.some((f) => String(f.check).startsWith('s6'))) +}) + test('假 census 常数(无 per-entity 事件)必须 FAIL', () => { const evidence = goodEvidence() evidence.census = { total: 101, botCount: 100, playerCount: 1 } diff --git a/modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/EntityChatSuite.cs b/modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/EntityChatSuite.cs index ce718d6..1a77215 100644 --- a/modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/EntityChatSuite.cs +++ b/modules/server-gameplay/src/Lumio.Game.EntityChat.Suite/EntityChatSuite.cs @@ -234,12 +234,15 @@ await File.WriteAllTextAsync( ["stale"] = stale.Outcome.ToString(), }; + InputCommandEnvelope? firstEnvelope = null; foreach (string connection in connections.Keys) { - host.AdmitChatInput(connection, "hello-" + connections[connection]); + InputCommandEnvelope command = ChatCmd("hello-" + connections[connection]); + firstEnvelope ??= command; + host.AdmitChatInput(connection, command); } - host.AdmitChatInput("c-browser", "hello-browser"); + host.AdmitChatInput("c-browser", ChatCmd("hello-browser")); RoomTickResult tick = host.RunTick(MainRoom); IReadOnlyList window = host.ClientChatWindow("c-browser"); bool chatOk = window.Count == 101 && tick.AppliedTick == 1UL; @@ -252,11 +255,18 @@ await File.WriteAllTextAsync( appliedTicks.Add(ev.AppliedTick); } + CommandBlock firstBlock = firstEnvelope is not null && firstEnvelope.Commands.Count > 0 + ? firstEnvelope.Commands[0] + : default; scenarios["6"] = new Dictionary { ["ok"] = chatOk, ["eventCount"] = window.Count, ["appliedTick"] = tick.AppliedTick, + ["messageType"] = firstEnvelope?.MessageType, + ["mappingId"] = firstBlock.MappingId, + ["payload"] = firstBlock.Payload, + ["payloadSha256"] = firstBlock.PayloadSha256, }; ChatPersistSnapshot snapshot = host.CapturePersistSnapshot(MainRoom); @@ -274,8 +284,8 @@ await File.WriteAllTextAsync( ulong entityA = host.MustSelf("c-bot100").NetEntityId; host.Disconnect("c-bot100"); - ChatOperationResult rejected = host.AdmitChatInput("c-bot100", "while-down"); - host.AdmitChatInput("c-browser", "room-continues"); + ChatOperationResult rejected = host.AdmitChatInput("c-bot100", ChatCmd("while-down")); + host.AdmitChatInput("c-browser", ChatCmd("room-continues")); host.RunTick(MainRoom); AccountLoginResult reLogin = await AccountLoginClient.LoginOrRegisterAsync( account.Uri, "Bot100", AccountPortPin.TestPassword, botClaim, cancellationToken).ConfigureAwait(false); @@ -343,7 +353,7 @@ await File.WriteAllTextAsync( { host.Admit(IsoRoom, "iso-a", new VerifiedAdmission(isoAp.AccountId, isoAp.LoginName, isoAp.BotToolContext)); host.Admit(IsoRoom, "iso-b", new VerifiedAdmission(isoBp.AccountId, isoBp.LoginName, isoBp.BotToolContext)); - host.AdmitChatInput("iso-a", "iso-only"); + host.AdmitChatInput("iso-a", ChatCmd("iso-only")); host.RunTick(IsoRoom); AttributeQueryResult cross = host.QueryAttribute(new AttributeQueryRequest( AttributeQueryScope.ServerAuthoritative, IsoRoom, browserBinding.NetEntityId, "EntityIdentity.entityType")); @@ -449,6 +459,8 @@ await File.WriteAllTextAsync( return null; } + private static InputCommandEnvelope ChatCmd(string text) => InputCommandEnvelope.FromChatText(text); + private static int MaxHistory(ChatPersistEntity[] entities) { int max = 0; diff --git a/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/ChatErrorCodes.cs b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/ChatErrorCodes.cs index 471b270..f3b81aa 100644 --- a/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/ChatErrorCodes.cs +++ b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/ChatErrorCodes.cs @@ -20,4 +20,19 @@ public static class ChatErrorCodes /// The room world has already fail-stopped. public const string WorldFaulted = "world_faulted"; + + /// InputCommand messageType or command-array shape is illegal. + public const string BadEnvelope = "bad_envelope"; + + /// CommandBlock.mappingId is unregistered or not kind=command. + public const string UnknownCommandType = "unknown_command_type"; + + /// payloadSha256 does not match the decoded payload bytes. + public const string BadPayloadHash = "bad_payload_hash"; + + /// payload is not valid LumioBinV1 for the mapping fieldOrder. + public const string UndecodablePayload = "undecodable_payload"; + + /// CommandBlock mappingId array is not strictly ascending unique. + public const string BlockOrderViolation = "block_order_violation"; } diff --git a/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/InputCommandEnvelope.cs b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/InputCommandEnvelope.cs new file mode 100644 index 0000000..c692e04 --- /dev/null +++ b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/Chat/InputCommandEnvelope.cs @@ -0,0 +1,245 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; + +namespace Lumio.Game.ServerGameplay; + +/// One CommandBlock of frozen lumio.gameplay-envelope.v1 InputCommand. +/// Registered mapping id. Chat tenant is . +/// LumioBinV1 payload as lowercase hex. +/// SHA-256 of the decoded payload bytes, lowercase hex. +public readonly record struct CommandBlock(string MappingId, string Payload, string PayloadSha256); + +/// +/// Frozen InputCommand envelope consumed by the 101-entity host ingress. +/// ChatInput remains text-only after this envelope is decoded. +/// +public sealed class InputCommandEnvelope +{ + /// Wire messageType for this envelope. + public const string MessageTypeName = "InputCommand"; + + /// boundedInput.maxCommandsPerEnvelope. + public const int MaxCommandsPerEnvelope = 16; + + /// Creates an envelope from already-encoded command blocks. + public InputCommandEnvelope(string messageType, IReadOnlyList commands) + { + MessageType = messageType ?? string.Empty; + Commands = commands ?? Array.Empty(); + } + + /// Must be . + public string MessageType { get; } + + /// Command blocks. Chat ingress requires exactly one chat.input. + public IReadOnlyList Commands { get; } + + /// Encodes a single chat.input CommandBlock using LumioBinV1 fieldOrder [text]. + public static InputCommandEnvelope FromChatText(string text) + { + byte[] payload = EncodeUtf8Prefixed(text ?? string.Empty); + return new InputCommandEnvelope( + MessageTypeName, + new[] + { + new CommandBlock(ChatMapping.InputMappingId, ToHex(payload), Sha256Hex(payload)) + }); + } + + /// + /// Validates messageType, mapping kind, payload digest, and LumioBinV1 text. + /// Hash mismatch is reported before any chat state is interpreted. + /// + public static bool TryDecodeChatText(InputCommandEnvelope? envelope, out string text, out string errorCode) + { + text = string.Empty; + errorCode = ChatErrorCodes.BadEnvelope; + if (envelope is null + || !string.Equals(envelope.MessageType, MessageTypeName, StringComparison.Ordinal)) + { + return false; + } + + IReadOnlyList commands = envelope.Commands; + if (commands is null || commands.Count == 0 || commands.Count > MaxCommandsPerEnvelope) + { + errorCode = commands is null || commands.Count > MaxCommandsPerEnvelope + ? ChatErrorCodes.BadEnvelope + : ChatErrorCodes.UnknownCommandType; + return false; + } + + string? previous = null; + string? decoded = null; + foreach (CommandBlock block in commands) + { + if (string.IsNullOrEmpty(block.MappingId)) + { + errorCode = ChatErrorCodes.UnknownCommandType; + return false; + } + + if (previous is not null && string.CompareOrdinal(previous, block.MappingId) >= 0) + { + errorCode = ChatErrorCodes.BlockOrderViolation; + return false; + } + + previous = block.MappingId; + + if (!TryDecodeHex(block.Payload, out byte[] payload)) + { + errorCode = ChatErrorCodes.UndecodablePayload; + return false; + } + + if (!IsLowerSha256Hex(block.PayloadSha256) || !string.Equals(Sha256Hex(payload), block.PayloadSha256, StringComparison.Ordinal)) + { + errorCode = ChatErrorCodes.BadPayloadHash; + return false; + } + + if (!string.Equals(block.MappingId, ChatMapping.InputMappingId, StringComparison.Ordinal)) + { + errorCode = ChatErrorCodes.UnknownCommandType; + return false; + } + + if (decoded is not null) + { + errorCode = ChatErrorCodes.BadEnvelope; + return false; + } + + if (!TryDecodeUtf8Prefixed(payload, out decoded)) + { + errorCode = ChatErrorCodes.UndecodablePayload; + return false; + } + } + + if (decoded is null) + { + errorCode = ChatErrorCodes.UnknownCommandType; + return false; + } + + text = decoded; + errorCode = string.Empty; + return true; + } + + private static byte[] EncodeUtf8Prefixed(string text) + { + byte[] utf8 = Encoding.UTF8.GetBytes(text); + byte[] payload = new byte[4 + utf8.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)utf8.Length); + Buffer.BlockCopy(utf8, 0, payload, 4, utf8.Length); + return payload; + } + + private static bool TryDecodeUtf8Prefixed(byte[] payload, out string text) + { + text = string.Empty; + if (payload is null || payload.Length < 4) + { + return false; + } + + uint declared = BinaryPrimitives.ReadUInt32LittleEndian(payload); + if (declared != (uint)(payload.Length - 4)) + { + return false; + } + + text = Encoding.UTF8.GetString(payload, 4, payload.Length - 4); + return true; + } + + private static string Sha256Hex(byte[] payload) + { +#if NETSTANDARD2_1 + using SHA256 sha = SHA256.Create(); + return ToHex(sha.ComputeHash(payload)); +#else + return ToHex(SHA256.HashData(payload)); +#endif + } + + private static string ToHex(byte[] bytes) + { + var chars = new char[bytes.Length * 2]; + for (int i = 0; i < bytes.Length; i++) + { + byte value = bytes[i]; + chars[i * 2] = ToNibble(value >> 4); + chars[(i * 2) + 1] = ToNibble(value & 0xF); + } + + return new string(chars); + } + + private static char ToNibble(int value) => (char)(value < 10 ? '0' + value : 'a' + (value - 10)); + + private static bool IsLowerSha256Hex(string? value) + { + if (value is null || value.Length != 64) + { + return false; + } + + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if ((c < '0' || c > '9') && (c < 'a' || c > 'f')) + { + return false; + } + } + + return true; + } + + private static bool TryDecodeHex(string? hex, out byte[] bytes) + { + bytes = Array.Empty(); + if (string.IsNullOrEmpty(hex) || (hex.Length & 1) != 0) + { + return false; + } + + bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + int hi = FromNibble(hex[i * 2]); + int lo = FromNibble(hex[(i * 2) + 1]); + if (hi < 0 || lo < 0) + { + bytes = Array.Empty(); + return false; + } + + bytes[i] = (byte)((hi << 4) | lo); + } + + return true; + } + + private static int FromNibble(char c) + { + if (c >= '0' && c <= '9') + { + return c - '0'; + } + + if (c >= 'a' && c <= 'f') + { + return c - 'a' + 10; + } + + return -1; + } +} diff --git a/modules/server-gameplay/src/Lumio.Game.ServerGameplay/EntityChat/GameRoomHost.cs b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/EntityChat/GameRoomHost.cs index 550a483..0d86ec7 100644 --- a/modules/server-gameplay/src/Lumio.Game.ServerGameplay/EntityChat/GameRoomHost.cs +++ b/modules/server-gameplay/src/Lumio.Game.ServerGameplay/EntityChat/GameRoomHost.cs @@ -214,15 +214,23 @@ public int ExpireDue() } } - /// Queues text-only ChatInput from the bound connection for the next tick. - public ChatOperationResult AdmitChatInput(string connectionId, string text) + /// + /// Decodes a frozen InputCommand (chat.input) envelope, then queues ChatInput for the next tick. + /// Hash mismatch is rejected before any room/chat state is read. + /// + public ChatOperationResult AdmitChatInput(string connectionId, InputCommandEnvelope envelope) { if (!IsOwnerThread()) { - return OnOwner(() => AdmitChatInput(connectionId, text)); + return OnOwner(() => AdmitChatInput(connectionId, envelope)); } - if (string.IsNullOrEmpty(connectionId) || text is null) + if (!InputCommandEnvelope.TryDecodeChatText(envelope, out string text, out string envelopeError)) + { + return ChatOperationResult.Rejected(envelopeError); + } + + if (string.IsNullOrEmpty(connectionId)) { return ChatOperationResult.Rejected("invalid_request"); } diff --git a/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/EntityChatAcceptanceTests.cs b/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/EntityChatAcceptanceTests.cs index de9fd7d..df8d081 100644 --- a/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/EntityChatAcceptanceTests.cs +++ b/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/EntityChatAcceptanceTests.cs @@ -181,7 +181,7 @@ public void Scenario6_ChatPathUpdatesSenderAtNextTickAndAllRoomClientsDisplay() var host = NewHost(); AdmitFullRoom(host); - ChatOperationResult admitted = host.AdmitChatInput("c-bot01", "gg from bot"); + ChatOperationResult admitted = host.AdmitChatInput("c-bot01", ChatCmd("gg from bot")); Assert.Equal(ChatOperationKind.Admitted, admitted.Kind); Assert.Empty(host.ClientChatWindow("c-browser")); @@ -198,7 +198,7 @@ public void Scenario6_ChatPathUpdatesSenderAtNextTickAndAllRoomClientsDisplay() Assert.Equal(emitted.MessageId, otherBot.MessageId); Assert.Equal(emitted.RoomSequence, otherBot.RoomSequence); - ChatOperationResult browserChat = host.AdmitChatInput("c-browser", "hello from browser"); + ChatOperationResult browserChat = host.AdmitChatInput("c-browser", ChatCmd("hello from browser")); Assert.Equal(ChatOperationKind.Admitted, browserChat.Kind); RoomTickResult tick2 = host.RunTick(MainRoom); Assert.Equal(2, host.ClientChatWindow("c-bot01").Count); @@ -211,7 +211,7 @@ public void Scenario7_PersistSnapshotRestoresLastMessageWithoutChatHistory() { var host = NewHost(); AdmitFullRoom(host); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", "keep-me").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", ChatCmd("keep-me")).Kind); RoomTickResult tick = host.RunTick(MainRoom); Assert.Equal("keep-me", Assert.Single(host.ClientChatWindow("c-browser")).Text); @@ -236,15 +236,15 @@ public void Scenario8_ReconnectWithinFiveMinutesRebindsEntityAAndClearsWindow() { var host = NewHost(); AdmitFullRoom(host); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", "before-disconnect").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", ChatCmd("before-disconnect")).Kind); host.RunTick(MainRoom); Assert.Single(host.ClientChatWindow("c-bot01")); ulong entityA = host.MustSelf("c-bot01").NetEntityId; Assert.True(host.Disconnect("c-bot01")); - ChatOperationResult rejected = host.AdmitChatInput("c-bot01", "while-down"); + ChatOperationResult rejected = host.AdmitChatInput("c-bot01", ChatCmd("while-down")); Assert.Equal(ChatOperationKind.Rejected, rejected.Kind); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-browser", "room-continues").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-browser", ChatCmd("room-continues")).Kind); RoomTickResult continued = host.RunTick(MainRoom); Assert.Equal("room-continues", Assert.Single(continued.Events).Text); Assert.Equal(101, host.Census(MainRoom).Total); @@ -300,9 +300,9 @@ public void Scenario10_IsolationDoesNotCrossRoomBoundaries() Assert.Equal(2, host.Census(IsoRoom).Total); Assert.Equal(101, host.Census(MainRoom).Total); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", "main-only").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-bot01", ChatCmd("main-only")).Kind); host.RunTick(MainRoom); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("iso-a", "iso-only").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("iso-a", ChatCmd("iso-only")).Kind); host.RunTick(IsoRoom); Assert.Equal("main-only", Assert.Single(host.ClientChatWindow("c-browser")).Text); @@ -349,10 +349,10 @@ private static ScaleEvidence CaptureScaleRun() foreach (string name in BotLaunchNames.All) { string connection = "c-" + name.ToLowerInvariant(); - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput(connection, "hello-" + name).Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput(connection, ChatCmd("hello-" + name)).Kind); } - Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-browser", "hello-browser").Kind); + Assert.Equal(ChatOperationKind.Admitted, host.AdmitChatInput("c-browser", ChatCmd("hello-browser")).Kind); RoomTickResult tick = host.RunTick(MainRoom); IReadOnlyList window = host.ClientChatWindow("c-browser"); RoomCensus census = host.Census(MainRoom); @@ -365,6 +365,8 @@ private static ScaleEvidence CaptureScaleRun() tick.AppliedTick); } + private static InputCommandEnvelope ChatCmd(string text) => InputCommandEnvelope.FromChatText(text); + private static GameRoomHost NewHost() => new(TimeSpan.FromMinutes(5), new ManualMonotonicClock()); private static Dictionary AdmitFullRoom(GameRoomHost host) diff --git a/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/InputCommandEnvelopeTests.cs b/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/InputCommandEnvelopeTests.cs new file mode 100644 index 0000000..4bbc627 --- /dev/null +++ b/modules/server-gameplay/tests/Lumio.Game.ServerGameplay.Tests/InputCommandEnvelopeTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Reflection; +using Lumio.Game.ServerGameplay; +using Xunit; + +namespace Lumio.Game.ServerGameplay.Tests; + +public sealed class InputCommandEnvelopeTests +{ + [Fact] + public void FromChatTextGgMatchesFrozenLumioBinV1HashExample() + { + InputCommandEnvelope envelope = InputCommandEnvelope.FromChatText("gg"); + Assert.Equal("InputCommand", envelope.MessageType); + CommandBlock block = Assert.Single(envelope.Commands); + Assert.Equal("chat.input", block.MappingId); + Assert.Equal("020000006767", block.Payload); + Assert.Equal("5dbd584f1718b8bcd0dab4abeea83169f4a990defab81a8316ed845798d92dab", block.PayloadSha256); + } + + [Fact] + public void HostAdmitRequiresInputCommandEnvelopeNotRawText() + { + MethodInfo? raw = typeof(GameRoomHost).GetMethod( + "AdmitChatInput", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(string), typeof(string) }, + modifiers: null); + Assert.Null(raw); + + MethodInfo? envelope = typeof(GameRoomHost).GetMethod( + "AdmitChatInput", + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(string), typeof(InputCommandEnvelope) }, + modifiers: null); + Assert.NotNull(envelope); + } + + [Fact] + public void ValidChatInputEnvelopeIsAdmittedAndDecodedTextReachesTick() + { + var host = new GameRoomHost(TimeSpan.FromMinutes(5), new ManualClock()); + Assert.True(host.Admit("room-main", "c-bot01", Bot("Bot01")).Accepted); + + ChatOperationResult admitted = host.AdmitChatInput("c-bot01", InputCommandEnvelope.FromChatText("hello-Bot01")); + Assert.Equal(ChatOperationKind.Admitted, admitted.Kind); + + RoomTickResult tick = host.RunTick("room-main"); + ChatMessageEvent ev = Assert.Single(tick.Events); + Assert.Equal("hello-Bot01", ev.Text); + } + + [Fact] + public void BadPayloadHashIsRejectedBeforeAnyChatStateChange() + { + var host = new GameRoomHost(TimeSpan.FromMinutes(5), new ManualClock()); + Assert.True(host.Admit("room-main", "c-bot01", Bot("Bot01")).Accepted); + + InputCommandEnvelope valid = InputCommandEnvelope.FromChatText("hello-Bot01"); + CommandBlock block = Assert.Single(valid.Commands); + var tampered = new InputCommandEnvelope( + valid.MessageType, + new[] { new CommandBlock(block.MappingId, block.Payload, string.Concat("ab", block.PayloadSha256.AsSpan(2))) }); + + ChatOperationResult rejected = host.AdmitChatInput("c-bot01", tampered); + Assert.Equal(ChatOperationKind.Rejected, rejected.Kind); + Assert.Equal(ChatErrorCodes.BadPayloadHash, rejected.ErrorCode); + Assert.Empty(host.RunTick("room-main").Events); + } + + [Fact] + public void UnknownMappingIdIsRejectedAsUnknownCommandType() + { + var host = new GameRoomHost(TimeSpan.FromMinutes(5), new ManualClock()); + Assert.True(host.Admit("room-main", "c-bot01", Bot("Bot01")).Accepted); + + InputCommandEnvelope valid = InputCommandEnvelope.FromChatText("gg"); + CommandBlock block = Assert.Single(valid.Commands); + var unknown = new InputCommandEnvelope( + valid.MessageType, + new[] { new CommandBlock("chat.not-a-command", block.Payload, block.PayloadSha256) }); + + ChatOperationResult rejected = host.AdmitChatInput("c-bot01", unknown); + Assert.Equal(ChatOperationKind.Rejected, rejected.Kind); + Assert.Equal(ChatErrorCodes.UnknownCommandType, rejected.ErrorCode); + } + + [Fact] + public void WrongMessageTypeIsRejectedAsBadEnvelope() + { + var host = new GameRoomHost(TimeSpan.FromMinutes(5), new ManualClock()); + Assert.True(host.Admit("room-main", "c-bot01", Bot("Bot01")).Accepted); + + InputCommandEnvelope valid = InputCommandEnvelope.FromChatText("gg"); + var wrong = new InputCommandEnvelope("Delta", valid.Commands); + + ChatOperationResult rejected = host.AdmitChatInput("c-bot01", wrong); + Assert.Equal(ChatOperationKind.Rejected, rejected.Kind); + Assert.Equal(ChatErrorCodes.BadEnvelope, rejected.ErrorCode); + } + + private static VerifiedAdmission Bot(string loginName) + { + string hex = Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(loginName.PadRight(16, 'x'))).ToLowerInvariant(); + if (hex.Length < 32) + { + hex = hex.PadRight(32, '0'); + } + + return new VerifiedAdmission("acct_" + hex[..32], loginName, BotToolContext: true); + } + + private sealed class ManualClock : IHostMonotonicClock + { + public long Milliseconds { get; private set; } + + public void Advance(TimeSpan delta) => Milliseconds += (long)delta.TotalMilliseconds; + } +}