process TODO.md step 5

This commit is contained in:
SepComet 2026-03-29 09:31:46 +08:00
parent f0064e2ae5
commit d3d25c8921
19 changed files with 3923 additions and 181 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ using Vector3 = UnityEngine.Vector3;
public sealed class ClientAuthoritativePlayerState
{
public ClientAuthoritativePlayerStateSnapshot Current { get; private set; }
public ClientCombatPresentationSnapshot CombatPresentation { get; private set; } = ClientCombatPresentationSnapshot.Empty;
public bool TryAccept(PlayerState state, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
@ -22,8 +23,67 @@ public sealed class ClientAuthoritativePlayerState
snapshot = new ClientAuthoritativePlayerStateSnapshot(state);
Current = snapshot;
CombatPresentation = CombatPresentation.WithAuthoritativeSnapshot(snapshot);
return true;
}
public bool TryApplyCombatEvent(CombatEvent combatEvent, string playerId, out ClientAuthoritativePlayerStateSnapshot snapshot, out ClientCombatPresentationSnapshot combatSnapshot)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
if (string.IsNullOrEmpty(playerId))
{
throw new ArgumentException("Player id is required.", nameof(playerId));
}
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var affectedPlayerId)
|| !string.Equals(affectedPlayerId, playerId, StringComparison.Ordinal))
{
snapshot = Current;
combatSnapshot = CombatPresentation;
return false;
}
Current = ApplyEventToCurrentSnapshot(combatEvent);
CombatPresentation = CombatPresentation.WithCombatEvent(combatEvent, Current);
snapshot = Current;
combatSnapshot = CombatPresentation;
return true;
}
private ClientAuthoritativePlayerStateSnapshot ApplyEventToCurrentSnapshot(CombatEvent combatEvent)
{
if (Current == null)
{
return null;
}
switch (combatEvent.EventType)
{
case CombatEventType.DamageApplied:
return CloneSnapshotWithHp(Mathf.Max(0, Current.Hp - Mathf.Max(0, combatEvent.Damage)));
case CombatEventType.Death:
return CloneSnapshotWithHp(0);
default:
return Current;
}
}
private ClientAuthoritativePlayerStateSnapshot CloneSnapshotWithHp(int hp)
{
if (Current == null)
{
return null;
}
var sourceState = Current.SourceState.Clone();
sourceState.Hp = hp;
return new ClientAuthoritativePlayerStateSnapshot(sourceState);
}
}
public sealed class ClientAuthoritativePlayerStateSnapshot
@ -60,3 +120,74 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
public Quaternion RotationQuaternion => Quaternion.Euler(0f, Rotation, 0f);
}
public sealed class ClientCombatPresentationSnapshot
{
public static readonly ClientCombatPresentationSnapshot Empty = new(false, CombatEventType.Unspecified, 0, 0, Vector3.zero, false);
public ClientCombatPresentationSnapshot(
bool hasLastEvent,
CombatEventType lastEventType,
long lastEventTick,
int lastDamage,
Vector3 lastHitPosition,
bool isDead)
{
HasLastEvent = hasLastEvent;
LastEventType = lastEventType;
LastEventTick = lastEventTick;
LastDamage = lastDamage;
LastHitPosition = lastHitPosition;
IsDead = isDead;
}
public bool HasLastEvent { get; }
public CombatEventType LastEventType { get; }
public long LastEventTick { get; }
public int LastDamage { get; }
public Vector3 LastHitPosition { get; }
public bool IsDead { get; }
public ClientCombatPresentationSnapshot WithAuthoritativeSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (snapshot == null)
{
return this;
}
return new ClientCombatPresentationSnapshot(
HasLastEvent,
LastEventType,
LastEventTick,
LastDamage,
LastHitPosition,
snapshot.Hp <= 0);
}
public ClientCombatPresentationSnapshot WithCombatEvent(CombatEvent combatEvent, ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
var isDead = combatEvent.EventType == CombatEventType.Death;
if (!isDead && snapshot != null)
{
isDead = snapshot.Hp <= 0;
}
return new ClientCombatPresentationSnapshot(
true,
combatEvent.EventType,
combatEvent.Tick,
combatEvent.Damage,
combatEvent.HitPosition != null ? combatEvent.HitPosition.ToVector3() : Vector3.zero,
isDead);
}
}

View File

@ -0,0 +1,28 @@
using System;
using Network.Defines;
public static class ClientCombatEventRouting
{
public static bool TryGetAffectedPlayerId(CombatEvent combatEvent, out string playerId)
{
if (combatEvent == null)
{
throw new ArgumentNullException(nameof(combatEvent));
}
switch (combatEvent.EventType)
{
case CombatEventType.ShootRejected:
playerId = combatEvent.AttackerId ?? string.Empty;
return !string.IsNullOrEmpty(playerId);
case CombatEventType.Hit:
case CombatEventType.DamageApplied:
case CombatEventType.Death:
playerId = combatEvent.TargetId ?? string.Empty;
return !string.IsNullOrEmpty(playerId);
default:
playerId = string.Empty;
return false;
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f12e9d15fab40204e9740ac5641c7b95
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -73,8 +73,31 @@ public class MasterManager : MonoBehaviour
else Debug.LogWarning("Player not found");
}
public void ApplyCombatEvent(CombatEvent combatEvent)
{
if (combatEvent == null)
{
Debug.LogWarning("CombatEvent is null");
return;
}
if (!ClientCombatEventRouting.TryGetAffectedPlayerId(combatEvent, out var playerId))
{
Debug.LogWarning($"CombatEvent has no routable player: {combatEvent.EventType}");
return;
}
if (_players.TryGetValue(playerId, out var player))
{
player.ApplyCombatEvent(combatEvent);
return;
}
Debug.LogWarning($"Player not found for CombatEvent: {playerId}");
}
public Player GetCurrentPlayer()
{
return _players[LocalPlayerId];
}
}
}

View File

@ -126,6 +126,7 @@ public class NetworkManager : MonoBehaviour
{
_networkRuntime.MessageManager.RegisterHandler(MessageType.LoginResponse, HandleLoginResponse);
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerState, HandlePlayerState);
_networkRuntime.MessageManager.RegisterHandler(MessageType.CombatEvent, HandleCombatEvent);
_networkRuntime.MessageManager.RegisterHandler(MessageType.HeartbeatResponse, HandleHeartbeatResponse);
_networkRuntime.MessageManager.RegisterHandler(MessageType.LogoutRequest, HandleLogoutRequest);
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerJoin, HandlePlayerJoin);
@ -178,6 +179,14 @@ public class NetworkManager : MonoBehaviour
}
}
private void HandleCombatEvent(byte[] data, IPEndPoint sender)
{
_networkRuntime.NotifyInboundActivity();
var combatEvent = CombatEvent.Parser.ParseFrom(data);
MasterManager.Instance.ApplyCombatEvent(combatEvent);
Debug.Log($"收到CombatEvent::Type={combatEvent.EventType},Attacker={combatEvent.AttackerId},Target={combatEvent.TargetId},Damage={combatEvent.Damage}");
}
private void HandleLogoutRequest(byte[] data, IPEndPoint sender)
{
_networkRuntime.NotifyInboundActivity();

View File

@ -6,6 +6,7 @@ public class Player : MonoBehaviour
//[SerializeField] private float _moveSpeed = 10f;
public string PlayerId { get; private set; } = "1001";
public ClientAuthoritativePlayerStateSnapshot AuthoritativeState => _authoritativeState.Current;
public ClientCombatPresentationSnapshot CombatPresentation => _authoritativeState.CombatPresentation;
[SerializeField] private MeshRenderer _meshRenderer;
[SerializeField] private Material[] _materials;
[SerializeField] private Camera _camera;
@ -56,10 +57,21 @@ public class Player : MonoBehaviour
return;
}
_playerUI?.SyncAuthoritativeState(snapshot);
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
_movement?.OnAuthoritativeState(snapshot);
}
public bool ApplyCombatEvent(CombatEvent combatEvent)
{
if (!_authoritativeState.TryApplyCombatEvent(combatEvent, PlayerId, out var snapshot, out var combatSnapshot))
{
return false;
}
_playerUI?.SyncAuthoritativeState(snapshot, combatSnapshot);
return true;
}
public void SyncTick(long serverTick)
{
_movement.SetServerTick(serverTick);

View File

@ -8,6 +8,8 @@ public class PlayerUI : MonoBehaviour
private Player _master;
private Camera _mainCamera;
private bool _isVisible = true;
private ClientAuthoritativePlayerStateSnapshot _authoritativeSnapshot;
private ClientCombatPresentationSnapshot _combatSnapshot = ClientCombatPresentationSnapshot.Empty;
public void Init(Player master)
{
@ -17,9 +19,11 @@ public class PlayerUI : MonoBehaviour
RefreshText();
}
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot, ClientCombatPresentationSnapshot combatSnapshot)
{
RefreshText(snapshot);
_authoritativeSnapshot = snapshot;
_combatSnapshot = combatSnapshot ?? ClientCombatPresentationSnapshot.Empty;
RefreshText();
}
private void FixedUpdate()
@ -47,19 +51,30 @@ public class PlayerUI : MonoBehaviour
_isVisible = false;
}
private void RefreshText(ClientAuthoritativePlayerStateSnapshot snapshot = null)
private void RefreshText()
{
if (_text == null || _master == null)
{
return;
}
if (snapshot == null)
if (_authoritativeSnapshot == null)
{
_text.text = _master.PlayerId;
_text.text = $"{_master.PlayerId}\nHP:?\nCombat:{FormatCombatLine()}";
return;
}
_text.text = $"{_master.PlayerId}\nHP:{snapshot.Hp} Tick:{snapshot.Tick}";
_text.text = $"{_master.PlayerId}\nHP:{_authoritativeSnapshot.Hp} Tick:{_authoritativeSnapshot.Tick}\nCombat:{FormatCombatLine()}";
}
private string FormatCombatLine()
{
if (_combatSnapshot == null || !_combatSnapshot.HasLastEvent)
{
return _combatSnapshot != null && _combatSnapshot.IsDead ? "Dead" : "None";
}
var deadSuffix = _combatSnapshot.IsDead ? " Dead" : string.Empty;
return $"{_combatSnapshot.LastEventType} Dmg:{_combatSnapshot.LastDamage} Tick:{_combatSnapshot.LastEventTick}{deadSuffix}";
}
}

View File

@ -156,6 +156,119 @@ namespace Tests.EditMode.Network
Assert.That(snapshot.SourceState.Hp, Is.EqualTo(88));
}
[Test]
public void ClientCombatEventRouting_DamageEvent_RoutesToTargetPlayer()
{
var routed = ClientCombatEventRouting.TryGetAffectedPlayerId(
new CombatEvent
{
EventType = CombatEventType.DamageApplied,
AttackerId = "player-a",
TargetId = "player-b",
Damage = 15
},
out var playerId);
Assert.That(routed, Is.True);
Assert.That(playerId, Is.EqualTo("player-b"));
}
[Test]
public void ClientCombatEventRouting_ShootRejected_RoutesToAttackerPlayer()
{
var routed = ClientCombatEventRouting.TryGetAffectedPlayerId(
new CombatEvent
{
EventType = CombatEventType.ShootRejected,
AttackerId = "player-a",
TargetId = "player-b"
},
out var playerId);
Assert.That(routed, Is.True);
Assert.That(playerId, Is.EqualTo("player-a"));
}
[Test]
public void ClientAuthoritativePlayerState_DamageCombatEvent_ReducesHpAndRecordsCombatResult()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 100 }, out _);
var applied = owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.DamageApplied,
AttackerId = "player-2",
TargetId = "player-1",
Damage = 30
},
"player-1",
out var snapshot,
out var combatSnapshot);
Assert.That(applied, Is.True);
Assert.That(snapshot, Is.Not.Null);
Assert.That(snapshot.Hp, Is.EqualTo(70));
Assert.That(owner.Current.Hp, Is.EqualTo(70));
Assert.That(combatSnapshot.HasLastEvent, Is.True);
Assert.That(combatSnapshot.LastEventType, Is.EqualTo(CombatEventType.DamageApplied));
Assert.That(combatSnapshot.LastDamage, Is.EqualTo(30));
Assert.That(combatSnapshot.IsDead, Is.False);
}
[Test]
public void ClientAuthoritativePlayerState_ShootRejected_LeavesHpUnchangedAndRecordsVisibility()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 90 }, out _);
var applied = owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 12,
EventType = CombatEventType.ShootRejected,
AttackerId = "player-1",
TargetId = "player-2"
},
"player-1",
out var snapshot,
out var combatSnapshot);
Assert.That(applied, Is.True);
Assert.That(snapshot.Hp, Is.EqualTo(90));
Assert.That(combatSnapshot.LastEventType, Is.EqualTo(CombatEventType.ShootRejected));
Assert.That(combatSnapshot.LastDamage, Is.EqualTo(0));
Assert.That(combatSnapshot.IsDead, Is.False);
}
[Test]
public void ClientAuthoritativePlayerState_DeathCombatEvent_MarksPlayerDeadAndAllowsLaterSnapshotRefresh()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 20 }, out _);
owner.TryApplyCombatEvent(
new CombatEvent
{
Tick = 11,
EventType = CombatEventType.Death,
AttackerId = "player-2",
TargetId = "player-1"
},
"player-1",
out var deathSnapshot,
out var deathCombatSnapshot);
var accepted = owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 12, Hp = 55 }, out var refreshedSnapshot);
Assert.That(deathSnapshot.Hp, Is.EqualTo(0));
Assert.That(deathCombatSnapshot.IsDead, Is.True);
Assert.That(accepted, Is.True);
Assert.That(refreshedSnapshot.Hp, Is.EqualTo(55));
Assert.That(owner.CombatPresentation.IsDead, Is.False);
}
[Test]
public void RemotePlayerSnapshotInterpolator_StaleOrDuplicateSnapshots_AreRejected()
{

View File

@ -26,7 +26,7 @@ Material:
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 2800000, guid: 79cfbbc88ce488e41aca90a3043b4786, type: 3}
m_Scale: {x: 2, y: 1}
m_Scale: {x: 10, y: 5}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
@ -50,7 +50,7 @@ Material:
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 2800000, guid: 79cfbbc88ce488e41aca90a3043b4786, type: 3}
m_Scale: {x: 2, y: 1}
m_Scale: {x: 10, y: 5}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
@ -83,7 +83,9 @@ Material:
m_Ints: []
m_Floats:
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BlendOp: 0
- _BumpScale: 1
- _ClearCoatMask: 0
@ -93,6 +95,7 @@ Material:
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 1
- _Glossiness: 0.5
@ -108,6 +111,7 @@ Material:
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _UVSec: 0
- _WorkflowMode: 1

26
TODO.md
View File

@ -2,7 +2,7 @@
## Goal
Make the current project actually satisfy the MVP described in [MobaSyncMVP.md](D:/Learn/GameLearn/UnityProjects/NetworkFW/MobaSyncMVP.md):
Make the current project actually satisfy the MVP described in [MobaSyncMVP.md](./MobaSyncMVP.md):
- Client sends only `MoveInput` and `ShootInput`
- Server owns gameplay truth for position, HP, combat resolution, and validation
@ -43,7 +43,7 @@ Still missing for MVP:
- [x] Keep `ShootInput` and `CombatEvent` on `ReliableOrdered`
- [x] Keep stale-drop logic only for `MoveInput` and `PlayerState`
- [x] Keep client prediction buffering limited to `MoveInput`
- [x] Keep dual-transport runtime construction in [`Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs)
- [x] Keep dual-transport runtime construction in [`Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs`](./Assets/Scripts/Network/NetworkApplication/NetworkIntegrationFactory.cs)
Acceptance:
@ -53,7 +53,7 @@ Acceptance:
### 2. Align Client Input Flow With MVP
- [x] Update [`Assets/Scripts/MovementComponent.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/MovementComponent.cs) so movement intent can send an explicit zero-vector stop message when the player releases input
- [x] Update [`Assets/Scripts/MovementComponent.cs`](./Assets/Scripts/MovementComponent.cs) so movement intent can send an explicit zero-vector stop message when the player releases input
- [x] Keep local prediction immediate for the controlled player
- [x] Add a client shooting input capture path
- [x] Add `NetworkManager.SendShootInput(...)`
@ -96,21 +96,21 @@ Acceptance:
### 5. Add Client-Side `CombatEvent` Handling
- [ ] Register a `CombatEvent` handler in [`Assets/Scripts/NetworkManager.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/NetworkManager.cs)
- [ ] Route combat results to the relevant player or combat presentation components
- [ ] Apply hit / damage / death / shoot-rejected results from server truth
- [ ] Keep local fire FX separate from authoritative damage and death resolution
- [ ] Add UI or debug output for combat-result visibility during MVP development
- [x] Register a `CombatEvent` handler in [`Assets/Scripts/NetworkManager.cs`](./Assets/Scripts/NetworkManager.cs)
- [x] Route combat results to the relevant player or combat presentation components
- [x] Apply hit / damage / death / shoot-rejected results from server truth
- [x] Keep local fire FX separate from authoritative damage and death resolution
- [x] Add UI or debug output for combat-result visibility during MVP development
Acceptance:
- [ ] `CombatEvent` updates HP, death state, or hit feedback on clients
- [ ] `ShootRejected` can be surfaced without client-side authoritative rollback logic
- [ ] Combat results are driven by server messages, not speculative client outcomes
- [x] `CombatEvent` updates HP, death state, or hit feedback on clients
- [x] `ShootRejected` can be surfaced without client-side authoritative rollback logic
- [x] Combat results are driven by server messages, not speculative client outcomes
### 6. Add A Real Server Startup / Integration Entry Point
- [ ] Add or update the runtime server bootstrap so production code actually constructs [`ServerNetworkHost`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs)
- [ ] Add or update the runtime server bootstrap so production code actually constructs [`ServerNetworkHost`](./Assets/Scripts/Network/NetworkHost/ServerNetworkHost.cs)
- [ ] Start both reliable and sync transports from the server integration layer
- [ ] Drain server pending messages on a regular loop
- [ ] Hook server lifecycle logging/diagnostics in the same way the client runtime does
@ -154,7 +154,7 @@ Acceptance:
### 9. Expand Regression Coverage From Network Layer To Gameplay Flow
- [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions
- [ ] Extend [`Assets/Tests/EditMode/Network/MessageManagerTests.cs`](./Assets/Tests/EditMode/Network/MessageManagerTests.cs) only as needed for lane policy regressions
- [ ] Add tests that cover explicit zero-input movement stop behavior
- [ ] Add tests for client `ShootInput` send routing
- [ ] Add tests for `CombatEvent` receive/apply behavior

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@ -0,0 +1,70 @@
## Context
The client currently has the full MVP wire contract for `CombatEvent`, but the Unity receive path stops at `PlayerState`. `NetworkManager` registers login, join, logout, heartbeat, and `PlayerState` handlers, while `Player` and `PlayerUI` only expose authoritative snapshot data that came from `PlayerState`. As a result, local muzzle flash can exist as cosmetic presentation, but server-truth combat outcomes such as damage, death, or `ShootRejected` are not surfaced on the client at all.
This step sits after authoritative player-state ownership and remote interpolation are already in place. The change must reuse that ownership model instead of inventing a second source of truth for HP or death, and it must avoid pulling future server-combat implementation details into the client-side receive/apply design. Shared networking contracts under `Assets/Scripts/Network/` already define the message and lane policy, so this change should stay on the Unity client side.
## Goals / Non-Goals
**Goals:**
- Register and route authoritative `CombatEvent` messages through the existing client receive path.
- Define one explicit client-side application point for authoritative combat outcomes so hit, damage, death, and `ShootRejected` are not handled ad hoc across unrelated presentation scripts.
- Let the client update owned authoritative player presentation state from server-confirmed combat results while keeping local firing FX cosmetic.
- Expose lightweight combat-result visibility in existing UI or diagnostics so MVP playtests can observe accepted damage, death, and rejected shots.
**Non-Goals:**
- Implement server-authoritative combat resolution or decide how the server emits each event.
- Redesign shared protobuf schemas, message lanes, or stale-drop rules for reliable gameplay events.
- Add speculative client-side rollback, lag compensation, or remote combat prediction.
- Replace `PlayerState` as the longer-lived authoritative snapshot source for movement and periodic HP correction.
## Decisions
### Decision: Route client combat events through `NetworkManager -> MasterManager -> Player`
`NetworkManager` will register a `CombatEvent` handler alongside `PlayerState`, then forward parsed events into player-owned presentation logic through `MasterManager`. `MasterManager` remains the player lookup boundary, and `Player` becomes the point that decides how a given authoritative combat result affects its owned state and presentation.
Why this approach:
- It matches the receive path already used for authoritative `PlayerState`.
- It keeps endpoint lookup and Unity object ownership out of `NetworkManager`.
- It avoids spreading combat-result handling across UI and movement components directly.
Alternative considered:
- Let `NetworkManager` update UI or scene objects directly. Rejected because it would bypass the current player ownership model and make later combat presentation harder to reason about.
### Decision: Keep `PlayerState` as the base authoritative snapshot, but allow authoritative combat-result deltas to update client-owned presentation state between snapshots
The client already owns one authoritative snapshot per player. `CombatEvent` handling will extend that player-owned state with authoritative combat-result application, such as subtracting confirmed damage from the current authoritative HP view, marking death state, or recording last combat-result metadata for presentation. The next accepted `PlayerState` remains allowed to refresh the full snapshot and correct any drift.
Why this approach:
- `CombatEvent` is itself authoritative server output, so applying it on the client does not create speculative gameplay truth.
- It gives the client immediate combat-result feedback without waiting for the next sync-lane `PlayerState`.
- It preserves the existing rule that periodic `PlayerState` snapshots can refresh the full authoritative state.
Alternative considered:
- Wait for `PlayerState` only and use `CombatEvent` for cosmetic feedback. Rejected because TODO step 5 explicitly requires client combat-result handling that can update HP or death state from server truth.
### Decision: Separate local fire FX from authoritative combat-result presentation
The controlled player may continue to play local fire FX immediately, but `CombatEvent` handling will own authoritative damage, death, hit feedback, and rejection feedback. `ShootRejected` will be surfaced as a client-visible authoritative result without introducing rollback of predicted movement or speculative state rewrites.
Why this approach:
- It preserves the MVP rule that cosmetic preplay is allowed, but gameplay truth is not.
- It keeps this step focused on receive/apply behavior rather than prediction rollback mechanics.
Alternative considered:
- Roll back or cancel local fire presentation on `ShootRejected`. Rejected because the MVP only requires authoritative visibility of rejection, not a full local combat preplay rollback system.
### Decision: Use lightweight player UI or diagnostics for development-time combat visibility
Existing MVP diagnostics such as `PlayerUI` should expose enough combat-result state to make damage, death, and `ShootRejected` visible during playtests. This should stay lightweight: a last combat-event line, death indicator, or similar output is enough.
Why this approach:
- It keeps observability close to the player object already showing authoritative HP.
- It avoids introducing a larger diagnostics framework for one MVP step.
Alternative considered:
- Defer visibility until end-to-end server combat exists. Rejected because the TODO step explicitly calls for combat-result visibility during development.
## Risks / Trade-offs
- [Applying `CombatEvent.damage` on top of the latest snapshot can be refreshed again by a later `PlayerState`] → Mitigation: treat `CombatEvent` as an immediate authoritative delta for presentation and let the next accepted `PlayerState` remain the full-state correction path.
- [A combat event may reference an attacker or target player that is not spawned locally] → Mitigation: route events through `MasterManager` and ignore or log missing-player references instead of throwing.
- [UI can accidentally become the owner of combat truth] → Mitigation: keep combat-result application in `Player` or a player-owned helper, and let UI only read derived authoritative state/diagnostic values.
- [`ShootRejected` handling could grow into a rollback system] → Mitigation: scope this step to surfacing rejection and keeping local fire FX cosmetic rather than retroactively undoing unrelated local state.

View File

@ -0,0 +1,24 @@
## Why
The MVP now has client input flow, authoritative `PlayerState`, and remote interpolation in place, but the client still has no authoritative receive/apply path for `CombatEvent`. TODO step 5 is the next dependency before server-authoritative combat can feel complete, because clients need one explicit place to consume server-truth hit, damage, death, and shoot-rejected outcomes without turning local fire FX into gameplay authority.
## What Changes
- Add a dedicated client-side combat-event handling capability that defines how the Unity client receives, routes, and applies authoritative `CombatEvent` messages.
- Register a client `CombatEvent` receive path in the gameplay runtime so combat results are delivered to the relevant player or presentation component instead of being ignored.
- Apply authoritative hit, damage, death, and `ShootRejected` outcomes on the client while keeping local muzzle flash or fire FX cosmetic and separate from authoritative gameplay resolution.
- Expose lightweight combat-result diagnostics or UI so MVP development can observe combat events and rejected shots during playtests.
## Capabilities
### New Capabilities
- `client-combat-event-handling`: Defines how the client receives, routes, applies, and exposes authoritative `CombatEvent` results for player presentation and development diagnostics.
### Modified Capabilities
- `client-authoritative-player-state`: Client-side authoritative player presentation expands from `PlayerState`-only updates to also consume authoritative combat outcomes that can change HP, death state, or related presentation truth.
## Impact
- Affected code: `Assets/Scripts/NetworkManager.cs`, `Assets/Scripts/MasterManager.cs`, `Assets/Scripts/Player.cs`, `Assets/Scripts/UI/PlayerUI.cs`, and any new client-side combat presentation helper introduced for routing or diagnostics.
- Affected behavior: The client begins reacting to server-authored `CombatEvent` messages for damage, death, hit feedback, and shot rejection while local fire FX remains cosmetic.
- Testing: Edit-mode regression coverage will need client receive/apply tests for `CombatEvent`, including routing, player-state updates, stale assumptions, and `ShootRejected` visibility.

View File

@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Client-owned authoritative player presentation can consume authoritative combat-result deltas
The client-owned authoritative player presentation model SHALL accept authoritative combat-result updates in addition to full `PlayerState` snapshots. Applying an authoritative `CombatEvent` for a player MUST be able to adjust the client-owned HP, death state, or related combat presentation truth for that player until a newer authoritative `PlayerState` snapshot refreshes the full state.
#### Scenario: Authoritative combat event updates owned player presentation state
- **WHEN** the client applies a `CombatEvent` that targets or otherwise affects a known player
- **THEN** that player's owned authoritative presentation state updates to reflect the authoritative combat result
- **THEN** a later accepted `PlayerState` snapshot remains allowed to refresh the full authoritative state for that player

View File

@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Client registers and routes authoritative combat events to player-owned presentation logic
The Unity client SHALL register a `CombatEvent` receive path in the gameplay runtime and route accepted authoritative combat results to the relevant player-owned presentation logic instead of ignoring them or applying them directly in transport-facing code.
#### Scenario: Incoming combat event is forwarded through the client gameplay runtime
- **WHEN** the client receives a `CombatEvent` message from the server
- **THEN** the gameplay receive path parses the message and routes it through the existing client player-management boundary
- **THEN** the relevant player-owned presentation logic receives the authoritative combat result for application or diagnostics
### Requirement: Client applies authoritative hit, damage, death, and rejection outcomes without making local fire FX authoritative
The Unity client SHALL apply authoritative `CombatEvent` outcomes for hit, damage, death, and `ShootRejected` as server-truth gameplay results. Any local muzzle flash, fire animation, or similar client fire FX MUST remain cosmetic and MUST NOT decide final damage, hit, death, or rejection outcomes before the authoritative event arrives.
#### Scenario: Damage or death event updates authoritative client combat presentation
- **WHEN** the client applies an authoritative `CombatEvent` whose `eventType` is `DamageApplied` or `Death`
- **THEN** the relevant player updates HP, death state, hit feedback, or comparable combat presentation from that authoritative event
- **THEN** the client does not wait for speculative local combat logic to decide that outcome
#### Scenario: Shoot rejection is surfaced without speculative rollback logic
- **WHEN** the controlled player receives an authoritative `CombatEvent` whose `eventType` is `ShootRejected`
- **THEN** the client surfaces that rejection through player presentation or diagnostics
- **THEN** the client does not need a speculative rollback system to make the rejection visible
### Requirement: MVP development diagnostics expose authoritative combat-result visibility
The Unity client SHALL expose lightweight UI or debug output that makes recent authoritative combat results observable during MVP development, including rejected shots and player death or damage outcomes where applicable.
#### Scenario: Development UI reflects latest authoritative combat result
- **WHEN** the client applies an authoritative combat result for a player
- **THEN** the current MVP UI or diagnostics update to show the most recent relevant combat-event information for that player
- **THEN** the displayed result is sourced from authoritative `CombatEvent` handling rather than speculative local combat logic

View File

@ -0,0 +1,16 @@
## 1. CombatEvent Receive Path
- [x] 1.1 Register a client-side `CombatEvent` handler in `Assets/Scripts/NetworkManager.cs` and route parsed authoritative combat events through the existing `NetworkManager -> MasterManager -> Player` gameplay boundary.
- [x] 1.2 Extend the player-management path so authoritative combat events are delivered to the relevant local and/or remote player instance safely, including missing-player guard behavior for MVP diagnostics.
## 2. Player-Owned Combat Application
- [x] 2.1 Add a player-owned client combat-result application path that can consume authoritative `CombatEvent` results and update HP, death state, hit feedback, or equivalent presentation truth without bypassing existing authoritative player ownership.
- [x] 2.2 Keep local fire FX cosmetic by ensuring `ShootRejected`, hit, damage, and death handling come from authoritative `CombatEvent` application rather than speculative local fire presentation.
- [x] 2.3 Expose lightweight MVP UI or debug visibility for recent authoritative combat results, including rejected shots and player damage/death changes where practical.
## 3. Verification
- [x] 3.1 Add or extend edit-mode tests for client `CombatEvent` receive routing and authoritative player-application behavior.
- [x] 3.2 Add or extend regression tests for `ShootRejected` visibility and authoritative HP/death or hit-feedback updates that this change introduces.
- [x] 3.3 Run the relevant validation flow and confirm the client-side `CombatEvent` handling path works in editor play/testing.

View File

@ -37,3 +37,11 @@ The client SHALL expose authoritative HP or comparable authoritative state infor
- **WHEN** the client accepts a `PlayerState` whose authoritative HP differs from the previously accepted snapshot
- **THEN** the relevant UI or diagnostics update to show the new authoritative HP value
- **THEN** the displayed value comes from authoritative `PlayerState` data rather than speculative local gameplay logic
### Requirement: Client-owned authoritative player presentation can consume authoritative combat-result deltas
The client-owned authoritative player presentation model SHALL accept authoritative combat-result updates in addition to full `PlayerState` snapshots. Applying an authoritative `CombatEvent` for a player MUST be able to adjust the client-owned HP, death state, or related combat presentation truth for that player until a newer authoritative `PlayerState` snapshot refreshes the full state.
#### Scenario: Authoritative combat event updates owned player presentation state
- **WHEN** the client applies a `CombatEvent` that targets or otherwise affects a known player
- **THEN** that player's owned authoritative presentation state updates to reflect the authoritative combat result
- **THEN** a later accepted `PlayerState` snapshot remains allowed to refresh the full authoritative state for that player

View File

@ -0,0 +1,34 @@
# client-combat-event-handling Specification
## Purpose
Define how the Unity client receives, routes, applies, and exposes authoritative `CombatEvent` results for player presentation and development diagnostics.
## Requirements
### Requirement: Client registers and routes authoritative combat events to player-owned presentation logic
The Unity client SHALL register a `CombatEvent` receive path in the gameplay runtime and route accepted authoritative combat results to the relevant player-owned presentation logic instead of ignoring them or applying them directly in transport-facing code.
#### Scenario: Incoming combat event is forwarded through the client gameplay runtime
- **WHEN** the client receives a `CombatEvent` message from the server
- **THEN** the gameplay receive path parses the message and routes it through the existing client player-management boundary
- **THEN** the relevant player-owned presentation logic receives the authoritative combat result for application or diagnostics
### Requirement: Client applies authoritative hit, damage, death, and rejection outcomes without making local fire FX authoritative
The Unity client SHALL apply authoritative `CombatEvent` outcomes for hit, damage, death, and `ShootRejected` as server-truth gameplay results. Any local muzzle flash, fire animation, or similar client fire FX MUST remain cosmetic and MUST NOT decide final damage, hit, death, or rejection outcomes before the authoritative event arrives.
#### Scenario: Damage or death event updates authoritative client combat presentation
- **WHEN** the client applies an authoritative `CombatEvent` whose `eventType` is `DamageApplied` or `Death`
- **THEN** the relevant player updates HP, death state, hit feedback, or comparable combat presentation from that authoritative event
- **THEN** the client does not wait for speculative local combat logic to decide that outcome
#### Scenario: Shoot rejection is surfaced without speculative rollback logic
- **WHEN** the controlled player receives an authoritative `CombatEvent` whose `eventType` is `ShootRejected`
- **THEN** the client surfaces that rejection through player presentation or diagnostics
- **THEN** the client does not need a speculative rollback system to make the rejection visible
### Requirement: MVP development diagnostics expose authoritative combat-result visibility
The Unity client SHALL expose lightweight UI or debug output that makes recent authoritative combat results observable during MVP development, including rejected shots and player death or damage outcomes where applicable.
#### Scenario: Development UI reflects latest authoritative combat result
- **WHEN** the client applies an authoritative combat result for a player
- **THEN** the current MVP UI or diagnostics update to show the most recent relevant combat-event information for that player
- **THEN** the displayed result is sourced from authoritative `CombatEvent` handling rather than speculative local combat logic