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);
}
///
/// F13 (night-round review):
/// previously read the raw signed int fields directly instead of
/// the SAME unsigned reinterpretation
/// uses. A multiplier whose stored bit pattern has the high bit set
/// would read as a small negative number here (failing the
/// >= 1 hasAttr gate, or printing a negative multiplier) instead
/// of the huge unsigned value TryCalculate actually computes
/// with for the SAME formula.
///
[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);
}
/// Same reinterpretation, but for the divisor and additive-bonus
/// suffixes rather than the multiplier — both must read unsigned too.
[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 { [firstAttributeId] = 8u });
Assert.Equal(3u, result);
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary()));
}
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. ─────────────────────────────────────
///
/// 's level bonus:
/// Untrained adds nothing, Trained adds 5, Specialized adds 10, on top
/// of the SAME base result
/// (formula here: w=0, x=1, y=0, z=1 against
/// attribute1=5 -> base 5).
///
[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);
}
///
/// The divisor-zero skip path: 's
/// own failure gate short-circuits
/// to a flat 0 BEFORE the level bonus is ever added — even for
/// Specialized, which would otherwise add 10.
///
[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));
}
///
/// 's six-way
/// ResolveAttribute switch — one case per
/// (Strength=1..Self=6) — resolving through a
/// formula that reads ONLY Attribute1 (x=1, y=0) so the
/// result unambiguously reports which of the six
/// fields the switch actually read.
///
[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,
};
}