acdream/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Erik ceec3bc440 feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice
TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:22:08 +02:00

545 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
/// window and owns the raise-request flow (wire send + optimistic local
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
/// assembly + XP-curve math is feature logic, not wiring).
///
/// <para>Retail property ids and the decomp anchors for every field are
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
///
/// <para><b>State ownership:</b> optimistic debits go through the owning
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
/// when the player object is in the table, else
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
/// Never write the raw property dictionaries from UI code. The next server
/// snapshot remains authoritative over every optimistic value.</para>
/// </summary>
internal sealed class CharacterSheetProvider
{
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
private const uint UnassignedXpPropertyId = 2u;
/// <summary>
/// Retail PropertyInt 0x18 = available skill credits. Properties 0xB5 and
/// 0xC0 are Chess Rank and Fishing Skill and must never be debited.
/// </summary>
private static readonly uint[] SkillCreditPropertyIds = { 0x18u };
private readonly ClientObjectTable _objects;
private readonly LocalPlayerState _localPlayer;
private readonly Func<uint> _playerGuid;
private readonly Func<string?>? _activeToonName;
private readonly Func<string, CharacterSheet>? _fallbackSheet;
private readonly Func<bool>? _canSendRaise;
private readonly Action<uint, ulong>? _sendRaiseAttribute;
private readonly Action<uint, ulong>? _sendRaiseVital;
private readonly Action<uint, ulong>? _sendRaiseSkill;
private readonly Action<uint, uint>? _sendTrainSkill;
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
public CharacterSheetProvider(
ClientObjectTable objects,
LocalPlayerState localPlayer,
Func<uint> playerGuid,
Func<string?>? activeToonName = null,
Func<string, CharacterSheet>? fallbackSheet = null,
Func<bool>? canSendRaise = null,
Action<uint, ulong>? sendRaiseAttribute = null,
Action<uint, ulong>? sendRaiseVital = null,
Action<uint, ulong>? sendRaiseSkill = null,
Action<uint, uint>? sendTrainSkill = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_activeToonName = activeToonName;
_fallbackSheet = fallbackSheet;
_canSendRaise = canSendRaise;
_sendRaiseAttribute = sendRaiseAttribute;
_sendRaiseVital = sendRaiseVital;
_sendRaiseSkill = sendRaiseSkill;
_sendTrainSkill = sendTrainSkill;
}
/// <summary>
/// Subscribe to semantic inputs used by <see cref="BuildSheet"/>. The
/// returned binding owns every event registration and filters object-table
/// notices to the current player GUID.
/// </summary>
public IDisposable SubscribeChanged(Action changed)
{
ArgumentNullException.ThrowIfNull(changed);
return new ChangeBinding(this, changed);
}
// ── Sheet assembly ─────────────────────────────────────────────────────
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
public string CharacterName()
{
string? toon = _activeToonName?.Invoke();
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
return toon;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
return objectName;
return "Player";
}
/// <summary>
/// Build the sheet from live state; falls back to the injected sample
/// sheet (Studio data) until any live character data has arrived.
/// Called per repaint by the Character window's bound widgets.
/// </summary>
public CharacterSheet BuildSheet()
{
if (!HasLiveData())
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
var props = CurrentPlayerProperties();
int level = props.GetInt(0x19u);
long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
int skillCredits = props.GetInt(0x18u);
return new CharacterSheet
{
Name = CharacterName(),
Level = level,
Gender = CharacterIdentityText.GenderDisplayName(
props.GetInt(CharacterIdentityText.GenderPropertyId)),
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
PkStatus = PkStatusText(props.GetInt(134u, 0)),
TotalXp = totalXp,
XpToNextLevel = xp.toNext,
XpFraction = xp.fraction,
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = skillCredits,
SpecializedSkillCredits = 0,
ChessRank = props.GetInt(0xB5u),
FishingSkill = props.GetInt(0xC0u),
BirthTimestamp = props.Ints.TryGetValue(0x62u, out int born)
? born
: null,
TotalPlayTimeSeconds = props.Ints.TryGetValue(0x7Du, out int played)
? played
: null,
Deaths = props.GetInt(0x2Bu),
SkillCredits = skillCredits,
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
EncumbranceAugmentations = props.GetInt(0xE6u),
CharacterInfoProperties = new Dictionary<uint, int>(props.Ints),
};
}
private bool HasLiveData()
{
var props = CurrentPlayerProperties();
return props.Ints.Count > 0
|| props.Int64s.Count > 0
|| _localPlayer.Skills.Count > 0
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
}
/// <summary>The player's canonical bundle: the live ClientObject's when the
/// player object has arrived (CreateObject merge-upsert), else the
/// PlayerDescription snapshot on LocalPlayerState.</summary>
private PropertyBundle CurrentPlayerProperties()
{
uint guid = _playerGuid();
return guid != 0u && _objects.Get(guid) is { } player
? player.Properties
: _localPlayer.Properties;
}
private sealed class ChangeBinding : IDisposable
{
private CharacterSheetProvider? _owner;
private readonly Action _changed;
public ChangeBinding(CharacterSheetProvider owner, Action changed)
{
_owner = owner;
_changed = changed;
owner._objects.ObjectAdded += OnObjectChanged;
owner._objects.ObjectUpdated += OnObjectChanged;
owner._objects.ObjectRemoved += OnObjectChanged;
owner._objects.Cleared += OnCleared;
owner._localPlayer.AttributeChanged += OnAttributeChanged;
owner._localPlayer.CharacterChanged += OnCharacterChanged;
}
private void OnObjectChanged(ClientObject value)
{
CharacterSheetProvider? owner = _owner;
if (owner is not null && value.ObjectId == owner._playerGuid())
_changed();
}
private void OnCleared() => _changed();
private void OnAttributeChanged(LocalPlayerState.AttributeKind _) =>
_changed();
private void OnCharacterChanged() => _changed();
public void Dispose()
{
CharacterSheetProvider? owner = Interlocked.Exchange(ref _owner, null);
if (owner is null)
return;
owner._objects.ObjectAdded -= OnObjectChanged;
owner._objects.ObjectUpdated -= OnObjectChanged;
owner._objects.ObjectRemoved -= OnObjectChanged;
owner._objects.Cleared -= OnCleared;
owner._localPlayer.AttributeChanged -= OnAttributeChanged;
owner._localPlayer.CharacterChanged -= OnCharacterChanged;
}
}
/// <summary>
/// Load the portal ExperienceTable (0x0E000018), falling back to a
/// type scan for older or odd dat collections. Failures are logged —
/// never silently swallowed — and leave raise costs unavailable (0).
/// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
IDatReaderWriter dats, Action<string>? log = null)
{
if (dats is null) return null;
try
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
if (table is not null) return table;
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
}
try
{
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
if (table is not null) return table;
}
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
}
return null;
}
/// <summary>XP still needed for the next level + fill fraction of the
/// current level band (retail (curâˆbase)/(capâˆbase); CharacterSheet.XpFraction).</summary>
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
{
var levels = ExperienceTable?.Levels;
if (levels is null || level < 0 || level + 1 >= levels.Length)
return (0L, 0f);
long current = ClampToLong(levels[level]);
long next = ClampToLong(levels[level + 1]);
if (next <= current) return (0L, 0f);
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
long toNext = next - clampedXp;
float fraction = (float)(clampedXp - current) / (next - current);
return (toNext, fraction);
}
/// <summary>Per-attribute/vital raise costs in retail display order
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
private long[] BuildAttributeRaiseCosts(int amount)
{
var xp = ExperienceTable;
return new[]
{
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
};
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
{
var attr = _localPlayer.GetAttribute(kind);
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
}
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
{
var vital = _localPlayer.Get(kind);
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
var xp = ExperienceTable;
foreach (var snapshot in _localPlayer.Skills.Values)
{
var advancement = AdvancementFromStatus(snapshot.Status);
if (advancement == CharacterSkillAdvancementClass.Inactive)
continue;
DatReaderWriter.Types.SkillBase? skillBase = null;
if (skillTable?.Skills is not null)
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
string? name = skillBase?.Name.Value;
if (string.IsNullOrWhiteSpace(name))
name = $"Skill {snapshot.SkillId}";
uint icon = skillBase?.IconId.DataId ?? 0u;
int trainedCost = skillBase?.TrainedCost ?? 0;
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
}
return result;
}
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
{
1u => CharacterSkillAdvancementClass.Untrained,
2u => CharacterSkillAdvancementClass.Trained,
3u => CharacterSkillAdvancementClass.Specialized,
_ => CharacterSkillAdvancementClass.Inactive,
};
private static bool IsUsableUntrained(uint skillId) => skillId switch
{
18u or 37u or 38u or 39u or 40u => false,
_ => true,
};
private static long SkillRaiseCost(
DatReaderWriter.DBObjs.ExperienceTable? xp,
CharacterSkillAdvancementClass advancement,
LocalPlayerState.SkillSnapshot skill,
int amount)
{
if (xp is null) return 0L;
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
? xp.SpecializedSkills
: xp.TrainedSkills;
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
}
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
/// cumulative-XP curve: curve[target] − xpAlreadySpent, clamped at the
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
{
if (curve is null || amount <= 0) return 0L;
long maxIndex = curve.Length - 1L;
if (maxIndex <= ranks) return 0L;
long targetLong = Math.Min((long)ranks + amount, maxIndex);
long targetXp = curve[(int)targetLong];
long cost = targetXp - spentXp;
return cost > 0 ? cost : 0L;
}
private static long ClampToLong(ulong value) =>
value > long.MaxValue ? long.MaxValue : (long)value;
private static string? PkStatusText(int status) => status switch
{
0x2 => "Non-Player Killer",
0x4 => "Player Killer",
0x40 => "Player Killer Lite",
_ => null,
};
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>
/// Send a raise/train action to the server and, when a send delegate
/// fired, optimistically apply the local effect so the sheet stays
/// current during the round trip. The next server snapshot remains
/// authoritative (a rejected raise is corrected by the property echo —
/// pending/rollback ledger tracked as a follow-up issue).
/// </summary>
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
{
if (request.Cost <= 0) return;
if (_canSendRaise is not null && !_canSendRaise()) return;
bool sent = false;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_sendRaiseAttribute is not null)
{
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_sendRaiseVital is not null)
{
_sendRaiseVital(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_sendRaiseSkill is not null)
{
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
{
_sendTrainSkill(request.StatId, (uint)request.Cost);
sent = true;
}
break;
}
if (sent)
ApplyLocalRaise(request);
}
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
{
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
ulong cost = (ulong)request.Cost;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_localPlayer.ApplySkillTraining(request.StatId))
SpendSkillCredits(request.Cost);
break;
}
}
private void SpendUnassignedExperience(long cost)
{
if (cost <= 0) return;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
if (current <= 0) return;
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
current > cost ? current - cost : 0L);
return;
}
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
}
private void SpendSkillCredits(long cost)
{
if (cost <= 0) return;
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
continue;
_objects.UpdateIntProperty(guid, propertyId,
current > debit ? current - debit : 0);
return;
}
return;
}
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (_localPlayer.DebitIntProperty(propertyId, debit))
return;
}
}
}