acdream/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs
Erik 0b0c7aa485 fix(ui): night-round review — F11/F13/F14/F15 one-liners
F11: logs when a Map tab town-marker's template resolves to something
other than a UiButton — that path previously silently skipped the
TooltipText write with no diagnostic, leaving a mounted-but-empty
tooltip popup indistinguishable from "no template configured".

F13: RetailSkillFormula.FormatFormula now reads Attribute1Multiplier/
Attribute2Multiplier/AdditiveBonus/Divisor through the SAME unsigned
reinterpretation TryCalculate already uses (this class's own doc
comment already stated the invariant; FormatFormula just didn't follow
it). A high-bit-set value would previously both mis-gate hasAttr1/
hasAttr2 and print a negative number, out of sync with what
TryCalculate actually computes with for the same formula. Added
regression tests, empirically verified to fail without the fix.

F14: documented the RefreshHouseMarker gap rather than guessing at the
byte-decode — Position::get_outside_cell_id @0x004527b0 is itself
BN-mangled (its `(eax_2 - eax_2) & objcell_id` return is the same
decompiler-obscures-a-real-conditional artifact class this round hit
elsewhere) and depends on LandDefs::adjust_to_outside, a genuinely
larger port than this round's other findings. HousePosition is wired
() => null in production today (ISSUES #413's remaining scope), so
this method is currently unreachable; left a TODO citing the retail
call chain for whenever that lands.

F15: fixed RefreshCoordinatesAndPlayerMarker's gate to AND-on-both-
present, matching gmMapUI::Update @0x004a2078's exact
`if (m_pCoordinateText != 0 && m_pPlayerLocationIcon != 0)` condition.
The prior `_coordinateText is null && _playerIcon is null` check only
skipped when BOTH were absent (proceeding whenever EITHER was
present), letting coordinate text and the player marker update
independently instead of as the single gated unit retail treats them
as. Added a regression test (player-icon template resolution failure
must also skip the coordinate-text write), empirically verified to
fail without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 05:06:17 +02:00

253 lines
9.6 KiB
C#

using AcDream.App.Net;
using AcDream.Core.CharGen;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Net;
public sealed class RetailSkillFormulaTests
{
[Fact]
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
{
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
Assert.Equal(0u, invalidResult);
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
Assert.Equal(5u, validResult);
}
[Theory]
[InlineData(9u, 4u, 2u)]
[InlineData(10u, 4u, 3u)]
[InlineData(11u, 4u, 3u)]
[InlineData(12u, 4u, 3u)]
public void DivisionRoundsToNearestWithExactHalvesUp(
uint numerator,
uint divisor,
uint expected)
{
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
numerator,
0u,
out uint result));
Assert.Equal(expected, result);
}
[Fact]
public void AdditiveWordIsInsideTheNumerator()
{
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
Assert.Equal(2u, result);
}
[Fact]
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
{
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
uint.MaxValue,
0u,
out uint result));
Assert.Equal(0x80000000u, result);
}
[Fact]
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
Assert.Equal(uint.MaxValue, result);
}
/// <summary>
/// F13 (night-round review): <see cref="RetailSkillFormula.FormatFormula"/>
/// previously read the raw signed <c>int</c> fields directly instead of
/// the SAME unsigned reinterpretation <see cref="RetailSkillFormula.TryCalculate"/>
/// uses. A multiplier whose stored bit pattern has the high bit set
/// would read as a small negative number here (failing the
/// <c>&gt;= 1</c> hasAttr gate, or printing a negative multiplier) instead
/// of the huge unsigned value <c>TryCalculate</c> actually computes
/// with for the SAME formula.
/// </summary>
[Fact]
public void FormatFormula_MultiplierIsReinterpretedAsRetailUnsignedWord()
{
SkillFormula formula = Formula(w: 0, x: -1, y: 0, z: 1);
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains(uint.MaxValue.ToString(), text);
Assert.DoesNotContain("-1", text);
}
/// <summary>Same reinterpretation, but for the divisor and additive-bonus
/// suffixes rather than the multiplier — both must read unsigned too.</summary>
[Fact]
public void FormatFormula_DivisorAndAdditiveBonusAreReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 1, y: 0, z: unchecked((uint)-2));
formula.Attribute1 = AttributeId.Strength;
string? text = RetailSkillFormula.FormatFormula(formula);
Assert.NotNull(text);
Assert.Contains($"/ {unchecked((uint)-2)}", text);
Assert.Contains($"+{uint.MaxValue}", text);
Assert.DoesNotContain("-1", text);
Assert.DoesNotContain("-2", text);
}
[Fact]
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
{
const uint skillId = 0x345u;
const uint firstAttributeId = 1u;
const uint secondAttributeId = 2u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 1,
Attribute1Multiplier = 1,
Attribute2Multiplier = 2,
Divisor = 3,
Attribute1 = (AttributeId)firstAttributeId,
Attribute2 = (AttributeId)secondAttributeId,
},
});
var resolver = new LiveSkillCreditResolver(skillTable);
uint result = resolver.Resolve(
skillId,
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
Assert.Equal(3u, result);
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
}
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
{
AdditiveBonus = w,
Attribute1Multiplier = x,
Attribute2Multiplier = y,
Divisor = unchecked((int)z),
};
// ── CC5 re-review residual round, R2 (2026-08-16): RetailSkillFormula.
// ── CalculateChargenScore / ChargenSkillScoreResolver had zero direct
// ── coverage — the F12(d) test substitutes `skillId * 10` instead of
// ── exercising the real formula. ─────────────────────────────────────
/// <summary>
/// <see cref="RetailSkillFormula.CalculateChargenScore"/>'s level bonus:
/// Untrained adds nothing, Trained adds 5, Specialized adds 10, on top
/// of the SAME base <see cref="RetailSkillFormula.TryCalculate"/> result
/// (formula here: <c>w=0, x=1, y=0, z=1</c> against
/// <c>attribute1=5</c> -&gt; base 5).
/// </summary>
[Theory]
[InlineData(ChargenSkillAdvancementClass.Untrained, 5u)]
[InlineData(ChargenSkillAdvancementClass.Trained, 10u)]
[InlineData(ChargenSkillAdvancementClass.Specialized, 15u)]
public void CalculateChargenScore_AddsTheLevelBonusOnTopOfTheBaseFormula(
ChargenSkillAdvancementClass level,
uint expected)
{
var skillBase = new SkillBase { Formula = Formula(w: 0, x: 1, y: 0, z: 1) };
uint result = RetailSkillFormula.CalculateChargenScore(skillBase, attribute1: 5u, attribute2: 0u, level);
Assert.Equal(expected, result);
}
/// <summary>
/// The divisor-zero skip path: <see cref="RetailSkillFormula.TryCalculate"/>'s
/// own failure gate short-circuits <see cref="RetailSkillFormula.CalculateChargenScore"/>
/// to a flat 0 BEFORE the level bonus is ever added — even for
/// Specialized, which would otherwise add 10.
/// </summary>
[Fact]
public void CalculateChargenScore_ZeroDivisor_ReturnsZero_RegardlessOfLevel()
{
var skillBase = new SkillBase { Formula = Formula(w: 7, x: 1, y: 1, z: 0) };
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Untrained));
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Trained));
Assert.Equal(0u, RetailSkillFormula.CalculateChargenScore(
skillBase, 10u, 20u, ChargenSkillAdvancementClass.Specialized));
}
/// <summary>
/// <see cref="ChargenSkillScoreResolver.Resolve"/>'s six-way
/// <c>ResolveAttribute</c> switch — one case per
/// <see cref="AttributeId"/> (Strength=1..Self=6) — resolving through a
/// formula that reads ONLY <c>Attribute1</c> (<c>x=1, y=0</c>) so the
/// result unambiguously reports which of the six
/// <see cref="ChargenAttributeValues"/> fields the switch actually read.
/// </summary>
[Theory]
[InlineData(AttributeId.Strength)]
[InlineData(AttributeId.Endurance)]
[InlineData(AttributeId.Coordination)]
[InlineData(AttributeId.Quickness)]
[InlineData(AttributeId.Focus)]
[InlineData(AttributeId.Self)]
public void ChargenSkillScoreResolver_ResolvesEachAttributeIdThroughTheSixWaySwitch(
AttributeId attributeId)
{
const uint skillId = 0x10u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 0,
Attribute1Multiplier = 1,
Attribute2Multiplier = 0,
Divisor = 1,
Attribute1 = attributeId,
// Attribute2 deliberately left at its zero default (Strength) —
// Attribute2Multiplier=0 means whatever it reads contributes
// nothing, so it cannot mask a wrong Attribute1 case.
},
});
var resolver = new ChargenSkillScoreResolver(skillTable);
ChargenAttributeValues attributes = AttributeValuesWith(attributeId, 42);
uint result = resolver.Resolve(skillId, attributes, ChargenSkillAdvancementClass.Untrained);
Assert.Equal(42u, result);
}
private static ChargenAttributeValues AttributeValuesWith(AttributeId attributeId, int value) =>
attributeId switch
{
AttributeId.Strength => new ChargenAttributeValues(value, 0, 0, 0, 0, 0),
AttributeId.Endurance => new ChargenAttributeValues(0, value, 0, 0, 0, 0),
AttributeId.Coordination => new ChargenAttributeValues(0, 0, value, 0, 0, 0),
AttributeId.Quickness => new ChargenAttributeValues(0, 0, 0, value, 0, 0),
AttributeId.Focus => new ChargenAttributeValues(0, 0, 0, 0, value, 0),
AttributeId.Self => new ChargenAttributeValues(0, 0, 0, 0, 0, value),
_ => default,
};
}