Skip to content

feat(items): item rule flags (tradable, droppable, storable, sellable, repairable) - #983

Merged
sven-n merged 7 commits into
MUnique:masterfrom
Mosch0512:feature/item-rule-flags
Sep 27, 2026
Merged

sven-n merged 7 commits into
MUnique:masterfrom
Mosch0512:feature/item-rule-flags

Conversation

@Mosch0512

@Mosch0512 Mosch0512 commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Server part of the data-driven items plan in MuMain (phase 3 / "PR A"): the client moved its item rules into data in sven-n/MuMain#635. This adds the same rules to OpenMU, so the server enforces what the client shows. Reviewed first in the fork (Mosch0512#1).

What changes

New fields on ItemDefinition, true unless set otherwise:

Field Checked in Client item data field
IsTradable MoveItemAction (into a trade) and TradeButtonAction (when the trade completes) tradable
IsStorable MoveItemAction (into the vault) storable
IsPersonalStoreSellable MoveItemAction (into the personal store) and BuyRequestAction (when someone buys it) personalShopSellable
IsSellableToNpc SellItemToNpcAction sellable
IsDroppable DropItemAction droppable
IsRepairable ItemRepairAction, via the new Item.CanBeRepaired() (repair all, offline auto repair and bot repairs skip such items) repairable

IsRepairable only covers the normal repair (NPC or inventory repair). Other ways to restore durability don't depend on it. For example, the Horn of Fenrir can't be repaired normally, but a Jewel of Bless still repairs it (configured as a repair target item of the Jewel of Bless consume handler). A test covers that.

  • A refused action shows a message to the player (new PlayerMessage entries) and logs a warning. The client should not have sent the request, so either its item data differs from the server or it is a modified client.
  • IsBoundToCharacter works as before (owner-only pickup, no moves between storages).
  • Trade and personal store rules are checked when an item arrives and again when the deal completes. The second check covers items that were in the storage before the rules existed.
  • Leftovers in the temporary storage: when a trade opens, items left there by another window (e.g. the chaos machine) go back to the inventory first. That storage is shared with the trade. Before, those items could be traded, or were lost when the trade was cancelled, because a cancel restores the inventory backup and empties the temporary storage.
  • Bots no longer try to sell items that NPCs refuse, which they then destroyed under slot pressure.
  • The admin panel shows the new fields as checkboxes on the item pages (generic AutoFields).

Data

  • The migration AddItemRuleFlags adds the columns with the default true, so existing items keep allowing everything.
  • VersionSeasonSix/Items/ItemRules lists the 156 Season 6 items that don't allow every action. The values come from the client item data. The list is applied by the Season 6 initialization and, for existing databases, by the mandatory AddItemRuleFlagsPlugIn update.
  • 0.75 and 0.95d are unchanged.

Two deliberate differences to the client data (and one deliberate match):

  • Wizards Ring: bound to the character here, so its flags say it can't be traded, stored or sold in a personal store. The client allows that at +0; its data will be adjusted.
  • Dark Horse and Dark Raven: stay repairable, because the server repairs trainable pets.
  • Matching the client on purpose: the other pets (Guardian Angel, Imp, Uniria, Dinorant, Fenrir) and the transformation rings can't be repaired, although they wear out. Until now the NPC repaired them. A pet that can't be trained is destroyed at durability 0.

Dark Horse can fly

Fixes #982.

The Icarus map requires CanFly. The wings, the Horn of Dinorant and the Horn of Fenrir give it; the Dark Horse didn't. The client counts the Dark Horse as a flying mount, so client and server disagreed about Icarus.

  • The Season 6 initialization now gives the Dark Horse the CanFly power-up.
  • The mandatory AddDarkHorseCanFlyPlugIn update adds it to existing databases, only once.

This is data only, like CanFly on the other items; there is no schema change.

Why repair matters

For items that can't be worn, OpenMU counts durability as the stack size, and the maximum durability of one piece is 1. A repair request for a stack sets the durability to 1, and because the durability is above the maximum, the repair price is negative. Verified against master, in a unit test (a stack of 3 apples becomes 1 apple, and the player gains 1,158 Zen) and in the game: a MuMain client whose data allowed repairing apples sent the request, and the server turned the stack into one apple while adding Zen.

The normal client never sends this request, because it blocks repairing potions, so players don't run into it. A modified client could, and could farm Zen with it. Item.CanBeRepaired() now refuses items that can't be worn, in every game version and for custom items, independent of the item data.

Not included

  • Rules that depend on the item level (e.g. Box of Luck +13). These level variants become items of their own in a later phase.
  • The client's rules for rented (time-limited) items, since OpenMU has no rental items.

Tests

  • ItemRuleFlagsTests:
    • drop (droppable and not), NPC sale;
    • repair of a wearable item (repaired to full, or not at all) and of a stack;
    • leftover items when a trade opens, a trade with an untradable item, a personal store purchase.
  • MoveItemActionTests: trade, vault and personal store.
  • ItemRulesDataTest:
    • every listed item exists once;
    • a new Season 6 database has the expected values;
    • the update gives an existing database the same values as a new one.
  • ItemConsumptionTest: the Jewel of Bless repairs an item that can't be repaired normally.
  • DarkHorseCanFlyTest: a new Season 6 database lets the Dark Horse fly, and the update adds the power-up once.
  • Full runs pass: MUnique.OpenMU.Tests (1033) and MUnique.OpenMU.Persistence.Initialization.Tests (30, 6 skipped), in Release as in the pipeline.

Tested in game

Against a local all-in-one container built from this branch, with an existing Season 6 database:

  • Startup: the migration ran automatically. After it, every item still allows everything. The update appeared as mandatory under Updates and set the rules; the admin panel shows the six new checkboxes on the item pages.
  • Stack repair, before this PR: a MuMain client whose data allowed repairing apples sent the request, and the server turned a stack of apples into one apple while adding Zen.
  • Stack repair, with this PR: the same client's request is refused. The stack and the Zen stay as they are, the player sees "This item can't be repaired.", and the server log has the warning.
  • Normal client data: the client doesn't send the request at all, and nothing is logged.

The update plugins here only change default values; #984 proposes showing such changes on the Updates page before they are applied.

Adds IsTradable, IsDroppable, IsStorable, IsSellableToNpc,
IsPersonalStoreSellable and IsRepairable to ItemDefinition. They are true
unless set otherwise, and the migration adds them with the default true,
so existing items keep allowing everything. The admin panel shows them
as checkboxes on the item pages.

They match the rule flags of the MuMain client item data
(tradable, droppable, storable, sellable, personalShopSellable,
repairable), so both sides can allow the same actions.
- MoveItemAction: an item can't be moved into a trade, the vault or the
  personal store when IsTradable, IsStorable or IsPersonalStoreSellable
  is false.
- SellItemToNpcAction: IsSellableToNpc.
- DropItemAction: IsDroppable.
- ItemRepairAction: IsRepairable; repair all skips such items.

A refused action shows a message to the player and logs a warning: the
client should not have sent it, so its item data may differ from the
server (or the client is modified). IsBoundToCharacter keeps working as
before.
ItemRules lists the Season 6 items which don't allow every action, with
the values of the MuMain client item data (src/bin/Data/Items). It is
applied by the Season 6 initialization and, for existing databases, by
the mandatory AddItemRuleFlagsPlugIn update.

Two differences to the client data:
- The Wizards Ring is bound to the character here, so it is not
  tradable, storable or sellable in a personal store (the client allows
  that at +0; its data will be adjusted).
- The Dark Horse and Dark Raven stay repairable, because the server
  repairs trainable pets.
- Trade: the rules are also checked when the trade completes. Items
  which another window (e.g. the chaos machine) left in the temporary
  storage go back to the inventory when a trade opens; before, they
  could be traded, or were lost when the trade was cancelled.
- Personal store: an item which isn't sellable there can't be bought,
  also when it was put into the store before the rules existed.
- Repair: new Item.CanBeRepaired() (wearable and IsRepairable). Items
  which can't be worn are no longer repaired in any version: their
  durability is the stack size, and a repair reduced it to one. Offline
  auto repair and the bots' merchant repair skip items which can't be
  repaired instead of logging a warning on every attempt.
- Bots keep items which can't be sold to NPCs instead of trying to sell
  and then destroying them.
- ItemRules.Apply no longer fails on duplicate item definitions; every
  copy gets the rule.
- The remarks of ItemRules say that the pets and transformation rings
  follow the client and can't be repaired.
- Tests: the repair test uses a wearable item and checks the full
  durability; new tests for stacks, leftover items when a trade opens,
  the trade completion and personal store purchases. The data test
  builds the Season 6 data once for its read-only tests.
IsRepairable and Item.CanBeRepaired() are about the repair by an NPC or
the inventory repair. Other ways to restore the durability don't use
them: the Horn of Fenrir, which can't be repaired normally, is still
repaired with a Jewel of Bless (the repair target items of the jewel of
bless consume handler). A test covers that.
- ItemRules lists the blocked actions of each item instead of combining
  them with bitwise operations: Codacy's Sonar analysis didn't see the
  Flags attribute of the nested enum and flagged every combination.
- TradeButtonAction checks both traders' items with two variables
  instead of a non-short-circuit operator, and gets the missing blank
  line after the block.
The Icarus map requires the CanFly attribute, which the wings, the Horn
of Dinorant and the Horn of Fenrir give. The Dark Horse didn't, while
the client counts it as a flying mount (its data tags it as flying), so
client and server disagreed about entering and staying in Icarus.

The Season 6 initialization now gives the Dark Horse the CanFly
power-up, and the mandatory AddDarkHorseCanFlyPlugIn update adds it to
existing databases (only once).

sven-n commented Sep 27, 2026

Copy link
Copy Markdown
Member

Review

I read the full diff on head 90b255b. Summary: this looks good to merge.

Verification

I ran the pipeline steps on Linux with the .NET 10.0.401 SDK, in Release with -p:ci=true:

  • dotnet build MUnique.OpenMU.sln succeeds with 0 errors.
  • MUnique.OpenMU.Tests: 1033 passed, 0 failed.
  • MUnique.OpenMU.Persistence.Initialization.Tests: 30 passed, 6 skipped, 0 failed.

The red MUnique.OpenMU Azure check (build 4893) finished in 0 s with "1 errors". Builds 4891 (#977) and 4894 (#974) fail the same way, so the pipeline itself is broken. This PR's code isn't the cause.

What I checked

  • Rule checks. Every refused action logs a warning, shows a PlayerMessage and sends the matching failure response (ItemDropResult(false), ItemMoveFailed, ItemSoldToNpc(false), ItemBlock), so the client isn't left waiting. MoveItemAction only checks moves between storages (fromStorage != toStorage), so moving an item inside a storage still works.
  • Trade. The rules are checked again in InternalFinishTradeAsync. When that check fails, the result is Cancelled, and TradeButtonChangedAsync then calls CancelTradeAsync(..., false) for both traders, which restores the inventory backups. This works the same way as the existing full-inventory failure.
  • Leftovers in the temporary storage. Moving them back to the inventory before BackupInventory is created fixes a real bug: before, they could be traded, or were lost when the trade was cancelled.
  • Repair. CanBeRepaired() requires IsWearable() before it reads Definition!, so the null-forgiving operator is safe. It closes the stack repair that produced a negative price and Zen. Repair all, offline repair and bot repair all use the same check. The Jewel of Bless path doesn't use it, so the Horn of Fenrir can still be restored with a Jewel of Bless.
  • Data. The migration uses defaultValue: true and matches the model defaults. Both update plugins are mandatory, belong to Season 6 only and can run more than once safely: ItemRules.Apply sets the values outright, and the Dark Horse update checks whether the power-up already exists. UpdateVersion 116 and 117 come right after AddDoppelgangerData = 115.

Minor notes (none of them block the merge)

  1. Rules apply to every level of an item. The PR description already says this, but it affects items people use a lot. For example, 14/21 (Rena) can no longer be traded or stored, and it also covers the Stone (+1) and Sign of Lord (+3). 14/11 (Box of Luck) can no longer be sold to NPCs, and it also covers the Box of Kundun +1 to +5, the medals and the event hearts and stars. Please mention this in the release notes until the level variants become separate items.
  2. ItemRules.cs: the comments on 12/9 and 12/14 both say "Orb of Greater Fortitude". 14 is probably another orb, and the name was copied from the client data. 13/39 says "Eilte" instead of "Elite".
  3. TradeButtonAction.CheckItemsAreTradableAsync: ITrader already has Logger (BaseTradeAction.SendMessageAsync uses it), so (trader as Player)?.Logger can be trader.Logger.
  4. Many Blocked.Repair entries, like the scrolls, jewels and orbs, are already covered by the IsWearable() check in CanBeRepaired(). Keeping them so the list matches the client data one-to-one is fine.

Generated by Claude Code

@sven-n
sven-n merged commit 866b0f5 into MUnique:master Sep 27, 2026
1 of 2 checks 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.

Dark Horse does not give CanFly, so it cannot be used to enter Icarus

2 participants