feat(player): port retail augmentation stat chain
This commit is contained in:
parent
0cb60d98a0
commit
461a1fb7b4
22 changed files with 953 additions and 138 deletions
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using DatReaderWriter;
|
||||
|
|
@ -75,6 +76,31 @@ public sealed class CharacterLayoutImportProbe
|
|||
Assert.Equal(1, closes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Footer_title_exposes_retail_append_text_palette()
|
||||
{
|
||||
string? datDir = DatDir();
|
||||
if (datDir is null) return;
|
||||
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
ImportedLayout? layout = LayoutImporter.Import(
|
||||
dats,
|
||||
CharacterLayout,
|
||||
_ => (1u, 30, 26),
|
||||
null);
|
||||
|
||||
UiText title = Assert.IsType<UiText>(
|
||||
layout!.FindElement(CharacterStatController.FooterTitleId));
|
||||
Assert.Equal(
|
||||
[
|
||||
Vector4.One,
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
new Vector4(1f, 0f, 0f, 1f),
|
||||
new Vector4(127f / 255f, 1f, 1f, 1f),
|
||||
],
|
||||
title.FontColorPalette);
|
||||
}
|
||||
|
||||
private static void CollectRows(UiElement node, List<UiClickablePanel> result)
|
||||
{
|
||||
if (node is UiClickablePanel row && row.Height is >= 20f and <= 22f && row.OnClick is not null)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -285,6 +286,65 @@ public sealed class CharacterSheetProviderTests
|
|||
Assert.Equal(-100, skill.VitaeModifier); // the exact user-reported oracle example
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_SkillAugmentations_UseRetailBeforeAndAfterOrdering()
|
||||
{
|
||||
var h = new VitaeHarness();
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 3;
|
||||
properties.Ints[(uint)PropertyInt.AugmentationSkilledMagic] = 1;
|
||||
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
|
||||
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 4;
|
||||
h.Player.OnProperties(properties);
|
||||
h.Player.OnSkillUpdate(
|
||||
skillId: 0x1Fu,
|
||||
ranks: 100u,
|
||||
status: 3u,
|
||||
xp: 0u,
|
||||
init: 0u,
|
||||
resistance: 0u,
|
||||
lastUsed: 0d,
|
||||
formulaBonus: 0u);
|
||||
|
||||
CharacterSkill skill = Assert.Single(h.Provider.BuildSheet().Skills);
|
||||
|
||||
Assert.Equal(113, skill.BaseLevel);
|
||||
Assert.Equal(126, skill.CurrentLevel);
|
||||
Assert.Equal(0, skill.VitaeModifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSheet_VitalPairsCarryBaseAndVitaeContribution()
|
||||
{
|
||||
var h = new VitaeHarness();
|
||||
h.Player.OnAttributeUpdate(
|
||||
atType: 2u,
|
||||
ranks: 100u,
|
||||
start: 100u,
|
||||
xp: 0u);
|
||||
h.Player.OnVitalUpdate(
|
||||
vitalId: 7u,
|
||||
ranks: 0u,
|
||||
start: 100u,
|
||||
xp: 0u,
|
||||
current: 150u);
|
||||
h.Book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
|
||||
SpellId: 1u,
|
||||
LayerId: 1u,
|
||||
Duration: -1d,
|
||||
CasterGuid: 0u,
|
||||
StatModType: 0u,
|
||||
StatModKey: 0u,
|
||||
StatModValue: 0.8f,
|
||||
Bucket: 4u));
|
||||
|
||||
CharacterSheet sheet = h.Provider.BuildSheet();
|
||||
|
||||
Assert.Equal(200, sheet.VitalBaseMaxValues[0]);
|
||||
Assert.Equal(-40, sheet.VitalVitaeModifiers[0]);
|
||||
Assert.Equal(160, sheet.HealthMax);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SubscribeChanged_FiresOnEnchantmentsChanged_AndRebuildReflectsNewValue()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -876,7 +876,7 @@ public class CharacterStatControllerTests
|
|||
|
||||
var meleeTexts = rows[0].Children.OfType<UiText>().ToList();
|
||||
Assert.Equal(Vector4.One, meleeTexts[1].LinesProvider()[0].Color);
|
||||
Assert.Equal(new Vector4(0.55f, 1f, 0.55f, 1f), meleeTexts[2].LinesProvider()[0].Color);
|
||||
Assert.Equal(new Vector4(0f, 1f, 0f, 1f), meleeTexts[2].LinesProvider()[0].Color);
|
||||
|
||||
var healingTexts = rows[5].Children.OfType<UiText>().ToList();
|
||||
Assert.Equal(Vector4.One, healingTexts[1].LinesProvider()[0].Color);
|
||||
|
|
@ -1314,6 +1314,42 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal(50, CharacterStatController.GetSkillBuffOnlyDelta(skill));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillValueColor_SubtractsVitaeBeforeChoosingFontState()
|
||||
{
|
||||
var vitaeOnly = new CharacterSkill(
|
||||
1u, "S", 0u, CharacterSkillAdvancementClass.Trained,
|
||||
BaseLevel: 300, CurrentLevel: 201, UsableUntrained: true,
|
||||
TrainedCost: 0, SpecializedCost: 0, RaiseCost: 0,
|
||||
VitaeModifier: -99);
|
||||
var vitaeAndBuff = vitaeOnly with { CurrentLevel = 211 };
|
||||
|
||||
Assert.Equal(Vector4.One, CharacterStatController.SkillValueColor(vitaeOnly));
|
||||
Assert.Equal(
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
CharacterStatController.SkillValueColor(vitaeAndBuff));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AttributeAndVitalValueColors_FollowRetailResidualComparison()
|
||||
{
|
||||
var sheet = new CharacterSheet
|
||||
{
|
||||
Strength = 220,
|
||||
AttributeBaseValues = [200, 0, 0, 0, 0, 0],
|
||||
HealthMax = 170,
|
||||
VitalBaseMaxValues = [200, 0, 0],
|
||||
VitalVitaeModifiers = [-40, 0, 0],
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
CharacterStatController.AttributeValueColor(sheet, 0));
|
||||
Assert.Equal(
|
||||
new Vector4(0f, 1f, 0f, 1f),
|
||||
CharacterStatController.VitalValueColor(sheet, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowClick_AttributeWithBuff_FooterTitleShowsPositiveDelta()
|
||||
{
|
||||
|
|
@ -1405,6 +1441,41 @@ public class CharacterStatControllerTests
|
|||
Assert.Equal("Test Skill: 251 (-99) (+50)", title.LinesProvider()[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillFooter_UsesAuthoredPaletteForVitaeAndBuffRuns()
|
||||
{
|
||||
Vector4 normal = new(0.1f, 0.1f, 0.1f, 1f);
|
||||
Vector4 buff = new(0.2f, 0.3f, 0.4f, 1f);
|
||||
Vector4 debuff = new(0.5f, 0.6f, 0.7f, 1f);
|
||||
Vector4 vitae = new(127f / 255f, 1f, 1f, 1f);
|
||||
var list = new UiPanel { Width = 300 };
|
||||
var title = new UiText
|
||||
{
|
||||
FontColorPalette = [normal, buff, debuff, vitae],
|
||||
};
|
||||
var layout = Fake(
|
||||
(CharacterStatController.ListBoxId, list),
|
||||
(CharacterStatController.FooterTitleId, title));
|
||||
|
||||
CharacterSheet Sheet() => VitaeSkillSheet(
|
||||
currentLevel: 251,
|
||||
baseLevel: 300,
|
||||
vitaeModifier: -99);
|
||||
CharacterStatController.Bind(
|
||||
layout,
|
||||
Sheet,
|
||||
spriteResolve: id => (id, 16, 16));
|
||||
|
||||
ClickTab(layout, left: 92f);
|
||||
SkillRows(list)[0].OnClick!();
|
||||
|
||||
IReadOnlyList<UiText.TextRun> runs = title.RunsProvider!();
|
||||
Assert.Equal(3, runs.Count);
|
||||
Assert.Equal(("Test Skill: 251", normal), (runs[0].Text, runs[0].Color));
|
||||
Assert.Equal((" (-99)", vitae), (runs[1].Text, runs[1].Color));
|
||||
Assert.Equal((" (+50)", buff), (runs[2].Text, runs[2].Color));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkillClick_ZeroDelta_NoParentheticals()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -639,6 +639,56 @@ public sealed class GameEventWiringTests
|
|||
Assert.Equal(60d, record.Duration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_MagicUpdateEnchantment_PropagatesFullStatModMidSession()
|
||||
{
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
var book = new Spellbook(SpellTable.Create([TestSpell(42u)]));
|
||||
var player = new LocalPlayerState(book);
|
||||
player.OnSkillUpdate(
|
||||
skillId: 7u,
|
||||
ranks: 100u,
|
||||
status: 2u,
|
||||
xp: 0u,
|
||||
init: 0u,
|
||||
resistance: 0u,
|
||||
lastUsed: 0d,
|
||||
formulaBonus: 0u);
|
||||
int changed = 0;
|
||||
book.EnchantmentsChanged += () => changed++;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher,
|
||||
new ClientObjectTable(),
|
||||
new CombatState(),
|
||||
book,
|
||||
new ChatLog(),
|
||||
localPlayer: player,
|
||||
clientTime: () => 100d);
|
||||
|
||||
byte[] payload = BuildEnchantment(
|
||||
spellId: 42,
|
||||
layer: 3,
|
||||
duration: 60,
|
||||
caster: 0xBEEF,
|
||||
startTime: 10,
|
||||
lastDegraded: 2,
|
||||
statModType: 0x00008010u,
|
||||
statModKey: 7u,
|
||||
statModValue: 25f);
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(
|
||||
GameEventType.MagicUpdateEnchantment,
|
||||
payload))!.Value);
|
||||
|
||||
ActiveEnchantmentRecord record =
|
||||
Assert.Single(book.ActiveEnchantmentSnapshot);
|
||||
Assert.Equal(0x00008010u, record.StatModType);
|
||||
Assert.Equal(7u, record.StatModKey);
|
||||
Assert.Equal(25f, record.StatModValue);
|
||||
Assert.Equal(125, player.GetEffectiveSkill(7u));
|
||||
Assert.Equal(1, changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_WeenieError_RoutesToChatLog()
|
||||
{
|
||||
|
|
@ -1294,14 +1344,16 @@ public sealed class GameEventWiringTests
|
|||
uint caster,
|
||||
double startTime,
|
||||
double lastDegraded,
|
||||
uint statModType)
|
||||
uint statModType,
|
||||
uint statModKey = 7u,
|
||||
float statModValue = 1.25f)
|
||||
{
|
||||
byte[] payload = new byte[60];
|
||||
int offset = 0;
|
||||
WriteU16(spellId); WriteU16(layer); WriteU16(3); WriteU16(0);
|
||||
WriteU32(8); WriteF64(startTime); WriteF64(duration); WriteU32(caster);
|
||||
WriteF32(0.1f); WriteF32(-1f); WriteF64(lastDegraded);
|
||||
WriteU32(statModType); WriteU32(7); WriteF32(1.25f);
|
||||
WriteU32(statModType); WriteU32(statModKey); WriteF32(statModValue);
|
||||
return payload;
|
||||
|
||||
void WriteU16(ushort value) { BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(offset), value); offset += 2; }
|
||||
|
|
@ -1310,4 +1362,9 @@ public sealed class GameEventWiringTests
|
|||
void WriteF64(double value) { BinaryPrimitives.WriteDoubleLittleEndian(payload.AsSpan(offset), value); offset += 8; }
|
||||
}
|
||||
|
||||
private static SpellMetadata TestSpell(uint spellId) => new(
|
||||
spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0,
|
||||
false, false, "", 0, 0, 0u, 0, false, false, true,
|
||||
0f, 0u, 0u, 0u, 0);
|
||||
|
||||
}
|
||||
|
|
|
|||
78
tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs
Normal file
78
tests/AcDream.Core.Tests/Player/PlayerSkillMathTests.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Player;
|
||||
using AcDream.Core.Properties;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.Core.Tests.Player;
|
||||
|
||||
public sealed class PlayerSkillMathTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calculate_PreservesRetailAugmentationOrdering()
|
||||
{
|
||||
var augmentations = new PlayerSkillMath.AugmentationBonuses(
|
||||
AllSkills: 3,
|
||||
JackOfAllTrades: true,
|
||||
SkilledSpecialized: 4,
|
||||
SkilledMelee: false,
|
||||
SkilledMissile: false,
|
||||
SkilledMagic: true);
|
||||
|
||||
PlayerSkillMath.Value value = PlayerSkillMath.Calculate(
|
||||
intrinsicLevel: 100,
|
||||
skillId: 0x1Fu,
|
||||
advancementClass: 3u,
|
||||
augmentations,
|
||||
enchantment: new EnchantmentMath.VitalMod(0.5f, 0f),
|
||||
vitaeMultiplier: 0.8f);
|
||||
|
||||
Assert.Equal(113, value.UnenchantedLevel); // 100 + all 3 + magic 10
|
||||
Assert.Equal(69, value.EffectiveLevel); // trunc(113 * .5) + JOAT 5 + spec 8
|
||||
Assert.Equal(-23, value.VitaeModifier); // trunc(113 * .8) - 113
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x29u, true, false, false, 10)]
|
||||
[InlineData(0x2Fu, false, true, false, 10)]
|
||||
[InlineData(0x22u, false, false, true, 10)]
|
||||
[InlineData(0x18u, true, true, true, 0)]
|
||||
public void BeforeEnchantments_UsesRetailSkillCategorySwitch(
|
||||
uint skillId,
|
||||
bool melee,
|
||||
bool missile,
|
||||
bool magic,
|
||||
int expectedCategoryBonus)
|
||||
{
|
||||
var augmentations = new PlayerSkillMath.AugmentationBonuses(
|
||||
AllSkills: 2,
|
||||
JackOfAllTrades: false,
|
||||
SkilledSpecialized: 0,
|
||||
SkilledMelee: melee,
|
||||
SkilledMissile: missile,
|
||||
SkilledMagic: magic);
|
||||
|
||||
Assert.Equal(
|
||||
2 + expectedCategoryBonus,
|
||||
augmentations.BeforeEnchantments(skillId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromProperties_MapsNamedRetailQualities()
|
||||
{
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 7;
|
||||
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
|
||||
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3;
|
||||
properties.Ints[(uint)PropertyInt.AugmentationSkilledMissile] = 2;
|
||||
|
||||
PlayerSkillMath.AugmentationBonuses value =
|
||||
PlayerSkillMath.AugmentationBonuses.FromProperties(properties);
|
||||
|
||||
Assert.Equal(7, value.AllSkills);
|
||||
Assert.True(value.JackOfAllTrades);
|
||||
Assert.Equal(3, value.SkilledSpecialized);
|
||||
Assert.True(value.SkilledMissile);
|
||||
Assert.False(value.SkilledMelee);
|
||||
Assert.False(value.SkilledMagic);
|
||||
}
|
||||
}
|
||||
|
|
@ -257,11 +257,56 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.Equal(100, state.MovementSkills.JumpSkill); // untouched
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovementSkillAugmentations_UseSameRetailChainAsCharacterSheet()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
state.LocalPlayer.OnSkillUpdate(
|
||||
RuntimeCharacterState.RunSkillId,
|
||||
ranks: 0u,
|
||||
status: 3u,
|
||||
xp: 0u,
|
||||
init: 0u,
|
||||
resistance: 0u,
|
||||
lastUsed: 0d,
|
||||
formulaBonus: 200u);
|
||||
state.LocalPlayer.OnSkillUpdate(
|
||||
RuntimeCharacterState.JumpSkillId,
|
||||
ranks: 0u,
|
||||
status: 2u,
|
||||
xp: 0u,
|
||||
init: 0u,
|
||||
resistance: 0u,
|
||||
lastUsed: 0d,
|
||||
formulaBonus: 100u);
|
||||
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
|
||||
|
||||
state.UpdateMovementSkillAugmentations(
|
||||
new PlayerSkillMath.AugmentationBonuses(
|
||||
AllSkills: 2,
|
||||
JackOfAllTrades: true,
|
||||
SkilledSpecialized: 3,
|
||||
SkilledMelee: false,
|
||||
SkilledMissile: false,
|
||||
SkilledMagic: false));
|
||||
|
||||
Assert.Equal(213, state.MovementSkills.RunSkill);
|
||||
Assert.Equal(107, state.MovementSkills.JumpSkill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_ClearsBurdenStaminaAndSkillBase()
|
||||
{
|
||||
using var state = new RuntimeCharacterState();
|
||||
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
|
||||
state.UpdateMovementSkillAugmentations(
|
||||
new PlayerSkillMath.AugmentationBonuses(
|
||||
AllSkills: 2,
|
||||
JackOfAllTrades: true,
|
||||
SkilledSpecialized: 3,
|
||||
SkilledMelee: false,
|
||||
SkilledMissile: false,
|
||||
SkilledMagic: false));
|
||||
state.MovementSkills.UpdateBurden(1.5f);
|
||||
state.MovementSkills.UpdateStamina(0);
|
||||
|
||||
|
|
@ -274,7 +319,7 @@ public sealed class RuntimeCharacterStateTests
|
|||
Assert.True(state.CaptureOwnership().MovementSkillsAreReset);
|
||||
|
||||
// A fresh base push after reset must not still carry the pre-reset
|
||||
// vitae/enchantment adjustment (spellbook was cleared too).
|
||||
// augmentation/vitae/enchantment adjustment (spellbook was cleared too).
|
||||
state.UpdateMovementSkillBase(runSkillBase: 200, jumpSkillBase: 100);
|
||||
Assert.Equal(200, state.MovementSkills.RunSkill);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,6 +233,58 @@ public sealed class LiveSessionEventRouterTests
|
|||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations()
|
||||
{
|
||||
using var session = NewSession();
|
||||
const uint playerGuid = 0x50000001u;
|
||||
var objects = new ClientObjectTable();
|
||||
var character = new RuntimeCharacterState();
|
||||
character.LocalPlayer.OnSkillUpdate(
|
||||
RuntimeCharacterState.RunSkillId,
|
||||
ranks: 0u,
|
||||
status: 3u,
|
||||
xp: 0u,
|
||||
init: 0u,
|
||||
resistance: 0u,
|
||||
lastUsed: 0d,
|
||||
formulaBonus: 200u);
|
||||
character.UpdateMovementSkillBase(
|
||||
runSkillBase: 200,
|
||||
jumpSkillBase: -1);
|
||||
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
NoOpEntitySink(),
|
||||
NoOpEnvironmentSink(),
|
||||
new LiveInventorySessionBindings(
|
||||
objects,
|
||||
PlayerGuid: () => playerGuid,
|
||||
OnShortcuts: null,
|
||||
OnUseDone: null,
|
||||
ItemMana: new ItemManaState(),
|
||||
ExternalContainers: new ExternalContainerState()),
|
||||
new LiveCharacterSessionBindings(
|
||||
new CombatState(),
|
||||
character,
|
||||
ResolveSkillFormulaBonus: null,
|
||||
OnSkillsUpdated: null,
|
||||
OnConfirmationRequest: null,
|
||||
OnConfirmationDone: null,
|
||||
ClientTime: () => 0d),
|
||||
NewSocialBindings());
|
||||
router.Attach();
|
||||
|
||||
var properties = new PropertyBundle();
|
||||
properties.Ints[(uint)PropertyInt.LumAugAllSkills] = 2;
|
||||
properties.Ints[(uint)PropertyInt.AugmentationJackOfAllTrades] = 1;
|
||||
properties.Ints[(uint)PropertyInt.LumAugSkilledSpec] = 3;
|
||||
objects.UpsertProperties(playerGuid, properties);
|
||||
|
||||
Assert.Equal(213, character.MovementSkills.RunSkill);
|
||||
router.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaminaVitalChange_PushesCurrentStamina()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue