Skip to content

feat(player-shop): prices in items and account coins - #1191

Merged
sven-n merged 4 commits into
masterfrom
claude/openmu-1186-shop-currencies-dphx9w
Oct 14, 2026
Merged

sven-n merged 4 commits into
masterfrom
claude/openmu-1186-shop-currencies-dphx9w

Conversation

@sven-n

@sven-n sven-n commented Oct 11, 2026 •

Copy link
Copy Markdown
Member

Closes #1186.

The packet definitions and the NuGet 0.9.11 bump were merged with #1190. This PR contains the server part.

Players can price each item of their personal store in:

  • zen;
  • configured price items: by default the Jewels of Bless, Soul, Chaos, Creation and Life, but an admin can add any item;
  • the account coins WCoin (C), WCoin (P) or Goblin Points.

The feature is disabled by default. While it's off, the protocol and the behavior are unchanged: no new packets are sent and only zen prices are possible.

Design

  • Model:
    • Item.StorePriceItemDefinition is a reference to the price item's ItemDefinition, following the model rule of references instead of ids.
    • Item.StorePriceCoinType (CashShopCoinType?) holds a coin price.
    • Both are null for zen, so existing rows need no data fix.
    • Comes with a migration (20261014104431_AddItemStorePriceCurrency: two nullable columns and a foreign key to config.ItemDefinition), which was regenerated on top of master's latest migrations. It also includes regenerated compiled models (Entity/Account/Trade contexts), the regenerated GameConfigurationLoader and the generated EF and basic models.
  • Toggle: PlayerShopCurrenciesFeaturePlugIn (IFeaturePlugIn, IDisabledByDefault) with its own config class:
    • PriceItems: a list of ItemDefinition, editable in the admin panel;
    • one flag per account coin, off by default because coins are usually bought with real money;
    • MaximumItemAmount, capped at 65535 because the amount travels in a 16-bit field;
    • MaximumCoinAmount.
  • Defaults: a new database gets the five jewels as price items, set up in DataInitializationBase like the existing Bless jewel plugin. On an existing database, the plugin configuration is created at startup without game data, so its price items list starts empty. Since the plugin is opt-in, the admin adds items when activating it; the docs say so.
  • ShopPrice / ShopPrices:
    • ShopPrice is the price value: amount, price item or coin type.
    • ShopPrices decides which prices are allowed, gives the localized currency name (item name or coin) and parses chat commands.
  • Wire encoding: PlayerShopPriceEncoding in the GameServer turns a price into the protocol fields and back. The item list, the 3F 09 handler and the 3F 0A price types message all use it.
  • Payment, one IShopCurrencyExchange per kind. Each returns a ShopPaymentResult enum instead of a bool:
    • Zen: same transfer and seller-overflow refund as before; the packet order is kept.
    • Items: only whole inventory items are used. A single counts 1; a bundle from the jewel mix counts (level + 1) * 10. Only tradable, option-free items count, and singles must be level 0. A small subset-sum picks a combination that matches the price exactly: bundles are never split and there is no change. The items are moved buyer → seller inside the same TradeContext save as the sold item.
    • Coins: pending grants are applied first, then the buyer's account is saved right away, with an in-memory rollback if that save fails. The seller's coins are persisted by the seller's regular save, like zen, so the seller's persistence lock is never taken from the buyer's flow (see the PlayerPersistence invariant).
  • Server-side enforcement:
    • set price: store closed, level ≥ 6, not negative, the price item or coin allowed, within the cap;
    • open store: refused if an item has a price that isn't allowed;
    • buy:
      • price and currency are checked again under StoreLock;
      • the original client (no IShowShopCurrenciesPlugIn) is refused for non-zen items;
      • the buyer's funds and the seller's capacity are checked.
  • Views:
    • The original client's list shows zen items only.
    • The extended list encodes the price and hides items whose price isn't allowed.
    • 3F 0A lists the allowed price types on world entry and before a shop list, only while the feature is on.
    • When items or coins are missing, the buyer gets LackOfPriceCurrency and a localized blue message with the real amount.
  • Chat fallback: /shopprice [position 1-32|slot 204-235] [amount] [currency], disabled by default. The currency is zen, a jewel alias (bless, soul, …), group/number (e.g. 14/42) or a coin alias (wcc, wcp, gp). Without arguments it lists the store's prices.
  • Localization: new messages and plugin names in English and German.
  • Docs: docs-website/docs/server-features/player-shop-currencies.md.

Testing

  • dotnet test on the branch merged with current master, all passing:
    • MUnique.OpenMU.Tests: 1819 passed, including CompiledModelTests and GameConfigurationLoaderGeneratorTest.
    • MUnique.OpenMU.Persistence.Initialization.Tests: 629 passed, 7 skipped.
    • MUnique.OpenMU.Web.Tests: 242 passed.
  • New tests:
    • PlayerShopCurrenciesTest:
      • setting prices: feature off/on, coins disallowed by default, the cap, an item that isn't configured, a 0 price clearing the price;
      • opening a store whose prices aren't allowed;
      • buying with jewels: an exact bundle + singles combination, and the "no exact combination" case;
      • buying with coins: success and too few coins;
      • buying with a currency that was disallowed after the store opened;
      • zen buys unchanged;
      • the subset-sum selection, chat currency parsing and the allowed prices.
    • PlayerShopCurrenciesRemoteViewTests: the price-types message bytes, an encode/decode round trip (including an unknown item and an amount that overflows int), and the extended list encoding with a disallowed item hidden.
    • PlayerShopCurrenciesDataTest: a new database has the plugin deactivated, with references to the five jewels.
  • Codacy: the findings of the first analysis are fixed, and the analysis now passes.
  • In game: @eduardosmaniotto tested it with the original client and with the MuMain client of feat(personal-shop): prices in items and account coins sven-n/MuMain#710 (now merged):
    • the original client hides the items with other prices;
    • MuMain shows the prices, the "not enough" message, and successful purchases.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying openmudocs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4cd5b42
Status: ✅  Deploy successful!
Preview URL: https://1d3aace5.openmudocs.pages.dev
Branch Preview URL: https://claude-openmu-1186-shop-curr.openmudocs.pages.dev

View logs

Players can define the price of each item of their personal store in
zen, in configured items (by default the Jewels of Bless, Soul, Chaos,
Creation and Life) or in the account coins WCoin C/P and Goblin Points.
Closes #1186.

- Item.StorePriceItemDefinition (reference to the price item) and
  Item.StorePriceCoinType, both null for zen prices, with migration,
  compiled models and generated loader code.
- PlayerShopCurrenciesFeaturePlugIn (disabled by default) with the list
  of price items, one flag per account coin and maximum amounts. A new
  database gets the five jewels as price items. While the plugin is
  off, the protocol and the behavior stay as before.
- ShopPrice describes a price; ShopPrices decides which prices are
  allowed, names them and parses them in chat commands.
- One payment exchange per price kind: zen as before; items move as
  whole items (singles and jewel mix bundles, exact amount, no change)
  within the trade context which also moves the sold item; coins are
  taken from the buyer's account and saved right away, with a rollback
  when the save fails.
- Set price, open store and buy re-validate the price server-side.
  The original client only sees zen items and can't buy others.
- Extended client: the 3F 09 set price handler and the extended item
  list share one price encoding (MoneyPrice, PriceItemType,
  RequiredItemAmount); 3F 0A lists the allowed price types on world
  entry and before a shop list.
- /shopprice chat command (disabled by default) for every client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE
@sven-n
sven-n force-pushed the claude/openmu-1186-shop-currencies-dphx9w branch from cab3e6f to 4d14134 Compare October 11, 2026 12:22
@sven-n
sven-n deleted the branch master October 11, 2026 12:24
@sven-n sven-n closed this Oct 11, 2026
@sven-n sven-n changed the title feat(player-shop): prices in jewels and account coins feat(player-shop): prices in items and account coins Oct 11, 2026
@sven-n sven-n reopened this Oct 11, 2026
@sven-n
sven-n changed the base branch from claude/openmu-1186-packet-nuget-dphx9w to master October 11, 2026 12:48
@eduardosmaniotto

Copy link
Copy Markdown
Contributor

I tested this feature and found some issues:

  1. No spacing between coin and allowed in admin:
space
  1. The modal to add an item was cancelling the modal to configure the currencies. I've addressed this bug in a separate PR: fix(admin): support nested modals #1194

  2. I review with Muse Spark 1.3 (take this with a grain of salt):

1.1 Zero-amount price sells items for free (DB-tamper reachable)

ItemStorePriceExtensions.GetStorePrice treats any non-null amount as a price:

return item.StorePrice is { } amount
    ? new ShopPrice(amount, item.StorePriceItemDefinition, item.StorePriceCoinType)
    : null;

ItemShopCurrencyExchange.SelectRows(rows, 0) then returns an empty (non-null) selection — the reachedBy[0] base case — and TryPayAsync succeeds while moving nothing. The set-price path maps 0 to removal, so this needs direct database access to plant, but defense in depth is a one-liner: return null for amount <= 0 in GetStorePrice. (Our branch guards exactly this, with a test.)

1.2 Bundle levels are honored without any bound

GetPaymentRows values a bundle at (item.Level + 1) * PiecesPerBundleLevel with no cap. A Level 255 row (database tampering, admin spawn, or any future minter) pays 2,560 — and because rows move, the seller receives a bundle that UnstackItemsAsync redeems to zero (its (byte) cast truncates 2560 → 0), so the loss lands on the seller. Suggested fix, all fail-closed:

  • Reject Level > 2 in one shared helper (JewelMix carries no size and the protocol caps stacks at 30, so the bound is structural, not conventional).
  • Skip such rows in GetPaymentRows (they already skip cleanly — the helper just needs to say no).

Our branch implements exactly this (MaxBundleLevel, shared helper, tests); happy to port it over.

1.3 Subset-sum array is sized by the price, not by the tender

var reachedBy = new int[price + 1];

price is admin-configurable (MaximumItemAmount, default 1000). An admin-set maximum near int.MaxValue turns a single buy attempt into a gigabytes allocation — a self-inflicted DoS with no attacker needed. Bound the DP by the actual tender (face value) instead; face value is capped by real inventory sizes, the configured price is not.

1.4 Packet handler throws on hostile input (log flood)

PlayerShopSetItemPriceExtendedPacketHandlerPlugIn.HandlePacketAsync converts the packet struct with no length check, and JewelMixHandlerPlugIn still throws InvalidEnumArgumentException on unknown operations/sizes. Each forged byte becomes an error + stack trace in the log, with no disconnect and no rate limit. Drop quietly with a warning (our branch does this for both handlers, tested).

- Captions and descriptions for the settings of the plugin, so that the
  admin panel shows "Is WCoin (C) Allowed" instead of words split at the
  wrong places.
- A stored price of 0 or less counts as no price, so such an item can't
  be bought for nothing; the buy and the store opening check it.
- Bundles with a higher level than the jewel mix creates (10, 20 or 30
  pieces) don't count as payment, because they couldn't be unstacked
  into their pieces.
- A truncated set price request is ignored with a warning instead of
  throwing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE

sven-n commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Thanks for testing and for the review, @eduardosmaniotto! Addressed in ca9f1f2:

  1. Missing space in the admin panel: fixed. The settings now have captions and descriptions in the model resources (en/de), so the panel shows e.g. "Is WCoin (C) Allowed". A test checks the caption.
  2. Modal cancelling the configuration modal: thanks for fixing it separately in fix(admin): support nested modals #1194; this PR doesn't touch it.
  3. Review points:
    • 1.1 Zero price: fixed. GetStorePrice treats an amount ≤ 0 as no price, and the buy and the store opening use it. A stored 0 is refused instead of being sold for nothing. A test covers it.
    • 1.2 Bundle level: fixed. Only bundles the jewel mix can create (level 0–2, i.e. 10/20/30 pieces) count as payment, through MaximumBundleLevel. A test checks that a level 3 bundle isn't accepted. The (byte) truncation in ItemStackAction.UnstackItemsAsync is pre-existing and outside this PR.
    • 1.3 Subset-sum array: I couldn't find a path to a large allocation. TryPayAsync returns LackOfCurrency before SelectRows when the buyer's rows add up to less than the price. So the array is bounded by the items the buyer actually has, not by the configured maximum, which is also capped at 65535 by GetMaximumAmount. I documented this bound on SelectRows instead of changing the algorithm.
    • 1.4 Truncated packet: fixed for the new 3F 09 handler. It now ignores a packet shorter than its length with a warning, like GuildRoleAssignHandlerPlugIn. A test covers it. JewelMixHandlerPlugIn is existing code that this PR doesn't change, so I left it for a separate change.

Tests: MUnique.OpenMU.Tests 1697 passed, Persistence.Initialization.Tests 614 passed (7 skipped), Web.Tests 215 passed. The three new tests for 1.1, 1.2 and 1.4 fail without the fixes.


Generated by Claude Code

sven-n and others added 2 commits October 14, 2026 10:57
Master added the migrations AddGensRewardClaim and AddCrywolfEvent. The
migration of the store prices is generated again on top of them
(20261014104431_AddItemStorePriceCurrency, same Up and Down as before),
and so are the snapshot and the compiled models of the EntityDataContext,
AccountContext and TradeContext.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE
- Rename PlayerShopCurrenciesFeaturePlugIn.GetConfiguration to
  GetActiveConfiguration, which can't be confused with its Configuration
  property (S4059).
- Walk back the subset-sum selection with a while loop instead of a for
  loop which changes its counter in the body (S127).
- Leave out the heading of the docs page, which the title of the front
  matter already gives (MD025), and quote the new sidebar entry as the
  linter wants it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7XufeAZ22z7ocXAXUqUsE
@sven-n
sven-n merged commit d46bd84 into master Oct 14, 2026
4 checks passed
@sven-n
sven-n deleted the claude/openmu-1186-shop-currencies-dphx9w branch October 14, 2026 11:31
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.

Feature request: personal shop with per-item prices in jewels and account coins

2 participants