Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .spec/knowledge/features/gameplay/entity-chat-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 实体。
Expand Down
30 changes: 30 additions & 0 deletions integration/entity-chat/verify-evidence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}` })
Expand Down Expand Up @@ -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' }
Expand Down Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessageEvent> window = host.ClientChatWindow("c-browser");
bool chatOk = window.Count == 101 && tick.AppliedTick == 1UL;
Expand All @@ -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<string, object?>
{
["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);
Expand All @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,19 @@ public static class ChatErrorCodes

/// <summary>The room world has already fail-stopped.</summary>
public const string WorldFaulted = "world_faulted";

/// <summary>InputCommand messageType or command-array shape is illegal.</summary>
public const string BadEnvelope = "bad_envelope";

/// <summary>CommandBlock.mappingId is unregistered or not kind=command.</summary>
public const string UnknownCommandType = "unknown_command_type";

/// <summary>payloadSha256 does not match the decoded payload bytes.</summary>
public const string BadPayloadHash = "bad_payload_hash";

/// <summary>payload is not valid LumioBinV1 for the mapping fieldOrder.</summary>
public const string UndecodablePayload = "undecodable_payload";

/// <summary>CommandBlock mappingId array is not strictly ascending unique.</summary>
public const string BlockOrderViolation = "block_order_violation";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>One CommandBlock of frozen <c>lumio.gameplay-envelope.v1</c> InputCommand.</summary>
/// <param name="MappingId">Registered mapping id. Chat tenant is <see cref="ChatMapping.InputMappingId"/>.</param>
/// <param name="Payload">LumioBinV1 payload as lowercase hex.</param>
/// <param name="PayloadSha256">SHA-256 of the decoded payload bytes, lowercase hex.</param>
public readonly record struct CommandBlock(string MappingId, string Payload, string PayloadSha256);

/// <summary>
/// Frozen InputCommand envelope consumed by the 101-entity host ingress.
/// ChatInput remains text-only after this envelope is decoded.
/// </summary>
public sealed class InputCommandEnvelope
{
/// <summary>Wire messageType for this envelope.</summary>
public const string MessageTypeName = "InputCommand";

/// <summary>boundedInput.maxCommandsPerEnvelope.</summary>
public const int MaxCommandsPerEnvelope = 16;

/// <summary>Creates an envelope from already-encoded command blocks.</summary>
public InputCommandEnvelope(string messageType, IReadOnlyList<CommandBlock> commands)
{
MessageType = messageType ?? string.Empty;
Commands = commands ?? Array.Empty<CommandBlock>();
}

/// <summary>Must be <see cref="MessageTypeName"/>.</summary>
public string MessageType { get; }

/// <summary>Command blocks. Chat ingress requires exactly one <c>chat.input</c>.</summary>
public IReadOnlyList<CommandBlock> Commands { get; }

/// <summary>Encodes a single chat.input CommandBlock using LumioBinV1 fieldOrder [text].</summary>
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))
});
}

/// <summary>
/// Validates messageType, mapping kind, payload digest, and LumioBinV1 text.
/// Hash mismatch is reported before any chat state is interpreted.
/// </summary>
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<CommandBlock> 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<byte>();
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<byte>();
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;
}
}
Loading
Loading