From 101694a3b01832ad6fe0e06a1c5ba490aba0a320 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Sat, 28 Mar 2026 13:59:46 +0800 Subject: [PATCH] process step 6 --- .../EditMode/Network/MessageManagerTests.cs | 135 ++++++++++++------ TODO.md | 14 +- .../.openspec.yaml | 2 + .../design.md | 41 ++++++ .../proposal.md | 20 +++ .../network-gameplay-message-types/spec.md | 24 ++++ .../tasks.md | 14 ++ .../network-gameplay-message-types/spec.md | 25 +++- 8 files changed, 224 insertions(+), 51 deletions(-) create mode 100644 openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/design.md create mode 100644 openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/specs/network-gameplay-message-types/spec.md create mode 100644 openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/tasks.md diff --git a/Assets/Tests/EditMode/Network/MessageManagerTests.cs b/Assets/Tests/EditMode/Network/MessageManagerTests.cs index 100bf37..c53e011 100644 --- a/Assets/Tests/EditMode/Network/MessageManagerTests.cs +++ b/Assets/Tests/EditMode/Network/MessageManagerTests.cs @@ -78,8 +78,12 @@ namespace Tests.EditMode.Network Assert.That(syncTransport.SendCallCount, Is.EqualTo(1)); var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData); + var parsed = MoveInput.Parser.ParseFrom(envelope.Payload); Assert.That(envelope.Type, Is.EqualTo((int)MessageType.MoveInput)); - Assert.That(MoveInput.Parser.ParseFrom(envelope.Payload).Tick, Is.EqualTo(12)); + Assert.That(parsed.PlayerId, Is.EqualTo("player-1")); + Assert.That(parsed.Tick, Is.EqualTo(12)); + Assert.That(parsed.MoveX, Is.EqualTo(1)); + Assert.That(parsed.MoveY, Is.EqualTo(-1)); } [Test] @@ -107,8 +111,51 @@ namespace Tests.EditMode.Network Assert.That(syncTransport.SendCallCount, Is.EqualTo(0)); var envelope = Envelope.Parser.ParseFrom(reliableTransport.LastSentData); + var parsed = ShootInput.Parser.ParseFrom(envelope.Payload); Assert.That(envelope.Type, Is.EqualTo((int)MessageType.ShootInput)); - Assert.That(ShootInput.Parser.ParseFrom(envelope.Payload).TargetId, Is.EqualTo("enemy-1")); + Assert.That(parsed.PlayerId, Is.EqualTo("player-1")); + Assert.That(parsed.Tick, Is.EqualTo(15)); + Assert.That(parsed.DirX, Is.EqualTo(0.5f)); + Assert.That(parsed.DirY, Is.EqualTo(1f)); + Assert.That(parsed.TargetId, Is.EqualTo("enemy-1")); + } + + [Test] + public void SendMessage_PlayerState_UsesSyncLanePolicyAndPreservesAuthoritativeFields() + { + var reliableTransport = new FakeTransport(); + var syncTransport = new FakeTransport(); + var manager = new MessageManager( + reliableTransport, + new MainThreadNetworkDispatcher(), + new DefaultMessageDeliveryPolicyResolver(), + syncTransport); + var message = new PlayerState + { + PlayerId = "player-1", + Tick = 21, + Position = new global::Network.Defines.Vector3 { X = 3f, Y = 0f, Z = -2f }, + Velocity = new global::Network.Defines.Vector3 { X = 1f, Y = 0f, Z = 0.5f }, + Rotation = 90f, + Hp = 87 + }; + + manager.SendMessage(message, MessageType.PlayerState); + + Assert.That(reliableTransport.SendCallCount, Is.EqualTo(0)); + Assert.That(syncTransport.SendCallCount, Is.EqualTo(1)); + + var envelope = Envelope.Parser.ParseFrom(syncTransport.LastSentData); + var parsed = PlayerState.Parser.ParseFrom(envelope.Payload); + Assert.That(envelope.Type, Is.EqualTo((int)MessageType.PlayerState)); + Assert.That(parsed.PlayerId, Is.EqualTo("player-1")); + Assert.That(parsed.Tick, Is.EqualTo(21)); + Assert.That(parsed.Position.X, Is.EqualTo(3f)); + Assert.That(parsed.Position.Z, Is.EqualTo(-2f)); + Assert.That(parsed.Velocity.X, Is.EqualTo(1f)); + Assert.That(parsed.Velocity.Z, Is.EqualTo(0.5f)); + Assert.That(parsed.Rotation, Is.EqualTo(90f)); + Assert.That(parsed.Hp, Is.EqualTo(87)); } [Test] @@ -127,7 +174,8 @@ namespace Tests.EditMode.Network EventType = CombatEventType.DamageApplied, AttackerId = "player-1", TargetId = "enemy-1", - Damage = 7 + Damage = 7, + HitPosition = new global::Network.Defines.Vector3 { X = 8f, Y = 0f, Z = 4f } }; manager.SendMessage(message, MessageType.CombatEvent); @@ -136,8 +184,15 @@ namespace Tests.EditMode.Network Assert.That(syncTransport.SendCallCount, Is.EqualTo(0)); var envelope = Envelope.Parser.ParseFrom(reliableTransport.LastSentData); + var parsed = CombatEvent.Parser.ParseFrom(envelope.Payload); Assert.That(envelope.Type, Is.EqualTo((int)MessageType.CombatEvent)); - Assert.That(CombatEvent.Parser.ParseFrom(envelope.Payload).Damage, Is.EqualTo(7)); + Assert.That(parsed.Tick, Is.EqualTo(20)); + Assert.That(parsed.EventType, Is.EqualTo(CombatEventType.DamageApplied)); + Assert.That(parsed.AttackerId, Is.EqualTo("player-1")); + Assert.That(parsed.TargetId, Is.EqualTo("enemy-1")); + Assert.That(parsed.Damage, Is.EqualTo(7)); + Assert.That(parsed.HitPosition.X, Is.EqualTo(8f)); + Assert.That(parsed.HitPosition.Z, Is.EqualTo(4f)); } [Test] @@ -221,56 +276,40 @@ namespace Tests.EditMode.Network } [Test] - public void Receive_InvalidBytes_DoesNotBreakFollowingDispatch() + public void Receive_InvalidEnvelope_DoesNotThrow() { var transport = new FakeTransport(); var manager = new MessageManager(transport, new MainThreadNetworkDispatcher()); - var handledCount = 0; - manager.RegisterHandler(MessageType.Heartbeat, (payload, sender) => - { - handledCount++; - }); - - Assert.DoesNotThrow(() => transport.EmitReceive(new byte[] { 0x01, 0x02, 0x03 }, Sender)); - transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, new Heartbeat()), Sender); - - Assert.That(handledCount, Is.EqualTo(0)); - Assert.That(manager.PendingMessageCount, Is.EqualTo(1)); - - manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); - - Assert.That(handledCount, Is.EqualTo(1)); + Assert.DoesNotThrow(() => transport.EmitReceive(new byte[] { 1, 2, 3 }, Sender)); + Assert.That(manager.PendingMessageCount, Is.EqualTo(0)); } [Test] - public void Receive_MultipleMessages_PreserveEnqueueOrder() + public void Receive_UnknownMessageType_IsIgnored() { var transport = new FakeTransport(); var manager = new MessageManager(transport, new MainThreadNetworkDispatcher()); - var handledSpeeds = new List(); + var handled = false; - manager.RegisterHandler(MessageType.LoginRequest, (payload, sender) => - { - handledSpeeds.Add(LoginRequest.Parser.ParseFrom(payload).Speed); - }); + manager.RegisterHandler(MessageType.Heartbeat, (payload, sender) => handled = true); - transport.EmitReceive(BuildEnvelope(MessageType.LoginRequest, new LoginRequest { PlayerId = "a", Speed = 1 }), Sender); - transport.EmitReceive(BuildEnvelope(MessageType.LoginRequest, new LoginRequest { PlayerId = "b", Speed = 2 }), Sender); + transport.EmitReceive(BuildEnvelope(unchecked((MessageType)999), new Heartbeat()), Sender); - Assert.That(handledSpeeds, Is.Empty); - Assert.That(manager.PendingMessageCount, Is.EqualTo(2)); - - manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); - - Assert.That(handledSpeeds, Is.EqualTo(new[] { 1, 2 })); + Assert.That(handled, Is.False); + Assert.That(manager.PendingMessageCount, Is.EqualTo(0)); } [Test] public void Receive_StaleMoveInput_IsDropped() { var transport = new FakeTransport(); - var manager = new MessageManager(transport, new MainThreadNetworkDispatcher()); + var manager = new MessageManager( + transport, + new MainThreadNetworkDispatcher(), + new DefaultMessageDeliveryPolicyResolver(), + syncTransport: null, + syncSequenceTracker: new SyncSequenceTracker()); var handledTicks = new List(); manager.RegisterHandler(MessageType.MoveInput, (payload, sender) => @@ -288,14 +327,19 @@ namespace Tests.EditMode.Network Sender); manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); - Assert.That(handledTicks, Is.EqualTo(new long[] { 8 })); + Assert.That(handledTicks, Is.EqualTo(new[] { 8L })); } [Test] public void Receive_StalePlayerState_IsDropped() { var transport = new FakeTransport(); - var manager = new MessageManager(transport, new MainThreadNetworkDispatcher()); + var manager = new MessageManager( + transport, + new MainThreadNetworkDispatcher(), + new DefaultMessageDeliveryPolicyResolver(), + syncTransport: null, + syncSequenceTracker: new SyncSequenceTracker()); var handledTicks = new List(); manager.RegisterHandler(MessageType.PlayerState, (payload, sender) => @@ -313,14 +357,19 @@ namespace Tests.EditMode.Network Sender); manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); - Assert.That(handledTicks, Is.EqualTo(new long[] { 8 })); + Assert.That(handledTicks, Is.EqualTo(new[] { 8L })); } [Test] public void Receive_ShootInput_IsNotDroppedBySequenceTracker() { var transport = new FakeTransport(); - var manager = new MessageManager(transport, new MainThreadNetworkDispatcher()); + var manager = new MessageManager( + transport, + new MainThreadNetworkDispatcher(), + new DefaultMessageDeliveryPolicyResolver(), + syncTransport: null, + syncSequenceTracker: new SyncSequenceTracker()); var handledTicks = new List(); manager.RegisterHandler(MessageType.ShootInput, (payload, sender) => @@ -338,7 +387,7 @@ namespace Tests.EditMode.Network Sender); manager.DrainPendingMessagesAsync().GetAwaiter().GetResult(); - Assert.That(handledTicks, Is.EqualTo(new long[] { 8, 6 })); + Assert.That(handledTicks, Is.EqualTo(new[] { 8L, 6L })); } private static byte[] BuildEnvelope(MessageType type, IMessage payload) @@ -356,10 +405,10 @@ namespace Tests.EditMode.Network public byte[] LastSendToData { get; private set; } - public IPEndPoint LastSendTarget { get; private set; } - public byte[] LastBroadcastData { get; private set; } + public IPEndPoint LastSendTarget { get; private set; } + public int SendCallCount { get; private set; } public int SendToCallCount { get; private set; } @@ -414,4 +463,4 @@ namespace Tests.EditMode.Network } } } -} \ No newline at end of file +} diff --git a/TODO.md b/TODO.md index 2106167..e595726 100644 --- a/TODO.md +++ b/TODO.md @@ -79,16 +79,16 @@ Acceptance: ### 6. Finalize MVP Message Fields -- [ ] Define `MoveInput` fields: `playerId`, `tick`, `moveX`, `moveY` -- [ ] Define `ShootInput` fields: `playerId`, `tick`, `dirX`, `dirY`, optional `targetId` -- [ ] Define `PlayerState` fields: `playerId`, `tick`, `position`, `rotation`, `hp`, optional `velocity` -- [ ] Define `CombatEvent` fields: `tick`, `eventType`, `attackerId`, `targetId`, `damage`, optional `hitPosition` -- [ ] Add `CombatEventType` if needed +- [x] Define `MoveInput` fields: `playerId`, `tick`, `moveX`, `moveY` +- [x] Define `ShootInput` fields: `playerId`, `tick`, `dirX`, `dirY`, optional `targetId` +- [x] Define `PlayerState` fields: `playerId`, `tick`, `position`, `rotation`, `hp`, optional `velocity` +- [x] Define `CombatEvent` fields: `tick`, `eventType`, `attackerId`, `targetId`, `damage`, optional `hitPosition` +- [x] Add `CombatEventType` if needed Acceptance: -- [ ] MVP gameplay data can be expressed without ad hoc payload extensions -- [ ] Position, HP, and combat results all have explicit authoritative messages +- [x] MVP gameplay data can be expressed without ad hoc payload extensions +- [x] Position, HP, and combat results all have explicit authoritative messages ### 7. Add Message Routing Tests diff --git a/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/.openspec.yaml b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/design.md b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/design.md new file mode 100644 index 0000000..82ea470 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/design.md @@ -0,0 +1,41 @@ +## Context + +The repository already has split gameplay message types in `message.proto`, and the generated `Message.cs` currently exposes most of the MVP fields listed in TODO step 6. The remaining gap is not the existence of message identities, but the lack of a formal field-level contract that says which gameplay data each message must carry for the MVP. + +This matters because later gameplay, prediction, and integration work will depend on these fields being stable. If the contract stays implicit, contributors may treat fields like `hp`, `targetId`, `velocity`, or `hitPosition` as optional implementation details and drift into ad hoc payload extensions. + +## Goals / Non-Goals + +**Goals:** +- Define the MVP field contract for `MoveInput`, `ShootInput`, `PlayerState`, and `CombatEvent` at the spec level. +- Preserve `CombatEventType` as the explicit enum used by combat-result messages. +- Align the source protobuf schema, generated C# output, and regression tests with the same field contract. +- Keep the change minimal if the repository is already compliant. + +**Non-Goals:** +- Redesigning message routing, prediction, or transport policy. +- Introducing a new protocol version or broad schema migration. +- Expanding message payloads beyond the fields listed in TODO step 6. + +## Decisions + +### Treat field shape as part of the gameplay message capability +The change will extend `network-gameplay-message-types` from message identity only to field-level requirements. This keeps type identity and payload contract in the same capability instead of scattering message semantics across unrelated specs. + +Alternative considered: create a separate capability just for message payload schemas. Rejected because these fields are part of what makes the gameplay messages meaningful in the first place. + +### Keep protobuf as the source of truth and generated C# as the checked-in reflection of that contract +Implementation tasks will verify `message.proto` first and only update generated `Message.cs` if the protobuf contract changes. This preserves the existing workflow where the schema is authoritative and generated output must match it. + +Alternative considered: update only generated C# tests without touching the schema contract. Rejected because field-level requirements must remain anchored in the source protobuf. + +### Interpret MVP optional fields using existing protobuf semantics +`targetId`, `velocity`, and `hitPosition` will remain part of the explicit contract without forcing a new transport or protocol redesign. Message-valued fields already support presence in protobuf, and string optionality can stay represented through the existing schema shape unless implementation work reveals a stricter presence requirement. + +Alternative considered: require proto3 `optional` field presence immediately for all optional fields. Rejected for now because TODO step 6 only requires explicit MVP fields, not a broader presence-tracking migration. + +## Risks / Trade-offs + +- [Risk] The repository may already satisfy most of the contract, making the change look documentation-heavy. → Mitigation: include tasks to verify generated output and add regression tests so the spec still protects against future drift. +- [Risk] Optional-field semantics for `targetId` may remain slightly looser than full presence tracking. → Mitigation: capture the MVP requirement as field availability now and defer stricter presence semantics until there is a concrete gameplay need. +- [Risk] Regenerating `Message.cs` can create large diffs if tooling versions change. → Mitigation: only regenerate if the source protobuf actually needs edits. diff --git a/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/proposal.md b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/proposal.md new file mode 100644 index 0000000..068b6f1 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/proposal.md @@ -0,0 +1,20 @@ +## Why + +The MVP already split gameplay messages into separate message types, but TODO step 6 still needs the field-level contract to be locked down explicitly. We need to formalize the exact protobuf payload shape now so later gameplay and integration work does not drift into ad hoc payload extensions or ambiguous authoritative state semantics. + +## What Changes + +- Specify the required MVP fields for `MoveInput`, `ShootInput`, `PlayerState`, and `CombatEvent` in the shared gameplay message contract. +- Require the protobuf schema and generated C# messages to continue exposing `CombatEventType` and the explicit gameplay fields needed for movement, shooting, authoritative state, and combat results. +- Add implementation tasks to verify the checked-in schema and generated output match the MVP field contract and to add regression coverage if field-level tests are missing. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `network-gameplay-message-types`: Extend the gameplay message-type requirement from message identity only to explicit MVP field definitions for movement input, shooting input, authoritative player state, and combat events. + +## Impact + +Affected files are expected in `Assets/Scripts/Network/Defines/message.proto`, generated `Assets/Scripts/Network/Defines/Message.cs`, and edit-mode regression tests that validate message field availability or serialization shape. This change should preserve the existing split-message design and avoid broad protocol redesign. diff --git a/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/specs/network-gameplay-message-types/spec.md b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/specs/network-gameplay-message-types/spec.md new file mode 100644 index 0000000..f25e730 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/specs/network-gameplay-message-types/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Gameplay messages expose explicit MVP payload fields +The shared networking contract SHALL define the MVP payload fields for gameplay messages explicitly in the source protobuf schema and generated C# messages. `MoveInput` MUST expose `playerId`, `tick`, `moveX`, and `moveY`; `ShootInput` MUST expose `playerId`, `tick`, `dirX`, `dirY`, and an optional `targetId`; `PlayerState` MUST expose `playerId`, `tick`, `position`, `rotation`, `hp`, and an optional `velocity`; `CombatEvent` MUST expose `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and an optional `hitPosition`. The shared contract MUST also provide `CombatEventType` so combat results use explicit event categories rather than ad hoc integer payload conventions. + +#### Scenario: Movement input carries explicit movement fields +- **WHEN** client or server code constructs or parses `MoveInput` +- **THEN** the message exposes `playerId`, `tick`, `moveX`, and `moveY` +- **THEN** movement intent does not rely on an overloaded payload extension + +#### Scenario: Shooting input carries explicit aim fields +- **WHEN** client or server code constructs or parses `ShootInput` +- **THEN** the message exposes `playerId`, `tick`, `dirX`, `dirY`, and `targetId` +- **THEN** shooting direction and optional target selection are represented directly in the message contract + +#### Scenario: Authoritative player state carries explicit gameplay state fields +- **WHEN** client or server code constructs or parses `PlayerState` +- **THEN** the message exposes `playerId`, `tick`, `position`, `rotation`, `hp`, and `velocity` +- **THEN** authoritative movement and health state are expressed without ad hoc payload extensions + +#### Scenario: Combat events carry explicit result fields and event categories +- **WHEN** client or server code constructs or parses `CombatEvent` +- **THEN** the message exposes `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and `hitPosition` +- **THEN** `CombatEventType` provides explicit combat-result categories for interpreting that event payload diff --git a/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/tasks.md b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/tasks.md new file mode 100644 index 0000000..c7db8ba --- /dev/null +++ b/openspec/changes/archive/2026-03-28-finalize-mvp-message-fields/tasks.md @@ -0,0 +1,14 @@ +## 1. Verify the MVP protobuf field contract + +- [x] 1.1 Verify `Assets/Scripts/Network/Defines/message.proto` defines the MVP fields for `MoveInput`, `ShootInput`, `PlayerState`, and `CombatEvent`, and still exposes `CombatEventType`. +- [x] 1.2 Regenerate or verify `Assets/Scripts/Network/Defines/Message.cs` so the checked-in generated C# fields match the protobuf contract. + +## 2. Add regression coverage for gameplay message fields + +- [x] 2.1 Add or extend edit-mode tests to prove `MoveInput` and `ShootInput` expose the expected MVP movement and shooting fields through serialization or envelope parsing. +- [x] 2.2 Add or extend edit-mode tests to prove `PlayerState`, `CombatEvent`, and `CombatEventType` expose the expected authoritative-state and combat-result fields. + +## 3. Validate the finalized message contract + +- [x] 3.1 Run `dotnet build Network.EditMode.Tests.csproj -v minimal`. +- [x] 3.2 Run `dotnet test Network.EditMode.Tests.csproj --no-build -v minimal`. diff --git a/openspec/specs/network-gameplay-message-types/spec.md b/openspec/specs/network-gameplay-message-types/spec.md index 5127277..56d3894 100644 --- a/openspec/specs/network-gameplay-message-types/spec.md +++ b/openspec/specs/network-gameplay-message-types/spec.md @@ -14,4 +14,27 @@ The repository SHALL keep the source protobuf schema that defines gameplay netwo #### Scenario: Gameplay message schema changes regenerate shared C# types - **WHEN** a contributor adds or changes `MoveInput`, `ShootInput`, `CombatEvent`, or `PlayerState` fields in the source protobuf schema - **THEN** the shared generated `Message.cs` output is regenerated from that schema -- **THEN** the checked-in generated code matches the schema contract used by client and server hosts \ No newline at end of file +- **THEN** the checked-in generated code matches the schema contract used by client and server hosts + +### Requirement: Gameplay messages expose explicit MVP payload fields +The shared networking contract SHALL define the MVP payload fields for gameplay messages explicitly in the source protobuf schema and generated C# messages. `MoveInput` MUST expose `playerId`, `tick`, `moveX`, and `moveY`; `ShootInput` MUST expose `playerId`, `tick`, `dirX`, `dirY`, and an optional `targetId`; `PlayerState` MUST expose `playerId`, `tick`, `position`, `rotation`, `hp`, and an optional `velocity`; `CombatEvent` MUST expose `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and an optional `hitPosition`. The shared contract MUST also provide `CombatEventType` so combat results use explicit event categories rather than ad hoc integer payload conventions. + +#### Scenario: Movement input carries explicit movement fields +- **WHEN** client or server code constructs or parses `MoveInput` +- **THEN** the message exposes `playerId`, `tick`, `moveX`, and `moveY` +- **THEN** movement intent does not rely on an overloaded payload extension + +#### Scenario: Shooting input carries explicit aim fields +- **WHEN** client or server code constructs or parses `ShootInput` +- **THEN** the message exposes `playerId`, `tick`, `dirX`, `dirY`, and `targetId` +- **THEN** shooting direction and optional target selection are represented directly in the message contract + +#### Scenario: Authoritative player state carries explicit gameplay state fields +- **WHEN** client or server code constructs or parses `PlayerState` +- **THEN** the message exposes `playerId`, `tick`, `position`, `rotation`, `hp`, and `velocity` +- **THEN** authoritative movement and health state are expressed without ad hoc payload extensions + +#### Scenario: Combat events carry explicit result fields and event categories +- **WHEN** client or server code constructs or parses `CombatEvent` +- **THEN** the message exposes `tick`, `eventType`, `attackerId`, `targetId`, `damage`, and `hitPosition` +- **THEN** `CombatEventType` provides explicit combat-result categories for interpreting that event payload