diff --git a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs index 2132a076e..4f7f804ee 100644 --- a/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs +++ b/Assets/_Project/Scripts/Authoring/Player/PlayerAuthoring.cs @@ -118,6 +118,11 @@ namespace ProjectM.Authoring sockets.Add(new AbilitySocket { SparkId = 0 }); AddComponent(entity); AddComponent(entity); + // Step 1b: per-socket effective-stats buffer (4 rows), folded each predicted tick by + // StatRecomputeSystem (additive; the legacy single EffectiveAbilityStats stays until steps 2/2.5). + var effSockets = AddBuffer(entity); + for (int sk2 = 0; sk2 < SocketId.Count; sk2++) + effSockets.Add(new EffectiveSocketStats()); } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs b/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs new file mode 100644 index 000000000..9f3c1a7ce --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs @@ -0,0 +1,27 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Per-SOCKET effective ability stats for the LANTERN 4-socket kit — one row per socket (buffer index = + /// socket 0..3), each the authored base of that socket's (from the + /// AbilityDatabase blob) folded with the player's shared band by + /// StatRecomputeSystem every predicted tick. The 4-wide analog of the single + /// (which is retired once AbilityFireSystem + the feel layer + /// migrate to this in steps 2/2.5). Derived/local, NOT replicated — both worlds recompute it + /// deterministically from the replicated socket loadout + modifier band, so it matches under prediction + /// without being in the snapshot. The per-player stays single. + /// Band semantics (Phase 1): the single per-player band (class traits + + /// boons) applies UNIFORMLY to all 4 sockets; per-socket/per-Spark warping is Phase 4. + /// + [InternalBufferCapacity(4)] + public struct EffectiveSocketStats : IBufferElementData + { + public float Damage; + public float ProjectileSpeed; + public float Range; + public float AutoTargetRange; + public float AutoTargetConeRadians; + public int CooldownTicks; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs.meta new file mode 100644 index 000000000..86c7af725 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/EffectiveSocketStats.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38f9e71803b1db84586fce59ff9cea7a \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/StatRecomputeSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/StatRecomputeSystem.cs index d9541de03..b70b349ca 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/StatRecomputeSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/StatRecomputeSystem.cs @@ -62,6 +62,36 @@ namespace ProjectM.Simulation }; } } + + // LANTERN Phase 1 (Step 1b): per-socket fold - each socket's Spark base folded with the SHARED + // StatModifier band into its EffectiveSocketStats row (uniform band; per-Spark warping is Phase 4). + // Separate query so this system stays under the 7-arg cap; the legacy single fold above stays + // until AbilityFireSystem + the feel layer migrate to the buffer (steps 2/2.5). + foreach (var (sockets, socketMods, effSockets) in + SystemAPI.Query, DynamicBuffer, DynamicBuffer>() + .WithAll()) + { + var effBuf = effSockets; // copy the buffer handle to a mutable local (foreach var is readonly -> CS1654) + for (int i = 0; i < SocketId.Count && i < sockets.Length && i < effBuf.Length; i++) + { + if (db.TryGetAbility(sockets[i].SparkId, out var sa)) + { + effBuf[i] = new EffectiveSocketStats + { + Damage = StatMath.Apply(sa.Damage, StatTarget.Damage, socketMods), + ProjectileSpeed = StatMath.Apply(sa.ProjectileSpeed, StatTarget.ProjectileSpeed, socketMods), + Range = StatMath.Apply(sa.Range, StatTarget.Range, socketMods), + AutoTargetRange = StatMath.Apply(sa.AutoTargetRange, StatTarget.AutoTargetRange, socketMods), + AutoTargetConeRadians = StatMath.Apply(sa.AutoTargetConeRadians, StatTarget.AutoTargetConeRadians, socketMods), + CooldownTicks = (int)math.round(StatMath.Apply(sa.CooldownTicks, StatTarget.CooldownTicks, socketMods)), + }; + } + else + { + effBuf[i] = default; + } + } + } } } } diff --git a/Assets/_Project/Tests/EditMode/StatRecomputeSystemTests.cs b/Assets/_Project/Tests/EditMode/StatRecomputeSystemTests.cs index 610bad4c5..f04b50bc9 100644 --- a/Assets/_Project/Tests/EditMode/StatRecomputeSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/StatRecomputeSystemTests.cs @@ -113,5 +113,47 @@ namespace ProjectM.Tests } finally { world.Dispose(); blob.Dispose(); } } + + [Test] + public void SocketFold_Folds_Per_Socket_With_Uniform_Band() + { + var world = new World("SocketFoldTestWorld"); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + var em = world.EntityManager; + var blob = BuildDb(); + try + { + var dbEntity = em.CreateEntity(typeof(AbilityDatabase)); + em.SetComponentData(dbEntity, new AbilityDatabase { Value = blob }); + + // player carrying the socket loadout + per-socket effective buffer (plus the legacy singles the + // first fold loop still needs during the step-1b transition). + var player = em.CreateEntity( + typeof(AbilityRef), typeof(CharacterStatsRef), typeof(StatModifier), + typeof(EffectiveAbilityStats), typeof(EffectiveCharacterStats), + typeof(AbilitySocket), typeof(EffectiveSocketStats), typeof(Simulate)); + em.SetComponentData(player, new AbilityRef { Id = AbilityPrimary }); + em.SetComponentData(player, new CharacterStatsRef { Id = CharDefault }); + var socketBuf = em.GetBuffer(player); + var effBuf = em.GetBuffer(player); + for (int i = 0; i < SocketId.Count; i++) { socketBuf.Add(new AbilitySocket { SparkId = 0 }); effBuf.Add(new EffectiveSocketStats()); } + socketBuf[1] = new AbilitySocket { SparkId = AbilityPrimary }; // socket 1 holds the Spark; 0/2/3 empty + // uniform band: +5 flat Damage applies to every socket's fold (Phase-1 band semantics). + em.GetBuffer(player).Add(new StatModifier { Target = (byte)StatTarget.Damage, Op = (byte)ModOp.Flat, Value = 5f }); + + group.Update(); + + var result = em.GetBuffer(player); + Assert.AreEqual(SocketId.Count, result.Length); + Assert.AreEqual(25f, result[1].Damage, 1e-3f, "socket 1: Primary base 20 + band 5"); + Assert.AreEqual(25f, result[1].ProjectileSpeed, 1e-3f); + Assert.AreEqual(12, result[1].CooldownTicks); + Assert.AreEqual(0f, result[0].Damage, 1e-3f, "empty socket 0 -> zeroed default"); + Assert.AreEqual(0f, result[2].Damage, 1e-3f, "empty socket 2 -> zeroed default"); + } + finally { world.Dispose(); blob.Dispose(); } + } } }