feat(economy): Enhance economy system with service charges and medical billing
- Expanded README.md to detail economy addon functionalities including refueling, medical services, and service charges. - Updated XEH_PREP.hpp to include initSEconomyStore preparation. - Modified XEH_postInit.sqf to ensure MEconomyStore initializes only if not nil. - Adjusted XEH_preInit.sqf to initialize SEconomyStore correctly. - Updated config.cpp to include forge_server_common as a required addon. - Enhanced fnc_initFEconomyStore.sqf to manage fuel refueling sessions and organization charges. - Improved fnc_initMEconomyStore.sqf to handle medical billing and fallback to organization funds. - Created fnc_initSEconomyStore.sqf for organization-funded service charges and repairs. - Updated org.rs and org.rs service layer to support member debt recording and organization fund charging. - Added ECONOMY_USAGE_GUIDE.md for comprehensive documentation on economy functionalities. - Updated MODULE_REFERENCE.md and README.md to include links to the new economy guide.
This commit is contained in:
parent
89169f1e84
commit
8117e6ffa6
@ -2,30 +2,80 @@
|
||||
|
||||
## Overview
|
||||
The economy addon contains server-side systems for world economic interactions
|
||||
that are still implemented in SQF.
|
||||
that are still implemented in SQF. It owns Arma-world behavior such as active
|
||||
refueling sessions, medical spawn occupancy, respawn placement, and death
|
||||
inventory handling.
|
||||
|
||||
Current stores cover fuel tracking, medical service behavior, and a placeholder
|
||||
service economy store.
|
||||
Current stores cover fuel tracking, medical service behavior, and service
|
||||
charges such as repairs.
|
||||
|
||||
## Dependencies
|
||||
- `forge_server_main`
|
||||
- `forge_server_common` at runtime for logging, formatting, and player lookup
|
||||
- `forge_server_bank` at runtime for medical service charges
|
||||
- `forge_server_common` for logging, formatting, and player lookup
|
||||
- `forge_server_bank` for player-funded medical billing
|
||||
- `forge_server_org` for extension-backed organization hot-cache charges
|
||||
- `forge_client_actor` and `forge_client_notifications` for response RPCs
|
||||
|
||||
## Main Components
|
||||
- `fnc_initFEconomyStore.sqf` tracks active refueling sessions and reports fuel
|
||||
totals.
|
||||
- `fnc_initFEconomyStore.sqf` tracks active refueling sessions, calculates fuel
|
||||
totals, charges the player's organization through `OrgStore`, syncs the org
|
||||
patch, and rolls fuel back to the starting level when organization funds
|
||||
cannot cover the refuel.
|
||||
- `fnc_initMEconomyStore.sqf` manages medical spawn occupancy, healing charges,
|
||||
respawn placement, death inventory handling, and body-bag transfer.
|
||||
- `fnc_initSEconomyStore.sqf` initializes the service economy placeholder.
|
||||
respawn placement, death inventory handling, and body-bag transfer. Medical
|
||||
charges use player bank/cash first, then organization funds with repayable
|
||||
member debt only when the player cannot cover the service.
|
||||
- `fnc_initSEconomyStore.sqf` handles organization-funded service charges and
|
||||
repairs. Repairs only apply after the organization charge succeeds. The
|
||||
shared org-charge helper can also record member debt for medical fallback.
|
||||
|
||||
## Event Surface
|
||||
The addon registers CBA server events for fuel start/tick/stop, player killed,
|
||||
player respawn, and healing. Medical store initialization runs after post-init
|
||||
to discover configured medical spawn objects.
|
||||
The addon registers CBA server events for fuel start/tick/stop, repair service,
|
||||
player killed, player respawn, and healing. Medical store initialization runs
|
||||
after post-init to discover configured medical spawn objects.
|
||||
|
||||
Repair service requests use:
|
||||
|
||||
```sqf
|
||||
[QEGVAR(economy,RepairService), [_target, _unit, _cost]] call CBA_fnc_serverEvent;
|
||||
```
|
||||
|
||||
`_cost` is optional. Passing `-1` uses the configured service repair cost.
|
||||
|
||||
## Billing Rules
|
||||
Economy does not own durable money state. It coordinates Arma-world effects
|
||||
after the relevant hot-cache charge succeeds.
|
||||
|
||||
Fuel and repair services are organization-funded:
|
||||
|
||||
1. Resolve the player's organization from actor state.
|
||||
2. Ensure the player is a member of that organization hot record.
|
||||
3. Call `OrgStore chargeCheckout` with `source = "org_funds"`,
|
||||
`commit = true`, and member service charging enabled.
|
||||
4. Send the returned organization patch to online members.
|
||||
5. If the charge fails, do not complete the service. Refueling rolls the target
|
||||
back to its starting fuel level; repairs are not applied.
|
||||
|
||||
Medical services are player-funded first:
|
||||
|
||||
1. Load the player's bank hot state.
|
||||
2. Charge the player's bank balance when it can cover the medical bill.
|
||||
3. Otherwise charge the player's cash when it can cover the bill.
|
||||
4. If neither personal balance can cover the bill, charge organization funds
|
||||
and record the same amount as a debt on the player's organization credit
|
||||
line.
|
||||
5. If personal billing is unavailable, or both personal and organization funds
|
||||
fail, do not complete the heal.
|
||||
|
||||
The organization fallback reduces org funds immediately and adds the medical
|
||||
cost to the player's credit-line balance due. Repayment uses the normal bank
|
||||
credit-line repayment flow, which moves player bank funds back into the
|
||||
organization treasury.
|
||||
|
||||
This keeps money mutation rules in the extension-backed organization service
|
||||
and bank service while leaving world interactions in SQF.
|
||||
|
||||
## Notes
|
||||
The service economy store is currently a stub. Fuel and medical behavior should
|
||||
stay server-authoritative because they mutate money, inventory, and respawn
|
||||
state.
|
||||
Fuel, medical, and service world behavior should stay server-authoritative
|
||||
because it mutates inventory, vehicles, and respawn state. Money mutations
|
||||
should continue to use extension-backed bank and organization hot state.
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
PREP(initFEconomyStore);
|
||||
PREP(initMEconomyStore);
|
||||
PREP(initSEconomyStore);
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
#include "script_component.hpp"
|
||||
|
||||
if !(isNil QGVAR(MEconomyStore)) then {
|
||||
GVAR(MEconomyStore) call ["init", []];
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ PREP_RECOMPILE_END;
|
||||
|
||||
if (isNil QGVAR(MEconomyStore)) then { call FUNC(initMEconomyStore); };
|
||||
if (isNil QGVAR(FEconomyStore)) then { call FUNC(initFEconomyStore); };
|
||||
// if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); };
|
||||
if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); };
|
||||
|
||||
[QGVAR(FuelStart), {
|
||||
params ["_source", "_target", "_unit"];
|
||||
@ -28,6 +28,11 @@ if (isNil QGVAR(FEconomyStore)) then { call FUNC(initFEconomyStore); };
|
||||
GVAR(FEconomyStore) call ["stop", [_source, _target]];
|
||||
}] call CFUNC(addEventHandler);
|
||||
|
||||
[QGVAR(RepairService), {
|
||||
params ["_target", "_unit", ["_cost", -1, [0]]];
|
||||
GVAR(SEconomyStore) call ["repair", [_target, _unit, _cost]];
|
||||
}] call CFUNC(addEventHandler);
|
||||
|
||||
[QGVAR(onKilled), {
|
||||
params ["_unit"];
|
||||
GVAR(MEconomyStore) call ["onKilled", [_unit]];
|
||||
|
||||
@ -8,7 +8,8 @@ class CfgPatches {
|
||||
name = COMPONENT_NAME;
|
||||
requiredVersion = REQUIRED_VERSION;
|
||||
requiredAddons[] = {
|
||||
"forge_server_main"
|
||||
"forge_server_main",
|
||||
"forge_server_common"
|
||||
};
|
||||
units[] = {};
|
||||
weapons[] = {};
|
||||
|
||||
@ -8,16 +8,18 @@
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* No description added yet.
|
||||
* Initializes the fuel economy store. Active refueling sessions remain
|
||||
* server-local; payment is routed through the organization extension hot
|
||||
* cache.
|
||||
*
|
||||
* Parameter(s):
|
||||
* N/A
|
||||
*
|
||||
* Returns:
|
||||
* Something [BOOL]
|
||||
* Fuel economy store object [HASHMAP OBJECT]
|
||||
*
|
||||
* Example(s):
|
||||
* [parameter] call forge_x_component_fnc_myFunction
|
||||
* call forge_server_economy_fnc_initFEconomyStore
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
@ -36,23 +38,66 @@ GVAR(FEconomyStore) = createHashMapObject [[
|
||||
private _uid = getPlayerUID _unit;
|
||||
private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap];
|
||||
|
||||
_fuelRegistry set [_index, _uid];
|
||||
_fuelRegistry set [_index, createHashMapFromArray [
|
||||
["uid", _uid],
|
||||
["initialFuel", fuel _target]
|
||||
]];
|
||||
SETVAR(_target,liters,0);
|
||||
}],
|
||||
["rollbackFuel", {
|
||||
params [["_target", objNull, [objNull]], ["_initialFuel", 0, [0]]];
|
||||
|
||||
if (isNull _target) exitWith { false };
|
||||
|
||||
_target setFuel (_initialFuel max 0 min 1);
|
||||
SETVAR(_target,liters,0);
|
||||
true
|
||||
}],
|
||||
["stop", {
|
||||
params ["_source", "_target"];
|
||||
|
||||
private _index = netId _target;
|
||||
private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap];
|
||||
private _uid = _fuelRegistry get _index;
|
||||
private _session = _fuelRegistry getOrDefault [_index, createHashMap];
|
||||
if (_session isEqualType "") then {
|
||||
_session = createHashMapFromArray [["uid", _session], ["initialFuel", fuel _target]];
|
||||
};
|
||||
|
||||
private _uid = _session getOrDefault ["uid", ""];
|
||||
private _initialFuel = _session getOrDefault ["initialFuel", fuel _target];
|
||||
private _player = [_uid] call EFUNC(common,getPlayer);
|
||||
|
||||
private _totalLiters = GETVAR(_target,liters,0);
|
||||
private _totalCost = _totalLiters * 5;
|
||||
private _totalCost = _totalLiters * GVAR(FuelCost);
|
||||
private _formattedTotalCost = [_totalCost] call EFUNC(common,formatNumber);
|
||||
private _formattedTotalLiters = _totalLiters toFixed 2;
|
||||
|
||||
[CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L<br />Total Cost: $%2", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent);
|
||||
if (isNull _player || { _uid isEqualTo "" }) exitWith {
|
||||
["WARNING", format ["Unable to resolve refueling player for vehicle %1.", _index], nil, nil] call EFUNC(common,log);
|
||||
_self call ["rollbackFuel", [_target, _initialFuel]];
|
||||
_fuelRegistry deleteAt _index;
|
||||
};
|
||||
|
||||
if (_totalCost <= 0) exitWith {
|
||||
[CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L", _formattedTotalLiters]], _player] call CFUNC(targetEvent);
|
||||
_fuelRegistry deleteAt _index;
|
||||
};
|
||||
|
||||
if (isNil QGVAR(SEconomyStore)) exitWith {
|
||||
["ERROR", "Service economy store unavailable for refueling charge.", nil, nil] call EFUNC(common,log);
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Refueling", "Organization billing is unavailable. Refueling was not completed."], _player] call CFUNC(targetEvent);
|
||||
_self call ["rollbackFuel", [_target, _initialFuel]];
|
||||
_fuelRegistry deleteAt _index;
|
||||
};
|
||||
|
||||
private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_player, _totalCost, "Refueling"]];
|
||||
if !(_chargeResult getOrDefault ["success", false]) exitWith {
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Refueling", _chargeResult getOrDefault ["message", "Organization funds cannot cover this refuel. Refueling was not completed."]], _player] call CFUNC(targetEvent);
|
||||
_self call ["rollbackFuel", [_target, _initialFuel]];
|
||||
_fuelRegistry deleteAt _index;
|
||||
};
|
||||
|
||||
[CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L<br />Organization charged $%2.", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent);
|
||||
_fuelRegistry deleteAt _index;
|
||||
}]
|
||||
]];
|
||||
|
||||
@ -4,20 +4,23 @@
|
||||
* File: fnc_initMEconomyStore.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2025-12-20
|
||||
* Last Update: 2026-02-13
|
||||
* Last Update: 2026-04-18
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* No description added yet.
|
||||
* Initializes the medical economy store. Respawn, body-bag, and spawn
|
||||
* occupancy behavior remains server-local, while money mutations are
|
||||
* routed through player bank hot state first, then organization hot state
|
||||
* with a repayable member debt when personal funds cannot cover the bill.
|
||||
*
|
||||
* Parameter(s):
|
||||
* N/A
|
||||
*
|
||||
* Returns:
|
||||
* Something [BOOL]
|
||||
* Medical economy store object [HASHMAP OBJECT]
|
||||
*
|
||||
* Example(s):
|
||||
* [parameter] call forge_x_component_fnc_myFunction
|
||||
* call forge_server_economy_fnc_initMEconomyStore
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
@ -63,37 +66,105 @@ GVAR(MEconomyStore) = createHashMapObject [[
|
||||
} forEach _mSpawns;
|
||||
};
|
||||
}],
|
||||
["chargePlayer", {
|
||||
params [["_uid", "", [""]], ["_amount", 0, [0]]];
|
||||
|
||||
private _result = createHashMapFromArray [
|
||||
["success", false],
|
||||
["fallbackEligible", false],
|
||||
["source", ""],
|
||||
["message", "Unable to charge personal funds."]
|
||||
];
|
||||
|
||||
if (_uid isEqualTo "") exitWith {
|
||||
_result set ["message", "A valid player UID is required for medical billing."];
|
||||
_result
|
||||
};
|
||||
if (_amount <= 0) exitWith {
|
||||
_result set ["success", true];
|
||||
_result
|
||||
};
|
||||
if (isNil QEGVAR(bank,BankStore)) exitWith {
|
||||
_result set ["message", "Personal billing is unavailable. Medical service cannot complete."];
|
||||
_result
|
||||
};
|
||||
|
||||
private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]];
|
||||
if (_account isEqualTo createHashMap) exitWith {
|
||||
_result set ["message", "Personal account could not be loaded for medical billing."];
|
||||
_result
|
||||
};
|
||||
|
||||
private _source = "";
|
||||
if ((_account getOrDefault ["bank", 0]) >= _amount) then {
|
||||
_source = "bank";
|
||||
} else {
|
||||
if ((_account getOrDefault ["cash", 0]) >= _amount) then {
|
||||
_source = "cash";
|
||||
};
|
||||
};
|
||||
|
||||
if (_source isEqualTo "") exitWith {
|
||||
_result set ["fallbackEligible", true];
|
||||
_result set ["message", "Personal bank and cash balances cannot cover this medical service."];
|
||||
_result
|
||||
};
|
||||
|
||||
private _charge = EGVAR(bank,BankStore) call ["chargeCheckout", [_uid, _source, _amount, true]];
|
||||
if !(_charge getOrDefault ["success", false]) exitWith {
|
||||
_result set ["message", _charge getOrDefault ["message", "Personal funds could not be charged for medical service."]];
|
||||
_result
|
||||
};
|
||||
|
||||
private _patch = _charge getOrDefault ["patch", createHashMap];
|
||||
if (_patch isNotEqualTo createHashMap && { !(isNil QEGVAR(bank,BankMessenger)) }) then {
|
||||
EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]];
|
||||
};
|
||||
|
||||
private _savedAccount = EGVAR(bank,BankStore) call ["save", [_uid]];
|
||||
if (_savedAccount isEqualTo createHashMap) then {
|
||||
["ERROR", format ["Medical charge for %1 succeeded in hot bank state, but durable bank save failed.", _uid]] call EFUNC(common,log);
|
||||
};
|
||||
|
||||
_result set ["success", true];
|
||||
_result set ["source", _source];
|
||||
_result set ["message", ""];
|
||||
_result
|
||||
}],
|
||||
["onHealed", {
|
||||
params [["_unit", objNull, [objNull]]];
|
||||
|
||||
if (isNull _unit) exitWith { ["WARNING", format ["Invalid unit provided: %1", (name _unit)], nil, nil] call EFUNC(common,log); };
|
||||
|
||||
private _uid = getPlayerUID _unit;
|
||||
private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]];
|
||||
if (_account isEqualTo createHashMap) then {
|
||||
_account = EGVAR(bank,BankStore) call ["init", [_uid]];
|
||||
};
|
||||
|
||||
if (_account isEqualTo createHashMap) exitWith { ["ERROR", format ["No account found for %1. UID: %2", (name _unit), _uid], nil, nil] call EFUNC(common,log); };
|
||||
|
||||
private _bank = _account get "bank";
|
||||
private _cash = _account get "cash";
|
||||
if (_uid isEqualTo "") exitWith { ["WARNING", "Unable to charge medical service for unit without UID.", nil, nil] call EFUNC(common,log); };
|
||||
|
||||
private _healCost = 100;
|
||||
private _newBalance = 0;
|
||||
|
||||
if (_bank < _healCost && _cash < _healCost) exitWith {
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Insufficient Funds", format ["Insufficient funds for %1. Bank: $%2, Cash: $%3, Required: $%4", (name _unit), [_bank] call EFUNC(common,formatNumber), [_cash] call EFUNC(common,formatNumber), [_healCost] call EFUNC(common,formatNumber)]], _unit] call CFUNC(targetEvent);
|
||||
private _personalCharge = _self call ["chargePlayer", [_uid, _healCost]];
|
||||
if (_personalCharge getOrDefault ["success", false]) exitWith {
|
||||
private _sourceLabel = ["cash", "bank"] select ((_personalCharge getOrDefault ["source", "bank"]) isEqualTo "bank");
|
||||
[CRPC(notifications,recieveNotification), ["info", "Medical Billing", format ["Medical service charged $%1 from your %2.", [_healCost] call EFUNC(common,formatNumber), _sourceLabel]], _unit] call CFUNC(targetEvent);
|
||||
[CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent);
|
||||
};
|
||||
|
||||
if (_bank >= _healCost) then {
|
||||
_newBalance = _bank - _healCost;
|
||||
_account set ["bank", _newBalance];
|
||||
} else {
|
||||
_newBalance = _cash - _healCost;
|
||||
_account set ["cash", _newBalance];
|
||||
if !(_personalCharge getOrDefault ["fallbackEligible", false]) exitWith {
|
||||
private _message = _personalCharge getOrDefault ["message", "Personal funds could not be charged for medical service."];
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Medical Billing", _message], _unit] call CFUNC(targetEvent);
|
||||
};
|
||||
|
||||
if (isNil QGVAR(SEconomyStore)) exitWith {
|
||||
["ERROR", "Service economy store unavailable for medical organization fallback charge.", nil, nil] call EFUNC(common,log);
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Medical Billing", "Organization billing is unavailable. Medical service cannot complete."], _unit] call CFUNC(targetEvent);
|
||||
};
|
||||
|
||||
private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _healCost, "Medical", true]];
|
||||
if !(_chargeResult getOrDefault ["success", false]) exitWith {
|
||||
private _message = _chargeResult getOrDefault ["message", "Organization funds cannot cover this medical service."];
|
||||
[CRPC(notifications,recieveNotification), ["danger", "Medical Billing", _message], _unit] call CFUNC(targetEvent);
|
||||
};
|
||||
|
||||
[CRPC(notifications,recieveNotification), ["info", "Medical Billing", format ["Personal funds could not cover medical service. Organization charged $%1; repay it through your organization credit line.", [_healCost] call EFUNC(common,formatNumber)]], _unit] call CFUNC(targetEvent);
|
||||
[CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent);
|
||||
}],
|
||||
["onRespawn", {
|
||||
|
||||
@ -1,31 +1,136 @@
|
||||
#include "..\script_component.hpp"
|
||||
|
||||
/*
|
||||
* File: initSEconomyStore.sqf
|
||||
* File: fnc_initSEconomyStore.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2025-12-20
|
||||
* Last Update: 2026-01-03
|
||||
* Last Update: 2026-04-18
|
||||
* Public: No
|
||||
*
|
||||
* Description:
|
||||
* No description added yet.
|
||||
* Initializes the service economy store for organization-funded world
|
||||
* services such as repairs, with optional member debt recording for
|
||||
* organization-covered medical fallback charges.
|
||||
*
|
||||
* Parameter(s):
|
||||
* N/A
|
||||
*
|
||||
* Returns:
|
||||
* Something [BOOL]
|
||||
* Service economy store object [HASHMAP OBJECT]
|
||||
*
|
||||
* Example(s):
|
||||
* [parameter] call forge_x_component_fnc_myFunction
|
||||
* call forge_server_economy_fnc_initSEconomyStore
|
||||
*/
|
||||
|
||||
#pragma hemtt ignore_variables ["_self"]
|
||||
GVAR(SEconomyStore) = createHashMapObject [[
|
||||
["#type", "IServiceEconomy"],
|
||||
["#create", {
|
||||
GVAR(ServiceRepairCost) = 500;
|
||||
["INFO", "Service Store Initialized!", nil, nil] call EFUNC(common,log);
|
||||
}],
|
||||
["notify", {
|
||||
params [["_unit", objNull, [objNull]], ["_type", "info", [""]], ["_title", "Service", [""]], ["_message", "", [""]]];
|
||||
|
||||
if (isNull _unit || { _message isEqualTo "" }) exitWith { false };
|
||||
|
||||
[CRPC(notifications,recieveNotification), [_type, _title, _message], _unit] call CFUNC(targetEvent);
|
||||
true
|
||||
}],
|
||||
["syncOrgPatch", {
|
||||
params [["_result", createHashMap, [createHashMap]]];
|
||||
|
||||
private _patch = _result getOrDefault ["patch", createHashMap];
|
||||
if ((keys _patch) isEqualTo []) exitWith { false };
|
||||
|
||||
{
|
||||
private _memberPlayer = [_x] call EFUNC(common,getPlayer);
|
||||
if (_memberPlayer isNotEqualTo objNull) then {
|
||||
[CRPC(org,responseSyncOrg), [_patch], _memberPlayer] call CFUNC(targetEvent);
|
||||
};
|
||||
} forEach (_result getOrDefault ["memberUids", []]);
|
||||
|
||||
true
|
||||
}],
|
||||
["chargeOrg", {
|
||||
params [
|
||||
["_unit", objNull, [objNull]],
|
||||
["_amount", 0, [0]],
|
||||
["_label", "Service", [""]],
|
||||
["_recordDebt", false, [false]]
|
||||
];
|
||||
|
||||
private _result = createHashMapFromArray [
|
||||
["success", false],
|
||||
["message", "Unable to charge organization funds."],
|
||||
["patch", createHashMap],
|
||||
["memberUids", []],
|
||||
["persisted", false],
|
||||
["persistenceMessage", ""]
|
||||
];
|
||||
|
||||
if (isNull _unit) exitWith {
|
||||
_result set ["message", "A valid player is required for organization billing."];
|
||||
_result
|
||||
};
|
||||
|
||||
private _uid = getPlayerUID _unit;
|
||||
if (_uid isEqualTo "") exitWith {
|
||||
_result set ["message", "A valid player UID is required for organization billing."];
|
||||
_result
|
||||
};
|
||||
|
||||
if (_amount <= 0) exitWith {
|
||||
_result set ["success", true];
|
||||
_result set ["message", ""];
|
||||
_result
|
||||
};
|
||||
|
||||
if (isNil QEGVAR(org,OrgStore)) exitWith {
|
||||
_result set ["message", "Organization service is unavailable."];
|
||||
["ERROR", format ["Org store unavailable for %1 charge.", _label], nil, nil] call EFUNC(common,log);
|
||||
_result
|
||||
};
|
||||
|
||||
private _orgID = EGVAR(org,OrgStore) call ["resolveOrgIdForUid", [_uid]];
|
||||
if (_orgID isEqualTo "") then { _orgID = "default"; };
|
||||
|
||||
private _actor = createHashMap;
|
||||
if !(isNil QEGVAR(actor,ActorStore)) then {
|
||||
_actor = EGVAR(actor,ActorStore) call ["load", [_uid]];
|
||||
};
|
||||
private _memberName = EGVAR(org,OrgStore) call ["resolveActorName", [_uid, _unit, _actor]];
|
||||
private _org = EGVAR(org,OrgStore) call ["ensureMember", [_orgID, _uid, _memberName]];
|
||||
if (_org isEqualTo createHashMap) exitWith {
|
||||
_result set ["message", "Organization membership could not be verified."];
|
||||
_result
|
||||
};
|
||||
|
||||
private _charge = EGVAR(org,OrgStore) call ["chargeCheckout", [_uid, _unit, "org_funds", _amount, true, true, _recordDebt]];
|
||||
if !(_charge getOrDefault ["success", false]) exitWith {
|
||||
_result set ["message", _charge getOrDefault ["message", "Organization funds cannot cover this service."]];
|
||||
_result
|
||||
};
|
||||
|
||||
_self call ["syncOrgPatch", [_charge]];
|
||||
_charge
|
||||
}],
|
||||
["repair", {
|
||||
params [["_target", objNull, [objNull]], ["_unit", objNull, [objNull]], ["_cost", -1, [0]]];
|
||||
|
||||
if (isNull _target || { isNull _unit }) exitWith { false };
|
||||
|
||||
private _repairCost = [_cost, GVAR(ServiceRepairCost)] select (_cost < 0);
|
||||
private _charge = _self call ["chargeOrg", [_unit, _repairCost, "Repair"]];
|
||||
if !(_charge getOrDefault ["success", false]) exitWith {
|
||||
_self call ["notify", [_unit, "danger", "Repair", _charge getOrDefault ["message", "Organization funds cannot cover this repair."]]];
|
||||
false
|
||||
};
|
||||
|
||||
_target setDamage 0;
|
||||
_self call ["notify", [_unit, "info", "Repair", format ["Repair complete. Organization charged $%1.", [_repairCost] call EFUNC(common,formatNumber)]]];
|
||||
true
|
||||
}],
|
||||
["init", {}]
|
||||
]];
|
||||
|
||||
|
||||
@ -4,12 +4,13 @@
|
||||
* File: fnc_initOrgStore.sqf
|
||||
* Author: IDSolutions
|
||||
* Date: 2026-02-13
|
||||
* Last Update: 2026-04-04
|
||||
* Last Update: 2026-04-18
|
||||
* Public: Yes
|
||||
*
|
||||
* Description:
|
||||
* Initializes the org store for managing player organizations.
|
||||
* Org hot state is owned by the extension; SQF acts as the bridge.
|
||||
* Org hot state is owned by the extension; SQF acts as the bridge for
|
||||
* treasury charges, credit lines, and service debt recording.
|
||||
*
|
||||
* Arguments:
|
||||
* None
|
||||
@ -800,7 +801,15 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
|
||||
_result
|
||||
}],
|
||||
["chargeCheckout", compileFinal {
|
||||
params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]];
|
||||
params [
|
||||
["_requesterUid", "", [""]],
|
||||
["_requesterPlayer", objNull, [objNull]],
|
||||
["_source", "org_funds", [""]],
|
||||
["_amount", 0, [0]],
|
||||
["_commit", false, [false]],
|
||||
["_allowMemberCharge", false, [false]],
|
||||
["_recordMemberDebt", false, [false]]
|
||||
];
|
||||
|
||||
private _result = createHashMapFromArray [
|
||||
["success", false],
|
||||
@ -822,6 +831,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
|
||||
["requesterUid", _requesterUid],
|
||||
["orgId", _orgID],
|
||||
["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo],
|
||||
["allowMemberCharge", _allowMemberCharge],
|
||||
["recordMemberDebt", _recordMemberDebt],
|
||||
["source", _source],
|
||||
["amount", _amount],
|
||||
["commit", _commit]
|
||||
|
||||
69
docs/ECONOMY_USAGE_GUIDE.md
Normal file
69
docs/ECONOMY_USAGE_GUIDE.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Economy Usage Guide
|
||||
|
||||
The economy server addon owns Arma-world service behavior for fuel, medical,
|
||||
and repair interactions. It does not own money state. Money mutations go
|
||||
through extension-backed bank and organization hot state before the world
|
||||
effect is applied.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `forge_server_common` for logging, formatting, and player lookup.
|
||||
- `forge_server_bank` for personal medical billing.
|
||||
- `forge_server_org` for organization-funded services and medical fallback
|
||||
debt.
|
||||
- `forge_client_actor` and `forge_client_notifications` for targeted client
|
||||
responses.
|
||||
|
||||
## Fuel
|
||||
|
||||
Fuel is organization-funded.
|
||||
|
||||
When refueling stops, `fnc_initFEconomyStore.sqf` calculates the fuel delta and
|
||||
cost, charges the player's organization through `OrgStore chargeCheckout`, and
|
||||
syncs the organization patch to online members. If organization funds cannot
|
||||
cover the refuel, the vehicle is rolled back to the fuel level it had when the
|
||||
session started.
|
||||
|
||||
## Repair
|
||||
|
||||
Repair is organization-funded.
|
||||
|
||||
Use the repair service event:
|
||||
|
||||
```sqf
|
||||
[QEGVAR(economy,RepairService), [_target, _unit, _cost]] call CBA_fnc_serverEvent;
|
||||
```
|
||||
|
||||
`_cost` is optional. Passing `-1` uses the configured service repair cost.
|
||||
The target is only repaired after the organization charge succeeds.
|
||||
|
||||
## Medical
|
||||
|
||||
Medical is player-funded first.
|
||||
|
||||
When a heal is requested, `fnc_initMEconomyStore.sqf` uses this billing order:
|
||||
|
||||
1. Charge the player's bank balance when it can cover the medical fee.
|
||||
2. Otherwise charge the player's cash when it can cover the fee.
|
||||
3. If neither personal balance can cover the fee, charge organization funds.
|
||||
4. When organization funds cover the fallback charge, record the same amount as
|
||||
debt on the player's organization credit line.
|
||||
|
||||
The heal only completes after one of those charges succeeds. If personal
|
||||
billing is unavailable, the heal does not fall back to organization funds
|
||||
because the server cannot verify that the player is unable to cover the fee.
|
||||
|
||||
## Medical Debt Repayment
|
||||
|
||||
Medical fallback debt uses the existing organization credit-line repayment
|
||||
flow. The organization treasury is reduced when the service is rendered, and
|
||||
the player's credit-line `amount_due` increases by the medical fee. When the
|
||||
player repays through the bank credit-line repayment action, player bank funds
|
||||
are moved back into the organization treasury.
|
||||
|
||||
## Hot-Cache Boundary
|
||||
|
||||
The economy addon should stay server-authoritative for world effects such as
|
||||
vehicle fuel, vehicle repair, healing, respawn placement, and death inventory
|
||||
movement. Bank and organization balances should continue to mutate through the
|
||||
extension-backed hot-cache services.
|
||||
@ -37,6 +37,7 @@ Server and extension guides:
|
||||
[Actor](./ACTOR_USAGE_GUIDE.md),
|
||||
[Bank](./BANK_USAGE_GUIDE.md),
|
||||
[CAD](./CAD_USAGE_GUIDE.md),
|
||||
[Economy](./ECONOMY_USAGE_GUIDE.md),
|
||||
[Garage](./GARAGE_USAGE_GUIDE.md),
|
||||
[Locker](./LOCKER_USAGE_GUIDE.md),
|
||||
[Organization](./ORG_USAGE_GUIDE.md),
|
||||
@ -66,7 +67,7 @@ Client guides:
|
||||
| `common` | Shared SQF helpers, base stores, utility functions, and shared UI bridge pieces. | `arma/client/addons/common`, `arma/server/addons/common` |
|
||||
| `extension` | Server SQF bridge around `forge_server` extension calls and chunked transport. | `arma/server/addons/extension` |
|
||||
| `main` | Mod-level configuration, pre-init wiring, and server/client startup glue. | `arma/client/addons/main`, `arma/server/addons/main` |
|
||||
| `economy` | Server-side economy store initialization and economy-specific state helpers. | `arma/server/addons/economy` |
|
||||
| `economy` | Server-side fuel, medical, and service economy helpers. Fuel and repair charge organization hot state; medical charges player bank/cash first, then organization funds with repayable member debt when personal funds cannot cover the bill. | `arma/server/addons/economy` |
|
||||
| `notifications` | Client notification UI, sounds, and UI event handling. | `arma/client/addons/notifications` |
|
||||
| `icom` | Rust helper for interprocess communication and event broadcasting. | `bin/icom`, `arma/server/extension/src/icom.rs` |
|
||||
| `terrain` | Extension-side terrain export helper. | `arma/server/extension/src/terrain.rs` |
|
||||
|
||||
@ -19,6 +19,7 @@ collects framework-level documentation for those pieces.
|
||||
- [Actor Usage Guide](./ACTOR_USAGE_GUIDE.md)
|
||||
- [Bank Usage Guide](./BANK_USAGE_GUIDE.md)
|
||||
- [CAD Usage Guide](./CAD_USAGE_GUIDE.md)
|
||||
- [Economy Usage Guide](./ECONOMY_USAGE_GUIDE.md)
|
||||
- [Garage Usage Guide](./GARAGE_USAGE_GUIDE.md)
|
||||
- [Locker Usage Guide](./LOCKER_USAGE_GUIDE.md)
|
||||
- [Organization Usage Guide](./ORG_USAGE_GUIDE.md)
|
||||
|
||||
@ -180,6 +180,10 @@ pub struct OrgCheckoutContext {
|
||||
pub requester_uid: String,
|
||||
pub org_id: String,
|
||||
pub requester_is_default_org_ceo: bool,
|
||||
#[serde(default)]
|
||||
pub allow_member_charge: bool,
|
||||
#[serde(default)]
|
||||
pub record_member_debt: bool,
|
||||
pub source: String,
|
||||
pub amount: f64,
|
||||
pub commit: bool,
|
||||
|
||||
@ -793,24 +793,69 @@ impl<R: OrgRepository, H: OrgHotRepository> OrgHotStateService<R, H> {
|
||||
|
||||
match context.source.trim().to_ascii_lowercase().as_str() {
|
||||
"org_funds" => {
|
||||
if !can_manage_treasury(
|
||||
let charged_amount = round_currency(context.amount);
|
||||
let can_charge_org_funds = can_manage_treasury(
|
||||
&org,
|
||||
&context.requester_uid,
|
||||
context.requester_is_default_org_ceo,
|
||||
) {
|
||||
) || (context.allow_member_charge
|
||||
&& org.members.contains_key(&context.requester_uid));
|
||||
|
||||
if !can_charge_org_funds {
|
||||
return Err(
|
||||
"Only the organization leader or CEO can charge org funds.".to_string()
|
||||
);
|
||||
}
|
||||
if org.funds < context.amount {
|
||||
if org.funds < charged_amount {
|
||||
return Err("Organization funds cannot cover this checkout.".to_string());
|
||||
}
|
||||
|
||||
org.funds -= context.amount;
|
||||
org.funds = round_currency(org.funds - charged_amount);
|
||||
if context.record_member_debt {
|
||||
let member_name = org
|
||||
.members
|
||||
.get(&context.requester_uid)
|
||||
.map(|member| member.name.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
let mut credit_line = org
|
||||
.credit_lines
|
||||
.get(&context.requester_uid)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| CreditLineSummary {
|
||||
uid: context.requester_uid.clone(),
|
||||
name: member_name.clone(),
|
||||
approved_amount: 0.0,
|
||||
available_amount: 0.0,
|
||||
outstanding_principal: 0.0,
|
||||
interest_rate: DEFAULT_CREDIT_LINE_INTEREST_RATE,
|
||||
amount_due: 0.0,
|
||||
amount: 0.0,
|
||||
});
|
||||
credit_line.normalize();
|
||||
credit_line.uid = context.requester_uid.clone();
|
||||
credit_line.name = member_name;
|
||||
if credit_line.interest_rate <= 0.0 {
|
||||
credit_line.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE;
|
||||
}
|
||||
credit_line.outstanding_principal =
|
||||
round_currency(credit_line.outstanding_principal + charged_amount);
|
||||
credit_line.amount_due =
|
||||
round_currency(credit_line.amount_due + charged_amount);
|
||||
credit_line.amount = credit_line.available_amount;
|
||||
org.credit_lines
|
||||
.insert(context.requester_uid.clone(), credit_line);
|
||||
}
|
||||
self.repository.save(&org)?;
|
||||
|
||||
let patch_fields = if context.record_member_debt {
|
||||
vec!["funds", "credit_lines"]
|
||||
} else {
|
||||
vec!["funds"]
|
||||
};
|
||||
|
||||
Ok(OrgMutationResult {
|
||||
patch: build_org_patch(&org, &["funds"])?,
|
||||
patch: build_org_patch(&org, &patch_fields)?,
|
||||
member_uids,
|
||||
message: String::new(),
|
||||
org,
|
||||
@ -1220,3 +1265,158 @@ fn format_currency(amount: f64) -> String {
|
||||
fn round_currency(amount: f64) -> f64 {
|
||||
(amount.max(0.0) * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use forge_repositories::InMemoryOrgHotRepository;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct TestOrgRepository;
|
||||
|
||||
impl OrgRepository for TestOrgRepository {
|
||||
fn create(&self, _org: &Org) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_by_id(&self, _id: &str) -> Result<Option<Org>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn update(&self, _org: &Org) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, _id: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn exists(&self, _id: &str) -> Result<bool, String> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn add_member(&self, _org_id: &str, _member_uid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_members(&self, _org_id: &str) -> Result<Vec<MemberSummary>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn remove_member(&self, _org_id: &str, _member_uid: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_assets(
|
||||
&self,
|
||||
_org_id: &str,
|
||||
) -> Result<HashMap<String, HashMap<String, OrgAssetEntry>>, String> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
fn update_assets(
|
||||
&self,
|
||||
_org_id: &str,
|
||||
_assets: &HashMap<String, HashMap<String, OrgAssetEntry>>,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_fleet(&self, _org_id: &str) -> Result<HashMap<String, OrgFleetEntry>, String> {
|
||||
Ok(HashMap::new())
|
||||
}
|
||||
|
||||
fn update_fleet(
|
||||
&self,
|
||||
_org_id: &str,
|
||||
_fleet: &HashMap<String, OrgFleetEntry>,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_hot_org() -> HotOrgRecord {
|
||||
let mut members = HashMap::new();
|
||||
members.insert(
|
||||
"member".to_string(),
|
||||
MemberSummary {
|
||||
uid: "member".to_string(),
|
||||
name: "Medic Patient".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
HotOrgRecord {
|
||||
id: "org".to_string(),
|
||||
owner: "owner".to_string(),
|
||||
name: "Test Org".to_string(),
|
||||
funds: 500.0,
|
||||
reputation: 0,
|
||||
credit_lines: HashMap::new(),
|
||||
assets: HashMap::new(),
|
||||
fleet: HashMap::new(),
|
||||
members,
|
||||
pending_invites: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_service(
|
||||
hot_repository: InMemoryOrgHotRepository,
|
||||
) -> OrgHotStateService<TestOrgRepository, InMemoryOrgHotRepository> {
|
||||
OrgHotStateService::new(TestOrgRepository, hot_repository)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_funds_checkout_without_member_debt_only_reduces_funds() {
|
||||
let hot_repository = InMemoryOrgHotRepository::new();
|
||||
hot_repository.save(&test_hot_org()).unwrap();
|
||||
let service = test_service(hot_repository);
|
||||
|
||||
let result = service
|
||||
.charge_checkout(OrgCheckoutContext {
|
||||
requester_uid: "member".to_string(),
|
||||
org_id: "org".to_string(),
|
||||
requester_is_default_org_ceo: false,
|
||||
allow_member_charge: true,
|
||||
record_member_debt: false,
|
||||
source: "org_funds".to_string(),
|
||||
amount: 125.0,
|
||||
commit: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.org.funds, 375.0);
|
||||
assert!(result.org.credit_lines.is_empty());
|
||||
assert!(result.patch.contains_key("funds"));
|
||||
assert!(!result.patch.contains_key("credit_lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn org_funds_checkout_can_record_member_debt() {
|
||||
let hot_repository = InMemoryOrgHotRepository::new();
|
||||
hot_repository.save(&test_hot_org()).unwrap();
|
||||
let service = test_service(hot_repository);
|
||||
|
||||
let result = service
|
||||
.charge_checkout(OrgCheckoutContext {
|
||||
requester_uid: "member".to_string(),
|
||||
org_id: "org".to_string(),
|
||||
requester_is_default_org_ceo: false,
|
||||
allow_member_charge: true,
|
||||
record_member_debt: true,
|
||||
source: "org_funds".to_string(),
|
||||
amount: 100.0,
|
||||
commit: true,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let credit_line = result.org.credit_lines.get("member").unwrap();
|
||||
assert_eq!(result.org.funds, 400.0);
|
||||
assert_eq!(credit_line.uid, "member");
|
||||
assert_eq!(credit_line.name, "Medic Patient");
|
||||
assert_eq!(credit_line.outstanding_principal, 100.0);
|
||||
assert_eq!(credit_line.amount_due, 100.0);
|
||||
assert_eq!(credit_line.available_amount, 0.0);
|
||||
assert!(result.patch.contains_key("funds"));
|
||||
assert!(result.patch.contains_key("credit_lines"));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user