(4.0.0 - 4.21.1) 0.1.2 (4.0.0 - 4.21.1) 0.1.1-beta1
An extensible economy plugin for PocketMine-MP.
Important: To use this plugin, you have to install InfoAPI too.
As a core API for economy, Capital supports different styles of account management:
Other cool features include:
After running the server with Capital the first time, Capital generates config.yml and db.yml, which you can edit to configure Capital.
db.yml is used for configuring the database used by Capital. You can use sqlite or mysql here. The configuration is same as most other plugins.
config.yml is a large config that allows you to change almost everything in Capital.
Read the comments in config.yml for more information.
Text after '# xxx:
are comments.
If you edit config.yml incorrectly,
Capital will try to fix the config.yml and save the old one as config.yml.old
so that you can refer to it if Capital fixed it incorrectly.
All commands in Capital can be configured in config.yml. Try searching them in the config file to find out the correct place. The following commands come from the default config:
Player commands:
/pay <player> <amount> [account...]
:
Pay money to another player with your own account./checkmoney
:
Check your own wealth./richest
:
View the richest players on the server.Admin commands:
addmoney <player> <amount> [account...]
:
Add money to a player's account.takemoney <player> <amount> [account...]
:
Remove money from a player's account./checkmoney <player>
:
Check the wealth of another player./capital-migrate
:
Import database from EconomyAPI.[account...]
can be used to select the account (e.g. currency)
if you change the schema in config.yml.
(You can still disable these arguments by setting up selector
in config.yml)
You can create many other useful commands by editing config.yml,
e.g. check how much money was paid by /pay
command!
Check out the comments in config.yml for more information.
If you want to get help, share your awesome config setup or show off your cool plugin that uses Capital, create a discussion on GitHub.
To report bugs, create an isuse on GitHub.
If you want to help with developing Capital, see the API tab.
Capital supports different account classifications (known as schemas). All operations involving player accounts require a config entry to select which account, e.g. if the user selects the Currency schema, you need a config that selects which currency to use. The good news is, you don't have to consider each schema, because Capital will figure it out. For each use case, just create an empty config entry to select the account:
selector:
Users can fill this selector with options like allowed-currencies
etc.,
depending on the schema they chose.
In onEnable
, store this in a class property:
use SOFe\Capital\{Capital, CapitalException, LabelSet};
class Main extends PluginBase {
private $selector;
protected function onEnable() : void {
Capital::api("0.1.0", function(Capital $api) {
$this->selector = $api->completeConfig($this->getConfig()->get("selector"));
});
}
}
Then you can use the stored selector in the code where you want to manipulate player money.
Let's make a plugin called "ChatFee" which charges the player $5 for every chat message:
public function onChat(PlayerChatEvent $event) : void {
$player = $event->getPlayer();
Capital::api("0.1.0", function(Capital $api) use($player) {
try {
yield from $api->takeMoney(
"ChatFee",
$player,
$this->selector,
5,
new LabelSet(["reason" => "chatting"]),
);
$player->sendMessage("You lost $5 for chatting");
} catch(CapitalException $e) {
$player->kick("You don't have money to chat");
}
});
}
The first "ChatFee" is your plugin name so that server admins can track which plugin gave the money. Capital will create a system account for your plugin, and the money will actually go into the ChatFee system account.
The second parameter $player
tells Capital which player to take money from.
The third parameter $this->selector
tells Capital which account to take money from,
as we have explained in the previous section.
Note: if you have multiple different scenarios of giving/taking money,
consider using different selectors.
The fourth parameter 5
is the amount of money to take.
This value must be an integer.
The last parameter new LabelSet(["reason" => "chatting"])
provides the labels for the transaction.
Server admins can use these labels to perform analytics.
You may want to let users configure these labels in the config too.
The try-catch block lets you handle the scenario where
player does not have enough money to be taken.
However, remember that you cannot cancel $event
after the first yield
,
because transactions are asynchronous, which means that
the event already happened by that time and it is too late to cancel.
Giving money is similar to taking money,
except takeMoney
becomes addMoney
.
Let's make a plugin called "HitReward" that
gives the player money when they attack someone:
public function onDamage(EntityDamageByEntityEvent $event) : void {
$player = $event->getDamager();
if(!$player instanceof Player) {
return;
}
Capital::api("0.1.0", function(Capital $api) use($player) {
try {
yield from $api->addMoney(
"HitReward",
$player,
$this->selector,
5,
new LabelSet(["reason" => "attacking"]),
);
$player->sendMessage("You got $5");
} catch(CapitalException $e) {
$player->sendMessage("You have too much money!");
}
});
}
Paying money is like taking money from one player and giving to another, but it only happens when both players have enough money and don't exceed limits. Neither player will lose or receive money if any limits are violated.
public function pay(Player $player1, Player $player2) : void {
Capital::api("0.1.0", function(Capital $api) use($player1, $player2) {
try {
yield from $api->pay(
$player1,
$player2,
$this->selector,
5,
new LabelSet(["reason" => "payment"]),
);
$player1->sendMessage("You paid $5 to " . $player2->getName());
} catch(CapitalException $e) {
$player1->sendMessage("Failed!");
}
});
}
All arguments are same as before, except you don't need to pass your plugin name because the money came from a player and you don't need a plugin account.
If the payer and receiver sides have different amounts (e.g. service fee),
you have to use payUnequal
instead.
The following code makes $player1
pay $player2
$5,
plus giving $3 service fee to the "ServiceFee" system account:
public function pay(Player $player1, Player $player2) : void {
Capital::api("0.1.0", function(Capital $api) use($player1, $player2) {
try {
yield from $api->payUnequal(
"ServiceFee",
$player1,
$player2,
$this->selector,
5 + 3, // this is the total amount that $player1 has to lose
5, // this is the total amount that $player2 gets
new LabelSet(["reason" => "payment"]),
new LabelSet(["reason" => "service-fee"]), // this label set is applied on the transaction from $player1 to the system account
);
$player1->sendMessage("You paid $5 to " . $player2->getName() . " and paid $3 service fee");
} catch(CapitalException $e) {
$player1->sendMessage("Failed!");
}
});
}
It is also possible for player1 to pay less and player2 to pay more. In that case, player1 only pays the amount to player2, then the system account will pay the rest to player2.
If you want to check whether the player has enough money for something,
use takeMoney
as explained above and handle the error case.
If you just want to display player money, use InfoAPI.
The default config registered the {money}
info on players,
but users can change this based on their config setup.
Consider using InfoAPI to compute the messages
and let the user set their own messages.
See InfoAPI readme for usage guide.
The advanced API is for advanced developers who want to use the specific features in Capital in addition to the basic money manipulation operations.
Capital uses await-generator,
which enables asynchronous programming using generators.
Functions that return Generator<mixed, mixed, mixed, T>
are async functions that return values of type T
.
There is a special phpstan-level type alias VoidPromise
,
which is just shorthand for Generator<mixed, mixed, mixed, void>
.
Generator functions must always be called with yield from
instead of yield
.
Functions that delegate to another generator function MUST always
return yield from delegate_function();
,
even though return delegate_function();
and
return yield delegate_function();
have similar semantics.
This is to ensure consistent behavior where
async functions only start executing when passed to the await-generator runtime.
Capital is module-based.
Every module containing a Mod
class is a module.
Each module has its independent semantic versioning line,
as indicated by the API_VERSION
constant.
The Mod
class is responsible for starting and stopping
components that cannot wait to be initialized only on-demand,
such as commands, event handlers and InfoAPI infos/fallbacks.
Classes that only have one useful instance in the runtime are called "singletons".
To facilitate unit testing in the future,
singletons do not use the traditional getInstance()
style.
Instead, all singletons are managed by the Di
namespace.
If a (singleton or non-singleton) class requires an instance of a singleton class,
it can implement the Di\FromContext
interface and use the Di\SingletonArgs
trait,
then declare all required classes in the constructor, for example:
use SOFe\Capital\Di;
class Foo implements Di\FromContext {
use Di\SingletonArgs;
public function __construct(
private Bar $bar,
private Qux $qux,
)
}
Alternatively, if initialization of the object is async
(e.g. Database\Database
initialization requires waiting for table creation queries),
or the developer does not wish to mix initialization logic in the constructor
(it is a bad practice to do anything
other than field initialization and validation in the constructor),
declare public static function fromSingletonArgs
in a similar style.
fromSingletonArgs
can return either self
or Generator<mixed, mixed, mixed, self>
.
The class can be instantiated with Foo::instantiateFromContext(Di\Context $context)
.
Alternatively, if Foo
itself is a singleton class,
it can additionally implement Di\Singleton
and use the trait Di\SingletonTrait
,
then it can be requested from another FromContext
class with the same style.
All Mod
classes are singleton and use Di\SingletonArgs
.
They are required in the Loader\Loader
(this is not the main class) singleton,
which is explicitly created from the main class onEnable
.
The main class (Plugin\MainClass
) is a singleton,
although it is not initialized lazily like other FromContext classes
(since it is the same instance that started loading everything).
Note that, unlike many other plugins,
the main class does not have any functionality by itself.
It serves only to implement the Plugin
interface
as required by some PocketMine APIs,
and is generally useless except for registering commands and event handlers.
The Di\Context
is also a singleton,
but similar to the main class, it is not initialized lazily.
Other classes can use it to flexibly require new objects that
were not requested in the constructor under special circumstances.
The await-std instance (\SOFe\AwaitStd\AwaitStd
) does not implement Di\Singleton
,
but it is also special-cased to allow singleton-like usage.
The \Logger
interface is not a singleton.
However, FromContext
classes can declare a parameter of type \Logger
,
then the DI framework will create a new logger for the class.
(This logger is derived from the plugin logger,
but is not equal to the plugin logger itself)
Capital implements a self-healing config manager.
Each module has its own Config
class to manage module-specific configuration.
In addition to the singleton and FromContext interfaces,
each Config
class also implements Config\ConfigInterface
and uses Config\ConfigTrait
,
implementing a parse
method that reads values
from a Config\Parser
object into itself.
The first time parse
is called, Config\Parser
is in non-fail-safe mode,
which means methods like expectString
would throw a Config\ConfigException
if the parsed config contains invalid types or data.
Upon catching a Config\ConfigException
,
the config framework calls parse
on all Config
classes again,
this time providing a Config\Parser
in fail-safe mode,
which would no longer throw Config\ConfigException
.
Instead, the parser will add missing fields (along with documentation)
or replace incorrect fields in the config,
which are saved to the config after all module configs have been parsed.
Config
classes can also use the failSafe
method in the Config\Parser
to either return a value or throw a Config\ConfigException
depending on the parser mode.
This strategy allows automatic config refactor when the user changes critical settings
like the schema, which cascades changes to many other parts in the config.
Due to difficulties with cyclic dependencies,
all Config
classes must be separated listed in the Config\Raw::ALL_CONFIG
constant.
Capital uses libasynql for database connection.
Note that the libasynql DataConnector is exposed in the Database API,
whcih means the SQL definition is part of semantic versioning.
All structural changes are considered as backward-incompatible changes.
The Database
class also provides some low-level
(although higher level than raw SQL queries) APIs to access the database.
Other modules should consider using the APIs in the SOFe\Capital\Capital
singleton,
which provides more user-friendly and type-safe APIs than the Database
class.
Raw queries are written in resources/mysql and resources/sqlite. There is a slight diversion in MySQL and SQLite queries due to different requirements; SQLite does not require any synchronization and assumes FIFO query execution. MySQL assumes there may be multiple servers using the database, plus external services (such as web servers) that may modify the data arbitrarily.
The Capital database is gameplay-agnostic. This means the database design is independent of how accounts are created. The database module does not know anything about players or currencies or worlds. Instead, each account is attached with labels (a string-to-string map), which provide information about the account and enable account searching.
Each player may have zero or more accounts,
determined by the schema configured.
Generally speaking, player accounts are identified by the capital/playerUuid
label
(or capital/playerName
if username display is required);
analytics modules can use this label to identify accounts associated to a player.
Capital aims to provide reproducible transactions.
All account balance changes other than initial account creation
should be performed through a transaction.
In the case of balance change as initiated by an operator
or automatic reward provided by certain gameplay (e.g. kill reward),
a system account (known as an "oracle") should be used as the payer/payee.
Oracles do not have player identification labels like capital/playerUuid
,
but they use the capital/oracle
to identify themselves.
Other modules/plugins can also define other account types. As long as they have their own labels that do not collide with existing labels, they are expected to work smoothly along with other components.
Transactions can also have their own labels. At the current state, the exact usage of transaction labels is not confirmed yet. It is expected that transaction labels can be used to analyze economic activity, such as the amount of capital flow in certain industries.
The database can search accounts and transactions matching a "label selector", which is an array of label names to values. Accounts/transactions are matched by a selector if they have all the labels specified in the selector. An empty value in a label selector matches all accounts/transactions with that label name regardless of the value.
A schema abstracts the concept of labels by expressing them in more user-friendly terminology. A schema is responsible for defining the accounts for a player and parsing config into a specific account selector.
There are currently two builtin schema types:
basic
: Each player uses the same account for everything.currency
: Currencies are defined in schema config,
where each player has one account for each currency.There are other planned schema types, which impose speical challenges:
world
: Each player has one account for each world.
This means accounts must be created lazily and dynamically,
because new worlds may be loaded over time.wallet
: Accounts are bound to inventory items instead of players.
Players can spend money in an account
when the item associated with the account is in the player's inventory.
This means the player label is mutable and requires real-time updating.Let's explain how schemas work with a payment command and a currency schema. The default schema is configured as:
schema:
type: currency
currencies:
coins: {...}
gems: {...}
tokens: {...}
The payment command is configured with a section like this:
accounts:
allowed-currencies: [coins, tokens]
This config section is passed to the default schema,
which returns a new Schema
object that
only contains the currency subset [coins, tokens]
.
When a player runs the payment command (e.g. /pay SOFe 100 coins
),
the remaining command arguments (["coins"]
)
are passed to the subset schema,
which decides to parse the first argument as the currency name.
Since we only use the subset schema,
only coins and tokens are accepted.
The subset schema returns a final Schema
object
that knows coins
have been selected.
The sender and recipient are passed to the final Schema
,
which returns a label selector for the sender and recipient accounts.
If no eligible accounts are found,
the plugin tries to migrate the accounts
from imported sources as specified by the schema.
If no migration is eligible,
it creates new accounts based on initial setup specified by the schema.
The Analytics module consists of two parts: Single and Top.
Analytics\Single
computes single-value metrics.
The Analytics\Single\Query
interface abstracts different metric types
parameterized by a generic parameter P
.
Use CachedValue::loop
to spawn a refresh loop that fetches the latest metric value.
If P
is Player
, use PlayerInfoUpdater::registerListener()
to automatically spawn refresh loops for online players.
Analytics\Top
reports server-wide top metrics.
Due to the label-oriented mechanism,
it is not possible to efficiently fetch the top accounts directly
because the SQL database cannot be indexed by a specific label.
To allow efficient top metric queries,
the metric is first computed for each grouping label value
(usually the player UUID) and cached in the capital_analytics_top_cache
table.
A top metric query is defined by the following:
AccountLabels::PLAYER_UUID
.These three values uniquely identify a top query for computation cache.
These values are md5-hashed into the capital_analytics_top_cache.query
column,
which are reused on multiple servers.
The computation takes place in batches, updating a subset of label values each time.
Call Analytics\Top\Mod::runRefreshLoop()
to start a refreshing loop.
Analytics\Top\DatabaseUtils::fetchTopAnalytics
fetches the cached data for display.
For each displayLabels
label, a random label value for matching rows
with the name equal to the displayLabels
label is returned in the output for display.