756 lines
28 KiB
C#
756 lines
28 KiB
C#
using AcDream.Core.Player;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Core.Items;
|
|
|
|
namespace AcDream.Runtime.Gameplay;
|
|
|
|
public readonly record struct RuntimeCharacterOwnershipSnapshot(
|
|
bool IsDisposed,
|
|
bool InternalSubscriptionsAttached,
|
|
int LearnedSpellCount,
|
|
int ActiveEnchantmentCount,
|
|
int DesiredComponentCount,
|
|
int FavoriteSpellCount,
|
|
int VitalCount,
|
|
int AttributeCount,
|
|
int SkillCount,
|
|
int PositionCount,
|
|
int PropertyCount,
|
|
bool OptionsAreDefaults,
|
|
bool MovementSkillsAreReset)
|
|
{
|
|
public bool IsConverged =>
|
|
IsDisposed
|
|
&& !InternalSubscriptionsAttached
|
|
&& LearnedSpellCount == 0
|
|
&& ActiveEnchantmentCount == 0
|
|
&& DesiredComponentCount == 0
|
|
&& FavoriteSpellCount == 0
|
|
&& VitalCount == 0
|
|
&& AttributeCount == 0
|
|
&& SkillCount == 0
|
|
&& PositionCount == 0
|
|
&& PropertyCount == 0
|
|
&& OptionsAreDefaults
|
|
&& MovementSkillsAreReset;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical presentation-independent owner for the local character's magic
|
|
/// and player-sheet state. The two objects form one lifetime group because
|
|
/// vital maxima read active enchantments from this exact spellbook.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterState : IDisposable
|
|
{
|
|
/// <summary>ACE Skill enum ordinal for Run (K-fix7 / pseudocode doc §5).</summary>
|
|
public const uint RunSkillId = 24u;
|
|
/// <summary>ACE Skill enum ordinal for Jump (K-fix7 / pseudocode doc §5).</summary>
|
|
public const uint JumpSkillId = 22u;
|
|
|
|
private bool _disposed;
|
|
private long _characterRevision;
|
|
private long _spellbookRevision;
|
|
private bool _internalSubscriptionsAttached;
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): the pre-<c>EnchantSkill</c> base
|
|
/// run/jump skill (formulaBonus+init+ranks, as parsed from
|
|
/// PlayerDescription) — kept so <see cref="RecomputeMovementSkills"/>
|
|
/// can re-derive the vitae/enchantment-adjusted value purely from a
|
|
/// spellbook change, without waiting for a fresh skill push. -1 =
|
|
/// unknown (mirrors <see cref="RuntimeMovementSkillState"/>'s own
|
|
/// sentinel convention).
|
|
/// </summary>
|
|
private int _runSkillBase = -1;
|
|
private int _jumpSkillBase = -1;
|
|
private PlayerSkillMath.AugmentationBonuses _movementSkillAugmentations;
|
|
|
|
public RuntimeCharacterState(SpellTable? spellTable = null)
|
|
{
|
|
Spellbook = new Spellbook(spellTable);
|
|
LocalPlayer = new LocalPlayerState(Spellbook);
|
|
Options = new RuntimeCharacterOptionsState();
|
|
MovementSkills = new RuntimeMovementSkillState();
|
|
View = new CharacterView(this);
|
|
Spellbook.StateChanged += OnSpellbookChanged;
|
|
Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement;
|
|
LocalPlayer.Changed += OnVitalChanged;
|
|
LocalPlayer.AttributeChanged += OnAttributeChanged;
|
|
LocalPlayer.CharacterChanged += OnCharacterChanged;
|
|
_internalSubscriptionsAttached = true;
|
|
}
|
|
|
|
public Spellbook Spellbook { get; }
|
|
public LocalPlayerState LocalPlayer { get; }
|
|
public RuntimeCharacterOptionsState Options { get; }
|
|
public RuntimeMovementSkillState MovementSkills { get; }
|
|
public IRuntimeCharacterView View { get; }
|
|
public bool IsDisposed => _disposed;
|
|
|
|
public RuntimeCharacterOwnershipSnapshot CaptureOwnership()
|
|
{
|
|
int favoriteCount = 0;
|
|
for (int tab = 0; tab < 8; tab++)
|
|
favoriteCount += Spellbook.GetFavorites(tab).Count;
|
|
|
|
int vitalCount = 0;
|
|
foreach (LocalPlayerState.VitalKind kind
|
|
in Enum.GetValues<LocalPlayerState.VitalKind>())
|
|
{
|
|
if (LocalPlayer.Get(kind) is not null)
|
|
vitalCount++;
|
|
}
|
|
|
|
int attributeCount = 0;
|
|
foreach (LocalPlayerState.AttributeKind kind
|
|
in Enum.GetValues<LocalPlayerState.AttributeKind>())
|
|
{
|
|
if (LocalPlayer.GetAttribute(kind) is not null)
|
|
attributeCount++;
|
|
}
|
|
|
|
PropertyBundle properties = LocalPlayer.Properties;
|
|
int propertyCount =
|
|
properties.Bools.Count
|
|
+ properties.Ints.Count
|
|
+ properties.Int64s.Count
|
|
+ properties.Floats.Count
|
|
+ properties.Strings.Count
|
|
+ properties.DataIds.Count
|
|
+ properties.InstanceIds.Count;
|
|
RuntimeCharacterOptionsSnapshot options = Options.Snapshot;
|
|
return new RuntimeCharacterOwnershipSnapshot(
|
|
_disposed,
|
|
_internalSubscriptionsAttached,
|
|
Spellbook.LearnedCount,
|
|
Spellbook.ActiveCount,
|
|
Spellbook.DesiredComponents.Count,
|
|
favoriteCount,
|
|
vitalCount,
|
|
attributeCount,
|
|
LocalPlayer.Skills.Count,
|
|
LocalPlayer.Positions.Count,
|
|
propertyCount,
|
|
options.Options1 == RuntimeCharacterOptionsState.DefaultOptions1
|
|
&& options.Options2
|
|
== RuntimeCharacterOptionsState.DefaultOptions2,
|
|
MovementSkills.RunSkill == -1
|
|
&& MovementSkills.JumpSkill == -1
|
|
&& MovementSkills.Burden == 0f
|
|
&& MovementSkills.CurrentStamina == -1
|
|
&& MovementSkills.OwnPwdBitfield == 0u
|
|
&& MovementSkills.PlayerKillerStatus == -1
|
|
&& MovementSkills.LastPkAttackTimestamp is null
|
|
&& _runSkillBase == -1
|
|
&& _jumpSkillBase == -1
|
|
&& _movementSkillAugmentations == default);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign P Slice P1 (2026-07-30): stores the pre-<c>EnchantSkill</c>
|
|
/// base run/jump skill (PlayerDescription's formulaBonus+init+ranks)
|
|
/// and pushes the augmentation/vitae/enchantment-adjusted result into
|
|
/// <see cref="MovementSkills"/> — the SAME call shape
|
|
/// <c>LiveSessionEventRouter</c>'s pre-P1 <c>onSkillsUpdated</c> callback
|
|
/// already used (<c>MovementSkills.Update(runSkill, jumpSkill)</c>), now
|
|
/// routed through the retail <c>CEnchantmentRegistry::EnchantSkill</c>
|
|
/// chain. A value < 0 leaves that half's base untouched (matches
|
|
/// <see cref="RuntimeMovementSkillState.Update"/>'s own "don't touch"
|
|
/// convention for a missing half).
|
|
/// </summary>
|
|
public void UpdateMovementSkillBase(int runSkillBase, int jumpSkillBase)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (runSkillBase >= 0) _runSkillBase = runSkillBase;
|
|
if (jumpSkillBase >= 0) _jumpSkillBase = jumpSkillBase;
|
|
RecomputeMovementSkills();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installs the player-quality augmentation terms consumed by retail
|
|
/// <c>CACQualities::InqRunRate</c>/<c>InqJumpVelocity</c>. The object
|
|
/// table remains authoritative for live PropertyInt updates; Runtime
|
|
/// retains only this immutable derived snapshot.
|
|
/// </summary>
|
|
public void UpdateMovementSkillAugmentations(
|
|
PlayerSkillMath.AugmentationBonuses augmentations)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
if (_movementSkillAugmentations == augmentations)
|
|
return;
|
|
_movementSkillAugmentations = augmentations;
|
|
RecomputeMovementSkills();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-derives the adjusted run/jump skill from the stored base plus the
|
|
/// CURRENT spellbook state (vitae + skill enchantments) — matching
|
|
/// retail's <c>CEnchantmentRegistry::EnchantSkill</c> (0x005947b0):
|
|
/// vitae multiplies first, then matching Skill-flagged mult/add
|
|
/// enchantments, floored to 0 below 0.5, truncated to int. Fires on
|
|
/// every base push AND on every <see cref="Spellbook.EnchantmentsChanged"/>
|
|
/// notification (a vitae/buff change alone must move the produced rate
|
|
/// without a fresh PlayerDescription).
|
|
/// </summary>
|
|
private void RecomputeMovementSkills()
|
|
{
|
|
int run = _runSkillBase >= 0
|
|
? CalculateMovementSkill(_runSkillBase, RunSkillId)
|
|
: -1;
|
|
int jump = _jumpSkillBase >= 0
|
|
? CalculateMovementSkill(_jumpSkillBase, JumpSkillId)
|
|
: -1;
|
|
// #266 apparatus (permanent, low-volume — fires only on skill-base or
|
|
// enchantment changes, the [snap] class): the full stat-chain state at
|
|
// each recompute. If vitae is active but runMod prints 1.0, the break
|
|
// is in GetSkillMod's record handling; if runMod is right but the
|
|
// felt speed doesn't change, the break is downstream of MovementSkills.
|
|
EnchantmentMath.VitalMod runMod = Spellbook.GetSkillMod(RunSkillId);
|
|
int activeCount = System.Linq.Enumerable.Count(Spellbook.ActiveEnchantments);
|
|
System.Console.WriteLine(
|
|
System.FormattableString.Invariant(
|
|
$"[stat-chain] base run={_runSkillBase} jump={_jumpSkillBase} runMod={runMod.Multiplier:F4}x+{runMod.Additive:F1} -> eff run={run} jump={jump} (activeEnchantments={activeCount})"));
|
|
MovementSkills.Update(run, jump);
|
|
}
|
|
|
|
private int CalculateMovementSkill(int baseSkill, uint skillId)
|
|
{
|
|
EnchantmentMath.VitalMod mod = Spellbook.GetSkillMod(skillId);
|
|
float vitae = EnchantmentMath.GetVitaeMultiplier(
|
|
Spellbook.ActiveEnchantments);
|
|
uint advancementClass = LocalPlayer.GetSkill(skillId)?.Status ?? 0u;
|
|
return PlayerSkillMath.Calculate(
|
|
baseSkill,
|
|
skillId,
|
|
advancementClass,
|
|
_movementSkillAugmentations,
|
|
mod,
|
|
vitae).EffectiveLevel;
|
|
}
|
|
|
|
private void OnEnchantmentsChangedForMovement() => RecomputeMovementSkills();
|
|
|
|
/// <summary>
|
|
/// Installs immutable DAT metadata without transferring its ownership to
|
|
/// Runtime. The content host may install one table after portal.dat opens.
|
|
/// </summary>
|
|
public void InstallSpellMetadata(SpellTable spellTable)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.InstallMetadata(spellTable);
|
|
}
|
|
|
|
public void ResetSpellbook()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
Spellbook.Clear();
|
|
}
|
|
|
|
public void ResetLocalPlayer()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
LocalPlayer.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply retail's local favorite insertion before sending the matching
|
|
/// character event.
|
|
/// </summary>
|
|
public bool TryAddFavorite(
|
|
int tabIndex,
|
|
int position,
|
|
uint spellId,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if ((uint)tabIndex >= 8u || position < 0 || spellId == 0u)
|
|
return false;
|
|
Spellbook.SetFavorite(tabIndex, position, spellId);
|
|
publishOutbound();
|
|
return true;
|
|
}
|
|
|
|
public bool TryRemoveFavorite(
|
|
int tabIndex,
|
|
uint spellId,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if ((uint)tabIndex >= 8u || spellId == 0u)
|
|
return false;
|
|
Spellbook.RemoveFavorite(tabIndex, spellId);
|
|
publishOutbound();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply and publish a spellbook filter only when it differs, matching
|
|
/// <c>gmSpellbookUI::UpdateFilter @ 0x0048B5E0</c>.
|
|
/// </summary>
|
|
public void SetSpellbookFilter(
|
|
uint filters,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if (Spellbook.SpellbookFilters == filters)
|
|
return;
|
|
Spellbook.SetSpellbookFilters(filters);
|
|
publishOutbound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retail publishes the desired-component event before changing its local
|
|
/// PlayerModule table.
|
|
/// </summary>
|
|
public bool TrySetDesiredComponent(
|
|
uint componentId,
|
|
uint amount,
|
|
Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
if (componentId == 0u || amount > 5000u)
|
|
return false;
|
|
try
|
|
{
|
|
publishOutbound();
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.SetDesiredComponent(componentId, amount);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public void ClearDesiredComponents(Action publishOutbound)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
ArgumentNullException.ThrowIfNull(publishOutbound);
|
|
try
|
|
{
|
|
publishOutbound();
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.ClearDesiredComponents();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears both coupled owners while retaining every failed suffix for a
|
|
/// retry. State mutation happens before the existing synchronous
|
|
/// invalidation callbacks, so retrying is safe and convergent.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
List<Exception>? failures = null;
|
|
Try(Spellbook.Clear, ref failures);
|
|
Try(LocalPlayer.Clear, ref failures);
|
|
Try(Options.ResetSession, ref failures);
|
|
_runSkillBase = -1;
|
|
_jumpSkillBase = -1;
|
|
_movementSkillAugmentations = default;
|
|
Try(MovementSkills.ResetSession, ref failures);
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Runtime character state did not converge during reset.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
List<Exception>? failures = null;
|
|
try
|
|
{
|
|
// Do not call ResetSession as one opaque step here. Terminal
|
|
// disposal must run every suffix even when an external UI observer
|
|
// throws from one Core owner's synchronous clear notification.
|
|
Try(Spellbook.Clear, ref failures);
|
|
Try(LocalPlayer.Clear, ref failures);
|
|
Try(Options.ResetSession, ref failures);
|
|
_runSkillBase = -1;
|
|
_jumpSkillBase = -1;
|
|
_movementSkillAugmentations = default;
|
|
Try(MovementSkills.ResetSession, ref failures);
|
|
}
|
|
finally
|
|
{
|
|
Spellbook.StateChanged -= OnSpellbookChanged;
|
|
Spellbook.EnchantmentsChanged -= OnEnchantmentsChangedForMovement;
|
|
LocalPlayer.Changed -= OnVitalChanged;
|
|
LocalPlayer.AttributeChanged -= OnAttributeChanged;
|
|
LocalPlayer.CharacterChanged -= OnCharacterChanged;
|
|
_internalSubscriptionsAttached = false;
|
|
_disposed = true;
|
|
}
|
|
if (failures is not null)
|
|
{
|
|
throw new AggregateException(
|
|
"Runtime character state did not converge during disposal.",
|
|
failures);
|
|
}
|
|
}
|
|
|
|
private void OnSpellbookChanged() =>
|
|
Interlocked.Increment(ref _spellbookRevision);
|
|
|
|
private void OnVitalChanged(LocalPlayerState.VitalKind _) =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private void OnCharacterChanged() =>
|
|
Interlocked.Increment(ref _characterRevision);
|
|
|
|
private static void Try(Action action, ref List<Exception>? failures)
|
|
{
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
(failures ??= []).Add(error);
|
|
}
|
|
}
|
|
|
|
private sealed class CharacterView(RuntimeCharacterState owner)
|
|
: IRuntimeCharacterView
|
|
{
|
|
public RuntimeCharacterSnapshot Snapshot => new(
|
|
Interlocked.Read(ref owner._characterRevision),
|
|
Interlocked.Read(ref owner._spellbookRevision),
|
|
owner.Options.Snapshot,
|
|
owner.MovementSkills.Snapshot,
|
|
owner.Spellbook.LearnedSpells.Count,
|
|
owner.Spellbook.ActiveEnchantments.Count(),
|
|
owner.Spellbook.DesiredComponents.Count,
|
|
owner.LocalPlayer.Skills.Count,
|
|
owner.Spellbook.SpellbookFilters);
|
|
|
|
public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital)
|
|
{
|
|
if (!Enum.IsDefined((LocalPlayerState.VitalKind)kind)
|
|
|| owner.LocalPlayer.Get((LocalPlayerState.VitalKind)kind)
|
|
is not LocalPlayerState.VitalSnapshot current)
|
|
{
|
|
vital = default;
|
|
return false;
|
|
}
|
|
|
|
vital = new RuntimeVitalSnapshot(
|
|
kind,
|
|
current.Ranks,
|
|
current.Start,
|
|
current.Xp,
|
|
current.Current,
|
|
owner.LocalPlayer.GetMaxApprox(
|
|
(LocalPlayerState.VitalKind)kind) ?? 0u);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetAttribute(
|
|
int kind,
|
|
out RuntimeAttributeSnapshot attribute)
|
|
{
|
|
if (!Enum.IsDefined((LocalPlayerState.AttributeKind)kind)
|
|
|| owner.LocalPlayer.GetAttribute(
|
|
(LocalPlayerState.AttributeKind)kind)
|
|
is not LocalPlayerState.AttributeSnapshot current)
|
|
{
|
|
attribute = default;
|
|
return false;
|
|
}
|
|
|
|
attribute = new RuntimeAttributeSnapshot(
|
|
kind,
|
|
current.Ranks,
|
|
current.Start,
|
|
current.Xp,
|
|
current.Current);
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetSkill(uint skillId, out RuntimeSkillSnapshot skill)
|
|
{
|
|
if (owner.LocalPlayer.GetSkill(skillId)
|
|
is not LocalPlayerState.SkillSnapshot current)
|
|
{
|
|
skill = default;
|
|
return false;
|
|
}
|
|
|
|
skill = new RuntimeSkillSnapshot(
|
|
current.SkillId,
|
|
current.Ranks,
|
|
current.Status,
|
|
current.Xp,
|
|
current.Init,
|
|
current.Resistance,
|
|
current.LastUsed,
|
|
current.FormulaBonus,
|
|
current.CurrentLevel);
|
|
return true;
|
|
}
|
|
|
|
public bool KnowsSpell(uint spellId) =>
|
|
owner.Spellbook.Knows(spellId);
|
|
|
|
public bool TryGetFavorite(
|
|
int tabIndex,
|
|
int position,
|
|
out uint spellId)
|
|
{
|
|
IReadOnlyList<uint> favorites =
|
|
owner.Spellbook.GetFavorites(tabIndex);
|
|
if ((uint)position >= (uint)favorites.Count)
|
|
{
|
|
spellId = 0u;
|
|
return false;
|
|
}
|
|
spellId = favorites[position];
|
|
return true;
|
|
}
|
|
|
|
public bool TryGetDesiredComponent(
|
|
uint componentId,
|
|
out uint amount) =>
|
|
owner.Spellbook.DesiredComponents.TryGetValue(
|
|
componentId,
|
|
out amount);
|
|
}
|
|
}
|
|
|
|
public readonly record struct RuntimeCharacterOptionsSnapshot(
|
|
uint Options1,
|
|
uint Options2,
|
|
long Revision)
|
|
{
|
|
public bool DragItemOnPlayerOpensSecureTrade =>
|
|
(Options1
|
|
& (uint)PlayerDescriptionParser.CharacterOptions1
|
|
.DragItemOnPlayerOpensSecureTrade) != 0u;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical session-owned copy of retail's two character-option bitfields.
|
|
/// <c>PlayerModule::PlayerModule @ 0x005D51F0</c> installs the defaults.
|
|
/// Runtime reset restores the equivalent fresh-player-module state because
|
|
/// one Runtime owner survives across graphical and no-window sessions.
|
|
/// </summary>
|
|
public sealed class RuntimeCharacterOptionsState
|
|
{
|
|
public const uint DefaultOptions1 =
|
|
(uint)PlayerDescriptionParser.CharacterOptions1.Default;
|
|
public const uint DefaultOptions2 = 0x00948700u;
|
|
|
|
private uint _options1 = DefaultOptions1;
|
|
private uint _options2 = DefaultOptions2;
|
|
private long _revision;
|
|
|
|
public uint Options1 => Volatile.Read(ref _options1);
|
|
public uint Options2 => Volatile.Read(ref _options2);
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
public RuntimeCharacterOptionsSnapshot Snapshot =>
|
|
new(_options1, _options2, Revision);
|
|
|
|
public bool DragItemOnPlayerOpensSecureTrade =>
|
|
Snapshot.DragItemOnPlayerOpensSecureTrade;
|
|
|
|
public void Replace(uint options1, uint options2)
|
|
{
|
|
Volatile.Write(ref _options1, options1);
|
|
Volatile.Write(ref _options2, options2);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
Volatile.Write(ref _options1, DefaultOptions1);
|
|
Volatile.Write(ref _options2, DefaultOptions2);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|
|
|
|
public readonly record struct RuntimeMovementSkillSnapshot(
|
|
int RunSkill,
|
|
int JumpSkill,
|
|
long Revision,
|
|
// Campaign P Slice P1 (2026-07-30): retail InqLoad-equivalent burden
|
|
// ratio (0.0 unencumbered) and current stamina (-1 = unknown/don't-gate,
|
|
// matching RunSkill/JumpSkill's own sentinel convention).
|
|
float Burden = 0f,
|
|
int CurrentStamina = -1,
|
|
// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own
|
|
// PublicWeenieDesc._bitfield (0 = never pushed / no PK-relevant bits —
|
|
// a no-op OR, matching every pre-P3 caller) and the raw
|
|
// PlayerKillerStatus/LastPkAttackTimestamp pair §12b's PK-timer
|
|
// jump-cost bump reads (-1 / null = never pushed).
|
|
uint OwnPwdBitfield = 0u,
|
|
int PlayerKillerStatus = -1,
|
|
float? LastPkAttackTimestamp = null)
|
|
{
|
|
public bool IsComplete => RunSkill >= 0 && JumpSkill >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-authoritative run/jump/burden/stamina/PK-status values retained
|
|
/// independently of any graphical movement controller. App applies this
|
|
/// borrowed state whenever its presentation/physics controller exists or is
|
|
/// rebuilt. Campaign P Slice P1 (2026-07-30) extended this beyond run/jump
|
|
/// skill to the full stat-coupled-movement input set (burden, current
|
|
/// stamina) — see the pseudocode doc §9. Campaign P Slice P3 (2026-07-30)
|
|
/// further added the player's own PWD bitfield (PK/PKLite/Impenetrable
|
|
/// collision-exemption bits) and the PlayerKillerStatus/
|
|
/// LastPkAttackTimestamp pair (the PK-timer jump-cost bump) — the SAME
|
|
/// reactive push seam, since both ride the player's own ClientObject
|
|
/// property-update stream. RunSkill/JumpSkill arrive here ALREADY
|
|
/// vitae/enchantment-adjusted by
|
|
/// <see cref="RuntimeCharacterState.RecomputeMovementSkills"/>.
|
|
/// </summary>
|
|
public sealed class RuntimeMovementSkillState
|
|
{
|
|
private int _runSkill = -1;
|
|
private int _jumpSkill = -1;
|
|
private float _burden;
|
|
private int _currentStamina = -1;
|
|
private uint _ownPwdBitfield;
|
|
private int _playerKillerStatus = -1;
|
|
private float _lastPkAttackTimestamp;
|
|
private bool _hasLastPkAttackTimestamp;
|
|
private long _revision;
|
|
|
|
public int RunSkill => Volatile.Read(ref _runSkill);
|
|
public int JumpSkill => Volatile.Read(ref _jumpSkill);
|
|
public float Burden => Volatile.Read(ref _burden);
|
|
public int CurrentStamina => Volatile.Read(ref _currentStamina);
|
|
public uint OwnPwdBitfield => Volatile.Read(ref _ownPwdBitfield);
|
|
public int PlayerKillerStatus => Volatile.Read(ref _playerKillerStatus);
|
|
public float? LastPkAttackTimestamp =>
|
|
Volatile.Read(ref _hasLastPkAttackTimestamp)
|
|
? Volatile.Read(ref _lastPkAttackTimestamp)
|
|
: null;
|
|
public bool IsComplete => _runSkill >= 0 && _jumpSkill >= 0;
|
|
public long Revision => Interlocked.Read(ref _revision);
|
|
public RuntimeMovementSkillSnapshot Snapshot =>
|
|
new(
|
|
_runSkill,
|
|
_jumpSkill,
|
|
Revision,
|
|
_burden,
|
|
_currentStamina,
|
|
OwnPwdBitfield,
|
|
PlayerKillerStatus,
|
|
LastPkAttackTimestamp);
|
|
|
|
public void Update(int runSkill, int jumpSkill)
|
|
{
|
|
bool changed = false;
|
|
if (runSkill >= 0 && RunSkill != runSkill)
|
|
{
|
|
Volatile.Write(ref _runSkill, runSkill);
|
|
changed = true;
|
|
}
|
|
if (jumpSkill >= 0 && JumpSkill != jumpSkill)
|
|
{
|
|
Volatile.Write(ref _jumpSkill, jumpSkill);
|
|
changed = true;
|
|
}
|
|
if (changed)
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes a fresh retail <c>InqLoad</c>-equivalent burden ratio (Strength
|
|
/// + augmentation property 0xE6 + EncumbranceVal property 5 — the same
|
|
/// inputs the burden HUD already assembles). Any value, including 0,
|
|
/// is a real reading.
|
|
/// </summary>
|
|
public void UpdateBurden(float burden)
|
|
{
|
|
if (Burden == burden) return;
|
|
Volatile.Write(ref _burden, burden);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes the current-stamina vital reading. Any non-negative value,
|
|
/// including 0 (exhausted — zeroes the effective run/jump skill), is
|
|
/// real; pass a negative value only to restore the "unknown" sentinel.
|
|
/// </summary>
|
|
public void UpdateStamina(int currentStamina)
|
|
{
|
|
if (CurrentStamina == currentStamina) return;
|
|
Volatile.Write(ref _currentStamina, currentStamina);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-23 (Campaign P Slice P3, 2026-07-30): pushes the local player's own
|
|
/// raw <c>PublicWeenieDesc._bitfield</c> — the PWD wire bit-space
|
|
/// <see cref="AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield"/>
|
|
/// decodes. Fires on the SAME <c>ClientObject</c> add/update events
|
|
/// <see cref="UpdateBurden"/> already reacts to (the player's own
|
|
/// PublicWeenieBitfield lives on the same row).
|
|
/// </summary>
|
|
public void UpdateOwnPwdBitfield(uint bitfield)
|
|
{
|
|
if (OwnPwdBitfield == bitfield) return;
|
|
Volatile.Write(ref _ownPwdBitfield, bitfield);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
/// <summary>
|
|
/// TS-23 §12b: pushes the raw <c>PlayerKillerStatus</c>
|
|
/// (<c>PropertyInt</c> 0x86) / <c>LastPkAttackTimestamp</c>
|
|
/// (<c>PropertyFloat</c> 0x91) pair feeding
|
|
/// <c>CACQualities::JumpStaminaCost</c>'s 20-second PK-timer bump. A
|
|
/// negative <paramref name="playerKillerStatus"/> restores "never
|
|
/// pushed" (matches <see cref="UpdateStamina"/>'s own sentinel
|
|
/// convention); <paramref name="lastPkAttackTimestamp"/> is <c>null</c>
|
|
/// when the property is absent.
|
|
/// </summary>
|
|
public void UpdatePlayerKillerStatus(int playerKillerStatus, float? lastPkAttackTimestamp)
|
|
{
|
|
bool changed = false;
|
|
if (PlayerKillerStatus != playerKillerStatus)
|
|
{
|
|
Volatile.Write(ref _playerKillerStatus, playerKillerStatus);
|
|
changed = true;
|
|
}
|
|
bool hasTimestamp = lastPkAttackTimestamp.HasValue;
|
|
float timestamp = lastPkAttackTimestamp ?? 0f;
|
|
if (Volatile.Read(ref _hasLastPkAttackTimestamp) != hasTimestamp
|
|
|| (hasTimestamp && Volatile.Read(ref _lastPkAttackTimestamp) != timestamp))
|
|
{
|
|
Volatile.Write(ref _lastPkAttackTimestamp, timestamp);
|
|
Volatile.Write(ref _hasLastPkAttackTimestamp, hasTimestamp);
|
|
changed = true;
|
|
}
|
|
if (changed)
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
|
|
public void ResetSession()
|
|
{
|
|
Volatile.Write(ref _runSkill, -1);
|
|
Volatile.Write(ref _jumpSkill, -1);
|
|
Volatile.Write(ref _burden, 0f);
|
|
Volatile.Write(ref _currentStamina, -1);
|
|
Volatile.Write(ref _ownPwdBitfield, 0u);
|
|
Volatile.Write(ref _playerKillerStatus, -1);
|
|
Volatile.Write(ref _lastPkAttackTimestamp, 0f);
|
|
Volatile.Write(ref _hasLastPkAttackTimestamp, false);
|
|
Interlocked.Increment(ref _revision);
|
|
}
|
|
}
|