Skip to content

test(items): pin today's item requirement, power-up and price formulas for every item - #2

Merged
Mosch0512 merged 18 commits into
masterfrom
feat/item-formulas-data
Oct 25, 2026
Merged

Mosch0512 merged 18 commits into
masterfrom
feat/item-formulas-data

Conversation

@Mosch0512

Copy link
Copy Markdown
Owner

Why

The next change moves the item requirement, power-up and price formulas into configuration data, so that the client's item sync can read and write them. With the default data, players must see exactly the same values as before. This PR adds the proof first, without changing any game code: a test that compares today's formulas with the current code for every item.

What

  • Frozen copies of today's code at 90a5711, in tests/MUnique.OpenMU.Persistence.Initialization.Tests/ItemFormulas/Legacy/:

    • LegacyItemExtensions: the requirement formulas (GetRequirement, CalculateDropLevel and the two private helpers) plus the item checks and durability functions they use, from src/GameLogic/ItemExtensions.cs lines 19-106, 131-141, 174-289, 361-426, 474-500 and 512-561.
    • LegacyItemPowerUpFactory: src/GameLogic/ItemPowerUpFactory.cs lines 17-455; only the class name changed.
    • LegacyItemPriceCalculator: src/GameLogic/ItemPriceCalculator.cs lines 19-581.

    Each header names its line ranges, so the copy can be checked with a line-range diff. The extension method calls in the copies bind to LegacyItemExtensions, because extension methods of the own namespace come before imported ones. So the copies don't depend on the file the next change edits; they still call IsWearable, IsTrainablePet, IsDarkRaven, IsWing and Durability of src/DataModel/ItemExtensions.cs.

  • ItemFormulasEqualTodayTest runs every item of the 0.75, 0.95d and Season 6 data through both sides and compares:

    • the requirement rows;
    • the power-ups, as sorted lists of (attribute, aggregate type, value), read from a fresh attribute system;
    • the buying, old buying, selling (at maximum and current durability) and repair prices (with and without discount);
    • that an exception is thrown by both sides.

    Cases:

    • every item level;
    • normal, excellent with all its excellent options and with one of them, ancient, and excellent ancient;
    • without its option, and at the option's first and highest level;
    • every slot the item fits;
    • full, half and no durability.

    A wearable item which can't be excellent or ancient in the data gets a made-up excellent option or ancient set, so that every kind of item (sceptres, books, wings, pets...) runs through those formulas. The requirements are also checked:

    • with made-up rows of each stat (0, 100, and 100 from item level 7);
    • with each harmony option that reduces a requirement, at its lowest and highest level;
    • with a made-up reduction of each stat.

    All differences are listed in one report, with a count per kind.

  • TestGameConfiguration.Create075Async.

Testing

  • dotnet build src/MUnique.OpenMU.sln -p:ci=true, then dotnet test tests/MUnique.OpenMU.Persistence.Initialization.Tests --no-build -p:ci=true: all 674 tests pass (7 skipped, as before). The new test takes about 34 s (Season 6 23 s, 0.95d 8 s, 0.75 3 s).

  • To see that the test catches changes, I broke 10 formulas in the live code on purpose, one round at a time, and reverted them afterwards. The test reported each one:

    • the excellent drop level bonus of requirements;
    • the excellent shield defense rate;
    • the excellent price bonus;
    • the staff rise slot check;
    • the ancient armour defense divisor;
    • the +2% option check of the double-wield −1;
    • the ancient sceptre rise divisor;
    • the zero guard of requirements;
    • the leadership reduction mapping;
    • IsArmorItem (this one shows that the copies no longer use the live item checks).

    0.75 has no excellent items, so it only fails for the breaks that affect normal items.

…s for every item

Adds frozen copies of the item requirement formulas (with the item checks
and durability functions they use), ItemPowerUpFactory and
ItemPriceCalculator at 90a5711, and a test which compares them with the
current code for every item of the 0.75, 0.95d and Season 6 data: at every
item level, as normal, excellent (all options and one option), ancient and
excellent ancient item (made up where the data has none), at option level
0, 1 and the highest, with harmony and made-up requirement reductions, in
every slot and at full, half and no durability. All differences are listed
in one report.

This prepares moving these formulas into configuration data: that change
has to keep every value the same.

@Mosch0512 Mosch0512 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: changes requested. Four items are required; the other 9 are non-blocking.

Required

  • "Half durability" sits exactly on the 50 % threshold, so the 0.8 factor only shows for odd maximum durabilities, and 0.7 and 0.5 never (inline, ItemFormulasEqualTodayTest.cs:437).
  • No item gets a skill, Luck, wing options or a 380 option, so those price surcharges are never compared (inline, ItemFormulasEqualTodayTest.cs:234).
  • The power-up copy still calls live helpers that change values, and the remarks don't name them, e.g. IsFixedOption (inline, LegacyItemPowerUpFactory.cs:281).
  • The power-up copy implements the live IItemPowerUpFactory, so changing that interface in the next step means editing the copy (inline, LegacyItemPowerUpFactory.cs:26).

Non-blocking

  • Only the first normal option of an item is tested (inline, ItemFormulasEqualTodayTest.cs:205).
  • Buying prices do depend on the durability, and stacks are never larger than one piece (inline, ItemFormulasEqualTodayTest.cs:457).
  • Capture keeps only the exception type (inline, ItemFormulasEqualTodayTest.cs:314).
  • The live calls that prepare the durability run outside Capture (inline, ItemFormulasEqualTodayTest.cs:434).
  • Power-ups that follow the character level always read 0 (inline, ItemFormulasEqualTodayTest.cs:320).
  • Nothing says when the test and the copies are removed, or what to do after an intentional formula change (inline, ItemFormulasEqualTodayTest.cs:20).
  • FormulaTestItem repeats TemporaryItem (inline, ItemFormulasEqualTodayTest.cs:476).
  • About 115 copied lines are never run (inline, LegacyItemPowerUpFactory.cs:114).
  • The two CompareRequirements overloads differ only in the list type (inline, ItemFormulasEqualTodayTest.cs:417).

I compared the three copies with the code at 90a5711 by the line ranges in their headers: there are no differences besides the class name in lines 30, 33 and 36 of ItemPowerUpFactory.cs. I checked where each extension method call in the copies binds, and read the test against the price and power-up code and the Season 6 data of wings, capes and stackable items. I didn't build or run the test. CI: build passed.

var fullDurability = item.GetMaximumDurabilityOfOnePiece();
foreach (var slot in slotType.ItemSlots)
{
foreach (var durability in new[] { fullDurability, fullDurability / 2 }.Distinct())

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required. GetCurrentDurabilityFactor returns 0.8 only when the durability is below half of the maximum (<, not <=). For an even maximum, e.g. 20, half is 10, and 10 < 10 is false, so this case gets factor 1, the same as full durability. Only odd maxima reach 0.8, and nothing goes below 30 % or 20 %, so the factors 0.7 and 0.5 are never tested. The next step rewrites the excellent and ancient bonuses, which are multiplied by this factor; if it applies the factor wrongly at low durability, every case still matches. One durability under each threshold (e.g. 40 %, 25 % and 10 % of the maximum) would cover all three.

{
foreach (var optionLevel in optionLevels)
{
var item = new FormulaTestItem { Definition = definition, Level = (byte)level };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required. The item never gets HasSkill, Luck, wing options or a Jewel of Guardian option. CalculateBuyingPrice adds 150 % for a skill (except the worthless ones), 25 % for Luck, 25 % per wing option and 16 % for the guardian option. So a weapon as the shops sell it (skill and Luck), 2nd and 3rd wings with their wing options, and 380 items are never priced, and a change that reorders or breaks one of these steps passes. Variants with HasSkill and Luck, plus the wing and guardian options where the item has them, would cover those branches.

var optionOfLevel = option.LevelDependentOptions?.FirstOrDefault(l => l.Level == level);

// Fixed options (e.g. of the Dinorant) have no level dependent values, so their level doesn't matter.
if (optionOfLevel is null && level > 1 && !option.IsFixedOption())

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required. IsFixedOption comes from src/GameLogic/ItemOptionExtensions.cs, which is live code, and here it decides whether an option's power-up is skipped. PowerUpWrapper.CreateByPowerUpDefinition, Boost.GetSimpleValueElement, ItemBasePowerUpDefinition.GetBaseValueElement and LevelBonus.GetAdditionalValueElement are live too, and they apply the durability factor. A change in any of them moves the copy and the live code together, so the test can't see it. The remarks in LegacyItemExtensions.cs (lines 23-24) name only IsWearable, IsTrainablePet, IsDarkRaven, IsWing and Durability. Copying IsFixedOption into LegacyItemExtensions (the call then binds there, like the others) and naming the rest in the remarks would make the claim true.

/// Lines 17-455 are copied unchanged, except the class name in lines 30, 33 and 36.
/// The item checks, the drop level and the durability factor come from <see cref="LegacyItemExtensions"/>.
/// </remarks>
internal class LegacyItemPowerUpFactory : IItemPowerUpFactory

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Required. The copy implements the live IItemPowerUpFactory. The next step gives the live side the configuration; if that adds a parameter to GetPowerUps or GetSetPowerUps, this class stops compiling and has to be changed, although its header says not to. The test calls GetPowerUps through a delegate, so the interface isn't needed. Removing : IItemPowerUpFactory (and the inheritdoc tags) keeps the copy independent.

public void CompareItem(ItemDefinition definition)
{
var possibleOptions = definition.PossibleItemOptions.SelectMany(o => o.PossibleOptions).ToList();
var option = possibleOptions.FirstOrDefault(o => o.OptionType == ItemOptionTypes.Option);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. FirstOrDefault takes only the first option of type Option. Several items have two or three: Wings of Spirits (HP recovery, then physical damage), the 3rd wings (plus defense), Cape of Fighter (HP recovery, then physical damage), and the Dinorant with its fixed options. So the cape's physical damage option (whose price gets no option bonus), the wings' defense option, more than one Dinorant option in its price, and the HP recovery exception from the durability factor (LegacyItemPowerUpFactory.cs:323) are covered only when that option comes first. Looping over all options of type Option would cover them.


private static string DescribePowerUps(Item item, Func<Item, AttributeSystem, IEnumerable<PowerUpWrapper>> getPowerUps)
{
using var attributes = new AttributeSystem([], [], []);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The excellent options "damage +level/20" (wizardry, curse and physical) follow Stats.TotalLevel through a related value. In this empty attribute system that input is 0, so their value is always 0 and only the target and the aggregate type are compared. Giving the attribute system a level (e.g. 400) before reading the elements would compare their values too.


/// <summary>
/// Tests that the item requirement, power-up and price formulas give for every item of every version's data
/// what the server computed at commit 90a571100, which the frozen copies in the <c>Legacy</c> folder keep.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The copies say "Don't change it", and the test fails on any intentional change to the requirements, power-ups or prices, e.g. fixing the DevilInvitation price that the code itself marks with Bug?. Nothing says whether the test and the Legacy folder are removed after the move to configuration data, or how to update them after such a fix. A sentence about that in the remarks would help whoever changes a formula next.

/// <summary>
/// An item which isn't persisted.
/// </summary>
private sealed class FormulaTestItem : Item

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. This is MUnique.OpenMU.GameLogic.TemporaryItem (src/GameLogic/TemporaryItem.cs): an item that isn't persisted, with the same constructor. The file already imports MUnique.OpenMU.GameLogic, so new TemporaryItem { Definition = definition, Level = (byte)level } would do.

}

/// <inheritdoc/>
public IEnumerable<PowerUpWrapper> GetSetPowerUps(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The test never calls GetSetPowerUps, GetOptionCombinationBonus and AreRequiredOptionsFound (about 110 lines), and IsTimeDecaying in LegacyItemExtensions.cs isn't called either. They come from the interface and the contiguous line ranges, and they suggest that the set bonuses are pinned, which they aren't. Either compare GetSetPowerUps too, or leave them out together with the interface.

}
}

private void CompareRequirements(Item item, List<AttributeRequirement> madeUpRequirements, string itemCase)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. This overload and the one at line 405 differ only in the element type of the list (List<AttributeRequirement> and List<ReductionOption>), and they call each other. Passing the other list still compiles and silently picks the other overload. A name of its own, e.g. CompareRequirementsWithReductions, would make each call clear.

…nterface

A change of IItemPowerUpFactory, e.g. a configuration parameter, would
otherwise force a change of the copy.
…ozen copies

The test never ran them, and they suggested that the set bonuses are
pinned, which they aren't.
…till call

The copies no longer import the game logic's namespace, so none of its
helpers can move the copy and the live code together; the remarks name
the live data model code which builds the power-up elements.
…name

The two overloads differed only in the element type of their list, so
passing the other list would have picked the other one silently.
…essage

A throw no longer replaces the whole list of power-ups or requirements,
and two failures of the same type with different messages now differ.
The throwing method isn't compared, because the live code may compute
the same in other methods.
…ifference

The cases were prepared outside the capture, so an item which throws
there would have stopped the test without reporting the others.
…er level

The empty attribute system read the total level as 0, so the excellent
damage +level/20 options were compared by their target only.
…ity factor

Half of an even maximum durability isn't below the 50 % threshold, so the
factor 0.8 showed only for odd maxima, and 0.7 and 0.5 never.
…tacks

The buying prices of potions, arrows and other stackable items multiply
the number of pieces, which is their durability; a stack of more than one
piece wasn't priced.
…ptions

The prices add a surcharge for a skill, luck, each wing option and the
option of the Jewel of Guardian, which no compared item had.
…ions together

Only the first option of type Option was tested, but wings, capes and the
Dinorant have two or three.
… change needs

The copies said only "Don't change it"; now the test's remarks say how
to update them after an intentional formula change and when they can be
removed.
The durability factor multiplies the same parts at every item level, so
the durabilities below its thresholds are compared at the lowest and the
highest item level; the prices, which have no durability factor, at full,
40 % and no durability, and with the item's skill and extra options at
full durability. Season 6 takes about 33 s instead of 61 s.
…nguage

The item names already were; the option type names followed the culture
of the machine.
They import MUnique.OpenMU.GameLogic.Attributes, so PowerUpWrapper and
Stats are live code too; only the extension methods of the game logic
can't change the copies and the live code together.
…tory

The copy's namespace lies within MUnique.OpenMU.Persistence, so GetId()
resolves without it (Codacy).
@Mosch0512
Mosch0512 merged commit 0e555f1 into master Oct 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant