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>
253 lines
9.6 KiB
C#
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>>= 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> -> 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,
|
|
};
|
|
}
|