feat: Implement organization registration fee and bank PIN change functionality

- Updated HomeView and RegistrationView to reflect the new $50,000 registration fee for organizations.
- Enhanced actor onboarding process to include sending welcome emails and messages, along with initializing bank accounts with $2,000 starting credit.
- Added functionality to change bank PINs, including validation and persistence of new PINs.
- Updated bank and organization modules to handle registration fee charges and refunds appropriately.
- Enhanced documentation to reflect changes in organization registration and bank operations.
This commit is contained in:
Jacob Schmidt 2026-05-16 12:13:13 -05:00
parent 80d2b1fc00
commit 264559306d
34 changed files with 595 additions and 20 deletions

View File

@ -3,7 +3,7 @@
## Overview
The bank addon provides the client banking UI and browser bridge for account
hydrate, deposits, withdrawals, transfers, PIN entry, earnings deposits, and
credit-line repayment.
credit-line repayment. It also exposes PIN changes from the full bank UI.
## Dependencies
- `forge_client_common`
@ -27,6 +27,7 @@ credit-line repayment.
- `bank::depositEarnings::request`
- `bank::repayCreditLine::request`
- `bank::pin::request`
- `bank::pin::change::request`
- `bank::close`
## Runtime Notes

View File

@ -78,6 +78,11 @@ switch (_event) do {
GVAR(BankUIBridge) call ["handleSubmitPinRequest", [_data]];
};
};
case "bank::pin::change::request": {
if !(isNil QGVAR(BankUIBridge)) then {
GVAR(BankUIBridge) call ["handleChangePinRequest", [_data]];
};
};
default {
hint format ["Unhandled bank UI event: %1", _event];
};

View File

@ -63,6 +63,17 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [
[SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent);
true
}],
["handleChangePinRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];
private _currentPin = _data getOrDefault ["currentPin", ""];
private _newPin = _data getOrDefault ["newPin", ""];
if !(_currentPin isEqualType "") then { _currentPin = str _currentPin; };
if !(_newPin isEqualType "") then { _newPin = str _newPin; };
[SRPC(bank,requestChangePin), [getPlayerUID player, _currentPin, _newPin]] call CFUNC(serverEvent);
true
}],
["handleRepayCreditLineRequest", compileFinal {
params [["_data", createHashMap, [createHashMap]]];

File diff suppressed because one or more lines are too long

View File

@ -49,6 +49,9 @@
requestRefresh() {
return bridge.send("bank::refresh", {});
},
requestChangePin(payload) {
return bridge.send("bank::pin::change::request", payload);
},
requestSubmitPin(payload) {
return bridge.send("bank::pin::request", payload);
},

View File

@ -352,6 +352,73 @@
: "Deposit Earnings",
),
),
h(
"section",
{ className: "bank-page-section" },
h(
"div",
{ className: "bank-section-header" },
h(
"div",
null,
h("span", { className: "bank-eyebrow" }, "Security"),
h(
"h2",
{ className: "bank-section-title" },
"Change ATM PIN",
),
),
),
h(
"div",
{ className: "bank-form-stack" },
h("input", {
id: "bank-current-pin",
className: "bank-input",
type: "password",
inputMode: "numeric",
maxLength: "4",
placeholder: "Current PIN",
}),
h("input", {
id: "bank-new-pin",
className: "bank-input",
type: "password",
inputMode: "numeric",
maxLength: "4",
placeholder: "New PIN",
}),
h("input", {
id: "bank-confirm-pin",
className: "bank-input",
type: "password",
inputMode: "numeric",
maxLength: "4",
placeholder: "Confirm new PIN",
}),
h(
"button",
{
type: "button",
className: "bank-btn bank-btn-primary",
disabled: pending("changepin"),
onClick: () => {
const sent = actions.requestChangePin(
readInputValue("bank-current-pin"),
readInputValue("bank-new-pin"),
readInputValue("bank-confirm-pin"),
);
if (sent) {
clearInputValue("bank-current-pin");
clearInputValue("bank-new-pin");
clearInputValue("bank-confirm-pin");
}
},
},
pending("changepin") ? "Updating PIN..." : "Update PIN",
),
),
),
);
}

View File

@ -149,6 +149,53 @@
return true;
}
function normalizePin(value) {
return String(value || "")
.replace(/\D/g, "")
.slice(0, 4);
}
function requestChangePin(currentPinValue, newPinValue, confirmPinValue) {
const currentPin = normalizePin(currentPinValue);
const newPin = normalizePin(newPinValue);
const confirmPin = normalizePin(confirmPinValue);
const bridge = BankApp.bridge;
if (!bridge || typeof bridge.requestChangePin !== "function") {
showNotice("error", "PIN change bridge is unavailable.");
return false;
}
if (currentPin.length !== 4) {
showNotice("error", "Enter your current four-digit PIN.");
return false;
}
if (newPin.length !== 4) {
showNotice("error", "Choose a new four-digit PIN.");
return false;
}
if (newPin !== confirmPin) {
showNotice("error", "New PIN confirmation does not match.");
return false;
}
if (currentPin === newPin) {
showNotice(
"error",
"Choose a different PIN from your current PIN.",
);
return false;
}
store.startAction("changepin");
const sent = bridge.requestChangePin({ currentPin, newPin });
if (!sent) {
store.finishAction();
showNotice("error", "PIN change bridge is unavailable.");
return false;
}
return true;
}
function appendPinDigit(digit) {
const nextDigit = String(digit || "").trim();
if (!nextDigit) {
@ -276,6 +323,7 @@
closeBank,
refreshBank,
requestAtmAmount,
requestChangePin,
requestDeposit,
requestDepositEarnings,
requestRepayCreditLine,

View File

@ -3,7 +3,8 @@
## Overview
The organization addon provides the client organization portal UI and bridge for
organization hydrate, registration, membership, invitations, credit lines,
leave/disband actions, assets, fleet, and treasury display.
leave/disband actions, assets, fleet, and treasury display. Registration shows
the $50,000 personal funds requirement enforced by the server org addon.
## Dependencies
- `forge_client_common`

File diff suppressed because one or more lines are too long

View File

@ -46,7 +46,7 @@ ${scopeSelector} .home-feedback {
h(
"p",
null,
"Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Receive your official unit designator and TO&E authorization instantly.",
"Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Registration requires $50,000 in personal funds.",
),
h(
"button",

View File

@ -177,7 +177,7 @@ ${scopeSelector} .form-feedback.is-error {
h(
"p",
null,
"Complete the form to add your organization to the Global Organization Registry.",
"Complete the form to add your organization to the Global Organization Registry. Registration requires at least $50,000 in personal funds.",
),
h(
"ul",
@ -258,7 +258,11 @@ ${scopeSelector} .form-feedback.is-error {
h(
"div",
{ className: "price-tag" },
h("span", { className: "price-label" }, "Registration Fee"),
h(
"span",
{ className: "price-label" },
"Required Registration Fee",
),
h("span", { className: "price-value" }, "$50,000"),
),
),

View File

@ -12,6 +12,8 @@ life state, phone number, email, organization, and holster state.
- `forge_server_main`
- `forge_server_common`
- `forge_server_extension` at runtime for actor extension calls
- `forge_server_phone` for new actor welcome email and messages
- `forge_server_bank` for new actor starting bank credit
- `forge_client_actor` for response RPCs
## Main Components
@ -23,6 +25,9 @@ life state, phone number, email, organization, and holster state.
## Runtime Behavior
- Missing persistent actors can be created from live player snapshots.
- Newly created actors receive a Field Commander job orientation email, two
Field Commander text messages, and a `$2,000` starting credit in their bank
account.
- Hot actor reads are migrated and hydrated before use.
- `saveHotState` in the main addon snapshots and saves actor state on player
disconnect and mission end.

View File

@ -4,7 +4,7 @@
* File: fnc_initActorStore.sqf
* Author: IDSolutions
* Date: 2025-12-17
* Last Update: 2026-04-05
* Last Update: 2026-05-16
* Public: Yes
*
* Description:
@ -149,6 +149,108 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [
_uids select { _x isEqualType "" && { _x isNotEqualTo "" } }
}],
["sendNewActorWelcomeComms", compileFinal {
params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]]];
if (_uid isEqualTo "") exitWith { false };
if (isNil QEGVAR(phone,PhoneStore)) exitWith {
["WARNING", format ["Unable to send new actor welcome comms for %1: phone store is unavailable.", _uid]] call EFUNC(common,log);
false
};
EGVAR(phone,PhoneStore) call ["init", [_uid]];
private _phoneNumber = _actor getOrDefault ["phone_number", ""];
private _emailAddress = _actor getOrDefault ["email", ""];
private _welcomeEmail = format [
"Welcome to your first day on the job. Forge Dynamics has issued you a work phone with phone number %1 and email address %2. Keep these details handy for field communications and future assignments.",
_phoneNumber,
_emailAddress
];
private _player = [_uid] call EFUNC(common,getPlayer);
private _emailObj = EGVAR(phone,PhoneStore) call [
"sendEmail",
["field_commander", _uid, "Job Orientation", _welcomeEmail]
];
if (
_emailObj isEqualType createHashMap
&& { _emailObj isNotEqualTo createHashMap }
&& { !(isNull _player) }
) then {
["forge_client_phone_responseEmailReceived", [_emailObj], _player] call CFUNC(targetEvent);
};
private _messages = [
"Welcome to your first day on the job. Forge Dynamics has issued your starting equipment and a small account credit. These are the only free supplies you will receive for this identity, so use them wisely. You are responsible for all purchases going forward.",
"Deposit your Earnings before leaving the session. Access the Bank from any laptop, then select Deposit Earnings."
];
{
private _messageObj = EGVAR(phone,PhoneStore) call [
"sendMessage",
["field_commander", _uid, _x]
];
if (
_messageObj isEqualType createHashMap
&& { _messageObj isNotEqualTo createHashMap }
&& { !(isNull _player) }
) then {
["forge_client_phone_responseMessageReceived", [_messageObj], _player] call CFUNC(targetEvent);
};
} forEach _messages;
true
}],
["grantNewActorStartingBankCredit", compileFinal {
params [["_uid", "", [""]], ["_amount", 2000, [0]]];
if (_uid isEqualTo "" || { _amount <= 0 }) exitWith { false };
if (isNil QEGVAR(bank,BankStore) || { isNil QEGVAR(bank,BankMessenger) }) exitWith {
["WARNING", format ["Unable to grant new actor starting bank credit for %1: bank store or messenger is unavailable.", _uid]] call EFUNC(common,log);
false
};
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 {
["WARNING", format ["Unable to grant new actor starting bank credit for %1: bank account could not be initialized.", _uid]] call EFUNC(common,log);
false
};
private _currentBank = _account getOrDefault ["bank", 0];
if !(_currentBank isEqualType 0) then { _currentBank = 0; };
private _patch = EGVAR(bank,BankStore) call [
"mset",
[
_uid,
createHashMapFromArray [["bank", _currentBank + _amount]],
true
]
];
if (_patch isEqualTo createHashMap) exitWith {
["WARNING", format ["Unable to grant new actor starting bank credit for %1: bank account update failed.", _uid]] call EFUNC(common,log);
false
};
EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]];
true
}],
["bootstrapNewActor", compileFinal {
params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]]];
if (_uid isEqualTo "") exitWith { false };
_self call ["sendNewActorWelcomeComms", [_uid, _actor]];
_self call ["grantNewActorStartingBankCredit", [_uid, 2000]];
true
}],
["loadHotActor", compileFinal {
params [["_uid", "", [""]], ["_initialize", false, [false]]];
@ -200,6 +302,13 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [
false
};
private _createdActor = fromJSON _createResult;
if !(_createdActor isEqualType createHashMap) then {
_createdActor = +_actor;
};
_createdActor = GVAR(ActorModel) call ["migrate", [_createdActor]];
_self call ["bootstrapNewActor", [_uid, _createdActor]];
true
}],
["hydrateActorIfNeeded", compileFinal {

View File

@ -3,7 +3,8 @@
## Overview
The bank addon owns the SQF bridge for player accounts, cash and bank balances,
PIN/session handling, transfers, checkout charging, earnings deposits, and
credit-line repayment.
credit-line repayment. It also verifies and persists player-requested ATM PIN
changes.
Account truth lives in the extension hot cache. SQF handles Arma-facing
validation, client messaging, session state, and payment integration with other
@ -28,7 +29,7 @@ server addons.
## Supported Operations
- initialize and hydrate player bank state
- deposit, withdraw, transfer, and deposit earnings
- validate PIN-backed sessions
- validate PIN-backed sessions and change ATM PINs
- charge checkout previews and committed purchases
- repay organization credit lines with rollback on failure
- save hot bank state to durable storage

View File

@ -32,6 +32,12 @@ PREP_RECOMPILE_END;
GVAR(BankSessionManager) call ["submitPin", [_uid, _pin]];
}] call CFUNC(addEventHandler);
[QGVAR(requestChangePin), {
params [["_uid", "", [""]], ["_currentPin", "", [""]], ["_newPin", "", [""]]];
GVAR(BankStore) call ["changePin", [_uid, _currentPin, _newPin]];
}] call CFUNC(addEventHandler);
[QGVAR(requestTransfer), {
params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]];

View File

@ -496,6 +496,34 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [
true
}
}],
["changePin", compileFinal {
params [["_uid", "", [""]], ["_currentPin", "", [""]], ["_newPin", "", [""]]];
if (_uid isEqualTo "") exitWith { false };
private _current = _currentPin;
private _next = _newPin;
if !(_current isEqualType "") then { _current = str _current; };
if !(_next isEqualType "") then { _next = str _next; };
private _changed = _self call [
"runMutation",
[
_uid,
"bank:hot:change_pin",
[_uid, _current, _next, toJSON (GVAR(BankPayloadBuilder) call ["buildOperationContext", [_uid]])],
true,
""
]
];
if (_changed) then {
GVAR(BankMessenger) call ["sendAlert", [_uid, "success", "Bank PIN updated."]];
_self call ["hydrateSession", [_uid, "", false]];
};
_changed
}],
["withdraw", compileFinal {
params [["_uid", "", [""]], ["_amount", 0, [0]]];

View File

@ -9,6 +9,9 @@ Organization hot state is owned by the extension. SQF coordinates Arma-facing
events, UI payloads, membership syncs, and integration with actor, bank, store,
and task flows.
Organization registration charges a $50,000 personal funds fee before the
player is assigned to the new organization.
## Dependencies
- `forge_server_main`
- `forge_server_common`

View File

@ -780,6 +780,91 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
true
}],
["syncBankPatch", compileFinal {
params [["_uid", "", [""]], ["_patch", createHashMap, [createHashMap]]];
if (_uid isEqualTo "" || { _patch isEqualTo createHashMap }) exitWith { false };
if (isNil QEGVAR(common,EventBus)) then {
EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]];
} else {
EGVAR(common,EventBus) call ["emit", [
"bank.account.sync.requested",
createHashMapFromArray [
["uid", _uid],
["account", +_patch]
],
createHashMapFromArray [["source", "org"]]
]];
};
true
}],
["chargeRegistrationFee", compileFinal {
params [["_uid", "", [""]], ["_amount", 50000, [0]]];
private _result = createHashMapFromArray [
["success", false],
["message", "Unable to charge the organization registration fee."],
["patch", createHashMap],
["refundPatch", createHashMap]
];
if (_uid isEqualTo "" || { _amount <= 0 }) exitWith { _result };
if (isNil QEGVAR(bank,BankStore)) exitWith {
_result set ["message", "Bank service is unavailable for organization registration."];
_result
};
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 {
_result set ["message", "Bank account could not be loaded for organization registration."];
_result
};
private _currentBank = _account getOrDefault ["bank", 0];
private _currentCash = _account getOrDefault ["cash", 0];
if ((_currentBank + _currentCash) < _amount) exitWith {
_result set ["message", format ["You need at least $%1 in personal funds to create an organization.", [_amount] call EFUNC(common,formatNumber)]];
_result
};
private _bankCharge = _amount min _currentBank;
private _cashCharge = _amount - _bankCharge;
private _patch = createHashMapFromArray [
["bank", _currentBank - _bankCharge],
["cash", _currentCash - _cashCharge]
];
private _refundPatch = createHashMapFromArray [
["bank", _currentBank],
["cash", _currentCash]
];
private _appliedPatch = EGVAR(bank,BankStore) call ["mset", [_uid, _patch, true]];
if (_appliedPatch isEqualTo createHashMap) exitWith {
_result set ["message", "Organization registration fee could not be charged."];
_result
};
_result set ["success", true];
_result set ["message", ""];
_result set ["patch", _appliedPatch];
_result set ["refundPatch", _refundPatch];
_result
}],
["refundRegistrationFee", compileFinal {
params [["_uid", "", [""]], ["_refundPatch", createHashMap, [createHashMap]]];
if (_uid isEqualTo "" || { _refundPatch isEqualTo createHashMap } || { isNil QEGVAR(bank,BankStore) }) exitWith { false };
private _patch = EGVAR(bank,BankStore) call ["mset", [_uid, _refundPatch, true]];
if (_patch isEqualTo createHashMap) exitWith { false };
_self call ["syncBankPatch", [_uid, _patch]]
}],
["updateOrgTreasuryFunds", compileFinal {
params [["_orgID", "", [""]], ["_funds", 0, [0]]];
@ -1201,24 +1286,36 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
["existingOrgId", _existingOrgID]
];
private _registrationFee = 50000;
private _feeCharge = _self call ["chargeRegistrationFee", [_uid, _registrationFee]];
if !(_feeCharge getOrDefault ["success", false]) exitWith {
_result set ["message", _feeCharge getOrDefault ["message", "Organization registration fee could not be charged."]];
_result
};
private _refundPatch = _feeCharge getOrDefault ["refundPatch", createHashMap];
["org:hot:register", [toJSON _context]] call EFUNC(extension,extCall) params ["_rawResult", "_isSuccess"];
if !_isSuccess exitWith {
_self call ["refundRegistrationFee", [_uid, _refundPatch]];
_result set ["message", "Organization service was unavailable during registration."];
_result
};
if !(_rawResult isEqualType "") exitWith {
_self call ["refundRegistrationFee", [_uid, _refundPatch]];
_result set ["message", "Organization service returned an invalid registration response."];
_result
};
if ((_rawResult find "Error:") == 0) exitWith {
_self call ["refundRegistrationFee", [_uid, _refundPatch]];
_result set ["message", _rawResult select [7]];
_result
};
private _envelope = fromJSON _rawResult;
if !(_envelope isEqualType createHashMap) exitWith {
_self call ["refundRegistrationFee", [_uid, _refundPatch]];
_result set ["message", "Organization service returned malformed registration data."];
_result
};
@ -1232,10 +1329,12 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [
private _actorPatch = _self call ["applyActorOrganization", [_uid, _envelope getOrDefault ["actorOrganization", _orgID], _actor]];
if (_actorPatch isEqualTo createHashMap) exitWith {
_self call ["refundRegistrationFee", [_uid, _refundPatch]];
_result set ["message", "Failed to assign the player to the new organization."];
_result
};
_self call ["syncBankPatch", [_uid, _feeCharge getOrDefault ["patch", createHashMap]]];
_result set ["success", true];
_result set ["message", _envelope getOrDefault ["message", ""]];
_result set ["org", _envelope getOrDefault ["org", createHashMap]];

View File

@ -58,6 +58,7 @@ pub fn group() -> Group {
.command("deposit_earnings", deposit_earnings_hot_bank)
.command("transfer", transfer_hot_bank)
.command("validate_pin", validate_pin_hot_bank)
.command("change_pin", change_pin_hot_bank)
.command("save", save_hot_bank)
.command("remove", remove_hot_bank),
)
@ -317,6 +318,28 @@ pub(crate) fn validate_pin_hot_bank(
}
}
pub(crate) fn change_pin_hot_bank(
call_context: CallContext,
key: String,
current_pin: String,
new_pin: String,
json_context: String,
) -> String {
let resolved_uid = match resolve_uid(&key, &call_context) {
Some(uid) => uid,
None => return format!("Error: Failed to resolve UID for key: {}", key),
};
let context = match parse_pin_context(json_context) {
Ok(value) => value,
Err(error) => return format!("Error: {}", error),
};
match HOT_BANK_SERVICE.change_pin(resolved_uid, current_pin, new_pin, context) {
Ok(result) => serialize_hot_bank_mutation(result),
Err(error) => format!("Error: {}", error),
}
}
pub(crate) fn save_hot_bank(call_context: CallContext, key: String) -> String {
let resolved_uid = match resolve_uid(&key, &call_context) {
Some(uid) => uid,

View File

@ -118,6 +118,16 @@ pub(super) fn route(
arguments[2].clone(),
))
}
"bank:hot:change_pin" => {
expect_arg_count(function_name, &arguments, 4)?;
Ok(bank::change_pin_hot_bank(
call_context,
arguments[0].clone(),
arguments[1].clone(),
arguments[2].clone(),
arguments[3].clone(),
))
}
"bank:hot:save" => {
expect_arg_count(function_name, &arguments, 1)?;
Ok(bank::save_hot_bank(call_context, arguments[0].clone()))

View File

@ -74,6 +74,22 @@ private _result = "forge_server" callExtension ["actor:create", [
]];
```
## New Player Bootstrap
The server actor store treats a player with no persisted actor as a new player.
After `actor:create` succeeds, the actor store runs onboarding once for that UID:
- Initializes the player's phone state.
- Sends a Field Commander email from `field_commander` with the `Job Orientation`
subject and the generated phone number and email address.
- Sends two Field Commander text messages with the first-day instructions.
- Initializes the player's bank account if needed and adds `$2,000` to the bank
balance.
This bootstrap is tied to persistent actor creation, not hot-state hydration, so
returning players and repaired partial actor records do not receive the welcome
messages or starting money again.
## Update an Actor
`actor:update` accepts a JSON object containing only fields to change.

View File

@ -2,7 +2,8 @@
The bank module stores player account balances, earnings, PINs, and transaction
strings. The hot-state API also owns the active banking workflows used by the
UI: deposit, withdraw, transfer, checkout charge, and PIN validation.
UI: deposit, withdraw, transfer, checkout charge, PIN validation, and PIN
changes.
## Storage Model
@ -73,6 +74,7 @@ private _result = "forge_server" callExtension ["bank:create", [
| `bank:hot:transfer` | `source_uid`, `target_uid`, `amount`, `context_json` | Transfer result JSON. |
| `bank:hot:charge_checkout` | `uid`, `amount`, `context_json` | `{ account, patch }`. |
| `bank:hot:validate_pin` | `uid`, `pin`, `context_json` | `{}` on success. |
| `bank:hot:change_pin` | `uid`, `current_pin`, `new_pin`, `context_json` | `{ account, patch }`. |
| `bank:hot:save` | `uid` | Current hot bank JSON and async durable save. |
| `bank:hot:remove` | `uid` | `OK`. |
@ -155,6 +157,25 @@ private _result = "forge_server" callExtension ["bank:hot:validate_pin", [
]];
```
## PIN Changes
PIN changes require the current PIN and a different four-digit new PIN. The
command is only valid from the full bank interface.
```sqf
private _context = createHashMapFromArray [
["mode", "bank"],
["atmAuthorized", false]
];
private _result = "forge_server" callExtension ["bank:hot:change_pin", [
getPlayerUID player,
"1234",
"5678",
toJSON _context
]];
```
## Error Handling
```sqf

View File

@ -33,8 +33,8 @@ state.
- bank/ATM mode
- browser ready handling
- account hydrate and sync responses
- deposit, withdrawal, transfer, earnings deposit, credit repayment, and PIN
requests
- deposit, withdrawal, transfer, earnings deposit, credit repayment, PIN
validation, and PIN change requests
- browser notice delivery
## Browser Events
@ -49,6 +49,7 @@ state.
| `bank::depositEarnings::request` | Request earnings deposit. |
| `bank::repayCreditLine::request` | Request credit-line repayment. |
| `bank::pin::request` | Forward PIN validation request. |
| `bank::pin::change::request` | Forward current and new PIN values for a PIN change. |
| `bank::close` | Dispose bridge screen state and close the display. |
## Browser Response Events
@ -77,6 +78,10 @@ Balances, PIN authorization, transfers, checkout charges, credit lines, and
persistence are server-owned. The client should only display account data and
request mutations through server events.
PIN changes are available from the full bank UI only. The browser validates the
current, new, and confirmation fields, but the server extension remains
authoritative and persists the updated PIN.
## Related Guides
- [Bank Usage Guide](./BANK_USAGE_GUIDE.md)

View File

@ -4,6 +4,10 @@ The client organization addon provides the organization portal UI and browser
bridge for login, registration, membership, invites, credit lines, leave and
disband flows, assets, fleet, and treasury display.
Organization registration requires $50,000 in personal funds. The server org
addon enforces and charges the fee; the browser only displays the requirement
and submits the registration request.
## Open Organization UI
```sqf

View File

@ -22,7 +22,7 @@ docs/ Framework-level documentation
| Domain | Purpose | Client addon | Server addon | Service/model layer | Extension group |
| --- | --- | --- | --- | --- | --- |
| Actor | Player identity, loadout, position, status, contact identifiers, and persistent character data. | `arma/client/addons/actor` | `arma/server/addons/actor` | `lib/models/src/actor.rs`, `lib/services/src/actor.rs` | `actor:*` |
| Bank | Player accounts, cash/bank balances, PIN validation, transfers, checkout charging, and transaction context. | `arma/client/addons/bank` | `arma/server/addons/bank` | `lib/models/src/bank.rs`, `lib/services/src/bank.rs` | `bank:*`, `bank:hot:*` |
| Bank | Player accounts, cash/bank balances, PIN validation and changes, transfers, checkout charging, and transaction context. | `arma/client/addons/bank` | `arma/server/addons/bank` | `lib/models/src/bank.rs`, `lib/services/src/bank.rs` | `bank:*`, `bank:hot:*` |
| CAD | Dispatch requests, assignments, orders, activity stream, profiles, groups, and hydrated dispatcher views. | `arma/client/addons/cad` | `arma/server/addons/cad` | `lib/models/src/cad.rs`, `lib/services/src/cad.rs` | `cad:*` |
| Garage | Player vehicle storage with plate IDs, fuel, damage, and hit point state. | `arma/client/addons/garage` | `arma/server/addons/garage` | `lib/models/src/garage.rs`, `lib/services/src/garage.rs` | `garage:*`, `garage:hot:*` |
| Locker | Player item storage keyed by classname with category and amount. | `arma/client/addons/locker` | `arma/server/addons/locker` | `lib/models/src/locker.rs`, `lib/services/src/locker.rs` | `locker:*`, `locker:hot:*` |
@ -114,7 +114,7 @@ See [Actor Usage Guide](./ACTOR_USAGE_GUIDE.md) for examples.
| `bank:hot:init`, `bank:hot:get`, `bank:hot:override`, `bank:hot:patch`, `bank:hot:save`, `bank:hot:remove` | Manage bank hot state. |
| `bank:hot:deposit`, `bank:hot:withdraw`, `bank:hot:deposit_earnings`, `bank:hot:transfer` | Mutate hot bank balances with operation context. |
| `bank:hot:charge_checkout` | Charge a checkout against hot bank state. |
| `bank:hot:validate_pin` | Validate a PIN for bank operations. |
| `bank:hot:validate_pin`, `bank:hot:change_pin` | Validate and update PINs for bank operations. |
See [Bank Usage Guide](./BANK_USAGE_GUIDE.md) for examples.

View File

@ -45,6 +45,9 @@ Rules validated by the Rust service:
- `funds`, reputation, and credit line amounts cannot be negative.
- Player registration is rejected when the player already belongs to a
non-default organization.
- Player registration through the server org addon requires a $50,000 personal
funds registration fee. The fee is charged from the player's bank balance
first, then on-hand cash if needed.
## Durable Commands
@ -159,6 +162,10 @@ private _fleet = createHashMapFromArray [
## Register from UI Context
The server-side `forge_server_org` registration flow charges the $50,000
registration fee before completing organization creation. If the organization
service rejects the registration, the bank charge is refunded.
```sqf
private _context = createHashMapFromArray [
["requesterUid", getPlayerUID player],

View File

@ -23,7 +23,7 @@ docs/ Framework-level documentation
| Domain | Purpose | Client addon | Server addon | Service/model layer | Extension group |
| --- | --- | --- | --- | --- | --- |
| Actor | Player identity, loadout, position, status, contact identifiers, and persistent character data. | `arma/client/addons/actor` | `arma/server/addons/actor` | `lib/models/src/actor.rs`, `lib/services/src/actor.rs` | `actor:*` |
| Bank | Player accounts, cash/bank balances, PIN validation, transfers, checkout charging, and transaction context. | `arma/client/addons/bank` | `arma/server/addons/bank` | `lib/models/src/bank.rs`, `lib/services/src/bank.rs` | `bank:*`, `bank:hot:*` |
| Bank | Player accounts, cash/bank balances, PIN validation and changes, transfers, checkout charging, and transaction context. | `arma/client/addons/bank` | `arma/server/addons/bank` | `lib/models/src/bank.rs`, `lib/services/src/bank.rs` | `bank:*`, `bank:hot:*` |
| CAD | Dispatch requests, assignments, orders, activity stream, profiles, groups, and hydrated dispatcher views. | `arma/client/addons/cad` | `arma/server/addons/cad` | `lib/models/src/cad.rs`, `lib/services/src/cad.rs` | `cad:*` |
| Garage | Player vehicle storage with plate IDs, fuel, damage, and hit point state. | `arma/client/addons/garage` | `arma/server/addons/garage` | `lib/models/src/garage.rs`, `lib/services/src/garage.rs` | `garage:*`, `garage:hot:*` |
| Locker | Player item storage keyed by classname with category and amount. | `arma/client/addons/locker` | `arma/server/addons/locker` | `lib/models/src/locker.rs`, `lib/services/src/locker.rs` | `locker:*`, `locker:hot:*` |
@ -115,7 +115,7 @@ See [Actor Usage Guide](/server-modules/actor) for examples.
| `bank:hot:init`, `bank:hot:get`, `bank:hot:override`, `bank:hot:patch`, `bank:hot:save`, `bank:hot:remove` | Manage bank hot state. |
| `bank:hot:deposit`, `bank:hot:withdraw`, `bank:hot:deposit_earnings`, `bank:hot:transfer` | Mutate hot bank balances with operation context. |
| `bank:hot:charge_checkout` | Charge a checkout against hot bank state. |
| `bank:hot:validate_pin` | Validate a PIN for bank operations. |
| `bank:hot:validate_pin`, `bank:hot:change_pin` | Validate and update PINs for bank operations. |
See [Bank Usage Guide](/server-modules/bank) for examples.

View File

@ -73,6 +73,22 @@ private _result = "forge_server" callExtension ["actor:create", [
]];
```
## New Player Bootstrap
The server actor store treats a player with no persisted actor as a new player.
After `actor:create` succeeds, the actor store runs onboarding once for that UID:
- Initializes the player's phone state.
- Sends a Field Commander email from `field_commander` with the `Job Orientation`
subject and the generated phone number and email address.
- Sends two Field Commander text messages with the first-day instructions.
- Initializes the player's bank account if needed and adds `$2,000` to the bank
balance.
This bootstrap is tied to persistent actor creation, not hot-state hydration, so
returning players and repaired partial actor records do not receive the welcome
messages or starting money again.
## Update an Actor
`actor:update` accepts a JSON object containing only fields to change.

View File

@ -1,6 +1,6 @@
---
title: "Bank Usage Guide"
description: "The bank module stores player account balances, earnings, PINs, and transaction strings. The hot-state API also owns the active banking workflows used by the UI: deposit, withdraw, transfer, checkout charge, and PIN validation."
description: "The bank module stores player account balances, earnings, PINs, and transaction strings. The hot-state API also owns the active banking workflows used by the UI: deposit, withdraw, transfer, checkout charge, PIN validation, and PIN changes."
---
## Storage Model
@ -72,6 +72,7 @@ private _result = "forge_server" callExtension ["bank:create", [
| `bank:hot:transfer` | `source_uid`, `target_uid`, `amount`, `context_json` | Transfer result JSON. |
| `bank:hot:charge_checkout` | `uid`, `amount`, `context_json` | `{ account, patch }`. |
| `bank:hot:validate_pin` | `uid`, `pin`, `context_json` | `{}` on success. |
| `bank:hot:change_pin` | `uid`, `current_pin`, `new_pin`, `context_json` | `{ account, patch }`. |
| `bank:hot:save` | `uid` | Current hot bank JSON and async durable save. |
| `bank:hot:remove` | `uid` | `OK`. |
@ -154,6 +155,25 @@ private _result = "forge_server" callExtension ["bank:hot:validate_pin", [
]];
```
## PIN Changes
PIN changes require the current PIN and a different four-digit new PIN. The
command is only valid from the full bank interface.
```sqf
private _context = createHashMapFromArray [
["mode", "bank"],
["atmAuthorized", false]
];
private _result = "forge_server" callExtension ["bank:hot:change_pin", [
getPlayerUID player,
"1234",
"5678",
toJSON _context
]];
```
## Error Handling
```sqf

View File

@ -44,6 +44,9 @@ Rules validated by the Rust service:
- `funds`, reputation, and credit line amounts cannot be negative.
- Player registration is rejected when the player already belongs to a
non-default organization.
- Player registration through the server org addon requires a $50,000 personal
funds registration fee. The fee is charged from the player's bank balance
first, then on-hand cash if needed.
## Durable Commands
@ -158,6 +161,10 @@ private _fleet = createHashMapFromArray [
## Register from UI Context
The server-side `forge_server_org` registration flow charges the $50,000
registration fee before completing organization creation. If the organization
service rejects the registration, the bank charge is refunded.
```sqf
private _context = createHashMapFromArray [
["requesterUid", getPlayerUID player],

View File

@ -32,8 +32,8 @@ state.
- bank/ATM mode
- browser ready handling
- account hydrate and sync responses
- deposit, withdrawal, transfer, earnings deposit, credit repayment, and PIN
requests
- deposit, withdrawal, transfer, earnings deposit, credit repayment, PIN
validation, and PIN change requests
- browser notice delivery
## Browser Events
@ -48,6 +48,7 @@ state.
| `bank::depositEarnings::request` | Request earnings deposit. |
| `bank::repayCreditLine::request` | Request credit-line repayment. |
| `bank::pin::request` | Forward PIN validation request. |
| `bank::pin::change::request` | Forward current and new PIN values for a PIN change. |
| `bank::close` | Dispose bridge screen state and close the display. |
## Browser Response Events
@ -76,6 +77,10 @@ Balances, PIN authorization, transfers, checkout charges, credit lines, and
persistence are server-owned. The client should only display account data and
request mutations through server events.
PIN changes are available from the full bank UI only. The browser validates the
current, new, and confirmation fields, but the server extension remains
authoritative and persists the updated PIN.
## Related Guides
- [Bank Usage Guide](/server-modules/bank)

View File

@ -3,6 +3,10 @@ title: "Client Organization Usage Guide"
description: "The client organization addon provides the organization portal UI and browser bridge for login, registration, membership, invites, credit lines, leave and disband flows, assets, fleet, and treasury display."
---
Organization registration requires $50,000 in personal funds. The server org
addon enforces and charges the fee; the browser only displays the requirement
and submits the registration request.
## Open Organization UI
```sqf

View File

@ -56,6 +56,8 @@ pub struct BankCheckoutContext {
#[serde(rename_all = "camelCase")]
pub struct BankPinContext {
pub mode: String,
#[serde(default)]
pub atm_authorized: bool,
}
impl Bank {

View File

@ -310,6 +310,46 @@ impl<R: BankRepository, H: BankHotRepository> BankHotStateService<R, H> {
Ok(())
}
pub fn change_pin(
&self,
key: String,
current_pin: String,
new_pin: String,
context: BankPinContext,
) -> Result<BankMutationResult, String> {
let mode = context.mode.trim();
if !mode.eq_ignore_ascii_case("bank") {
return Err("PIN changes are only available from the full bank interface.".to_string());
}
if !is_four_digit_pin(&current_pin) {
return Err("Enter your current four-digit PIN.".to_string());
}
if !is_four_digit_pin(&new_pin) {
return Err("Choose a new four-digit PIN.".to_string());
}
if current_pin == new_pin {
return Err("Choose a different PIN from your current PIN.".to_string());
}
let mut bank = self.get_bank(key)?;
if current_pin != bank.pin.to_string() {
return Err("Current PIN is incorrect.".to_string());
}
bank.pin = new_pin
.parse::<u64>()
.map_err(|error| format!("Invalid new PIN: {}", error))?;
bank.validate()
.map_err(|e| format!("Validation failed: {}", e))?;
self.repository.save(&bank)?;
Ok(BankMutationResult {
account: bank.clone(),
patch: build_patch(&bank, &["pin"])?,
})
}
pub fn save_bank(&self, key: String) -> Result<Bank, String> {
let bank = self
.repository
@ -402,6 +442,10 @@ fn build_patch(bank: &Bank, fields: &[&str]) -> Result<HashMap<String, Value>, S
Ok(patch)
}
fn is_four_digit_pin(pin: &str) -> bool {
pin.len() == 4 && pin.chars().all(|character| character.is_ascii_digit())
}
fn validate_atm_access(context: &BankOperationContext, action: &str) -> Result<(), String> {
if context.mode.eq_ignore_ascii_case("atm") && !context.atm_authorized {
return Err(format!("ATM authorization is required before {}.", action));