Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions docs-website/docs/server-features/player-shop-currencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
title: Player shop currencies
sidebar_label: Player shop currencies
sidebar_position: 5
description: Prices of items in personal stores in items like jewels, and in account coins, in addition to zen.
---

In a personal store, the price of an item is usually defined in zen. With the
plugin **Player shop currencies**, players can define the price of each item in
one of these currencies, too:

* items which are configured as price items, by default the Jewels of Bless,
Soul, Chaos, Creation and Life;
* the account coins WCoin (C), WCoin (P) and Goblin Points (see [Cash shop](cash-shop.md)).

The plugin is deactivated by default. Then, the personal stores work exactly as
before: only zen prices are possible, and the server sends nothing new to the
clients.

## Clients

Only the open source client ([MuMain](https://github.com/sven-n/MuMain)) shows
and buys items with prices in other currencies than zen. When the plugin is
active, the server tells it which price items and coins are allowed, so it
offers them when the player sets the prices of the store.

The original Season 6 client can only show zen prices. Items with prices in
other currencies are hidden in its store list, and the server refuses to sell
them to it.

## Settings

The plugin is configured on the [Plugins](../admin-panel/plugins.md) page:

| Setting | Default | Meaning |
|---|---|---|
| Price Items | the five jewels | The items with which prices can be paid. |
| Is WCoin C/WCoin P/Goblin Points Allowed | no | Prices in this account coin are allowed. The coins are usually bought with real money, so trading them has to be allowed explicitly. |
| Maximum Item Amount | 1000 | The maximum number of item pieces of a price; at most 65535. |
| Maximum Coin Amount | 1000000 | The maximum amount of account coins of a price. |

A new database gets the five jewels as price items. On an existing database, the
plugin is created without price items, because its default configuration can't
reference items: add them when you activate the plugin.

When a price item or coin is disallowed while players have prices in it, their
stores can't be opened until the prices are changed, and these items can't be
bought from stores which are already open.

## Paying with items

The buyer pays with the price items of the inventory. A single item counts as
one piece, and the bundles of the jewel mix count as their number of pieces
(10, 20 or 30). Only tradable items without level and options count.

Bundles are never split, and there is no change: the buyer needs a combination
of items and bundles which matches the price exactly. The items move into the
inventory of the seller, which needs a free slot for each of them.

## Paying with account coins

The coins move from the account of the buyer to the account of the seller.
Pending coin grants of the buyer are applied first.

## Chat command

The plugin **Shop price chat command** (deactivated by default) sets prices with
the chat, which works with every client:

* `/shopprice` lists the items of the own store with their prices;
* `/shopprice <position> <amount> [currency]` sets the price of the item at the
position 1 to 32 of the store (or its inventory slot 204 to 235). Without a
currency, the price is in zen. A price of 0 removes the price.

The currency can be `zen`, a price item by its group and number (e.g. `14/13`),
one of the jewels by name (`bless`/`job`, `soul`/`jos`, `chaos`/`joc`,
`creation`/`jocr`, `life`/`jol`) or an account coin (`wcoinc`/`wcc`,
`wcoinp`/`wcp`, `goblin`/`gp`). The store has to be closed to change its prices.
1 change: 1 addition & 0 deletions docs-website/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const sidebars = {
'server-features/test-actors',
'server-features/gens',
'server-features/cash-shop',
"server-features/player-shop-currencies",
],
},
{
Expand Down
13 changes: 13 additions & 0 deletions src/DataModel/Entities/Item.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.DataModel.Entities;

using System.Globalization;
using MUnique.OpenMU.Annotations;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Properties;

Expand Down Expand Up @@ -62,6 +63,18 @@ public partial class Item
/// </summary>
public int? StorePrice { get; set; }

/// <summary>
/// Gets or sets the definition of the items with which the <see cref="StorePrice"/> is paid, e.g. a jewel.
/// It's <c>null</c>, if the price is paid in zen or account coins.
/// </summary>
public virtual ItemDefinition? StorePriceItemDefinition { get; set; }

/// <summary>
/// Gets or sets the account coin type with which the <see cref="StorePrice"/> is paid.
/// It's <c>null</c>, if the price is paid in zen or items.
/// </summary>
public CashShopCoinType? StorePriceCoinType { get; set; }

/// <summary>
/// Gets or sets the pet experience.
/// Only applies, if this item is actually a trainable pet.
Expand Down
123 changes: 91 additions & 32 deletions src/GameLogic/PlayerActions/PlayerStore/BuyRequestAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace MUnique.OpenMU.GameLogic.PlayerActions.PlayerStore;

using MUnique.OpenMU.GameLogic.PlayerShopCurrencies;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.Inventory;
Expand Down Expand Up @@ -46,7 +47,7 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
}

var item = requestedPlayer.ShopStorage.GetItem(slot);
if (item?.StorePrice is null)
if (item?.GetStorePrice() is not { } price)
{
player.Logger.LogDebug("Item unavailable, Slot {0}", slot);
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.NameMismatchOrPriceMissing, null)).ConfigureAwait(false);
Expand All @@ -67,11 +68,27 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
return;
}

var itemPrice = item.StorePrice.Value;
if (price.Kind != ShopPriceKind.Zen && !player.SupportsShopCurrencies())
{
// The client can't show such items, so it should never request them.
player.Logger.LogWarning("Player {0} tried to buy {1} with a price in {2} from the store of {3}, which its client can't show.", player, item, price.Kind, requestedPlayer);
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.ItemBlock, null)).ConfigureAwait(false);
return;
}

if (!ShopPrices.IsAllowed(player.GameContext, price))
{
player.Logger.LogDebug("The price of item {0} isn't allowed.", item);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerShopCurrencyNotAllowed), player.GetCurrencyName(price)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.ItemBlock, null)).ConfigureAwait(false);
return;
}

if (player.Money < itemPrice)
var exchange = ShopPrices.GetExchange(price.Kind);
var fundsResult = exchange.CheckFunds(player, price);
if (fundsResult != ShopPaymentResult.Success)
{
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.LackOfMoney, null)).ConfigureAwait(false);
await this.ShowPaymentFailedAsync(player, requestedPlayer, price, fundsResult).ConfigureAwait(false);
return;
}

Expand Down Expand Up @@ -101,36 +118,50 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
return;
}

player.Logger.LogDebug("BuyRequest, Item Price: {0}", itemPrice);
if (player.TryRemoveMoney(itemPrice))
if (item.GetStorePrice() is not { } currentPrice || currentPrice.Amount != price.Amount || !currentPrice.HasSameCurrency(price))
{
if (requestedPlayer.TryAddMoney(itemPrice))
{
using var itemContext = requestedPlayer.GameContext.PersistenceContextProvider.CreateNewTradeContext();
itemContext.Attach(item);
await requestedPlayer.ShopStorage.RemoveItemAsync(item).ConfigureAwait(false);
await requestedPlayer.InvokeViewPlugInAsync<IUpdateMoneyPlugIn>(p => p.UpdateMoneyAsync()).ConfigureAwait(false);
await requestedPlayer.InvokeViewPlugInAsync<IItemSoldByPlayerShopPlugIn>(p => p.ItemSoldByPlayerShopAsync(slot, player)).ConfigureAwait(false);
await requestedPlayer.InvokeViewPlugInAsync<IItemRemovedPlugIn>(p => p.RemoveItemAsync(slot)).ConfigureAwait(false);
item.ItemSlot = (byte)freeslot;
item.StorePrice = null;
await player.Inventory!.AddItemAsync(item).ConfigureAwait(false);
requestedPlayer.PersistenceContext.Detach(item);
await itemContext.SaveChangesAsync().ConfigureAwait(false);
player.PersistenceContext.Attach(item);
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.Success, item)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IUpdateMoneyPlugIn>(p => p.UpdateMoneyAsync()).ConfigureAwait(false);
itemSold = true;

player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToOtherPlayerPlugIn>()?.ItemSold(requestedPlayer, item, player);
}
else
{
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.MoneyOverflowOrNotEnoughSpace, null)).ConfigureAwait(false);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.SellerInventoryFull)).ConfigureAwait(false);
player.TryAddMoney(itemPrice);
}
// The seller can't change prices while the store is open, but the slot may contain another item by now.
player.Logger.LogDebug("The price of the item changed, Slot {0}", slot);
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.NameMismatchOrPriceMissing, null)).ConfigureAwait(false);
return;
}

player.Logger.LogDebug("BuyRequest, Item Price: {0} {1}", price.Amount, price.PriceItem?.ToString() ?? price.CoinType?.ToString() ?? "zen");
using var itemContext = requestedPlayer.GameContext.PersistenceContextProvider.CreateNewTradeContext();
var purchase = new ShopPurchase(player, requestedPlayer, price, itemContext);
var paymentResult = await exchange.TryPayAsync(purchase).ConfigureAwait(false);
if (paymentResult != ShopPaymentResult.Success)
{
await this.ShowPaymentFailedAsync(player, requestedPlayer, price, paymentResult).ConfigureAwait(false);
return;
}

itemContext.Attach(item);
await requestedPlayer.ShopStorage.RemoveItemAsync(item).ConfigureAwait(false);
await exchange.NotifySellerAsync(purchase).ConfigureAwait(false);
await requestedPlayer.InvokeViewPlugInAsync<IItemSoldByPlayerShopPlugIn>(p => p.ItemSoldByPlayerShopAsync(slot, player)).ConfigureAwait(false);
await requestedPlayer.InvokeViewPlugInAsync<IItemRemovedPlugIn>(p => p.RemoveItemAsync(slot)).ConfigureAwait(false);
item.ItemSlot = (byte)freeslot;
item.SetStorePrice(null);
await player.Inventory!.AddItemAsync(item).ConfigureAwait(false);
requestedPlayer.PersistenceContext.Detach(item);
foreach (var (paymentItem, _) in purchase.PaymentItems)
{
player.PersistenceContext.Detach(paymentItem);
}

await itemContext.SaveChangesAsync().ConfigureAwait(false);
player.PersistenceContext.Attach(item);
foreach (var (paymentItem, _) in purchase.PaymentItems)
{
requestedPlayer.PersistenceContext.Attach(paymentItem);
}

await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(requestedPlayer, ItemBuyResult.Success, item)).ConfigureAwait(false);
await exchange.NotifyBuyerAsync(purchase).ConfigureAwait(false);
itemSold = true;

player.GameContext.PlugInManager.GetPlugInPoint<IItemSoldToOtherPlayerPlugIn>()?.ItemSold(requestedPlayer, item, player);
}

if (itemSold)
Expand All @@ -146,4 +177,32 @@ public async ValueTask BuyItemAsync(Player player, Player requestedPlayer, byte
}
}
}

private async ValueTask ShowPaymentFailedAsync(Player player, Player seller, ShopPrice price, ShopPaymentResult paymentResult)
{
var isZen = price.Kind == ShopPriceKind.Zen;
switch (paymentResult)
{
case ShopPaymentResult.LackOfCurrency when isZen:
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(seller, ItemBuyResult.LackOfMoney, null)).ConfigureAwait(false);
break;
case ShopPaymentResult.LackOfCurrency:
// The client shows a message about zen for the result, so we tell the actual price.
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(seller, ItemBuyResult.LackOfPriceCurrency, null)).ConfigureAwait(false);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerShopNotEnoughCurrency), price.Amount, player.GetCurrencyName(price)).ConfigureAwait(false);
break;
case ShopPaymentResult.ExactAmountNotPayable:
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(seller, ItemBuyResult.LackOfPriceCurrency, null)).ConfigureAwait(false);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerShopItemPriceNotPayableExactly), price.Amount, player.GetCurrencyName(price)).ConfigureAwait(false);
break;
case ShopPaymentResult.SellerOverflow:
case ShopPaymentResult.SellerInventoryFull:
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(seller, ItemBuyResult.MoneyOverflowOrNotEnoughSpace, null)).ConfigureAwait(false);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.SellerInventoryFull)).ConfigureAwait(false);
break;
default:
await player.InvokeViewPlugInAsync<IPlayerShopBuyRequestResultPlugIn>(p => p.ShowResultAsync(seller, ItemBuyResult.Undefined, null)).ConfigureAwait(false);
break;
}
}
}
11 changes: 10 additions & 1 deletion src/GameLogic/PlayerActions/PlayerStore/OpenStoreAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace MUnique.OpenMU.GameLogic.PlayerActions.PlayerStore;

using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.PlayerShopCurrencies;
using MUnique.OpenMU.GameLogic.Views.PlayerShop;

/// <summary>
Expand All @@ -27,7 +28,7 @@ public async ValueTask OpenStoreAsync(Player player, string storeName)
return;
}

if (player.ShopStorage?.Items.Any(i => !i.StorePrice.HasValue) ?? true)
if (player.ShopStorage?.Items.Any(i => i.GetStorePrice() is null) ?? true)
{
player.Logger.LogWarning("OpenStore request failed: Not all store items have a price assigned. Player: [{0}], StoreName: [{1}]", character.Name, character.StoreName);
return;
Expand All @@ -39,6 +40,14 @@ public async ValueTask OpenStoreAsync(Player player, string storeName)
return;
}

if (player.ShopStorage.Items.FirstOrDefault(i => i.GetStorePrice() is { } price && !ShopPrices.IsAllowed(player.GameContext, price)) is { } itemWithDisallowedPrice)
{
var price = itemWithDisallowedPrice.GetStorePrice()!.Value;
player.Logger.LogWarning("OpenStore request failed: The price of item {0} isn't allowed. Player: [{1}], StoreName: [{2}]", itemWithDisallowedPrice, character.Name, character.StoreName);
await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.PlayerShopCurrencyNotAllowed), player.GetCurrencyName(price)).ConfigureAwait(false);
return;
}

character.StoreName = storeName;
player.ShopStorage.StoreOpen = true;
player.Logger.LogDebug("OpenStore: Player: [{0}], StoreName: [{1}]", character.Name, character.StoreName);
Expand Down
Loading
Loading