From 6c0ce9e867943e61091de09f02afba3ea423e104 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Mon, 16 Mar 2026 10:54:25 -0500 Subject: [PATCH 01/19] refactor(bank): split monolithic store into focused modules; fix locker grant for attachments - Split BankBaseStore into SessionManager, Messenger, Model, Store, Validator - Extract validation logic into BankValidator with try/catch and per-action methods - Remove duplicate notifications from transaction actions - Update event handlers to call validator first, forward context to store/session - Fix locker grantItems: add missing 'attachment' category mapping to 'item' - Fix locker grantItems: replace exitWith with if/else to prevent skipping remaining items Co-Authored-By: Oz --- arma/client/addons/bank/XEH_PREP.hpp | 1 - .../client/addons/bank/XEH_postInitClient.sqf | 22 +- .../bank/functions/fnc_handleUIEvents.sqf | 5 + .../addons/bank/functions/fnc_initClass.sqf | 1 - .../bank/functions/fnc_initSessionService.sqf | 80 ---- .../bank/functions/fnc_initUIBridge.sqf | 73 ++-- arma/client/addons/bank/ui/_site/bank-ui.js | 2 +- arma/client/addons/bank/ui/src/bridge.js | 3 + arma/client/addons/bank/ui/src/data.js | 2 +- .../addons/bank/ui/src/registry/events.js | 90 +---- .../addons/bank/ui/src/registry/store.js | 20 +- arma/server/addons/bank/XEH_PREP.hpp | 6 +- arma/server/addons/bank/XEH_preInit.sqf | 68 ++-- .../bank/functions/fnc_initBankStore.sqf | 326 ---------------- .../bank/functions/fnc_initMessenger.sqf | 75 ++++ .../addons/bank/functions/fnc_initModel.sqf | 91 +++++ .../bank/functions/fnc_initSessionManager.sqf | 94 +++++ .../addons/bank/functions/fnc_initStore.sqf | 350 ++++++++++++++++++ .../bank/functions/fnc_initValidator.sqf | 259 +++++++++++++ .../locker/functions/fnc_initLockerStore.sqf | 40 +- .../addons/main/functions/fnc_initStores.sqf | 6 +- 21 files changed, 1032 insertions(+), 582 deletions(-) delete mode 100644 arma/client/addons/bank/functions/fnc_initSessionService.sqf delete mode 100644 arma/server/addons/bank/functions/fnc_initBankStore.sqf create mode 100644 arma/server/addons/bank/functions/fnc_initMessenger.sqf create mode 100644 arma/server/addons/bank/functions/fnc_initModel.sqf create mode 100644 arma/server/addons/bank/functions/fnc_initSessionManager.sqf create mode 100644 arma/server/addons/bank/functions/fnc_initStore.sqf create mode 100644 arma/server/addons/bank/functions/fnc_initValidator.sqf diff --git a/arma/client/addons/bank/XEH_PREP.hpp b/arma/client/addons/bank/XEH_PREP.hpp index f1a55dc..7d71bae 100644 --- a/arma/client/addons/bank/XEH_PREP.hpp +++ b/arma/client/addons/bank/XEH_PREP.hpp @@ -1,5 +1,4 @@ PREP(handleUIEvents); PREP(initClass); -PREP(initSessionService); PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/bank/XEH_postInitClient.sqf b/arma/client/addons/bank/XEH_postInitClient.sqf index a4cf8d6..b779e98 100644 --- a/arma/client/addons/bank/XEH_postInitClient.sqf +++ b/arma/client/addons/bank/XEH_postInitClient.sqf @@ -1,7 +1,6 @@ #include "script_component.hpp" if (isNil QGVAR(BankClass)) then { call FUNC(initClass); }; -if (isNil QGVAR(BankSessionService)) then { call FUNC(initSessionService); }; if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(initBank), { @@ -26,8 +25,27 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; }; }] call CFUNC(addEventHandler); +[QGVAR(responseHydrateBank), { + params [["_data", createHashMap, [createHashMap]]]; + + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleHydrateResponse", [_data, "bank::hydrate"]]; + }; +}] call CFUNC(addEventHandler); + +[QGVAR(responseBankNotice), { + params [ + ["_type", "error", [""]], + ["_message", "", [""]] + ]; + + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleNoticeResponse", [_type, _message]]; + }; +}] call CFUNC(addEventHandler); + [{ - EGVAR(org,OrgClass) get "isLoaded"; + getPlayerUID player isNotEqualTo ""; }, { [QGVAR(initBank), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf index b2fcd53..68237d0 100644 --- a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf @@ -68,6 +68,11 @@ switch (_event) do { GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]]; }; }; + case "bank::pin::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleSubmitPinRequest", [_data]]; + }; + }; default { hint format ["Unhandled bank UI event: %1", _event]; }; diff --git a/arma/client/addons/bank/functions/fnc_initClass.sqf b/arma/client/addons/bank/functions/fnc_initClass.sqf index ede4cc8..2a46590 100644 --- a/arma/client/addons/bank/functions/fnc_initClass.sqf +++ b/arma/client/addons/bank/functions/fnc_initClass.sqf @@ -18,7 +18,6 @@ GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ ["bank", 0], ["cash", 0], ["earnings", 0], - ["pin", 1234], ["transactions", []] ]]; _self set ["isLoaded", false]; diff --git a/arma/client/addons/bank/functions/fnc_initSessionService.sqf b/arma/client/addons/bank/functions/fnc_initSessionService.sqf deleted file mode 100644 index 155652b..0000000 --- a/arma/client/addons/bank/functions/fnc_initSessionService.sqf +++ /dev/null @@ -1,80 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_initSessionService.sqf - * Author: IDSolutions - * Public: No - * - * Description: - * Initializes the bank session service that shapes the browser payload. - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(BankSessionServiceBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "BankSessionServiceBaseClass"], - ["buildTransferTargets", compileFinal { - private _targets = []; - - { - if (isNull _x || { _x isEqualTo player }) then { - continue; - }; - - private _uid = getPlayerUID _x; - private _name = name _x; - if (_uid isEqualTo "" || { _name isEqualTo "" }) then { - continue; - }; - - _targets pushBack (createHashMapFromArray [ - ["name", _name], - ["uid", _uid] - ]); - } forEach allPlayers; - - private _targetPairs = _targets apply { - [toLowerANSI (_x getOrDefault ["name", ""]), _x] - }; - _targetPairs sort true; - _targetPairs apply { - _x param [1, createHashMap] - } - }], - ["buildPayload", compileFinal { - params [["_mode", "bank", [""]]]; - - private _account = if (isNil QGVAR(BankClass)) then { - createHashMap - } else { - GVAR(BankClass) call ["getAccountState", []] - }; - - private _orgFunds = 0; - private _orgName = ""; - if !(isNil QEGVAR(org,OrgClass)) then { - _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; - _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; - }; - - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["mode", ["bank", "atm"] select (toLowerANSI _mode isEqualTo "atm")], - ["orgFunds", _orgFunds], - ["orgName", _orgName], - ["playerName", name player], - ["transferTargets", _self call ["buildTransferTargets", []]], - ["uid", getPlayerUID player] - ]], - ["account", createHashMapFromArray [ - ["bank", _account getOrDefault ["bank", 0]], - ["cash", _account getOrDefault ["cash", 0]], - ["earnings", _account getOrDefault ["earnings", 0]], - ["pin", str (_account getOrDefault ["pin", 1234])], - ["transactions", _account getOrDefault ["transactions", []]] - ]] - ] - }] -]; - -GVAR(BankSessionService) = createHashMapObject [GVAR(BankSessionServiceBaseClass)]; -GVAR(BankSessionService) diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf index 32e1b0b..3b7a0d6 100644 --- a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -19,9 +19,6 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["#create", compileFinal { _self set ["mode", "bank"]; }], - ["buildPayload", compileFinal { - GVAR(BankSessionService) call ["buildPayload", [_self call ["getMode", []]]] - }], ["getActiveBrowserControl", compileFinal { private _display = uiNamespace getVariable ["RscBank", displayNull]; if (isNull _display) exitWith { @@ -36,14 +33,16 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["getMode", compileFinal { _self getOrDefault ["mode", "bank"] }], + ["hasOpenScreen", compileFinal { + private _screen = _self call ["getScreen", []]; + private _control = _self call ["getActiveBrowserControl", []]; + + !(isNull _control) && { _screen call ["isReady", []] } + }], ["handleDepositEarningsRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; private _amount = floor (_data getOrDefault ["amount", 0]); - if (_amount <= 0) exitWith { - _self call ["sendNotice", ["error", "No earnings are available to deposit."]]; - }; - [SRPC(bank,requestDepositEarnings), [getPlayerUID player, _amount]] call CFUNC(serverEvent); true }], @@ -51,22 +50,41 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ params [["_data", createHashMap, [createHashMap]]]; private _amount = floor (_data getOrDefault ["amount", 0]); - if (_amount <= 0) exitWith { - _self call ["sendNotice", ["error", "Enter a valid deposit amount."]]; - }; - [SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent); true }], + ["handleHydrateResponse", compileFinal { + params [["_data", createHashMap, [createHashMap]], ["_event", "bank::hydrate", [""]]]; + + if !(_self call ["hasOpenScreen", []]) exitWith { false }; + + _self call ["sendEvent", [_event, _data, _self call ["getActiveBrowserControl", []]]] + }], + ["handleNoticeResponse", compileFinal { + params [["_type", "error", [""]], ["_message", "", [""]]]; + + _self call ["sendNotice", [_type, _message]] + }], ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; private _screen = _self call ["getScreen", []]; _screen call ["setControl", [_control]]; _screen call ["markReady", [true]]; - _self call ["flushPendingEvents", []]; - _self call ["sendEvent", ["bank::hydrate", _self call ["buildPayload", []], _control]]; + + _self call ["requestHydrate", [true]] + }], + ["handleSubmitPinRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _pin = _data getOrDefault ["pin", ""]; + if !(_pin isEqualType "") then { + _pin = str _pin; + }; + + [SRPC(bank,requestSubmitPin), [getPlayerUID player, _pin]] call CFUNC(serverEvent); + true }], ["handleTransferRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; @@ -75,18 +93,6 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ private _target = _data getOrDefault ["target", ""]; private _from = toLowerANSI (_data getOrDefault ["from", "bank"]); - if (_target isEqualTo "") exitWith { - _self call ["sendNotice", ["error", "Select a transfer recipient."]]; - }; - - if (_target isEqualTo getPlayerUID player) exitWith { - _self call ["sendNotice", ["error", "You cannot transfer funds to yourself."]]; - }; - - if (_amount <= 0) exitWith { - _self call ["sendNotice", ["error", "Enter a valid transfer amount."]]; - }; - [SRPC(bank,requestTransfer), [getPlayerUID player, _target, _from, _amount]] call CFUNC(serverEvent); true }], @@ -94,23 +100,24 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ params [["_data", createHashMap, [createHashMap]]]; private _amount = floor (_data getOrDefault ["amount", 0]); - if (_amount <= 0) exitWith { - _self call ["sendNotice", ["error", "Enter a valid withdrawal amount."]]; - }; - [SRPC(bank,requestWithdraw), [getPlayerUID player, _amount]] call CFUNC(serverEvent); true }], ["refreshSession", compileFinal { - private _control = _self call ["getActiveBrowserControl", []]; - if (isNull _control) exitWith { false }; + _self call ["requestHydrate", [false]] + }], + ["requestHydrate", compileFinal { + params [["_resetAuthorization", false, [false]]]; - _self call ["sendEvent", ["bank::sync", _self call ["buildPayload", []], _control]] + if !(_self call ["hasOpenScreen", []]) exitWith { false }; + + [SRPC(bank,requestHydrateBank), [getPlayerUID player, _self call ["getMode", []], _resetAuthorization]] call CFUNC(serverEvent); + true }], ["sendNotice", compileFinal { params [["_type", "error", [""]], ["_message", "", [""]], ["_control", controlNull, [controlNull]]]; - if (_message isEqualTo "") exitWith { false }; + if (_message isEqualTo "" || { !(_self call ["hasOpenScreen", []]) }) exitWith { false }; _self call ["sendEvent", ["bank::notice", createHashMapFromArray [ ["message", _message], diff --git a/arma/client/addons/bank/ui/_site/bank-ui.js b/arma/client/addons/bank/ui/_site/bank-ui.js index cf05616..f4f99bd 100644 --- a/arma/client/addons/bank/ui/_site/bank-ui.js +++ b/arma/client/addons/bank/ui/_site/bank-ui.js @@ -1 +1 @@ -!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,pin:"1234",transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=this.getMode(),a=this.getAtmView();this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setNotice({text:"",type:""}),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"!==e?this.setAtmView("dashboard"):this.setAtmView("atm"===t?a:"pin")}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});function a(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}t.on("bank::hydrate",a),t.on("bank::sync",a),t.on("bank::notice",e=>{n.actions&&n.actions.showNotice(e.type||"error",e.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(){return n.data?.account||{}}function s(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function i(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function o(t){const o=s(t),r=a();if(o<=0)return i("error","Enter a valid deposit amount."),!1;if(o>Number(r.cash||0))return i("error","Cash on hand cannot cover that deposit."),!1;const c=n.bridge;if(!c||"function"!=typeof c.requestDeposit)return i("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!c.requestDeposit({amount:o})||(e.finishAction(),i("error","Deposit bridge is unavailable."),!1)}function r(t){const o=s(t),r=a();if(o<=0)return i("error","Enter a valid withdrawal amount."),!1;if(o>Number(r.bank||0))return i("error","Bank balance cannot cover that withdrawal."),!1;const c=n.bridge;if(!c||"function"!=typeof c.requestWithdraw)return i("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!c.requestWithdraw({amount:o})||(e.finishAction(),i("error","Withdraw bridge is unavailable."),!1)}function c(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:c,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return i("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return i("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,t){const a="deposit"===String(n||"").trim().toLowerCase()?o(t):r(t);return a&&e.setAtmView("menu"),a},requestDeposit:o,requestDepositEarnings:function(t){const o=s(t),r=a();if(o<=0)return i("error","No earnings are available to deposit."),!1;if(o>Number(r.earnings||0))return i("error","Pending earnings cannot cover that deposit request."),!1;const c=n.bridge;return c&&"function"==typeof c.requestDepositEarnings?(e.startAction("depositearnings"),!!c.requestDepositEarnings({amount:o})||(e.finishAction(),i("error","Earnings bridge is unavailable."),!1)):(i("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,o){const r=s(o),c=n.data?.session||{},u=a(),l=String(t||"").trim();if(!l)return i("error","Select a transfer recipient."),!1;if(l===String(c.uid||""))return i("error","You cannot transfer funds to yourself."),!1;if(r<=0)return i("error","Enter a valid transfer amount."),!1;if(r>Number(u.bank||0))return i("error","Bank balance cannot cover that transfer."),!1;const m=n.bridge;return m&&"function"==typeof m.requestTransfer?(e.startAction("transfer"),!!m.requestTransfer({amount:r,from:"bank",target:l})||(e.finishAction(),i("error","Transfer bridge is unavailable."),!1)):(i("error","Transfer bridge is unavailable."),!1)},requestWithdraw:r,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:i,submitCustomAmount:function(n){const t=s(e.getCustomAmount()),a=String(n||"").trim().toLowerCase();if(t<=0)return i("error","Enter a valid transaction amount."),!1;const c="deposit"===a?o(t):r(t);return c&&(e.setCustomAmount(""),e.setAtmView("menu")),c},submitPin:function(){const n=String(e.getEnteredPin()||""),t=String(a().pin||"1234");return 4!==n.length?(i("error","Enter your four-digit access PIN."),!1):n!==t?(c(),i("error","Incorrect PIN."),!1):(c(),e.setAtmView("menu"),!0)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file +!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={atmAuthorized:!1,mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=Boolean(n?.session?.atmAuthorized),a=this.getMode(),s=this.getAtmView(),i=this.getPendingAction();if(this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"===e)return t?"deposit"===i||"withdraw"===i||"pin"===s||"atm"!==a?void this.setAtmView("menu"):void this.setAtmView(s):void this.setAtmView("pin");this.setAtmView("dashboard")}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});function a(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}t.on("bank::hydrate",a),t.on("bank::sync",a),t.on("bank::notice",e=>{n.actions&&n.actions.showNotice(e.type||"error",e.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestSubmitPin:n=>t.send("bank::pin::request",n),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function s(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function i(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestDeposit)return s("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!o.requestDeposit({amount:i})||(e.finishAction(),s("error","Deposit bridge is unavailable."),!1)}function o(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestWithdraw)return s("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!o.requestWithdraw({amount:i})||(e.finishAction(),s("error","Withdraw bridge is unavailable."),!1)}function r(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:r,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return s("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return s("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,e){return"deposit"===String(n||"").trim().toLowerCase()?i(e):o(e)},requestDeposit:i,requestDepositEarnings:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestDepositEarnings?(e.startAction("depositearnings"),!!o.requestDepositEarnings({amount:i})||(e.finishAction(),s("error","Earnings bridge is unavailable."),!1)):(s("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,i){const o=a(i),r=String(t||"").trim(),c=n.bridge;return c&&"function"==typeof c.requestTransfer?(e.startAction("transfer"),!!c.requestTransfer({amount:o,from:"bank",target:r})||(e.finishAction(),s("error","Transfer bridge is unavailable."),!1)):(s("error","Transfer bridge is unavailable."),!1)},requestWithdraw:o,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:s,submitCustomAmount:function(n){const t=a(e.getCustomAmount()),r=String(n||"").trim().toLowerCase();if(t<=0)return s("error","Enter a valid transaction amount."),!1;const c="deposit"===r?i(t):o(t);return c&&e.setCustomAmount(""),c},submitPin:function(){const t=String(e.getEnteredPin()||""),a=n.bridge;return a&&"function"==typeof a.requestSubmitPin?(e.startAction("pin"),a.requestSubmitPin({pin:t})?(r(),!0):(e.finishAction(),s("error","PIN bridge is unavailable."),!1)):(s("error","PIN bridge is unavailable."),!1)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/bank/ui/src/bridge.js b/arma/client/addons/bank/ui/src/bridge.js index 1ceed4e..41f872b 100644 --- a/arma/client/addons/bank/ui/src/bridge.js +++ b/arma/client/addons/bank/ui/src/bridge.js @@ -40,6 +40,9 @@ requestRefresh() { return bridge.send("bank::refresh", {}); }, + requestSubmitPin(payload) { + return bridge.send("bank::pin::request", payload); + }, requestTransfer(payload) { return bridge.send("bank::transfer::request", payload); }, diff --git a/arma/client/addons/bank/ui/src/data.js b/arma/client/addons/bank/ui/src/data.js index 856ca90..c3558b8 100644 --- a/arma/client/addons/bank/ui/src/data.js +++ b/arma/client/addons/bank/ui/src/data.js @@ -2,6 +2,7 @@ const BankApp = (window.BankApp = window.BankApp || {}); const defaultSession = { + atmAuthorized: false, mode: "bank", orgFunds: 0, orgName: "", @@ -14,7 +15,6 @@ bank: 0, cash: 0, earnings: 0, - pin: "1234", transactions: [], }; diff --git a/arma/client/addons/bank/ui/src/registry/events.js b/arma/client/addons/bank/ui/src/registry/events.js index 01facaa..70c5413 100644 --- a/arma/client/addons/bank/ui/src/registry/events.js +++ b/arma/client/addons/bank/ui/src/registry/events.js @@ -4,14 +4,6 @@ let noticeTimer = null; - function getAccount() { - return BankApp.data?.account || {}; - } - - function getSession() { - return BankApp.data?.session || {}; - } - function normalizeAmount(value) { const amount = Math.floor(Number(value || 0)); return Number.isFinite(amount) ? amount : 0; @@ -58,18 +50,6 @@ function requestDeposit(amountValue) { const amount = normalizeAmount(amountValue); - const account = getAccount(); - - if (amount <= 0) { - showNotice("error", "Enter a valid deposit amount."); - return false; - } - - if (amount > Number(account.cash || 0)) { - showNotice("error", "Cash on hand cannot cover that deposit."); - return false; - } - const bridge = BankApp.bridge; if (!bridge || typeof bridge.requestDeposit !== "function") { showNotice("error", "Deposit bridge is unavailable."); @@ -89,18 +69,6 @@ function requestWithdraw(amountValue) { const amount = normalizeAmount(amountValue); - const account = getAccount(); - - if (amount <= 0) { - showNotice("error", "Enter a valid withdrawal amount."); - return false; - } - - if (amount > Number(account.bank || 0)) { - showNotice("error", "Bank balance cannot cover that withdrawal."); - return false; - } - const bridge = BankApp.bridge; if (!bridge || typeof bridge.requestWithdraw !== "function") { showNotice("error", "Withdraw bridge is unavailable."); @@ -120,30 +88,8 @@ function requestTransfer(targetUid, amountValue) { const amount = normalizeAmount(amountValue); - const session = getSession(); - const account = getAccount(); const targetId = String(targetUid || "").trim(); - if (!targetId) { - showNotice("error", "Select a transfer recipient."); - return false; - } - - if (targetId === String(session.uid || "")) { - showNotice("error", "You cannot transfer funds to yourself."); - return false; - } - - if (amount <= 0) { - showNotice("error", "Enter a valid transfer amount."); - return false; - } - - if (amount > Number(account.bank || 0)) { - showNotice("error", "Bank balance cannot cover that transfer."); - return false; - } - const bridge = BankApp.bridge; if (!bridge || typeof bridge.requestTransfer !== "function") { showNotice("error", "Transfer bridge is unavailable."); @@ -167,21 +113,6 @@ function requestDepositEarnings(amountValue) { const amount = normalizeAmount(amountValue); - const account = getAccount(); - - if (amount <= 0) { - showNotice("error", "No earnings are available to deposit."); - return false; - } - - if (amount > Number(account.earnings || 0)) { - showNotice( - "error", - "Pending earnings cannot cover that deposit request.", - ); - return false; - } - const bridge = BankApp.bridge; if (!bridge || typeof bridge.requestDepositEarnings !== "function") { showNotice("error", "Earnings bridge is unavailable."); @@ -224,21 +155,21 @@ function submitPin() { const enteredPin = String(store.getEnteredPin() || ""); - const actualPin = String(getAccount().pin || "1234"); - - if (enteredPin.length !== 4) { - showNotice("error", "Enter your four-digit access PIN."); + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestSubmitPin !== "function") { + showNotice("error", "PIN bridge is unavailable."); return false; } - if (enteredPin !== actualPin) { - clearPin(); - showNotice("error", "Incorrect PIN."); + store.startAction("pin"); + const sent = bridge.requestSubmitPin({ pin: enteredPin }); + if (!sent) { + store.finishAction(); + showNotice("error", "PIN bridge is unavailable."); return false; } clearPin(); - store.setAtmView("menu"); return true; } @@ -299,7 +230,6 @@ if (success) { store.setCustomAmount(""); - store.setAtmView("menu"); } return success; @@ -314,10 +244,6 @@ ? requestDeposit(amount) : requestWithdraw(amount); - if (success) { - store.setAtmView("menu"); - } - return success; } diff --git a/arma/client/addons/bank/ui/src/registry/store.js b/arma/client/addons/bank/ui/src/registry/store.js index 56b7233..2acf40b 100644 --- a/arma/client/addons/bank/ui/src/registry/store.js +++ b/arma/client/addons/bank/ui/src/registry/store.js @@ -25,19 +25,35 @@ const mode = String(payload?.session?.mode || "bank") .trim() .toLowerCase(); + const atmAuthorized = Boolean(payload?.session?.atmAuthorized); const currentMode = this.getMode(); const currentAtmView = this.getAtmView(); + const currentPendingAction = this.getPendingAction(); this.setMode(mode === "atm" ? "atm" : "bank"); this.setPendingAction(""); - this.setNotice({ text: "", type: "" }); this.setEnteredPin(""); this.setCustomAmount(""); this.setAccountVersion(this.getAccountVersion() + 1); this.setSessionVersion(this.getSessionVersion() + 1); if (mode === "atm") { - this.setAtmView(currentMode === "atm" ? currentAtmView : "pin"); + if (!atmAuthorized) { + this.setAtmView("pin"); + return; + } + + if ( + currentPendingAction === "deposit" || + currentPendingAction === "withdraw" || + currentAtmView === "pin" || + currentMode !== "atm" + ) { + this.setAtmView("menu"); + return; + } + + this.setAtmView(currentAtmView); return; } diff --git a/arma/server/addons/bank/XEH_PREP.hpp b/arma/server/addons/bank/XEH_PREP.hpp index fae036d..c0f781b 100644 --- a/arma/server/addons/bank/XEH_PREP.hpp +++ b/arma/server/addons/bank/XEH_PREP.hpp @@ -1,2 +1,6 @@ PREP(initBank); -PREP(initBankStore); +PREP(initMessenger); +PREP(initModel); +PREP(initSessionManager); +PREP(initStore); +PREP(initValidator); diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index b652346..141a418 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -13,15 +13,24 @@ PREP_RECOMPILE_END; GVAR(BankStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); +[QGVAR(requestHydrateBank), { + params [["_uid", "", [""]], ["_mode", "bank", [""]], ["_resetAuthorization", false, [false]]]; + + if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; + GVAR(BankStore) call ["hydrateSession", [_uid, _mode, _resetAuthorization]]; +}] call CFUNC(addEventHandler); + [QGVAR(requestGetBank), { params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; private _finalData = GVAR(BankStore) call ["get", [GVAR(Registry), _uid, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); + if (_field isNotEqualTo "") then { + _finalData = createHashMapFromArray [[_field, _finalData]]; + }; - [CRPC(bank,responseSyncBank), [_finalData], _player] call CFUNC(targetEvent); + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalData]]; }] call CFUNC(addEventHandler); [QGVAR(requestSetBank), { @@ -30,9 +39,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Key!" }; private _hashMap = GVAR(BankStore) call ["set", [GVAR(Registry), "bank:update", _uid, _field, _value, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(bank,responseSyncBank), [_hashMap], _player] call CFUNC(targetEvent); + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _hashMap]]; }] call CFUNC(addEventHandler); [QGVAR(requestMSetBank), { @@ -42,9 +49,7 @@ PREP_RECOMPILE_END; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid field pairs!" }; private _hashMap = GVAR(BankStore) call ["mset", [GVAR(Registry), "bank:update", _uid, _fieldValuePairs, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(bank,responseSyncBank), [_hashMap], _player] call CFUNC(targetEvent); + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _hashMap]]; }] call CFUNC(addEventHandler); [QGVAR(requestSaveBank), { @@ -53,9 +58,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; private _finalData = GVAR(BankStore) call ["save", [GVAR(Registry), "bank:update", _uid]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(bank,responseSyncBank), [_finalData], _player] call CFUNC(targetEvent); + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalData]]; }] call CFUNC(addEventHandler); [QGVAR(requestRemoveBank), { @@ -68,44 +71,47 @@ PREP_RECOMPILE_END; [QGVAR(requestDeposit), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - GVAR(BankStore) call ["deposit", [_uid, _amount]]; + private _context = GVAR(BankValidator) call ["validateDeposit", [_uid, _amount]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankStore) call ["deposit", [_uid, _amount, _context]]; }] call CFUNC(addEventHandler); [QGVAR(requestPayment), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - GVAR(BankStore) call ["payment", [_uid, _amount]]; + private _context = GVAR(BankValidator) call ["validatePayment", [_uid, _amount]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankStore) call ["payment", [_uid, _amount, _context]]; +}] call CFUNC(addEventHandler); + +[QGVAR(requestSubmitPin), { + params [["_uid", "", [""]], ["_pin", "", [""]]]; + + private _context = GVAR(BankValidator) call ["validateSubmitPin", [_uid, _pin]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankSessionManager) call ["submitPin", [_uid, _context]]; }] call CFUNC(addEventHandler); [QGVAR(requestTransfer), { params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo "" || _target isEqualTo "" || _from isEqualTo "" || _amount isEqualTo 0) exitWith { - diag_log "[FORGE:Server:Bank] Empty/Invalid UID, Target, From Account, or Amount!" - }; - - if (_uid isEqualTo _target) exitWith { - diag_log format ["[FORGE:Server:Bank] SECURITY: Player %1 attempted self-transfer!", _uid]; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(notifications,recieveNotification), ["error", "Bank", "Cannot transfer to yourself!"], _player] call CFUNC(targetEvent); - }; - - GVAR(BankStore) call ["transfer", [_uid, _target, _from, _amount]]; + private _context = GVAR(BankValidator) call ["validateTransfer", [_uid, _target, _from, _amount]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankStore) call ["transfer", [_uid, _target, _amount, _context]]; }] call CFUNC(addEventHandler); [QGVAR(requestWithdraw), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - GVAR(BankStore) call ["withdraw", [_uid, _amount]]; + private _context = GVAR(BankValidator) call ["validateWithdraw", [_uid, _amount]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankStore) call ["withdraw", [_uid, _amount, _context]]; }] call CFUNC(addEventHandler); [QGVAR(requestDepositEarnings), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - GVAR(BankStore) call ["depositEarnings", [_uid, _amount]]; + private _context = GVAR(BankValidator) call ["validateDepositEarnings", [_uid, _amount]]; + if (_context isEqualTo false) exitWith {}; + GVAR(BankStore) call ["depositEarnings", [_uid, _amount, _context]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/bank/functions/fnc_initBankStore.sqf b/arma/server/addons/bank/functions/fnc_initBankStore.sqf deleted file mode 100644 index f130777..0000000 --- a/arma/server/addons/bank/functions/fnc_initBankStore.sqf +++ /dev/null @@ -1,326 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_initBankStore.sqf - * Author: IDSolutions - * Date: 2025-12-17 - * Last Update: 2026-02-17 - * Public: Yes - * - * Description: - * Initializes the bank store for managing player bank accounts. - * Provides methods for syncing, saving, and applying bank accounts to the player. - * - * Arguments: - * None - * - * Return Value: - * Bank store object [HASHMAP OBJECT] - * - * Example: - * call forge_server_bank_fnc_initBankStore - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(BankModel) = compileFinal createHashMapObject [[ - ["#type", "BankModel"], - ["defaults", compileFinal { - private _account = createHashMap; - - _account set ["uid", ""]; - _account set ["name", ""]; - _account set ["bank", 0]; - _account set ["cash", 0]; - _account set ["earnings", 0]; - _account set ["pin", 1234]; - _account set ["transactions", []]; - - _account - }], - ["fromPlayer", compileFinal { - params [["_player", objNull, [objNull]]]; - - if (_player isEqualTo objNull) exitWith { _self call ["defaults", []] }; - - private _account = _self call ["defaults", []]; - - _account set ["uid", getPlayerUID _player]; - _account set ["name", name _player]; - _account set ["bank", 0]; - _account set ["cash", 0]; - _account set ["earnings", 0]; - _account set ["pin", 1234]; - _account set ["transactions", []]; - - _account - }], - ["migrate", compileFinal { - params [["_account", createHashMap, [createHashMap]]]; - - private _defaults = _self call ["defaults", []]; - - { - if !(_x in _account) then { _account set [_x, _y]; }; - } forEach _defaults; - - _account - }], - ["validate", compileFinal { - params [["_account", createHashMap, [createHashMap]]]; - - private _uid = _account getOrDefault ["uid", ""]; - private _name = _account getOrDefault ["name", ""]; - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - private _earnings = _account getOrDefault ["earnings", 0]; - private _pin = _account getOrDefault ["pin", 1234]; - - [_uid, _name, _bank, _cash, _earnings, _pin] try { - if (_uid isEqualTo "" || !(_uid isEqualType "")) then { throw "Invalid UID!"; }; - if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; - if (_bank < 0 || !(_bank isEqualType 0)) then { throw "Invalid Bank!"; }; - if (_cash < 0 || !(_cash isEqualType 0)) then { throw "Invalid Cash!"; }; - if (_earnings < 0 || !(_earnings isEqualType 0)) then { throw "Invalid Earnings!"; }; - if (_pin < 1000 || _pin > 9999 || !(_pin isEqualType 0)) then { throw "Invalid Pin!"; }; - } catch { - ["ERROR", format ["Failed to validate account %1!", _exception]] call EFUNC(common,log); - false - }; - - true - }] -]]; - -GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ - ["#base", EGVAR(common,BaseStore)], - ["#type", "BankBaseStore"], - ["#create", compileFinal { - GVAR(IndexRegistry) = createHashMap; - GVAR(Registry) = createHashMap; - ["INFO", "Bank Store Initialized!"] call EFUNC(common,log); - }], - ["init", compileFinal { - params [["_uid", "", [""]]]; - - private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(Registry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { [CRPC(bank,responseInitBank), [_cached], _player] call CFUNC(targetEvent); _cached }; - - ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log); - - private _fallbackAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; - _fallbackAccount set ["uid", _uid]; - - private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - - GVAR(Registry) set [_uid, _fallbackAccount]; - [CRPC(bank,responseInitBank), [_fallbackAccount], _player] call CFUNC(targetEvent); - - _fallbackAccount - }; - - private _finalAccount = createHashMap; - - if (_result == "true") then { - _finalAccount = _self call ["fetch", ["bank:get", _uid]]; - ["INFO", format ["Found bank account for %1", _uid]] call EFUNC(common,log); - } else { - _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; - _finalAccount set ["uid", _uid]; - - private _json = _self call ["toJSON", [_finalAccount]]; - ["bank:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log); - - private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - - GVAR(Registry) set [_uid, _finalAccount]; - [CRPC(bank,responseInitBank), [_finalAccount], _player] call CFUNC(targetEvent); - - _finalAccount - }; - - ["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log); - }; - - - private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - - // _finalAccount = GVAR(BankModel) call ["migrate", [_finalAccount]]; - GVAR(Registry) set [_uid, _finalAccount]; - [CRPC(bank,responseInitBank), [_finalAccount], _player] call CFUNC(targetEvent); - - _finalAccount - }], - ["deposit", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - ["INFO", format ["Deposit %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _account = GVAR(Registry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; - - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - if (_cash < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; - - private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)], ["cash", (_cash - _amount)]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - GVAR(Registry) set [_uid, _finalAccount]; - - [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Deposited $%1", _amount]], _player] call CFUNC(targetEvent); - }], - ["payment", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - ["INFO", format ["Payment %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _account = GVAR(Registry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; - - private _bank = _account getOrDefault ["bank", 0]; - private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - GVAR(Registry) set [_uid, _finalAccount]; - - [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Paid $%1", _amount]], _player] call CFUNC(targetEvent); - }], - ["buildChargeResult", compileFinal { - params [["_message", "Unable to process bank payment.", [""]]]; - - createHashMapFromArray [ - ["success", false], - ["message", _message], - ["patch", createHashMap] - ] - }], - ["chargeCheckout", compileFinal { - params [ - ["_uid", "", [""]], - ["_source", "cash", [""]], - ["_amount", 0, [0]], - ["_commit", false, [false]] - ]; - - private _result = _self call ["buildChargeResult", []]; - private _field = switch (toLowerANSI _source) do { - case "cash": { "cash" }; - case "bank": { "bank" }; - default { "" }; - }; - - if (_field isEqualTo "") exitWith { - _result set ["message", "Selected bank payment source is unsupported."]; - _result - }; - - private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; - if (_account isEqualTo createHashMap) exitWith { - _result set ["message", "Bank account data is unavailable for checkout."]; - _result - }; - - private _balance = _account getOrDefault [_field, 0]; - if (_balance < _amount) exitWith { - private _message = [ - "Bank balance cannot cover this checkout.", - "Cash on hand cannot cover this checkout." - ] select (_field isEqualTo "cash"); - - _result set ["message", _message]; - _result - }; - - private _patch = createHashMapFromArray [[_field, (_balance - _amount)]]; - if (_commit) then { - _patch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; - }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result - }], - ["transfer", compileFinal { - params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; - - if (_uid isEqualTo _target) exitWith { ["WARNING", format ["Self-transfer attempt blocked for %1", _uid]] call EFUNC(common,log); }; - - private _account = GVAR(Registry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; - - private _targetAccount = GVAR(Registry) getOrDefault [_target, nil]; - if (isNil "_targetAccount") exitWith { ["ERROR", "Empty/Invalid Target Account!"] call EFUNC(common,log); }; - - private _selected = _account getOrDefault [_from, 0]; - if (_selected < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; - - private _targetBank = _targetAccount getOrDefault ["bank", 0]; - private _finalAccount = createHashMapFromArray [[_from, (_selected - _amount)]]; - private _finalTargetBank = createHashMapFromArray [["bank", (_targetBank + _amount)]]; - - GVAR(Registry) set [_uid, _finalAccount]; - GVAR(Registry) set [_target, _finalTargetBank]; - - private _player = [_uid] call EFUNC(common,getPlayer); - private _targetPlayer = [_target] call EFUNC(common,getPlayer); - - [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); - [CRPC(bank,responseSyncBank), [_finalTargetBank], _targetPlayer] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Transferred $%1 to %2", _amount, (name _targetPlayer)]], _player] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Received $%1 from %2", _amount, (name _player)]], _targetPlayer] call CFUNC(targetEvent); - }], - ["withdraw", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - ["INFO", format ["Withdraw %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _account = GVAR(Registry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; - - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - if (_bank < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; - - private _finalAccount = createHashMapFromArray [["bank", (_bank - _amount)], ["cash", (_cash + _amount)]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - GVAR(Registry) set [_uid, _finalAccount]; - - [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Withdrew $%1", _amount]], _player] call CFUNC(targetEvent); - }], - ["depositEarnings", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - ["INFO", format ["Deposit Earnings %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _account = GVAR(Registry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; - - private _bank = _account getOrDefault ["bank", 0]; - private _earnings = _account getOrDefault ["earnings", 0]; - if (_earnings < _amount) exitWith { ["WARNING", "Insufficient Earnings!"] call EFUNC(common,log); }; - - private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)], ["earnings", (_earnings - _amount)]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - GVAR(Registry) set [_uid, _finalAccount]; - - [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Deposited $%1 from earnings", _amount]], _player] call CFUNC(targetEvent); - }] -]; - -GVAR(BankStore) = createHashMapObject [GVAR(BankBaseStore)]; -GVAR(BankStore) diff --git a/arma/server/addons/bank/functions/fnc_initMessenger.sqf b/arma/server/addons/bank/functions/fnc_initMessenger.sqf new file mode 100644 index 0000000..c3e0b90 --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initMessenger.sqf @@ -0,0 +1,75 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initMessenger.sqf + * Author: IDSolutions + * Date: 2026-03-16 + * Last Update: 2026-03-16 + * Public: No + * + * Description: + * Initializes the bank messenger for all server-to-client + * communication including account syncs, toast notifications, + * and inline bank UI notices. + * + * Parameter(s): + * None + * + * Returns: + * Messenger object [HASHMAP OBJECT] + * + * Example(s): + * call forge_server_bank_fnc_initMessenger + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankMessenger) = createHashMapObject [[ + ["#type", "BankMessenger"], + ["buildClientAccountPatch", compileFinal { + params [["_account", createHashMap, [createHashMap]]]; + + private _patch = createHashMap; + { + if (_x in _account) then { + _patch set [_x, _account get _x]; + }; + } forEach ["uid", "name", "bank", "cash", "earnings", "transactions"]; + + _patch + }], + ["sendAccountSync", compileFinal { + params [["_uid", "", [""]], ["_account", createHashMap, [createHashMap]], ["_event", CRPC(bank,responseSyncBank), [""]]]; + + if (_uid isEqualTo "" || { _account isEqualTo createHashMap }) exitWith { false }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { false }; + + [_event, [_self call ["buildClientAccountPatch", [_account]]], _player] call CFUNC(targetEvent); + true + }], + ["sendClientNotification", compileFinal { + params [["_uid", "", [""]], ["_type", "info", [""]], ["_title", "Bank", [""]], ["_message", "", [""]]]; + + if (_uid isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { false }; + + [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent); + true + }], + ["sendNotice", compileFinal { + params [["_uid", "", [""]], ["_type", "error", [""]], ["_message", "", [""]]]; + + if (_uid isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { false }; + + [CRPC(bank,responseBankNotice), [_type, _message], _player] call CFUNC(targetEvent); + true + }] +]]; + +GVAR(BankMessenger) diff --git a/arma/server/addons/bank/functions/fnc_initModel.sqf b/arma/server/addons/bank/functions/fnc_initModel.sqf new file mode 100644 index 0000000..77b32eb --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initModel.sqf @@ -0,0 +1,91 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initModel.sqf + * Author: IDSolutions + * Date: 2026-03-16 + * Last Update: 2026-03-16 + * Public: No + * + * Description: + * Initializes the bank account data model. Provides default account + * schema, player-based account creation, schema migration for + * existing accounts, and field-level validation. + * + * Parameter(s): + * None + * + * Returns: + * Bank model object [HASHMAP OBJECT] + * + * Example(s): + * call forge_server_bank_fnc_initModel + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankModel) = compileFinal createHashMapObject [[ + ["#type", "BankModel"], + ["defaults", compileFinal { + private _account = createHashMap; + + _account set ["uid", ""]; + _account set ["name", ""]; + _account set ["bank", 0]; + _account set ["cash", 0]; + _account set ["earnings", 0]; + _account set ["pin", 1234]; + _account set ["transactions", []]; + + _account + }], + ["fromPlayer", compileFinal { + params [["_player", objNull, [objNull]]]; + + if (_player isEqualTo objNull) exitWith { _self call ["defaults", []] }; + + private _account = _self call ["defaults", []]; + + _account set ["uid", getPlayerUID _player]; + _account set ["name", name _player]; + + _account + }], + ["migrate", compileFinal { + params [["_account", createHashMap, [createHashMap]]]; + + private _defaults = _self call ["defaults", []]; + { + if !(_x in _account) then { + _account set [_x, _y]; + }; + } forEach _defaults; + + _account + }], + ["validate", compileFinal { + params [["_account", createHashMap, [createHashMap]]]; + + private _uid = _account getOrDefault ["uid", ""]; + private _name = _account getOrDefault ["name", ""]; + private _bank = _account getOrDefault ["bank", 0]; + private _cash = _account getOrDefault ["cash", 0]; + private _earnings = _account getOrDefault ["earnings", 0]; + private _pin = _account getOrDefault ["pin", 1234]; + + [_uid, _name, _bank, _cash, _earnings, _pin] try { + if (_uid isEqualTo "" || !(_uid isEqualType "")) then { throw "Invalid UID!"; }; + if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; + if (_bank < 0 || !(_bank isEqualType 0)) then { throw "Invalid Bank!"; }; + if (_cash < 0 || !(_cash isEqualType 0)) then { throw "Invalid Cash!"; }; + if (_earnings < 0 || !(_earnings isEqualType 0)) then { throw "Invalid Earnings!"; }; + if (_pin < 1000 || _pin > 9999 || !(_pin isEqualType 0)) then { throw "Invalid Pin!"; }; + } catch { + ["ERROR", format ["Failed to validate account %1!", _exception]] call EFUNC(common,log); + false + }; + + true + }] +]]; + +GVAR(BankModel) diff --git a/arma/server/addons/bank/functions/fnc_initSessionManager.sqf b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf new file mode 100644 index 0000000..75ea131 --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf @@ -0,0 +1,94 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initSessionManager.sqf + * Author: IDSolutions + * Date: 2026-03-16 + * Last Update: 2026-03-16 + * Public: No + * + * Description: + * Initializes the bank session manager for managing ATM/bank + * session state, mode resolution, and PIN authorization. + * + * Parameter(s): + * None + * + * Returns: + * Session manager object [HASHMAP OBJECT] + * + * Example(s): + * call forge_server_bank_fnc_initSessionManager + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankSessionManager) = createHashMapObject [[ + ["#type", "BankSessionManager"], + ["getSessionState", compileFinal { + params [["_uid", "", [""]]]; + + private _session = GVAR(SessionRegistry) getOrDefault [_uid, createHashMap]; + if (_session isEqualTo createHashMap) then { + _session = createHashMapFromArray [ + ["atmAuthorized", false], + ["mode", "bank"] + ]; + GVAR(SessionRegistry) set [_uid, _session]; + }; + + _session + }], + ["setSessionState", compileFinal { + params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _session = +(_self call ["getSessionState", [_uid]]); + { _session set [_x, _y]; } forEach _fieldValuePairs; + + GVAR(SessionRegistry) set [_uid, _session]; + _session + }], + ["resolveMode", compileFinal { + params [["_mode", "bank", [""]]]; + + private _finalMode = toLowerANSI _mode; + if !(_finalMode in ["atm", "bank"]) then { _finalMode = "bank"; }; + + _finalMode + }], + ["syncSessionMode", compileFinal { + params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; + + private _current = _self call ["getSessionState", [_uid]]; + private _finalMode = if (_mode isEqualTo "") then { + _current getOrDefault ["mode", "bank"] + } else { + _self call ["resolveMode", [_mode]] + }; + private _atmAuthorized = _current getOrDefault ["atmAuthorized", false]; + + if (_finalMode isEqualTo "atm") then { + if (_resetAuthorization || { (_current getOrDefault ["mode", "bank"]) isNotEqualTo "atm" }) then { + _atmAuthorized = false; + }; + } else { + _atmAuthorized = false; + }; + + _self call ["setSessionState", [_uid, createHashMapFromArray [ + ["atmAuthorized", _atmAuthorized], + ["mode", _finalMode] + ]]] + }], + ["submitPin", compileFinal { + params [["_uid", "", [""]], ["_context", createHashMap, [createHashMap]]]; + + _self call ["setSessionState", [_uid, createHashMapFromArray [["atmAuthorized", true], ["mode", "atm"]]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", "ATM access granted."]]; + GVAR(BankStore) call ["hydrateSession", [_uid, "atm", false]]; + true + }] +]]; + +GVAR(BankSessionManager) diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf new file mode 100644 index 0000000..fdaaefa --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -0,0 +1,350 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initStore.sqf + * Author: IDSolutions + * Date: 2025-12-17 + * Last Update: 2026-03-16 + * Public: No + * + * Description: + * Initializes the bank store for managing player bank accounts. + * Handles account lifecycle (init/fetch/create/migrate), transaction + * mutations, checkout charges, and session hydration. + * + * Parameter(s): + * None + * + * Returns: + * Bank store object [HASHMAP OBJECT] + * + * Example(s): + * call forge_server_bank_fnc_initStore + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "BankBaseStore"], + ["#create", compileFinal { + GVAR(IndexRegistry) = createHashMap; + GVAR(Registry) = createHashMap; + GVAR(SessionRegistry) = createHashMap; + ["INFO", "Bank Store Initialized!"] call EFUNC(common,log); + }], + ["buildChargeResult", compileFinal { + params [["_message", "Unable to process bank payment.", [""]]]; + + createHashMapFromArray [ + ["success", false], + ["message", _message], + ["patch", createHashMap] + ] + }], + ["buildHydratePayload", compileFinal { + params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) then { _account = _self call ["init", [_uid]]; }; + if (_account isEqualTo createHashMap) exitWith { createHashMap }; + + private _session = GVAR(BankSessionManager) call ["syncSessionMode", [_uid, _mode, _resetAuthorization]]; + private _orgState = _self call ["resolveOrgState", [_uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); + private _playerName = if (isNull _player) then { + _account getOrDefault ["name", "Unknown"] + } else { + name _player + }; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["atmAuthorized", _session getOrDefault ["atmAuthorized", false]], + ["mode", _session getOrDefault ["mode", "bank"]], + ["orgFunds", _orgState getOrDefault ["funds", 0]], + ["orgName", _orgState getOrDefault ["name", ""]], + ["playerName", _playerName], + ["transferTargets", _self call ["buildTransferTargets", [_uid]]], + ["uid", _uid] + ]], + ["account", GVAR(BankMessenger) call ["buildClientAccountPatch", [_account]]] + ] + }], + ["buildTransferTargets", compileFinal { + params [["_sourceUid", "", [""]]]; + + private _targets = []; + { + if (isNull _x) then { continue; }; + + private _targetUid = getPlayerUID _x; + private _targetName = name _x; + if (_targetUid isEqualTo "" || { _targetUid isEqualTo _sourceUid } || { _targetName isEqualTo "" }) then { continue; }; + + _targets pushBack (createHashMapFromArray [ + ["name", _targetName], + ["uid", _targetUid] + ]); + } forEach allPlayers; + + private _targetPairs = _targets apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] }; + _targetPairs sort true; + _targetPairs apply { _x param [1, createHashMap] } + }], + ["chargeCheckout", compileFinal { + params [["_uid", "", [""]], ["_source", "cash", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; + + private _result = _self call ["buildChargeResult", []]; + private _field = switch (toLowerANSI _source) do { + case "cash": { "cash" }; + case "bank": { "bank" }; + default { "" }; + }; + + if (_field isEqualTo "") exitWith { + _result set ["message", "Selected bank payment source is unsupported."]; + _result + }; + + private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) exitWith { + _result set ["message", "Bank account data is unavailable for checkout."]; + _result + }; + + private _balance = _account getOrDefault [_field, 0]; + if (_balance < _amount) exitWith { + private _message = [ + "Bank balance cannot cover this checkout.", + "Cash on hand cannot cover this checkout." + ] select (_field isEqualTo "cash"); + + _result set ["message", _message]; + _result + }; + + private _patch = createHashMapFromArray [[_field, (_balance - _amount)]]; + if (_commit) then { + _patch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result + }], + ["deposit", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + + ["INFO", format ["Deposit %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _bank = _context getOrDefault ["bank", 0]; + private _cash = _context getOrDefault ["cash", 0]; + + private _patch = createHashMapFromArray [ + ["bank", (_bank + _amount)], + ["cash", (_cash - _amount)] + ]; + private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1", _amount]]]; + true + }], + ["hydrateSession", compileFinal { + params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; + + private _payload = _self call ["buildHydratePayload", [_uid, _mode, _resetAuthorization]]; + if (_payload isEqualTo createHashMap) exitWith { false }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { false }; + + [CRPC(bank,responseHydrateBank), [_payload], _player] call CFUNC(targetEvent); + true + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _playerName = if (isNull _player) then { "Unknown" } else { name _player }; + private _cached = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_cached isNotEqualTo createHashMap) exitWith { + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _cached, CRPC(bank,responseInitBank)]]; + _cached + }; + + ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log); + + private _fallbackAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; + _fallbackAccount set ["uid", _uid]; + if ((_fallbackAccount getOrDefault ["name", ""]) isEqualTo "") then { + _fallbackAccount set ["name", _playerName]; + }; + + private _regEntry = createHashMapFromArray [["uid", _uid], ["name", _playerName]]; + GVAR(IndexRegistry) set [_uid, _regEntry]; + GVAR(Registry) set [_uid, _fallbackAccount]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _fallbackAccount, CRPC(bank,responseInitBank)]]; + _fallbackAccount + }; + + private _finalAccount = createHashMap; + if (_result isEqualTo "true") then { + _finalAccount = _self call ["fetch", ["bank:get", _uid]]; + ["INFO", format ["Found bank account for %1", _uid]] call EFUNC(common,log); + } else { + _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; + _finalAccount set ["uid", _uid]; + if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then { + _finalAccount set ["name", _playerName]; + }; + + private _json = _self call ["toJSON", [_finalAccount]]; + ["bank:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; + if (!_createSuccess) exitWith { + ["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log); + + private _regEntry = createHashMapFromArray [["uid", _uid], ["name", _playerName]]; + GVAR(IndexRegistry) set [_uid, _regEntry]; + GVAR(Registry) set [_uid, _finalAccount]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]]; + _finalAccount + }; + + ["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log); + }; + + _finalAccount = GVAR(BankModel) call ["migrate", [_finalAccount]]; + if ((_finalAccount getOrDefault ["uid", ""]) isEqualTo "") then { + _finalAccount set ["uid", _uid]; + }; + if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then { + _finalAccount set ["name", _playerName]; + }; + + GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["uid", _uid], ["name", _playerName]]]; + GVAR(Registry) set [_uid, _finalAccount]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]]; + _finalAccount + }], + ["payment", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + + ["INFO", format ["Payment %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _bank = _context getOrDefault ["bank", 0]; + private _patch = createHashMapFromArray [["bank", (_bank + _amount)]]; + private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Paid $%1", _amount]]]; + true + }], + ["resolveOrgState", compileFinal { + params [["_uid", "", [""]]]; + + private _defaultState = createHashMapFromArray [ + ["funds", 0], + ["name", ""] + ]; + if (_uid isEqualTo "") exitWith { _defaultState }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; + }; + if (_org isEqualTo createHashMap) exitWith { _defaultState }; + + createHashMapFromArray [ + ["funds", _org getOrDefault ["funds", 0]], + ["name", _org getOrDefault ["name", ""]] + ] + }], + ["transfer", compileFinal { + params [["_uid", "", [""]], ["_target", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + + private _account = _context getOrDefault ["account", createHashMap]; + private _targetAccount = _context getOrDefault ["targetAccount", createHashMap]; + private _sourceField = _context getOrDefault ["sourceField", "bank"]; + private _selected = _context getOrDefault ["sourceBalance", 0]; + private _targetBank = _context getOrDefault ["targetBank", 0]; + + private _sourcePatch = createHashMapFromArray [[_sourceField, (_selected - _amount)]]; + private _targetPatch = createHashMapFromArray [["bank", (_targetBank + _amount)]]; + private _finalSourcePatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _sourcePatch, false]]; + private _finalTargetPatch = _self call ["mset", [GVAR(Registry), "bank:update", _target, _targetPatch, false]]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalSourcePatch]]; + GVAR(BankMessenger) call ["sendAccountSync", [_target, _finalTargetPatch]]; + + private _targetPlayer = [_target] call EFUNC(common,getPlayer); + private _targetName = if (isNull _targetPlayer) then { + _targetAccount getOrDefault ["name", "Recipient"] + } else { + name _targetPlayer + }; + private _player = [_uid] call EFUNC(common,getPlayer); + private _playerName = if (isNull _player) then { + _account getOrDefault ["name", "Unknown"] + } else { + name _player + }; + + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Transferred $%1 to %2", _amount, _targetName]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_target, "info", "Bank", format ["Received $%1 from %2", _amount, _playerName]]]; + true + }], + ["withdraw", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + + ["INFO", format ["Withdraw %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _bank = _context getOrDefault ["bank", 0]; + private _cash = _context getOrDefault ["cash", 0]; + + private _patch = createHashMapFromArray [ + ["bank", (_bank - _amount)], + ["cash", (_cash + _amount)] + ]; + private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Withdrew $%1", _amount]]]; + true + }], + ["depositEarnings", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + + ["INFO", format ["Deposit Earnings %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _bank = _context getOrDefault ["bank", 0]; + private _earnings = _context getOrDefault ["earnings", 0]; + + private _patch = createHashMapFromArray [ + ["bank", (_bank + _amount)], + ["earnings", (_earnings - _amount)] + ]; + private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1 from earnings", _amount]]]; + true + }] +]; + +GVAR(BankStore) = createHashMapObject [GVAR(BankBaseStore)]; +GVAR(BankStore) diff --git a/arma/server/addons/bank/functions/fnc_initValidator.sqf b/arma/server/addons/bank/functions/fnc_initValidator.sqf new file mode 100644 index 0000000..0bf06da --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initValidator.sqf @@ -0,0 +1,259 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_validator.sqf + * Author: IDSolutions + * Date: 2026-03-16 + * Last Update: 2026-03-16 + * Public: No + * + * Description: + * Initializes the bank validator for pre-checking action payloads + * before they reach the bank store. Each method uses try/catch to + * validate inputs and state, sending a notice to the player on + * failure and returning false. On success returns a context hashmap + * containing resolved data (account, balances, etc.) for the store. + * + * Parameter(s): + * None + * + * Returns: + * Validator object [HASHMAP OBJECT] + * + * Example(s): + * call forge_server_bank_fnc_validator + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankValidator) = createHashMapObject [[ + ["#type", "BankValidator"], + ["resolveAccount", compileFinal { + params [["_uid", "", [""]]]; + + private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) then { + throw "Bank account data is unavailable."; + }; + + _account + }], + ["validateDeposit", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + private _context = createHashMap; + + [_uid, _amount] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + if (_amount <= 0) then { throw "Enter a valid deposit amount." }; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + if ((_session getOrDefault ["mode", "bank"]) isEqualTo "atm") then { + if !(_session getOrDefault ["atmAuthorized", false]) then { + throw "ATM authorization is required before deposit."; + }; + }; + + private _account = _self call ["resolveAccount", [_uid]]; + private _bank = _account getOrDefault ["bank", 0]; + private _cash = _account getOrDefault ["cash", 0]; + + if (_cash < _amount) then { throw "Cash on hand cannot cover that deposit." }; + + _context set ["account", _account]; + _context set ["bank", _bank]; + _context set ["cash", _cash]; + } catch { + ["ERROR", format ["Deposit validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }], + ["validateWithdraw", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + private _context = createHashMap; + + [_uid, _amount] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + if (_amount <= 0) then { throw "Enter a valid withdrawal amount." }; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + if ((_session getOrDefault ["mode", "bank"]) isEqualTo "atm") then { + if !(_session getOrDefault ["atmAuthorized", false]) then { + throw "ATM authorization is required before withdrawal."; + }; + }; + + private _account = _self call ["resolveAccount", [_uid]]; + private _bank = _account getOrDefault ["bank", 0]; + private _cash = _account getOrDefault ["cash", 0]; + + if (_bank < _amount) then { throw "Bank balance cannot cover that withdrawal." }; + + _context set ["account", _account]; + _context set ["bank", _bank]; + _context set ["cash", _cash]; + } catch { + ["ERROR", format ["Withdraw validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }], + ["validateTransfer", compileFinal { + params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; + + private _context = createHashMap; + + [_uid, _target, _from, _amount] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + if (_uid isEqualTo _target) then { throw "You cannot transfer funds to yourself." }; + if (_amount <= 0) then { throw "Enter a valid transfer amount." }; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "bank") then { + throw "Transfers are only available from the full bank interface."; + }; + + private _account = _self call ["resolveAccount", [_uid]]; + + private _targetAccount = GVAR(Registry) getOrDefault [_target, createHashMap]; + if (_targetAccount isEqualTo createHashMap) then { + _targetAccount = GVAR(BankStore) call ["init", [_target]]; + }; + if (_targetAccount isEqualTo createHashMap) then { + throw "Selected transfer recipient is unavailable."; + }; + + private _sourceField = ["bank", "cash"] select (toLowerANSI _from isEqualTo "cash"); + private _selected = _account getOrDefault [_sourceField, 0]; + if (_selected < _amount) then { + private _message = [ + "Bank balance cannot cover that transfer.", + "Cash on hand cannot cover that transfer." + ] select (_sourceField isEqualTo "cash"); + throw _message; + }; + + _context set ["account", _account]; + _context set ["targetAccount", _targetAccount]; + _context set ["sourceField", _sourceField]; + _context set ["sourceBalance", _selected]; + _context set ["targetBank", _targetAccount getOrDefault ["bank", 0]]; + } catch { + ["ERROR", format ["Transfer validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }], + ["validateDepositEarnings", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + private _context = createHashMap; + + [_uid, _amount] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "bank") then { + throw "Earnings deposits are only available from the full bank interface."; + }; + + if (_amount <= 0) then { throw "No earnings are available to deposit." }; + + private _account = _self call ["resolveAccount", [_uid]]; + private _bank = _account getOrDefault ["bank", 0]; + private _earnings = _account getOrDefault ["earnings", 0]; + + if (_earnings < _amount) then { throw "Pending earnings cannot cover that deposit request." }; + + _context set ["account", _account]; + _context set ["bank", _bank]; + _context set ["earnings", _earnings]; + } catch { + ["ERROR", format ["DepositEarnings validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }], + ["validatePayment", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + private _context = createHashMap; + + [_uid, _amount] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + if (_amount <= 0) then { throw "Enter a valid payment amount." }; + + private _account = _self call ["resolveAccount", [_uid]]; + private _bank = _account getOrDefault ["bank", 0]; + + _context set ["account", _account]; + _context set ["bank", _bank]; + } catch { + ["ERROR", format ["Payment validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }], + ["validateSubmitPin", compileFinal { + params [["_uid", "", [""]], ["_pin", "", [""]]]; + + private _context = createHashMap; + + [_uid, _pin] try { + if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "atm") then { + _session = GVAR(BankSessionManager) call ["setSessionState", [_uid, createHashMapFromArray [ + ["atmAuthorized", false], + ["mode", "atm"] + ]]]; + }; + + private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) then { + _account = GVAR(BankStore) call ["init", [_uid]]; + }; + if (_account isEqualTo createHashMap) then { + throw "Bank account data is unavailable."; + }; + + private _enteredPin = _pin; + if !(_enteredPin isEqualType "") then { + _enteredPin = str _enteredPin; + }; + if ((count _enteredPin) isNotEqualTo 4) then { + throw "Enter your four-digit access PIN."; + }; + + private _accountPin = str (_account getOrDefault ["pin", 1234]); + if (_enteredPin isNotEqualTo _accountPin) then { + GVAR(BankSessionManager) call ["setSessionState", [_uid, createHashMapFromArray [["atmAuthorized", false]]]]; + throw "Incorrect PIN."; + }; + + _context set ["account", _account]; + _context set ["session", _session]; + } catch { + ["ERROR", format ["SubmitPin validation failed: %1", _exception]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; + GVAR(BankStore) call ["hydrateSession", [_uid, "atm", false]]; + }; + + if (_context isEqualTo createHashMap) exitWith { false }; + _context + }] +]]; + +GVAR(BankValidator) diff --git a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf index 352c940..3a9c5bf 100644 --- a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf @@ -91,33 +91,33 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ private _category = toLowerANSI (_x getOrDefault ["category", ""]); private _quantity = floor ((_x getOrDefault ["quantity", 0]) max 0); private _lockerCategory = switch (_category) do { - case "item": { "item" }; + case "item"; + case "attachment": { "item" }; case "weapon": { "weapon" }; case "magazine": { "magazine" }; case "backpack": { "backpack" }; default { "" }; }; - if (_className isEqualTo "" || { _lockerCategory isEqualTo "" } || { _quantity <= 0 }) exitWith { - _result set ["message", "Checkout item was missing a valid classname, category, or quantity."]; - _result set ["success", false]; + if (_className isEqualTo "" || { _lockerCategory isEqualTo "" } || { _quantity <= 0 }) then { + ["WARN", format ["Skipping invalid locker grant entry: %1 (category: %2)", _className, _category]] call EFUNC(common,log); + } else { + private _entry = +(_locker getOrDefault [_className, createHashMap]); + private _amount = _entry getOrDefault ["amount", 0]; + private _updatedEntry = createHashMapFromArray [ + ["amount", (_amount + _quantity)], + ["classname", _className], + ["category", _lockerCategory] + ]; + + _locker set [_className, _updatedEntry]; + _patch set [_className, _updatedEntry]; + _granted pushBack (createHashMapFromArray [ + ["classname", _className], + ["category", _lockerCategory], + ["quantity", _quantity] + ]); }; - - private _entry = +(_locker getOrDefault [_className, createHashMap]); - private _amount = _entry getOrDefault ["amount", 0]; - private _updatedEntry = createHashMapFromArray [ - ["amount", (_amount + _quantity)], - ["classname", _className], - ["category", _lockerCategory] - ]; - - _locker set [_className, _updatedEntry]; - _patch set [_className, _updatedEntry]; - _granted pushBack (createHashMapFromArray [ - ["classname", _className], - ["category", _lockerCategory], - ["quantity", _quantity] - ]); } forEach _items; if ((count (keys _locker)) > 25) exitWith { diff --git a/arma/server/addons/main/functions/fnc_initStores.sqf b/arma/server/addons/main/functions/fnc_initStores.sqf index 9093c32..1d37eb8 100644 --- a/arma/server/addons/main/functions/fnc_initStores.sqf +++ b/arma/server/addons/main/functions/fnc_initStores.sqf @@ -23,7 +23,11 @@ if (isNil QEGVAR(common,BaseStore)) then { call EFUNC(common,baseStore); }; if (isNil QEGVAR(actor,ActorStore)) then { call EFUNC(actor,initActorStore); }; // Bank -if (isNil QEGVAR(bank,BankStore)) then { call EFUNC(bank,initBankStore); }; +if (isNil QEGVAR(bank,BankSessionManager)) then { call EFUNC(bank,initSessionManager); }; +if (isNil QEGVAR(bank,BankMessenger)) then { call EFUNC(bank,initMessenger); }; +if (isNil QEGVAR(bank,BankModel)) then { call EFUNC(bank,initModel); }; +if (isNil QEGVAR(bank,BankStore)) then { call EFUNC(bank,initStore); }; +if (isNil QEGVAR(bank,BankValidator)) then { call EFUNC(bank,initValidator); }; // Garage if (isNil QEGVAR(garage,GarageStore)) then { call EFUNC(garage,initGarageStore); }; From db565d1e5193c731fe94cb30db02315439da1dac Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Wed, 25 Mar 2026 20:29:51 -0500 Subject: [PATCH 02/19] Refactor module hydration and init flow --- arma/client/addons/actor/XEH_preStart.sqf | 1 - .../actor/functions/fnc_initActorClass.sqf | 3 +- .../client/addons/bank/XEH_postInitClient.sqf | 11 +- arma/client/addons/bank/XEH_preStart.sqf | 1 - .../addons/bank/functions/fnc_initClass.sqf | 40 +---- .../bank/functions/fnc_initUIBridge.sqf | 8 +- arma/client/addons/bank/ui/_site/bank-ui.js | 2 +- arma/client/addons/bank/ui/src/bridge.js | 1 + arma/client/addons/common/XEH_preStart.sqf | 1 - .../addons/garage/functions/fnc_initClass.sqf | 3 +- .../garage/functions/fnc_initVGClass.sqf | 3 +- .../locker/functions/fnc_initLockerClass.sqf | 3 +- .../locker/functions/fnc_initVAClass.sqf | 3 +- .../notifications/XEH_postInitClient.sqf | 2 +- .../addons/notifications/XEH_preStart.sqf | 1 - arma/client/addons/org/XEH_postInitClient.sqf | 14 +- arma/client/addons/org/XEH_preStart.sqf | 1 - .../addons/org/functions/fnc_initClass.sqf | 165 ++---------------- .../addons/org/functions/fnc_initUIBridge.sqf | 57 ++++-- arma/client/addons/store/XEH_PREP.hpp | 2 - .../addons/store/XEH_postInitClient.sqf | 7 +- .../store/functions/fnc_buildUIPayload.sqf | 125 ------------- .../addons/store/functions/fnc_initClass.sqf | 42 ----- .../store/functions/fnc_initUIBridge.sqf | 25 ++- arma/server/addons/main/XEH_preInit.sqf | 10 ++ arma/server/addons/org/XEH_preInit.sqf | 18 ++ .../addons/org/functions/fnc_initOrgStore.sqf | 112 ++++++++++++ arma/server/addons/store/XEH_preInit.sqf | 20 +++ .../store/functions/fnc_initStoreStore.sqf | 121 +++++++++++++ 29 files changed, 397 insertions(+), 405 deletions(-) delete mode 100644 arma/client/addons/store/functions/fnc_buildUIPayload.sqf delete mode 100644 arma/client/addons/store/functions/fnc_initClass.sqf diff --git a/arma/client/addons/actor/XEH_preStart.sqf b/arma/client/addons/actor/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/actor/XEH_preStart.sqf +++ b/arma/client/addons/actor/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/actor/functions/fnc_initActorClass.sqf b/arma/client/addons/actor/functions/fnc_initActorClass.sqf index 729b790..294d20f 100644 --- a/arma/client/addons/actor/functions/fnc_initActorClass.sqf +++ b/arma/client/addons/actor/functions/fnc_initActorClass.sqf @@ -4,7 +4,7 @@ * File: fnc_initActorClass.sqf * Author: IDSolutions * Date: 2026-01-28 - * Last Update: 2026-02-17 + * Last Update: 2026-03-25 * Public: Yes * * Description: @@ -33,6 +33,7 @@ GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ ["init", compileFinal { private _uid = _self get "uid"; [SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["Actor loaded for %1", (name player)]; diag_log "[FORGE:Client:Actor] Actor Class Initialized!"; diff --git a/arma/client/addons/bank/XEH_postInitClient.sqf b/arma/client/addons/bank/XEH_postInitClient.sqf index b779e98..5eb9fc0 100644 --- a/arma/client/addons/bank/XEH_postInitClient.sqf +++ b/arma/client/addons/bank/XEH_postInitClient.sqf @@ -10,7 +10,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseInitBank), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(BankClass) call ["sync", [_data, true]]; + GVAR(BankClass) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["refreshSession", []]; }; @@ -19,7 +19,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseSyncBank), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - GVAR(BankClass) call ["sync", [_data, _jip]]; + GVAR(BankClass) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["refreshSession", []]; }; @@ -34,10 +34,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [QGVAR(responseBankNotice), { - params [ - ["_type", "error", [""]], - ["_message", "", [""]] - ]; + params [["_type", "error", [""]], ["_message", "", [""]]]; if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["handleNoticeResponse", [_type, _message]]; @@ -45,7 +42,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [{ - getPlayerUID player isNotEqualTo ""; + EGVAR(actor,ActorClass) get "isLoaded"; }, { [QGVAR(initBank), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/bank/XEH_preStart.sqf b/arma/client/addons/bank/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/bank/XEH_preStart.sqf +++ b/arma/client/addons/bank/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/bank/functions/fnc_initClass.sqf b/arma/client/addons/bank/functions/fnc_initClass.sqf index 2a46590..1643c0d 100644 --- a/arma/client/addons/bank/functions/fnc_initClass.sqf +++ b/arma/client/addons/bank/functions/fnc_initClass.sqf @@ -6,7 +6,7 @@ * Public: No * * Description: - * Initializes the bank class for account sync and access helpers. + * Initializes the bank class for lifecycle and save helpers. */ #pragma hemtt ignore_variables ["_self"] @@ -14,46 +14,24 @@ GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ ["#type", "BankBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; - _self set ["account", createHashMapFromArray [ - ["bank", 0], - ["cash", 0], - ["earnings", 0], - ["transactions", []] - ]]; _self set ["isLoaded", false]; _self set ["lastSave", time]; }], - ["getAccountState", compileFinal { - _self getOrDefault ["account", createHashMap] - }], - ["get", compileFinal { - params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; - - private _account = _self getOrDefault ["account", createHashMap]; - _account getOrDefault [_key, _default] - }], ["init", compileFinal { [SRPC(bank,requestInitBank), [getPlayerUID player]] call CFUNC(serverEvent); _self set ["lastSave", time]; + + systemChat format ["Bank loaded for %1", (name player)]; + diag_log "[FORGE:Client:Bank] Bank Class Initialized!"; + }], + ["markLoaded", compileFinal { + if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; + + true }], ["save", compileFinal { [SRPC(bank,requestSaveBank), [getPlayerUID player]] call CFUNC(serverEvent); _self set ["lastSave", time]; - }], - ["sync", compileFinal { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - - private _account = _self getOrDefault ["account", createHashMap]; - { - _account set [_x, _y]; - } forEach _data; - - _self set ["account", _account]; - if !(_self getOrDefault ["isLoaded", false]) then { - _self set ["isLoaded", true]; - }; - - true }] ]; diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf index 3b7a0d6..4d82ee9 100644 --- a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -79,9 +79,7 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ params [["_data", createHashMap, [createHashMap]]]; private _pin = _data getOrDefault ["pin", ""]; - if !(_pin isEqualType "") then { - _pin = str _pin; - }; + if !(_pin isEqualType "") then { _pin = str _pin; }; [SRPC(bank,requestSubmitPin), [getPlayerUID player, _pin]] call CFUNC(serverEvent); true @@ -128,9 +126,7 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ params [["_mode", "bank", [""]]]; private _finalMode = toLowerANSI _mode; - if !(_finalMode in ["bank", "atm"]) then { - _finalMode = "bank"; - }; + if !(_finalMode in ["bank", "atm"]) then { _finalMode = "bank"; }; _self set ["mode", _finalMode]; _finalMode diff --git a/arma/client/addons/bank/ui/_site/bank-ui.js b/arma/client/addons/bank/ui/_site/bank-ui.js index f4f99bd..ace527e 100644 --- a/arma/client/addons/bank/ui/_site/bank-ui.js +++ b/arma/client/addons/bank/ui/_site/bank-ui.js @@ -1 +1 @@ -!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={atmAuthorized:!1,mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=Boolean(n?.session?.atmAuthorized),a=this.getMode(),s=this.getAtmView(),i=this.getPendingAction();if(this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"===e)return t?"deposit"===i||"withdraw"===i||"pin"===s||"atm"!==a?void this.setAtmView("menu"):void this.setAtmView(s):void this.setAtmView("pin");this.setAtmView("dashboard")}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});function a(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}t.on("bank::hydrate",a),t.on("bank::sync",a),t.on("bank::notice",e=>{n.actions&&n.actions.showNotice(e.type||"error",e.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestSubmitPin:n=>t.send("bank::pin::request",n),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function s(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function i(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestDeposit)return s("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!o.requestDeposit({amount:i})||(e.finishAction(),s("error","Deposit bridge is unavailable."),!1)}function o(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestWithdraw)return s("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!o.requestWithdraw({amount:i})||(e.finishAction(),s("error","Withdraw bridge is unavailable."),!1)}function r(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:r,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return s("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return s("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,e){return"deposit"===String(n||"").trim().toLowerCase()?i(e):o(e)},requestDeposit:i,requestDepositEarnings:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestDepositEarnings?(e.startAction("depositearnings"),!!o.requestDepositEarnings({amount:i})||(e.finishAction(),s("error","Earnings bridge is unavailable."),!1)):(s("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,i){const o=a(i),r=String(t||"").trim(),c=n.bridge;return c&&"function"==typeof c.requestTransfer?(e.startAction("transfer"),!!c.requestTransfer({amount:o,from:"bank",target:r})||(e.finishAction(),s("error","Transfer bridge is unavailable."),!1)):(s("error","Transfer bridge is unavailable."),!1)},requestWithdraw:o,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:s,submitCustomAmount:function(n){const t=a(e.getCustomAmount()),r=String(n||"").trim().toLowerCase();if(t<=0)return s("error","Enter a valid transaction amount."),!1;const c="deposit"===r?i(t):o(t);return c&&e.setCustomAmount(""),c},submitPin:function(){const t=String(e.getEnteredPin()||""),a=n.bridge;return a&&"function"==typeof a.requestSubmitPin?(e.startAction("pin"),a.requestSubmitPin({pin:t})?(r(),!0):(e.finishAction(),s("error","PIN bridge is unavailable."),!1)):(s("error","PIN bridge is unavailable."),!1)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file +!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={atmAuthorized:!1,mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=Boolean(n?.session?.atmAuthorized),a=this.getMode(),s=this.getAtmView(),i=this.getPendingAction();if(this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"===e)return t?"deposit"===i||"withdraw"===i||"pin"===s||"atm"!==a?void this.setAtmView("menu"):void this.setAtmView(s):void this.setAtmView("pin");this.setAtmView("dashboard")}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});function a(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}t.on("bank::hydrate",a),t.on("bank::sync",a),t.on("bank::notice",t=>{e.finishAction(),n.actions&&n.actions.showNotice(t.type||"error",t.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestSubmitPin:n=>t.send("bank::pin::request",n),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function s(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function i(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestDeposit)return s("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!o.requestDeposit({amount:i})||(e.finishAction(),s("error","Deposit bridge is unavailable."),!1)}function o(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestWithdraw)return s("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!o.requestWithdraw({amount:i})||(e.finishAction(),s("error","Withdraw bridge is unavailable."),!1)}function r(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:r,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return s("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return s("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,e){return"deposit"===String(n||"").trim().toLowerCase()?i(e):o(e)},requestDeposit:i,requestDepositEarnings:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestDepositEarnings?(e.startAction("depositearnings"),!!o.requestDepositEarnings({amount:i})||(e.finishAction(),s("error","Earnings bridge is unavailable."),!1)):(s("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,i){const o=a(i),r=String(t||"").trim(),c=n.bridge;return c&&"function"==typeof c.requestTransfer?(e.startAction("transfer"),!!c.requestTransfer({amount:o,from:"bank",target:r})||(e.finishAction(),s("error","Transfer bridge is unavailable."),!1)):(s("error","Transfer bridge is unavailable."),!1)},requestWithdraw:o,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:s,submitCustomAmount:function(n){const t=a(e.getCustomAmount()),r=String(n||"").trim().toLowerCase();if(t<=0)return s("error","Enter a valid transaction amount."),!1;const c="deposit"===r?i(t):o(t);return c&&e.setCustomAmount(""),c},submitPin:function(){const t=String(e.getEnteredPin()||""),a=n.bridge;return a&&"function"==typeof a.requestSubmitPin?(e.startAction("pin"),a.requestSubmitPin({pin:t})?(r(),!0):(e.finishAction(),s("error","PIN bridge is unavailable."),!1)):(s("error","PIN bridge is unavailable."),!1)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/bank/ui/src/bridge.js b/arma/client/addons/bank/ui/src/bridge.js index 41f872b..02d3113 100644 --- a/arma/client/addons/bank/ui/src/bridge.js +++ b/arma/client/addons/bank/ui/src/bridge.js @@ -15,6 +15,7 @@ bridge.on("bank::hydrate", hydrate); bridge.on("bank::sync", hydrate); bridge.on("bank::notice", (payloadData) => { + store.finishAction(); if (BankApp.actions) { BankApp.actions.showNotice( payloadData.type || "error", diff --git a/arma/client/addons/common/XEH_preStart.sqf b/arma/client/addons/common/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/common/XEH_preStart.sqf +++ b/arma/client/addons/common/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/garage/functions/fnc_initClass.sqf b/arma/client/addons/garage/functions/fnc_initClass.sqf index 841550b..6bb114e 100644 --- a/arma/client/addons/garage/functions/fnc_initClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initClass.sqf @@ -4,7 +4,7 @@ * File: fnc_initClass.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-03-25 * Public: No * * Description: @@ -35,6 +35,7 @@ GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ private _garage = _self get "garage"; [SRPC(garage,requestInitGarage), [_uid, _garage]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["Garage loaded for %1", (name player)]; diag_log "[FORGE:Client:Garage] Garage Class Initialized!"; diff --git a/arma/client/addons/garage/functions/fnc_initVGClass.sqf b/arma/client/addons/garage/functions/fnc_initVGClass.sqf index f3901f9..e5370d7 100644 --- a/arma/client/addons/garage/functions/fnc_initVGClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initVGClass.sqf @@ -4,7 +4,7 @@ * File: fnc_initVGClass.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2026-02-13 + * Last Update: 2026-03-25 * Public: No * * Description: @@ -37,6 +37,7 @@ GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ private _vGarage = _self get "vGarage"; [SRPC(garage,requestInitVG), [_uid, _vGarage]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["VGarage loaded for %1", (name player)]; diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!"; diff --git a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf b/arma/client/addons/locker/functions/fnc_initLockerClass.sqf index 68c5a94..4012c52 100644 --- a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initLockerClass.sqf @@ -4,7 +4,7 @@ * File: fnc_initLockerClass.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-03-25 * Public: No * * Description: @@ -34,6 +34,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ private _uid = _self get "uid"; [SRPC(locker,requestInitLocker), [_uid]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["Locker loaded for %1", (name player)]; diag_log "[FORGE:Client:Locker] Locker Class Initialized!"; diff --git a/arma/client/addons/locker/functions/fnc_initVAClass.sqf b/arma/client/addons/locker/functions/fnc_initVAClass.sqf index 5711d63..d6c0749 100644 --- a/arma/client/addons/locker/functions/fnc_initVAClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initVAClass.sqf @@ -4,7 +4,7 @@ * File: fnc_init.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2026-02-13 + * Last Update: 2026-03-25 * Public: No * * Description: @@ -34,6 +34,7 @@ GVAR(VABaseClass) = compileFinal createHashMapFromArray [ private _uid = _self get "uid"; FORGE_Locker_Box = "ReammoBox_F" createVehicleLocal [0, 0, -999]; [SRPC(locker,requestInitVA), [_uid]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["VArsenal loaded for %1", (name player)]; diag_log "[FORGE:Client:VArsenal] VArsenal Class Initialized!"; diff --git a/arma/client/addons/notifications/XEH_postInitClient.sqf b/arma/client/addons/notifications/XEH_postInitClient.sqf index 6eada6a..9981181 100644 --- a/arma/client/addons/notifications/XEH_postInitClient.sqf +++ b/arma/client/addons/notifications/XEH_postInitClient.sqf @@ -1,7 +1,7 @@ #include "script_component.hpp" [{ - EGVAR(actor,ActorClass) get "isLoaded"; + EGVAR(locker,VAClass) get "isLoaded"; }, { ("NotificationHudLayer" call BFUNC(rscLayer)) cutRsc ["RscNotifications", "PLAIN"]; call FUNC(openUI); diff --git a/arma/client/addons/notifications/XEH_preStart.sqf b/arma/client/addons/notifications/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/notifications/XEH_preStart.sqf +++ b/arma/client/addons/notifications/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index 3f9e4d9..1d0d2c2 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -3,24 +3,24 @@ if (isNil QGVAR(OrgClass)) then { call FUNC(initClass); }; if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; -[QGVAR(initOrg), { - GVAR(OrgClass) call ["init", []]; -}] call CFUNC(addEventHandler); - [QGVAR(responseInitOrg), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(OrgClass) call ["sync", [_data, true]]; GVAR(OrgUIBridge) call ["refreshPortal", []]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncOrg), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - GVAR(OrgClass) call ["sync", [_data, _jip]]; GVAR(OrgUIBridge) call ["refreshPortal", []]; }] call CFUNC(addEventHandler); +[QGVAR(responseHydrateOrg), { + params [["_payload", createHashMap, [createHashMap]], ["_bridgeEvent", "org::sync", [""]]]; + + GVAR(OrgUIBridge) call ["handleHydrateResponse", [_payload, _bridgeEvent]]; +}] call CFUNC(addEventHandler); + [QGVAR(responseCreateOrg), { params [["_payload", createHashMap, [createHashMap]]]; @@ -46,7 +46,7 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [{ - EGVAR(actor,ActorClass) get "isLoaded"; + EGVAR(locker,VAClass) get "isLoaded"; }, { [QGVAR(initOrg), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/org/XEH_preStart.sqf b/arma/client/addons/org/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/org/XEH_preStart.sqf +++ b/arma/client/addons/org/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/org/functions/fnc_initClass.sqf b/arma/client/addons/org/functions/fnc_initClass.sqf index dab354d..52695a5 100644 --- a/arma/client/addons/org/functions/fnc_initClass.sqf +++ b/arma/client/addons/org/functions/fnc_initClass.sqf @@ -3,21 +3,21 @@ /* * File: fnc_initClass.sqf * Author: IDSolutions - * Date: 2026-02-13 - * Last Update: 2026-02-13 + * Date: 2026-03-25 + * Last Update: 2026-03-25 * Public: No * * Description: - * Initializes the org class. + * No description added yet. * - * Arguments: - * None + * Parameter(s): + * N/A * - * Return Value: - * Org class object [HASHMAP OBJECT] + * Returns: + * Something [BOOL] * - * Examples: - * call forge_client_org_fnc_initClass + * Example(s): + * [parameter] call forge_x_component_fnc_myFunction */ #pragma hemtt ignore_variables ["_self"] @@ -25,155 +25,24 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ ["#type", "OrgBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; - _self set ["org", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; - - private _org = createHashMap; - _org set ["id", ""]; - _org set ["owner", ""]; - _org set ["name", ""]; - _org set ["funds", 0]; - _org set ["reputation", 0]; - _org set ["credit_lines", createHashMap]; - _org set ["assets", createHashMap]; - _org set ["fleet", createHashMap]; - _org set ["members", createHashMap]; - - _self set ["org", _org]; }], ["init", compileFinal { - private _uid = _self get "uid"; - private _org = _self get "org"; - - [SRPC(org,requestInitOrg), [_uid, _org]] call CFUNC(serverEvent); + [SRPC(org,requestInitOrg), [getPlayerUID player]] call CFUNC(serverEvent); + _self set ["lastSave", time]; systemChat format ["Org loaded for %1", (name player)]; diag_log "[FORGE:Client:Org] Org Class Initialized!"; }], + ["markLoaded", compileFinal { + if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; + + true + }], ["save", compileFinal { - params [["_sync", false, [false]]]; - - private _uid = _self get "uid"; - [SRPC(org,requestSaveOrg), [_uid, _sync]] call CFUNC(serverEvent); - + [SRPC(bank,requestSaveOrg), [getPlayerUID player]] call CFUNC(serverEvent); _self set ["lastSave", time]; - }], - ["sync", compileFinal { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - - private _isLoaded = _self get "isLoaded"; - private _org = _self get "org"; - - { _org set [_x, _y]; } forEach _data; - _self set ["org", _org]; - - if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:Org] Sync completed"; - }], - ["buildPortalPayload", compileFinal { - private _orgData = _self get "org"; - - private _name = _orgData get "name"; - private _id = _orgData get "id"; - private _ownerUid = _orgData get "owner"; - private _funds = _orgData get "funds"; - private _reputation = _orgData get "reputation"; - private _creditLinesRaw = _orgData getOrDefault ["credit_lines", createHashMap]; - private _assetsRaw = _orgData get "assets"; - private _fleetRaw = _orgData get "fleet"; - private _membersRaw = _orgData get "members"; - private _isDefaultOrg = (_orgData getOrDefault ["default", false]) - || {toLower _id isEqualTo "default"} - || {toLower _ownerUid isEqualTo "server"}; - - private _playerName = name player; - private _playerUid = getPlayerUID player; - private _playerVar = vehicleVarName player; - private _sessionRole = "Member"; - private _sessionIsCeo = _isDefaultOrg && {_playerVar isEqualTo "ceo"}; - private _ownerName = ["", "Server"] select (toLower _ownerUid isEqualTo "server"); - - private _membersList = []; - { - private _memberData = _y; - private _memberName = _memberData getOrDefault ["name", "Unknown"]; - private _memberUid = _memberData getOrDefault ["uid", ""]; - - if (_memberUid isEqualTo _ownerUid && {_ownerName isEqualTo ""}) then { _ownerName = _memberName; }; - if (_memberUid isEqualTo _playerUid) then { _sessionRole = "Member"; }; - - _membersList pushBack (createHashMapFromArray [ - ["uid", _memberUid], - ["name", _memberName] - ]); - } forEach _membersRaw; - - if (_ownerName isEqualTo "" && { _ownerUid isEqualTo _playerUid }) then { _ownerName = _playerName; }; - if (_ownerName isEqualTo "" && { _ownerUid isNotEqualTo "" }) then { _ownerName = "Unknown Owner"; }; - if (_ownerUid isEqualTo _playerUid) then { _sessionRole = "Leader"; }; - - private _assetsList = []; - { - private _assetData = _y; - _assetsList pushBack (createHashMapFromArray [ - ["name", _assetData getOrDefault ["name", "Unknown Asset"]], - ["type", _assetData getOrDefault ["type", "items"]], - ["quantity", str (_assetData getOrDefault ["quantity", 0])] - ]); - } forEach _assetsRaw; - - private _fleetList = []; - { - private _vehicleData = _y; - _fleetList pushBack (createHashMapFromArray [ - ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], - ["type", _vehicleData getOrDefault ["type", "other"]], - ["status", _vehicleData getOrDefault ["status", "Unknown"]], - ["damage", _vehicleData getOrDefault ["damage", "0%"]] - ]); - } forEach _fleetRaw; - - private _creditLinesList = []; - { - private _creditLineData = _y; - _creditLinesList pushBack (createHashMapFromArray [ - ["uid", _creditLineData getOrDefault ["uid", _x]], - ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], - ["amount", _creditLineData getOrDefault ["amount", 0]] - ]); - } forEach _creditLinesRaw; - - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["actorName", _playerName], - ["actorUid", _playerUid], - ["role", _sessionRole], - ["ceo", _sessionIsCeo] - ]], - ["portalData", createHashMapFromArray [ - ["org", createHashMapFromArray [ - ["name", _name], - ["tag", _id], - ["owner", _ownerName], - ["ownerUid", _ownerUid], - ["isDefault", _isDefaultOrg] - ]], - ["funds", _funds], - ["reputation", _reputation], - ["creditLines", _creditLinesList], - ["members", _membersList], - ["fleet", _fleetList], - ["assets", _assetsList], - ["activity", []] - ]] - ] - }], - ["get", compileFinal { - params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; - - private _org = _self get "org"; - _org getOrDefault [_key, _default]; }] ]; diff --git a/arma/client/addons/org/functions/fnc_initUIBridge.sqf b/arma/client/addons/org/functions/fnc_initUIBridge.sqf index cfc5875..a5593df 100644 --- a/arma/client/addons/org/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/org/functions/fnc_initUIBridge.sqf @@ -50,20 +50,42 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["setActiveBrowserControl", [_control]]; _control }], + ["hasOpenScreen", compileFinal { + private _screen = _self call ["getScreen", []]; + private _control = _self call ["getActiveBrowserControl", []]; + + !(isNull _control) && { _screen call ["isReady", []] } + }], + ["requestHydrate", compileFinal { + params [["_bridgeEvent", "org::sync", [""]]]; + + if !(_self call ["hasOpenScreen", []]) exitWith { false }; + + private _event = _bridgeEvent; + if !(_event in ["org::login::success", "org::create::success", "org::sync"]) then { + _event = "org::sync"; + }; + + [SRPC(org,requestHydrateOrg), [getPlayerUID player, _event]] call CFUNC(serverEvent); + true + }], + ["handleHydrateResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]], ["_bridgeEvent", "org::sync", [""]]]; + + if !(_self call ["hasOpenScreen", []]) exitWith { false }; + + private _event = _bridgeEvent; + if !(_event in ["org::login::success", "org::create::success", "org::sync"]) then { + _event = "org::sync"; + }; + + _self call ["sendEvent", [_event, _payload, _self call ["getActiveBrowserControl", []]]] + }], ["handleLoginRequest", compileFinal { params [["_control", controlNull, [controlNull]]]; - private _orgData = GVAR(OrgClass) get "org"; - private _orgId = _orgData getOrDefault ["id", ""]; - private _orgName = _orgData getOrDefault ["name", ""]; - - if (_orgId isEqualTo "" && { _orgName isEqualTo "" }) exitWith { - _self call ["sendEvent", ["org::login::failure", createHashMapFromArray [ - ["message", "No organization data is available for this player."] - ], _control]]; - }; - - _self call ["sendEvent", ["org::login::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; + _self call ["setActiveBrowserControl", [_control]]; + _self call ["requestHydrate", ["org::login::success"]]; }], ["handleCreateRequest", compileFinal { params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; @@ -91,11 +113,11 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ], _control]]; }; - private _orgData = _payload getOrDefault ["org", createHashMap]; - GVAR(OrgClass) call ["sync", [_orgData, true]]; + if !(isNull _control) then { + _self call ["setActiveBrowserControl", [_control]]; + }; - if (isNull _control) exitWith {}; - _self call ["sendEvent", ["org::create::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; + _self call ["requestHydrate", ["org::create::success"]]; }], ["handleDisbandResponse", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; @@ -155,10 +177,7 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ [SRPC(org,requestAssignCreditLine), [getPlayerUID player, _memberUid, _memberName, _amount]] call CFUNC(serverEvent); }], ["refreshPortal", compileFinal { - private _control = _self call ["getActiveBrowserControl", []]; - if (isNull _control) exitWith { false }; - - _self call ["sendEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]] + _self call ["requestHydrate", ["org::sync"]] }] ]; diff --git a/arma/client/addons/store/XEH_PREP.hpp b/arma/client/addons/store/XEH_PREP.hpp index 868066a..339e665 100644 --- a/arma/client/addons/store/XEH_PREP.hpp +++ b/arma/client/addons/store/XEH_PREP.hpp @@ -1,5 +1,3 @@ -PREP(buildUIPayload); PREP(handleUIEvents); -PREP(initClass); PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/store/XEH_postInitClient.sqf b/arma/client/addons/store/XEH_postInitClient.sqf index ac1eb81..44eb8c8 100644 --- a/arma/client/addons/store/XEH_postInitClient.sqf +++ b/arma/client/addons/store/XEH_postInitClient.sqf @@ -1,6 +1,5 @@ #include "script_component.hpp" -if (isNil QGVAR(StoreClass)) then { call FUNC(initClass); }; if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseCategory), { @@ -9,6 +8,12 @@ if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initUIBridge); }; GVAR(StoreUIBridge) call ["handleCategoryResponse", [_payload]]; }] call CFUNC(addEventHandler); +[QGVAR(responseHydrateStore), { + params [["_payload", createHashMap, [createHashMap]], ["_bridgeEvent", "store::hydrate", [""]]]; + + GVAR(StoreUIBridge) call ["handleHydrateResponse", [_payload, _bridgeEvent]]; +}] call CFUNC(addEventHandler); + [QGVAR(responseCheckout), { params [["_payload", createHashMap, [createHashMap]]]; diff --git a/arma/client/addons/store/functions/fnc_buildUIPayload.sqf b/arma/client/addons/store/functions/fnc_buildUIPayload.sqf deleted file mode 100644 index 3b748ce..0000000 --- a/arma/client/addons/store/functions/fnc_buildUIPayload.sqf +++ /dev/null @@ -1,125 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_buildUIPayload.sqf - * Author: IDSolutions - * Date: 2026-03-13 - * Public: No - * - * Description: - * Builds the browser hydrate payload for the store UI from current client state. - * - * Arguments: - * None - * - * Return Value: - * Store UI payload [HASHMAP] - */ - -private _storeState = createHashMap; -private _budget = 50000; -private _creditLine = 0; -private _cashBalance = 0; -private _bankBalance = 0; -private _orgFunds = 0; -private _orgId = ""; -private _orgName = ""; -private _orgOwnerUid = ""; -private _orgCreditLines = createHashMap; -private _playerUid = getPlayerUID player; -private _playerVar = toLowerANSI (vehicleVarName player); -private _isOrgLeader = false; -private _isDefaultOrg = false; -private _isDefaultOrgCeo = false; - -if !(isNil QGVAR(StoreClass)) then { - _storeState = GVAR(StoreClass) call ["getStoreState", []]; - _budget = _storeState getOrDefault ["budget", _budget]; -}; - -if !(isNil QEGVAR(bank,BankClass)) then { - _cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]]; - _bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]]; -}; - -if !(isNil QEGVAR(org,OrgClass)) then { - _orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]]; - _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; - _orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]]; - _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; - _orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]]; - _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; - _isOrgLeader = _orgOwnerUid isEqualTo _playerUid; - _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; -}; - -if (_orgCreditLines isEqualType createHashMap) then { - private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap]; - if (_playerCreditLine isEqualType createHashMap) then { - _creditLine = _playerCreditLine getOrDefault ["amount", 0]; - }; -}; - -private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; -private _orgFundsEnabled = _canUseOrgFunds && { _orgFunds > 0 }; -private _paymentSources = [ - createHashMapFromArray [ - ["id", "cash"], - ["label", "Cash"], - ["balance", _cashBalance], - ["enabled", _cashBalance > 0], - ["detail", "Use on-hand cash carried by the player."] - ], - createHashMapFromArray [ - ["id", "bank"], - ["label", "Bank"], - ["balance", _bankBalance], - ["enabled", _bankBalance > 0], - ["detail", "Charge the player bank account."] - ], - createHashMapFromArray [ - ["id", "org_funds"], - ["label", "Org Funds"], - ["balance", _orgFunds], - ["enabled", _orgFundsEnabled], - ["detail", [ - "Only organization leaders or the default-org CEO can use treasury funds.", - [ - "Charge organization treasury funds.", - "No organization funds are currently available." - ] select _orgFundsEnabled - ] select _canUseOrgFunds] - ], - createHashMapFromArray [ - ["id", "credit_line"], - ["label", "Credit Line"], - ["balance", _creditLine], - ["enabled", _creditLine > 0], - ["detail", [ - "No approved credit line is assigned to this member.", - "Use the approved procurement credit line." - ] select (_creditLine > 0)] - ] -]; - -createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["actorName", name player], - ["actorUid", _playerUid], - ["approval", "Field Access"], - ["orgId", _orgId], - ["orgName", _orgName], - ["orgLeader", _isOrgLeader], - ["defaultOrgCeo", _isDefaultOrgCeo], - ["canUseOrgFunds", _canUseOrgFunds] - ]], - ["storeConfig", createHashMapFromArray [ - ["budget", _budget], - ["creditLine", _creditLine], - ["availability", _storeState getOrDefault ["availability", "In-Stock"]], - ["moduleState", _storeState getOrDefault ["moduleState", "Preview"]], - ["paymentSources", _paymentSources], - ["defaultPaymentSource", "cash"] - ]], - ["cartItems", []] -] diff --git a/arma/client/addons/store/functions/fnc_initClass.sqf b/arma/client/addons/store/functions/fnc_initClass.sqf deleted file mode 100644 index f88d2ff..0000000 --- a/arma/client/addons/store/functions/fnc_initClass.sqf +++ /dev/null @@ -1,42 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_initClass.sqf - * Author: IDSolutions - * Date: 2026-01-28 - * Last Update: 2026-03-12 - * Public: Yes - * - * Description: - * Initializes the store class for managing store data. - * - * Arguments: - * None - * - * Return Value: - * Store class object [HASHMAP OBJECT] - * - * Example: - * call forge_client_store_fnc_initClass - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "StoreBaseClass"], - ["#create", compileFinal { - _self set ["uid", getPlayerUID player]; - _self set ["store", createHashMapFromArray [ - ["budget", 50000], - ["availability", "In-Stock"], - ["moduleState", "Preview"] - ]]; - _self set ["isLoaded", false]; - _self set ["lastSave", time]; - }], - ["getStoreState", compileFinal { - _self getOrDefault ["store", createHashMap] - }] -]; - -GVAR(StoreClass) = createHashMapObject [GVAR(StoreBaseClass)]; -GVAR(StoreClass) diff --git a/arma/client/addons/store/functions/fnc_initUIBridge.sqf b/arma/client/addons/store/functions/fnc_initUIBridge.sqf index 2e707d0..e4a1b3c 100644 --- a/arma/client/addons/store/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initUIBridge.sqf @@ -4,7 +4,7 @@ * File: fnc_initUIBridge.sqf * Author: IDSolutions * Date: 2026-03-10 - * Last Update: 2026-03-12 + * Last Update: 2026-03-25 * Public: No * * Description: @@ -47,8 +47,12 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]]]; - private _payload = call FUNC(buildUIPayload); - _self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]]; + private _uid = getPlayerUID player; + if (_uid isEqualTo "") exitWith { + _self call ["sendBridgeEvent", ["store::hydrate", createHashMap, _control]]; + }; + + [SRPC(store,requestHydrateStore), [_uid, "store::hydrate"]] call CFUNC(serverEvent); }], ["handleCategoryRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; @@ -79,8 +83,19 @@ GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["sendBridgeEvent", [_bridgeEvent, _payload]]; }], ["refreshStoreConfig", compileFinal { - private _payload = call FUNC(buildUIPayload); - _self call ["sendBridgeEvent", ["store::config::hydrate", _payload]]; + private _uid = getPlayerUID player; + if (_uid isEqualTo "") exitWith { false }; + + [SRPC(store,requestHydrateStore), [_uid, "store::config::hydrate"]] call CFUNC(serverEvent); + true + }], + ["handleHydrateResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]], ["_bridgeEvent", "store::hydrate", [""]]]; + + private _event = _bridgeEvent; + if !(_event in ["store::hydrate", "store::config::hydrate"]) then { _event = "store::hydrate"; }; + + _self call ["sendBridgeEvent", [_event, _payload]] }], ["handleCheckoutRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; diff --git a/arma/server/addons/main/XEH_preInit.sqf b/arma/server/addons/main/XEH_preInit.sqf index dcc00c2..5e34c8a 100644 --- a/arma/server/addons/main/XEH_preInit.sqf +++ b/arma/server/addons/main/XEH_preInit.sqf @@ -4,6 +4,8 @@ PREP_RECOMPILE_START; #include "XEH_PREP.hpp" PREP_RECOMPILE_END; +GVAR(PlayerBootstrapRegistry) = createHashMap; + ["forge_icom_event", { params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]]]; @@ -54,3 +56,11 @@ addMissionEventHandler ["ExtensionCallback", { }]); }; }]; + +addMissionEventHandler ["PlayerConnected", { + params ["_id", "_uid", "_name", "_jip", "_owner", "_idStr"]; +}]; + +addMissionEventHandler ["PlayerDisconnected", { + params ["_id", "_uid", "_name", "_jip", "_owner", "_idStr"]; +}]; diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 41fab57..807e24b 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -14,6 +14,24 @@ PREP_RECOMPILE_END; GVAR(OrgStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); +[QGVAR(requestHydrateOrg), { + params [["_uid", "", [""]], ["_bridgeEvent", "org::sync", [""]]]; + + if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; + + if !(_bridgeEvent in ["org::login::success", "org::create::success", "org::sync"]) then { + _bridgeEvent = "org::sync"; + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _payload = GVAR(OrgStore) call ["buildPortalPayload", [_uid]]; + if (_payload isEqualTo createHashMap) exitWith {}; + + [CRPC(org,responseHydrateOrg), [_payload, _bridgeEvent], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + [QGVAR(requestCreateOrg), { params [["_uid", "", [""]], ["_orgName", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 7e73c65..cd01078 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -167,6 +167,118 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["assignCreditLine", compileFinal { GVAR(OrgTreasuryService) call ["assignCreditLine", _this] }], + ["buildPortalPayload", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { createHashMap }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = _self call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) then { + _org = _self call ["init", [_uid]]; + }; + if (_org isEqualTo createHashMap) exitWith { createHashMap }; + + private _name = _org getOrDefault ["name", ""]; + private _id = _org getOrDefault ["id", _orgID]; + private _ownerUid = _org getOrDefault ["owner", ""]; + private _funds = _org getOrDefault ["funds", 0]; + private _reputation = _org getOrDefault ["reputation", 0]; + private _creditLinesRaw = _org getOrDefault ["credit_lines", createHashMap]; + private _assetsRaw = _org getOrDefault ["assets", createHashMap]; + private _fleetRaw = _org getOrDefault ["fleet", createHashMap]; + private _membersRaw = _org getOrDefault ["members", createHashMap]; + private _isDefaultOrg = (_org getOrDefault ["default", false]) + || { toLower _id isEqualTo "default" } + || { toLower _ownerUid isEqualTo "server" }; + + private _playerName = name _player; + private _playerVar = vehicleVarName _player; + private _sessionRole = "Member"; + private _sessionIsCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; + private _ownerName = ["", "Server"] select (toLower _ownerUid isEqualTo "server"); + + private _membersList = []; + { + private _memberData = _y; + private _memberName = _memberData getOrDefault ["name", "Unknown"]; + private _memberUid = _memberData getOrDefault ["uid", ""]; + + if (_memberUid isEqualTo _ownerUid && { _ownerName isEqualTo "" }) then { _ownerName = _memberName; }; + if (_memberUid isEqualTo _uid) then { _sessionRole = "Member"; }; + + _membersList pushBack (createHashMapFromArray [ + ["uid", _memberUid], + ["name", _memberName] + ]); + } forEach _membersRaw; + + if (_ownerName isEqualTo "" && { _ownerUid isEqualTo _uid }) then { _ownerName = _playerName; }; + if (_ownerName isEqualTo "" && { _ownerUid isNotEqualTo "" }) then { _ownerName = "Unknown Owner"; }; + if (_ownerUid isEqualTo _uid) then { _sessionRole = "Leader"; }; + + private _assetsList = []; + { + private _assetData = _y; + _assetsList pushBack (createHashMapFromArray [ + ["name", _assetData getOrDefault ["name", "Unknown Asset"]], + ["type", _assetData getOrDefault ["type", "items"]], + ["quantity", str (_assetData getOrDefault ["quantity", 0])] + ]); + } forEach _assetsRaw; + + private _fleetList = []; + { + private _vehicleData = _y; + _fleetList pushBack (createHashMapFromArray [ + ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], + ["type", _vehicleData getOrDefault ["type", "other"]], + ["status", _vehicleData getOrDefault ["status", "Unknown"]], + ["damage", _vehicleData getOrDefault ["damage", "0%"]] + ]); + } forEach _fleetRaw; + + private _creditLinesList = []; + { + private _creditLineData = _y; + _creditLinesList pushBack (createHashMapFromArray [ + ["uid", _creditLineData getOrDefault ["uid", _x]], + ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], + ["amount", _creditLineData getOrDefault ["amount", 0]] + ]); + } forEach _creditLinesRaw; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", _playerName], + ["actorUid", _uid], + ["role", _sessionRole], + ["ceo", _sessionIsCeo] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", _name], + ["tag", _id], + ["owner", _ownerName], + ["ownerUid", _ownerUid], + ["isDefault", _isDefaultOrg] + ]], + ["funds", _funds], + ["reputation", _reputation], + ["creditLines", _creditLinesList], + ["members", _membersList], + ["fleet", _fleetList], + ["assets", _assetsList], + ["activity", []] + ]] + ] + }], ["buildChargeResult", compileFinal { GVAR(OrgTreasuryService) call ["buildChargeResult", _this] }], diff --git a/arma/server/addons/store/XEH_preInit.sqf b/arma/server/addons/store/XEH_preInit.sqf index 54bfe32..ed11957 100644 --- a/arma/server/addons/store/XEH_preInit.sqf +++ b/arma/server/addons/store/XEH_preInit.sqf @@ -24,6 +24,26 @@ PREP_RECOMPILE_END; [CRPC(store,responseCategory), [_result], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); +[QGVAR(requestHydrateStore), { + params [["_uid", "", [""]], ["_bridgeEvent", "store::hydrate", [""]]]; + + if (_uid isEqualTo "") exitWith { + diag_log "[FORGE:Server:Store] Invalid hydrate request payload." + }; + + if !(_bridgeEvent in ["store::hydrate", "store::config::hydrate"]) then { + _bridgeEvent = "store::hydrate"; + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _payload = GVAR(StoreStore) call ["buildHydratePayload", [_uid]]; + if (_payload isEqualTo createHashMap) exitWith {}; + + [CRPC(store,responseHydrateStore), [_payload, _bridgeEvent], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + [QGVAR(requestCheckout), { params [["_uid", "", [""]], ["_payloadJson", "", [""]]]; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index 6e99752..7526eb6 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -19,6 +19,127 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ ["#create", compileFinal { ["INFO", "Store checkout service initialized!"] call EFUNC(common,log); }], + ["buildHydratePayload", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { createHashMap }; + + private _budget = 50000; + private _creditLine = 0; + private _cashBalance = 0; + private _bankBalance = 0; + private _orgFunds = 0; + private _orgName = ""; + private _orgOwnerUid = ""; + private _orgCreditLines = createHashMap; + private _playerVar = toLowerANSI (vehicleVarName _player); + private _isOrgLeader = false; + private _isDefaultOrg = false; + private _isDefaultOrgCeo = false; + + private _bankAccount = EGVAR(bank,Registry) getOrDefault [_uid, createHashMap]; + if (_bankAccount isEqualTo createHashMap) then { + _bankAccount = EGVAR(bank,BankStore) call ["init", [_uid]]; + }; + if (_bankAccount isNotEqualTo createHashMap) then { + _cashBalance = _bankAccount getOrDefault ["cash", 0]; + _bankBalance = _bankAccount getOrDefault ["bank", 0]; + }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgId = _actor getOrDefault ["organization", "default"]; + if (_orgId isEqualTo "") then { _orgId = "default"; }; + + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgId]]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; + _orgId = _org getOrDefault ["id", "default"]; + }; + + if (_org isNotEqualTo createHashMap) then { + _orgName = _org getOrDefault ["name", ""]; + _orgOwnerUid = _org getOrDefault ["owner", ""]; + _orgFunds = _org getOrDefault ["funds", 0]; + _orgCreditLines = _org getOrDefault ["credit_lines", createHashMap]; + _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; + _isOrgLeader = _orgOwnerUid isEqualTo _uid; + _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; + }; + + if (_orgCreditLines isEqualType createHashMap) then { + private _playerCreditLine = _orgCreditLines getOrDefault [_uid, createHashMap]; + if (_playerCreditLine isEqualType createHashMap) then { + _creditLine = _playerCreditLine getOrDefault ["amount", 0]; + }; + }; + + private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; + private _orgFundsEnabled = _canUseOrgFunds && { _orgFunds > 0 }; + private _paymentSources = [ + createHashMapFromArray [ + ["id", "cash"], + ["label", "Cash"], + ["balance", _cashBalance], + ["enabled", _cashBalance > 0], + ["detail", "Use on-hand cash carried by the player."] + ], + createHashMapFromArray [ + ["id", "bank"], + ["label", "Bank"], + ["balance", _bankBalance], + ["enabled", _bankBalance > 0], + ["detail", "Charge the player bank account."] + ], + createHashMapFromArray [ + ["id", "org_funds"], + ["label", "Org Funds"], + ["balance", _orgFunds], + ["enabled", _orgFundsEnabled], + ["detail", [ + "Only organization leaders or the default-org CEO can use treasury funds.", + [ + "Charge organization treasury funds.", + "No organization funds are currently available." + ] select _orgFundsEnabled + ] select _canUseOrgFunds] + ], + createHashMapFromArray [ + ["id", "credit_line"], + ["label", "Credit Line"], + ["balance", _creditLine], + ["enabled", _creditLine > 0], + ["detail", [ + "No approved credit line is assigned to this member.", + "Use the approved procurement credit line." + ] select (_creditLine > 0)] + ] + ]; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", name _player], + ["actorUid", _uid], + ["approval", "Field Access"], + ["orgId", _orgId], + ["orgName", _orgName], + ["orgLeader", _isOrgLeader], + ["defaultOrgCeo", _isDefaultOrgCeo], + ["canUseOrgFunds", _canUseOrgFunds] + ]], + ["storeConfig", createHashMapFromArray [ + ["budget", _budget], + ["creditLine", _creditLine], + ["availability", "In-Stock"], + ["moduleState", "Preview"], + ["paymentSources", _paymentSources], + ["defaultPaymentSource", "cash"] + ]], + ["cartItems", []] + ] + }], ["buildResult", compileFinal { params [["_message", "Checkout failed.", [""]], ["_paymentMethod", "cash", [""]]]; From 65f33ee02a736b2e118ce0df0fb1fa7e75a0e781 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Fri, 27 Mar 2026 19:00:32 -0500 Subject: [PATCH 03/19] Align client module naming and org member hydrate --- arma/client/addons/actor/XEH_PREP.hpp | 2 +- .../addons/actor/XEH_postInitClient.sqf | 12 +- .../actor/functions/fnc_handleUIEvents.sqf | 2 +- ...tActorClass.sqf => fnc_initRepository.sqf} | 34 +- arma/client/addons/bank/XEH_PREP.hpp | 2 +- .../client/addons/bank/XEH_postInitClient.sqf | 10 +- ...c_initClass.sqf => fnc_initRepository.sqf} | 27 +- .../bank/functions/fnc_initUIBridge.sqf | 12 +- arma/client/addons/garage/XEH_PREP.hpp | 10 +- .../addons/garage/XEH_postInitClient.sqf | 30 +- .../garage/functions/fnc_handleUIEvents.sqf | 8 +- .../functions/fnc_initActionService.sqf | 133 ++++++++ .../functions/fnc_initContextService.sqf | 146 +++++++++ ...gService.sqf => fnc_initHelperService.sqf} | 23 +- .../functions/fnc_initPayloadService.sqf | 44 +++ ...c_initClass.sqf => fnc_initRepository.sqf} | 36 +-- .../functions/fnc_initSessionService.sqf | 298 ------------------ .../garage/functions/fnc_initUIBridge.sqf | 172 +--------- ...itVGClass.sqf => fnc_initVGRepository.sqf} | 37 +-- .../addons/garage/functions/fnc_openVG.sqf | 2 +- arma/client/addons/locker/XEH_PREP.hpp | 4 +- .../addons/locker/XEH_postInitClient.sqf | 20 +- ...LockerClass.sqf => fnc_initRepository.sqf} | 51 ++- ...itVAClass.sqf => fnc_initVARepository.sqf} | 26 +- arma/client/addons/notifications/XEH_PREP.hpp | 2 +- .../notifications/XEH_postInitClient.sqf | 6 +- .../functions/fnc_handleUIEvents.sqf | 2 +- ...ificationClass.sqf => fnc_initService.sqf} | 22 +- arma/client/addons/org/XEH_PREP.hpp | 2 +- arma/client/addons/org/XEH_postInitClient.sqf | 4 +- .../org/functions/fnc_handleUIEvents.sqf | 15 +- ...c_initClass.sqf => fnc_initRepository.sqf} | 32 +- .../addons/org/functions/fnc_openUI.sqf | 11 +- .../store/functions/fnc_initUIBridge.sqf | 14 +- .../addons/org/functions/fnc_initOrgStore.sqf | 6 + build-arma.ps1 | 16 +- 36 files changed, 592 insertions(+), 681 deletions(-) rename arma/client/addons/actor/functions/{fnc_initActorClass.sqf => fnc_initRepository.sqf} (88%) rename arma/client/addons/bank/functions/{fnc_initClass.sqf => fnc_initRepository.sqf} (55%) create mode 100644 arma/client/addons/garage/functions/fnc_initActionService.sqf create mode 100644 arma/client/addons/garage/functions/fnc_initContextService.sqf rename arma/client/addons/garage/functions/{fnc_initCatalogService.sqf => fnc_initHelperService.sqf} (91%) create mode 100644 arma/client/addons/garage/functions/fnc_initPayloadService.sqf rename arma/client/addons/garage/functions/{fnc_initClass.sqf => fnc_initRepository.sqf} (59%) delete mode 100644 arma/client/addons/garage/functions/fnc_initSessionService.sqf rename arma/client/addons/garage/functions/{fnc_initVGClass.sqf => fnc_initVGRepository.sqf} (74%) rename arma/client/addons/locker/functions/{fnc_initLockerClass.sqf => fnc_initRepository.sqf} (86%) rename arma/client/addons/locker/functions/{fnc_initVAClass.sqf => fnc_initVARepository.sqf} (79%) rename arma/client/addons/notifications/functions/{fnc_initNotificationClass.sqf => fnc_initService.sqf} (66%) rename arma/client/addons/org/functions/{fnc_initClass.sqf => fnc_initRepository.sqf} (57%) diff --git a/arma/client/addons/actor/XEH_PREP.hpp b/arma/client/addons/actor/XEH_PREP.hpp index 0dcc312..97e1950 100644 --- a/arma/client/addons/actor/XEH_PREP.hpp +++ b/arma/client/addons/actor/XEH_PREP.hpp @@ -1,3 +1,3 @@ PREP(handleUIEvents); -PREP(initActorClass); +PREP(initRepository); PREP(openUI); diff --git a/arma/client/addons/actor/XEH_postInitClient.sqf b/arma/client/addons/actor/XEH_postInitClient.sqf index 12c69b1..483c48c 100644 --- a/arma/client/addons/actor/XEH_postInitClient.sqf +++ b/arma/client/addons/actor/XEH_postInitClient.sqf @@ -23,17 +23,17 @@ player addEventHandler ["Respawn", { [SRPC(economy,onRespawn), [_unit, _corpse, _uid]] call CFUNC(serverEvent); }]; -if (isNil QGVAR(ActorClass)) then { call FUNC(initActorClass); }; +if (isNil QGVAR(ActorRepository)) then { call FUNC(initRepository); }; [QGVAR(initActor), { - GVAR(ActorClass) call ["init", []]; + GVAR(ActorRepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(onActorRespawn), { params [["_loadout", [], [[]]], ["_medSpawnPos", [0,0,0], [[]]], ["_medSpawnDir", 0, [0]]]; private _message = ["warning", "Medical Alert", "You have been revived at a medical facility.", 5000]; - EGVAR(notifications,NotificationClass) call ["create", _message]; + EGVAR(notifications,NotificationService) call ["create", _message]; player setUnitLoadout _loadout; player setPosATL _medSpawnPos; @@ -53,14 +53,14 @@ if (isNil QGVAR(ActorClass)) then { call FUNC(initActorClass); }; [QGVAR(responseInitActor), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(ActorClass) call ["sync", [_data, true]]; + GVAR(ActorRepository) call ["sync", [_data, true]]; cutText ["", "PLAIN", 1]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncActor), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - GVAR(ActorClass) call ["sync", [_data, _jip]]; + GVAR(ActorRepository) call ["sync", [_data, _jip]]; }] call CFUNC(addEventHandler); [QGVAR(initActor), []] call CFUNC(localEvent); @@ -68,6 +68,6 @@ if (isNil QGVAR(ActorClass)) then { call FUNC(initActorClass); }; [{ GETVAR(player,FORGE_isLoaded,false) }, { - private _holster = GVAR(ActorClass) call ["get", ["holster", true]]; + private _holster = GVAR(ActorRepository) call ["get", ["holster", true]]; if (_holster) then { [player] call AFUNC(weaponselect,putWeaponAway); }; }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf index 869b624..9894313 100644 --- a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf @@ -31,7 +31,7 @@ private _data = _alert get "data"; diag_log format ["[FORGE:Client:Actor] Handling UI event: %1 with data: %2", _event, _data]; switch (_event) do { - case "actor::get::actions": { GVAR(ActorClass) call ["getNearbyActions", [_control]]; }; + case "actor::get::actions": { GVAR(ActorRepository) call ["getNearbyActions", [_control]]; }; case "actor::close::menu": { closeDialog 1; }; case "actor::open::atm": { [true] spawn EFUNC(bank,openUI); }; case "actor::open::bank": { [] spawn EFUNC(bank,openUI); }; diff --git a/arma/client/addons/actor/functions/fnc_initActorClass.sqf b/arma/client/addons/actor/functions/fnc_initRepository.sqf similarity index 88% rename from arma/client/addons/actor/functions/fnc_initActorClass.sqf rename to arma/client/addons/actor/functions/fnc_initRepository.sqf index 294d20f..71cdd8a 100644 --- a/arma/client/addons/actor/functions/fnc_initActorClass.sqf +++ b/arma/client/addons/actor/functions/fnc_initRepository.sqf @@ -1,29 +1,27 @@ #include "..\script_component.hpp" /* - * File: fnc_initActorClass.sqf + * File: fnc_initRepository.sqf * Author: IDSolutions - * Date: 2026-01-28 - * Last Update: 2026-03-25 - * Public: Yes + * Date: 2026-03-27 + * Public: No * * Description: - * Initializes the actor class for managing player data. - * Provides methods for saving, loading, and applying actor data. + * Initializes the actor repository for managing player actor data. * * Arguments: * None * * Return Value: - * Actor class object [HASHMAP OBJECT] + * Actor repository object [HASHMAP OBJECT] * * Example: - * call forge_client_actor_fnc_initActorClass + * call forge_client_actor_fnc_initRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "ActorBaseClass"], +GVAR(ActorRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "ActorRepositoryBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; _self set ["actor", createHashMap]; @@ -35,8 +33,8 @@ GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ [SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Actor loaded for %1", (name player)]; - diag_log "[FORGE:Client:Actor] Actor Class Initialized!"; + systemChat format ["Actor loaded for %1", name player]; + diag_log "[FORGE:Client:Actor] Actor Repository Initialized!"; }], ["save", compileFinal { params [["_sync", false, [false]]]; @@ -69,29 +67,23 @@ GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ _self set ["actor", _actor]; SETPVAR(player,FORGE_isLoaded,true); - if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:Actor] Sync completed"; }], ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; - private _actor = _self get "actor"; _actor getOrDefault [_key, _default]; }], ["applyPosition", compileFinal { private _position = _self call ["get", ["position", [0, 0, 0]]]; - if (GVAR(enableLoc)) then { player setPosASL _position; - private _pAlt = ((getPosATLVisual player) select 2); private _pVelZ = ((velocity player) select 2); - if (_pAlt > 5 && _pVelZ < 0) then { player setVelocity [0, 0, 0]; player setPosATL [((getPosATLVisual player) select 0), ((getPosATLVisual player) select 1), 1]; - hint "You logged off mid air. You were moved to a safe position on the ground"; }; }; @@ -114,9 +106,7 @@ GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ }], ["getNearbyActions", compileFinal { params [["_control", controlNull, [controlNull]]]; - private _nearbyActions = []; - { private _storeType = _x getVariable ["storeType", ""]; private _isAtm = _x getVariable ["isAtm", false]; @@ -141,5 +131,5 @@ GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(ActorClass) = createHashMapObject [GVAR(ActorBaseClass)]; -GVAR(ActorClass) +GVAR(ActorRepository) = createHashMapObject [GVAR(ActorRepositoryBaseClass)]; +GVAR(ActorRepository) diff --git a/arma/client/addons/bank/XEH_PREP.hpp b/arma/client/addons/bank/XEH_PREP.hpp index 7d71bae..fb83b48 100644 --- a/arma/client/addons/bank/XEH_PREP.hpp +++ b/arma/client/addons/bank/XEH_PREP.hpp @@ -1,4 +1,4 @@ PREP(handleUIEvents); -PREP(initClass); +PREP(initRepository); PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/bank/XEH_postInitClient.sqf b/arma/client/addons/bank/XEH_postInitClient.sqf index 5eb9fc0..3ec1fc8 100644 --- a/arma/client/addons/bank/XEH_postInitClient.sqf +++ b/arma/client/addons/bank/XEH_postInitClient.sqf @@ -1,16 +1,16 @@ #include "script_component.hpp" -if (isNil QGVAR(BankClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(BankRepository)) then { call FUNC(initRepository); }; if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(initBank), { - GVAR(BankClass) call ["init", []]; + GVAR(BankRepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitBank), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(BankClass) call ["markLoaded", []]; + GVAR(BankRepository) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["refreshSession", []]; }; @@ -19,7 +19,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseSyncBank), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - GVAR(BankClass) call ["markLoaded", []]; + GVAR(BankRepository) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["refreshSession", []]; }; @@ -42,7 +42,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [{ - EGVAR(actor,ActorClass) get "isLoaded"; + EGVAR(actor,ActorRepository) get "isLoaded"; }, { [QGVAR(initBank), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/bank/functions/fnc_initClass.sqf b/arma/client/addons/bank/functions/fnc_initRepository.sqf similarity index 55% rename from arma/client/addons/bank/functions/fnc_initClass.sqf rename to arma/client/addons/bank/functions/fnc_initRepository.sqf index 1643c0d..c91ecf2 100644 --- a/arma/client/addons/bank/functions/fnc_initClass.sqf +++ b/arma/client/addons/bank/functions/fnc_initRepository.sqf @@ -1,17 +1,27 @@ #include "..\script_component.hpp" /* - * File: fnc_initClass.sqf + * File: fnc_initRepository.sqf * Author: IDSolutions + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the bank class for lifecycle and save helpers. + * Initializes the bank repository for client bank lifecycle state. + * + * Arguments: + * None + * + * Return Value: + * Bank repository object [HASHMAP OBJECT] + * + * Example: + * call forge_client_bank_fnc_initRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "BankBaseClass"], +GVAR(BankRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "BankRepositoryBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; _self set ["isLoaded", false]; @@ -21,12 +31,11 @@ GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ [SRPC(bank,requestInitBank), [getPlayerUID player]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Bank loaded for %1", (name player)]; - diag_log "[FORGE:Client:Bank] Bank Class Initialized!"; + systemChat format ["Bank loaded for %1", name player]; + diag_log "[FORGE:Client:Bank] Bank Repository Initialized!"; }], ["markLoaded", compileFinal { if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; - true }], ["save", compileFinal { @@ -35,5 +44,5 @@ GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(BankClass) = createHashMapObject [GVAR(BankBaseClass)]; -GVAR(BankClass) +GVAR(BankRepository) = createHashMapObject [GVAR(BankRepositoryBaseClass)]; +GVAR(BankRepository) diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf index 4d82ee9..9788f08 100644 --- a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -3,10 +3,20 @@ /* * File: fnc_initUIBridge.sqf * Author: IDSolutions + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the bank web UI bridge. + * Initializes the bank UI bridge for browser control state and bank UI events. + * + * Arguments: + * None + * + * Return Value: + * Bank UI bridge object [HASHMAP OBJECT] + * + * Example: + * call forge_client_bank_fnc_initUIBridge; */ #pragma hemtt ignore_variables ["_self"] diff --git a/arma/client/addons/garage/XEH_PREP.hpp b/arma/client/addons/garage/XEH_PREP.hpp index 9db23f1..f39faee 100644 --- a/arma/client/addons/garage/XEH_PREP.hpp +++ b/arma/client/addons/garage/XEH_PREP.hpp @@ -1,8 +1,10 @@ PREP(handleUIEvents); -PREP(initCatalogService); -PREP(initClass); -PREP(initSessionService); +PREP(initActionService); +PREP(initContextService); +PREP(initHelperService); +PREP(initPayloadService); +PREP(initRepository); PREP(initUIBridge); -PREP(initVGClass); +PREP(initVGRepository); PREP(openUI); PREP(openVG); diff --git a/arma/client/addons/garage/XEH_postInitClient.sqf b/arma/client/addons/garage/XEH_postInitClient.sqf index 7d53dc5..76cd623 100644 --- a/arma/client/addons/garage/XEH_postInitClient.sqf +++ b/arma/client/addons/garage/XEH_postInitClient.sqf @@ -1,19 +1,21 @@ #include "script_component.hpp" -if (isNil QGVAR(GarageCatalogService)) then { call FUNC(initCatalogService); }; -if (isNil QGVAR(GarageClass)) then { call FUNC(initClass); }; -if (isNil QGVAR(GarageSessionService)) then { call FUNC(initSessionService); }; +if (isNil QGVAR(GarageHelperService)) then { call FUNC(initHelperService); }; +if (isNil QGVAR(GarageRepository)) then { call FUNC(initRepository); }; +if (isNil QGVAR(GarageContextService)) then { call FUNC(initContextService); }; +if (isNil QGVAR(GaragePayloadService)) then { call FUNC(initPayloadService); }; +if (isNil QGVAR(GarageActionService)) then { call FUNC(initActionService); }; if (isNil QGVAR(GarageUIBridge)) then { call FUNC(initUIBridge); }; -if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; +if (isNil QGVAR(VGRepository)) then { call FUNC(initVGRepository); }; [QGVAR(initGarage), { - GVAR(GarageClass) call ["init", []]; + GVAR(GarageRepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitGarage), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(GarageClass) call ["sync", [_data]]; + GVAR(GarageRepository) call ["sync", [_data]]; if !(isNil QGVAR(GarageUIBridge)) then { GVAR(GarageUIBridge) call ["refreshGarage", []]; }; @@ -22,7 +24,7 @@ if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; [QGVAR(responseSyncGarage), { params [["_data", createHashMap, [createHashMap, []]]]; - GVAR(GarageClass) call ["sync", [_data]]; + GVAR(GarageRepository) call ["sync", [_data]]; if !(isNil QGVAR(GarageUIBridge)) then { GVAR(GarageUIBridge) call ["refreshGarage", []]; }; @@ -31,35 +33,35 @@ if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; [QGVAR(responseGarageAction), { params [["_payload", createHashMap, [createHashMap]]]; - if !(isNil QGVAR(GarageUIBridge)) then { - GVAR(GarageUIBridge) call ["handleActionResponse", [_payload]]; + if !(isNil QGVAR(GarageActionService)) then { + GVAR(GarageActionService) call ["handleActionResponse", [_payload]]; }; }] call CFUNC(addEventHandler); [QGVAR(initVG), { - GVAR(VGClass) call ["init", []]; + GVAR(VGRepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitVG), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(VGClass) call ["sync", [_data]]; + GVAR(VGRepository) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncVG), { params [["_data", createHashMap, [createHashMap, []]]]; - GVAR(VGClass) call ["sync", [_data]]; + GVAR(VGRepository) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [{ - EGVAR(bank,BankClass) get "isLoaded"; + EGVAR(bank,BankRepository) get "isLoaded"; }, { [QGVAR(initGarage), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); [{ - GVAR(GarageClass) get "isLoaded"; + GVAR(GarageRepository) get "isLoaded"; }, { [QGVAR(initVG), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf index ae6ee6e..94c1ad1 100644 --- a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf @@ -44,13 +44,13 @@ switch (_event) do { }; }; case "garage::vehicle::retrieve::request": { - if !(isNil QGVAR(GarageUIBridge)) then { - GVAR(GarageUIBridge) call ["handleRetrieveRequest", [_data]]; + if !(isNil QGVAR(GarageActionService)) then { + GVAR(GarageActionService) call ["handleRetrieveRequest", [_data]]; }; }; case "garage::vehicle::store::request": { - if !(isNil QGVAR(GarageUIBridge)) then { - GVAR(GarageUIBridge) call ["handleStoreRequest", [_data]]; + if !(isNil QGVAR(GarageActionService)) then { + GVAR(GarageActionService) call ["handleStoreRequest", [_data]]; }; }; case "garage::refresh": { diff --git a/arma/client/addons/garage/functions/fnc_initActionService.sqf b/arma/client/addons/garage/functions/fnc_initActionService.sqf new file mode 100644 index 0000000..408d5a8 --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initActionService.sqf @@ -0,0 +1,133 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initActionService.sqf + * Author: IDSolutions + * Date: 2026-03-27 + * Public: No + * + * Description: + * Initializes the garage action service for retrieve and store world actions. + * + * Arguments: + * None + * + * Return Value: + * Garage action service object [HASHMAP OBJECT] + * + * Example: + * call forge_client_garage_fnc_initActionService; + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(GarageActionServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageActionServiceBaseClass"], + ["#create", compileFinal { + _self set ["pendingStoreVehicle", objNull]; + _self set ["pendingRetrieve", createHashMap]; + }], + ["handleRetrieveRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _plate = _data getOrDefault ["plate", ""]; + if (_plate isEqualTo "") exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [["message", "Select a stored vehicle to retrieve."]]]]; + }; + + private _garageMap = if (isNil QGVAR(GarageRepository)) then { createHashMap } else { GVAR(GarageRepository) call ["getState", []] }; + private _vehicleData = _garageMap getOrDefault [_plate, createHashMap]; + if (_vehicleData isEqualTo createHashMap) exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [["message", "Stored vehicle record could not be found."]]]]; + }; + + private _context = GVAR(GarageContextService) call ["getContext", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnHeading = _context getOrDefault ["spawnHeading", getDir player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _blockingVehicles = []; + { _blockingVehicles pushBackUnique _x; } forEach (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]); + { _blockingVehicles pushBackUnique _x; } forEach (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]); + if (_blockingVehicles isNotEqualTo []) exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [["message", "The garage spawn area is blocked."]]]]; + }; + + private _className = _vehicleData getOrDefault ["classname", ""]; + if (_className isEqualTo "") exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [["message", "Stored vehicle record is missing a classname."]]]]; + }; + + private _vehicle = createVehicle [_className, _spawnPosition, [], 0, "CAN_COLLIDE"]; + _vehicle setDir _spawnHeading; + _vehicle setFuel (_vehicleData getOrDefault ["fuel", 0]); + _vehicle setDamage (_vehicleData getOrDefault ["damage", 0]); + + private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; + private _hitPointNames = _hitPoints getOrDefault ["names", []]; + private _hitPointValues = _hitPoints getOrDefault ["values", []]; + for "_index" from 0 to ((count _hitPointNames) - 1) do { + _vehicle setHitPointDamage [_hitPointNames param [_index, ""], _hitPointValues param [_index, 0]]; + }; + + _vehicle setVariable ["forge_garage_plate", _plate, true]; + _vehicle setVariable ["forge_garage_owner_uid", getPlayerUID player, true]; + + _self set ["pendingRetrieve", createHashMapFromArray [["plate", _plate], ["vehicle", _vehicle]]]; + [SRPC(garage,requestRetrieveVehicle), [getPlayerUID player, _plate]] call CFUNC(serverEvent); + }], + ["handleStoreRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _netId = _data getOrDefault ["netId", ""]; + if (_netId isEqualTo "") exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::store::failure", createHashMapFromArray [["message", "Select a nearby vehicle to store."]]]]; + }; + + private _vehicle = objectFromNetId _netId; + if (isNull _vehicle) exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::store::failure", createHashMapFromArray [["message", "The selected vehicle is no longer available."]]]]; + }; + + if (crew _vehicle isNotEqualTo []) exitWith { + GVAR(GarageUIBridge) call ["sendEvent", ["garage::store::failure", createHashMapFromArray [["message", "All crew must exit the vehicle before storing it."]]]]; + }; + + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointsJson = toJSON (createHashMapFromArray [["names", _rawHitPoints param [0, []]], ["selections", _rawHitPoints param [1, []]], ["values", _rawHitPoints param [2, []]]]); + + _self set ["pendingStoreVehicle", _vehicle]; + [SRPC(garage,requestStoreVehicle), [getPlayerUID player, typeOf _vehicle, fuel _vehicle, damage _vehicle, _hitPointsJson]] call CFUNC(serverEvent); + }], + ["handleActionResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _action = _payload getOrDefault ["action", ""]; + private _success = _payload getOrDefault ["success", false]; + private _message = _payload getOrDefault ["message", "Garage action failed."]; + + switch (_action) do { + case "retrieve": { + private _pendingRetrieve = _self getOrDefault ["pendingRetrieve", createHashMap]; + private _vehicle = _pendingRetrieve getOrDefault ["vehicle", objNull]; + if (!_success && { !isNull _vehicle }) then { deleteVehicle _vehicle; }; + _self set ["pendingRetrieve", createHashMap]; + GVAR(GarageUIBridge) call ["sendEvent", [[ "garage::retrieve::failure", "garage::retrieve::success" ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + case "store": { + private _vehicle = _self getOrDefault ["pendingStoreVehicle", objNull]; + if (_success && { !isNull _vehicle }) then { deleteVehicle _vehicle; }; + _self set ["pendingStoreVehicle", objNull]; + GVAR(GarageUIBridge) call ["sendEvent", [[ "garage::store::failure", "garage::store::success" ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + }; + + [] spawn { + sleep 0.05; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + }] +]; + +GVAR(GarageActionService) = createHashMapObject [GVAR(GarageActionServiceBaseClass)]; +GVAR(GarageActionService) diff --git a/arma/client/addons/garage/functions/fnc_initContextService.sqf b/arma/client/addons/garage/functions/fnc_initContextService.sqf new file mode 100644 index 0000000..45da63e --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initContextService.sqf @@ -0,0 +1,146 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initContextService.sqf + * Author: IDSolutions + * Date: 2026-03-27 + * Public: No + * + * Description: + * Initializes the garage context service for local garage context and nearby state. + * + * Arguments: + * None + * + * Return Value: + * Garage context service object [HASHMAP OBJECT] + * + * Example: + * call forge_client_garage_fnc_initContextService; + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(GarageContextServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageContextServiceBaseClass"], + ["#create", compileFinal { _self set ["lastContext", createHashMap]; }], + ["#delete", compileFinal { _self set ["lastContext", createHashMap]; }], + ["createDefaultContext", compileFinal { + createHashMapFromArray [ + ["name", "Vehicle Garage"], + ["anchorPosition", getPosATL player], + ["sourceObject", objNull], + ["spawnHeading", getDir player], + ["spawnPosition", player getPos [8, getDir player]], + ["spawnRadius", 6], + ["nearbyRadius", 30] + ] + }], + ["scanEntryValues", compileFinal { + params [["_values", [], [[]]], ["_state", createHashMap, [createHashMap]]]; + { + if (_x isEqualType "" && { (_state getOrDefault ["name", "Vehicle Garage"]) isEqualTo "Vehicle Garage" }) then { _state set ["name", _x]; }; + if (_x isEqualType "") then { + private _resolvedObject = _state getOrDefault ["sourceObject", objNull]; + if (isNull _resolvedObject) then { + private _namedObject = missionNamespace getVariable [_x, objNull]; + if (!isNull _namedObject) then { _state set ["sourceObject", _namedObject]; }; + }; + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo [] && { _x in allMapMarkers }) then { _state set ["anchorPosition", markerPos _x]; }; + continue; + }; + if (_x isEqualType objNull && { isNull (_state getOrDefault ["sourceObject", objNull]) }) then { + _state set ["sourceObject", _x]; + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { _state set ["anchorPosition", getPosATL _x]; }; + continue; + }; + if (_x isEqualType 0 && { (_state getOrDefault ["spawnHeading", -1]) < 0 }) then { _state set ["spawnHeading", _x]; continue; }; + if (_x isEqualType [] && { count _x > 0 }) then { + if ({ _x isEqualType 0 } count _x >= 2 && { ((_state getOrDefault ["offset", []]) isEqualTo []) || ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) }) then { + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { _state set ["anchorPosition", _x]; } else { _state set ["offset", _x]; }; + continue; + }; + _self call ["scanEntryValues", [_x, _state]]; + }; + } forEach _values; + _state + }], + ["resolveEntry", compileFinal { + params [["_entry", [], [[]]]]; + private _state = createHashMapFromArray [["name", "Vehicle Garage"], ["anchorPosition", []], ["sourceObject", objNull], ["offset", []], ["spawnHeading", -1]]; + _self call ["scanEntryValues", [_entry, _state]]; + private _anchorPosition = _state getOrDefault ["anchorPosition", []]; + private _offset = _state getOrDefault ["offset", []]; + private _spawnPosition = if (_anchorPosition isEqualTo []) then { [] } else { if (_offset isEqualTo []) then { _anchorPosition } else { _anchorPosition vectorAdd _offset } }; + createHashMapFromArray [["name", _state getOrDefault ["name", "Vehicle Garage"]], ["anchorPosition", _anchorPosition], ["sourceObject", _state getOrDefault ["sourceObject", objNull]], ["spawnHeading", _state getOrDefault ["spawnHeading", -1]], ["spawnPosition", _spawnPosition]] + }], + ["resolveContext", compileFinal { + private _context = _self call ["createDefaultContext", []]; + private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData); + if !(_locations isEqualType []) exitWith { _self set ["lastContext", _context]; _context }; + + private _nearestEntry = []; + private _nearestDistance = 1e10; + { + private _entry = _self call ["resolveEntry", [_x]]; + private _anchorPosition = _entry getOrDefault ["anchorPosition", []]; + if (_anchorPosition isEqualTo []) then { continue; }; + private _distance = player distance2D _anchorPosition; + if (_distance < _nearestDistance) then { _nearestDistance = _distance; _nearestEntry = _entry; }; + } forEach _locations; + + if (_nearestEntry isEqualTo []) exitWith { _self set ["lastContext", _context]; _context }; + + private _anchorPosition = _nearestEntry getOrDefault ["anchorPosition", []]; + private _garageObject = _nearestEntry getOrDefault ["sourceObject", objNull]; + private _garageName = _nearestEntry getOrDefault ["name", "Vehicle Garage"]; + private _spawnHeading = _nearestEntry getOrDefault ["spawnHeading", getDir player]; + if (_spawnHeading < 0) then { _spawnHeading = if (!isNull _garageObject) then { getDir _garageObject } else { getDir player }; }; + + private _spawnPosition = _nearestEntry getOrDefault ["spawnPosition", []]; + if (_spawnPosition isEqualTo []) then { _spawnPosition = if (_anchorPosition isEqualTo []) then { player getPos [8, _spawnHeading] } else { _anchorPosition }; }; + + _context set ["name", _garageName]; + _context set ["anchorPosition", _anchorPosition]; + _context set ["sourceObject", _garageObject]; + _context set ["spawnHeading", _spawnHeading]; + _context set ["spawnPosition", _spawnPosition]; + _self set ["lastContext", _context]; + _context + }], + ["getContext", compileFinal { _self call ["resolveContext", []] }], + ["buildNearbyState", compileFinal { + private _context = _self call ["getContext", []]; + private _anchorPosition = _context getOrDefault ["anchorPosition", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _nearbyRadius = _context getOrDefault ["nearbyRadius", 30]; + private _nearbyOrigin = [_anchorPosition, _spawnPosition] select (_anchorPosition isEqualTo []); + private _nearbyVehicles = []; + private _nearbyEntities = []; + private _candidateVehicles = []; + { _candidateVehicles pushBackUnique _x; } forEach (_nearbyOrigin nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { _candidateVehicles pushBackUnique _x; } forEach ((getPosATL player) nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { _candidateVehicles pushBackUnique _x; } forEach (nearestObjects [_nearbyOrigin, ["AllVehicles"], _nearbyRadius]); + { _candidateVehicles pushBackUnique _x; } forEach (nearestObjects [getPosATL player, ["AllVehicles"], _nearbyRadius]); + { + if (isNull _x) then { continue; }; + if (_x isKindOf "CAManBase") then { continue; }; + if !(_x isKindOf "Car" || _x isKindOf "Tank" || _x isKindOf "Air" || _x isKindOf "Ship") then { continue; }; + _nearbyEntities pushBackUnique _x; + } forEach _candidateVehicles; + { + if (isNull _x) then { continue; }; + private _builtVehicle = GVAR(GarageHelperService) call ["buildNearbyVehicle", [_x, _nearbyOrigin]]; + if (_builtVehicle isEqualTo createHashMap) then { continue; }; + _nearbyVehicles pushBack _builtVehicle; + } forEach _nearbyEntities; + private _nearbyVehiclePairs = _nearbyVehicles apply { [_x getOrDefault ["distance", 0], _x] }; + _nearbyVehiclePairs sort true; + _nearbyVehicles = _nearbyVehiclePairs apply { _x param [1, createHashMap] }; + private _spawnBlocked = ((_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]) + (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius])) isNotEqualTo []; + createHashMapFromArray [["session", createHashMapFromArray [["garageName", _context getOrDefault ["name", "Vehicle Garage"]], ["nearbyCount", count _nearbyVehicles], ["spawnBlocked", _spawnBlocked], ["spawnStatus", ["Ready", "Blocked"] select _spawnBlocked]]], ["nearby", createHashMapFromArray [["vehicles", _nearbyVehicles]]]] + }] +]; + +GVAR(GarageContextService) = createHashMapObject [GVAR(GarageContextServiceBaseClass)]; +GVAR(GarageContextService) diff --git a/arma/client/addons/garage/functions/fnc_initCatalogService.sqf b/arma/client/addons/garage/functions/fnc_initHelperService.sqf similarity index 91% rename from arma/client/addons/garage/functions/fnc_initCatalogService.sqf rename to arma/client/addons/garage/functions/fnc_initHelperService.sqf index 748b5e4..714d2a2 100644 --- a/arma/client/addons/garage/functions/fnc_initCatalogService.sqf +++ b/arma/client/addons/garage/functions/fnc_initHelperService.sqf @@ -1,18 +1,27 @@ #include "..\script_component.hpp" /* - * File: fnc_initCatalogService.sqf + * File: fnc_initHelperService.sqf * Author: IDSolutions - * Date: 2026-03-14 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the garage catalog service for vehicle metadata and UI-friendly shaping. + * Initializes the garage helper service for vehicle metadata and UI-friendly shaping. + * + * Arguments: + * None + * + * Return Value: + * Garage helper service object [HASHMAP OBJECT] + * + * Example: + * call forge_client_garage_fnc_initHelperService; */ #pragma hemtt ignore_variables ["_self"] -GVAR(GarageCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "GarageCatalogServiceBaseClass"], +GVAR(GarageHelperServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageHelperServiceBaseClass"], ["resolveCategory", compileFinal { params [["_className", "", [""]]]; @@ -156,5 +165,5 @@ GVAR(GarageCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(GarageCatalogService) = createHashMapObject [GVAR(GarageCatalogServiceBaseClass)]; -GVAR(GarageCatalogService) +GVAR(GarageHelperService) = createHashMapObject [GVAR(GarageHelperServiceBaseClass)]; +GVAR(GarageHelperService) diff --git a/arma/client/addons/garage/functions/fnc_initPayloadService.sqf b/arma/client/addons/garage/functions/fnc_initPayloadService.sqf new file mode 100644 index 0000000..184065e --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initPayloadService.sqf @@ -0,0 +1,44 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initPayloadService.sqf + * Author: IDSolutions + * Date: 2026-03-27 + * Public: No + * + * Description: + * Initializes the garage payload service for browser hydrate payload composition. + * + * Arguments: + * None + * + * Return Value: + * Garage payload service object [HASHMAP OBJECT] + * + * Example: + * call forge_client_garage_fnc_initPayloadService; + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(GaragePayloadServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GaragePayloadServiceBaseClass"], + ["buildStoredVehicles", compileFinal { + private _garageMap = if (isNil QGVAR(GarageRepository)) then { createHashMap } else { GVAR(GarageRepository) call ["getState", []] }; + private _storedVehicles = []; + { _storedVehicles pushBack (GVAR(GarageHelperService) call ["buildStoredVehicle", [_x, _y]]); } forEach _garageMap; + private _storedVehiclePairs = _storedVehicles apply { [toLowerANSI (_x getOrDefault ["displayName", ""]), _x] }; + _storedVehiclePairs sort true; + _storedVehiclePairs apply { _x param [1, createHashMap] } + }], + ["buildPayload", compileFinal { + private _localState = GVAR(GarageContextService) call ["buildNearbyState", []]; + private _storedVehicles = _self call ["buildStoredVehicles", []]; + private _session = +(_localState getOrDefault ["session", createHashMap]); + _session set ["capacityUsed", count _storedVehicles]; + _session set ["capacityMax", 5]; + createHashMapFromArray [["session", _session], ["garage", createHashMapFromArray [["vehicles", _storedVehicles]]], ["nearby", +(_localState getOrDefault ["nearby", createHashMap])]] + }] +]; + +GVAR(GaragePayloadService) = createHashMapObject [GVAR(GaragePayloadServiceBaseClass)]; +GVAR(GaragePayloadService) diff --git a/arma/client/addons/garage/functions/fnc_initClass.sqf b/arma/client/addons/garage/functions/fnc_initRepository.sqf similarity index 59% rename from arma/client/addons/garage/functions/fnc_initClass.sqf rename to arma/client/addons/garage/functions/fnc_initRepository.sqf index 6bb114e..5129610 100644 --- a/arma/client/addons/garage/functions/fnc_initClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initRepository.sqf @@ -1,49 +1,44 @@ #include "..\script_component.hpp" /* - * File: fnc_initClass.sqf + * File: fnc_initRepository.sqf * Author: IDSolutions - * Date: 2025-12-17 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the Garage class for managing player vehicles. - * Provides methods for syncing, saving, and applying vehicles to the player's garage. + * Initializes the garage repository for persisted stored vehicle records. * * Arguments: * None * * Return Value: - * Garage class object [HASHMAP OBJECT] + * Garage repository object [HASHMAP OBJECT] * * Example: - * call forge_client_garage_fnc_initClass + * call forge_client_garage_fnc_initRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "GarageBaseClass"], +GVAR(GarageRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageRepositoryBaseClass"], ["#create", compileFinal { - _self set ["uid", (getPlayerUID player)]; + _self set ["uid", getPlayerUID player]; _self set ["garage", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; }], ["init", compileFinal { private _uid = _self get "uid"; - private _garage = _self get "garage"; - - [SRPC(garage,requestInitGarage), [_uid, _garage]] call CFUNC(serverEvent); + [SRPC(garage,requestInitGarage), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Garage loaded for %1", (name player)]; - diag_log "[FORGE:Client:Garage] Garage Class Initialized!"; + systemChat format ["Garage loaded for %1", name player]; + diag_log "[FORGE:Client:Garage] Garage Repository Initialized!"; }], ["save", compileFinal { private _uid = _self get "uid"; [SRPC(garage,requestSaveGarage), [_uid]] call CFUNC(serverEvent); - _self set ["lastSave", time]; }], ["sync", compileFinal { @@ -51,14 +46,13 @@ GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ private _isLoaded = _self get "isLoaded"; private _garage = createHashMap; - { _garage set [_x, _y]; } forEach _data; _self set ["garage", _garage]; if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:Garage] Sync completed"; + diag_log "[FORGE:Client:Garage] Repository sync completed"; }], - ["getGarageState", compileFinal { + ["getState", compileFinal { _self getOrDefault ["garage", createHashMap] }], ["get", compileFinal { @@ -69,5 +63,5 @@ GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(GarageClass) = createHashMapObject [GVAR(GarageBaseClass)]; -GVAR(GarageClass) +GVAR(GarageRepository) = createHashMapObject [GVAR(GarageRepositoryBaseClass)]; +GVAR(GarageRepository) diff --git a/arma/client/addons/garage/functions/fnc_initSessionService.sqf b/arma/client/addons/garage/functions/fnc_initSessionService.sqf deleted file mode 100644 index 7fe871b..0000000 --- a/arma/client/addons/garage/functions/fnc_initSessionService.sqf +++ /dev/null @@ -1,298 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_initSessionService.sqf - * Author: IDSolutions - * Date: 2026-03-14 - * Public: No - * - * Description: - * Initializes the typed garage session service responsible for resolving the - * active garage context and building the browser hydrate payload. - */ - -#pragma hemtt ignore_variables ["_self"] - -GVAR(GarageSessionServiceBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "GarageSessionServiceBaseClass"], - ["#create", compileFinal { - _self set ["lastContext", createHashMap]; - }], - ["#delete", compileFinal { - _self set ["lastContext", createHashMap]; - }], - ["createDefaultContext", compileFinal { - createHashMapFromArray [ - ["name", "Vehicle Garage"], - ["anchorPosition", getPosATL player], - ["sourceObject", objNull], - ["spawnHeading", getDir player], - ["spawnPosition", player getPos [8, getDir player]], - ["spawnRadius", 6], - ["nearbyRadius", 30] - ] - }], - ["scanEntryValues", compileFinal { - params [ - ["_values", [], [[]]], - ["_state", createHashMap, [createHashMap]] - ]; - - { - if (_x isEqualType "" && { (_state getOrDefault ["name", "Vehicle Garage"]) isEqualTo "Vehicle Garage" }) then { - _state set ["name", _x]; - }; - - if (_x isEqualType "") then { - private _resolvedObject = _state getOrDefault ["sourceObject", objNull]; - if (isNull _resolvedObject) then { - private _namedObject = missionNamespace getVariable [_x, objNull]; - if (!isNull _namedObject) then { - _state set ["sourceObject", _namedObject]; - }; - }; - - if ((_state getOrDefault ["anchorPosition", []]) isEqualTo [] && { _x in allMapMarkers }) then { - _state set ["anchorPosition", markerPos _x]; - }; - - continue; - }; - - if (_x isEqualType objNull && { isNull (_state getOrDefault ["sourceObject", objNull]) }) then { - _state set ["sourceObject", _x]; - if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { - _state set ["anchorPosition", getPosATL _x]; - }; - continue; - }; - - if (_x isEqualType 0 && { (_state getOrDefault ["spawnHeading", -1]) < 0 }) then { - _state set ["spawnHeading", _x]; - continue; - }; - - if (_x isEqualType [] && { count _x > 0 }) then { - if ( - { _x isEqualType 0 } count _x >= 2 && - { - ((_state getOrDefault ["offset", []]) isEqualTo []) || - ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) - } - ) then { - if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { - _state set ["anchorPosition", _x]; - } else { - _state set ["offset", _x]; - }; - continue; - }; - - _self call ["scanEntryValues", [_x, _state]]; - }; - } forEach _values; - - _state - }], - ["resolveEntry", compileFinal { - params [["_entry", [], [[]]]]; - - private _state = createHashMapFromArray [ - ["name", "Vehicle Garage"], - ["anchorPosition", []], - ["sourceObject", objNull], - ["offset", []], - ["spawnHeading", -1] - ]; - - _self call ["scanEntryValues", [_entry, _state]]; - - private _anchorPosition = _state getOrDefault ["anchorPosition", []]; - private _offset = _state getOrDefault ["offset", []]; - private _spawnPosition = if (_anchorPosition isEqualTo []) then { - [] - } else { - if (_offset isEqualTo []) then { - _anchorPosition - } else { - _anchorPosition vectorAdd _offset - } - }; - - createHashMapFromArray [ - ["name", _state getOrDefault ["name", "Vehicle Garage"]], - ["anchorPosition", _anchorPosition], - ["sourceObject", _state getOrDefault ["sourceObject", objNull]], - ["spawnHeading", _state getOrDefault ["spawnHeading", -1]], - ["spawnPosition", _spawnPosition] - ] - }], - ["resolveContext", compileFinal { - private _context = _self call ["createDefaultContext", []]; - private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData); - if !(_locations isEqualType []) exitWith { - _self set ["lastContext", _context]; - _context - }; - - private _nearestEntry = []; - private _nearestDistance = 1e10; - - { - private _entry = _self call ["resolveEntry", [_x]]; - private _anchorPosition = _entry getOrDefault ["anchorPosition", []]; - if (_anchorPosition isEqualTo []) then { - continue; - }; - - private _distance = player distance2D _anchorPosition; - if (_distance < _nearestDistance) then { - _nearestDistance = _distance; - _nearestEntry = _entry; - }; - } forEach _locations; - - if (_nearestEntry isEqualTo []) exitWith { - _self set ["lastContext", _context]; - _context - }; - - private _anchorPosition = _nearestEntry getOrDefault ["anchorPosition", []]; - private _garageObject = _nearestEntry getOrDefault ["sourceObject", objNull]; - private _garageName = _nearestEntry getOrDefault ["name", "Vehicle Garage"]; - private _spawnHeading = _nearestEntry getOrDefault ["spawnHeading", getDir player]; - if (_spawnHeading < 0) then { - _spawnHeading = if (!isNull _garageObject) then { getDir _garageObject } else { getDir player }; - }; - - private _spawnPosition = _nearestEntry getOrDefault ["spawnPosition", []]; - if (_spawnPosition isEqualTo []) then { - _spawnPosition = if (_anchorPosition isEqualTo []) then { - player getPos [8, _spawnHeading] - } else { - _anchorPosition - }; - }; - - _context set ["name", _garageName]; - _context set ["anchorPosition", _anchorPosition]; - _context set ["sourceObject", _garageObject]; - _context set ["spawnHeading", _spawnHeading]; - _context set ["spawnPosition", _spawnPosition]; - - _self set ["lastContext", _context]; - _context - }], - ["getContext", compileFinal { - _self call ["resolveContext", []] - }], - ["buildPayload", compileFinal { - private _context = _self call ["getContext", []]; - private _garageMap = if (isNil QGVAR(GarageClass)) then { - createHashMap - } else { - GVAR(GarageClass) call ["getGarageState", []] - }; - - private _anchorPosition = _context getOrDefault ["anchorPosition", []]; - private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; - private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; - private _nearbyRadius = _context getOrDefault ["nearbyRadius", 30]; - private _nearbyOrigin = [_anchorPosition, _spawnPosition] select (_anchorPosition isEqualTo []); - - private _storedVehicles = []; - private _nearbyVehicles = []; - private _nearbyEntities = []; - private _candidateVehicles = []; - - { - _candidateVehicles pushBackUnique _x; - } forEach (_nearbyOrigin nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); - { - _candidateVehicles pushBackUnique _x; - } forEach ((getPosATL player) nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); - { - _candidateVehicles pushBackUnique _x; - } forEach (nearestObjects [_nearbyOrigin, ["AllVehicles"], _nearbyRadius]); - { - _candidateVehicles pushBackUnique _x; - } forEach (nearestObjects [getPosATL player, ["AllVehicles"], _nearbyRadius]); - - { - if (isNull _x) then { - continue; - }; - - if (_x isKindOf "CAManBase") then { - continue; - }; - - if !( - _x isKindOf "Car" || - _x isKindOf "Tank" || - _x isKindOf "Air" || - _x isKindOf "Ship" - ) then { - continue; - }; - - _nearbyEntities pushBackUnique _x; - } forEach _candidateVehicles; - - { - _storedVehicles pushBack ( - GVAR(GarageCatalogService) call ["buildStoredVehicle", [_x, _y]] - ); - } forEach _garageMap; - - private _storedVehiclePairs = _storedVehicles apply { - [toLowerANSI (_x getOrDefault ["displayName", ""]), _x] - }; - _storedVehiclePairs sort true; - _storedVehicles = _storedVehiclePairs apply { _x param [1, createHashMap] }; - - { - if (isNull _x) then { - continue; - }; - - private _builtVehicle = GVAR(GarageCatalogService) call ["buildNearbyVehicle", [_x, _nearbyOrigin]]; - if (_builtVehicle isEqualTo createHashMap) then { - continue; - }; - - _nearbyVehicles pushBack _builtVehicle; - } forEach _nearbyEntities; - - private _nearbyVehiclePairs = _nearbyVehicles apply { - [_x getOrDefault ["distance", 0], _x] - }; - _nearbyVehiclePairs sort true; - _nearbyVehicles = _nearbyVehiclePairs apply { _x param [1, createHashMap] }; - - private _spawnBlocked = ( - (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]) + - (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]) - ) isNotEqualTo []; - - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["garageName", _context getOrDefault ["name", "Vehicle Garage"]], - ["capacityUsed", count _storedVehicles], - ["capacityMax", 5], - ["nearbyCount", count _nearbyVehicles], - ["spawnBlocked", _spawnBlocked], - ["spawnStatus", ["Ready", "Blocked"] select _spawnBlocked] - ]], - ["garage", createHashMapFromArray [ - ["vehicles", _storedVehicles] - ]], - ["nearby", createHashMapFromArray [ - ["vehicles", _nearbyVehicles] - ]] - ] - }] -]; - -GVAR(GarageSessionService) = createHashMapObject [GVAR(GarageSessionServiceBaseClass)]; -GVAR(GarageSessionService) diff --git a/arma/client/addons/garage/functions/fnc_initUIBridge.sqf b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf index ec15d50..ee20a28 100644 --- a/arma/client/addons/garage/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf @@ -3,11 +3,20 @@ /* * File: fnc_initUIBridge.sqf * Author: IDSolutions - * Date: 2026-03-14 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the garage UI bridge for browser control state and retrieve/store actions. + * Initializes the garage UI bridge for browser control state and UI events. + * + * Arguments: + * None + * + * Return Value: + * Garage UI bridge object [HASHMAP OBJECT] + * + * Example: + * call forge_client_garage_fnc_initUIBridge; */ #pragma hemtt ignore_variables ["_self"] @@ -17,10 +26,6 @@ private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; GVAR(GarageUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["#base", _webUIBridgeDeclaration], ["#type", "GarageUIBridgeBaseClass"], - ["#create", compileFinal { - _self set ["pendingStoreVehicle", objNull]; - _self set ["pendingRetrieve", createHashMap]; - }], ["getActiveBrowserControl", compileFinal { private _display = uiNamespace getVariable ["RscGarage", displayNull]; if (isNull _display) exitWith { @@ -40,164 +45,13 @@ GVAR(GarageUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _screen call ["markReady", [true]]; _self call ["flushPendingEvents", []]; - _self call ["sendEvent", ["garage::hydrate", GVAR(GarageSessionService) call ["buildPayload", []], _control]]; + _self call ["sendEvent", ["garage::hydrate", GVAR(GaragePayloadService) call ["buildPayload", []], _control]]; }], ["refreshGarage", compileFinal { private _control = _self call ["getActiveBrowserControl", []]; if (isNull _control) exitWith { false }; - _self call ["sendEvent", ["garage::sync", GVAR(GarageSessionService) call ["buildPayload", []], _control]] - }], - ["handleRetrieveRequest", compileFinal { - params [["_data", createHashMap, [createHashMap]]]; - - private _plate = _data getOrDefault ["plate", ""]; - if (_plate isEqualTo "") exitWith { - _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ - ["message", "Select a stored vehicle to retrieve."] - ]]]; - }; - - private _garageMap = if (isNil QGVAR(GarageClass)) then { - createHashMap - } else { - GVAR(GarageClass) call ["getGarageState", []] - }; - private _vehicleData = _garageMap getOrDefault [_plate, createHashMap]; - if (_vehicleData isEqualTo createHashMap) exitWith { - _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ - ["message", "Stored vehicle record could not be found."] - ]]]; - }; - - private _context = GVAR(GarageSessionService) call ["getContext", []]; - private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; - private _spawnHeading = _context getOrDefault ["spawnHeading", getDir player]; - private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; - private _blockingVehicles = []; - { - _blockingVehicles pushBackUnique _x; - } forEach (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]); - { - _blockingVehicles pushBackUnique _x; - } forEach (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]); - if (_blockingVehicles isNotEqualTo []) exitWith { - _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ - ["message", "The garage spawn area is blocked."] - ]]]; - }; - - private _className = _vehicleData getOrDefault ["classname", ""]; - if (_className isEqualTo "") exitWith { - _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ - ["message", "Stored vehicle record is missing a classname."] - ]]]; - }; - - private _vehicle = createVehicle [_className, _spawnPosition, [], 0, "CAN_COLLIDE"]; - _vehicle setDir _spawnHeading; - _vehicle setFuel (_vehicleData getOrDefault ["fuel", 0]); - _vehicle setDamage (_vehicleData getOrDefault ["damage", 0]); - - private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; - private _hitPointNames = _hitPoints getOrDefault ["names", []]; - private _hitPointValues = _hitPoints getOrDefault ["values", []]; - for "_index" from 0 to ((count _hitPointNames) - 1) do { - _vehicle setHitPointDamage [_hitPointNames param [_index, ""], _hitPointValues param [_index, 0]]; - }; - - _vehicle setVariable ["forge_garage_plate", _plate, true]; - _vehicle setVariable ["forge_garage_owner_uid", getPlayerUID player, true]; - - _self set ["pendingRetrieve", createHashMapFromArray [ - ["plate", _plate], - ["vehicle", _vehicle] - ]]; - - [SRPC(garage,requestRetrieveVehicle), [getPlayerUID player, _plate]] call CFUNC(serverEvent); - }], - ["handleStoreRequest", compileFinal { - params [["_data", createHashMap, [createHashMap]]]; - - private _netId = _data getOrDefault ["netId", ""]; - if (_netId isEqualTo "") exitWith { - _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ - ["message", "Select a nearby vehicle to store."] - ]]]; - }; - - private _vehicle = objectFromNetId _netId; - if (isNull _vehicle) exitWith { - _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ - ["message", "The selected vehicle is no longer available."] - ]]]; - }; - - if (crew _vehicle isNotEqualTo []) exitWith { - _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ - ["message", "All crew must exit the vehicle before storing it."] - ]]]; - }; - - private _rawHitPoints = getAllHitPointsDamage _vehicle; - private _hitPointsJson = toJSON (createHashMapFromArray [ - ["names", _rawHitPoints param [0, []]], - ["selections", _rawHitPoints param [1, []]], - ["values", _rawHitPoints param [2, []]] - ]); - - _self set ["pendingStoreVehicle", _vehicle]; - [SRPC(garage,requestStoreVehicle), [ - getPlayerUID player, - typeOf _vehicle, - fuel _vehicle, - damage _vehicle, - _hitPointsJson - ]] call CFUNC(serverEvent); - }], - ["handleActionResponse", compileFinal { - params [["_payload", createHashMap, [createHashMap]]]; - - private _action = _payload getOrDefault ["action", ""]; - private _success = _payload getOrDefault ["success", false]; - private _message = _payload getOrDefault ["message", "Garage action failed."]; - - switch (_action) do { - case "retrieve": { - private _pendingRetrieve = _self getOrDefault ["pendingRetrieve", createHashMap]; - private _vehicle = _pendingRetrieve getOrDefault ["vehicle", objNull]; - - if (!_success && { !isNull _vehicle }) then { - deleteVehicle _vehicle; - }; - - _self set ["pendingRetrieve", createHashMap]; - _self call ["sendEvent", [[ - "garage::retrieve::failure", - "garage::retrieve::success" - ] select _success, createHashMapFromArray [["message", _message]]]]; - }; - case "store": { - private _vehicle = _self getOrDefault ["pendingStoreVehicle", objNull]; - - if (_success && { !isNull _vehicle }) then { - deleteVehicle _vehicle; - }; - - _self set ["pendingStoreVehicle", objNull]; - _self call ["sendEvent", [[ - "garage::store::failure", - "garage::store::success" - ] select _success, createHashMapFromArray [["message", _message]]]]; - }; - }; - - [] spawn { - sleep 0.05; - if !(isNil QGVAR(GarageUIBridge)) then { - GVAR(GarageUIBridge) call ["refreshGarage", []]; - }; - }; + _self call ["sendEvent", ["garage::sync", GVAR(GaragePayloadService) call ["buildPayload", []], _control]] }] ]; diff --git a/arma/client/addons/garage/functions/fnc_initVGClass.sqf b/arma/client/addons/garage/functions/fnc_initVGRepository.sqf similarity index 74% rename from arma/client/addons/garage/functions/fnc_initVGClass.sqf rename to arma/client/addons/garage/functions/fnc_initVGRepository.sqf index e5370d7..4e99a8a 100644 --- a/arma/client/addons/garage/functions/fnc_initVGClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initVGRepository.sqf @@ -1,51 +1,45 @@ #include "..\script_component.hpp" /* - * File: fnc_initVGClass.sqf + * File: fnc_initVGRepository.sqf * Author: IDSolutions - * Date: 2025-12-16 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the Virtual Garage class for managing player garage unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Garage. + * Initializes the virtual garage repository for BIS virtual garage state. * * Arguments: * None * * Return Value: - * vGarage class object [HASHMAP OBJECT] + * Virtual garage repository object [HASHMAP OBJECT] * * Example: - * call forge_client_garage_fnc_initVGClass; + * call forge_client_garage_fnc_initVGRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "VGBaseClass"], +GVAR(VGRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "VGRepositoryBaseClass"], ["#create", compileFinal { GVAR(isPreLoaded) = false; - - _self set ["uid", (getPlayerUID player)]; + _self set ["uid", getPlayerUID player]; _self set ["vGarage", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; }], ["init", compileFinal { private _uid = _self get "uid"; - private _vGarage = _self get "vGarage"; - - [SRPC(garage,requestInitVG), [_uid, _vGarage]] call CFUNC(serverEvent); + [SRPC(garage,requestInitVG), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["VGarage loaded for %1", (name player)]; - diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!"; + systemChat format ["VGarage loaded for %1", name player]; + diag_log "[FORGE:Client:VGarage] Repository Initialized!"; }], ["save", compileFinal { private _uid = _self get "uid"; [SRPC(garage,requestSaveVG), [_uid]] call CFUNC(serverEvent); - _self set ["lastSave", time]; }], ["sync", compileFinal { @@ -56,7 +50,6 @@ GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ { _vGarage set [_x, _y]; - switch (_x) do { case "cars": { _self call ["apply", ["cars"]]; }; case "armor": { _self call ["apply", ["armor"]]; }; @@ -69,9 +62,8 @@ GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ } forEach _data; _self set ["vGarage", _vGarage]; - if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:VGarage] Sync completed"; + diag_log "[FORGE:Client:VGarage] Repository sync completed"; }], ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; @@ -84,7 +76,6 @@ GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ private _vehicles = _self call ["get", [_key, []]]; private _appliedVehicles = []; - { _appliedVehicles append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]]; } forEach _vehicles; @@ -101,5 +92,5 @@ GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(VGClass) = createHashMapObject [GVAR(VGBaseClass)]; -GVAR(VGClass) +GVAR(VGRepository) = createHashMapObject [GVAR(VGRepositoryBaseClass)]; +GVAR(VGRepository) diff --git a/arma/client/addons/garage/functions/fnc_openVG.sqf b/arma/client/addons/garage/functions/fnc_openVG.sqf index ddc3d6b..70c3518 100644 --- a/arma/client/addons/garage/functions/fnc_openVG.sqf +++ b/arma/client/addons/garage/functions/fnc_openVG.sqf @@ -89,7 +89,7 @@ if !(GVAR(isPreLoaded)) then { private _nearVehicles = FORGE_VehSpawnPos nearEntities [["Car", "Tank", "Air", "Ship"], 5]; if (_nearVehicles isNotEqualTo []) exitWith { private _params = ["warning", "Virtual Garage", "Vehicle spawn position is blocked. Please move the vehicle before accessing the garage.", 3000]; - EGVAR(notifications,NotificationClass) call ["create", _params]; + EGVAR(notifications,NotificationService) call ["create", _params]; }; ["Open", true] call BFUNC(garage); diff --git a/arma/client/addons/locker/XEH_PREP.hpp b/arma/client/addons/locker/XEH_PREP.hpp index f3e1d08..b979dfe 100644 --- a/arma/client/addons/locker/XEH_PREP.hpp +++ b/arma/client/addons/locker/XEH_PREP.hpp @@ -1,2 +1,2 @@ -PREP(initLockerClass); -PREP(initVAClass); +PREP(initRepository); +PREP(initVARepository); diff --git a/arma/client/addons/locker/XEH_postInitClient.sqf b/arma/client/addons/locker/XEH_postInitClient.sqf index d4baf95..20123fa 100644 --- a/arma/client/addons/locker/XEH_postInitClient.sqf +++ b/arma/client/addons/locker/XEH_postInitClient.sqf @@ -1,48 +1,48 @@ #include "script_component.hpp" -if (isNil QGVAR(LockerClass)) then { call FUNC(initLockerClass); }; -if (isNil QGVAR(VAClass)) then { call FUNC(initVAClass); }; +if (isNil QGVAR(LockerRepository)) then { call FUNC(initRepository); }; +if (isNil QGVAR(VARepository)) then { call FUNC(initVARepository); }; [QGVAR(initLocker), { - GVAR(LockerClass) call ["init", []]; + GVAR(LockerRepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitLocker), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(LockerClass) call ["sync", [_data]]; + GVAR(LockerRepository) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncLocker), { params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]]; - GVAR(LockerClass) call ["sync", [_data, _jip]]; + GVAR(LockerRepository) call ["sync", [_data, _jip]]; }] call CFUNC(addEventHandler); [QGVAR(initVA), { - GVAR(VAClass) call ["init", []]; + GVAR(VARepository) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitVA), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(VAClass) call ["sync", [_data]]; + GVAR(VARepository) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncVA), { params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]]; - GVAR(VAClass) call ["sync", [_data, _jip]]; + GVAR(VARepository) call ["sync", [_data, _jip]]; }] call CFUNC(addEventHandler); [{ - EGVAR(garage,GarageClass) get "isLoaded"; + EGVAR(garage,GarageRepository) get "isLoaded"; }, { [QGVAR(initLocker), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); [{ - GVAR(LockerClass) get "isLoaded"; + GVAR(LockerRepository) get "isLoaded"; }, { [QGVAR(initVA), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf b/arma/client/addons/locker/functions/fnc_initRepository.sqf similarity index 86% rename from arma/client/addons/locker/functions/fnc_initLockerClass.sqf rename to arma/client/addons/locker/functions/fnc_initRepository.sqf index 4012c52..e67157e 100644 --- a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initRepository.sqf @@ -1,31 +1,29 @@ #include "..\script_component.hpp" /* - * File: fnc_initLockerClass.sqf + * File: fnc_initRepository.sqf * Author: IDSolutions - * Date: 2025-12-17 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the Locker class for managing player locker items. - * Provides methods for syncing, saving, and applying locker items to the player's locker. + * Initializes the locker repository for managing player locker items. * * Arguments: * None * * Return Value: - * Locker class object [HASHMAP OBJECT] + * Locker repository object [HASHMAP OBJECT] * * Example: - * call forge_client_locker_fnc_initLockerClass + * call forge_client_locker_fnc_initRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "LockerBaseClass"], +GVAR(LockerRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "LockerRepositoryBaseClass"], ["#create", compileFinal { - _self set ["uid", (getPlayerUID player)]; + _self set ["uid", getPlayerUID player]; _self set ["isLoaded", false]; _self set ["lastSave", time]; _self set ["locker", createHashMap]; @@ -36,8 +34,8 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ [SRPC(locker,requestInitLocker), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Locker loaded for %1", (name player)]; - diag_log "[FORGE:Client:Locker] Locker Class Initialized!"; + systemChat format ["Locker loaded for %1", name player]; + diag_log "[FORGE:Client:Locker] Locker Repository Initialized!"; }], ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; @@ -84,8 +82,8 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ private _cfgWeapons = configFile >> "CfgWeapons" >> _containerClass; private _itemInfoType = getNumber (_cfgWeapons >> "ItemInfo" >> "type"); private _isBackpack = isClass _cfgVehicles; - private _isUniform = isClass _cfgWeapons && {_itemInfoType == TYPE_UNIFORM}; - private _isVest = isClass _cfgWeapons && {_itemInfoType == TYPE_VEST}; + private _isUniform = isClass _cfgWeapons && { _itemInfoType == TYPE_UNIFORM }; + private _isVest = isClass _cfgWeapons && { _itemInfoType == TYPE_VEST }; if (!_isBackpack && !_isVest && !_isUniform) then { continue; }; @@ -142,7 +140,6 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ private _weaponItems = weaponsItemsCargo _container; { - // private _weapon = _x param [0, ""]; private _muzzle = _x param [1, ""]; private _pointer = _x param [2, ""]; private _optic = _x param [3, ""]; @@ -150,7 +147,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ private _underbarrel = _x param [5, ""]; private _bipod = _x param [6, ""]; private _secondaryMag = _x param [7, ["", 0]]; - private _attachments = [_muzzle, _pointer, _optic, _underbarrel, _bipod] select {(_x isEqualType "") && {_x != ""}}; + private _attachments = [_muzzle, _pointer, _optic, _underbarrel, _bipod] select { (_x isEqualType "") && { _x != "" } }; { private _existing = _locker getOrDefault [_x, createHashMap]; private _existingCount = _existing getOrDefault ["amount", 0]; @@ -163,7 +160,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ } forEach _attachments; if (_primaryMag isNotEqualTo ["", 0]) then { - _primaryMag params ["_magClass", "_ammoCount"]; // TODO: Add ammo count to locker + _primaryMag params ["_magClass", "_ammoCount"]; if (_magClass != "") then { private _existing = _locker getOrDefault [_magClass, createHashMap]; private _existingCount = _existing getOrDefault ["amount", 0]; @@ -177,7 +174,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ }; if (_secondaryMag isNotEqualTo ["", 0]) then { - _secondaryMag params ["_magClass", "_ammoCount"]; // TODO: Add ammo count to locker + _secondaryMag params ["_magClass", "_ammoCount"]; if (_magClass != "") then { private _existing = _locker getOrDefault [_magClass, createHashMap]; private _existingCount = _existing getOrDefault ["amount", 0]; @@ -205,7 +202,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ _locker addEventHandler ["ContainerOpened", { params ["_container", "_unit"]; - private _index = GVAR(LockerClass) get "locker"; + private _index = GVAR(LockerRepository) get "locker"; clearBackpackCargo _container; clearItemCargo _container; @@ -228,7 +225,7 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ if (count _index > 25) then { private _params = ["warning", "Over Capacity", "Locker has more then 25 items, please remove some items", 3000]; - GVAR(NotificationClass) call ["create", _params]; + GVAR(NotificationService) call ["create", _params]; }; }]; @@ -236,17 +233,17 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ params ["_container", "_unit"]; private _newLocker = createHashMap; - _newLocker = GVAR(LockerClass) call ["getCargo", [_container, _newLocker]]; - _newLocker = GVAR(LockerClass) call ["getContainerItems", [_container, _newLocker]]; - _newLocker = GVAR(LockerClass) call ["getAttachments", [_container, _newLocker]]; + _newLocker = GVAR(LockerRepository) call ["getCargo", [_container, _newLocker]]; + _newLocker = GVAR(LockerRepository) call ["getContainerItems", [_container, _newLocker]]; + _newLocker = GVAR(LockerRepository) call ["getAttachments", [_container, _newLocker]]; private _uid = getPlayerUID _unit; [SRPC(locker,requestOverrideLocker), [_uid, _newLocker]] call CFUNC(serverEvent); - GVAR(LockerClass) set ["locker", _newLocker]; + GVAR(LockerRepository) set ["locker", _newLocker]; if (count _newLocker > 25) then { private _params = ["warning", "Over Capacity", "Locker has more then 25 items, please remove some items", 3000]; - GVAR(NotificationClass) call ["create", _params]; + GVAR(NotificationService) call ["create", _params]; }; }]; }], @@ -296,5 +293,5 @@ GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(LockerClass) = createHashMapObject [GVAR(LockerBaseClass)]; -GVAR(LockerClass) +GVAR(LockerRepository) = createHashMapObject [GVAR(LockerRepositoryBaseClass)]; +GVAR(LockerRepository) diff --git a/arma/client/addons/locker/functions/fnc_initVAClass.sqf b/arma/client/addons/locker/functions/fnc_initVARepository.sqf similarity index 79% rename from arma/client/addons/locker/functions/fnc_initVAClass.sqf rename to arma/client/addons/locker/functions/fnc_initVARepository.sqf index d6c0749..d4aa4b9 100644 --- a/arma/client/addons/locker/functions/fnc_initVAClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initVARepository.sqf @@ -1,31 +1,29 @@ #include "..\script_component.hpp" /* - * File: fnc_init.sqf + * File: fnc_initVARepository.sqf * Author: IDSolutions - * Date: 2025-12-16 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the Virtual Arsenal class for managing player arsenal unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. + * Initializes the virtual arsenal repository for managing player arsenal unlocks. * * Arguments: * None * * Return Value: - * vArsenal class object [HASHMAP OBJECT] + * Virtual arsenal repository object [HASHMAP OBJECT] * * Example: - * call forge_client_locker_fnc_init; + * call forge_client_locker_fnc_initVARepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(VABaseClass) = compileFinal createHashMapFromArray [ - ["#type", "VABaseClass"], +GVAR(VARepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "VARepositoryBaseClass"], ["#create", compileFinal { - _self set ["uid", (getPlayerUID player)]; + _self set ["uid", getPlayerUID player]; _self set ["vArsenal", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; @@ -36,8 +34,8 @@ GVAR(VABaseClass) = compileFinal createHashMapFromArray [ [SRPC(locker,requestInitVA), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["VArsenal loaded for %1", (name player)]; - diag_log "[FORGE:Client:VArsenal] VArsenal Class Initialized!"; + systemChat format ["VArsenal loaded for %1", name player]; + diag_log "[FORGE:Client:VArsenal] Repository Initialized!"; }], ["save", compileFinal { private _uid = _self get "uid"; @@ -92,5 +90,5 @@ GVAR(VABaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(VAClass) = createHashMapObject [GVAR(VABaseClass)]; -GVAR(VAClass) +GVAR(VARepository) = createHashMapObject [GVAR(VARepositoryBaseClass)]; +GVAR(VARepository) diff --git a/arma/client/addons/notifications/XEH_PREP.hpp b/arma/client/addons/notifications/XEH_PREP.hpp index c0e2dad..e3b9ad1 100644 --- a/arma/client/addons/notifications/XEH_PREP.hpp +++ b/arma/client/addons/notifications/XEH_PREP.hpp @@ -1,3 +1,3 @@ PREP(handleUIEvents); -PREP(initNotificationClass); +PREP(initService); PREP(openUI); diff --git a/arma/client/addons/notifications/XEH_postInitClient.sqf b/arma/client/addons/notifications/XEH_postInitClient.sqf index 9981181..6504295 100644 --- a/arma/client/addons/notifications/XEH_postInitClient.sqf +++ b/arma/client/addons/notifications/XEH_postInitClient.sqf @@ -1,16 +1,16 @@ #include "script_component.hpp" [{ - EGVAR(locker,VAClass) get "isLoaded"; + EGVAR(locker,VARepository) get "isLoaded"; }, { ("NotificationHudLayer" call BFUNC(rscLayer)) cutRsc ["RscNotifications", "PLAIN"]; call FUNC(openUI); - if (isNil QGVAR(NotificationClass)) then { call FUNC(initNotificationClass); }; + if (isNil QGVAR(NotificationService)) then { call FUNC(initService); }; }] call CFUNC(waitUntilAndExecute); [QGVAR(recieveNotification), { params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]]; playSound QGVAR(notify); - GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]]; + GVAR(NotificationService) call ["create", [_type, _title, _content, _duration]]; }] call CFUNC(addEventHandler); diff --git a/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf b/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf index 7c84d95..287842c 100644 --- a/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf @@ -32,7 +32,7 @@ diag_log format ["[FORGE:Client:Notifications] Handling UI event: %1 with data: switch (_event) do { case "notifications::ready": { - GVAR(NotificationClass) call ["init", []]; + GVAR(NotificationService) call ["init", []]; }; default { hint format ["[FORGE:Client:Notifications] Unhandled event: %1", _event]; }; }; diff --git a/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf b/arma/client/addons/notifications/functions/fnc_initService.sqf similarity index 66% rename from arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf rename to arma/client/addons/notifications/functions/fnc_initService.sqf index 06ed9cc..cfdb3ce 100644 --- a/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf +++ b/arma/client/addons/notifications/functions/fnc_initService.sqf @@ -1,29 +1,27 @@ #include "..\script_component.hpp" /* - * File: fnc_initNotificationClass.sqf + * File: fnc_initService.sqf * Author: IDSolutions - * Date: 2026-01-28 - * Last Update: 2026-01-30 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the notification class for managing player notifications. - * Provides methods for creating and displaying notifications. + * Initializes the notification service for client notification display. * * Arguments: * None * * Return Value: - * Notification class object [HASHMAP OBJECT] + * Notification service object [HASHMAP OBJECT] * * Example: - * call forge_client_notifications_fnc_initNotificationClass + * call forge_client_notifications_fnc_initService; */ #pragma hemtt ignore_variables ["_self"] -GVAR(NotificationClass) = createHashMapObject [[ - ["#type", "INotificationClass"], +GVAR(NotificationService) = createHashMapObject [[ + ["#type", "INotificationService"], ["#create", { private _display = uiNamespace getVariable ["RscNotifications", nil]; private _control = _display displayCtrl 1004; @@ -37,8 +35,8 @@ GVAR(NotificationClass) = createHashMapObject [[ _self call ["create", _params]; _self set ["isLoaded", true]; - systemChat format ["Notifications loaded for %1", (name player)]; - diag_log "[FORGE:Client:Notifications] Notification Class Initialized!"; + systemChat format ["Notifications loaded for %1", name player]; + diag_log "[FORGE:Client:Notifications] Notification Service Initialized!"; }], ["create", { params [["_type", "", ["info"]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000]]; @@ -55,4 +53,4 @@ GVAR(NotificationClass) = createHashMapObject [[ }] ]]; -GVAR(NotificationClass) +GVAR(NotificationService) diff --git a/arma/client/addons/org/XEH_PREP.hpp b/arma/client/addons/org/XEH_PREP.hpp index 7d71bae..fb83b48 100644 --- a/arma/client/addons/org/XEH_PREP.hpp +++ b/arma/client/addons/org/XEH_PREP.hpp @@ -1,4 +1,4 @@ PREP(handleUIEvents); -PREP(initClass); +PREP(initRepository); PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index 1d0d2c2..870a9fc 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -1,6 +1,6 @@ #include "script_component.hpp" -if (isNil QGVAR(OrgClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(OrgRepository)) then { call FUNC(initRepository); }; if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(responseInitOrg), { @@ -46,7 +46,7 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [{ - EGVAR(locker,VAClass) get "isLoaded"; + EGVAR(locker,VARepository) get "isLoaded"; }, { [QGVAR(initOrg), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf index a9657c3..6563ac6 100644 --- a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf @@ -1,19 +1,24 @@ #include "..\script_component.hpp" /* + * File: fnc_handleUIEvents.sqf * Author: IDSolutions - * Handles the UI events. + * Date: 2026-03-27 + * Public: No + * + * Description: + * Handles the org UI events. * * Arguments: - * None + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data * * Return Value: - * None + * UI events handled [BOOL] * * Example: * call forge_client_org_fnc_handleUIEvents; - * - * Public: No */ params ["_control", "_isConfirmDialog", "_message"]; diff --git a/arma/client/addons/org/functions/fnc_initClass.sqf b/arma/client/addons/org/functions/fnc_initRepository.sqf similarity index 57% rename from arma/client/addons/org/functions/fnc_initClass.sqf rename to arma/client/addons/org/functions/fnc_initRepository.sqf index 52695a5..ac50b8e 100644 --- a/arma/client/addons/org/functions/fnc_initClass.sqf +++ b/arma/client/addons/org/functions/fnc_initRepository.sqf @@ -1,28 +1,27 @@ #include "..\script_component.hpp" /* - * File: fnc_initClass.sqf + * File: fnc_initRepository.sqf * Author: IDSolutions - * Date: 2026-03-25 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * No description added yet. + * Initializes the org repository for client org lifecycle state. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * Org repository object [HASHMAP OBJECT] * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_client_org_fnc_initRepository; */ #pragma hemtt ignore_variables ["_self"] -GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ - ["#type", "OrgBaseClass"], +GVAR(OrgRepositoryBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "OrgRepositoryBaseClass"], ["#create", compileFinal { _self set ["uid", getPlayerUID player]; _self set ["isLoaded", false]; @@ -32,12 +31,11 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ [SRPC(org,requestInitOrg), [getPlayerUID player]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Org loaded for %1", (name player)]; - diag_log "[FORGE:Client:Org] Org Class Initialized!"; + systemChat format ["Org loaded for %1", name player]; + diag_log "[FORGE:Client:Org] Org Repository Initialized!"; }], ["markLoaded", compileFinal { if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; - true }], ["save", compileFinal { @@ -46,5 +44,5 @@ GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ }] ]; -GVAR(OrgClass) = createHashMapObject [GVAR(OrgBaseClass)]; -GVAR(OrgClass) +GVAR(OrgRepository) = createHashMapObject [GVAR(OrgRepositoryBaseClass)]; +GVAR(OrgRepository) diff --git a/arma/client/addons/org/functions/fnc_openUI.sqf b/arma/client/addons/org/functions/fnc_openUI.sqf index 7506dd4..e146d45 100644 --- a/arma/client/addons/org/functions/fnc_openUI.sqf +++ b/arma/client/addons/org/functions/fnc_openUI.sqf @@ -1,19 +1,22 @@ #include "..\script_component.hpp" /* + * File: fnc_openUI.sqf * Author: IDSolutions - * Opens the player interaction interface. + * Date: 2026-03-27 + * Public: No + * + * Description: + * Opens the org UI. * * Arguments: * None * * Return Value: - * None + * UI opened [BOOL] * * Example: * call forge_client_org_fnc_openUI; - * - * Public: No */ private _display = createDialog ["RscOrg", true]; diff --git a/arma/client/addons/store/functions/fnc_initUIBridge.sqf b/arma/client/addons/store/functions/fnc_initUIBridge.sqf index e4a1b3c..e2e7b47 100644 --- a/arma/client/addons/store/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/store/functions/fnc_initUIBridge.sqf @@ -3,12 +3,20 @@ /* * File: fnc_initUIBridge.sqf * Author: IDSolutions - * Date: 2026-03-10 - * Last Update: 2026-03-25 + * Date: 2026-03-27 * Public: No * * Description: - * Initializes the store UI bridge for browser control state, event routing, and catalog queries. + * Initializes the store UI bridge for browser control state and store UI events. + * + * Arguments: + * None + * + * Return Value: + * Store UI bridge object [HASHMAP OBJECT] + * + * Example: + * call forge_client_store_fnc_initUIBridge; */ #pragma hemtt ignore_variables ["_self"] diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index cd01078..b55cf35 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -185,6 +185,12 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; if (_org isEqualTo createHashMap) exitWith { createHashMap }; + // Ensure the requesting player's membership is present in the cached roster + // before shaping the portal payload. This prevents stale org caches from + // omitting the current member while still resolving owner metadata. + _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; + GVAR(Registry) set [_orgID, _org, true]; + private _name = _org getOrDefault ["name", ""]; private _id = _org getOrDefault ["id", _orgID]; private _ownerUid = _org getOrDefault ["owner", ""]; diff --git a/build-arma.ps1 b/build-arma.ps1 index d7b67c3..612f650 100644 --- a/build-arma.ps1 +++ b/build-arma.ps1 @@ -10,6 +10,9 @@ .PARAMETER Target Specify which target to build: 'client', 'server', or 'both' (default) +.PARAMETER BuildUI + Rebuild the web UI bundles before running the client build. + .EXAMPLE .\build-arma.ps1 Builds both client and server @@ -17,12 +20,19 @@ .EXAMPLE .\build-arma.ps1 -Target client Builds only the client + +.EXAMPLE + .\build-arma.ps1 -Target client -BuildUI + Rebuilds web UI bundles and then builds the client #> param( [Parameter(Mandatory=$false)] [ValidateSet('client', 'server', 'both')] - [string]$Target = 'both' + [string]$Target = 'both', + + [Parameter(Mandatory=$false)] + [switch]$BuildUI ) $ErrorActionPreference = "Stop" @@ -70,7 +80,9 @@ $serverPath = Join-Path $scriptDir "arma\server" try { if ($Target -eq 'client' -or $Target -eq 'both') { - Build-WebUIAssets + if ($BuildUI) { + Build-WebUIAssets + } Build-HemttProject -ProjectPath $clientPath -ProjectName "Client" } From 45a4f7460a6ae63a798b84cdccaf549b1b9196b8 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sat, 28 Mar 2026 02:20:34 -0500 Subject: [PATCH 04/19] Integrate task contracts and CAD UI pipeline - add the imported server task addon to the current framework with task ownership, task catalog, mission-manager attack generation, org-owned reward routing, participant notifications, and reputation syncing - restructure org persistence so core org data, assets, fleet, and members are handled through the current Redis/extension model with matching Rust repository and service updates - wire the client CAD addon into the framework, actor device action, shared web UI bridge pattern, and task listing/acceptance flow - add a source-driven CAD web UI layout with ui.config.mjs and extend the shared web UI builder to support custom HTML template pages for multi-surface UIs --- .../actor/functions/fnc_handleUIEvents.sqf | 3 +- arma/client/addons/actor/ui/_site/script.js | 12 + arma/client/addons/cad/$PBOPREFIX$ | 1 + arma/client/addons/cad/CfgEventHandlers.hpp | 12 + arma/client/addons/cad/MAP_README.md | 214 +++++ arma/client/addons/cad/XEH_PREP.hpp | 5 + arma/client/addons/cad/XEH_postInitClient.sqf | 24 + arma/client/addons/cad/XEH_preInit.sqf | 5 + arma/client/addons/cad/XEH_preInitClient.sqf | 1 + arma/client/addons/cad/config.cpp | 21 + .../cad/functions/fnc_handleUIEvents.sqf | 87 ++ .../cad/functions/fnc_initRepository.sqf | 52 ++ .../addons/cad/functions/fnc_initUI.sqf | 90 ++ .../addons/cad/functions/fnc_initUIBridge.sqf | 84 ++ .../addons/cad/functions/fnc_openUI.sqf | 47 ++ arma/client/addons/cad/script_component.hpp | 9 + arma/client/addons/cad/ui/RscCommon.hpp | 6 + arma/client/addons/cad/ui/RscMapUI.hpp | 90 ++ .../client/addons/cad/ui/_site/bottombar.html | 1 + .../addons/cad/ui/_site/cad-bottombar.css | 1 + .../addons/cad/ui/_site/cad-bottombar.js | 1 + .../client/addons/cad/ui/_site/cad-common.css | 1 + arma/client/addons/cad/ui/_site/cad-shared.js | 1 + .../addons/cad/ui/_site/cad-sidepanel.css | 1 + .../addons/cad/ui/_site/cad-sidepanel.js | 1 + .../client/addons/cad/ui/_site/cad-topbar.css | 1 + arma/client/addons/cad/ui/_site/cad-topbar.js | 1 + .../client/addons/cad/ui/_site/sidepanel.html | 1 + arma/client/addons/cad/ui/_site/topbar.html | 1 + arma/client/addons/cad/ui/src/bottombar.html | 49 ++ arma/client/addons/cad/ui/src/bottombar.js | 6 + arma/client/addons/cad/ui/src/shared.js | 69 ++ arma/client/addons/cad/ui/src/sidepanel.html | 63 ++ arma/client/addons/cad/ui/src/sidepanel.js | 94 +++ .../addons/cad/ui/src/styles/bottombar.css | 33 + .../addons/cad/ui/src/styles/common.css | 78 ++ .../addons/cad/ui/src/styles/sidepanel.css | 136 +++ .../addons/cad/ui/src/styles/topbar.css | 67 ++ arma/client/addons/cad/ui/src/topbar.html | 63 ++ arma/client/addons/cad/ui/src/topbar.js | 17 + arma/client/addons/cad/ui/ui.config.mjs | 69 ++ arma/server/.hemtt/lints.toml | 2 +- .../addons/bank/functions/fnc_initStore.sqf | 12 +- .../common/functions/fnc_formatNumber.sqf | 7 +- .../functions/fnc_initFEconomyStore.sqf | 2 +- .../functions/fnc_initMEconomyStore.sqf | 2 +- .../locker/functions/fnc_initVAStore.sqf | 4 +- arma/server/addons/org/XEH_preInit.sqf | 2 +- .../addons/org/functions/fnc_initOrgStore.sqf | 219 ++++- .../org/functions/fnc_treasuryService.sqf | 6 +- .../functions/fnc_initCatalogService.sqf | 2 +- .../store/functions/fnc_initStoreStore.sqf | 2 +- arma/server/addons/task/$PBOPREFIX$ | 1 + arma/server/addons/task/CfgEventHandlers.hpp | 17 + arma/server/addons/task/CfgFactionClasses.hpp | 6 + arma/server/addons/task/CfgMissions.hpp | 269 ++++++ arma/server/addons/task/CfgVehicles.hpp | 782 ++++++++++++++++++ arma/server/addons/task/README.md | 104 +++ arma/server/addons/task/XEH_PREP.hpp | 31 + arma/server/addons/task/XEH_postInit.sqf | 16 + arma/server/addons/task/XEH_preInit.sqf | 35 + arma/server/addons/task/XEH_preStart.sqf | 2 + arma/server/addons/task/config.cpp | 23 + .../addons/task/functions/fnc_attack.sqf | 116 +++ .../task/functions/fnc_attackModule.sqf | 51 ++ .../addons/task/functions/fnc_defend.sqf | 126 +++ .../task/functions/fnc_defendModule.sqf | 61 ++ .../addons/task/functions/fnc_defuse.sqf | 114 +++ .../task/functions/fnc_defuseModule.sqf | 64 ++ .../addons/task/functions/fnc_delivery.sqf | 120 +++ .../task/functions/fnc_deliveryModule.sqf | 67 ++ .../addons/task/functions/fnc_destroy.sqf | 114 +++ .../task/functions/fnc_destroyModule.sqf | 51 ++ .../task/functions/fnc_explosivesModule.sqf | 23 + .../task/functions/fnc_handleTaskRewards.sqf | 223 +++++ .../addons/task/functions/fnc_handler.sqf | 108 +++ .../addons/task/functions/fnc_heartBeat.sqf | 68 ++ .../addons/task/functions/fnc_hostage.sqf | 173 ++++ .../task/functions/fnc_hostageModule.sqf | 76 ++ .../task/functions/fnc_hostagesModule.sqf | 23 + arma/server/addons/task/functions/fnc_hvt.sqf | 128 +++ .../addons/task/functions/fnc_hvtModule.sqf | 59 ++ .../task/functions/fnc_initTaskStore.sqf | 545 ++++++++++++ .../addons/task/functions/fnc_makeCargo.sqf | 41 + .../addons/task/functions/fnc_makeHVT.sqf | 30 + .../addons/task/functions/fnc_makeHostage.sqf | 30 + .../addons/task/functions/fnc_makeIED.sqf | 32 + .../addons/task/functions/fnc_makeObject.sqf | 28 + .../addons/task/functions/fnc_makeShooter.sqf | 28 + .../addons/task/functions/fnc_makeTarget.sqf | 28 + .../task/functions/fnc_missionManager.sqf | 369 +++++++++ .../task/functions/fnc_protectedModule.sqf | 23 + .../task/functions/fnc_shootersModule.sqf | 23 + .../task/functions/fnc_spawnEnemyWave.sqf | 83 ++ arma/server/addons/task/script_component.hpp | 9 + arma/server/addons/task/stringtable.xml | 8 + arma/server/extension/src/org.rs | 62 ++ arma/server/extension/src/redis/hash.rs | 11 +- lib/models/src/lib.rs | 2 +- lib/models/src/org.rs | 24 + lib/repositories/src/actor.rs | 19 +- lib/repositories/src/bank.rs | 19 +- lib/repositories/src/org.rs | 141 +++- lib/repositories/src/v_garage.rs | 19 +- lib/repositories/src/v_locker.rs | 19 +- lib/services/src/org.rs | 74 +- tools/build-webui.mjs | 61 +- 107 files changed, 6435 insertions(+), 122 deletions(-) create mode 100644 arma/client/addons/cad/$PBOPREFIX$ create mode 100644 arma/client/addons/cad/CfgEventHandlers.hpp create mode 100644 arma/client/addons/cad/MAP_README.md create mode 100644 arma/client/addons/cad/XEH_PREP.hpp create mode 100644 arma/client/addons/cad/XEH_postInitClient.sqf create mode 100644 arma/client/addons/cad/XEH_preInit.sqf create mode 100644 arma/client/addons/cad/XEH_preInitClient.sqf create mode 100644 arma/client/addons/cad/config.cpp create mode 100644 arma/client/addons/cad/functions/fnc_handleUIEvents.sqf create mode 100644 arma/client/addons/cad/functions/fnc_initRepository.sqf create mode 100644 arma/client/addons/cad/functions/fnc_initUI.sqf create mode 100644 arma/client/addons/cad/functions/fnc_initUIBridge.sqf create mode 100644 arma/client/addons/cad/functions/fnc_openUI.sqf create mode 100644 arma/client/addons/cad/script_component.hpp create mode 100644 arma/client/addons/cad/ui/RscCommon.hpp create mode 100644 arma/client/addons/cad/ui/RscMapUI.hpp create mode 100644 arma/client/addons/cad/ui/_site/bottombar.html create mode 100644 arma/client/addons/cad/ui/_site/cad-bottombar.css create mode 100644 arma/client/addons/cad/ui/_site/cad-bottombar.js create mode 100644 arma/client/addons/cad/ui/_site/cad-common.css create mode 100644 arma/client/addons/cad/ui/_site/cad-shared.js create mode 100644 arma/client/addons/cad/ui/_site/cad-sidepanel.css create mode 100644 arma/client/addons/cad/ui/_site/cad-sidepanel.js create mode 100644 arma/client/addons/cad/ui/_site/cad-topbar.css create mode 100644 arma/client/addons/cad/ui/_site/cad-topbar.js create mode 100644 arma/client/addons/cad/ui/_site/sidepanel.html create mode 100644 arma/client/addons/cad/ui/_site/topbar.html create mode 100644 arma/client/addons/cad/ui/src/bottombar.html create mode 100644 arma/client/addons/cad/ui/src/bottombar.js create mode 100644 arma/client/addons/cad/ui/src/shared.js create mode 100644 arma/client/addons/cad/ui/src/sidepanel.html create mode 100644 arma/client/addons/cad/ui/src/sidepanel.js create mode 100644 arma/client/addons/cad/ui/src/styles/bottombar.css create mode 100644 arma/client/addons/cad/ui/src/styles/common.css create mode 100644 arma/client/addons/cad/ui/src/styles/sidepanel.css create mode 100644 arma/client/addons/cad/ui/src/styles/topbar.css create mode 100644 arma/client/addons/cad/ui/src/topbar.html create mode 100644 arma/client/addons/cad/ui/src/topbar.js create mode 100644 arma/client/addons/cad/ui/ui.config.mjs create mode 100644 arma/server/addons/task/$PBOPREFIX$ create mode 100644 arma/server/addons/task/CfgEventHandlers.hpp create mode 100644 arma/server/addons/task/CfgFactionClasses.hpp create mode 100644 arma/server/addons/task/CfgMissions.hpp create mode 100644 arma/server/addons/task/CfgVehicles.hpp create mode 100644 arma/server/addons/task/README.md create mode 100644 arma/server/addons/task/XEH_PREP.hpp create mode 100644 arma/server/addons/task/XEH_postInit.sqf create mode 100644 arma/server/addons/task/XEH_preInit.sqf create mode 100644 arma/server/addons/task/XEH_preStart.sqf create mode 100644 arma/server/addons/task/config.cpp create mode 100644 arma/server/addons/task/functions/fnc_attack.sqf create mode 100644 arma/server/addons/task/functions/fnc_attackModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_defend.sqf create mode 100644 arma/server/addons/task/functions/fnc_defendModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_defuse.sqf create mode 100644 arma/server/addons/task/functions/fnc_defuseModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_delivery.sqf create mode 100644 arma/server/addons/task/functions/fnc_deliveryModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_destroy.sqf create mode 100644 arma/server/addons/task/functions/fnc_destroyModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_explosivesModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_handleTaskRewards.sqf create mode 100644 arma/server/addons/task/functions/fnc_handler.sqf create mode 100644 arma/server/addons/task/functions/fnc_heartBeat.sqf create mode 100644 arma/server/addons/task/functions/fnc_hostage.sqf create mode 100644 arma/server/addons/task/functions/fnc_hostageModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_hostagesModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_hvt.sqf create mode 100644 arma/server/addons/task/functions/fnc_hvtModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_initTaskStore.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeCargo.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeHVT.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeHostage.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeIED.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeObject.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeShooter.sqf create mode 100644 arma/server/addons/task/functions/fnc_makeTarget.sqf create mode 100644 arma/server/addons/task/functions/fnc_missionManager.sqf create mode 100644 arma/server/addons/task/functions/fnc_protectedModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_shootersModule.sqf create mode 100644 arma/server/addons/task/functions/fnc_spawnEnemyWave.sqf create mode 100644 arma/server/addons/task/script_component.hpp create mode 100644 arma/server/addons/task/stringtable.xml diff --git a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf index 9894313..32dfca8 100644 --- a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf @@ -4,7 +4,7 @@ * File: fnc_handleUIEvents.sqf * Author: IDSolutions * Date: 2026-01-28 - * Last Update: 2026-02-17 + * Last Update: 2026-03-28 * Public: No * * Description: @@ -35,6 +35,7 @@ switch (_event) do { case "actor::close::menu": { closeDialog 1; }; case "actor::open::atm": { [true] spawn EFUNC(bank,openUI); }; case "actor::open::bank": { [] spawn EFUNC(bank,openUI); }; + case "actor::open::cad": { [] spawn EFUNC(cad,openUI); }; case "actor::open::device": { hint "Device interaction is not yet implemented."; }; case "actor::open::garage": { [] spawn EFUNC(garage,openUI); }; case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); }; diff --git a/arma/client/addons/actor/ui/_site/script.js b/arma/client/addons/actor/ui/_site/script.js index 62fcf3a..66a283e 100644 --- a/arma/client/addons/actor/ui/_site/script.js +++ b/arma/client/addons/actor/ui/_site/script.js @@ -100,6 +100,12 @@ const actions = { //============================================================================= const baseMenuItems = [ + { + id: "cad", + title: "CAD", + description: "Access CAD (Computer Aided Dispatch)", + action: "actor::open::cad", + }, { id: "phone", title: "Phone", @@ -133,6 +139,12 @@ const actionDefinitions = { description: "Access your bank account and manage finances", action: "actor::open::bank", }, + cad: { + id: "cad", + title: "CAD", + description: "Access the CAD", + action: "actor::open::cad", + }, phone: { id: "phone", title: "Phone", diff --git a/arma/client/addons/cad/$PBOPREFIX$ b/arma/client/addons/cad/$PBOPREFIX$ new file mode 100644 index 0000000..4067b98 --- /dev/null +++ b/arma/client/addons/cad/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_client\addons\cad diff --git a/arma/client/addons/cad/CfgEventHandlers.hpp b/arma/client/addons/cad/CfgEventHandlers.hpp new file mode 100644 index 0000000..86e43be --- /dev/null +++ b/arma/client/addons/cad/CfgEventHandlers.hpp @@ -0,0 +1,12 @@ +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + clientInit = QUOTE(call COMPILE_SCRIPT(XEH_preInitClient)); + }; +}; + +class Extended_PostInit_EventHandlers { + class ADDON { + clientInit = QUOTE(call COMPILE_SCRIPT(XEH_postInitClient)); + }; +}; diff --git a/arma/client/addons/cad/MAP_README.md b/arma/client/addons/cad/MAP_README.md new file mode 100644 index 0000000..157db6b --- /dev/null +++ b/arma/client/addons/cad/MAP_README.md @@ -0,0 +1,214 @@ +# Integrated Map Display System (A3API Pattern) + +This system integrates the Arma 3 native map control (`RscMapControl`) within an HTML/CSS/JS UI using Arma's proper WebBrowser control (type 106) and A3API communication pattern. + +## How It Works + +### Layered Architecture + +1. **IFrame Control (type 106)** - Loads HTML content using `ctrlWebBrowserAction` +2. **Map Control (RscMapControl)** - Native Arma map positioned behind/within the UI +3. **A3API Communication** - Bidirectional communication between JavaScript and SQF + +### Communication Flow + +**JavaScript → SQF:** +```javascript +// Send alert (no response expected) +A3API.SendAlert(JSON.stringify({ + event: "map::zoomIn", + data: null +})); + +// Send confirm (expects response via ExecJS) +A3API.SendConfirm(JSON.stringify({ + event: "map::getPosition", + data: null +})); +``` + +**SQF → JavaScript:** +```sqf +_control ctrlWebBrowserAction ["ExecJS", "updateMapState({center: [1000, 2000], scale: 0.5});"]; +``` + +## File Structure + +``` +UI/map/ +├── _site/ +│ ├── index.html # HTML with A3API dynamic loading +│ ├── script.js # JavaScript using A3API +│ └── style.css # Styling +└── MAP_README.md # This file + +functions/map/ +├── fn_openMap.sqf # Opens the display +├── fn_mapHandleUIEvents.sqf # Handles JS events +├── fn_mapDisplay.sqf # Display initialization +└── fn_mapDisplayUpdate.sqf # Update loop + +UI/MapDisplay.h # Dialog definition +``` + +## Usage + +### Opening the Map + +```sqf +[] call FORGE_fnc_openMap; +``` + +### From Init or Action + +```sqf +// Add player action +player addAction ["Open Map", {[] call FORGE_fnc_openMap;}]; + +// In init.sqf +[] call FORGE_fnc_openMap; +``` + +## Key Differences from Standard HTML/CSS/JS + +### 1. Dynamic Resource Loading + +Instead of `` and ` +``` + +### 2. Event Communication + +Use **A3API.SendAlert()** for one-way messages: +```javascript +A3API.SendAlert(JSON.stringify({event: "map::action", data: value})); +``` + +Use **A3API.SendConfirm()** for messages expecting a response: +```javascript +A3API.SendConfirm(JSON.stringify({event: "map::getdata", data: null})); +``` + +### 3. Pointer Events + +UI elements need `pointer-events: auto` while the body has `pointer-events: none`: + +```css +body { + pointer-events: none; /* Allows clicks through to map */ +} + +#topBar { + pointer-events: auto; /* UI elements catch clicks */ +} +``` + +## Dialog Definition Pattern + +```cpp +class RscMapDisplay { + idd = 9000; + onLoad = "['onLoad', _this] call FORGE_fnc_mapDisplay;"; + + class Controls { + class Browser: RscText { + type = 106; // IFrame control type + idc = 9001; + x = "safeZoneX"; + y = "safeZoneY"; + w = "safeZoneW"; + h = "safeZoneH"; + }; + + class MapControl: RscMapControl { + idc = 9002; + // Position to fit within HTML UI + }; + }; +}; +``` + +## Event Handler Pattern + +In `fn_openMap.sqf`: +```sqf +private _ctrl = _display displayCtrl 9001; + +// Add JSDialog event handler +_ctrl ctrlAddEventHandler ["JSDialog", { + params ["_control", "_isConfirmDialog", "_message"]; + [_control, _isConfirmDialog, _message] call FORGE_fnc_mapHandleUIEvents; +}]; + +// Load HTML file +_ctrl ctrlWebBrowserAction ["LoadFile", "UI\\map\\_site\\index.html"]; +``` + +In `fn_mapHandleUIEvents.sqf`: +```sqf +params ["_control", "_isConfirmDialog", "_message"]; + +private _eventData = fromJSON _message; +private _event = _eventData get "event"; +private _data = _eventData get "data"; + +switch (_event) do { + case "map::ready": { + // Initialize + }; + case "map::zoomIn": { + // Handle zoom + }; +}; +``` + +## Benefits of This Pattern + +1. **Proper Arma Integration** - Uses native WebBrowser control (type 106) +2. **File System Compatibility** - A3API.RequestFile() works with Arma's file system +3. **Reliable Communication** - JSDialog event handler is more stable than htmlLoad +4. **Modular** - CSS and JS in separate files, dynamically loaded +5. **Consistent** - Matches bank module pattern used in FORGE + +## Troubleshooting + +**Files not loading:** +- Check paths use double backslashes: `"UI\\map\\_site\\style.css"` +- Verify files exist in the correct directory +- Check .rpt log for file loading errors + +**Events not firing:** +- Verify JSDialog event handler is attached +- Check JSON formatting in A3API calls +- Look for JavaScript console errors (use OpenDevConsole) + +**Map not showing:** +- Verify MapControl idc matches (9002) +- Check map control positioning in MapDisplay.h +- Ensure map control is rendered after browser control + +## Developer Tools + +Enable dev console in `fn_openMap.sqf`: +```sqf +_ctrl ctrlWebBrowserAction ["OpenDevConsole"]; +``` + +This opens Chromium dev tools for debugging JavaScript, CSS, and network requests. diff --git a/arma/client/addons/cad/XEH_PREP.hpp b/arma/client/addons/cad/XEH_PREP.hpp new file mode 100644 index 0000000..3a2f563 --- /dev/null +++ b/arma/client/addons/cad/XEH_PREP.hpp @@ -0,0 +1,5 @@ +PREP(handleUIEvents); +PREP(initRepository); +PREP(initUIBridge); +PREP(initUI); +PREP(openUI); diff --git a/arma/client/addons/cad/XEH_postInitClient.sqf b/arma/client/addons/cad/XEH_postInitClient.sqf new file mode 100644 index 0000000..fdd9bce --- /dev/null +++ b/arma/client/addons/cad/XEH_postInitClient.sqf @@ -0,0 +1,24 @@ +#include "script_component.hpp" + +if (isNil QGVAR(CADRepository)) then { call FUNC(initRepository); }; +if (isNil QGVAR(CADUIBridge)) then { call FUNC(initUIBridge); }; + +[QGVAR(openCAD), { + call FUNC(openUI); +}] call CFUNC(addEventHandler); + +[QGVAR(responseTaskCatalog), { + params [["_entries", [], [[]]]]; + + if !(isNil QGVAR(CADRepository)) then { + GVAR(CADRepository) call ["setTaskCatalog", [_entries]]; + }; + + GVAR(CADUIBridge) call ["refreshTaskCatalog", []]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseTaskAccept), { + params [["_result", createHashMap, [createHashMap]]]; + + GVAR(CADUIBridge) call ["handleTaskAcceptResponse", [_result]]; +}] call CFUNC(addEventHandler); diff --git a/arma/client/addons/cad/XEH_preInit.sqf b/arma/client/addons/cad/XEH_preInit.sqf new file mode 100644 index 0000000..1f72eca --- /dev/null +++ b/arma/client/addons/cad/XEH_preInit.sqf @@ -0,0 +1,5 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; diff --git a/arma/client/addons/cad/XEH_preInitClient.sqf b/arma/client/addons/cad/XEH_preInitClient.sqf new file mode 100644 index 0000000..421c54b --- /dev/null +++ b/arma/client/addons/cad/XEH_preInitClient.sqf @@ -0,0 +1 @@ +#include "script_component.hpp" diff --git a/arma/client/addons/cad/config.cpp b/arma/client/addons/cad/config.cpp new file mode 100644 index 0000000..47de21d --- /dev/null +++ b/arma/client/addons/cad/config.cpp @@ -0,0 +1,21 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"IDSolutions"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_client_main" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" +#include "ui\RscCommon.hpp" +#include "ui\RscMapUI.hpp" diff --git a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf new file mode 100644 index 0000000..22c93cb --- /dev/null +++ b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf @@ -0,0 +1,87 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_handleUIEvents.sqf + * Author: IDSolutions + * Date: 2026-03-28 + * Public: No + * + * Description: + * Handles CAD browser UI events. + * + * Arguments: + * 0: Control [CONTROL] + * 1: Confirm dialog flag [BOOL] + * 2: Browser message [STRING] + * + * Return Value: + * UI event handled [BOOL] + * + * Example: + * [_control, false, _message] call forge_client_cad_fnc_handleUIEvents + */ + +params ["_control", "_isConfirmDialog", "_message"]; + +private _alert = fromJSON _message; +private _event = _alert getOrDefault ["event", ""]; +private _data = _alert getOrDefault ["data", nil]; + +diag_log format ["[FORGE:Client:CAD] Handling UI event: %1", _event]; + +if (_isConfirmDialog) exitWith { true }; + +switch (_event) do { + case "cad::ready": { + GVAR(CADUIBridge) call ["handleReady", [_control, _data]]; + }; + case "cad::tasks::refresh": { + GVAR(CADUIBridge) call ["requestTaskCatalog", []]; + }; + case "cad::tasks::accept": { + private _taskID = ""; + if (_data isEqualType createHashMap) then { + _taskID = _data getOrDefault ["taskID", ""]; + }; + + GVAR(CADUIBridge) call ["requestTaskAccept", [_taskID]]; + }; + case "map::zoomIn": { + private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; + if (isNull _mapCtrl) exitWith {}; + + private _currentZoom = ctrlMapScale _mapCtrl; + private _newZoom = (_currentZoom * 0.5) max 0.001; + private _center = _mapCtrl ctrlMapScreenToWorld [0.5, 0.5]; + _mapCtrl ctrlMapAnimAdd [0.3, _newZoom, _center]; + ctrlMapAnimCommit _mapCtrl; + }; + case "map::zoomOut": { + private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; + if (isNull _mapCtrl) exitWith {}; + + private _currentZoom = ctrlMapScale _mapCtrl; + private _newZoom = (_currentZoom * 2) min 1; + private _center = _mapCtrl ctrlMapScreenToWorld [0.5, 0.5]; + _mapCtrl ctrlMapAnimAdd [0.3, _newZoom, _center]; + ctrlMapAnimCommit _mapCtrl; + }; + case "map::search": { + private _query = str _data; + private _bottomBar = uiNamespace getVariable [QGVAR(BottomBarCtrl), controlNull]; + if (isNull _bottomBar) exitWith {}; + + _bottomBar ctrlWebBrowserAction ["ExecJS", format ["updateStatus('Search not yet implemented: %1');", _query]]; + }; + case "map::close": { + if !(isNil QGVAR(CADUIBridge)) then { + GVAR(CADUIBridge) call ["handleClose", []]; + }; + closeDialog 1; + }; + default { + diag_log format ["[FORGE:Client:CAD] WARNING: Unhandled UI event: %1", _event]; + }; +}; + +true diff --git a/arma/client/addons/cad/functions/fnc_initRepository.sqf b/arma/client/addons/cad/functions/fnc_initRepository.sqf new file mode 100644 index 0000000..6cec168 --- /dev/null +++ b/arma/client/addons/cad/functions/fnc_initRepository.sqf @@ -0,0 +1,52 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initRepository.sqf + * Author: IDSolutions + * Date: 2026-03-28 + * Public: No + * + * Description: + * Initializes the CAD repository for lightweight client lifecycle state. + * + * Arguments: + * None + * + * Return Value: + * CAD repository object [HASHMAP OBJECT] + * + * Example: + * call forge_client_cad_fnc_initRepository + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(CADRepository) = createHashMapObject [[ + ["#type", "CADRepository"], + ["#create", compileFinal { + _self set ["isLoaded", true]; + _self set ["isOpen", false]; + _self set ["taskCatalog", []]; + }], + ["pushTaskCatalog", compileFinal { + params [["_bridge", createHashMap, [createHashMap]]]; + + if (_bridge isEqualTo createHashMap) exitWith { false }; + + _bridge call ["sendEvent", ["cad::tasks::hydrate", createHashMapFromArray [ + ["tasks", +(_self getOrDefault ["taskCatalog", []])] + ]]] + }], + ["setTaskCatalog", compileFinal { + params [["_entries", [], [[]]]]; + + _self set ["taskCatalog", +_entries]; + true + }], + ["setOpen", compileFinal { + params [["_isOpen", false, [false]]]; + _self set ["isOpen", _isOpen]; + true + }] +]]; + +GVAR(CADRepository) diff --git a/arma/client/addons/cad/functions/fnc_initUI.sqf b/arma/client/addons/cad/functions/fnc_initUI.sqf new file mode 100644 index 0000000..bb84979 --- /dev/null +++ b/arma/client/addons/cad/functions/fnc_initUI.sqf @@ -0,0 +1,90 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUI.sqf + * Author: IDSolutions + * Date: 2026-03-28 + * Public: No + * + * Description: + * Initializes the CAD map dialog controls and local map event handling. + * + * Arguments: + * 0: Display [DISPLAY] + * + * Return Value: + * UI initialized [BOOL] + * + * Example: + * [_display] call forge_client_cad_fnc_initUI + */ + +params [["_display", displayNull, [displayNull]]]; + +if (isNull _display) exitWith { false }; + +private _mapCtrl = _display displayCtrl 1001; +private _topBarCtrl = _display displayCtrl 1002; +private _bottomBarCtrl = _display displayCtrl 1003; +private _sidePanelCtrl = _display displayCtrl 1005; + +uiNamespace setVariable [QGVAR(Display), _display]; +uiNamespace setVariable [QGVAR(MapCtrl), _mapCtrl]; +uiNamespace setVariable [QGVAR(TopBarCtrl), _topBarCtrl]; +uiNamespace setVariable [QGVAR(BottomBarCtrl), _bottomBarCtrl]; +uiNamespace setVariable [QGVAR(SidePanelCtrl), _sidePanelCtrl]; + +private _center = if (isNull player) then { + [worldSize / 2, worldSize / 2, 0] +} else { + getPosATL player +}; + +_mapCtrl ctrlMapAnimAdd [0, 0.2, _center]; +ctrlMapAnimCommit _mapCtrl; + +_mapCtrl ctrlAddEventHandler ["MouseButtonClick", { + params ["_ctrl", "_button", "_xPos", "_yPos"]; + + private _worldPos = _ctrl ctrlMapScreenToWorld [_xPos, _yPos]; + private _bottomBar = uiNamespace getVariable [QGVAR(BottomBarCtrl), controlNull]; + if (isNull _bottomBar) exitWith {}; + + private _jsCode = format [ + "updateStatus('Clicked at: %1, %2');", + round (_worldPos # 0), + round (_worldPos # 1) + ]; + _bottomBar ctrlWebBrowserAction ["ExecJS", _jsCode]; +}]; + +_mapCtrl ctrlAddEventHandler ["MouseMoving", { + params ["_ctrl", "_xPos", "_yPos"]; + + private _worldPos = _ctrl ctrlMapScreenToWorld [_xPos, _yPos]; + private _topBar = uiNamespace getVariable [QGVAR(TopBarCtrl), controlNull]; + if (isNull _topBar) exitWith {}; + + private _jsCode = format [ + "updateCoordinates(%1, %2);", + _worldPos # 0, + _worldPos # 1 + ]; + _topBar ctrlWebBrowserAction ["ExecJS", _jsCode]; +}]; + +[] spawn { + while { !isNull (uiNamespace getVariable [QGVAR(Display), displayNull]) } do { + private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; + private _topBar = uiNamespace getVariable [QGVAR(TopBarCtrl), controlNull]; + + if (!isNull _mapCtrl && { !isNull _topBar }) then { + _topBar ctrlWebBrowserAction ["ExecJS", format ["updateScale(%1);", round (ctrlMapScale _mapCtrl)]]; + }; + + sleep 0.5; + }; +}; + +diag_log "[FORGE:Client:CAD] CAD UI initialized."; +true diff --git a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..0205f34 --- /dev/null +++ b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf @@ -0,0 +1,84 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-28 + * Public: No + * + * Description: + * Initializes the CAD UI bridge for sidepanel browser state and task event routing. + * + * Arguments: + * None + * + * Return Value: + * CAD UI bridge object [HASHMAP OBJECT] + * + * Example: + * call forge_client_cad_fnc_initUIBridge + */ + +#pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + +GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], + ["#type", "CADUIBridgeBaseClass"], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; + if (isNull _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull + }; + + private _control = _display displayCtrl 1005; + _self call ["setActiveBrowserControl", [_control]]; + _control + }], + ["hasOpenScreen", compileFinal { + private _screen = _self call ["getScreen", []]; + private _control = _self call ["getActiveBrowserControl", []]; + !(isNull _control) && { _screen call ["isReady", []] } + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + _self call ["flushPendingEvents", []]; + + _self call ["requestTaskCatalog", []]; + _self call ["refreshTaskCatalog", []]; + true + }], + ["requestTaskCatalog", compileFinal { + [SRPC(task,requestTaskCatalog), [getPlayerUID player]] call CFUNC(serverEvent); + true + }], + ["requestTaskAccept", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + [SRPC(task,requestAcceptTask), [getPlayerUID player, _taskID]] call CFUNC(serverEvent); + true + }], + ["refreshTaskCatalog", compileFinal { + if (isNil QGVAR(CADRepository)) exitWith { false }; + GVAR(CADRepository) call ["pushTaskCatalog", [_self]] + }], + ["handleTaskAcceptResponse", compileFinal { + params [["_result", createHashMap, [createHashMap]]]; + + _self call ["sendEvent", ["cad::tasks::accept::response", createHashMapFromArray [ + ["message", _result getOrDefault ["message", "Task request processed."]], + ["success", _result getOrDefault ["success", false]] + ]]] + }] +]; + +GVAR(CADUIBridge) = createHashMapObject [GVAR(CADUIBridgeBaseClass)]; +GVAR(CADUIBridge) diff --git a/arma/client/addons/cad/functions/fnc_openUI.sqf b/arma/client/addons/cad/functions/fnc_openUI.sqf new file mode 100644 index 0000000..0d0804b --- /dev/null +++ b/arma/client/addons/cad/functions/fnc_openUI.sqf @@ -0,0 +1,47 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_openUI.sqf + * Author: IDSolutions + * Date: 2026-03-28 + * Public: No + * + * Description: + * Opens the CAD map interface. + * + * Arguments: + * None + * + * Return Value: + * UI opened [BOOL] + * + * Example: + * call forge_client_cad_fnc_openUI + */ + +private _display = createDialog ["RscMapUI", true]; +if (isNull _display) exitWith { + diag_log "[FORGE:Client:CAD] ERROR: Failed to create CAD dialog."; + false +}; + +private _topBarCtrl = _display displayCtrl 1002; +private _bottomBarCtrl = _display displayCtrl 1003; +private _sidePanelCtrl = _display displayCtrl 1005; + +{ + _x ctrlAddEventHandler ["JSDialog", { + params ["_control", "_isConfirmDialog", "_message"]; + [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); + }]; +} forEach [_topBarCtrl, _bottomBarCtrl, _sidePanelCtrl]; + +_topBarCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\topbar.html)]; +_bottomBarCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bottombar.html)]; +_sidePanelCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\sidepanel.html)]; + +if !(isNil QGVAR(CADRepository)) then { + GVAR(CADRepository) call ["setOpen", [true]]; +}; + +true diff --git a/arma/client/addons/cad/script_component.hpp b/arma/client/addons/cad/script_component.hpp new file mode 100644 index 0000000..6fb40c2 --- /dev/null +++ b/arma/client/addons/cad/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT cad +#define COMPONENT_BEAUTIFIED CAD +#include "\forge\forge_client\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_client\addons\main\script_macros.hpp" diff --git a/arma/client/addons/cad/ui/RscCommon.hpp b/arma/client/addons/cad/ui/RscCommon.hpp new file mode 100644 index 0000000..4135f3f --- /dev/null +++ b/arma/client/addons/cad/ui/RscCommon.hpp @@ -0,0 +1,6 @@ +// Control types +#define CT_STATIC 0 +#define CT_MAP 100 + +class RscText; +class RscMapControl; diff --git a/arma/client/addons/cad/ui/RscMapUI.hpp b/arma/client/addons/cad/ui/RscMapUI.hpp new file mode 100644 index 0000000..37f599d --- /dev/null +++ b/arma/client/addons/cad/ui/RscMapUI.hpp @@ -0,0 +1,90 @@ +class RscMapUI { + idd = 1004; + movingEnable = 0; + enableSimulation = 1; + fadein = 0; + fadeout = 0; + duration = 1e+011; + onLoad = "uiNamespace setVariable ['forge_client_cad_Display', _this select 0]; [_this select 0] call forge_client_cad_fnc_initUI;"; + onUnLoad = "uiNamespace setVariable ['forge_client_cad_Display', nil]; uiNamespace setVariable ['forge_client_cad_MapCtrl', nil]; uiNamespace setVariable ['forge_client_cad_TopBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_BottomBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_SidePanelCtrl', nil]; if !(isNil 'forge_client_cad_CADRepository') then { forge_client_cad_CADRepository set ['isOpen', false]; };"; + + class controlsBackground { + class MapControl: RscMapControl { + idc = 1001; + x = "safeZoneX + (safeZoneW * 0.1)"; // 10% margin (80% width centered) + y = "safeZoneY + (safeZoneH * 0.1) + 0.0926"; // 10% margin + 50px top bar + w = "safeZoneW * 0.8"; // 80% width + h = "(safeZoneH * 0.8) - 0.0926 - 0.0556"; // 80% height minus top and bottom bars + + // Map specific settings + maxSatelliteAlpha = 0.85; + alphaFadeStartScale = 0.35; + alphaFadeEndScale = 0.4; + colorBackground[] = {0.969, 0.957, 0.949, 1}; + colorSea[] = {0.467, 0.631, 0.851, 0.5}; + colorForest[] = {0.624, 0.78, 0.388, 0.5}; + colorRocks[] = {0, 0, 0, 0}; + colorCountlines[] = {0.572, 0.354, 0.318, 0.25}; + colorMainCountlines[] = {0.572, 0.354, 0.318, 0.5}; + colorCountlinesWater[] = {0.491, 0.577, 0.702, 0.3}; + colorMainCountlinesWater[] = {0.491, 0.577, 0.702, 0.6}; + colorForestBorder[] = {0, 0, 0, 0}; + colorRocksBorder[] = {0, 0, 0, 0}; + colorPowerLines[] = {0.1, 0.1, 0.1, 1}; + colorRailWay[] = {0.8, 0.2, 0, 1}; + colorNames[] = {0.1, 0.1, 0.1, 0.9}; + colorInactive[] = {1, 1, 1, 0.5}; + colorLevels[] = {0.286, 0.177, 0.094, 0.5}; + colorTracks[] = {0.84, 0.76, 0.65, 0.15}; + colorRoads[] = {0.7, 0.7, 0.7, 1}; + colorMainRoads[] = {0.9, 0.5, 0.3, 1}; + colorTracksFill[] = {0.84, 0.76, 0.65, 1}; + colorRoadsFill[] = {1, 1, 1, 1}; + colorMainRoadsFill[] = {1, 0.6, 0.4, 1}; + colorGrid[] = {0.1, 0.1, 0.1, 0.6}; + colorGridMap[] = {0.1, 0.1, 0.1, 0.6}; + colorText[] = {1, 1, 1, 1}; + font = "PuristaMedium"; + sizeEx = 0.04; + showCountourInterval = 0; + scaleMin = 0.001; + scaleMax = 1; + scaleDefault = 0.16; + }; + }; + + class controls { + // Top bar browser + class TopBarBrowser: RscText { + type = 106; + idc = 1002; + x = "safeZoneX + (safeZoneW * 0.1)"; + y = "safeZoneY + (safeZoneH * 0.1)"; + w = "safeZoneW * 0.8"; + h = "0.0926"; // 50px + colorBackground[] = {0, 0, 0, 0}; + }; + + // Bottom bar browser + class BottomBarBrowser: RscText { + type = 106; + idc = 1003; + x = "safeZoneX + (safeZoneW * 0.1)"; + y = "safeZoneY + (safeZoneH * 0.9) - 0.0556"; + w = "safeZoneW * 0.8"; + h = "0.0556"; // 30px + colorBackground[] = {0, 0, 0, 0}; + }; + + // Side panel browser (overlays from right side of 80% box) + class SidePanelBrowser: RscText { + type = 106; + idc = 1005; + x = "safeZoneX + (safeZoneW * 0.1) + (safeZoneW * 0.8) - 0.4630"; // Right edge of 80% box minus panel width + y = "safeZoneY + (safeZoneH * 0.1) + 0.0926"; // Below top bar + w = "0.4630"; // ~250px width + h = "(safeZoneH * 0.8) - 0.0926 - 0.0556"; // Full height minus bars + colorBackground[] = {0, 0, 0, 0}; + }; + }; +}; diff --git a/arma/client/addons/cad/ui/_site/bottombar.html b/arma/client/addons/cad/ui/_site/bottombar.html new file mode 100644 index 0000000..33fb1ec --- /dev/null +++ b/arma/client/addons/cad/ui/_site/bottombar.html @@ -0,0 +1 @@ +Map Ready \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-bottombar.css b/arma/client/addons/cad/ui/_site/cad-bottombar.css new file mode 100644 index 0000000..7133cd2 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-bottombar.css @@ -0,0 +1 @@ +body{-webkit-backdrop-filter:blur(18px);background:linear-gradient(90deg,#0e131bf5,#121720ed 55%,#0d1219f5);border-top:1px solid #ffffff24;justify-content:space-between;align-items:center;min-height:36px;padding:0 20px;display:flex;position:absolute;bottom:0;left:0;right:0;overflow:hidden;box-shadow:0 -12px 26px #0000003d}span{color:#f5f8ffcc;text-shadow:0 1px 10px #00000047;font-size:12px}#statusText{color:var(--accent);font-weight:600} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-bottombar.js b/arma/client/addons/cad/ui/_site/cad-bottombar.js new file mode 100644 index 0000000..d39ab4b --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-bottombar.js @@ -0,0 +1 @@ +window.CADBottombar=window.CADBottombar||{}; \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-common.css b/arma/client/addons/cad/ui/_site/cad-common.css new file mode 100644 index 0000000..c2d789e --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-common.css @@ -0,0 +1 @@ +:root{--bg:#090c12d1;--panel:#141821e6;--panel2:#11151ed1;--stroke:#ffffff1f;--stroke2:#fff3;--text:#f5f8ffeb;--muted:#f5f8ff9e;--muted2:#f5f8ff6b;--accent:#68c4fff2;--danger:#ff6060f2;--shadow:0 20px 60px #0000008c;--radius:14px;--radius2:10px;--font:ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif}*{box-sizing:border-box;margin:0;padding:0}body{font-family:var(--font);color:var(--text);background:var(--bg);-webkit-backdrop-filter:blur(16px)}.btn{border-radius:var(--radius2);color:var(--text);cursor:pointer;user-select:none;background:#ffffff08;border:1px solid #ffffff1a;padding:8px 16px;font-size:14px;transition:background .16s,border-color .16s,transform .16s}.btn:hover{background:#ffffff12;border-color:#ffffff29}.btn:active{transform:scale(.98)}.btn-close{color:#ffdcdcf2;background:#ff60601a;border-color:#ff606040;font-weight:700}.btn-close:hover{background:#ff606033;border-color:#ff606059}::-webkit-scrollbar{width:10px}::-webkit-scrollbar-thumb{background:#ffffff1a;border:2px solid #0000001a;border-radius:999px} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-shared.js b/arma/client/addons/cad/ui/_site/cad-shared.js new file mode 100644 index 0000000..75008e5 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-shared.js @@ -0,0 +1 @@ +window.mapUIState={layersPanelVisible:!0,sidePanelElement:null},window.mapUI={sendEvent(e,t){A3API.SendAlert(JSON.stringify({event:e,data:t}))},updateCoordinates(e,t){const n=document.getElementById("coordsDisplay");n&&(n.textContent=`X: ${Math.round(e).toString().padStart(4,"0")} Y: ${Math.round(t).toString().padStart(4,"0")}`)},updateScale(e){const t=document.getElementById("scaleDisplay");t&&(t.textContent=`Scale: 1:${Math.round(e)}`)},updateStatus(e){const t=document.getElementById("statusText");t&&(t.textContent=e)}},window.updateCoordinates=window.mapUI.updateCoordinates,window.updateScale=window.mapUI.updateScale,window.updateStatus=window.mapUI.updateStatus,window.ForgeBridge=window.ForgeBridge||{_handlers:{},on(e,t){this._handlers[e]=this._handlers[e]||[],this._handlers[e].push(t)},ready:e=>(window.mapUI.sendEvent("cad::ready",e||{}),!0),receive(e){if(!e||"object"!=typeof e)return;(this._handlers[e.event]||[]).forEach(t=>t(e.data||{}))},send:(e,t)=>(window.mapUI.sendEvent(e,t||{}),!0),close:e=>(window.mapUI.sendEvent("map::close",e||{}),!0)}; \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.css b/arma/client/addons/cad/ui/_site/cad-sidepanel.css new file mode 100644 index 0000000..15d65fb --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.css @@ -0,0 +1 @@ +html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.task-toolbar{margin-bottom:10px}.task-toolbar button,.task-accept-btn{color:#f3f6f9;cursor:pointer;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-toolbar button:hover,.task-accept-btn:hover{background:#2e3942f2}.task-toolbar button:disabled,.task-accept-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.js b/arma/client/addons/cad/ui/_site/cad-sidepanel.js new file mode 100644 index 0000000..05e57ab --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.js @@ -0,0 +1 @@ +window.cadTasks={tasks:[],init(){const s=document.getElementById("refreshTasksBtn");s&&s.addEventListener("click",()=>this.refresh()),window.ForgeBridge.on("cad::tasks::hydrate",s=>{this.setTasks(s.tasks||[])}),window.ForgeBridge.on("cad::tasks::accept::response",s=>{this.handleAcceptResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setTasks(s){this.tasks=Array.isArray(s)?s:[];const t=document.getElementById("taskStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.render()},setStatus(s,t){const e=document.getElementById("taskStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"info")},handleAcceptResponse(s,t){this.setStatus(t||(s?"Task accepted.":"Unable to accept task."),s?"success":"error")},refresh(){this.setStatus("Refreshing tasks...","info"),window.mapUI.sendEvent("cad::tasks::refresh",{})},acceptTask(s){this.setStatus("Submitting acceptance...","info"),window.mapUI.sendEvent("cad::tasks::accept",{taskID:s})},render(){const s=document.getElementById("taskList");s&&(this.tasks.length?s.innerHTML=this.tasks.map(s=>{const t=Array.isArray(s.position)?s.position:[0,0,0],e=!!s.accepted,a=e?`Assigned: ${s.orgID||"Unknown"}`:"Available";return`\n
\n
\n ${s.title||s.taskID}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${a}\n X: ${Math.round(t[0]||0)} Y: ${Math.round(t[1]||0)}\n
\n \n
\n `}).join(""):s.innerHTML='

No active tasks are available.

')}},window.cadTasks.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-topbar.css b/arma/client/addons/cad/ui/_site/cad-topbar.css new file mode 100644 index 0000000..59129ec --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-topbar.css @@ -0,0 +1 @@ +body{-webkit-backdrop-filter:blur(18px);background:linear-gradient(90deg,#10161ff5,#131a24f0 55%,#0f141cf5);border-bottom:1px solid #ffffff24;justify-content:space-between;align-items:center;height:56px;padding:0 20px;display:flex;position:absolute;top:0;left:0;right:0;overflow:hidden;box-shadow:0 14px 28px #00000047}.logo{color:var(--accent);text-transform:uppercase;letter-spacing:.4px;text-shadow:0 1px 12px #00000059;font-size:16px;font-weight:650}.controls{align-items:center;gap:10px;display:flex}.search-input{color:var(--text);background:#ffffff14;border:1px solid #ffffff24;border-radius:999px;outline:none;width:250px;padding:10px 12px;font-size:13px;box-shadow:inset 0 1px #ffffff08}.search-input::placeholder{color:var(--muted2)}.search-input:focus{background:#ffffff1c;border-color:#68c4ff73}.info{color:#f5f8ffd6;font-size:12px;font-family:var(--font);text-shadow:0 1px 10px #00000047;gap:20px;display:flex} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-topbar.js b/arma/client/addons/cad/ui/_site/cad-topbar.js new file mode 100644 index 0000000..91698ea --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-topbar.js @@ -0,0 +1 @@ +document.getElementById("btnZoomIn").addEventListener("click",()=>{window.mapUI.sendEvent("map::zoomIn",null)}),document.getElementById("btnZoomOut").addEventListener("click",()=>{window.mapUI.sendEvent("map::zoomOut",null)}),document.getElementById("btnClose").addEventListener("click",()=>{window.mapUI.sendEvent("map::close",null)}),document.getElementById("searchBox").addEventListener("keypress",e=>{"Enter"===e.key&&window.mapUI.sendEvent("map::search",e.target.value)}); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/sidepanel.html b/arma/client/addons/cad/ui/_site/sidepanel.html new file mode 100644 index 0000000..3cd718e --- /dev/null +++ b/arma/client/addons/cad/ui/_site/sidepanel.html @@ -0,0 +1 @@ +

CAD System

Loading available tasks...

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/topbar.html b/arma/client/addons/cad/ui/_site/topbar.html new file mode 100644 index 0000000..6eed3c9 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/topbar.html @@ -0,0 +1 @@ +
X: 0000 Y: 0000 Scale: 1:1000
\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/bottombar.html b/arma/client/addons/cad/ui/src/bottombar.html new file mode 100644 index 0000000..b87d3cb --- /dev/null +++ b/arma/client/addons/cad/ui/src/bottombar.html @@ -0,0 +1,49 @@ + + + + + + + Map Ready + + + + + diff --git a/arma/client/addons/cad/ui/src/bottombar.js b/arma/client/addons/cad/ui/src/bottombar.js new file mode 100644 index 0000000..910bfb5 --- /dev/null +++ b/arma/client/addons/cad/ui/src/bottombar.js @@ -0,0 +1,6 @@ +/* + * Bottombar UI Component + * Displays status and selection information. + */ + +window.CADBottombar = window.CADBottombar || {}; diff --git a/arma/client/addons/cad/ui/src/shared.js b/arma/client/addons/cad/ui/src/shared.js new file mode 100644 index 0000000..204e159 --- /dev/null +++ b/arma/client/addons/cad/ui/src/shared.js @@ -0,0 +1,69 @@ +/** + * Shared JavaScript for Map UI + * Provides common utilities and state management across all UI components + */ + +window.mapUIState = { + layersPanelVisible: true, + sidePanelElement: null, +}; + +window.mapUI = { + sendEvent(event, data) { + A3API.SendAlert(JSON.stringify({ event: event, data: data })); + }, + updateCoordinates(x, y) { + const coordDisplay = document.getElementById("coordsDisplay"); + if (coordDisplay) { + coordDisplay.textContent = `X: ${Math.round(x) + .toString() + .padStart(4, "0")} Y: ${Math.round(y) + .toString() + .padStart(4, "0")}`; + } + }, + updateScale(scale) { + const scaleDisplay = document.getElementById("scaleDisplay"); + if (scaleDisplay) { + scaleDisplay.textContent = `Scale: 1:${Math.round(scale)}`; + } + }, + updateStatus(text) { + const statusText = document.getElementById("statusText"); + if (statusText) { + statusText.textContent = text; + } + }, +}; + +window.updateCoordinates = window.mapUI.updateCoordinates; +window.updateScale = window.mapUI.updateScale; +window.updateStatus = window.mapUI.updateStatus; + +window.ForgeBridge = window.ForgeBridge || { + _handlers: {}, + on(event, handler) { + this._handlers[event] = this._handlers[event] || []; + this._handlers[event].push(handler); + }, + ready(payload) { + window.mapUI.sendEvent("cad::ready", payload || {}); + return true; + }, + receive(payload) { + if (!payload || typeof payload !== "object") { + return; + } + + const handlers = this._handlers[payload.event] || []; + handlers.forEach((handler) => handler(payload.data || {})); + }, + send(event, data) { + window.mapUI.sendEvent(event, data || {}); + return true; + }, + close(data) { + window.mapUI.sendEvent("map::close", data || {}); + return true; + }, +}; diff --git a/arma/client/addons/cad/ui/src/sidepanel.html b/arma/client/addons/cad/ui/src/sidepanel.html new file mode 100644 index 0000000..8efa438 --- /dev/null +++ b/arma/client/addons/cad/ui/src/sidepanel.html @@ -0,0 +1,63 @@ + + + + + + +
+

CAD System

+
+
+
+ +
+
+
+
+

Loading available tasks...

+
+
+
+ + + + diff --git a/arma/client/addons/cad/ui/src/sidepanel.js b/arma/client/addons/cad/ui/src/sidepanel.js new file mode 100644 index 0000000..bebcd95 --- /dev/null +++ b/arma/client/addons/cad/ui/src/sidepanel.js @@ -0,0 +1,94 @@ +window.cadTasks = { + tasks: [], + init() { + const refreshBtn = document.getElementById("refreshTasksBtn"); + if (refreshBtn) { + refreshBtn.addEventListener("click", () => this.refresh()); + } + + window.ForgeBridge.on("cad::tasks::hydrate", (payload) => { + this.setTasks(payload.tasks || []); + }); + + window.ForgeBridge.on("cad::tasks::accept::response", (payload) => { + this.handleAcceptResponse(!!payload.success, payload.message || ""); + }); + + window.ForgeBridge.ready({ loaded: true }); + }, + setTasks(tasks) { + this.tasks = Array.isArray(tasks) ? tasks : []; + const statusEl = document.getElementById("taskStatusMessage"); + if ( + statusEl && + (!statusEl.dataset.type || statusEl.dataset.type === "info") + ) { + this.setStatus("", ""); + } + this.render(); + }, + setStatus(message, type) { + const statusEl = document.getElementById("taskStatusMessage"); + if (!statusEl) { + return; + } + + statusEl.textContent = message || ""; + statusEl.dataset.type = type || "info"; + }, + handleAcceptResponse(success, message) { + this.setStatus( + message || (success ? "Task accepted." : "Unable to accept task."), + success ? "success" : "error", + ); + }, + refresh() { + this.setStatus("Refreshing tasks...", "info"); + window.mapUI.sendEvent("cad::tasks::refresh", {}); + }, + acceptTask(taskID) { + this.setStatus("Submitting acceptance...", "info"); + window.mapUI.sendEvent("cad::tasks::accept", { taskID: taskID }); + }, + render() { + const listEl = document.getElementById("taskList"); + if (!listEl) { + return; + } + + if (!this.tasks.length) { + listEl.innerHTML = + '

No active tasks are available.

'; + return; + } + + listEl.innerHTML = this.tasks + .map((task) => { + const position = Array.isArray(task.position) + ? task.position + : [0, 0, 0]; + const accepted = !!task.accepted; + const ownerLabel = accepted + ? `Assigned: ${task.orgID || "Unknown"}` + : "Available"; + + return ` +
+
+ ${task.title || task.taskID} + ${task.type || "task"} +
+

${task.description || ""}

+
+ ${ownerLabel} + X: ${Math.round(position[0] || 0)} Y: ${Math.round(position[1] || 0)} +
+ +
+ `; + }) + .join(""); + }, +}; + +window.cadTasks.init(); diff --git a/arma/client/addons/cad/ui/src/styles/bottombar.css b/arma/client/addons/cad/ui/src/styles/bottombar.css new file mode 100644 index 0000000..99dcfd2 --- /dev/null +++ b/arma/client/addons/cad/ui/src/styles/bottombar.css @@ -0,0 +1,33 @@ +body { + position: absolute; + bottom: 0; + left: 0; + right: 0; + min-height: 36px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: linear-gradient( + 90deg, + rgba(14, 19, 27, 0.96), + rgba(18, 23, 32, 0.93) 55%, + rgba(13, 18, 25, 0.96) + ); + border-top: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 -12px 26px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + overflow: hidden; +} + +span { + color: rgba(245, 248, 255, 0.8); + font-size: 12px; + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.28); +} + +#statusText { + color: var(--accent); + font-weight: 600; +} diff --git a/arma/client/addons/cad/ui/src/styles/common.css b/arma/client/addons/cad/ui/src/styles/common.css new file mode 100644 index 0000000..d674ed5 --- /dev/null +++ b/arma/client/addons/cad/ui/src/styles/common.css @@ -0,0 +1,78 @@ +:root { + --bg: rgba(9, 12, 18, 0.82); + --panel: rgba(20, 24, 33, 0.9); + --panel2: rgba(17, 21, 30, 0.82); + --stroke: rgba(255, 255, 255, 0.12); + --stroke2: rgba(255, 255, 255, 0.2); + --text: rgba(245, 248, 255, 0.92); + --muted: rgba(245, 248, 255, 0.62); + --muted2: rgba(245, 248, 255, 0.42); + --accent: rgba(104, 196, 255, 0.95); + --danger: rgba(255, 96, 96, 0.95); + --shadow: 0 20px 60px rgba(0, 0, 0, 0.55); + --radius: 14px; + --radius2: 10px; + --font: + ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, + sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font); + color: var(--text); + background: var(--bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.btn { + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.03); + padding: 8px 16px; + border-radius: var(--radius2); + font-size: 14px; + color: var(--text); + cursor: pointer; + transition: + background 0.16s ease, + border-color 0.16s ease, + transform 0.16s ease; + user-select: none; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.16); +} + +.btn:active { + transform: scale(0.98); +} + +.btn-close { + background: rgba(255, 96, 96, 0.1); + border-color: rgba(255, 96, 96, 0.25); + color: rgba(255, 220, 220, 0.95); + font-weight: bold; +} + +.btn-close:hover { + background: rgba(255, 96, 96, 0.2); + border-color: rgba(255, 96, 96, 0.35); +} + +::-webkit-scrollbar { + width: 10px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + border: 2px solid rgba(0, 0, 0, 0.1); +} diff --git a/arma/client/addons/cad/ui/src/styles/sidepanel.css b/arma/client/addons/cad/ui/src/styles/sidepanel.css new file mode 100644 index 0000000..0a74484 --- /dev/null +++ b/arma/client/addons/cad/ui/src/styles/sidepanel.css @@ -0,0 +1,136 @@ +html, +body { + overflow: hidden; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: var(--panel); + border-left: 1px solid var(--stroke); + box-shadow: var(--shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +body { + opacity: 1; + visibility: visible; +} + +.panel-header { + padding: 14px; + border-bottom: 1px solid var(--stroke); + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.05), + transparent + ); + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel-header h3 { + color: var(--accent); + font-size: 14px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.8px; +} + +.panel-content { + padding: 14px; + height: calc(100% - 56px); + overflow: auto; +} + +.placeholder-message { + padding: 20px; + text-align: center; +} + +.placeholder-message p { + color: var(--muted); + font-size: 13px; + font-style: italic; +} + +.task-toolbar { + margin-bottom: 10px; +} + +.task-toolbar button, +.task-accept-btn { + width: 100%; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(30, 37, 43, 0.9); + color: #f3f6f9; + cursor: pointer; +} + +.task-toolbar button:hover, +.task-accept-btn:hover { + background: rgba(46, 57, 66, 0.95); +} + +.task-toolbar button:disabled, +.task-accept-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.task-status-message { + min-height: 18px; + margin-bottom: 10px; + font-size: 12px; + color: #cdd6dd; +} + +.task-status-message[data-type="success"] { + color: #79d28a; +} + +.task-status-message[data-type="error"] { + color: #ff8a80; +} + +.task-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.task-card { + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 16, 20, 0.62); +} + +.task-card-header { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.task-type { + opacity: 0.7; + text-transform: uppercase; + font-size: 11px; +} + +.task-description { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.4; +} + +.task-meta { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + font-size: 11px; + opacity: 0.8; +} diff --git a/arma/client/addons/cad/ui/src/styles/topbar.css b/arma/client/addons/cad/ui/src/styles/topbar.css new file mode 100644 index 0000000..3649d05 --- /dev/null +++ b/arma/client/addons/cad/ui/src/styles/topbar.css @@ -0,0 +1,67 @@ +body { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + background: linear-gradient( + 90deg, + rgba(16, 22, 31, 0.96), + rgba(19, 26, 36, 0.94) 55%, + rgba(15, 20, 28, 0.96) + ); + border-bottom: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + overflow: hidden; +} + +.logo { + color: var(--accent); + font-size: 16px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.4px; + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.35); +} + +.controls { + display: flex; + gap: 10px; + align-items: center; +} + +.search-input { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + color: var(--text); + padding: 10px 12px; + border-radius: 999px; + width: 250px; + outline: none; + font-size: 13px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.search-input::placeholder { + color: var(--muted2); +} + +.search-input:focus { + border-color: rgba(104, 196, 255, 0.45); + background: rgba(255, 255, 255, 0.11); +} + +.info { + display: flex; + gap: 20px; + color: rgba(245, 248, 255, 0.84); + font-size: 12px; + font-family: var(--font); + text-shadow: 0 1px 10px rgba(0, 0, 0, 0.28); +} diff --git a/arma/client/addons/cad/ui/src/topbar.html b/arma/client/addons/cad/ui/src/topbar.html new file mode 100644 index 0000000..83f8ffa --- /dev/null +++ b/arma/client/addons/cad/ui/src/topbar.html @@ -0,0 +1,63 @@ + + + + + + + +
+ + + + +
+
+ X: 0000 Y: 0000 + Scale: 1:1000 +
+ + + + diff --git a/arma/client/addons/cad/ui/src/topbar.js b/arma/client/addons/cad/ui/src/topbar.js new file mode 100644 index 0000000..30ec5fb --- /dev/null +++ b/arma/client/addons/cad/ui/src/topbar.js @@ -0,0 +1,17 @@ +document.getElementById("btnZoomIn").addEventListener("click", () => { + window.mapUI.sendEvent("map::zoomIn", null); +}); + +document.getElementById("btnZoomOut").addEventListener("click", () => { + window.mapUI.sendEvent("map::zoomOut", null); +}); + +document.getElementById("btnClose").addEventListener("click", () => { + window.mapUI.sendEvent("map::close", null); +}); + +document.getElementById("searchBox").addEventListener("keypress", (event) => { + if (event.key === "Enter") { + window.mapUI.sendEvent("map::search", event.target.value); + } +}); diff --git a/arma/client/addons/cad/ui/ui.config.mjs b/arma/client/addons/cad/ui/ui.config.mjs new file mode 100644 index 0000000..47553eb --- /dev/null +++ b/arma/client/addons/cad/ui/ui.config.mjs @@ -0,0 +1,69 @@ +export default { + addonName: "cad", + title: "FORGE CAD", + logLabel: "CAD UI", + outputDir: "_site", + generateIndex: false, + jsBundles: [ + { + name: "CAD shared bridge/runtime", + output: "cad-shared.js", + sources: ["src/shared.js"], + }, + { + name: "CAD topbar app", + output: "cad-topbar.js", + sources: ["src/topbar.js"], + }, + { + name: "CAD sidepanel app", + output: "cad-sidepanel.js", + sources: ["src/sidepanel.js"], + }, + { + name: "CAD bottombar app", + output: "cad-bottombar.js", + sources: ["src/bottombar.js"], + }, + ], + cssBundles: [ + { + name: "CAD common styles", + output: "cad-common.css", + sources: ["src/styles/common.css"], + }, + { + name: "CAD topbar styles", + output: "cad-topbar.css", + sources: ["src/styles/topbar.css"], + }, + { + name: "CAD sidepanel styles", + output: "cad-sidepanel.css", + sources: ["src/styles/sidepanel.css"], + }, + { + name: "CAD bottombar styles", + output: "cad-bottombar.css", + sources: ["src/styles/bottombar.css"], + }, + ], + htmlTemplates: [ + { + name: "CAD topbar page", + output: "topbar.html", + source: "src/topbar.html", + }, + { + name: "CAD sidepanel page", + output: "sidepanel.html", + source: "src/sidepanel.html", + }, + { + name: "CAD bottombar page", + output: "bottombar.html", + source: "src/bottombar.html", + }, + ], + site: {}, +}; diff --git a/arma/server/.hemtt/lints.toml b/arma/server/.hemtt/lints.toml index 46cef0b..87607ed 100644 --- a/arma/server/.hemtt/lints.toml +++ b/arma/server/.hemtt/lints.toml @@ -1,6 +1,6 @@ [sqf.banned_commands] options.banned = [ - "spawn", # Scheduled should be avoided whenever possible + # "spawn", # Scheduled should be avoided whenever possible "execVM", # Script files should never be run directly, they should be functions # "remoteExec", # CBA events should be used for networking ] diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf index fdaaefa..b5b62af 100644 --- a/arma/server/addons/bank/functions/fnc_initStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -150,7 +150,7 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1", _amount]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1", [_amount] call EFUNC(common,formatNumber)]]]; true }], ["hydrateSession", compileFinal { @@ -247,7 +247,7 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Paid $%1", _amount]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Paid $%1", [_amount] call EFUNC(common,formatNumber)]]]; true }], ["resolveOrgState", compileFinal { @@ -304,8 +304,8 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ name _player }; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Transferred $%1 to %2", _amount, _targetName]]]; - GVAR(BankMessenger) call ["sendClientNotification", [_target, "info", "Bank", format ["Received $%1 from %2", _amount, _playerName]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Transferred $%1 to %2", [_amount] call EFUNC(common,formatNumber), _targetName]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_target, "info", "Bank", format ["Received $%1 from %2", [_amount] call EFUNC(common,formatNumber), _playerName]]]; true }], ["withdraw", compileFinal { @@ -323,7 +323,7 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Withdrew $%1", _amount]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Withdrew $%1", [_amount] call EFUNC(common,formatNumber)]]]; true }], ["depositEarnings", compileFinal { @@ -341,7 +341,7 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1 from earnings", _amount]]]; + GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1 from earnings", [_amount] call EFUNC(common,formatNumber)]]]; true }] ]; diff --git a/arma/server/addons/common/functions/fnc_formatNumber.sqf b/arma/server/addons/common/functions/fnc_formatNumber.sqf index 30fc559..b0e3132 100644 --- a/arma/server/addons/common/functions/fnc_formatNumber.sqf +++ b/arma/server/addons/common/functions/fnc_formatNumber.sqf @@ -20,8 +20,13 @@ #define PX_TH_SEP "," #define PX_DC_PL 2 +private _value = _this; +if (_value isEqualType []) then { + _value = _value param [0, 0, [0]]; +}; + private _count = 0; -private _arr = (_this toFixed PX_DC_PL) splitString "."; +private _arr = (_value toFixed PX_DC_PL) splitString "."; private _str = PX_DC_SEP+(_arr select 1); _arr = toArray(_arr select 0); diff --git a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf index 1bb6135..830cee0 100644 --- a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf @@ -47,7 +47,7 @@ GVAR(FEconomyStore) = createHashMapObject [[ private _totalLiters = GETVAR(_target,liters,0); private _totalCost = _totalLiters * 5; - private _formattedTotalCost = _totalCost toFixed 2; + private _formattedTotalCost = [_totalCost] call EFUNC(common,formatNumber); private _formattedTotalLiters = _totalLiters toFixed 2; [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L
Total Cost: $%2", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent); diff --git a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf index cbc2d91..7225a09 100644 --- a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf @@ -80,7 +80,7 @@ GVAR(MEconomyStore) = createHashMapObject [[ 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, _cash, _healCost]], _unit] call CFUNC(targetEvent); + [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); }; if (_bank >= _healCost) then { diff --git a/arma/server/addons/locker/functions/fnc_initVAStore.sqf b/arma/server/addons/locker/functions/fnc_initVAStore.sqf index 809fa95..385b120 100644 --- a/arma/server/addons/locker/functions/fnc_initVAStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initVAStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initVAStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-03-27 * Public: No * * Description: @@ -28,7 +28,7 @@ GVAR(VArsenalModel) = compileFinal createHashMapObject [[ private _vArsenal = createHashMap; _vArsenal set ["backpacks", ["B_AssaultPack_rgr"]]; - _vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_IG_Guerrilla_6_1", "V_TacVest_oli"]]; + _vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_IG_Guerrilla_6_1", "V_TacVest_oli", "ACE_EarPlugs"]]; _vArsenal set ["magazines", ["16Rnd_9x21_Mag", "30Rnd_65x39_caseless_black_mag", "Chemlight_blue", "Chemlight_green", "Chemlight_red", "Chemlight_yellow", "HandGrenade", "SmokeShell", "SmokeShellBlue", "SmokeShellGreen", "SmokeShellOrange", "SmokeShellPurple", "SmokeShellRed", "SmokeShellYellow"]]; _vArsenal set ["weapons", ["arifle_MX_F", "hgun_P07_F"]]; diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 807e24b..0d56d12 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -127,7 +127,7 @@ PREP_RECOMPILE_END; private _index = GVAR(IndexRegistry) get _uid; private _key = _index get "orgID"; - GVAR(OrgStore) call ["save", [GVAR(Registry), "org:update", _key]]; + GVAR(OrgStore) call ["saveById", [_key]]; }] call CFUNC(addEventHandler); [QGVAR(requestRemoveOrg), { diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index b55cf35..038d6d4 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -50,6 +50,42 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ if !(_x in _org) then { _org set [_x, _y]; }; } forEach _defaults; + private _assets = _org getOrDefault ["assets", createHashMap]; + if !(_assets isEqualType createHashMap) then { + _assets = createHashMap; + }; + + private _migratedAssets = createHashMap; + { + private _categoryKey = _x; + private _value = _y; + + if (_value isEqualType createHashMap) then { + private _categoryMap = createHashMap; + + if (_categoryKey find ":" >= 0) then { + private _legacyAsset = +_value; + private _category = toLowerANSI (_legacyAsset getOrDefault ["type", "items"]); + private _className = _legacyAsset getOrDefault ["classname", ""]; + if (_className isNotEqualTo "") then { + _categoryMap = +(_migratedAssets getOrDefault [_category, createHashMap]); + _categoryMap set [_className, _legacyAsset]; + _migratedAssets set [_category, _categoryMap]; + }; + } else { + { + if (_y isEqualType createHashMap) then { + _categoryMap set [_x, +_y]; + }; + } forEach _value; + + _migratedAssets set [toLowerANSI _categoryKey, _categoryMap]; + }; + }; + } forEach _assets; + + _org set ["assets", _migratedAssets]; + _org }], ["validate", compileFinal { @@ -121,6 +157,13 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["org:create", ["default", _defaultJson]] call EFUNC(extension,extCall); }; + _defaultOrg = GVAR(OrgModel) call ["migrate", [_defaultOrg]]; + private _defaultAssets = _self call ["fetch", ["org:assets:get", "default"]]; + if !(_defaultAssets isEqualType createHashMap) then { _defaultAssets = createHashMap; }; + _defaultOrg set ["assets", _defaultAssets]; + private _defaultFleet = _self call ["fetch", ["org:fleet:get", "default"]]; + if !(_defaultFleet isEqualType createHashMap) then { _defaultFleet = createHashMap; }; + _defaultOrg set ["fleet", _defaultFleet]; GVAR(Registry) set ["default", _defaultOrg]; }], ["verifyMember", compileFinal { @@ -231,12 +274,30 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ private _assetsList = []; { - private _assetData = _y; - _assetsList pushBack (createHashMapFromArray [ - ["name", _assetData getOrDefault ["name", "Unknown Asset"]], - ["type", _assetData getOrDefault ["type", "items"]], - ["quantity", str (_assetData getOrDefault ["quantity", 0])] - ]); + private _category = _x; + { + private _assetData = _y; + private _className = _assetData getOrDefault ["classname", ""]; + private _displayName = _className; + { + private _cfg = _x >> _className; + if (isClass _cfg) exitWith { + private _resolvedName = getText (_cfg >> "displayName"); + if (_resolvedName isNotEqualTo "") then { _displayName = _resolvedName; }; + }; + } forEach [ + configFile >> "CfgWeapons", + configFile >> "CfgMagazines", + configFile >> "CfgVehicles", + configFile >> "CfgGlasses" + ]; + + _assetsList pushBack (createHashMapFromArray [ + ["name", _displayName], + ["type", _assetData getOrDefault ["type", _category]], + ["quantity", str (_assetData getOrDefault ["quantity", 0])] + ]); + } forEach _y; } forEach _assetsRaw; private _fleetList = []; @@ -291,8 +352,112 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["chargeCheckout", compileFinal { GVAR(OrgTreasuryService) call ["chargeCheckout", _this] }], + ["saveById", compileFinal { + params [["_orgID", "", [""]]]; + + if (_orgID isEqualTo "") exitWith { createHashMap }; + + private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = _self call ["loadById", [_orgID]]; + }; + if (_org isEqualTo createHashMap) exitWith { createHashMap }; + + private _coreOrg = createHashMapFromArray [ + ["id", _org getOrDefault ["id", _orgID]], + ["owner", _org getOrDefault ["owner", ""]], + ["name", _org getOrDefault ["name", ""]], + ["funds", _org getOrDefault ["funds", 0]], + ["reputation", _org getOrDefault ["reputation", 0]], + ["credit_lines", _org getOrDefault ["credit_lines", createHashMap]] + ]; + + private _coreJson = _self call ["toJSON", [_coreOrg]]; + ["org:update", [_orgID, _coreJson]] call EFUNC(extension,extCall); + + private _assets = _org getOrDefault ["assets", createHashMap]; + private _assetsJson = _self call ["toJSON", [_assets]]; + ["org:assets:update", [_orgID, _assetsJson]] call EFUNC(extension,extCall); + + private _fleet = _org getOrDefault ["fleet", createHashMap]; + private _fleetJson = _self call ["toJSON", [_fleet]]; + ["org:fleet:update", [_orgID, _fleetJson]] call EFUNC(extension,extCall); + + _org + }], + ["addAssets", compileFinal { + params [["_requesterUid", "", [""]], ["_assets", [], [[]]], ["_commit", false, [false]], ["_orgID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to update organization assets."], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if (_assets isEqualTo []) exitWith { + _result set ["success", true]; + _result set ["message", ""]; + _result + }; + + private _resolvedOrgID = _orgID; + if (_resolvedOrgID isEqualTo "") then { + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + _resolvedOrgID = _requesterActor getOrDefault ["organization", "default"]; + }; + if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; + + private _org = GVAR(Registry) getOrDefault [_resolvedOrgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = _self call ["loadById", [_resolvedOrgID]]; + }; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Organization data is unavailable for asset updates."]; + _result + }; + + private _assetMap = +(_org getOrDefault ["assets", createHashMap]); + + { + private _className = _x getOrDefault ["classname", ""]; + private _category = toLowerANSI (_x getOrDefault ["category", "items"]); + private _quantity = floor ((_x getOrDefault ["quantity", 0]) max 0); + if (_className isEqualTo "" || { _quantity <= 0 }) then { continue; }; + + private _categoryMap = +(_assetMap getOrDefault [_category, createHashMap]); + private _assetEntry = +(_categoryMap getOrDefault [_className, createHashMap]); + + private _existingQuantity = _assetEntry getOrDefault ["quantity", 0]; + _categoryMap set [_className, createHashMapFromArray [ + ["classname", _className], + ["type", _category], + ["quantity", (_existingQuantity + _quantity)] + ]]; + _assetMap set [_category, _categoryMap]; + } forEach _assets; + + private _patch = _self call ["mset", [ + GVAR(Registry), + "org:update", + _resolvedOrgID, + createHashMapFromArray [["assets", _assetMap]], + false + ]]; + + if (_commit) then { + private _assetJson = _self call ["toJSON", [_assetMap]]; + ["org:assets:update", [_resolvedOrgID, _assetJson]] call EFUNC(extension,extCall); + }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["memberUids", GVAR(OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]]; + _result + }], ["addFleetVehicles", compileFinal { - params [["_requesterUid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]]]; + params [["_requesterUid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]], ["_orgID", "", [""]]]; private _result = createHashMapFromArray [ ["success", false], @@ -301,17 +466,23 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["memberUids", []] ]; - if (_requesterUid isEqualTo "" || { _vehicles isEqualTo [] }) exitWith { + if (_vehicles isEqualTo []) exitWith { _result set ["success", true]; _result set ["message", ""]; _result }; - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; + private _resolvedOrgID = _orgID; + if (_resolvedOrgID isEqualTo "") then { + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + _resolvedOrgID = _requesterActor getOrDefault ["organization", "default"]; + }; + if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; - private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + private _org = GVAR(Registry) getOrDefault [_resolvedOrgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = _self call ["loadById", [_resolvedOrgID]]; + }; if (_org isEqualTo createHashMap) exitWith { _result set ["message", "Organization data is unavailable for fleet updates."]; _result @@ -347,9 +518,17 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _fleetIndex = _fleetIndex + 1; } forEach _vehicles; - private _patch = createHashMapFromArray [["fleet", _fleet]]; + private _patch = _self call ["mset", [ + GVAR(Registry), + "org:update", + _resolvedOrgID, + createHashMapFromArray [["fleet", _fleet]], + false + ]]; + if (_commit) then { - _patch = _self call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; + private _fleetJson = _self call ["toJSON", [_fleet]]; + ["org:fleet:update", [_resolvedOrgID, _fleetJson]] call EFUNC(extension,extCall); }; _result set ["success", true]; @@ -373,6 +552,16 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_org isEqualTo createHashMap) exitWith { _org }; _org = GVAR(OrgModel) call ["migrate", [_org]]; + private _assets = _self call ["fetch", ["org:assets:get", _orgID]]; + if !(_assets isEqualType createHashMap) then { + _assets = createHashMap; + }; + _org set ["assets", _assets]; + private _fleet = _self call ["fetch", ["org:fleet:get", _orgID]]; + if !(_fleet isEqualType createHashMap) then { + _fleet = createHashMap; + }; + _org set ["fleet", _fleet]; private _memberRows = _self call ["fetch", ["org:members:get", _orgID]]; if !(_memberRows isEqualType []) then { @@ -438,8 +627,6 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["funds", 0], ["reputation", 0], ["credit_lines", createHashMap], - ["assets", createHashMap], - ["fleet", createHashMap], ["members", createHashMap] ]; diff --git a/arma/server/addons/org/functions/fnc_treasuryService.sqf b/arma/server/addons/org/functions/fnc_treasuryService.sqf index 33cffe8..dfc46d2 100644 --- a/arma/server/addons/org/functions/fnc_treasuryService.sqf +++ b/arma/server/addons/org/functions/fnc_treasuryService.sqf @@ -17,7 +17,9 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ params [["_org", createHashMap, [createHashMap]], ["_requesterUid", "", [""]]]; private _memberUids = keys (_org getOrDefault ["members", createHashMap]); - if !(_requesterUid in _memberUids) then { _memberUids pushBack _requesterUid; }; + if (_requesterUid isNotEqualTo "" && { !(_requesterUid in _memberUids) }) then { + _memberUids pushBack _requesterUid; + }; _memberUids }], @@ -88,7 +90,7 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; _result set ["success", true]; - _result set ["message", format ["Credit line of $%1 assigned to %2.", [_amount] call BIS_fnc_numberText, _resolvedMemberName]]; + _result set ["message", format ["Credit line of $%1 assigned to %2.", [_amount] call EFUNC(common,formatNumber), _resolvedMemberName]]; _result set ["patch", _patch]; _result set ["memberUids", _memberUids]; _result diff --git a/arma/server/addons/store/functions/fnc_initCatalogService.sqf b/arma/server/addons/store/functions/fnc_initCatalogService.sqf index 2e81ba4..8eb6b52 100644 --- a/arma/server/addons/store/functions/fnc_initCatalogService.sqf +++ b/arma/server/addons/store/functions/fnc_initCatalogService.sqf @@ -20,7 +20,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ ["formatCurrency", compileFinal { params [["_amount", 0, [0]]]; - format ["$%1", [_amount max 0] call BIS_fnc_numberText] + format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)] }], ["isVisibleConfig", compileFinal { params [["_cfg", configNull, [configNull]]]; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index 7526eb6..b7019dd 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -158,7 +158,7 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ ["formatCurrency", compileFinal { params [["_amount", 0, [0]]]; - format ["$%1", [_amount max 0] call BIS_fnc_numberText] + format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)] }], ["applyPaymentPatch", compileFinal { params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_paymentMethod", "cash", [""]], ["_total", 0, [0]], ["_commit", false, [false]]]; diff --git a/arma/server/addons/task/$PBOPREFIX$ b/arma/server/addons/task/$PBOPREFIX$ new file mode 100644 index 0000000..429c994 --- /dev/null +++ b/arma/server/addons/task/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_server\addons\task diff --git a/arma/server/addons/task/CfgEventHandlers.hpp b/arma/server/addons/task/CfgEventHandlers.hpp new file mode 100644 index 0000000..f6503c2 --- /dev/null +++ b/arma/server/addons/task/CfgEventHandlers.hpp @@ -0,0 +1,17 @@ +class Extended_PreStart_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preStart)); + }; +}; + +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + }; +}; + +class Extended_PostInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_postInit)); + }; +}; diff --git a/arma/server/addons/task/CfgFactionClasses.hpp b/arma/server/addons/task/CfgFactionClasses.hpp new file mode 100644 index 0000000..84782dd --- /dev/null +++ b/arma/server/addons/task/CfgFactionClasses.hpp @@ -0,0 +1,6 @@ +class CfgFactionClasses { + class NO_CATEGORY; + class FORGE_Modules: NO_CATEGORY { + displayName = "FORGE"; + }; +}; diff --git a/arma/server/addons/task/CfgMissions.hpp b/arma/server/addons/task/CfgMissions.hpp new file mode 100644 index 0000000..33f7c12 --- /dev/null +++ b/arma/server/addons/task/CfgMissions.hpp @@ -0,0 +1,269 @@ +// TODO: Move to mission template and provide documentation +class CfgMissions { + // Global settings + maxConcurrentMissions = 3; + missionInterval = 300; // 5 minutes between mission generation + + // Mission type weights + class MissionWeights { + attack = 0.2; + defend = 0.2; + hostage = 0.2; + hvt = 0.15; + defuse = 0.15; + delivery = 0.1; + }; + + // Mission locations + class Locations { + class CityOne { + position[] = {1000, 1000, 0}; + type = "city"; + radius = 300; + suitable[] = {"attack", "defend", "hostage"}; + }; + class MilitaryBase { + position[] = {2000, 2000, 0}; + type = "military"; + radius = 500; + suitable[] = {"hvt", "defend", "attack"}; + }; + class Industrial { + position[] = {3000, 3000, 0}; + type = "industrial"; + radius = 200; + suitable[] = {"delivery", "defuse"}; + }; + }; + + // AI Groups configuration + class AIGroups { + class Infantry { + side = "EAST"; + class Units { + class Unit0 { + vehicle = "O_Soldier_TL_F"; + rank = "SERGEANT"; + position[] = {0, 0, 0}; + }; + class Unit1 { + vehicle = "O_Soldier_AR_F"; + rank = "CORPORAL"; + position[] = {5, -5, 0}; + }; + class Unit2 { + vehicle = "O_Soldier_LAT_F"; + rank = "PRIVATE"; + position[] = {-5, -5, 0}; + }; + }; + suitable[] = {"attack", "defend", "hostage"}; + }; + class Assault { + side = "EAST"; + class Units { + class Unit0 { + vehicle = "O_Soldier_SL_F"; + rank = "SERGEANT"; + position[] = {0, 0, 0}; + }; + class Unit1 { + vehicle = "O_Soldier_GL_F"; + rank = "CORPORAL"; + position[] = {4, -3, 0}; + }; + class Unit2 { + vehicle = "O_Soldier_AR_F"; + rank = "CORPORAL"; + position[] = {-4, -3, 0}; + }; + class Unit3 { + vehicle = "O_medic_F"; + rank = "PRIVATE"; + position[] = {7, -6, 0}; + }; + }; + suitable[] = {"attack", "defend"}; + }; + class MotorizedPatrol { + side = "EAST"; + class Units { + class Unit0 { + vehicle = "O_Soldier_TL_F"; + rank = "SERGEANT"; + position[] = {0, 0, 0}; + }; + class Unit1 { + vehicle = "O_Soldier_LAT_F"; + rank = "CORPORAL"; + position[] = {5, -4, 0}; + }; + class Unit2 { + vehicle = "O_Soldier_F"; + rank = "PRIVATE"; + position[] = {-5, -4, 0}; + }; + class Unit3 { + vehicle = "O_Soldier_A_F"; + rank = "PRIVATE"; + position[] = {8, -7, 0}; + }; + }; + suitable[] = {"attack", "defend"}; + }; + class SpecOps { + side = "EAST"; + class Units { + class Unit0 { + vehicle = "O_recon_TL_F"; + rank = "SERGEANT"; + position[] = {0, 0, 0}; + }; + class Unit1 { + vehicle = "O_recon_M_F"; + rank = "CORPORAL"; + position[] = {5, -5, 0}; + }; + }; + suitable[] = {"hvt", "hostage"}; + }; + class ReconRaid { + side = "EAST"; + class Units { + class Unit0 { + vehicle = "O_recon_TL_F"; + rank = "SERGEANT"; + position[] = {0, 0, 0}; + }; + class Unit1 { + vehicle = "O_recon_M_F"; + rank = "CORPORAL"; + position[] = {4, -4, 0}; + }; + class Unit2 { + vehicle = "O_recon_LAT_F"; + rank = "CORPORAL"; + position[] = {-4, -4, 0}; + }; + class Unit3 { + vehicle = "O_recon_medic_F"; + rank = "PRIVATE"; + position[] = {7, -7, 0}; + }; + }; + suitable[] = {"attack", "hvt", "hostage"}; + }; + }; + + // TODO: Continue to refine mission types and their specific settings + // Mission type specific settings + class MissionTypes { + class Attack { + minUnits = 4; + maxUnits = 8; + class Rewards { + money[] = {25000, 60000}; + reputation[] = {6, 14}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-8, -3}; + timeLimit[] = {900, 1800}; // 15-30 minutes + }; + + class Defend { + minWaves = 3; + maxWaves = 8; + unitsPerWave[] = {4, 8}; + waveCooldown = 300; + class Rewards { + money[] = {40000, 90000}; + reputation[] = {8, 18}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-12, -4}; + timeLimit[] = {1800, 3600}; // 30-60 minutes + }; + + class Hostage { + class Hostages { + civilian[] = {"C_man_1", "C_man_polo_1_F"}; + military[] = {"B_Pilot_F", "B_officer_F"}; + }; + class Rewards { + money[] = {60000, 140000}; + reputation[] = {12, 25}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-16, -6}; + timeLimit[] = {600, 900}; // 10-15 minutes + }; + + class HVT { + class Targets { + officer[] = {"O_officer_F"}; + sniper[] = {"O_sniper_F"}; + }; + escorts = 4; + class Rewards { + money[] = {50000, 120000}; + reputation[] = {10, 22}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-14, -5}; + timeLimit[] = {900, 1800}; // 15-30 minutes + }; + + class Defuse { + class Devices { + small[] = {"DemoCharge_Remote_Mag"}; + large[] = {"SatchelCharge_Remote_Mag"}; + }; + maxDevices = 3; + class Rewards { + money[] = {20000, 50000}; + reputation[] = {5, 12}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-9, -3}; + timeLimit[] = {600, 900}; // 10-15 minutes + }; + + class Delivery { + class Cargo { + supplies[] = {"Land_CargoBox_V1_F"}; + vehicles[] = {"B_MRAP_01_F", "B_Truck_01_transport_F"}; + }; + class Rewards { + money[] = {10000, 30000}; + reputation[] = {3, 8}; + equipment[] = {{"ItemGPS", 0.5}, {"ItemCompass", 0.3}}; + supplies[] = {{"FirstAidKit", 0.2}, {"Medikit", 0.1}}; + weapons[] = {{"arifle_MX_F", 0.3}, {"arifle_Katiba_F", 0.2}}; + vehicles[] = {{"B_MRAP_01_F", 0.1}, {"B_APC_Wheeled_01_cannon_F", 0.05}}; + special[] = {{"B_UAV_01_F", 0.05}, {"B_Heli_Light_01_F", 0.02}}; + }; + penalty[] = {-6, -2}; + timeLimit[] = {900, 1800}; // 15-30 minutes + }; + }; +}; diff --git a/arma/server/addons/task/CfgVehicles.hpp b/arma/server/addons/task/CfgVehicles.hpp new file mode 100644 index 0000000..06a1a39 --- /dev/null +++ b/arma/server/addons/task/CfgVehicles.hpp @@ -0,0 +1,782 @@ +class CfgVehicles { + class Logic; + class Module_F: Logic { + class AttributesBase { + class Edit; + class Combo; + }; + class ModuleDescription {}; + }; + + class FORGE_Module_Attack: Module_F { + scope = 2; + displayName = "Attack Task"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(attackModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 1; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase { + class TaskID: Edit { + property = "FORGE_Module_Attack_TaskID"; + displayName = "Task ID"; + tooltip = "Unique identifier for this task"; + typeName = "STRING"; + // defaultValue = """"; + }; + class LimitFail: Edit { + property = "FORGE_Module_Attack_LimitFail"; + displayName = "Fail Limit"; + tooltip = "Number of targets that escape to fail the task"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class LimitSuccess: Edit { + property = "FORGE_Module_Attack_LimitSuccess"; + displayName = "Success Limit"; + tooltip = "Number of targets that need to be eliminated to succeed the task"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class CompanyFunds: Edit { + property = "FORGE_Module_Attack_CompanyFunds"; + displayName = "Reward Funds"; + tooltip = "Amount of funds awarded on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingFail: Edit { + property = "FORGE_Module_Attack_RatingFail"; + displayName = "Rating Loss"; + tooltip = "Amount of rating lost on failure"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingSuccess: Edit { + property = "FORGE_Module_Attack_RatingSuccess"; + displayName = "Rating Gain"; + tooltip = "Amount of rating gained on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class EndSuccess: Combo { + property = "FORGE_Module_Attack_EndSuccess"; + displayName = "End on Success"; + tooltip = "End mission when task is completed successfully"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndSuccess { name = "Enable"; value = 1; }; + class DisableEndSuccess { name = "Disable"; value = 0; }; + }; + }; + class EndFail: Combo { + property = "FORGE_Module_Attack_EndFail"; + displayName = "End on Failure"; + tooltip = "End mission when task fails"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndFail { name = "Enable"; value = 1; }; + class DisableEndFail { name = "Disable"; value = 0; }; + }; + }; + class TimeLimit: Edit { + property = "FORGE_Module_Attack_TimeLimit"; + displayName = "Time Limit"; + tooltip = "Time in seconds before targets escape (0 for no limit)"; + typeName = "NUMBER"; + defaultValue = 0; + }; + }; + + class ModuleDescription: ModuleDescription { + description = "Creates an attack task with configurable parameters"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Attack task module", + "Sync with units/vehicles to mark as targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Explosives: Module_F { + scope = 2; + displayName = "Explosive Entities"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(explosivesModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 0; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase {}; + class ModuleDescription: ModuleDescription { + description = "Module for explosive entities that need to be defused"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Explosive entities module", + "Sync with objects to mark as explosives", + "Those objects will be processed as defusal targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Hostages: Module_F { + scope = 2; + displayName = "Hostage Entities"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(hostagesModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 0; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase {}; + class ModuleDescription: ModuleDescription { + description = "Module for hostage entities that need to be rescued"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Hostage entities module", + "Sync with units to mark as hostages", + "Those objects will be processed as rescue targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Shooters: Module_F { + scope = 2; + displayName = "Shooter Entities"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(shootersModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 0; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase {}; + class ModuleDescription: ModuleDescription { + description = "Module for shooter entities that need to be eliminated"; + sync[] = { "AnyBrain" }; + + class AnyBrain { + description[] = { + "Shooter entities module", + "Sync with units to mark as shooters", + "Those objects will be processed as elimination targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Protected: Module_F { + scope = 2; + displayName = "Protected Entities"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(protectedModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 0; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase {}; + class ModuleDescription: ModuleDescription { + description = "Module for protected entities that need to be protected"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Protected entities module", + "Sync with objects to mark as protected entities", + "Those objects will be processed as protected targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Defuse: Module_F { + scope = 2; + displayName = "Defuse Task"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(defuseModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 1; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase { + class TaskID: Edit { + property = "FORGE_Module_Defuse_TaskID"; + displayName = "Task ID"; + tooltip = "Unique identifier for this task"; + typeName = "STRING"; + // defaultValue = """"; + }; + class LimitFail: Edit { + property = "FORGE_Module_Defuse_LimitFail"; + displayName = "Fail Limit"; + tooltip = "Number of protected entities destroyed to fail the task"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class LimitSuccess: Edit { + property = "FORGE_Module_Defuse_LimitSuccess"; + displayName = "Success Limit"; + tooltip = "Number of entities that need to be defused to complete the task"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class CompanyFunds: Edit { + property = "FORGE_Module_Defuse_CompanyFunds"; + displayName = "Reward Funds"; + tooltip = "Amount of funds awarded on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingFail: Edit { + property = "FORGE_Module_Defuse_RatingFail"; + displayName = "Rating Loss"; + tooltip = "Amount of rating lost on failure"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingSuccess: Edit { + property = "FORGE_Module_Defuse_RatingSuccess"; + displayName = "Rating Gain"; + tooltip = "Amount of rating gained on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class EndSuccess: Combo { + property = "FORGE_Module_Defuse_EndSuccess"; + displayName = "End on Success"; + tooltip = "End mission when task is completed successfully"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndSuccess { name = "Enable"; value = 1; }; + class DisableEnSuccess { name = "Disable"; value = 0; }; + }; + }; + class EndFail: Combo { + property = "FORGE_Module_Defuse_EndFail"; + displayName = "End on Failure"; + tooltip = "End mission when task fails"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndFail { name = "Enable"; value = 1; }; + class DisableEndFail { name = "Disable"; value = 0; }; + }; + }; + class TimeLimit: Edit { + property = "FORGE_Module_Defuse_TimeLimit"; + displayName = "Time Limit"; + tooltip = "Time in seconds before detenation (0 for no limit)"; + typeName = "NUMBER"; + defaultValue = 0; + }; + }; + + class ModuleDescription: ModuleDescription { + description = "Creates a defuse task with configurable parameters"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Defuse task module", + "Sync with entities to mark as explosives and protected entities", + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Destroy: Module_F { + scope = 2; + displayName = "Destroy Task"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(destroyModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 1; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase { + class TaskID: Edit { + property = "FORGE_Module_Destroy_TaskID"; + displayName = "Task ID"; + tooltip = "Unique identifier for this task"; + typeName = "STRING"; + // defaultValue = """"; + }; + class LimitFail: Edit { + property = "FORGE_Module_Destroy_LimitFail"; + displayName = "Fail Limit"; + tooltip = "Number of targets that can escape before failing"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class LimitSuccess: Edit { + property = "FORGE_Module_Destroy_LimitSuccess"; + displayName = "Success Limit"; + tooltip = "Number of targets that need to be destroyed"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class CompanyFunds: Edit { + property = "FORGE_Module_Destroy_CompanyFunds"; + displayName = "Reward Funds"; + tooltip = "Amount of funds awarded on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingFail: Edit { + property = "FORGE_Module_Destroy_RatingFail"; + displayName = "Rating Loss"; + tooltip = "Amount of rating lost on failure"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingSuccess: Edit { + property = "FORGE_Module_Destroy_RatingSuccess"; + displayName = "Rating Gain"; + tooltip = "Amount of rating gained on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class EndSuccess: Combo { + property = "FORGE_Module_Destroy_EndSuccess"; + displayName = "End on Success"; + tooltip = "End mission when task is completed successfully"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndSuccess { name = "Enable"; value = 1; }; + class DisableEndSuccess { name = "Disable"; value = 0; }; + }; + }; + class EndFail: Combo { + property = "FORGE_Module_Destroy_EndFail"; + displayName = "End on Failure"; + tooltip = "End mission when task fails"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndFail { name = "Enable"; value = 1; }; + class DisableEndFail { name = "Disable"; value = 0; }; + }; + }; + class TimeLimit: Edit { + property = "FORGE_Module_Destroy_TimeLimit"; + displayName = "Time Limit"; + tooltip = "Time in seconds before targets escape (0 for no limit)"; + typeName = "NUMBER"; + defaultValue = 0; + }; + }; + + class ModuleDescription: ModuleDescription { + description = "Creates a destroy task with configurable parameters"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Destroy task module", + "Sync with units and/or vehicles to mark as targets" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_Hostage: Module_F { + scope = 2; + displayName = "Hostage Task"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(hostageModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 1; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase { + class TaskID: Edit { + property = "FORGE_Module_Hostage_TaskID"; + displayName = "Task ID"; + tooltip = "Unique identifier for this task"; + typeName = "STRING"; + // defaultValue = """"; + }; + class LimitFail: Edit { + property = "FORGE_Module_Hostage_LimitFail"; + displayName = "Fail Limit"; + tooltip = "Number of hostages KIA before failing"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class LimitSuccess: Edit { + property = "FORGE_Module_Hostage_LimitSuccess"; + displayName = "Success Limit"; + tooltip = "Number of hostages rescued before succeeding"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class ExtZone: Edit { + property = "FORGE_Module_Hostage_ExtZone"; + displayName = "Extraction Zone"; + tooltip = "Unique marker name for the extraction zone"; + typeName = "STRING"; + // defaultValue = """"; + }; + class CompanyFunds: Edit { + property = "FORGE_Module_Hostage_CompanyFunds"; + displayName = "Reward Funds"; + tooltip = "Amount of funds awarded on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingFail: Edit { + property = "FORGE_Module_Hostage_RatingFail"; + displayName = "Rating Loss"; + tooltip = "Amount of rating lost on failure"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingSuccess: Edit { + property = "FORGE_Module_Hostage_RatingSuccess"; + displayName = "Rating Gain"; + tooltip = "Amount of rating gained on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class CBRN: Combo { + property = "FORGE_Module_Hostage_CBRN"; + displayName = "CBRN Attack"; + tooltip = "CBRN Attack instead of execution"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class TrueCBRN { name = "True"; value = 1; }; + class FalseCBRN { name = "False"; value = 0; }; + }; + }; + class Execution: Combo { + property = "FORGE_Module_Hostage_Execution"; + displayName = "Execution"; + tooltip = "Execution instead of CBRN Attack"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class TrueExecution { name = "True"; value = 1; }; + class FalseExecution { name = "False"; value = 0; }; + }; + }; + class EndSuccess: Combo { + property = "FORGE_Module_Hostage_EndSuccess"; + displayName = "End on Success"; + tooltip = "End mission when task is completed successfully"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndSuccess { name = "Enable"; value = 1; }; + class DisableEndSuccess { name = "Disable"; value = 0; }; + }; + }; + class EndFail: Combo { + property = "FORGE_Module_Hostage_EndFail"; + displayName = "End on Failure"; + tooltip = "End mission when task fails"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndFail { name = "Enable"; value = 1; }; + class DisableEndFail { name = "Disable"; value = 0; }; + }; + }; + class TimeLimit: Edit { + property = "FORGE_Module_Hostage_TimeLimit"; + displayName = "Time Limit"; + tooltip = "Time in seconds before HVTs escape (0 for no limit)"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class CBRNZone: Edit { + property = "FORGE_Module_Hostage_CBRNZone"; + displayName = "CBRN Zone"; + tooltip = "Unique marker name for the CBRN zone"; + typeName = "STRING"; + // defaultValue = """"; + }; + }; + + class ModuleDescription: ModuleDescription { + description = "Creates a Hostage task with configurable parameters"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "Hostage task module", + "Sync with hostage and shooter module to register the entities to the task" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; + + class FORGE_Module_HVT: Module_F { + scope = 2; + displayName = "HVT Task"; + // icon = "\a3\ui_f\data\IGUI\Cfg\simpleTasks\types\default_ca.paa"; + category = "FORGE_Modules"; + + function = QFUNC(hvtModule); + functionPriority = 1; + isGlobal = 1; + isTriggerActivated = 1; + isDisposable = 1; + is3DEN = 0; + + canSetArea = 0; + canSetAreaShape = 0; + canSetAreaHeight = 0; + + class AttributeValues {}; + class Attributes: AttributesBase { + class TaskID: Edit { + property = "FORGE_Module_HVT_TaskID"; + displayName = "Task ID"; + tooltip = "Unique identifier for this task"; + typeName = "STRING"; + // defaultValue = """"; + }; + class LimitFail: Edit { + property = "FORGE_Module_HVT_LimitFail"; + displayName = "Fail Limit"; + tooltip = "Number of hvts that can escape or KIA before failing"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class LimitSuccess: Edit { + property = "FORGE_Module_HVT_LimitSuccess"; + displayName = "Success Limit"; + tooltip = "Number of hvts that need to be captured or KIA"; + typeName = "NUMBER"; + defaultValue = -1; + }; + class ExtZone: Edit { + property = "FORGE_Module_HVT_ExtZone"; + displayName = "Extraction Zone"; + tooltip = "Unique marker name for the extraction zone"; + typeName = "STRING"; + // defaultValue = """"; + }; + class CompanyFunds: Edit { + property = "FORGE_Module_HVT_CompanyFunds"; + displayName = "Reward Funds"; + tooltip = "Amount of funds awarded on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingFail: Edit { + property = "FORGE_Module_HVT_RatingFail"; + displayName = "Rating Loss"; + tooltip = "Amount of rating lost on failure"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class RatingSuccess: Edit { + property = "FORGE_Module_HVT_RatingSuccess"; + displayName = "Rating Gain"; + tooltip = "Amount of rating gained on success"; + typeName = "NUMBER"; + defaultValue = 0; + }; + class CaptureHVT: Combo { + property = "FORGE_Module_HVT_CaptureHVT"; + displayName = "Capture HVT"; + tooltip = "Capture HVT instead of eliminating"; + typeName = "BOOL"; + defaultValue = 1; + + class Values { + class TrueCapture { name = "True"; value = 1; }; + class FalseCapture { name = "False"; value = 0; }; + }; + }; + class EliminateHVT: Combo { + property = "FORGE_Module_HVT_EliminateHVT"; + displayName = "Eliminate HVT"; + tooltip = "Eliminate HVT instead of capturing"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class TrueEliminate { name = "True"; value = 1; }; + class FalseEliminate { name = "False"; value = 0; }; + }; + }; + class EndSuccess: Combo { + property = "FORGE_Module_HVT_EndSuccess"; + displayName = "End on Success"; + tooltip = "End mission when task is completed successfully"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndSuccess { name = "Enable"; value = 1; }; + class DisableEndSuccess { name = "Disable"; value = 0; }; + }; + }; + class EndFail: Combo { + property = "FORGE_Module_HVT_EndFail"; + displayName = "End on Failure"; + tooltip = "End mission when task fails"; + typeName = "BOOL"; + defaultValue = 0; + + class Values { + class EnableEndFail { name = "Enable"; value = 1; }; + class DisableEndFail { name = "Disable"; value = 0; }; + }; + }; + class TimeLimit: Edit { + property = "FORGE_Module_HVT_TimeLimit"; + displayName = "Time Limit"; + tooltip = "Time in seconds before HVTs escape (0 for no limit)"; + typeName = "NUMBER"; + defaultValue = 0; + }; + }; + + class ModuleDescription: ModuleDescription { + description = "Creates a HVT task with configurable parameters"; + sync[] = { "Anything" }; + + class Anything { + description[] = { + "HVT task module", + "Sync with units to mark as HVTs" + }; + position = 1; + direction = 1; + optional = 1; + duplicate = 1; + }; + }; + }; +}; diff --git a/arma/server/addons/task/README.md b/arma/server/addons/task/README.md new file mode 100644 index 0000000..3ea76e3 --- /dev/null +++ b/arma/server/addons/task/README.md @@ -0,0 +1,104 @@ +# Forge Task Module + +## Overview +The task addon is a server-owned mission/task system for Forge. It manages task execution, task-owned state, participant tracking, contribution-based player earnings, and org-owned rewards. + +## Responsibilities +- spawn and monitor task flows on the server +- track per-task entities through `TaskStore` +- track task participants and engine-rating contribution +- award player earnings through the bank module +- award org funds, reputation, assets, and fleet rewards +- notify task participants and sync org updates to online members + +## Dependencies +- `forge_server_common` +- `forge_server_actor` +- `forge_server_bank` +- `forge_server_org` +- `forge_client_notifications` + +## Main Components + +### Task Flows +- `fnc_attack.sqf` +- `fnc_defend.sqf` +- `fnc_defuse.sqf` +- `fnc_delivery.sqf` +- `fnc_destroy.sqf` +- `fnc_hostage.sqf` +- `fnc_hvt.sqf` + +### TaskStore +`fnc_initTaskStore.sqf` initializes `TaskStore`, which owns: +- task ownership bindings +- participant snapshots +- defuse progress +- per-task entity registries for cargo, hostages, HVTs, IEDs, protected entities, shooters, and targets + +### Reward Handling +`fnc_handleTaskRewards.sqf` applies org-owned rewards: +- `funds` -> org funds +- `equipment`, `supplies`, `weapons`, `special` -> org assets +- `vehicles` -> org fleet + +Player `earnings` and org `reputation` from task outcomes are distributed separately through `TaskStore.applyRatingOutcome` using Arma engine `rating` deltas. + +## Task Ownership +Tasks are bound to an owner org when they are started through `fnc_handler.sqf`. + +- if a requester UID is provided, the task is owned by that requester's org +- if no requester UID is available, the task is bound to the `default` org + +Org rewards always go to the bound owner org. Player earnings still use per-player contribution. + +## Usage + +### Start Through The Handler +Use the handler when you want reputation gating and task ownership binding. + +```sqf +["attack", ["task_attack_1", 1, 2, 1500000, -75, 375, false, false], 250, getPlayerUID player] call forge_server_task_fnc_handler; +["delivery", ["task_delivery_1", 1, 3, "delivery_zone", 250000, -75, 300, false, false, 900], 0, getPlayerUID player] call forge_server_task_fnc_handler; +``` + +Arguments: +- `0`: task type +- `1`: task-specific argument array +- `2`: minimum org reputation required to start the task +- `3`: requester UID used for ownership binding + +### Start Task Functions Directly +Direct task calls still work, but they do not provide a requester UID. That means task ownership falls back to the `default` org. + +Use direct starts only when that behavior is intended, such as: +- mission-authored tasks +- editor-placed tasks +- server-owned/random tasks + +If you want the accepting player's org to own the task rewards, use `fnc_handler.sqf` instead. + +```sqf +["task_attack_1", 1, 2, 1500000, -75, 375, false, false] spawn forge_server_task_fnc_attack; +["task_hostage_1", 1, 2, "extract_marker", 1500000, -75, 500, [false, true], false, false] spawn forge_server_task_fnc_hostage; +``` + +## Event Hooks +- `XEH_preInit.sqf` + - compiles functions + - initializes `TaskStore` +- `XEH_postInit.sqf` + - registers the ACE defuse event hook + - starts the attack-only mission manager on the server + +## Notes +- the dynamic mission manager in `fnc_missionManager.sqf` is now limited to attack missions only +- it starts server-owned tasks through `fnc_handler.sqf` and binds them to the `default` org +- task lifecycle for the mission manager is tracked through `TaskStore` status entries +- task rewards are org-owned, not player-owned +- participant notifications are sent through the notifications module, not through local server UI + +## Authors +- J. Schmidt +- Creedcoder +- IDSolutions diff --git a/arma/server/addons/task/XEH_PREP.hpp b/arma/server/addons/task/XEH_PREP.hpp new file mode 100644 index 0000000..1fb187a --- /dev/null +++ b/arma/server/addons/task/XEH_PREP.hpp @@ -0,0 +1,31 @@ +PREP(attack); +PREP(attackModule); +PREP(defend); +PREP(defendModule); +PREP(defuse); +PREP(defuseModule); +PREP(delivery); +PREP(deliveryModule); +PREP(destroy); +PREP(destroyModule); +PREP(explosivesModule); +PREP(handler); +PREP(handleTaskRewards); +PREP(heartBeat); +PREP(hostage); +PREP(hostageModule); +PREP(hostagesModule); +PREP(hvt); +PREP(hvtModule); +PREP(makeCargo); +PREP(makeHostage); +PREP(makeHVT); +PREP(makeIED); +PREP(makeObject); +PREP(makeShooter); +PREP(makeTarget); +PREP(missionManager); +PREP(initTaskStore); +PREP(protectedModule); +PREP(shootersModule); +PREP(spawnEnemyWave); diff --git a/arma/server/addons/task/XEH_postInit.sqf b/arma/server/addons/task/XEH_postInit.sqf new file mode 100644 index 0000000..5dcafcd --- /dev/null +++ b/arma/server/addons/task/XEH_postInit.sqf @@ -0,0 +1,16 @@ +#include "script_component.hpp" + +if (isServer) then { [] call FUNC(missionManager); }; + +["ace_explosives_defuse", { + private _taskID = ""; + { + if (_x isEqualType objNull && { !isNull _x }) then { + _taskID = _x getVariable ["assignedTask", ""]; + if (_taskID isNotEqualTo "") exitWith {}; + }; + } forEach _this; + + if (_taskID isEqualTo "") exitWith {}; + GVAR(TaskStore) call ["incrementDefuseCount", [_taskID]]; +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/task/XEH_preInit.sqf b/arma/server/addons/task/XEH_preInit.sqf new file mode 100644 index 0000000..40139e4 --- /dev/null +++ b/arma/server/addons/task/XEH_preInit.sqf @@ -0,0 +1,35 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; + +call FUNC(initTaskStore); + +[QGVAR(requestTaskCatalog), { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { + ["WARNING", "Task catalog request received with empty UID."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + [CRPC(cad,responseTaskCatalog), [GVAR(TaskStore) call ["getActiveTaskCatalog", []]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestAcceptTask), { + params [["_uid", "", [""]], ["_taskID", "", [""]]]; + + if (_uid isEqualTo "" || { _taskID isEqualTo "" }) exitWith { + ["WARNING", "Invalid task accept request payload."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(TaskStore) call ["acceptTask", [_taskID, _uid]]; + [CRPC(cad,responseTaskAccept), [_result], _player] call CFUNC(targetEvent); + [CRPC(cad,responseTaskCatalog), [GVAR(TaskStore) call ["getActiveTaskCatalog", []]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/task/XEH_preStart.sqf b/arma/server/addons/task/XEH_preStart.sqf new file mode 100644 index 0000000..a51262a --- /dev/null +++ b/arma/server/addons/task/XEH_preStart.sqf @@ -0,0 +1,2 @@ +#include "script_component.hpp" +#include "XEH_PREP.hpp" diff --git a/arma/server/addons/task/config.cpp b/arma/server/addons/task/config.cpp new file mode 100644 index 0000000..fa97541 --- /dev/null +++ b/arma/server/addons/task/config.cpp @@ -0,0 +1,23 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"J.Schmidt"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_server_main", + "forge_server_common" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" +#include "CfgFactionClasses.hpp" +#include "CfgVehicles.hpp" +#include "CfgMissions.hpp" diff --git a/arma/server/addons/task/functions/fnc_attack.sqf b/arma/server/addons/task/functions/fnc_attack.sqf new file mode 100644 index 0000000..ef91a6e --- /dev/null +++ b/arma/server/addons/task/functions/fnc_attack.sqf @@ -0,0 +1,116 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers an attack task + * + * Arguments: + * 0: ID of the task + * 1: Amount of targets escaped to fail the task + * 2: Amount of targets eliminated to complete the task + * 3: Amount of funds the company recieves if the task is successful (default: 0) + * 4: Amount of rating the company and player lose if the task is failed (default: 0) + * 5: Amount of rating the company and player recieve if the task is successful (default: 0) + * 6: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 7: Should the mission end (MissionFailed) if the task is failed (default: false) + * 8: Amount of time before target(s) escape (default: -1) + * 9: Equipment rewards (default: []) + * 10: Supply rewards (default: []) + * 11: Weapon rewards (default: []) + * 12: Vehicle rewards (default: []) + * 13: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["task_name", 1, 2, 1500000, -75, 375, false, false] spawn forge_server_task_fnc_attack; + * ["task_name", 1, 2, 1500000, -75, 375, false, false, 45] spawn forge_server_task_fnc_attack; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_limitFail", -1, [0]], + ["_limitSuccess", -1, [0]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_time", -1, [0]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _result = 0; +private _targets = []; + +waitUntil { + sleep 1; + _targets = GVAR(TaskStore) call ["getTaskEntities", ["targets", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _targets, "", 300]]; + count _targets > 0 +}; + +_targets = GVAR(TaskStore) call ["getTaskEntities", ["targets", _taskID]]; +private _startTime = if (!isNil "_time") then { floor(time) } else { nil }; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _targets, "", 300]]; + + private _targetsKilled = ({ !alive _x } count _targets); + + if (_time isNotEqualTo -1) then { + private _timeExpired = (floor time - _startTime >= _time); + + if (_targetsKilled < _limitSuccess && _timeExpired) then { _result = 1; }; + + (_result == 1) or (_targetsKilled >= _limitSuccess) + } else { + (_targetsKilled >= _limitSuccess) + }; +}; + +if (_result == 1) then { + { deleteVehicle _x } forEach _targets; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]]; + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _targets; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]]; + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_attackModule.sqf b/arma/server/addons/task/functions/fnc_attackModule.sqf new file mode 100644 index 0000000..7a364d6 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_attackModule.sqf @@ -0,0 +1,51 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the attack module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_attackModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; + +private _taskID = _logic getVariable ["TaskID", ""]; +private _limitFail = _logic getVariable ["LimitFail", -1]; +private _limitSuccess = _logic getVariable ["LimitSuccess", -1]; +private _companyFunds = _logic getVariable ["CompanyFunds", 0]; +private _ratingFail = _logic getVariable ["RatingFail", 0]; +private _ratingSuccess = _logic getVariable ["RatingSuccess", 0]; +private _endSuccess = _logic getVariable ["EndSuccess", false]; +private _endFail = _logic getVariable ["EndFail", false]; +private _timeLimit = _logic getVariable ["TimeLimit", 0]; + +["INFO", format ["Attack Module Parameters: TaskID: %1, LimitFail: %2, LimitSuccess: %3, Funds: %4, RatingFail: %5, RatingSuccess: %6, EndSuccess: %7, EndFail: %8, Time: %9", _taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail, _timeLimit]] call EFUNC(common,log); + +private _syncedEntities = synchronizedObjects _logic; +["INFO", format ["Attack Module Synced Entities: %1", _syncedEntities]] call EFUNC(common,log); + +{ + [_x, _taskID] spawn FUNC(makeTarget); +} forEach _syncedEntities; + +private _params = [_taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail]; +if (_timeLimit != 0) then { + _params pushBack _timeLimit; +}; + +["attack", _params, 0, ""] spawn FUNC(handler); + +deleteVehicle _logic; diff --git a/arma/server/addons/task/functions/fnc_defend.sqf b/arma/server/addons/task/functions/fnc_defend.sqf new file mode 100644 index 0000000..c1e7b20 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_defend.sqf @@ -0,0 +1,126 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers a defend task where players must hold a zone marked by a marker + * + * Arguments: + * 0: ID of the task + * 1: Defense zone marker name + * 2: Time to defend in seconds + * 3: Amount of funds the company receives if the task is successful (default: 0) + * 4: Amount of rating the company and player lose if the task is failed (default: 0) + * 5: Amount of rating the company and player receive if the task is successful (default: 0) + * 6: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 7: Should the mission end (MissionFailed) if the task is failed (default: false) + * 8: Enemy wave count (default: 3) + * 9: Time between waves in seconds (default: 300) + * 10: Minimum BLUFOR units required in zone (default: 1) + * 11: Equipment rewards (default: []) + * 12: Supply rewards (default: []) + * 13: Weapon rewards (default: []) + * 14: Vehicle rewards (default: []) + * 15: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["defend_zone_1", "defend_marker", 900, 500000, -100, 400, false, false, 3, 300, 1, ["ItemGPS"], ["FirstAidKit"], ["arifle_MX_F"], ["B_MRAP_01_F"], ["B_UAV_01_F"]] spawn forge_server_task_fnc_defend; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_defenseZone", "", [""]], + ["_defendTime", 600, [0]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_waveCount", 3, [0]], + ["_waveCooldown", 300, [0]], + ["_minBlufor", 1, [0]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +if (_defenseZone == "" || !(markerShape _defenseZone in ["RECTANGLE", "ELLIPSE"])) exitWith { + ["ERROR", format ["Invalid defense zone marker: %1", _defenseZone]] call EFUNC(common,log); +}; + +private _result = 0; +private _startTime = time; +private _nextWaveTime = _startTime; +private _currentWave = 0; +private _zoneEmptyCounter = 0; +private _warningIssued = false; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, [], _defenseZone, 0]]; + private _bluforInZone = count (allUnits select { _x isKindOf "CAManBase" && { side _x == west } && { alive _x }} inAreaArray _defenseZone); + private _timeElapsed = time - _startTime; + + if (_bluforInZone < _minBlufor) then { + _zoneEmptyCounter = _zoneEmptyCounter + 1; + + if (_zoneEmptyCounter == 15 && !_warningIssued) then { + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", "Defense zone is empty. Return immediately."]]; + _warningIssued = true; + }; + } else { + _zoneEmptyCounter = 0; + _warningIssued = false; + }; + + if (_currentWave < _waveCount && time >= _nextWaveTime) then { + [_defenseZone, _taskID, _currentWave] call FUNC(spawnEnemyWave); + + _currentWave = _currentWave + 1; + _nextWaveTime = time + _waveCooldown; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "info", "Tasks", format ["Enemy forces approaching. Wave %1 of %2.", _currentWave, _waveCount]]]; + }; + + if (_zoneEmptyCounter >= 30) then { _result = 1; }; + + (_result == 1) or ((_bluforInZone >= _minBlufor) && (_timeElapsed >= _defendTime) && (_currentWave >= _waveCount)); +}; + +if (_result == 1) then { + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_defendModule.sqf b/arma/server/addons/task/functions/fnc_defendModule.sqf new file mode 100644 index 0000000..8d8dbe3 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_defendModule.sqf @@ -0,0 +1,61 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Creates a defend task module + * + * Arguments: + * None + * + * Return Value: + * None + * + * Example: + * call forge_server_task_fnc_defendModule; + * + * Public: No + */ + +// Module category +private _category = "Forge Tasks"; +private _subCategory = "Defense Tasks"; + +// Create the module +private _module = createDialog "RscDisplayAttributes"; +_module setVariable ["category", _category]; +_module setVariable ["subcategory", _subCategory]; +_module setVariable ["description", "Configure a defend task"]; + +// Add fields for task configuration +[_module, "Task ID", "taskID", "", true] call BIS_fnc_addAttribute; +[_module, "Defense Zone Marker", "defenseZone", "", true] call BIS_fnc_addAttribute; +[_module, "Defense Time (seconds)", "defendTime", "600", true] call BIS_fnc_addAttribute; +[_module, "Min BLUFOR in Zone", "minBlufor", "1", true] call BIS_fnc_addAttribute; +[_module, "Company Funds Reward", "companyFunds", "500000", true] call BIS_fnc_addAttribute; +[_module, "Rating Loss on Fail", "ratingFail", "-100", true] call BIS_fnc_addAttribute; +[_module, "Rating Gain on Success", "ratingSuccess", "400", true] call BIS_fnc_addAttribute; +[_module, "End Mission on Success", "endSuccess", "false", false] call BIS_fnc_addAttribute; +[_module, "End Mission on Fail", "endFail", "false", false] call BIS_fnc_addAttribute; +[_module, "Enemy Wave Count", "waveCount", "3", false] call BIS_fnc_addAttribute; +[_module, "Time Between Waves (seconds)", "waveCooldown", "300", false] call BIS_fnc_addAttribute; + +// Add confirm button handler +_module setVariable ["onConfirm", { + params ["_module"]; + private _taskID = _module getVariable ["taskID", ""]; + private _defenseZone = _module getVariable ["defenseZone", ""]; + private _defendTime = parseNumber (_module getVariable ["defendTime", "600"]); + private _companyFunds = parseNumber (_module getVariable ["companyFunds", "500000"]); + private _ratingFail = parseNumber (_module getVariable ["ratingFail", "-100"]); + private _ratingSuccess = parseNumber (_module getVariable ["ratingSuccess", "400"]); + private _endSuccess = _module getVariable ["endSuccess", "false"] == "true"; + private _endFail = _module getVariable ["endFail", "false"] == "true"; + private _waveCount = parseNumber (_module getVariable ["waveCount", "3"]); + private _waveCooldown = parseNumber (_module getVariable ["waveCooldown", "300"]); + private _minBlufor = parseNumber (_module getVariable ["minBlufor", "1"]); + + // Create the task + private _params = [_taskID, _defenseZone, _defendTime, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail, _waveCount, _waveCooldown, _minBlufor]; + private _requesterUid = ["", getPlayerUID player] select hasInterface; + ["defend", _params, 0, _requesterUid] spawn FUNC(handler); +}]; diff --git a/arma/server/addons/task/functions/fnc_defuse.sqf b/arma/server/addons/task/functions/fnc_defuse.sqf new file mode 100644 index 0000000..1c4d6b0 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_defuse.sqf @@ -0,0 +1,114 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers a defuse task + * + * Arguments: + * 0: ID of the task + * 1: Amount of entities destroyed to fail the task + * 2: Amount of ieds defused to complete the task + * 3: Amount of funds the company recieves if the task is successful (default: 0) + * 4: Amount of rating the company and player lose if the task is failed (default: 0) + * 5: Amount of rating the company and player recieve if the task is successful (default: 0) + * 6: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 7: Should the mission end (MissionFailed) if the task is failed (default: false) + * 8: Equipment rewards (default: []) + * 9: Supply rewards (default: []) + * 10: Weapon rewards (default: []) + * 11: Vehicle rewards (default: []) + * 12: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["task_name", 2, 3, 375000, -75, 300, false, false] spawn forge_server_task_fnc_defuse; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_limitFail", -1, [0]], + ["_limitSuccess", -1, [0]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _result = 0; +private _ieds = []; +private _entities = []; + +waitUntil { + sleep 1; + _ieds = GVAR(TaskStore) call ["getTaskEntities", ["ieds", _taskID]]; + count _ieds > 0 +}; + +waitUntil { + sleep 1; + _entities = GVAR(TaskStore) call ["getTaskEntities", ["entities", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _ieds + _entities, "", 250]]; + count _entities > 0 +}; + +_ieds = GVAR(TaskStore) call ["getTaskEntities", ["ieds", _taskID]]; +_entities = GVAR(TaskStore) call ["getTaskEntities", ["entities", _taskID]]; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _ieds + _entities, "", 250]]; + + private _entitiesDestroyed = ({ !alive _x } count _entities); + + if (_entitiesDestroyed >= _limitFail) then { _result = 1; }; + + (_result == 1) or ((GVAR(TaskStore) call ["getDefuseCount", [_taskID]]) >= _limitSuccess && (_entitiesDestroyed < _limitFail)) +}; + +if (_result == 1) then { + { deleteVehicle _x } forEach _ieds; + { deleteVehicle _x } forEach _entities; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _ieds; + { deleteVehicle _x } forEach _entities; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; + +GVAR(TaskStore) call ["clearTask", [_taskID]]; diff --git a/arma/server/addons/task/functions/fnc_defuseModule.sqf b/arma/server/addons/task/functions/fnc_defuseModule.sqf new file mode 100644 index 0000000..759c62b --- /dev/null +++ b/arma/server/addons/task/functions/fnc_defuseModule.sqf @@ -0,0 +1,64 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the defuse module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_defuseModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; + +private _taskID = _logic getVariable ["TaskID", ""]; +private _limitFail = _logic getVariable ["LimitFail", -1]; +private _limitSuccess = _logic getVariable ["LimitSuccess", -1]; +private _companyFunds = _logic getVariable ["CompanyFunds", 0]; +private _ratingFail = _logic getVariable ["RatingFail", 0]; +private _ratingSuccess = _logic getVariable ["RatingSuccess", 0]; +private _endSuccess = _logic getVariable ["EndSuccess", false]; +private _endFail = _logic getVariable ["EndFail", false]; +private _timeLimit = _logic getVariable ["TimeLimit", 0]; + +["INFO", format ["Defuse Module Parameters: TaskID: %1, LimitFail: %2, LimitSuccess: %3, Funds: %4, RatingFail: %5, RatingSuccess: %6, EndSuccess: %7, EndFail: %8, Time: %9", _taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail, _timeLimit]] call EFUNC(common,log); + +private _syncedModules = synchronizedObjects _logic; +["INFO", format ["Defuse Module Synced Modules: %1", _syncedModules]] call EFUNC(common,log); + +private _iedModule = (_syncedModules select { typeOf _x == "FORGE_Module_Explosives" }) select 0; +private _protectedModule = (_syncedModules select { typeOf _x == "FORGE_Module_Protected" }) select 0; + +private _explosiveEntities = synchronizedObjects _iedModule; +["INFO", format ["Defuse Module Explosive Entites: %1", _explosiveEntities]] call EFUNC(common,log); + +private _protectedEntities = synchronizedObjects _protectedModule; +["INFO", format ["Defuse Module Protected Entities: %1", _protectedEntities]] call EFUNC(common,log); + +{ + if (!isNull _x) then { + [_x, _taskID, _timeLimit] spawn FUNC(makeIED); + }; +} forEach _explosiveEntities; + +{ + if (!isNull _x) then { + [_x, _taskID] spawn FUNC(makeObject); + }; +} forEach _protectedEntities; + +private _params = [_taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail]; +["defuse", _params, 0, ""] spawn FUNC(handler); + +deleteVehicle _logic; diff --git a/arma/server/addons/task/functions/fnc_delivery.sqf b/arma/server/addons/task/functions/fnc_delivery.sqf new file mode 100644 index 0000000..1088e0f --- /dev/null +++ b/arma/server/addons/task/functions/fnc_delivery.sqf @@ -0,0 +1,120 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers a delivery task + * + * Arguments: + * 0: ID of the task + * 1: Amount of damaged cargo to fail the task + * 2: Amount of cargo delivered to complete the task + * 3: Marker name for the delivery zone + * 4: Amount of funds the company receives if the task is successful (default: 0) + * 5: Amount of rating the company and player lose if the task is failed (default: 0) + * 6: Amount of rating the company and player receive if the task is successful (default: 0) + * 7: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 8: Should the mission end (MissionFailed) if the task is failed (default: false) + * 9: Amount of time to complete delivery (default: -1) + * 10: Equipment rewards (default: []) + * 11: Supply rewards (default: []) + * 12: Weapon rewards (default: []) + * 13: Vehicle rewards (default: []) + * 14: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["delivery_1", 1, 3, "delivery_zone", 250000, -75, 300, false, false] spawn forge_server_task_fnc_delivery; + * ["delivery_1", 1, 3, "delivery_zone", 250000, -75, 300, false, false, 900] spawn forge_server_task_fnc_delivery; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_limitFail", -1, [0]], + ["_limitSuccess", -1, [0]], + ["_deliveryZone", "", [""]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_time", -1, [0]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _result = 0; +private _cargo = []; + +waitUntil { + sleep 1; + _cargo = GVAR(TaskStore) call ["getTaskEntities", ["cargo", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _cargo, _deliveryZone, 125]]; + count _cargo > 0 +}; + +_cargo = GVAR(TaskStore) call ["getTaskEntities", ["cargo", _taskID]]; +private _startTime = if (_time isNotEqualTo -1) then { floor(time) } else { nil }; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _cargo, _deliveryZone, 125]]; + + private _cargoDelivered = ({ _x inArea _deliveryZone && (damage _x) < 0.7 } count _cargo); + private _cargoDamaged = ({ damage _x >= 0.7 } count _cargo); + + if (_time isNotEqualTo -1) then { + private _timeExpired = (floor time - _startTime >= _time); + + if (_cargoDamaged >= _limitFail) then { _result = 1; }; + if (_cargoDelivered < _limitSuccess && _timeExpired) then { _result = 1; }; + + (_result == 1) or ((_cargoDelivered >= _limitSuccess) && (_cargoDamaged < _limitFail)) + } else { + if (_cargoDamaged >= _limitFail) then { _result = 1; }; + + (_result == 1) or ((_cargoDelivered >= _limitSuccess) && (_cargoDamaged < _limitFail)) + }; +}; + +if (_result == 1) then { + { deleteVehicle _x } forEach _cargo; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _cargo; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_deliveryModule.sqf b/arma/server/addons/task/functions/fnc_deliveryModule.sqf new file mode 100644 index 0000000..19ead22 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_deliveryModule.sqf @@ -0,0 +1,67 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Creates a delivery task module + * + * Arguments: + * None + * + * Return Value: + * None + * + * Example: + * call forge_server_task_fnc_deliveryModule; + * + * Public: No + */ + +// Module category +private _category = "Forge Tasks"; +private _subCategory = "Delivery Tasks"; + +// Create the module +private _module = createDialog "RscDisplayAttributes"; +_module setVariable ["category", _category]; +_module setVariable ["subcategory", _subCategory]; +_module setVariable ["description", "Configure a delivery task"]; + +// Add fields for task configuration +[_module, "Task ID", "taskID", "", true] call BIS_fnc_addAttribute; +[_module, "Fail Limit", "limitFail", "1", true] call BIS_fnc_addAttribute; +[_module, "Success Count", "limitSuccess", "3", true] call BIS_fnc_addAttribute; +[_module, "Delivery Zone", "deliveryZone", "", true] call BIS_fnc_addAttribute; +[_module, "Company Funds Reward", "companyFunds", "250000", true] call BIS_fnc_addAttribute; +[_module, "Rating Loss on Fail", "ratingFail", "-75", true] call BIS_fnc_addAttribute; +[_module, "Rating Gain on Success", "ratingSuccess", "300", true] call BIS_fnc_addAttribute; +[_module, "End Mission on Success", "endSuccess", "false", false] call BIS_fnc_addAttribute; +[_module, "End Mission on Fail", "endFail", "false", false] call BIS_fnc_addAttribute; +[_module, "Time Limit (seconds)", "timeLimit", "", false] call BIS_fnc_addAttribute; + +// Add confirm button handler +_module setVariable ["onConfirm", { + params ["_module"]; + + private _taskID = _module getVariable ["taskID", ""]; + private _limitFail = parseNumber (_module getVariable ["limitFail", "1"]); + private _limitSuccess = parseNumber (_module getVariable ["limitSuccess", "3"]); + private _deliveryZone = _module getVariable ["deliveryZone", ""]; + private _companyFunds = parseNumber (_module getVariable ["companyFunds", "250000"]); + private _ratingFail = parseNumber (_module getVariable ["ratingFail", "-75"]); + private _ratingSuccess = parseNumber (_module getVariable ["ratingSuccess", "300"]); + private _endSuccess = _module getVariable ["endSuccess", "false"] == "true"; + private _endFail = _module getVariable ["endFail", "false"] == "true"; + private _timeLimit = _module getVariable ["timeLimit", ""]; + + // Convert time limit to number or nil + private _timeLimitNum = if (_timeLimit == "") then { nil } else { parseNumber _timeLimit }; + + // Create the task + private _params = [_taskID, _limitFail, _limitSuccess, _deliveryZone, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail]; + if (!isNil "_timeLimitNum") then { + _params pushBack _timeLimitNum; + }; + + private _requesterUid = ["", getPlayerUID player] select hasInterface; + ["delivery", _params, 0, _requesterUid] spawn FUNC(handler); +}]; diff --git a/arma/server/addons/task/functions/fnc_destroy.sqf b/arma/server/addons/task/functions/fnc_destroy.sqf new file mode 100644 index 0000000..dcb1013 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_destroy.sqf @@ -0,0 +1,114 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers an destroy task + * + * Arguments: + * 0: ID of the task + * 1: Amount of targets escaped to fail the task + * 2: Amount of targets eliminated to complete the task + * 3: Amount of funds the company recieves if the task is successful (default: 0) + * 4: Amount of rating the company and player lose if the task is failed (default: 0) + * 5: Amount of rating the company and player recieve if the task is successful (default: 0) + * 6: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 7: Should the mission end (MissionFailed) if the task is failed (default: false) + * 8: Amount of time before target(s) escape (default: -1) + * 9: Equipment rewards (default: []) + * 10: Supply rewards (default: []) + * 11: Weapon rewards (default: []) + * 12: Vehicle rewards (default: []) + * 13: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["task_name", 1, 2, 250000, -75, 300, false, false] spawn forge_server_task_fnc_destroy; + * ["task_name", 1, 2, 250000, -75, 300, false, false, 45] spawn forge_server_task_fnc_destroy; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_limitFail", -1, [0]], + ["_limitSuccess", -1, [0]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_time", -1, [0]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _result = 0; +private _targets = []; + +waitUntil { + sleep 1; + _targets = GVAR(TaskStore) call ["getTaskEntities", ["targets", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _targets, "", 300]]; + count _targets > 0 +}; + +_targets = GVAR(TaskStore) call ["getTaskEntities", ["targets", _taskID]]; +private _startTime = if (!isNil "_time") then { floor(time) } else { nil }; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _targets, "", 300]]; + + private _targetsDestroyed = ({ !alive _x } count _targets); + + if (!isNil "_time") then { + private _timeExpired = (floor time - _startTime >= _time); + + if (_targetsDestroyed < _limitSuccess && _timeExpired) then { _result = 1; }; + + (_result == 1) or (_targetsDestroyed >= _limitSuccess) + } else { + (_targetsDestroyed >= _limitSuccess) + }; +}; + +if (_result == 1) then { + { deleteVehicle _x } forEach _targets; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _targets; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_destroyModule.sqf b/arma/server/addons/task/functions/fnc_destroyModule.sqf new file mode 100644 index 0000000..eaac010 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_destroyModule.sqf @@ -0,0 +1,51 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the destroy module. + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_destroyModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; + +private _taskID = _logic getVariable ["TaskID", ""]; +private _limitFail = _logic getVariable ["LimitFail", -1]; +private _limitSuccess = _logic getVariable ["LimitSuccess", -1]; +private _companyFunds = _logic getVariable ["CompanyFunds", 0]; +private _ratingFail = _logic getVariable ["RatingFail", 0]; +private _ratingSuccess = _logic getVariable ["RatingSuccess", 0]; +private _endSuccess = _logic getVariable ["EndSuccess", false]; +private _endFail = _logic getVariable ["EndFail", false]; +private _timeLimit = _logic getVariable ["TimeLimit", 0]; + +["INFO", format ["Destroy Module Parameters: TaskID: %1, LimitFail: %2, LimitSuccess: %3, Funds: %4, RatingFail: %5, RatingSuccess: %6, EndSuccess: %7, EndFail: %8, Time: %9", _taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail, _timeLimit]] call EFUNC(common,log); + +private _syncedEntities = synchronizedObjects _logic; +["INFO", format ["Destroy Module Synced Entities: %1", _syncedEntities]] call EFUNC(common,log); + +{ + [_x, _taskID] spawn FUNC(makeTarget); +} forEach _syncedEntities; + +private _params = [_taskID, _limitFail, _limitSuccess, _companyFunds, _ratingFail, _ratingSuccess, _endSuccess, _endFail]; +if (_timeLimit != 0) then { + _params pushBack _timeLimit; +}; + +["destroy", _params, 0, ""] spawn FUNC(handler); + +deleteVehicle _logic; diff --git a/arma/server/addons/task/functions/fnc_explosivesModule.sqf b/arma/server/addons/task/functions/fnc_explosivesModule.sqf new file mode 100644 index 0000000..6725b17 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_explosivesModule.sqf @@ -0,0 +1,23 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the explosives module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_explosivesModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; diff --git a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf new file mode 100644 index 0000000..0812355 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf @@ -0,0 +1,223 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Handles task completion rewards for organizations. + * + * Arguments: + * 0: Task ID + * 1: Reward Data + * - funds: Amount of money to award + * - equipment: Array of equipment classnames to award + * - supplies: Array of supply classnames to award + * - weapons: Array of weapon classnames to award + * - vehicles: Array of vehicle classnames to award + * - special: Array of special item classnames to award + * + * Return Value: + * Success + * + * Example: + * private _rewards = createHashMapFromArray [ + * ["funds", 10000], + * ["reputation", 50], + * ["equipment", ["ItemGPS", "ItemCompass"]], + * ["supplies", ["FirstAidKit", "Medikit"]], + * ["weapons", ["arifle_MX_F"]], + * ["vehicles", ["B_MRAP_01_F"]], + * ["special", ["B_UAV_01_F"]] + * ]; + * ["task_1", _rewards] call forge_server_task_fnc_handleTaskRewards; + * + * Public: No + */ + +params [["_taskID", ""], ["_rewards", createHashMap]]; + +if (_taskID == "") exitWith { + ["ERROR", "No task ID provided for rewards"] call EFUNC(common,log); + false +}; + +private _rewardContext = GVAR(TaskStore) call ["resolveRewardContext", [_taskID]]; +private _requesterUid = _rewardContext getOrDefault ["requesterUid", ""]; +private _orgID = _rewardContext getOrDefault ["orgID", ""]; +private _memberUids = _rewardContext getOrDefault ["memberUids", []]; +if (_orgID isEqualTo "") exitWith { + ["ERROR", format ["No organization reward context found for task %1.", _taskID]] call EFUNC(common,log); + false +}; + +private _success = true; +private _funds = _rewards getOrDefault ["funds", 0]; +private _rewardMessages = []; + +private _resolveRewardLabel = { + params [["_className", "", [""]]]; + + if (_className isEqualTo "") exitWith { "" }; + + { + private _cfg = _x >> _className; + if (isClass _cfg) exitWith { + private _displayName = getText (_cfg >> "displayName"); + [_displayName, _className] select (_displayName isEqualTo ""); + }; + } forEach [ + configFile >> "CfgWeapons", + configFile >> "CfgMagazines", + configFile >> "CfgVehicles", + configFile >> "CfgGlasses" + ]; + + _className +}; + +private _notifyMembers = { + params [["_type", "info", [""]], ["_title", "Tasks", [""]], ["_message", "", [""]]]; + + if (_message isEqualTo "") exitWith {}; + { + private _player = [_x] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent); + } forEach _memberUids; +}; + +private _syncOrgPatch = { + params [["_patch", createHashMap, [createHashMap]]]; + + if (_patch isEqualTo createHashMap) exitWith {}; + { + private _player = [_x] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent); + } forEach _memberUids; +}; + +if (_funds > 0) then { + private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; + }; + + if (_org isEqualTo createHashMap) then { + ["ERROR", format ["Failed to load organization %1 for task %2 funds reward.", _orgID, _taskID]] call EFUNC(common,log); + _success = false; + } else { + private _patch = EGVAR(org,OrgStore) call [ + "set", + [ + EGVAR(org,Registry), + "org:update", + _orgID, + "funds", + ((_org getOrDefault ["funds", 0]) + _funds), + false + ] + ]; + + [_patch] call _syncOrgPatch; + _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)]; + }; +}; + +private _grantOrgAssets = { + params [["_category", "items", [""]], ["_items", [], [[]]]]; + + if (_items isEqualTo []) exitWith {}; + + private _assetEntries = _items apply { + createHashMapFromArray [ + ["classname", _x], + ["category", _category], + ["quantity", 1] + ] + }; + + private _grantResult = EGVAR(org,OrgStore) call ["addAssets", [_requesterUid, _assetEntries, false, _orgID]]; + if !(_grantResult getOrDefault ["success", false]) then { + ["ERROR", format ["Failed to award %1 assets for task %2: %3", _category, _taskID, _grantResult getOrDefault ["message", "Unknown error."]]] call EFUNC(common,log); + _success = false; + } else { + [_grantResult getOrDefault ["patch", createHashMap]] call _syncOrgPatch; + private _labels = _items apply { [_x] call _resolveRewardLabel }; + _rewardMessages pushBack format ["%1: %2", _category, _labels joinString ", "]; + }; +}; + +private _grantOrgFleet = { + params [["_vehicles", [], [[]]]]; + + if (_vehicles isEqualTo []) exitWith {}; + + private _vehicleEntries = _vehicles apply { + private _category = "other"; + if (_x isKindOf "Car") then { _category = "cars"; }; + if (_x isKindOf "Tank") then { _category = "armor"; }; + if (_x isKindOf "Helicopter") then { _category = "helis"; }; + if (_x isKindOf "Plane") then { _category = "planes"; }; + if (_x isKindOf "Ship") then { _category = "naval"; }; + + createHashMapFromArray [ + ["classname", _x], + ["category", _category] + ] + }; + + private _fleetResult = EGVAR(org,OrgStore) call ["addFleetVehicles", [_requesterUid, _vehicleEntries, false, _orgID]]; + if !(_fleetResult getOrDefault ["success", false]) then { + ["ERROR", format ["Failed to award vehicle rewards for task %2: %1", _fleetResult getOrDefault ["message", "Unknown error."], _taskID]] call EFUNC(common,log); + _success = false; + } else { + [_fleetResult getOrDefault ["patch", createHashMap]] call _syncOrgPatch; + private _labels = _vehicles apply { [_x] call _resolveRewardLabel }; + _rewardMessages pushBack format ["vehicles: %1", _labels joinString ", "]; + }; +}; + +private _equipment = _rewards getOrDefault ["equipment", []]; +if (count _equipment > 0) then { + ["equipment", _equipment] call _grantOrgAssets; +}; + +private _supplies = _rewards getOrDefault ["supplies", []]; +if (count _supplies > 0) then { + ["supplies", _supplies] call _grantOrgAssets; +}; + +private _weapons = _rewards getOrDefault ["weapons", []]; +if (count _weapons > 0) then { + ["weapons", _weapons] call _grantOrgAssets; +}; + +private _special = _rewards getOrDefault ["special", []]; +if (count _special > 0) then { + ["special", _special] call _grantOrgAssets; +}; + +private _vehicles = _rewards getOrDefault ["vehicles", []]; +if (count _vehicles > 0) then { + [_vehicles] call _grantOrgFleet; +}; + +if (_success) then { + private _orgName = ""; + private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + if (_org isNotEqualTo createHashMap) then { + _orgName = _org getOrDefault ["name", _orgID]; + }; + if (_orgName isEqualTo "") then { _orgName = _orgID; }; + + private _message = format ["Task rewards added to %1.", _orgName]; + if (_rewardMessages isNotEqualTo []) then { + _message = format ["%1 %2", _message, _rewardMessages joinString ", "]; + }; + + ["INFO", _message] call EFUNC(common,log); + ["success", "Tasks", _message] call _notifyMembers; +} else { + ["warning", "Tasks", format ["Task %1 completed, but one or more org rewards failed to apply.", _taskID]] call _notifyMembers; +}; + +_success diff --git a/arma/server/addons/task/functions/fnc_handler.sqf b/arma/server/addons/task/functions/fnc_handler.sqf new file mode 100644 index 0000000..73416f9 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_handler.sqf @@ -0,0 +1,108 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Server side task handler/spawner + * + * Arguments: + * 0: Type of task + * 1: Arguments for task + * 2: Minimum org reputation for task (default: 0) + * 3: Requester UID (default: "") + * + * Return Value: + * None + * + * Example: + * ["task_type", [_reward, _punish, _time, etc.....], minReputation, requesterUid] call forge_server_task_fnc_handler; + * + * Public: Yes + */ + +params [["_taskType", "", [""]], ["_args", [], [[]]], ["_minRating", 0, [0]], ["_requesterUid", "", [""]]]; + +private _taskID = ""; + +if (_minRating > 0) then { + if (_requesterUid isEqualTo "") then { + ["WARNING", format ["Task %1 requires minimum reputation %2 but no requester UID was provided, skipping reputation gate.", _taskType, _minRating]] call EFUNC(common,log); + } else { + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + if (_requesterActor isEqualTo createHashMap) then { + _requesterActor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; + }; + + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; + }; + + private _orgReputation = _org getOrDefault ["reputation", 0]; + if (_orgReputation < _minRating) exitWith { + private _message = format ["Organization reputation of %1 does not meet the minimum required reputation of %2.", _orgReputation, _minRating]; + ["WARNING", format ["Task %1 blocked: %2", _taskType, _message]] call EFUNC(common,log); + + private _player = [_requesterUid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith {}; + + [CRPC(notifications,recieveNotification), ["warning", "Tasks", _message], _player] call CFUNC(targetEvent); + }; + }; +}; + +if (_args isNotEqualTo [] && { (_args select 0) isEqualType "" }) then { + _taskID = _args select 0; +}; + +if (_taskID isNotEqualTo "") then { + private _ownershipResult = GVAR(TaskStore) call ["bindTaskOwnership", [_taskID, _requesterUid]]; + if !(_ownershipResult getOrDefault ["success", false]) then { + ["WARNING", format [ + "Failed to bind task ownership for %1 (%2): %3", + _taskID, + _taskType, + _ownershipResult getOrDefault ["message", "Unknown error."] + ]] call EFUNC(common,log); + }; + + GVAR(TaskStore) call ["setTaskStatus", [_taskID, "active"]]; +}; + +switch (_taskType) do { + case "attack": { + private _thread = _args spawn FUNC(attack); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "defuse": { + private _thread = _args spawn FUNC(defuse); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "destroy": { + private _thread = _args spawn FUNC(destroy); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "delivery": { + private _thread = _args spawn FUNC(delivery); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "defend": { + private _thread = _args spawn FUNC(defend); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "hostage": { + private _thread = _args spawn FUNC(hostage); + waitUntil { sleep 2; scriptDone _thread }; + }; + case "hvt": { + private _thread = _args spawn FUNC(hvt); + waitUntil { sleep 2; scriptDone _thread }; + }; + default { + ["ERROR", format ["Unknown Contract Type: %1", _taskType]] call EFUNC(common,log); + }; +}; + +["INFO", "Mission Handler Done"] call EFUNC(common,log); diff --git a/arma/server/addons/task/functions/fnc_heartBeat.sqf b/arma/server/addons/task/functions/fnc_heartBeat.sqf new file mode 100644 index 0000000..7a57941 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_heartBeat.sqf @@ -0,0 +1,68 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers Entity and starts heartbeat + * + * Arguments: + * 0: The entity + * 1: Type of the entity + * 2: The countdown timer + * + * Return Value: + * None + * + * Example: + * [_entity, "entity_type", 30] spawn FUNC(heartBeat); + * + * Public: Yes + */ + +params [["_entity", nil, [objNull, 0, [], sideUnknown, grpNull, ""]], ["_typeOf", "", [""]], ["_time", 0, [0]]]; + +private _nearPlayers = []; + +switch (_typeOf) do { + case "hostage": { + _entity setCaptive true; + _entity enableAIFeature ["MOVE", false]; + _entity playMove "acts_executionvictim_loop"; + + waitUntil { + sleep 1; + _nearPlayers = allPlayers inAreaArray [ASLToAGL getPosASL _entity, 2, 2, 0, false, 2]; + count _nearPlayers > 0 + }; + + private _nearPlayer = _nearPlayers select 0; + + [_entity] joinSilent (group _nearPlayer); + + _entity setCaptive false; + _entity enableAIFeature ["MOVE", true]; + _entity playMove "acts_executionvictim_unbow"; + }; + case "hvt": { + waitUntil { + sleep 1; + _nearPlayers = allPlayers inAreaArray [ASLToAGL getPosASL _entity, 2, 2, 0, false, 2]; + count _nearPlayers > 0 + }; + + _entity setCaptive true; + doStop _entity; + }; + case "ied": { + while { alive _entity && _time > 0} do { + if (_time > 10) then { _entity say3D "FORGE_timerBeep" }; + if (_time <= 10 && _time > 5) then { _entity say3D "FORGE_timerBeepShort" }; + if (_time <= 5) then { _entity say3D "FORGE_timerEnd" }; + if (_time <= 0) exitWith { _entity setDamage 1 }; + + _time = _time -1; + sleep 1; + }; + + if (alive _entity && _time <= 0) then { _entity setDamage 1 }; + }; +}; diff --git a/arma/server/addons/task/functions/fnc_hostage.sqf b/arma/server/addons/task/functions/fnc_hostage.sqf new file mode 100644 index 0000000..ead6b2a --- /dev/null +++ b/arma/server/addons/task/functions/fnc_hostage.sqf @@ -0,0 +1,173 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers an hostage task + * + * Arguments: + * 0: ID of the task + * 1: Amount of hostages KIA to fail the task + * 2: Amount of hostages rescued to complete the task + * 3: Marker name for the extraction zone + * 4: Amount of funds the company recieves if the task is successful (default: 0) + * 5: Amount of rating the company and player lose if the task is failed (default: 0) + * 6: Amount of rating the company and player recieve if the task is successful (default: 0) + * 7: Subcategory of task (default: [false, true]) + * 8: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 9: Should the mission end (MissionFailed) if the task is failed (default: false) + * 10: Amount of time before hostage(s) die (default: -1) + * 11: Marker name for the cbrn zone (default: "") + * 12: Equipment rewards (default: []) + * 13: Supply rewards (default: []) + * 14: Weapon rewards (default: []) + * 15: Vehicle rewards (default: []) + * 16: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["task_name", 1, 2, "marker_name", 1500000, -75, 500, [false, true], false, false] spawn forge_server_task_fnc_hostage; + * ["task_name", 1, 2, "marker_name", 1500000, -75, 500, [false, true], false, false, 45] spawn forge_server_task_fnc_hostage; + * ["task_name", 1, 2, "marker_name", 1500000, -75, 500, [true, false], false, false, 45, "marker_name"] spawn forge_server_task_fnc_hostage; + * + * Public: Yes + */ + +params [ + ["_taskID", ""], + ["_limitFail", -1], + ["_limitSuccess", -1], + ["_extZone", ""], + ["_companyFunds", 0], + ["_ratingFail", 0], + ["_ratingSuccess", 0], + ["_type", [["_cbrn", false, [false]], ["_hostage", true, [false]]]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_time", -1, [0]], + ["_cbrnZone", "", [""]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _cbrn = (_this select 7) select 0; +private _hostage = (_this select 7) select 1; +private _result = 0; +private _hostages = []; +private _shooters = []; + +waitUntil { + sleep 1; + _hostages = GVAR(TaskStore) call ["getTaskEntities", ["hostages", _taskID]]; + count _hostages > 0 +}; + +waitUntil { + sleep 1; + _shooters = GVAR(TaskStore) call ["getTaskEntities", ["shooters", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _hostages + _shooters, _extZone, 250]]; + count _shooters > 0 +}; + +_hostages = GVAR(TaskStore) call ["getTaskEntities", ["hostages", _taskID]]; +_shooters = GVAR(TaskStore) call ["getTaskEntities", ["shooters", _taskID]]; +private _startTime = if (_time isNotEqualTo -1) then { floor(time) } else { nil }; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _hostages + _shooters, _extZone, 250]]; + + private _hostagesFreed = ({ !captive _x } count _hostages); + private _hostagesInZone = ({ _x inArea _extZone } count _hostages); + private _hostagesKilled = ({ !alive _x } count _hostages); + private _shootersAlive = ({ alive _x } count _shooters); + + if (_time isNotEqualTo -1) then { + private _timeExpired = (floor time - _startTime >= _time); + + if (_hostagesFreed < _limitSuccess && _timeExpired) then { _result = 1; }; + if (_hostagesKilled >= _limitFail) then { _result = 1; }; + + (_result == 1) or + ((_hostagesInZone >= _limitSuccess) && (_hostagesKilled < _limitFail)) or + ((!isNil "_shooters") && (_shootersAlive <= 0) && (_hostagesInZone >= _limitSuccess) && (_hostagesKilled < _limitFail)) + } else { + if (_hostagesKilled >= _limitFail) then { _result = 1; }; + + (_result == 1) or + ((_hostagesInZone >= _limitSuccess) && (_hostagesKilled < _limitFail)) or + ((!isNil "_shooters") && (_shootersAlive <= 0) && (_hostagesInZone >= _limitSuccess) && (_hostagesKilled < _limitFail)) + }; +}; + +if (_result == 1) then { + if (_cbrn) then { + "SmokeShellYellow" createVehicle getMarkerPos _cbrnZone; + + sleep 5; + + { + if (captive _x) then { + _x setDamage 0.9; + _x playMove "acts_executionvictim_kill_end"; + + sleep 2.75; + + _x setDamage 1; + } + } forEach _hostages; + }; + + if (_hostage) then { + { + _x enableAIFeature ["MOVE", true]; + _x playMove ""; + } forEach _shooters; + + sleep 1; + + { _x setCaptive false; } forEach _hostages; + + sleep 5; + }; + + { deleteVehicle _x } forEach _hostages; + { deleteVehicle _x } forEach _shooters; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _hostages; + { deleteVehicle _x } forEach _shooters; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_hostageModule.sqf b/arma/server/addons/task/functions/fnc_hostageModule.sqf new file mode 100644 index 0000000..b5d2a9a --- /dev/null +++ b/arma/server/addons/task/functions/fnc_hostageModule.sqf @@ -0,0 +1,76 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the hostage module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_hostageModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; + +private _taskID = _logic getVariable ["TaskID", ""]; +private _limitFail = _logic getVariable ["LimitFail", -1]; +private _limitSuccess = _logic getVariable ["LimitSuccess", -1]; +private _extraction = _logic getVariable ["ExtZone", ""]; +private _companyFunds = _logic getVariable ["CompanyFunds", 0]; +private _ratingFail = _logic getVariable ["RatingFail", 0]; +private _ratingSuccess = _logic getVariable ["RatingSuccess", 0]; +private _cbrn = _logic getVariable ["CBRN", false]; +private _execution = _logic getVariable ["Execution", false]; +private _endSuccess = _logic getVariable ["EndSuccess", false]; +private _endFail = _logic getVariable ["EndFail", false]; +private _timeLimit = _logic getVariable ["TimeLimit", 0]; +private _cbrnZone = _logic getVariable ["CBRNZone", ""]; + +["INFO", format [ + "Hostage Module Parameters: TaskID: %1, LimitFail: %2, LimitSuccess: %3, ExtractionZone: %4, Funds: %5, RatingFail: %6, RatingSuccess: %7, CBRN: %8, Execution: %9, EndSuccess: %10, EndFail: %11, Time: %12, CBRNZone: %13", + _taskID, _limitFail, _limitSuccess, _extraction, _companyFunds, _ratingFail, _ratingSuccess, _cbrn, _execution, _endSuccess, _endFail, _timeLimit, _cbrnZone +]] call EFUNC(common,log); + +private _syncedModules = synchronizedObjects _logic; +["INFO", format ["Hostage Module Synced Entities: %1", _syncedModules]] call EFUNC(common,log); + +private _hostageModule = (_syncedModules select { typeOf _x == "FORGE_Module_Hostages" }) select 0; +private _shooterModule = (_syncedModules select { typeOf _x == "FORGE_Module_Shooters" }) select 0; + +private _hostageEntities = synchronizedObjects _hostageModule; +["INFO", format ["Hostage Module Hostage Entities: %1", _hostageEntities]] call EFUNC(common,log); + +private _shooterEntities = synchronizedObjects _shooterModule; +["INFO", format ["Hostage Module Shooter Entities: %1", _shooterEntities]] call EFUNC(common,log); + +{ + if (!isNull _x && (_x isNotEqualTo str objNull)) then { + [_x, _taskID] spawn FUNC(makeHostage); + }; +} forEach _hostageEntities; + +{ + if (!isNull _x && (_x isNotEqualTo str objNull)) then { + [_x, _taskID] spawn FUNC(makeShooter); + }; +} forEach _shooterEntities; + +private _params = [_taskID, _limitFail, _limitSuccess, _extraction, _companyFunds, _ratingFail, _ratingSuccess, [_cbrn, _execution], _endSuccess, _endFail]; +if (_timeLimit != 0) then { + _params pushBack _timeLimit; + _params pushBack _cbrnZone; +}; + +["hostage", _params, 0, ""] spawn FUNC(handler); + +deleteVehicle _logic; diff --git a/arma/server/addons/task/functions/fnc_hostagesModule.sqf b/arma/server/addons/task/functions/fnc_hostagesModule.sqf new file mode 100644 index 0000000..80b7b00 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_hostagesModule.sqf @@ -0,0 +1,23 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the hostage module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_hostagesModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; diff --git a/arma/server/addons/task/functions/fnc_hvt.sqf b/arma/server/addons/task/functions/fnc_hvt.sqf new file mode 100644 index 0000000..f763300 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_hvt.sqf @@ -0,0 +1,128 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Registers an hvt task + * + * Arguments: + * 0: ID of the task + * 1: Amount of HVTs KIA to fail the task + * 2: Amount of HVTs Captured or KIA to complete the task + * 3: Marker name for the extraction zone + * 4: Amount of funds the company recieves if the task is successful (default: 0) + * 5: Amount of rating the company and player lose if the task is failed (default: 0) + * 6: Amount of rating the company and player recieve if the task is successful (default: 0) + * 7: Subcategory of task (default: [true, false]) + * 8: Should the mission end (MissionSuccess) if the task is successful (default: false) + * 9: Should the mission end (MissionFailed) if the task is failed (default: false) + * 10: Amount of time before hvt(s) die (default: -1) + * 11: Equipment rewards (default: []) + * 12: Supply rewards (default: []) + * 13: Weapon rewards (default: []) + * 14: Vehicle rewards (default: []) + * 15: Special rewards (default: []) + * + * Return Value: + * None + * + * Example: + * ["task_name", 1, 1, "marker_name", 500000, -75, 300, [true, false], false, false] spawn forge_server_task_fnc_hvt; + * ["task_name", -1, 1, "", 500000, -75, 300, [false, true], false, false] spawn forge_server_task_fnc_hvt; + * ["task_name", 1, 1, "marker_name", 500000, -75, 300, [true, false], false, false, 45] spawn forge_server_task_fnc_hvt; + * ["task_name", -1, 1, "", 500000, -75, 300, [false, true], false, false, 45] spawn forge_server_task_fnc_hvt; + * + * Public: Yes + */ + +params [ + ["_taskID", "", [""]], + ["_limitFail", -1, [0]], + ["_limitSuccess", -1, [0]], + ["_extZone", "", [""]], + ["_companyFunds", 0, [0]], + ["_ratingFail", 0, [0]], + ["_ratingSuccess", 0, [0]], + ["_type", [["_capture", true, [false]], ["_eliminate", false, [false]]]], + ["_endSuccess", false, [false]], + ["_endFail", false, [false]], + ["_time", -1, [0]], + ["_equipmentRewards", [], [[]]], + ["_supplyRewards", [], [[]]], + ["_weaponRewards", [], [[]]], + ["_vehicleRewards", [], [[]]], + ["_specialRewards", [], [[]]] +]; + +private _capture = (_this select 7) select 0; +private _eliminate = (_this select 7) select 1; +private _result = 0; +private _hvts = []; + +waitUntil { + sleep 1; + _hvts = GVAR(TaskStore) call ["getTaskEntities", ["hvts", _taskID]]; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _hvts, _extZone, 250]]; + count _hvts > 0 +}; + +_hvts = GVAR(TaskStore) call ["getTaskEntities", ["hvts", _taskID]]; +private _startTime = if (!isNil "_time") then { floor(time) } else { nil }; + +waitUntil { + sleep 1; + GVAR(TaskStore) call ["trackParticipants", [_taskID, _hvts, _extZone, 250]]; + + private _hvtsCaptive = ({ captive _x } count _hvts); + private _hvtsKilled = ({ !alive _x } count _hvts); + private _hvtsInZone = ({ _x inArea _extZone } count _hvts); + + if (!isNil "_time") then { + private _timeExpired = (floor time - _startTime >= _time); + + if (_capture && _hvtsKilled >= _limitFail) then { _result = 1; }; + if (_capture && _hvtsCaptive < _limitSuccess && _timeExpired) then { _result = 1; }; + if (_eliminate && _hvtsKilled < _limitSuccess && _timeExpired) then { _result = 1; }; + + (_result == 1) or (_capture && (_hvtsInZone >= _limitSuccess) && (_hvtsKilled < _limitFail)) or (_eliminate && (_hvtsKilled >= _limitSuccess)) + } else { + if (_capture && (_hvtsKilled >= _limitFail)) then { _result = 1; }; + + (_result == 1) or (_capture && (_hvtsInZone >= _limitSuccess) && (_hvtsKilled < _limitFail)) or (_eliminate && (_hvtsKilled >= _limitSuccess)) + }; +}; + +if (_result == 1) then { + { deleteVehicle _x } forEach _hvts; + + [_taskID, "FAILED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Task failed: %1 reputation", _ratingFail]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingFail]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endFail) then { ["MissionFail", false] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +} else { + { deleteVehicle _x } forEach _hvts; + + private _rewards = createHashMap; + _rewards set ["funds", _companyFunds]; + + if (_equipmentRewards isNotEqualTo []) then { _rewards set ["equipment", _equipmentRewards]; }; + if (_supplyRewards isNotEqualTo []) then { _rewards set ["supplies", _supplyRewards]; }; + if (_weaponRewards isNotEqualTo []) then { _rewards set ["weapons", _weaponRewards]; }; + if (_vehicleRewards isNotEqualTo []) then { _rewards set ["vehicles", _vehicleRewards]; }; + if (_specialRewards isNotEqualTo []) then { _rewards set ["special", _specialRewards]; }; + + [_taskID, _rewards] call FUNC(handleTaskRewards); + [_taskID, "SUCCEEDED"] call BFUNC(taskSetState); + + sleep 1; + + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "success", "Tasks", format ["Task completed: %1 reputation, $%2 funds", _ratingSuccess, [_companyFunds] call EFUNC(common,formatNumber)]]]; + GVAR(TaskStore) call ["applyRatingOutcome", [_taskID, _ratingSuccess]]; + GVAR(TaskStore) call ["clearTask", [_taskID]]; + + if (_endSuccess) then { ["MissionSuccess", true] remoteExecCall ["BIS_fnc_endMission", playerSide]; }; +}; diff --git a/arma/server/addons/task/functions/fnc_hvtModule.sqf b/arma/server/addons/task/functions/fnc_hvtModule.sqf new file mode 100644 index 0000000..2ed59b0 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_hvtModule.sqf @@ -0,0 +1,59 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the hvt module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_hvtModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; + +private _taskID = _logic getVariable ["TaskID", ""]; +private _limitFail = _logic getVariable ["LimitFail", -1]; +private _limitSuccess = _logic getVariable ["LimitSuccess", -1]; +private _extraction = _logic getVariable ["ExtZone", ""]; +private _companyFunds = _logic getVariable ["CompanyFunds", 0]; +private _ratingFail = _logic getVariable ["RatingFail", 0]; +private _ratingSuccess = _logic getVariable ["RatingSuccess", 0]; +private _capture = _logic getVariable ["CaptureHVT", true]; +private _eliminate = _logic getVariable ["EliminateHVT", false]; +private _endSuccess = _logic getVariable ["EndSuccess", false]; +private _endFail = _logic getVariable ["EndFail", false]; +private _timeLimit = _logic getVariable ["TimeLimit", 0]; + +["INFO", format [ + "HVT Module Parameters: TaskID: %1, LimitFail: %2, LimitSuccess: %3, ExtractionZone: %4, Funds: %5, RatingFail: %6, RatingSuccess: %7, CaptureHvt: %8, EliminateHvt: %9, EndSuccess: %10, EndFail: %11, Time: %12", + _taskID, _limitFail, _limitSuccess, _extraction, _companyFunds, _ratingFail, _ratingSuccess, _capture, _eliminate, _endSuccess, _endFail, _timeLimit +]] call EFUNC(common,log); + +private _syncedEntities = synchronizedObjects _logic; +["INFO", format ["HVT Module Synced Entities: %1", _syncedEntities]] call EFUNC(common,log); + +{ + if (!isNull _x && (_x isNotEqualTo str objNull)) then { + [_x, _taskID] spawn FUNC(makeHVT); + }; +} forEach _syncedEntities; + +private _params = [_taskID, _limitFail, _limitSuccess, _extraction, _companyFunds, _ratingFail, _ratingSuccess, [_capture, _eliminate], _endSuccess, _endFail]; +if (_timeLimit != 0) then { + _params pushBack _timeLimit; +}; + +["hvt", _params, 0, ""] spawn FUNC(handler); + +deleteVehicle _logic; diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf new file mode 100644 index 0000000..2ba7a07 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf @@ -0,0 +1,545 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the task store for task entity tracking, participant + * contribution tracking, and task outcome application. + * + * Arguments: + * None + * + * Return Value: + * Task store object [HASHMAP OBJECT] + * + * Example: + * call forge_server_task_fnc_initTaskStore + * + * Public: No + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(TaskStore) = createHashMapObject [[ + ["#type", "TaskStore"], + ["#create", compileFinal { + _self set ["participantRegistry", createHashMap]; + _self set ["defuseRegistry", createHashMap]; + _self set ["taskOwnershipRegistry", createHashMap]; + _self set ["taskStatusRegistry", createHashMap]; + _self set ["completedTaskStatusRegistry", createHashMap]; + _self set ["taskCatalogRegistry", createHashMap]; + _self set ["taskEntityRegistries", createHashMapFromArray [ + ["cargo", createHashMap], + ["hostages", createHashMap], + ["hvts", createHashMap], + ["ieds", createHashMap], + ["entities", createHashMap], + ["shooters", createHashMap], + ["targets", createHashMap] + ]]; + }], + ["bindTaskOwnership", compileFinal { + params [["_taskID", "", [""]], ["_requesterUid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["requesterUid", _requesterUid], + ["orgID", "default"], + ["message", ""] + ]; + + if (_taskID isEqualTo "") exitWith { + _result set ["message", "Missing task ID."]; + _result + }; + + if (_requesterUid isEqualTo "") exitWith { + private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; + _taskOwnershipRegistry set [_taskID, createHashMapFromArray [ + ["requesterUid", ""], + ["orgID", "default"] + ]]; + _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; + + _result set ["success", true]; + _result set ["message", "No requester UID provided. Bound task to default organization."]; + _result + }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + if (_actor isEqualTo createHashMap) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; + }; + + if (_actor isEqualTo createHashMap) exitWith { + _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; + _result + }; + + private _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; + _taskOwnershipRegistry set [_taskID, createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgID", _orgID] + ]]; + _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; + + private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + private _catalogEntry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]); + if (_catalogEntry isNotEqualTo createHashMap) then { + _catalogEntry set ["requesterUid", _requesterUid]; + _catalogEntry set ["orgID", _orgID]; + _catalogEntry set ["accepted", true]; + _taskCatalogRegistry set [_taskID, _catalogEntry]; + _self set ["taskCatalogRegistry", _taskCatalogRegistry]; + }; + + _result set ["success", true]; + _result set ["orgID", _orgID]; + _result + }], + ["registerTaskCatalogEntry", compileFinal { + params [["_taskID", "", [""]], ["_entry", createHashMap, [createHashMap]]]; + + if (_taskID isEqualTo "" || { _entry isEqualTo createHashMap }) exitWith { false }; + + private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + _taskCatalogRegistry set [_taskID, +_entry]; + _self set ["taskCatalogRegistry", _taskCatalogRegistry]; + true + }], + ["getActiveTaskCatalog", compileFinal { + private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; + private _entries = []; + + { + if ((_taskStatusRegistry getOrDefault [_x, ""]) isNotEqualTo "active") then { continue; }; + + private _entry = +_y; + _entry set ["taskID", _x]; + _entry set ["status", "active"]; + _entries pushBack _entry; + } forEach _taskCatalogRegistry; + + _entries + }], + ["acceptTask", compileFinal { + params [["_taskID", "", [""]], ["_requesterUid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to accept task."], + ["entry", createHashMap] + ]; + + if (_taskID isEqualTo "" || { _requesterUid isEqualTo "" }) exitWith { + _result set ["message", "Missing task ID or requester UID."]; + _result + }; + + if ((_self call ["getTaskStatus", [_taskID]]) isNotEqualTo "active") exitWith { + _result set ["message", "Task is no longer active."]; + _result + }; + + private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + private _entry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]); + if (_entry isEqualTo createHashMap) exitWith { + _result set ["message", "Task does not exist."]; + _result + }; + + private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; + private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap]; + private _currentRequesterUid = _ownership getOrDefault ["requesterUid", ""]; + + if (_currentRequesterUid isNotEqualTo "" && { _currentRequesterUid isNotEqualTo _requesterUid }) exitWith { + _result set ["message", "Task has already been accepted."]; + _result set ["entry", _entry]; + _result + }; + + private _bindResult = _self call ["bindTaskOwnership", [_taskID, _requesterUid]]; + if !(_bindResult getOrDefault ["success", false]) exitWith { + _result set ["message", _bindResult getOrDefault ["message", "Failed to bind task ownership."]]; + _result + }; + + private _updatedTaskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + private _updatedEntry = +(_updatedTaskCatalogRegistry getOrDefault [_taskID, _entry]); + _updatedEntry set ["accepted", true]; + _updatedEntry set ["requesterUid", _requesterUid]; + _updatedEntry set ["orgID", _bindResult getOrDefault ["orgID", "default"]]; + _updatedTaskCatalogRegistry set [_taskID, _updatedEntry]; + _self set ["taskCatalogRegistry", _updatedTaskCatalogRegistry]; + + _result set ["success", true]; + _result set ["message", "Task accepted."]; + _result set ["entry", _updatedEntry]; + _result + }], + ["setTaskStatus", compileFinal { + params [["_taskID", "", [""]], ["_status", "", [""]]]; + + if (_taskID isEqualTo "" || { _status isEqualTo "" }) exitWith { false }; + + private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; + private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; + _taskStatusRegistry set [_taskID, _status]; + if (_status in ["succeeded", "failed"]) then { + _completedTaskStatusRegistry set [_taskID, _status]; + } else { + _completedTaskStatusRegistry deleteAt _taskID; + }; + _self set ["taskStatusRegistry", _taskStatusRegistry]; + _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry]; + true + }], + ["getTaskStatus", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { "" }; + + private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; + private _status = _taskStatusRegistry getOrDefault [_taskID, ""]; + if (_status isNotEqualTo "") exitWith { _status }; + + private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; + _completedTaskStatusRegistry getOrDefault [_taskID, ""] + }], + ["clearTaskStatus", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; + private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; + _taskStatusRegistry deleteAt _taskID; + _completedTaskStatusRegistry deleteAt _taskID; + _self set ["taskStatusRegistry", _taskStatusRegistry]; + _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry]; + true + }], + ["registerTaskEntity", compileFinal { + params [["_registryKey", "", [""]], ["_taskID", "", [""]], ["_entity", objNull, [objNull]]]; + + if (_registryKey isEqualTo "" || { _taskID isEqualTo "" } || { isNull _entity }) exitWith { false }; + + private _taskEntityRegistries = _self getOrDefault ["taskEntityRegistries", createHashMap]; + private _registry = +(_taskEntityRegistries getOrDefault [_registryKey, createHashMap]); + private _entities = +(_registry getOrDefault [_taskID, []]); + _entities pushBackUnique _entity; + _registry set [_taskID, _entities]; + _taskEntityRegistries set [_registryKey, _registry]; + _self set ["taskEntityRegistries", _taskEntityRegistries]; + + true + }], + ["getTaskEntities", compileFinal { + params [["_registryKey", "", [""]], ["_taskID", "", [""]]]; + + if (_registryKey isEqualTo "" || { _taskID isEqualTo "" }) exitWith { [] }; + + private _taskEntityRegistries = _self getOrDefault ["taskEntityRegistries", createHashMap]; + private _registry = _taskEntityRegistries getOrDefault [_registryKey, createHashMap]; + + +(_registry getOrDefault [_taskID, []]) + }], + ["clearTaskEntities", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + private _taskEntityRegistries = _self getOrDefault ["taskEntityRegistries", createHashMap]; + + { + private _registry = +_y; + _registry deleteAt _taskID; + _taskEntityRegistries set [_x, _registry]; + } forEach _taskEntityRegistries; + + _self set ["taskEntityRegistries", _taskEntityRegistries]; + true + }], + ["trackParticipants", compileFinal { + params [["_taskID", "", [""]], ["_entities", [], [[]]], ["_marker", "", [""]], ["_radius", 300, [0]]]; + + if (_taskID isEqualTo "") exitWith { createHashMap }; + + private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; + private _participantSnapshots = +(_participantRegistry getOrDefault [_taskID, createHashMap]); + private _activePlayers = allPlayers select { + alive _x + && { side group _x isEqualTo west } + }; + + if (_marker isNotEqualTo "" && { markerShape _marker in ["RECTANGLE", "ELLIPSE"] }) then { + { + private _uid = getPlayerUID _x; + if (_uid isNotEqualTo "" && { _x inArea _marker }) then { + if !(_uid in _participantSnapshots) then { + _participantSnapshots set [_uid, createHashMapFromArray [ + ["startRating", rating _x] + ]]; + }; + }; + } forEach _activePlayers; + }; + + if (_radius > 0 && { _entities isNotEqualTo [] }) then { + { + private _entity = _x; + if (isNull _entity) then { continue; }; + + { + private _uid = getPlayerUID _x; + if (_uid isNotEqualTo "" && { (_x distance2D _entity) <= _radius }) then { + if !(_uid in _participantSnapshots) then { + _participantSnapshots set [_uid, createHashMapFromArray [ + ["startRating", rating _x] + ]]; + }; + }; + } forEach _activePlayers; + } forEach _entities; + }; + + _participantRegistry set [_taskID, _participantSnapshots]; + _self set ["participantRegistry", _participantRegistry]; + + _participantSnapshots + }], + ["resolveRewardContext", compileFinal { + params [["_taskID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["requesterUid", ""], + ["orgID", ""], + ["memberUids", []] + ]; + + if (_taskID isEqualTo "") exitWith { _result }; + + private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; + private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap]; + if (_ownership isEqualTo createHashMap) exitWith { _result }; + + private _requesterUid = _ownership getOrDefault ["requesterUid", ""]; + private _resolvedOrgID = _ownership getOrDefault ["orgID", ""]; + if (_resolvedOrgID isEqualTo "") exitWith { _result }; + + private _org = EGVAR(org,Registry) getOrDefault [_resolvedOrgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", [_resolvedOrgID]]; + }; + + private _memberUids = []; + if (_org isNotEqualTo createHashMap) then { + _memberUids = EGVAR(org,OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]; + }; + + _result set ["requesterUid", _requesterUid]; + _result set ["orgID", _resolvedOrgID]; + _result set ["memberUids", _memberUids]; + _result + }], + ["incrementDefuseCount", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { 0 }; + + private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; + private _nextCount = 1 + (_defuseRegistry getOrDefault [_taskID, 0]); + _defuseRegistry set [_taskID, _nextCount]; + _self set ["defuseRegistry", _defuseRegistry]; + + _nextCount + }], + ["getDefuseCount", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { 0 }; + + private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; + _defuseRegistry getOrDefault [_taskID, 0] + }], + ["notifyParticipants", compileFinal { + params [ + ["_taskID", "", [""]], + ["_type", "info", [""]], + ["_title", "Tasks", [""]], + ["_message", "", [""]] + ]; + + if (_taskID isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; + + private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; + private _participantSnapshots = +(_participantRegistry getOrDefault [_taskID, createHashMap]); + if (_participantSnapshots isEqualTo createHashMap) exitWith { false }; + + { + private _player = [_x] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent); + } forEach (keys _participantSnapshots); + + true + }], + ["clearTask", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; + private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; + private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; + private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; + private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; + + _participantRegistry deleteAt _taskID; + _defuseRegistry deleteAt _taskID; + _taskOwnershipRegistry deleteAt _taskID; + _taskStatusRegistry deleteAt _taskID; + _taskCatalogRegistry deleteAt _taskID; + + _self set ["participantRegistry", _participantRegistry]; + _self set ["defuseRegistry", _defuseRegistry]; + _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; + _self set ["taskStatusRegistry", _taskStatusRegistry]; + _self set ["taskCatalogRegistry", _taskCatalogRegistry]; + _self call ["clearTaskEntities", [_taskID]]; + true + }], + ["applyRatingOutcome", compileFinal { + params [["_taskID", "", [""]], ["_delta", 0, [0]]]; + + private _result = createHashMapFromArray [ + ["participantUids", []], + ["orgIds", []], + ["contributions", createHashMap] + ]; + + if (_taskID isEqualTo "" || { _delta isEqualTo 0 }) exitWith { _result }; + + private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; + private _participantSnapshots = +(_participantRegistry getOrDefault [_taskID, createHashMap]); + if (_participantSnapshots isEqualTo createHashMap) exitWith { _result }; + + private _participantUids = keys _participantSnapshots; + if (_participantUids isEqualTo []) exitWith { _result }; + + private _orgIds = []; + private _contributions = createHashMap; + private _totalContribution = 0; + + { + private _uid = _x; + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + + private _snapshot = _participantSnapshots getOrDefault [_uid, createHashMap]; + private _startRating = _snapshot getOrDefault ["startRating", rating _player]; + private _ratingDelta = (rating _player) - _startRating; + private _contribution = _ratingDelta max 0; + + if (_delta < 0) then { + _contribution = (0 - _ratingDelta) max 0; + }; + + if (_contribution <= 0) then { continue; }; + + _contributions set [_uid, _contribution]; + _totalContribution = _totalContribution + _contribution; + } forEach _participantUids; + + if (_totalContribution <= 0) exitWith { + _self call ["clearTask", [_taskID]]; + _result + }; + + { + private _uid = _x; + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + if (_actor isEqualTo createHashMap) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_uid]]; + }; + + private _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isNotEqualTo "") then { + _orgIds pushBackUnique _orgID; + }; + + if (_delta > 0) then { + private _contribution = _contributions getOrDefault [_uid, 0]; + if (_contribution <= 0) then { continue; }; + + private _account = EGVAR(bank,Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) then { + _account = EGVAR(bank,BankStore) call ["init", [_uid]]; + }; + + if (_account isNotEqualTo createHashMap) then { + private _earnings = _account getOrDefault ["earnings", 0]; + private _earningsDelta = round ((_delta * _contribution) / _totalContribution); + if (_earningsDelta <= 0) then { continue; }; + + private _patch = EGVAR(bank,BankStore) call [ + "mset", + [ + EGVAR(bank,Registry), + "bank:update", + _uid, + createHashMapFromArray [["earnings", (_earnings + _earningsDelta)]], + false + ] + ]; + + EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]]; + }; + }; + } forEach _participantUids; + + private _rewardContext = _self call ["resolveRewardContext", [_taskID]]; + private _ownerOrgID = _rewardContext getOrDefault ["orgID", ""]; + if (_ownerOrgID isNotEqualTo "") then { + private _org = EGVAR(org,Registry) getOrDefault [_ownerOrgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", [_ownerOrgID]]; + }; + + if (_org isNotEqualTo createHashMap) then { + private _reputation = _org getOrDefault ["reputation", 0]; + private _nextReputation = round (_reputation + _delta); + private _patch = EGVAR(org,OrgStore) call [ + "set", + [ + EGVAR(org,Registry), + "org:update", + _ownerOrgID, + "reputation", + _nextReputation, + false + ] + ]; + + private _memberUids = _rewardContext getOrDefault ["memberUids", []]; + { + private _player = [_x] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent); + } forEach _memberUids; + + _orgIds = [_ownerOrgID]; + }; + }; + + _result set ["participantUids", _participantUids]; + _result set ["orgIds", _orgIds]; + _result set ["contributions", _contributions]; + _result + }] +]]; + +GVAR(TaskStore) diff --git a/arma/server/addons/task/functions/fnc_makeCargo.sqf b/arma/server/addons/task/functions/fnc_makeCargo.sqf new file mode 100644 index 0000000..098b6ea --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeCargo.sqf @@ -0,0 +1,41 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns cargo to a task for delivery + * + * Arguments: + * 0: Object to convert to delivery cargo + * 1: Task ID to assign the cargo to + * + * Return Value: + * None + * + * Example: + * [_cargoObject, "delivery_1"] call forge_server_task_fnc_makeCargo; + * + * Public: Yes + */ + +params [["_cargo", objNull, [objNull]], ["_taskID", "", [""]]]; + +["INFO", format ["Make Cargo: %1", _this]] call EFUNC(common,log); + +if (isNull _cargo) exitWith { ["ERROR", "Attempt to create cargo from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for cargo"] call EFUNC(common,log); }; + +SETPVAR(_cargo,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["cargo", _taskID, _cargo]]; + +_cargo addEventHandler ["Dammaged", { + params ["_unit", "_hitSelection", "_damage", "_hitPartIndex", "_hitPoint", "_shooter", "_projectile"]; + + if (damage _unit >= 0.7) then { + private _taskID = GETVAR(_unit,assignedTask,""); + if (_taskID isEqualTo "") exitWith {}; + if (_unit getVariable [QGVAR(cargoDamageWarned), false]) exitWith {}; + + _unit setVariable [QGVAR(cargoDamageWarned), true]; + GVAR(TaskStore) call ["notifyParticipants", [_taskID, "warning", "Tasks", format ["Cargo for task %1 has been severely damaged.", _taskID]]]; + }; +}]; diff --git a/arma/server/addons/task/functions/fnc_makeHVT.sqf b/arma/server/addons/task/functions/fnc_makeHVT.sqf new file mode 100644 index 0000000..8091035 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeHVT.sqf @@ -0,0 +1,30 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an AI unit to a task as a hvt + * + * Arguments: + * 0: The AI unit + * 1: ID of the task + * + * Return Value: + * None + * + * Example: + * [this, "task_name"] spawn forge_server_task_fnc_makeHVT; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull, grpNull]], ["_taskID", "", [""]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; + +["INFO", format ["Make HVT: %1", _this]] call EFUNC(common,log); + +SETVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["hvts", _taskID, _entity]]; + +if (alive _entity) then { [_entity, "hvt"] spawn FUNC(heartBeat); }; diff --git a/arma/server/addons/task/functions/fnc_makeHostage.sqf b/arma/server/addons/task/functions/fnc_makeHostage.sqf new file mode 100644 index 0000000..4644ac8 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeHostage.sqf @@ -0,0 +1,30 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an AI unit to a task as a hostage + * + * Arguments: + * 0: The AI unit + * 1: ID of the task + * + * Return Value: + * None + * + * Example: + * [this, "task_name"] spawn forge_server_task_fnc_makeHostage; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull, grpNull]], ["_taskID", "", [""]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; + +["INFO", format ["Make Hostage: %1", _this]] call EFUNC(common,log); + +SETVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["hostages", _taskID, _entity]]; + +if (alive _entity) then { [_entity, "hostage"] spawn FUNC(heartBeat); }; diff --git a/arma/server/addons/task/functions/fnc_makeIED.sqf b/arma/server/addons/task/functions/fnc_makeIED.sqf new file mode 100644 index 0000000..a4489c6 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeIED.sqf @@ -0,0 +1,32 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an IED to a task and starts countdown timer + * + * Arguments: + * 0: The object + * 1: ID of the task + * 2: The Countdown Timer + * + * Return Value: + * None + * + * Example: + * [this, "task_name", 30] spawn forge_server_task_fnc_makeIED; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull]], ["_taskID", "", [""]], ["_time", 0, [0]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; +if (_time < 0) exitWith { ["ERROR", "Invalid time provided for IED"] call EFUNC(common,log); }; + +["INFO", format ["Make IED: %1", _this]] call EFUNC(common,log); + +SETVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["ieds", _taskID, _entity]]; + +if (alive _entity) then { [_entity, "ied", _time] spawn FUNC(heartBeat); }; diff --git a/arma/server/addons/task/functions/fnc_makeObject.sqf b/arma/server/addons/task/functions/fnc_makeObject.sqf new file mode 100644 index 0000000..72c1d83 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeObject.sqf @@ -0,0 +1,28 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an object to a task as a protected target + * + * Arguments: + * 0: The object + * 1: ID of the task + * + * Return Value: + * None + * + * Example: + * [this, "task_name"] spawn forge_server_task_fnc_makeObject; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull]], ["_taskID", "", [""]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; + +["INFO", format ["Make Object: %1", _this]] call EFUNC(common,log); + +SETPVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["entities", _taskID, _entity]]; diff --git a/arma/server/addons/task/functions/fnc_makeShooter.sqf b/arma/server/addons/task/functions/fnc_makeShooter.sqf new file mode 100644 index 0000000..ffce942 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeShooter.sqf @@ -0,0 +1,28 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an AI unit to a task as a shooter + * + * Arguments: + * 0: The AI unit + * 1: ID of the task + * + * Return Value: + * None + * + * Example: + * [this, "task_name"] spawn forge_server_task_fnc_makeShooter; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull, grpNull]], ["_taskID", "", [""]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; + +["INFO", format ["Make Shooter: %1", _this]] call EFUNC(common,log); + +SETVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["shooters", _taskID, _entity]]; diff --git a/arma/server/addons/task/functions/fnc_makeTarget.sqf b/arma/server/addons/task/functions/fnc_makeTarget.sqf new file mode 100644 index 0000000..284ce4e --- /dev/null +++ b/arma/server/addons/task/functions/fnc_makeTarget.sqf @@ -0,0 +1,28 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Assigns an object to a task as a target + * + * Arguments: + * 0: The object + * 1: ID of the task + * + * Return Value: + * None + * + * Example: + * [this, "task_name"] spawn forge_server_task_fnc_makeTarget; + * + * Public: Yes + */ + +params [["_entity", objNull, [objNull, grpNull]], ["_taskID", "", [""]]]; + +if (isNull _entity) exitWith { ["ERROR", "Attempt to create entity from null object"] call EFUNC(common,log); }; +if (_taskID == "") exitWith { ["ERROR", "No task ID provided for entity"] call EFUNC(common,log); }; + +["INFO", format ["Make Target: %1", _this]] call EFUNC(common,log); + +SETVAR(_entity,assignedTask,_taskID); +GVAR(TaskStore) call ["registerTaskEntity", ["targets", _taskID, _entity]]; diff --git a/arma/server/addons/task/functions/fnc_missionManager.sqf b/arma/server/addons/task/functions/fnc_missionManager.sqf new file mode 100644 index 0000000..a1aec02 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_missionManager.sqf @@ -0,0 +1,369 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Manages attack-only dynamic mission generation. + * + * Arguments: + * None + * + * Return Value: + * None + * + * Example: + * [] call forge_server_task_fnc_missionManager + * + * Public: No + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(MissionManagerBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "MissionManagerBaseClass"], + ["#create", compileFinal { + private _missionConfig = missionConfigFile >> "CfgMissions"; + _self set ["missionConfig", _missionConfig]; + _self set ["locationsConfig", (_missionConfig >> "Locations")]; + _self set ["aiGroupsConfig", (_missionConfig >> "AIGroups")]; + _self set ["attackConfig", (_missionConfig >> "MissionTypes" >> "Attack")]; + _self set ["maxConcurrentMissions", getNumber (_missionConfig >> "maxConcurrentMissions")]; + _self set ["missionInterval", getNumber (_missionConfig >> "missionInterval")]; + _self set ["recentLocationRegistry", createHashMap]; + _self set ["activeMissionRegistry", createHashMap]; + }], + ["getMissionInterval", compileFinal { + private _interval = _self getOrDefault ["missionInterval", 300]; + if (_interval <= 0) then { _interval = 300; }; + _interval + }], + ["getMaxConcurrentMissions", compileFinal { + private _maxConcurrent = _self getOrDefault ["maxConcurrentMissions", 1]; + if (_maxConcurrent <= 0) then { _maxConcurrent = 1; }; + _maxConcurrent + }], + ["getLocationReuseCooldown", compileFinal { + private _missionConfig = _self getOrDefault ["missionConfig", configNull]; + private _cooldown = getNumber (_missionConfig >> "locationReuseCooldown"); + if (_cooldown <= 0) then { _cooldown = 900; }; + _cooldown + }], + ["getActiveMissionIds", compileFinal { + private _activeMissionRegistry = _self getOrDefault ["activeMissionRegistry", createHashMap]; + keys _activeMissionRegistry + }], + ["getActiveLocationKeys", compileFinal { + private _activeMissionRegistry = _self getOrDefault ["activeMissionRegistry", createHashMap]; + private _locationKeys = []; + { + private _locationKey = _y getOrDefault ["locationKey", ""]; + if (_locationKey isNotEqualTo "") then { + _locationKeys pushBackUnique _locationKey; + }; + } forEach _activeMissionRegistry; + _locationKeys + }], + ["buildAttackSpawnPosition", compileFinal { + params [["_locationConfig", configNull, [configNull]]]; + + if (isNull _locationConfig) exitWith { [0, 0, 0] }; + + private _center = getArray (_locationConfig >> "position"); + private _radius = getNumber (_locationConfig >> "radius"); + if (_radius <= 0) exitWith { _center }; + + private _spawnPosition = +_center; + private _attempts = 0; + while { _attempts < 8 } do { + private _angle = random 360; + private _distance = (_radius * 0.2) + random (_radius * 0.65); + private _candidate = [ + (_center # 0) + ((sin _angle) * _distance), + (_center # 1) + ((cos _angle) * _distance), + _center param [2, 0] + ]; + + if !(surfaceIsWater _candidate) exitWith { + _spawnPosition = _candidate; + }; + + _attempts = _attempts + 1; + }; + + _spawnPosition + }], + ["selectAttackLocation", compileFinal { + private _locationsConfig = _self getOrDefault ["locationsConfig", configNull]; + private _locations = []; + private _recentLocationRegistry = _self getOrDefault ["recentLocationRegistry", createHashMap]; + private _activeLocationKeys = _self call ["getActiveLocationKeys", []]; + private _reuseCooldown = _self call ["getLocationReuseCooldown", []]; + private _now = serverTime; + + { + private _locationKey = configName _x; + private _lastUsed = _recentLocationRegistry getOrDefault [_locationKey, -1]; + private _isCoolingDown = (_lastUsed >= 0) && { (_now - _lastUsed) < _reuseCooldown }; + + if ( + "attack" in getArray (_x >> "suitable") + && { !(_locationKey in _activeLocationKeys) } + && { !_isCoolingDown } + ) then { + _locations pushBack _x; + }; + } forEach ("true" configClasses _locationsConfig); + + if (_locations isEqualTo []) then { + { + if ("attack" in getArray (_x >> "suitable") && { !(configName _x in _activeLocationKeys) }) then { + _locations pushBack _x; + }; + } forEach ("true" configClasses _locationsConfig); + }; + + if (_locations isEqualTo []) exitWith { createHashMap }; + + private _location = selectRandom _locations; + createHashMapFromArray [ + ["config", _location], + ["key", configName _location], + ["position", _self call ["buildAttackSpawnPosition", [_location]]] + ] + }], + ["spawnAttackGroup", compileFinal { + params [["_position", [0, 0, 0], [[]]]]; + + private _aiGroupsConfig = _self getOrDefault ["aiGroupsConfig", configNull]; + private _attackConfig = _self getOrDefault ["attackConfig", configNull]; + private _groups = []; + { + if ("attack" in getArray (_x >> "suitable")) then { + _groups pushBack _x; + }; + } forEach ("true" configClasses _aiGroupsConfig); + + if (_groups isEqualTo []) exitWith { grpNull }; + + private _groupConfig = selectRandom _groups; + private _side = getText (_groupConfig >> "side"); + private _group = createGroup (call compile _side); + private _minUnits = getNumber (_attackConfig >> "minUnits"); + private _maxUnits = getNumber (_attackConfig >> "maxUnits"); + if (_minUnits <= 0) then { _minUnits = 4; }; + if (_maxUnits < _minUnits) then { _maxUnits = _minUnits; }; + private _targetUnitCount = floor random [ _minUnits, ceil ((_minUnits + _maxUnits) / 2), _maxUnits + 1 ]; + + private _unitPool = []; + { + if ((getText (_x >> "side")) isNotEqualTo _side) then { continue; }; + + { + _unitPool pushBack createHashMapFromArray [ + ["vehicle", getText (_x >> "vehicle")], + ["rank", getText (_x >> "rank")], + ["position", getArray (_x >> "position")] + ]; + } forEach ("true" configClasses (_x >> "Units")); + } forEach _groups; + + if (_unitPool isEqualTo []) exitWith { + deleteGroup _group; + grpNull + }; + + private _leaderPool = _unitPool select { + toUpperANSI (_x getOrDefault ["rank", "PRIVATE"]) in ["SERGEANT", "LIEUTENANT", "CAPTAIN", "MAJOR", "COLONEL"] + }; + if (_leaderPool isEqualTo []) then { _leaderPool = +_unitPool; }; + + private _spawnDefs = [selectRandom _leaderPool]; + for "_i" from 1 to (_targetUnitCount - 1) do { + _spawnDefs pushBack (selectRandom _unitPool); + }; + + { + private _unitClass = _x getOrDefault ["vehicle", ""]; + if (_unitClass isEqualTo "") then { continue; }; + + private _unitOffset = +(_x getOrDefault ["position", [0, 0, 0]]); + if (count _unitOffset < 3) then { _unitOffset resize 3; }; + _unitOffset set [0, (_unitOffset # 0) + (random 6 - 3)]; + _unitOffset set [1, (_unitOffset # 1) + (random 6 - 3)]; + + private _unit = _group createUnit [_unitClass, _position vectorAdd _unitOffset, [], 0, "NONE"]; + _unit setRank (_x getOrDefault ["rank", "PRIVATE"]); + } forEach _spawnDefs; + + _group + }], + ["rollRewards", compileFinal { + private _attackConfig = _self getOrDefault ["attackConfig", configNull]; + private _equipmentRewards = []; + private _supplyRewards = []; + private _weaponRewards = []; + private _vehicleRewards = []; + private _specialRewards = []; + + { + private _category = _x; + { + _x params ["_item", "_chance"]; + if (random 1 < _chance) then { + switch (_category) do { + case "equipment": { _equipmentRewards pushBack _item; }; + case "supplies": { _supplyRewards pushBack _item; }; + case "weapons": { _weaponRewards pushBack _item; }; + case "vehicles": { _vehicleRewards pushBack _item; }; + case "special": { _specialRewards pushBack _item; }; + }; + }; + } forEach (getArray (_attackConfig >> "Rewards" >> _category)); + } forEach ["equipment", "supplies", "weapons", "vehicles", "special"]; + + createHashMapFromArray [ + ["equipment", _equipmentRewards], + ["supplies", _supplyRewards], + ["weapons", _weaponRewards], + ["vehicles", _vehicleRewards], + ["special", _specialRewards] + ] + }], + ["createAttackTask", compileFinal { + params [ + ["_taskID", "", [""]], + ["_position", [0, 0, 0], [[]]], + ["_locationConfig", configNull, [configNull]] + ]; + + if (_taskID isEqualTo "" || { isNull _locationConfig }) exitWith { false }; + + private _locationKey = configName _locationConfig; + private _locationType = getText (_locationConfig >> "type"); + if (_locationType isEqualTo "") then { _locationType = "area"; }; + + [ + west, + _taskID, + [ + format ["Eliminate hostile forces operating near %1.", _locationKey], + format ["Attack: %1", _locationKey], + _locationType + ], + _position, + "CREATED", + 1, + true, + "attack" + ] call BFUNC(taskCreate); + + true + }], + ["startAttackMission", compileFinal { + private _attackConfig = _self getOrDefault ["attackConfig", configNull]; + private _locationData = _self call ["selectAttackLocation"]; + if (_locationData isEqualTo createHashMap) exitWith { "" }; + + private _location = _locationData getOrDefault ["config", configNull]; + private _locationKey = _locationData getOrDefault ["key", ""]; + private _position = _locationData getOrDefault ["position", [0, 0, 0]]; + private _group = _self call ["spawnAttackGroup", [_position]]; + if (isNull _group) exitWith { "" }; + + private _units = units _group; + if (_units isEqualTo []) exitWith { + deleteGroup _group; + "" + }; + + private _taskID = format ["task_attack_%1", round (diag_tickTime * 1000)]; + { + [_x, _taskID] call FUNC(makeTarget); + } forEach _units; + + _self call ["createAttackTask", [_taskID, _position, _location]]; + GVAR(TaskStore) call ["registerTaskCatalogEntry", [_taskID, createHashMapFromArray [ + ["type", "attack"], + ["title", format ["Attack: %1", _locationKey]], + ["description", format ["Eliminate hostile forces operating near %1.", _locationKey]], + ["position", _position], + ["locationKey", _locationKey], + ["accepted", false], + ["requesterUid", ""], + ["orgID", "default"], + ["source", "mission_manager"] + ]]]; + + private _rewardRange = getArray (_attackConfig >> "Rewards" >> "money"); + private _reputationRange = getArray (_attackConfig >> "Rewards" >> "reputation"); + private _penaltyRange = getArray (_attackConfig >> "penalty"); + private _timeRange = getArray (_attackConfig >> "timeLimit"); + private _rewards = _self call ["rollRewards"]; + + private _params = [ + _taskID, + 0, + count _units, + _rewardRange call BFUNC(randomNum), + _penaltyRange call BFUNC(randomNum), + _reputationRange call BFUNC(randomNum), + false, + false, + _timeRange call BFUNC(randomNum), + _rewards get "equipment", + _rewards get "supplies", + _rewards get "weapons", + _rewards get "vehicles", + _rewards get "special" + ]; + + private _activeMissionRegistry = _self getOrDefault ["activeMissionRegistry", createHashMap]; + _activeMissionRegistry set [_taskID, createHashMapFromArray [ + ["locationKey", _locationKey], + ["startedAt", serverTime] + ]]; + _self set ["activeMissionRegistry", _activeMissionRegistry]; + + ["attack", _params, 0, ""] spawn FUNC(handler); + _taskID + }], + ["completeMission", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + private _activeMissionRegistry = _self getOrDefault ["activeMissionRegistry", createHashMap]; + private _missionRecord = _activeMissionRegistry getOrDefault [_taskID, createHashMap]; + private _locationKey = _missionRecord getOrDefault ["locationKey", ""]; + + _activeMissionRegistry deleteAt _taskID; + _self set ["activeMissionRegistry", _activeMissionRegistry]; + + if (_locationKey isNotEqualTo "") then { + private _recentLocationRegistry = _self getOrDefault ["recentLocationRegistry", createHashMap]; + _recentLocationRegistry set [_locationKey, serverTime]; + _self set ["recentLocationRegistry", _recentLocationRegistry]; + }; + + true + }] +]; + +GVAR(MissionManager) = createHashMapObject [GVAR(MissionManagerBaseClass)]; + +[{ + { + private _status = GVAR(TaskStore) call ["getTaskStatus", [_x]]; + if (_status in ["succeeded", "failed"]) then { + GVAR(MissionManager) call ["completeMission", [_x]]; + GVAR(TaskStore) call ["clearTaskStatus", [_x]]; + }; + } forEach (GVAR(MissionManager) call ["getActiveMissionIds", []]); + + if (count (GVAR(MissionManager) call ["getActiveMissionIds", []]) >= (GVAR(MissionManager) call ["getMaxConcurrentMissions", []])) exitWith {}; + + private _taskID = GVAR(MissionManager) call ["startAttackMission", []]; + if (_taskID isEqualTo "") exitWith { + ["WARNING", "Mission manager failed to start an attack mission."] call EFUNC(common,log); + }; + + ["INFO", format ["Mission manager started attack mission %1.", _taskID]] call EFUNC(common,log); +}, GVAR(MissionManager) call ["getMissionInterval", []], []] call CFUNC(addPerFrameHandler); diff --git a/arma/server/addons/task/functions/fnc_protectedModule.sqf b/arma/server/addons/task/functions/fnc_protectedModule.sqf new file mode 100644 index 0000000..cd80fd0 --- /dev/null +++ b/arma/server/addons/task/functions/fnc_protectedModule.sqf @@ -0,0 +1,23 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the protected module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_protectedModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; diff --git a/arma/server/addons/task/functions/fnc_shootersModule.sqf b/arma/server/addons/task/functions/fnc_shootersModule.sqf new file mode 100644 index 0000000..610f89b --- /dev/null +++ b/arma/server/addons/task/functions/fnc_shootersModule.sqf @@ -0,0 +1,23 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the shooters module + * + * Arguments: + * 0: Logic - The logic object + * 1: Units - The array of units + * 2: Activated - Whether the module is activated + * + * Return Value: + * None + * + * Example: + * [logicObject, [unit1, unit2], true] call forge_server_task_fnc_shootersModule; + * + * Public: No + */ + +params [["_logic", objNull, [objNull]], ["_units", [], [[]]], ["_activated", true, [true]]]; + +if !(_activated) exitWith {}; diff --git a/arma/server/addons/task/functions/fnc_spawnEnemyWave.sqf b/arma/server/addons/task/functions/fnc_spawnEnemyWave.sqf new file mode 100644 index 0000000..eac719a --- /dev/null +++ b/arma/server/addons/task/functions/fnc_spawnEnemyWave.sqf @@ -0,0 +1,83 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Spawns an enemy wave for a defense task + * + * Arguments: + * 0: Defense zone marker name + * 1: Task ID + * 2: Wave number (0-based) + * + * Return Value: + * None + * + * Example: + * ["defend_marker", "defend_1", 0] call forge_server_task_fnc_spawnEnemyWave; + * + * Public: No + */ + +params [["_defenseZone", "", [""]], ["_taskID", "", [""]], ["_waveNumber", 0, [0]]]; + +if (_defenseZone == "") exitWith { ["ERROR", "No defense zone provided for enemy wave spawn"] call EFUNC(common,log); }; + +// TODO: Add unit types to mission config +private _basicTypes = ["O_Soldier_F", "O_Soldier_AR_F", "O_Soldier_GL_F", "O_medic_F"]; +private _specialTypes = ["O_Soldier_LAT_F", "O_soldier_M_F", "O_Soldier_TL_F", "O_Soldier_SL_F"]; +private _eliteTypes = ["O_Soldier_HAT_F", "O_Soldier_AA_F", "O_engineer_F", "O_Sharpshooter_F"]; + +private _unitCount = 6 + (_waveNumber * 2); // TODO: Make this configurable in mission config +private _specialChance = 0.2 + (_waveNumber * 0.1); // TODO: Make this configurable in mission config +private _eliteChance = (_waveNumber * 0.05); // TODO: Make this configurable in mission config + +private _center = getMarkerPos _defenseZone; +private _radius = (getMarkerSize _defenseZone select 0) max (getMarkerSize _defenseZone select 1); +private _spawnRadius = _radius + 150; +private _spawnPositions = []; + +for "_i" from 0 to 3 do { + private _angle = _i * 90; + private _variance = 45; + private _spawnAngle = _angle + (random (_variance * 2) - _variance); + private _spawnDist = _spawnRadius + (random 50 - 25); + + private _spawnX = (_center select 0) + (_spawnDist * cos _spawnAngle); + private _spawnY = (_center select 1) + (_spawnDist * sin _spawnAngle); + private _spawnPos = [_spawnX, _spawnY, 0]; + + private _safePos = _spawnPos findEmptyPosition [0, 50, "O_Soldier_F"]; + if (count _safePos > 0) then { + _spawnPositions pushBack _safePos; + }; +}; + +private _groups = []; +{ + private _groupSize = ceil(_unitCount / (count _spawnPositions)); + private _group = createGroup east; + _groups pushBack _group; + + for "_i" from 1 to _groupSize do { + private _unitType = _basicTypes select (floor random count _basicTypes); + private _roll = random 1; + + if (_roll < _eliteChance) then { + _unitType = _eliteTypes select (floor random count _eliteTypes); + } else { + if (_roll < _specialChance) then { + _unitType = _specialTypes select (floor random count _specialTypes); + }; + }; + + private _unit = _group createUnit [_unitType, _x, [], 0, "NONE"]; + _unit setVariable ["assignedTask", _taskID, true]; + _unit setBehaviour "AWARE"; + _unit setSpeedMode "NORMAL"; + _unit enableDynamicSimulation true; + }; + + [_group, _center, _radius * 0.75] call CBA_fnc_taskDefend; +} forEach _spawnPositions; + +["INFO", format ["Spawned defense wave %1 for task %2 with %3 units", _waveNumber + 1, _taskID, _unitCount]] call EFUNC(common,log); diff --git a/arma/server/addons/task/script_component.hpp b/arma/server/addons/task/script_component.hpp new file mode 100644 index 0000000..c90c053 --- /dev/null +++ b/arma/server/addons/task/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT task +#define COMPONENT_BEAUTIFIED Task +#include "\forge\forge_server\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_server\addons\main\script_macros.hpp" diff --git a/arma/server/addons/task/stringtable.xml b/arma/server/addons/task/stringtable.xml new file mode 100644 index 0000000..ea8a314 --- /dev/null +++ b/arma/server/addons/task/stringtable.xml @@ -0,0 +1,8 @@ + + + + + Task + + + diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index f73ef8e..2f4d7f4 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -31,6 +31,18 @@ pub fn group() -> Group { .command("update", update_org) .command("exists", org_exists) .command("delete", delete_org) + .group( + "assets", + Group::new() + .command("get", get_assets) + .command("update", update_assets), + ) + .group( + "fleet", + Group::new() + .command("get", get_fleet) + .command("update", update_fleet), + ) .group( "members", Group::new() @@ -162,6 +174,56 @@ pub fn delete_org(key: String) -> String { } } +pub fn get_assets(key: String) -> String { + match ORG_SERVICE.get_assets(key) { + Ok(assets) => match serde_json::to_string(&assets) { + Ok(json) => json, + Err(e) => format!("Error: Failed to serialize org assets: {}", e), + }, + Err(e) => format!("Error: {}", e), + } +} + +pub fn update_assets(key: String, json_update: String) -> String { + let assets_value: serde_json::Value = match serde_json::from_str(&json_update) { + Ok(value) => value, + Err(e) => return format!("Error: Invalid JSON: {}", e), + }; + + match ORG_SERVICE.update_assets(key, assets_value) { + Ok(assets) => match serde_json::to_string(&assets) { + Ok(json) => json, + Err(e) => format!("Error: Failed to serialize org assets: {}", e), + }, + Err(e) => format!("Error: {}", e), + } +} + +pub fn get_fleet(key: String) -> String { + match ORG_SERVICE.get_fleet(key) { + Ok(fleet) => match serde_json::to_string(&fleet) { + Ok(json) => json, + Err(e) => format!("Error: Failed to serialize org fleet: {}", e), + }, + Err(e) => format!("Error: {}", e), + } +} + +pub fn update_fleet(key: String, json_update: String) -> String { + let fleet_value: serde_json::Value = match serde_json::from_str(&json_update) { + Ok(value) => value, + Err(e) => return format!("Error: Invalid JSON: {}", e), + }; + + match ORG_SERVICE.update_fleet(key, fleet_value) { + Ok(fleet) => match serde_json::to_string(&fleet) { + Ok(json) => json, + Err(e) => format!("Error: Failed to serialize org fleet: {}", e), + }, + Err(e) => format!("Error: {}", e), + } +} + // ============================================================================ // Member Operations // ============================================================================ diff --git a/arma/server/extension/src/redis/hash.rs b/arma/server/extension/src/redis/hash.rs index 8b53240..399ba45 100644 --- a/arma/server/extension/src/redis/hash.rs +++ b/arma/server/extension/src/redis/hash.rs @@ -39,13 +39,10 @@ pub fn hash_get(key: String, field: String) -> String { pub fn hash_get_all(key: String) -> String { redis_operation!(conn => { match conn.hgetall::<_, HashMap>(&key).await { - Ok(hash_map) => { - let formatted_pairs: Vec = hash_map - .iter() - .map(|(k, v)| format!("{}, {}", k, v)) - .collect(); - formatted_pairs.join(", ") - } + Ok(hash_map) => match serde_json::to_string(&hash_map) { + Ok(json) => json, + Err(e) => format!("Error: Failed to serialize hash map: {}", e), + }, Err(e) => format!("Error: {}", e), } }) diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index b710d4f..1a149e7 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -10,6 +10,6 @@ pub use actor::Actor; pub use bank::Bank; pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; -pub use org::{CreditLineSummary, MemberSummary, Org}; +pub use org::{CreditLineSummary, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index b4da3fa..7683b29 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -10,6 +10,24 @@ pub struct CreditLineSummary { pub amount: f64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrgAssetEntry { + pub classname: String, + #[serde(rename = "type")] + pub asset_type: String, + pub quantity: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrgFleetEntry { + pub classname: String, + pub name: String, + #[serde(rename = "type")] + pub fleet_type: String, + pub status: String, + pub damage: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Org { pub id: String, @@ -62,6 +80,12 @@ impl Org { return Err(OrgValidationError::NegativeFunds); } + if self.reputation < 0 { + return Err(OrgValidationError::InvalidName( + "Organization reputation cannot be negative".to_string(), + )); + } + if !self.id.chars().all(|c| c.is_alphanumeric() || c == '_') { return Err(OrgValidationError::InvalidId(self.id.clone())); } diff --git a/lib/repositories/src/actor.rs b/lib/repositories/src/actor.rs index c20fcd8..c576a3f 100644 --- a/lib/repositories/src/actor.rs +++ b/lib/repositories/src/actor.rs @@ -98,21 +98,14 @@ impl ActorRepository for RedisActorRepository { return Ok(None); } - // Parse comma-separated field-value pairs - let parts: Vec<&str> = actor_string.split(", ").collect(); + let redis_map: std::collections::HashMap = + serde_json::from_str(&actor_string) + .map_err(|e| format!("Failed to parse actor hash response: {}", e))?; let mut json_map = serde_json::Map::new(); - let mut i = 0; - // Process pairs of field names and values - while i + 1 < parts.len() { - let key = parts[i]; - let value = parts[i + 1]; - - // Convert Redis string value back to proper JSON type - let json_value = parse_redis_value(value); - json_map.insert(key.to_string(), json_value); - - i += 2; // Move to next field-value pair + for (key, value) in redis_map { + let json_value = parse_redis_value(&value); + json_map.insert(key, json_value); } // Reconstruct Actor from JSON object diff --git a/lib/repositories/src/bank.rs b/lib/repositories/src/bank.rs index 11940d6..0189c94 100644 --- a/lib/repositories/src/bank.rs +++ b/lib/repositories/src/bank.rs @@ -98,21 +98,14 @@ impl BankRepository for RedisBankRepository { return Ok(None); } - // Parse comma-separated field-value pairs - let parts: Vec<&str> = bank_string.split(", ").collect(); + let redis_map: std::collections::HashMap = + serde_json::from_str(&bank_string) + .map_err(|e| format!("Failed to parse bank hash response: {}", e))?; let mut json_map = serde_json::Map::new(); - let mut i = 0; - // Process pairs of field names and values - while i + 1 < parts.len() { - let key = parts[i]; - let value = parts[i + 1]; - - // Convert Redis string value back to proper JSON type - let json_value = parse_redis_value(value); - json_map.insert(key.to_string(), json_value); - - i += 2; // Move to next field-value pair + for (key, value) in redis_map { + let json_value = parse_redis_value(&value); + json_map.insert(key, json_value); } // Reconstruct Bank from JSON object diff --git a/lib/repositories/src/org.rs b/lib/repositories/src/org.rs index 60340cd..cd854f9 100644 --- a/lib/repositories/src/org.rs +++ b/lib/repositories/src/org.rs @@ -5,8 +5,9 @@ //! //! For full documentation and examples, see the [crate README](../README.md). -use forge_models::{MemberSummary, Org}; +use forge_models::{MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; +use std::collections::HashMap; /// Repository trait defining the contract for organization data operations. /// @@ -37,6 +38,29 @@ pub trait OrgRepository: Send + Sync { /// Removes a specific member from an organization. fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String>; + + /// Retrieves all organization assets grouped by category and classname. + fn get_assets( + &self, + org_id: &str, + ) -> Result>, String>; + + /// Replaces the organization asset hash with the provided grouped assets. + fn update_assets( + &self, + org_id: &str, + assets: &HashMap>, + ) -> Result<(), String>; + + /// Retrieves all organization fleet entries. + fn get_fleet(&self, org_id: &str) -> Result, String>; + + /// Replaces the organization fleet hash with the provided fleet entries. + fn update_fleet( + &self, + org_id: &str, + fleet: &HashMap, + ) -> Result<(), String>; } /// Redis-based implementation of the OrgRepository trait. @@ -109,21 +133,14 @@ impl OrgRepository for RedisOrgRepository { return Ok(None); } - // Parse comma-separated field-value pairs - let parts: Vec<&str> = org_string.split(", ").collect(); + let redis_map: std::collections::HashMap = + serde_json::from_str(&org_string) + .map_err(|e| format!("Failed to parse org hash response: {}", e))?; let mut json_map = serde_json::Map::new(); - let mut i = 0; - // Process pairs of field names and values - while i + 1 < parts.len() { - let key = parts[i]; - let value = parts[i + 1]; - - // Convert Redis string value back to proper JSON type - let json_value = parse_redis_value(value); - json_map.insert(key.to_string(), json_value); - - i += 2; // Move to next field-value pair + for (key, value) in redis_map { + let json_value = parse_redis_value(&value); + json_map.insert(key, json_value); } // Reconstruct Org from JSON object @@ -261,4 +278,100 @@ impl OrgRepository for RedisOrgRepository { // Remove the UID from the set using SREM self.client.set_del(redis_key, member_uid.to_string()) } + + fn get_assets( + &self, + org_id: &str, + ) -> Result>, String> { + let redis_key = format!("org:{}:assets", org_id); + let assets_string = self.client.hash_get_all(redis_key)?; + + if assets_string.is_empty() { + return Ok(HashMap::new()); + } + + let redis_map: HashMap = serde_json::from_str(&assets_string) + .map_err(|e| format!("Failed to parse org asset hash response: {}", e))?; + + let mut assets = HashMap::new(); + for (category, value) in redis_map { + let json_value = parse_redis_value(&value); + let category_assets = + serde_json::from_value::>(json_value) + .map_err(|e| format!("Failed to parse asset category '{}': {}", category, e))?; + assets.insert(category, category_assets); + } + + Ok(assets) + } + + fn update_assets( + &self, + org_id: &str, + assets: &HashMap>, + ) -> Result<(), String> { + let redis_key = format!("org:{}:assets", org_id); + + if assets.is_empty() { + return self.client.delete_key(redis_key); + } + + let fields: Vec<(String, String)> = assets + .iter() + .map(|(category, value)| { + let json_value = serde_json::to_value(value) + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + (category.clone(), parse_json_value(&json_value)) + }) + .collect(); + + self.client.delete_key(redis_key.clone())?; + self.client.hash_mset(redis_key, fields) + } + + fn get_fleet(&self, org_id: &str) -> Result, String> { + let redis_key = format!("org:{}:fleet", org_id); + let fleet_string = self.client.hash_get_all(redis_key)?; + + if fleet_string.is_empty() { + return Ok(HashMap::new()); + } + + let redis_map: HashMap = serde_json::from_str(&fleet_string) + .map_err(|e| format!("Failed to parse org fleet hash response: {}", e))?; + + let mut fleet = HashMap::new(); + for (fleet_key, value) in redis_map { + let json_value = parse_redis_value(&value); + let fleet_entry = serde_json::from_value::(json_value) + .map_err(|e| format!("Failed to parse fleet entry '{}': {}", fleet_key, e))?; + fleet.insert(fleet_key, fleet_entry); + } + + Ok(fleet) + } + + fn update_fleet( + &self, + org_id: &str, + fleet: &HashMap, + ) -> Result<(), String> { + let redis_key = format!("org:{}:fleet", org_id); + + if fleet.is_empty() { + return self.client.delete_key(redis_key); + } + + let fields: Vec<(String, String)> = fleet + .iter() + .map(|(fleet_key, value)| { + let json_value = serde_json::to_value(value) + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + (fleet_key.clone(), parse_json_value(&json_value)) + }) + .collect(); + + self.client.delete_key(redis_key.clone())?; + self.client.hash_mset(redis_key, fields) + } } diff --git a/lib/repositories/src/v_garage.rs b/lib/repositories/src/v_garage.rs index e9e0e00..1a3751e 100644 --- a/lib/repositories/src/v_garage.rs +++ b/lib/repositories/src/v_garage.rs @@ -98,21 +98,14 @@ impl VGarageRepository for RedisVGarageRepository { return Ok(None); } - // Parse comma-separated field-value pairs - let parts: Vec<&str> = garage_string.split(", ").collect(); + let redis_map: std::collections::HashMap = + serde_json::from_str(&garage_string) + .map_err(|e| format!("Failed to parse virtual garage hash response: {}", e))?; let mut json_map = serde_json::Map::new(); - let mut i = 0; - // Process pairs of field names and values - while i + 1 < parts.len() { - let key = parts[i]; - let value = parts[i + 1]; - - // Convert Redis string value back to proper JSON type - let json_value = parse_redis_value(value); - json_map.insert(key.to_string(), json_value); - - i += 2; // Move to next field-value pair + for (key, value) in redis_map { + let json_value = parse_redis_value(&value); + json_map.insert(key, json_value); } // Reconstruct VLocker from JSON object diff --git a/lib/repositories/src/v_locker.rs b/lib/repositories/src/v_locker.rs index 54382d9..23bb442 100644 --- a/lib/repositories/src/v_locker.rs +++ b/lib/repositories/src/v_locker.rs @@ -94,21 +94,14 @@ impl VLockerRepository for RedisVLockerRepository { return Ok(None); } - // Parse comma-separated field-value pairs - let parts: Vec<&str> = locker_string.split(", ").collect(); + let redis_map: std::collections::HashMap = + serde_json::from_str(&locker_string) + .map_err(|e| format!("Failed to parse virtual locker hash response: {}", e))?; let mut json_map = serde_json::Map::new(); - let mut i = 0; - // Process pairs of field names and values - while i + 1 < parts.len() { - let key = parts[i]; - let value = parts[i + 1]; - - // Convert Redis string value back to proper JSON type - let json_value = parse_redis_value(value); - json_map.insert(key.to_string(), json_value); - - i += 2; // Move to next field-value pair + for (key, value) in redis_map { + let json_value = parse_redis_value(&value); + json_map.insert(key, json_value); } // Reconstruct VLocker from JSON object diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 05bddee..d2855c8 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -5,7 +5,7 @@ //! //! For full documentation, architecture, and examples, see the [crate README](../README.md). -use forge_models::{CreditLineSummary, MemberSummary, Org}; +use forge_models::{CreditLineSummary, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; use forge_repositories::OrgRepository; use std::collections::HashMap; @@ -237,4 +237,76 @@ impl OrgService { // Delegate member removal to repository layer self.repository.remove_member(&key, &member_uid) } + + pub fn get_assets( + &self, + key: String, + ) -> Result>, String> { + if !self.repository.exists(&key)? { + return Err(format!("Organization with ID '{}' not found", key)); + } + + self.repository.get_assets(&key) + } + + pub fn update_assets( + &self, + key: String, + mut assets_update: serde_json::Value, + ) -> Result>, String> { + if !self.repository.exists(&key)? { + return Err(format!("Organization with ID '{}' not found", key)); + } + + if matches!(&assets_update, serde_json::Value::Array(lines) if lines.is_empty()) { + assets_update = serde_json::Value::Object(serde_json::Map::new()); + } + + let assets = if assets_update.is_null() { + HashMap::new() + } else { + serde_json::from_value::>>(assets_update) + .map_err(|e| { + format!( + "Assets must be an object of category maps keyed by classname: {}", + e + ) + })? + }; + + self.repository.update_assets(&key, &assets)?; + Ok(assets) + } + + pub fn get_fleet(&self, key: String) -> Result, String> { + if !self.repository.exists(&key)? { + return Err(format!("Organization with ID '{}' not found", key)); + } + + self.repository.get_fleet(&key) + } + + pub fn update_fleet( + &self, + key: String, + mut fleet_update: serde_json::Value, + ) -> Result, String> { + if !self.repository.exists(&key)? { + return Err(format!("Organization with ID '{}' not found", key)); + } + + if matches!(&fleet_update, serde_json::Value::Array(lines) if lines.is_empty()) { + fleet_update = serde_json::Value::Object(serde_json::Map::new()); + } + + let fleet = if fleet_update.is_null() { + HashMap::new() + } else { + serde_json::from_value::>(fleet_update) + .map_err(|e| format!("Fleet must be an object of fleet entries: {}", e))? + }; + + self.repository.update_fleet(&key, &fleet)?; + Ok(fleet) + } } diff --git a/tools/build-webui.mjs b/tools/build-webui.mjs index 900d143..0103401 100644 --- a/tools/build-webui.mjs +++ b/tools/build-webui.mjs @@ -191,6 +191,21 @@ async function buildHtmlPage({ name, output, title, siteConfig }) { console.log(`Built ${output}`); } +async function buildHtmlTemplate({ name, output, source }) { + const html = await readSource(source); + const minifiedHtml = await minifyHtml(html, { + collapseBooleanAttributes: true, + collapseWhitespace: true, + minifyCSS: true, + minifyJS: true, + removeComments: true, + removeRedundantAttributes: true, + }); + + await writeBundle(output, minifiedHtml); + console.log(`Built ${output}`); +} + async function pathExists(absolutePath) { try { await stat(absolutePath); @@ -297,22 +312,38 @@ async function loadUiConfig(absoluteConfigPath) { resolveFromConfigDir(configDir, source), ), })); - const htmlPage = { - name: `${config.addonName} UI index`, - output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")), - title: config.title, - siteConfig: { - addonName: config.addonName, - logLabel: config.logLabel || `${config.addonName} UI`, - ...config.site, - }, - }; + const htmlPages = []; + if (config.generateIndex !== false) { + htmlPages.push({ + kind: "generated", + name: `${config.addonName} UI index`, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")), + title: config.title, + siteConfig: { + addonName: config.addonName, + logLabel: config.logLabel || `${config.addonName} UI`, + ...config.site, + }, + }); + } + + for (const page of config.htmlTemplates || []) { + htmlPages.push({ + kind: "template", + name: page.name || `${config.addonName} UI template`, + output: resolveFromConfigDir( + configDir, + path.join(config.outputDir, page.output), + ), + source: resolveFromConfigDir(configDir, page.source), + }); + } return { outputDir, jsBundles, cssBundles, - htmlPage, + htmlPages, formatSourceTargets, }; } @@ -325,7 +356,7 @@ async function collectUiBuildArtifacts() { outputDirs: uiConfigs.map((config) => config.outputDir), jsBundles: uiConfigs.flatMap((config) => config.jsBundles), cssBundles: uiConfigs.flatMap((config) => config.cssBundles), - htmlPages: uiConfigs.map((config) => config.htmlPage), + htmlPages: uiConfigs.flatMap((config) => config.htmlPages), formatSourceTargets: uiConfigs.flatMap( (config) => config.formatSourceTargets, ), @@ -348,7 +379,11 @@ async function build() { ...uiArtifacts.jsBundles.map(buildJsBundle), ]); await Promise.all(uiArtifacts.cssBundles.map(buildCssBundle)); - await Promise.all(uiArtifacts.htmlPages.map(buildHtmlPage)); + await Promise.all( + uiArtifacts.htmlPages.map((page) => + page.kind === "template" ? buildHtmlTemplate(page) : buildHtmlPage(page), + ), + ); } build().catch((error) => { From 0e9d0d3dc4e09f6430ac6f7823f7c6192514836c Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 29 Mar 2026 22:17:07 -0500 Subject: [PATCH 05/19] Refactor CAD sidepanel to hydrate board data - Replace task-only refresh flow with hydrate/assignment/group events - Add contracts, groups, and activity tabs to the client sidepanel - Introduce server-side CAD store and request handlers --- arma/client/addons/cad/CfgEventHandlers.hpp | 1 - arma/client/addons/cad/XEH_postInitClient.sqf | 20 +- .../cad/functions/fnc_handleUIEvents.sqf | 38 +- .../cad/functions/fnc_initRepository.sqf | 26 +- .../addons/cad/functions/fnc_initUIBridge.sqf | 64 ++- .../addons/cad/ui/_site/cad-sidepanel.css | 2 +- .../addons/cad/ui/_site/cad-sidepanel.js | 2 +- .../client/addons/cad/ui/_site/sidepanel.html | 2 +- arma/client/addons/cad/ui/src/sidepanel.html | 65 ++- arma/client/addons/cad/ui/src/sidepanel.js | 266 ++++++++-- .../addons/cad/ui/src/styles/sidepanel.css | 80 ++- arma/server/addons/cad/$PBOPREFIX$ | 1 + arma/server/addons/cad/CfgEventHandlers.hpp | 5 + arma/server/addons/cad/XEH_PREP.hpp | 1 + arma/server/addons/cad/XEH_preInit.sqf | 86 +++ arma/server/addons/cad/config.cpp | 23 + .../addons/cad/functions/fnc_initCadStore.sqf | 498 ++++++++++++++++++ arma/server/addons/cad/script_component.hpp | 9 + 18 files changed, 1114 insertions(+), 75 deletions(-) create mode 100644 arma/server/addons/cad/$PBOPREFIX$ create mode 100644 arma/server/addons/cad/CfgEventHandlers.hpp create mode 100644 arma/server/addons/cad/XEH_PREP.hpp create mode 100644 arma/server/addons/cad/XEH_preInit.sqf create mode 100644 arma/server/addons/cad/config.cpp create mode 100644 arma/server/addons/cad/functions/fnc_initCadStore.sqf create mode 100644 arma/server/addons/cad/script_component.hpp diff --git a/arma/client/addons/cad/CfgEventHandlers.hpp b/arma/client/addons/cad/CfgEventHandlers.hpp index 86e43be..289a18f 100644 --- a/arma/client/addons/cad/CfgEventHandlers.hpp +++ b/arma/client/addons/cad/CfgEventHandlers.hpp @@ -1,7 +1,6 @@ class Extended_PreInit_EventHandlers { class ADDON { init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); - clientInit = QUOTE(call COMPILE_SCRIPT(XEH_preInitClient)); }; }; diff --git a/arma/client/addons/cad/XEH_postInitClient.sqf b/arma/client/addons/cad/XEH_postInitClient.sqf index fdd9bce..dd44a7a 100644 --- a/arma/client/addons/cad/XEH_postInitClient.sqf +++ b/arma/client/addons/cad/XEH_postInitClient.sqf @@ -7,18 +7,20 @@ if (isNil QGVAR(CADUIBridge)) then { call FUNC(initUIBridge); }; call FUNC(openUI); }] call CFUNC(addEventHandler); -[QGVAR(responseTaskCatalog), { - params [["_entries", [], [[]]]]; +[QGVAR(responseHydrateCad), { + params [["_payload", createHashMap, [createHashMap]]]; - if !(isNil QGVAR(CADRepository)) then { - GVAR(CADRepository) call ["setTaskCatalog", [_entries]]; - }; - - GVAR(CADUIBridge) call ["refreshTaskCatalog", []]; + GVAR(CADUIBridge) call ["handleHydrateResponse", [_payload]]; }] call CFUNC(addEventHandler); -[QGVAR(responseTaskAccept), { +[QGVAR(responseCadAssignment), { params [["_result", createHashMap, [createHashMap]]]; - GVAR(CADUIBridge) call ["handleTaskAcceptResponse", [_result]]; + GVAR(CADUIBridge) call ["handleAssignmentResponse", [_result]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseCadGroupUpdate), { + params [["_result", createHashMap, [createHashMap]]]; + + GVAR(CADUIBridge) call ["handleGroupUpdateResponse", [_result]]; }] call CFUNC(addEventHandler); diff --git a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf index 22c93cb..8a10c41 100644 --- a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf @@ -35,16 +35,46 @@ switch (_event) do { case "cad::ready": { GVAR(CADUIBridge) call ["handleReady", [_control, _data]]; }; - case "cad::tasks::refresh": { - GVAR(CADUIBridge) call ["requestTaskCatalog", []]; + case "cad::refresh": { + GVAR(CADUIBridge) call ["requestHydrate", []]; }; - case "cad::tasks::accept": { + case "cad::tasks::assign": { + private _taskID = ""; + private _groupID = ""; + private _note = ""; + if (_data isEqualType createHashMap) then { + _taskID = _data getOrDefault ["taskID", ""]; + _groupID = _data getOrDefault ["groupID", ""]; + _note = _data getOrDefault ["note", ""]; + }; + + GVAR(CADUIBridge) call ["requestAssignTask", [_taskID, _groupID, _note]]; + }; + case "cad::tasks::acknowledge": { private _taskID = ""; if (_data isEqualType createHashMap) then { _taskID = _data getOrDefault ["taskID", ""]; }; - GVAR(CADUIBridge) call ["requestTaskAccept", [_taskID]]; + GVAR(CADUIBridge) call ["requestAcknowledgeTask", [_taskID]]; + }; + case "cad::tasks::decline": { + private _taskID = ""; + if (_data isEqualType createHashMap) then { + _taskID = _data getOrDefault ["taskID", ""]; + }; + + GVAR(CADUIBridge) call ["requestDeclineTask", [_taskID]]; + }; + case "cad::groups::status": { + private _groupID = ""; + private _status = ""; + if (_data isEqualType createHashMap) then { + _groupID = _data getOrDefault ["groupID", ""]; + _status = _data getOrDefault ["status", ""]; + }; + + GVAR(CADUIBridge) call ["requestGroupStatus", [_groupID, _status]]; }; case "map::zoomIn": { private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; diff --git a/arma/client/addons/cad/functions/fnc_initRepository.sqf b/arma/client/addons/cad/functions/fnc_initRepository.sqf index 6cec168..ba109da 100644 --- a/arma/client/addons/cad/functions/fnc_initRepository.sqf +++ b/arma/client/addons/cad/functions/fnc_initRepository.sqf @@ -25,21 +25,33 @@ GVAR(CADRepository) = createHashMapObject [[ ["#create", compileFinal { _self set ["isLoaded", true]; _self set ["isOpen", false]; - _self set ["taskCatalog", []]; + _self set ["groups", []]; + _self set ["contracts", []]; + _self set ["assignments", []]; + _self set ["activity", []]; + _self set ["session", createHashMap]; }], - ["pushTaskCatalog", compileFinal { + ["pushHydratePayload", compileFinal { params [["_bridge", createHashMap, [createHashMap]]]; if (_bridge isEqualTo createHashMap) exitWith { false }; - _bridge call ["sendEvent", ["cad::tasks::hydrate", createHashMapFromArray [ - ["tasks", +(_self getOrDefault ["taskCatalog", []])] + _bridge call ["sendEvent", ["cad::hydrate", createHashMapFromArray [ + ["groups", +(_self getOrDefault ["groups", []])], + ["contracts", +(_self getOrDefault ["contracts", []])], + ["assignments", +(_self getOrDefault ["assignments", []])], + ["activity", +(_self getOrDefault ["activity", []])], + ["session", +(_self getOrDefault ["session", createHashMap])] ]]] }], - ["setTaskCatalog", compileFinal { - params [["_entries", [], [[]]]]; + ["setHydratePayload", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; - _self set ["taskCatalog", +_entries]; + _self set ["groups", +(_payload getOrDefault ["groups", []])]; + _self set ["contracts", +(_payload getOrDefault ["contracts", []])]; + _self set ["assignments", +(_payload getOrDefault ["assignments", []])]; + _self set ["activity", +(_payload getOrDefault ["activity", []])]; + _self set ["session", +(_payload getOrDefault ["session", createHashMap])]; true }], ["setOpen", compileFinal { diff --git a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf index 0205f34..ab4cb86 100644 --- a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf @@ -7,7 +7,7 @@ * Public: No * * Description: - * Initializes the CAD UI bridge for sidepanel browser state and task event routing. + * Initializes the CAD UI bridge for sidepanel browser state and CAD event routing. * * Arguments: * None @@ -50,33 +50,73 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _screen call ["markReady", [true]]; _self call ["flushPendingEvents", []]; - _self call ["requestTaskCatalog", []]; - _self call ["refreshTaskCatalog", []]; + _self call ["requestHydrate", []]; + _self call ["refreshHydrate", []]; true }], - ["requestTaskCatalog", compileFinal { - [SRPC(task,requestTaskCatalog), [getPlayerUID player]] call CFUNC(serverEvent); + ["requestHydrate", compileFinal { + [SRPC(cad,requestHydrateCad), [getPlayerUID player]] call CFUNC(serverEvent); true }], - ["requestTaskAccept", compileFinal { + ["requestAssignTask", compileFinal { + params [["_taskID", "", [""]], ["_groupID", "", [""]], ["_note", "", [""]]]; + + if (_taskID isEqualTo "" || { _groupID isEqualTo "" }) exitWith { false }; + + [SRPC(cad,requestAssignCadTask), [getPlayerUID player, _taskID, _groupID, _note]] call CFUNC(serverEvent); + true + }], + ["requestAcknowledgeTask", compileFinal { params [["_taskID", "", [""]]]; if (_taskID isEqualTo "") exitWith { false }; - [SRPC(task,requestAcceptTask), [getPlayerUID player, _taskID]] call CFUNC(serverEvent); + [SRPC(cad,requestAcknowledgeCadTask), [getPlayerUID player, _taskID]] call CFUNC(serverEvent); true }], - ["refreshTaskCatalog", compileFinal { - if (isNil QGVAR(CADRepository)) exitWith { false }; - GVAR(CADRepository) call ["pushTaskCatalog", [_self]] + ["requestDeclineTask", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + [SRPC(cad,requestDeclineCadTask), [getPlayerUID player, _taskID]] call CFUNC(serverEvent); + true }], - ["handleTaskAcceptResponse", compileFinal { + ["requestGroupStatus", compileFinal { + params [["_groupID", "", [""]], ["_status", "", [""]]]; + + if (_groupID isEqualTo "" || { _status isEqualTo "" }) exitWith { false }; + + [SRPC(cad,requestUpdateCadGroupStatus), [getPlayerUID player, _groupID, _status]] call CFUNC(serverEvent); + true + }], + ["refreshHydrate", compileFinal { + if (isNil QGVAR(CADRepository)) exitWith { false }; + GVAR(CADRepository) call ["pushHydratePayload", [_self]] + }], + ["handleHydrateResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + if (isNil QGVAR(CADRepository)) exitWith { false }; + + GVAR(CADRepository) call ["setHydratePayload", [_payload]]; + _self call ["refreshHydrate", []] + }], + ["handleAssignmentResponse", compileFinal { params [["_result", createHashMap, [createHashMap]]]; - _self call ["sendEvent", ["cad::tasks::accept::response", createHashMapFromArray [ + _self call ["sendEvent", ["cad::assignment::response", createHashMapFromArray [ ["message", _result getOrDefault ["message", "Task request processed."]], ["success", _result getOrDefault ["success", false]] ]]] + }], + ["handleGroupUpdateResponse", compileFinal { + params [["_result", createHashMap, [createHashMap]]]; + + _self call ["sendEvent", ["cad::group::response", createHashMapFromArray [ + ["message", _result getOrDefault ["message", "Group update processed."]], + ["success", _result getOrDefault ["success", false]] + ]]] }] ]; diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.css b/arma/client/addons/cad/ui/_site/cad-sidepanel.css index 15d65fb..112ce7f 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.css +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.css @@ -1 +1 @@ -html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.task-toolbar{margin-bottom:10px}.task-toolbar button,.task-accept-btn{color:#f3f6f9;cursor:pointer;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-toolbar button:hover,.task-accept-btn:hover{background:#2e3942f2}.task-toolbar button:disabled,.task-accept-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex} \ No newline at end of file +html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.task-toolbar{margin-bottom:10px}.cad-tabs{grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;display:grid}.cad-tab{color:#f3f6f9c7;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;background:#141b21e0;border:1px solid #ffffff24;padding:8px 10px;font-size:11px}.cad-tab:hover{color:#f3f6f9;background:#1f282ff0}.cad-tab.is-active{color:var(--accent);background:#0f283af5;border-color:#5bbbff6b}.cad-tab-panels{min-height:0}.cad-section{display:none}.cad-section.is-active{display:block}.cad-section-header{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:12px;font-weight:700}.task-toolbar button,.task-accept-btn,.task-secondary-btn,.cad-select{color:#f3f6f9;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-toolbar button,.task-accept-btn,.task-secondary-btn{cursor:pointer}.task-toolbar button:hover,.task-accept-btn:hover,.task-secondary-btn:hover{background:#2e3942f2}.task-toolbar button:disabled,.task-accept-btn:disabled,.task-secondary-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-action-stack,.task-action-row{flex-direction:column;gap:8px;display:flex}.task-action-row{flex-direction:row}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex}.task-secondary-btn{background:#3c302deb} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.js b/arma/client/addons/cad/ui/_site/cad-sidepanel.js index 05e57ab..fa1bdc4 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.js +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.js @@ -1 +1 @@ -window.cadTasks={tasks:[],init(){const s=document.getElementById("refreshTasksBtn");s&&s.addEventListener("click",()=>this.refresh()),window.ForgeBridge.on("cad::tasks::hydrate",s=>{this.setTasks(s.tasks||[])}),window.ForgeBridge.on("cad::tasks::accept::response",s=>{this.handleAcceptResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setTasks(s){this.tasks=Array.isArray(s)?s:[];const t=document.getElementById("taskStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.render()},setStatus(s,t){const e=document.getElementById("taskStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"info")},handleAcceptResponse(s,t){this.setStatus(t||(s?"Task accepted.":"Unable to accept task."),s?"success":"error")},refresh(){this.setStatus("Refreshing tasks...","info"),window.mapUI.sendEvent("cad::tasks::refresh",{})},acceptTask(s){this.setStatus("Submitting acceptance...","info"),window.mapUI.sendEvent("cad::tasks::accept",{taskID:s})},render(){const s=document.getElementById("taskList");s&&(this.tasks.length?s.innerHTML=this.tasks.map(s=>{const t=Array.isArray(s.position)?s.position:[0,0,0],e=!!s.accepted,a=e?`Assigned: ${s.orgID||"Unknown"}`:"Available";return`\n
\n
\n ${s.title||s.taskID}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${a}\n X: ${Math.round(t[0]||0)} Y: ${Math.round(t[1]||0)}\n
\n \n
\n `}).join(""):s.innerHTML='

No active tasks are available.

')}},window.cadTasks.init(); \ No newline at end of file +window.cadTasks={contracts:[],groups:[],activity:[],session:{},activeTab:"contracts",statuses:["available","en_route","on_task","holding","danger","refit","offline"],init(){const s=document.getElementById("refreshCadBtn");s&&s.addEventListener("click",()=>this.refresh()),document.querySelectorAll(".cad-tab").forEach(s=>{s.addEventListener("click",()=>{this.setActiveTab(s.dataset.tab||"contracts")})}),window.ForgeBridge.on("cad::hydrate",s=>{this.setHydratePayload(s||{})}),window.ForgeBridge.on("cad::assignment::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.on("cad::group::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setActiveTab(s){this.activeTab=s||"contracts",document.querySelectorAll(".cad-tab").forEach(s=>{s.classList.toggle("is-active",s.dataset.tab===this.activeTab)}),document.querySelectorAll("[data-panel]").forEach(s=>{s.classList.toggle("is-active",s.dataset.panel===this.activeTab)})},setHydratePayload(s){this.contracts=Array.isArray(s.contracts)?s.contracts:[],this.groups=Array.isArray(s.groups)?s.groups:[],this.activity=Array.isArray(s.activity)?s.activity:[],this.session=s.session&&"object"==typeof s.session?s.session:{};const t=document.getElementById("cadStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.render()},setStatus(s,t){const e=document.getElementById("cadStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"")},handleServerResponse(s,t){this.setStatus(t||(s?"CAD update succeeded.":"CAD update failed."),s?"success":"error")},refresh(){this.setStatus("Refreshing board...","info"),window.mapUI.sendEvent("cad::refresh",{})},assignTask(s){const t=document.getElementById(`assign-group-${s}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:s,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},acknowledgeTask(s){this.setStatus("Acknowledging contract...","info"),window.mapUI.sendEvent("cad::tasks::acknowledge",{taskID:s})},declineTask(s){this.setStatus("Declining contract...","info"),window.mapUI.sendEvent("cad::tasks::decline",{taskID:s})},updateGroupStatus(s,t){this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:s,status:t})},getPlayerGroupId(){return this.session.groupId||""},canDispatch(){return!!this.session.isDispatcher},isLeader(){return!!this.session.isLeader},renderContracts(){const s=document.getElementById("taskList");if(!s)return;if(!this.contracts.length)return void(s.innerHTML='

No active contracts are available.

');const t=this.getPlayerGroupId();s.innerHTML=this.contracts.map(s=>{const e=s.taskId||s.taskID||"",a=Array.isArray(s.position)?s.position:[0,0,0],n=s.assignedGroupId||"",i=s.assignmentState||"unassigned",o=this.groups.find(s=>s.groupId===n),r=this.isLeader()&&n===t,c=this.groups.map(s=>``).join("");return`\n
\n
\n ${s.title||e}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${"unassigned"===i?"Available":`${i}: ${o?o.callsign:n}`}\n X: ${Math.round(a[0]||0)} Y: ${Math.round(a[1]||0)}\n
\n ${this.canDispatch()?`
\n \n \n
`:""}\n ${r&&"assigned"===i?`
\n \n \n
`:""}\n
\n `}).join("")},renderGroups(){const s=document.getElementById("groupList");if(!s)return;if(!this.groups.length)return void(s.innerHTML='

No active groups are available.

');const t=this.getPlayerGroupId();s.innerHTML=this.groups.map(s=>{const e=this.canDispatch()||this.isLeader()&&s.groupId===t,a=this.statuses.map(t=>``).join("");return`\n
\n
\n ${s.callsign||s.groupId}\n ${s.role||"group"}\n
\n
\n Leader: ${s.leaderName||"Unknown"}\n Status: ${s.status||"unknown"}\n
\n
\n Org: ${s.orgId||"default"}\n Task: ${s.currentTaskId||"None"}\n
\n ${e?`
\n \n \n
`:""}\n
\n `}).join("")},renderActivity(){const s=document.getElementById("activityList");s&&(this.activity.length?s.innerHTML=this.activity.slice().reverse().slice(0,8).map(s=>`\n
\n
\n ${s.type||"activity"}\n ${Math.round(s.timestamp||0)}s\n
\n

${s.message||""}

\n
\n `).join(""):s.innerHTML='

No recent activity.

')},render(){this.renderContracts(),this.renderGroups(),this.renderActivity(),this.setActiveTab(this.activeTab)}},window.cadTasks.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/sidepanel.html b/arma/client/addons/cad/ui/_site/sidepanel.html index 3cd718e..778ef42 100644 --- a/arma/client/addons/cad/ui/_site/sidepanel.html +++ b/arma/client/addons/cad/ui/_site/sidepanel.html @@ -1 +1 @@ -

CAD System

Loading available tasks...

\ No newline at end of file +

CAD System

Contracts

Loading contracts...

Groups

Loading groups...

Activity

No recent activity.

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/sidepanel.html b/arma/client/addons/cad/ui/src/sidepanel.html index 8efa438..6457059 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.html +++ b/arma/client/addons/cad/ui/src/sidepanel.html @@ -9,14 +9,67 @@
- +
+
+
+ + +
-
-
-
-

Loading available tasks...

+
+
+
Contracts
+
+
+

Loading contracts...

+
+
+
+
+
Groups
+
+
+

Loading groups...

+
+
+
+
+
Activity
+
+
+

No recent activity.

+
+
diff --git a/arma/client/addons/cad/ui/src/sidepanel.js b/arma/client/addons/cad/ui/src/sidepanel.js index bebcd95..950327d 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.js +++ b/arma/client/addons/cad/ui/src/sidepanel.js @@ -1,94 +1,300 @@ window.cadTasks = { - tasks: [], + contracts: [], + groups: [], + activity: [], + session: {}, + activeTab: "contracts", + statuses: [ + "available", + "en_route", + "on_task", + "holding", + "danger", + "refit", + "offline", + ], init() { - const refreshBtn = document.getElementById("refreshTasksBtn"); + const refreshBtn = document.getElementById("refreshCadBtn"); if (refreshBtn) { refreshBtn.addEventListener("click", () => this.refresh()); } - window.ForgeBridge.on("cad::tasks::hydrate", (payload) => { - this.setTasks(payload.tasks || []); + document.querySelectorAll(".cad-tab").forEach((tab) => { + tab.addEventListener("click", () => { + this.setActiveTab(tab.dataset.tab || "contracts"); + }); }); - window.ForgeBridge.on("cad::tasks::accept::response", (payload) => { - this.handleAcceptResponse(!!payload.success, payload.message || ""); + window.ForgeBridge.on("cad::hydrate", (payload) => { + this.setHydratePayload(payload || {}); + }); + + window.ForgeBridge.on("cad::assignment::response", (payload) => { + this.handleServerResponse(!!payload.success, payload.message || ""); + }); + + window.ForgeBridge.on("cad::group::response", (payload) => { + this.handleServerResponse(!!payload.success, payload.message || ""); }); window.ForgeBridge.ready({ loaded: true }); }, - setTasks(tasks) { - this.tasks = Array.isArray(tasks) ? tasks : []; - const statusEl = document.getElementById("taskStatusMessage"); + setActiveTab(tabName) { + this.activeTab = tabName || "contracts"; + + document.querySelectorAll(".cad-tab").forEach((tab) => { + tab.classList.toggle( + "is-active", + tab.dataset.tab === this.activeTab, + ); + }); + + document.querySelectorAll("[data-panel]").forEach((panel) => { + panel.classList.toggle( + "is-active", + panel.dataset.panel === this.activeTab, + ); + }); + }, + setHydratePayload(payload) { + this.contracts = Array.isArray(payload.contracts) + ? payload.contracts + : []; + this.groups = Array.isArray(payload.groups) ? payload.groups : []; + this.activity = Array.isArray(payload.activity) ? payload.activity : []; + this.session = + payload.session && typeof payload.session === "object" + ? payload.session + : {}; + + const statusEl = document.getElementById("cadStatusMessage"); if ( statusEl && (!statusEl.dataset.type || statusEl.dataset.type === "info") ) { this.setStatus("", ""); } + this.render(); }, setStatus(message, type) { - const statusEl = document.getElementById("taskStatusMessage"); + const statusEl = document.getElementById("cadStatusMessage"); if (!statusEl) { return; } statusEl.textContent = message || ""; - statusEl.dataset.type = type || "info"; + statusEl.dataset.type = type || ""; }, - handleAcceptResponse(success, message) { + handleServerResponse(success, message) { this.setStatus( - message || (success ? "Task accepted." : "Unable to accept task."), + message || + (success ? "CAD update succeeded." : "CAD update failed."), success ? "success" : "error", ); }, refresh() { - this.setStatus("Refreshing tasks...", "info"); - window.mapUI.sendEvent("cad::tasks::refresh", {}); + this.setStatus("Refreshing board...", "info"); + window.mapUI.sendEvent("cad::refresh", {}); }, - acceptTask(taskID) { - this.setStatus("Submitting acceptance...", "info"); - window.mapUI.sendEvent("cad::tasks::accept", { taskID: taskID }); + assignTask(taskID) { + const selector = document.getElementById(`assign-group-${taskID}`); + if (!selector || !selector.value) { + this.setStatus( + "Select a group before assigning a contract.", + "error", + ); + return; + } + + this.setStatus("Submitting assignment...", "info"); + window.mapUI.sendEvent("cad::tasks::assign", { + taskID: taskID, + groupID: selector.value, + note: "", + }); }, - render() { + acknowledgeTask(taskID) { + this.setStatus("Acknowledging contract...", "info"); + window.mapUI.sendEvent("cad::tasks::acknowledge", { taskID: taskID }); + }, + declineTask(taskID) { + this.setStatus("Declining contract...", "info"); + window.mapUI.sendEvent("cad::tasks::decline", { taskID: taskID }); + }, + updateGroupStatus(groupID, status) { + this.setStatus("Updating group status...", "info"); + window.mapUI.sendEvent("cad::groups::status", { + groupID: groupID, + status: status, + }); + }, + getPlayerGroupId() { + return this.session.groupId || ""; + }, + canDispatch() { + return !!this.session.isDispatcher; + }, + isLeader() { + return !!this.session.isLeader; + }, + renderContracts() { const listEl = document.getElementById("taskList"); if (!listEl) { return; } - if (!this.tasks.length) { + if (!this.contracts.length) { listEl.innerHTML = - '

No active tasks are available.

'; + '

No active contracts are available.

'; return; } - listEl.innerHTML = this.tasks + const currentGroupId = this.getPlayerGroupId(); + listEl.innerHTML = this.contracts .map((task) => { + const taskId = task.taskId || task.taskID || ""; const position = Array.isArray(task.position) ? task.position : [0, 0, 0]; - const accepted = !!task.accepted; - const ownerLabel = accepted - ? `Assigned: ${task.orgID || "Unknown"}` - : "Available"; + const assignedGroupId = task.assignedGroupId || ""; + const assignmentState = task.assignmentState || "unassigned"; + const assignedGroup = this.groups.find( + (group) => group.groupId === assignedGroupId, + ); + const isAssignedToLeader = + this.isLeader() && assignedGroupId === currentGroupId; + const groupOptions = this.groups + .map( + (group) => + ``, + ) + .join(""); return ` -
+
- ${task.title || task.taskID} + ${task.title || taskId} ${task.type || "task"}

${task.description || ""}

- ${ownerLabel} + ${assignmentState === "unassigned" ? "Available" : `${assignmentState}: ${assignedGroup ? assignedGroup.callsign : assignedGroupId}`} X: ${Math.round(position[0] || 0)} Y: ${Math.round(position[1] || 0)}
- + ${ + this.canDispatch() + ? `
+ + +
` + : "" + } + ${ + isAssignedToLeader && assignmentState === "assigned" + ? `
+ + +
` + : "" + }
`; }) .join(""); }, + renderGroups() { + const listEl = document.getElementById("groupList"); + if (!listEl) { + return; + } + + if (!this.groups.length) { + listEl.innerHTML = + '

No active groups are available.

'; + return; + } + + const currentGroupId = this.getPlayerGroupId(); + listEl.innerHTML = this.groups + .map((group) => { + const canUpdate = + this.canDispatch() || + (this.isLeader() && group.groupId === currentGroupId); + const statusOptions = this.statuses + .map( + (status) => + ``, + ) + .join(""); + + return ` +
+
+ ${group.callsign || group.groupId} + ${group.role || "group"} +
+
+ Leader: ${group.leaderName || "Unknown"} + Status: ${group.status || "unknown"} +
+
+ Org: ${group.orgId || "default"} + Task: ${group.currentTaskId || "None"} +
+ ${ + canUpdate + ? `
+ + +
` + : "" + } +
+ `; + }) + .join(""); + }, + renderActivity() { + const listEl = document.getElementById("activityList"); + if (!listEl) { + return; + } + + if (!this.activity.length) { + listEl.innerHTML = + '

No recent activity.

'; + return; + } + + listEl.innerHTML = this.activity + .slice() + .reverse() + .slice(0, 8) + .map( + (entry) => ` +
+
+ ${entry.type || "activity"} + ${Math.round(entry.timestamp || 0)}s +
+

${entry.message || ""}

+
+ `, + ) + .join(""); + }, + render() { + this.renderContracts(); + this.renderGroups(); + this.renderActivity(); + this.setActiveTab(this.activeTab); + }, }; window.cadTasks.init(); diff --git a/arma/client/addons/cad/ui/src/styles/sidepanel.css b/arma/client/addons/cad/ui/src/styles/sidepanel.css index 0a74484..5224e84 100644 --- a/arma/client/addons/cad/ui/src/styles/sidepanel.css +++ b/arma/client/addons/cad/ui/src/styles/sidepanel.css @@ -59,23 +59,82 @@ body { margin-bottom: 10px; } +.cad-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin-bottom: 12px; +} + +.cad-tab { + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(20, 27, 33, 0.88); + color: rgba(243, 246, 249, 0.78); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; + cursor: pointer; +} + +.cad-tab:hover { + background: rgba(31, 40, 47, 0.94); + color: #f3f6f9; +} + +.cad-tab.is-active { + border-color: rgba(91, 187, 255, 0.42); + background: rgba(15, 40, 58, 0.96); + color: var(--accent); +} + +.cad-tab-panels { + min-height: 0; +} + +.cad-section { + display: none; +} + +.cad-section.is-active { + display: block; +} + +.cad-section-header { + margin-bottom: 8px; + color: var(--accent); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + .task-toolbar button, -.task-accept-btn { +.task-accept-btn, +.task-secondary-btn, +.cad-select { width: 100%; padding: 8px 10px; border: 1px solid rgba(255, 255, 255, 0.2); background: rgba(30, 37, 43, 0.9); color: #f3f6f9; +} + +.task-toolbar button, +.task-accept-btn, +.task-secondary-btn { cursor: pointer; } .task-toolbar button:hover, -.task-accept-btn:hover { +.task-accept-btn:hover, +.task-secondary-btn:hover { background: rgba(46, 57, 66, 0.95); } .task-toolbar button:disabled, -.task-accept-btn:disabled { +.task-accept-btn:disabled, +.task-secondary-btn:disabled { opacity: 0.55; cursor: default; } @@ -101,6 +160,17 @@ body { gap: 10px; } +.task-action-stack, +.task-action-row { + display: flex; + flex-direction: column; + gap: 8px; +} + +.task-action-row { + flex-direction: row; +} + .task-card { padding: 10px; border: 1px solid rgba(255, 255, 255, 0.08); @@ -134,3 +204,7 @@ body { font-size: 11px; opacity: 0.8; } + +.task-secondary-btn { + background: rgba(60, 48, 45, 0.92); +} diff --git a/arma/server/addons/cad/$PBOPREFIX$ b/arma/server/addons/cad/$PBOPREFIX$ new file mode 100644 index 0000000..5062385 --- /dev/null +++ b/arma/server/addons/cad/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_server\addons\cad diff --git a/arma/server/addons/cad/CfgEventHandlers.hpp b/arma/server/addons/cad/CfgEventHandlers.hpp new file mode 100644 index 0000000..9b160c1 --- /dev/null +++ b/arma/server/addons/cad/CfgEventHandlers.hpp @@ -0,0 +1,5 @@ +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + }; +}; diff --git a/arma/server/addons/cad/XEH_PREP.hpp b/arma/server/addons/cad/XEH_PREP.hpp new file mode 100644 index 0000000..552c605 --- /dev/null +++ b/arma/server/addons/cad/XEH_PREP.hpp @@ -0,0 +1 @@ +PREP(initCadStore); diff --git a/arma/server/addons/cad/XEH_preInit.sqf b/arma/server/addons/cad/XEH_preInit.sqf new file mode 100644 index 0000000..832b1fc --- /dev/null +++ b/arma/server/addons/cad/XEH_preInit.sqf @@ -0,0 +1,86 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; + +call FUNC(initCadStore); + +[QGVAR(requestHydrateCad), { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { + ["WARNING", "CAD hydrate request received with empty UID."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _payload = GVAR(CadStore) call ["buildHydratePayload", [_uid]]; + [CRPC(cad,responseHydrateCad), [_payload], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestAssignCadTask), { + params [ + ["_uid", "", [""]], + ["_taskID", "", [""]], + ["_groupID", "", [""]], + ["_note", "", [""]] + ]; + + if (_uid isEqualTo "" || { _taskID isEqualTo "" } || { _groupID isEqualTo "" }) exitWith { + ["WARNING", "Invalid CAD task assignment payload."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(CadStore) call ["assignTaskToGroup", [_uid, _taskID, _groupID, _note]]; + [CRPC(cad,responseCadAssignment), [_result], _player] call CFUNC(targetEvent); + [CRPC(cad,responseHydrateCad), [GVAR(CadStore) call ["buildHydratePayload", [_uid]]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestAcknowledgeCadTask), { + params [["_uid", "", [""]], ["_taskID", "", [""]]]; + + if (_uid isEqualTo "" || { _taskID isEqualTo "" }) exitWith { + ["WARNING", "Invalid CAD acknowledge payload."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(CadStore) call ["acknowledgeTask", [_uid, _taskID]]; + [CRPC(cad,responseCadAssignment), [_result], _player] call CFUNC(targetEvent); + [CRPC(cad,responseHydrateCad), [GVAR(CadStore) call ["buildHydratePayload", [_uid]]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestDeclineCadTask), { + params [["_uid", "", [""]], ["_taskID", "", [""]]]; + + if (_uid isEqualTo "" || { _taskID isEqualTo "" }) exitWith { + ["WARNING", "Invalid CAD decline payload."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(CadStore) call ["declineTask", [_uid, _taskID]]; + [CRPC(cad,responseCadAssignment), [_result], _player] call CFUNC(targetEvent); + [CRPC(cad,responseHydrateCad), [GVAR(CadStore) call ["buildHydratePayload", [_uid]]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestUpdateCadGroupStatus), { + params [["_uid", "", [""]], ["_groupID", "", [""]], ["_status", "", [""]]]; + + if (_uid isEqualTo "" || { _groupID isEqualTo "" } || { _status isEqualTo "" }) exitWith { + ["WARNING", "Invalid CAD group status payload."] call EFUNC(common,log); + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(CadStore) call ["updateGroupStatus", [_uid, _groupID, _status]]; + [CRPC(cad,responseCadGroupUpdate), [_result], _player] call CFUNC(targetEvent); + [CRPC(cad,responseHydrateCad), [GVAR(CadStore) call ["buildHydratePayload", [_uid]]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/cad/config.cpp b/arma/server/addons/cad/config.cpp new file mode 100644 index 0000000..74d149d --- /dev/null +++ b/arma/server/addons/cad/config.cpp @@ -0,0 +1,23 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"IDSolutions"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_server_main", + "forge_server_common", + "forge_server_actor", + "forge_server_org", + "forge_server_task" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" diff --git a/arma/server/addons/cad/functions/fnc_initCadStore.sqf b/arma/server/addons/cad/functions/fnc_initCadStore.sqf new file mode 100644 index 0000000..61bc8f6 --- /dev/null +++ b/arma/server/addons/cad/functions/fnc_initCadStore.sqf @@ -0,0 +1,498 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initCadStore.sqf + * Author: IDSolutions + * Date: 2026-03-29 + * Public: Yes + * + * Description: + * Initializes the CAD store for group tracking, assignment state, + * activity history, and CAD hydrate payloads. + * + * Arguments: + * None + * + * Return Value: + * CAD store object [HASHMAP OBJECT] + * + * Example: + * call forge_server_cad_fnc_initCadStore + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "CadStoreBaseClass"], + ["#create", compileFinal { + _self set ["groupRegistry", createHashMap]; + _self set ["assignmentRegistry", createHashMap]; + _self set ["activityRegistry", []]; + _self set ["validStatuses", [ + "available", + "en_route", + "on_task", + "holding", + "danger", + "refit", + "offline" + ]]; + ["INFO", "CAD Store Initialized!"] call EFUNC(common,log); + }], + ["appendActivity", compileFinal { + params [ + ["_type", "", [""]], + ["_message", "", [""]], + ["_taskID", "", [""]], + ["_groupID", "", [""]], + ["_actorUid", "", [""]] + ]; + + if (_type isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; + + private _activityRegistry = +(_self getOrDefault ["activityRegistry", []]); + _activityRegistry pushBack createHashMapFromArray [ + ["type", _type], + ["message", _message], + ["timestamp", serverTime], + ["taskId", _taskID], + ["groupId", _groupID], + ["actorUid", _actorUid] + ]; + + if ((count _activityRegistry) > 50) then { + _activityRegistry deleteRange [0, (count _activityRegistry) - 50]; + }; + + _self set ["activityRegistry", _activityRegistry]; + true + }], + ["resolveGroupId", compileFinal { + params [["_group", grpNull, [grpNull]]]; + + if (isNull _group) exitWith { "" }; + + private _leader = leader _group; + private _leaderUid = if (isNull _leader) then { "" } else { getPlayerUID _leader }; + if (_leaderUid isNotEqualTo "") exitWith { format ["group:%1", _leaderUid] }; + + private _groupLabel = groupId _group; + if (_groupLabel isNotEqualTo "") exitWith { format ["group:%1", _groupLabel] }; + + str _group + }], + ["canDispatch", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + if (_actor isEqualTo createHashMap) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_uid]]; + }; + + private _orgID = _actor getOrDefault ["organization", "default"]; + private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; + }; + + if (_org getOrDefault ["owner", ""] isEqualTo _uid) exitWith { true }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith { false }; + + (_orgID isEqualTo "default") && { vehicleVarName _player isEqualTo "ceo" } + }], + ["getCurrentTaskIdForGroup", compileFinal { + params [["_groupID", "", [""]]]; + + if (_groupID isEqualTo "") exitWith { "" }; + + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _taskID = ""; + { + if ((_y getOrDefault ["groupId", ""]) isNotEqualTo _groupID) then { continue; }; + if !((_y getOrDefault ["state", ""]) in ["assigned", "acknowledged"]) then { continue; }; + if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; + + _taskID = _x; + } forEach _assignmentRegistry; + + _taskID + }], + ["syncGroups", compileFinal { + private _previousRegistry = _self getOrDefault ["groupRegistry", createHashMap]; + private _nextRegistry = createHashMap; + + { + if (side _x isNotEqualTo west) then { continue; }; + + private _members = (units _x) select { isPlayer _x }; + if (_members isEqualTo []) then { continue; }; + + private _leader = leader _x; + if (isNull _leader || { !isPlayer _leader }) then { + _leader = _members # 0; + }; + + private _groupID = _self call ["resolveGroupId", [_x]]; + if (_groupID isEqualTo "") then { continue; }; + + private _leaderUid = getPlayerUID _leader; + private _actor = EGVAR(actor,Registry) getOrDefault [_leaderUid, createHashMap]; + if (_actor isEqualTo createHashMap && { _leaderUid isNotEqualTo "" }) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_leaderUid]]; + }; + + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _existingRecord = +(_previousRegistry getOrDefault [_groupID, createHashMap]); + private _memberUids = []; + { + private _memberUid = getPlayerUID _x; + if (_memberUid isNotEqualTo "") then { + _memberUids pushBack _memberUid; + }; + } forEach _members; + + private _record = createHashMapFromArray [ + ["groupId", _groupID], + ["callsign", [groupId _x, _groupID] select ((groupId _x) isEqualTo "")], + ["leaderUid", _leaderUid], + ["leaderName", name _leader], + ["memberUids", _memberUids], + ["orgId", _orgID], + ["role", _existingRecord getOrDefault ["role", "infantry"]], + ["status", _existingRecord getOrDefault ["status", "available"]], + ["position", getPosATL _leader], + ["currentTaskId", _self call ["getCurrentTaskIdForGroup", [_groupID]]], + ["lastUpdate", serverTime] + ]; + + _nextRegistry set [_groupID, _record]; + } forEach allGroups; + + _self set ["groupRegistry", _nextRegistry]; + _nextRegistry + }], + ["getGroupRecord", compileFinal { + params [["_groupID", "", [""]]]; + + if (_groupID isEqualTo "") exitWith { createHashMap }; + + private _groupRegistry = _self call ["syncGroups", []]; + +(_groupRegistry getOrDefault [_groupID, createHashMap]) + }], + ["getPlayerGroupId", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { "" }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith { "" }; + + _self call ["resolveGroupId", [group _player]] + }], + ["isGroupLeader", compileFinal { + params [["_uid", "", [""]], ["_groupID", "", [""]]]; + + if (_uid isEqualTo "" || { _groupID isEqualTo "" }) exitWith { false }; + + private _groupRecord = _self call ["getGroupRecord", [_groupID]]; + (_groupRecord getOrDefault ["leaderUid", ""]) isEqualTo _uid + }], + ["pruneAssignments", compileFinal { + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _keysToRemove = []; + + { + private _status = EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]; + if !(_status in ["active", ""]) then { + _keysToRemove pushBack _x; + }; + } forEach _assignmentRegistry; + + { + _assignmentRegistry deleteAt _x; + } forEach _keysToRemove; + + _self set ["assignmentRegistry", _assignmentRegistry]; + count _keysToRemove + }], + ["buildContracts", compileFinal { + _self call ["pruneAssignments", []]; + + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _contracts = []; + + { + private _taskID = _x getOrDefault ["taskID", ""]; + if (_taskID isEqualTo "") then { continue; }; + + private _assignment = _assignmentRegistry getOrDefault [_taskID, createHashMap]; + private _entry = +_x; + _entry set ["taskId", _taskID]; + _entry set ["assignedGroupId", _assignment getOrDefault ["groupId", ""]]; + _entry set ["assignmentState", [_assignment getOrDefault ["state", ""], "unassigned"] select (_assignment isEqualTo createHashMap)]; + _contracts pushBack _entry; + } forEach (EGVAR(task,TaskStore) call ["getActiveTaskCatalog", []]); + + _contracts + }], + ["buildGroups", compileFinal { + private _groupRegistry = _self call ["syncGroups", []]; + private _groups = []; + + { + _groups pushBack +_y; + } forEach _groupRegistry; + + _groups + }], + ["buildHydratePayload", compileFinal { + params [["_uid", "", [""]]]; + + private _activity = +(_self getOrDefault ["activityRegistry", []]); + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + if (_actor isEqualTo createHashMap && { _uid isNotEqualTo "" }) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_uid]]; + }; + + createHashMapFromArray [ + ["groups", _self call ["buildGroups", []]], + ["contracts", _self call ["buildContracts", []]], + ["assignments", values (_self getOrDefault ["assignmentRegistry", createHashMap])], + ["activity", _activity], + ["session", createHashMapFromArray [ + ["uid", _uid], + ["orgId", _actor getOrDefault ["organization", "default"]], + ["isDispatcher", _self call ["canDispatch", [_uid]]], + ["groupId", _self call ["getPlayerGroupId", [_uid]]], + ["isLeader", _self call ["isGroupLeader", [_uid, _self call ["getPlayerGroupId", [_uid]]]]] + ]] + ] + }], + ["notifyPlayer", compileFinal { + params [ + ["_uid", "", [""]], + ["_type", "info", [""]], + ["_title", "CAD", [""]], + ["_message", "", [""]] + ]; + + if (_uid isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith { false }; + + [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent); + true + }], + ["assignTaskToGroup", compileFinal { + params [ + ["_requesterUid", "", [""]], + ["_taskID", "", [""]], + ["_groupID", "", [""]], + ["_note", "", [""]] + ]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to assign task."], + ["assignment", createHashMap] + ]; + + if !(_self call ["canDispatch", [_requesterUid]]) exitWith { + _result set ["message", "You are not authorized to assign contracts."]; + _result + }; + + if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_taskID]]) isNotEqualTo "active") exitWith { + _result set ["message", "Task is no longer active."]; + _result + }; + + private _groupRecord = _self call ["getGroupRecord", [_groupID]]; + if (_groupRecord isEqualTo createHashMap) exitWith { + _result set ["message", "Selected group is unavailable."]; + _result + }; + + private _leaderUid = _groupRecord getOrDefault ["leaderUid", ""]; + if (_leaderUid isEqualTo "") exitWith { + _result set ["message", "Selected group has no online leader."]; + _result + }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _assignment = createHashMapFromArray [ + ["taskId", _taskID], + ["groupId", _groupID], + ["assignedByUid", _requesterUid], + ["assignedByName", ["Dispatcher", name _requesterPlayer] select (_requesterPlayer isNotEqualTo objNull)], + ["assignedAt", serverTime], + ["state", "assigned"], + ["note", _note] + ]; + + _assignmentRegistry set [_taskID, _assignment]; + _self set ["assignmentRegistry", _assignmentRegistry]; + + _self call ["appendActivity", [ + "task_assigned", + format ["%1 assigned %2 to %3.", _assignment get "assignedByName", _taskID, _groupRecord getOrDefault ["callsign", _groupID]], + _taskID, + _groupID, + _requesterUid + ]]; + + _self call ["notifyPlayer", [ + _leaderUid, + "info", + "Tasks", + format ["Contract assigned: %1. Open CAD to review and acknowledge.", _taskID] + ]]; + + _result set ["success", true]; + _result set ["message", "Task assigned."]; + _result set ["assignment", _assignment]; + _result + }], + ["acknowledgeTask", compileFinal { + params [["_requesterUid", "", [""]], ["_taskID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to acknowledge task."], + ["assignment", createHashMap] + ]; + + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _assignment = +(_assignmentRegistry getOrDefault [_taskID, createHashMap]); + if (_assignment isEqualTo createHashMap) exitWith { + _result set ["message", "Task is not assigned."]; + _result + }; + + private _groupID = _assignment getOrDefault ["groupId", ""]; + if !(_self call ["isGroupLeader", [_requesterUid, _groupID]]) exitWith { + _result set ["message", "Only the assigned group leader can acknowledge this task."]; + _result + }; + + private _bindResult = EGVAR(task,TaskStore) call ["bindTaskOwnership", [_taskID, _requesterUid]]; + if !(_bindResult getOrDefault ["success", false]) exitWith { + _result set ["message", _bindResult getOrDefault ["message", "Failed to bind task ownership."]]; + _result + }; + + _assignment set ["state", "acknowledged"]; + _assignment set ["acknowledgedAt", serverTime]; + _assignmentRegistry set [_taskID, _assignment]; + _self set ["assignmentRegistry", _assignmentRegistry]; + + _self call ["appendActivity", [ + "task_acknowledged", + format ["%1 acknowledged %2.", _requesterUid, _taskID], + _taskID, + _groupID, + _requesterUid + ]]; + + _result set ["success", true]; + _result set ["message", "Task acknowledged."]; + _result set ["assignment", _assignment]; + _result + }], + ["declineTask", compileFinal { + params [["_requesterUid", "", [""]], ["_taskID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to decline task."], + ["assignment", createHashMap] + ]; + + private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; + private _assignment = +(_assignmentRegistry getOrDefault [_taskID, createHashMap]); + if (_assignment isEqualTo createHashMap) exitWith { + _result set ["message", "Task is not assigned."]; + _result + }; + + private _groupID = _assignment getOrDefault ["groupId", ""]; + if !(_self call ["isGroupLeader", [_requesterUid, _groupID]]) exitWith { + _result set ["message", "Only the assigned group leader can decline this task."]; + _result + }; + + _assignment set ["state", "declined"]; + _assignment set ["declinedAt", serverTime]; + _assignmentRegistry set [_taskID, _assignment]; + _self set ["assignmentRegistry", _assignmentRegistry]; + + _self call ["appendActivity", [ + "task_declined", + format ["%1 declined %2.", _requesterUid, _taskID], + _taskID, + _groupID, + _requesterUid + ]]; + + _result set ["success", true]; + _result set ["message", "Task declined."]; + _result set ["assignment", _assignment]; + _result + }], + ["updateGroupStatus", compileFinal { + params [["_requesterUid", "", [""]], ["_groupID", "", [""]], ["_status", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to update group status."], + ["group", createHashMap] + ]; + + private _finalStatus = toLowerANSI _status; + if !(_finalStatus in (_self getOrDefault ["validStatuses", []])) exitWith { + _result set ["message", "Invalid group status."]; + _result + }; + + private _isAuthorized = (_self call ["isGroupLeader", [_requesterUid, _groupID]]) || { _self call ["canDispatch", [_requesterUid]] }; + if !_isAuthorized exitWith { + _result set ["message", "You are not authorized to update that group."]; + _result + }; + + private _groupRegistry = _self call ["syncGroups", []]; + private _groupRecord = +(_groupRegistry getOrDefault [_groupID, createHashMap]); + if (_groupRecord isEqualTo createHashMap) exitWith { + _result set ["message", "Group could not be resolved."]; + _result + }; + + _groupRecord set ["status", _finalStatus]; + _groupRecord set ["lastUpdate", serverTime]; + _groupRegistry set [_groupID, _groupRecord]; + _self set ["groupRegistry", _groupRegistry]; + + _self call ["appendActivity", [ + "group_status", + format ["%1 updated %2 to %3.", _requesterUid, _groupRecord getOrDefault ["callsign", _groupID], _finalStatus], + "", + _groupID, + _requesterUid + ]]; + + _result set ["success", true]; + _result set ["message", "Group status updated."]; + _result set ["group", _groupRecord]; + _result + }] +]; + +GVAR(CadStore) = createHashMapObject [GVAR(CadStoreBaseClass)]; +GVAR(CadStore) diff --git a/arma/server/addons/cad/script_component.hpp b/arma/server/addons/cad/script_component.hpp new file mode 100644 index 0000000..e5f508d --- /dev/null +++ b/arma/server/addons/cad/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT cad +#define COMPONENT_BEAUTIFIED CAD +#include "\forge\forge_server\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_server\addons\main\script_macros.hpp" From 1ca2499af759be7c630af1d92f393dd340514b93 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Mon, 30 Mar 2026 20:22:48 -0500 Subject: [PATCH 06/19] Add dispatcher mode to CAD UI - Add a dispatcher web view and layout switching - Route group role updates and dispatcher hydration through the UI bridge - Refresh top bar and hide map/side panel outside operations mode --- .../cad/functions/fnc_handleUIEvents.sqf | 24 + .../cad/functions/fnc_initRepository.sqf | 40 +- .../addons/cad/functions/fnc_initUI.sqf | 47 +- .../addons/cad/functions/fnc_initUIBridge.sqf | 164 +++++- .../addons/cad/functions/fnc_openUI.sqf | 4 +- arma/client/addons/cad/ui/RscMapUI.hpp | 31 +- .../client/addons/cad/ui/_site/bottombar.html | 2 +- .../addons/cad/ui/_site/cad-bottombar.css | 2 +- .../addons/cad/ui/_site/cad-bottombar.js | 2 +- .../addons/cad/ui/_site/cad-dispatcher.css | 1 + .../addons/cad/ui/_site/cad-dispatcher.js | 1 + .../addons/cad/ui/_site/cad-sidepanel.css | 2 +- .../addons/cad/ui/_site/cad-sidepanel.js | 2 +- .../client/addons/cad/ui/_site/cad-topbar.css | 2 +- arma/client/addons/cad/ui/_site/cad-topbar.js | 2 +- .../addons/cad/ui/_site/dispatcher.html | 1 + .../client/addons/cad/ui/_site/sidepanel.html | 2 +- arma/client/addons/cad/ui/_site/topbar.html | 2 +- arma/client/addons/cad/ui/src/bottombar.html | 4 +- arma/client/addons/cad/ui/src/bottombar.js | 11 +- arma/client/addons/cad/ui/src/dispatcher.html | 193 +++++++ arma/client/addons/cad/ui/src/dispatcher.js | 393 +++++++++++++++ arma/client/addons/cad/ui/src/sidepanel.html | 14 +- arma/client/addons/cad/ui/src/sidepanel.js | 165 +++--- .../addons/cad/ui/src/styles/bottombar.css | 11 +- .../addons/cad/ui/src/styles/dispatcher.css | 339 +++++++++++++ .../addons/cad/ui/src/styles/sidepanel.css | 23 + .../addons/cad/ui/src/styles/topbar.css | 232 +++++++-- arma/client/addons/cad/ui/src/topbar.html | 71 ++- arma/client/addons/cad/ui/src/topbar.js | 125 ++++- arma/client/addons/cad/ui/ui.config.mjs | 15 + arma/client/addons/org/XEH_postInitClient.sqf | 7 +- .../org/functions/fnc_initRepository.sqf | 2 +- arma/server/addons/cad/XEH_PREP.hpp | 4 + arma/server/addons/cad/XEH_preInit.sqf | 15 + .../functions/fnc_initActivityRepository.sqf | 61 +++ .../fnc_initAssignmentRepository.sqf | 243 +++++++++ .../addons/cad/functions/fnc_initCadStore.sqf | 473 +++--------------- .../cad/functions/fnc_initGroupRepository.sqf | 304 +++++++++++ .../functions/fnc_initPermissionService.sqf | 48 ++ 40 files changed, 2458 insertions(+), 626 deletions(-) create mode 100644 arma/client/addons/cad/ui/_site/cad-dispatcher.css create mode 100644 arma/client/addons/cad/ui/_site/cad-dispatcher.js create mode 100644 arma/client/addons/cad/ui/_site/dispatcher.html create mode 100644 arma/client/addons/cad/ui/src/dispatcher.html create mode 100644 arma/client/addons/cad/ui/src/dispatcher.js create mode 100644 arma/client/addons/cad/ui/src/styles/dispatcher.css create mode 100644 arma/server/addons/cad/functions/fnc_initActivityRepository.sqf create mode 100644 arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf create mode 100644 arma/server/addons/cad/functions/fnc_initGroupRepository.sqf create mode 100644 arma/server/addons/cad/functions/fnc_initPermissionService.sqf diff --git a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf index 8a10c41..bfe05a9 100644 --- a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf @@ -32,9 +32,23 @@ diag_log format ["[FORGE:Client:CAD] Handling UI event: %1", _event]; if (_isConfirmDialog) exitWith { true }; switch (_event) do { + case "cad::topbar::ready": { + GVAR(CADUIBridge) call ["handleTopBarReady", []]; + }; case "cad::ready": { GVAR(CADUIBridge) call ["handleReady", [_control, _data]]; }; + case "cad::dispatcher::ready": { + GVAR(CADUIBridge) call ["handleDispatcherReady", []]; + }; + case "cad::mode::set": { + private _mode = ""; + if (_data isEqualType createHashMap) then { + _mode = _data getOrDefault ["mode", ""]; + }; + + GVAR(CADUIBridge) call ["setMode", [_mode]]; + }; case "cad::refresh": { GVAR(CADUIBridge) call ["requestHydrate", []]; }; @@ -76,6 +90,16 @@ switch (_event) do { GVAR(CADUIBridge) call ["requestGroupStatus", [_groupID, _status]]; }; + case "cad::groups::role": { + private _groupID = ""; + private _role = ""; + if (_data isEqualType createHashMap) then { + _groupID = _data getOrDefault ["groupID", ""]; + _role = _data getOrDefault ["role", ""]; + }; + + GVAR(CADUIBridge) call ["requestGroupRole", [_groupID, _role]]; + }; case "map::zoomIn": { private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; if (isNull _mapCtrl) exitWith {}; diff --git a/arma/client/addons/cad/functions/fnc_initRepository.sqf b/arma/client/addons/cad/functions/fnc_initRepository.sqf index ba109da..749e691 100644 --- a/arma/client/addons/cad/functions/fnc_initRepository.sqf +++ b/arma/client/addons/cad/functions/fnc_initRepository.sqf @@ -30,19 +30,35 @@ GVAR(CADRepository) = createHashMapObject [[ _self set ["assignments", []]; _self set ["activity", []]; _self set ["session", createHashMap]; + _self set ["mode", "operations"]; + }], + ["getHydratePayload", compileFinal { + createHashMapFromArray [ + ["groups", +(_self getOrDefault ["groups", []])], + ["contracts", +(_self getOrDefault ["contracts", []])], + ["assignments", +(_self getOrDefault ["assignments", []])], + ["activity", +(_self getOrDefault ["activity", []])], + ["session", +(_self getOrDefault ["session", createHashMap])], + ["mode", _self getOrDefault ["mode", "operations"]] + ] + }], + ["getCurrentGroup", compileFinal { + private _session = _self getOrDefault ["session", createHashMap]; + private _groupID = _session getOrDefault ["groupId", ""]; + if (_groupID isEqualTo "") exitWith { createHashMap }; + + private _groups = _self getOrDefault ["groups", []]; + private _group = _groups findIf { (_x getOrDefault ["groupId", ""]) isEqualTo _groupID }; + if (_group < 0) exitWith { createHashMap }; + + +(_groups # _group) }], ["pushHydratePayload", compileFinal { params [["_bridge", createHashMap, [createHashMap]]]; if (_bridge isEqualTo createHashMap) exitWith { false }; - _bridge call ["sendEvent", ["cad::hydrate", createHashMapFromArray [ - ["groups", +(_self getOrDefault ["groups", []])], - ["contracts", +(_self getOrDefault ["contracts", []])], - ["assignments", +(_self getOrDefault ["assignments", []])], - ["activity", +(_self getOrDefault ["activity", []])], - ["session", +(_self getOrDefault ["session", createHashMap])] - ]]] + _bridge call ["sendEvent", ["cad::hydrate", _self call ["getHydratePayload", []]]] }], ["setHydratePayload", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; @@ -54,6 +70,16 @@ GVAR(CADRepository) = createHashMapObject [[ _self set ["session", +(_payload getOrDefault ["session", createHashMap])]; true }], + ["setMode", compileFinal { + params [["_mode", "operations", [""]]]; + + if !(_mode in ["operations", "dispatch"]) then { + _mode = "operations"; + }; + + _self set ["mode", _mode]; + _mode + }], ["setOpen", compileFinal { params [["_isOpen", false, [false]]]; _self set ["isOpen", _isOpen]; diff --git a/arma/client/addons/cad/functions/fnc_initUI.sqf b/arma/client/addons/cad/functions/fnc_initUI.sqf index bb84979..981d317 100644 --- a/arma/client/addons/cad/functions/fnc_initUI.sqf +++ b/arma/client/addons/cad/functions/fnc_initUI.sqf @@ -27,12 +27,16 @@ private _mapCtrl = _display displayCtrl 1001; private _topBarCtrl = _display displayCtrl 1002; private _bottomBarCtrl = _display displayCtrl 1003; private _sidePanelCtrl = _display displayCtrl 1005; +private _dispatcherCtrl = _display displayCtrl 1006; uiNamespace setVariable [QGVAR(Display), _display]; uiNamespace setVariable [QGVAR(MapCtrl), _mapCtrl]; uiNamespace setVariable [QGVAR(TopBarCtrl), _topBarCtrl]; uiNamespace setVariable [QGVAR(BottomBarCtrl), _bottomBarCtrl]; uiNamespace setVariable [QGVAR(SidePanelCtrl), _sidePanelCtrl]; +uiNamespace setVariable [QGVAR(DispatcherCtrl), _dispatcherCtrl]; + +_dispatcherCtrl ctrlShow false; private _center = if (isNull player) then { [worldSize / 2, worldSize / 2, 0] @@ -43,48 +47,5 @@ private _center = if (isNull player) then { _mapCtrl ctrlMapAnimAdd [0, 0.2, _center]; ctrlMapAnimCommit _mapCtrl; -_mapCtrl ctrlAddEventHandler ["MouseButtonClick", { - params ["_ctrl", "_button", "_xPos", "_yPos"]; - - private _worldPos = _ctrl ctrlMapScreenToWorld [_xPos, _yPos]; - private _bottomBar = uiNamespace getVariable [QGVAR(BottomBarCtrl), controlNull]; - if (isNull _bottomBar) exitWith {}; - - private _jsCode = format [ - "updateStatus('Clicked at: %1, %2');", - round (_worldPos # 0), - round (_worldPos # 1) - ]; - _bottomBar ctrlWebBrowserAction ["ExecJS", _jsCode]; -}]; - -_mapCtrl ctrlAddEventHandler ["MouseMoving", { - params ["_ctrl", "_xPos", "_yPos"]; - - private _worldPos = _ctrl ctrlMapScreenToWorld [_xPos, _yPos]; - private _topBar = uiNamespace getVariable [QGVAR(TopBarCtrl), controlNull]; - if (isNull _topBar) exitWith {}; - - private _jsCode = format [ - "updateCoordinates(%1, %2);", - _worldPos # 0, - _worldPos # 1 - ]; - _topBar ctrlWebBrowserAction ["ExecJS", _jsCode]; -}]; - -[] spawn { - while { !isNull (uiNamespace getVariable [QGVAR(Display), displayNull]) } do { - private _mapCtrl = uiNamespace getVariable [QGVAR(MapCtrl), controlNull]; - private _topBar = uiNamespace getVariable [QGVAR(TopBarCtrl), controlNull]; - - if (!isNull _mapCtrl && { !isNull _topBar }) then { - _topBar ctrlWebBrowserAction ["ExecJS", format ["updateScale(%1);", round (ctrlMapScale _mapCtrl)]]; - }; - - sleep 0.5; - }; -}; - diag_log "[FORGE:Client:CAD] CAD UI initialized."; true diff --git a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf index ab4cb86..0fb004b 100644 --- a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf @@ -26,6 +26,10 @@ private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["#base", _webUIBridgeDeclaration], ["#type", "CADUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["dispatcherReady", false]; + _self set ["topBarReady", false]; + }], ["getActiveBrowserControl", compileFinal { private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; if (isNull _display) exitWith { @@ -37,11 +41,115 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["setActiveBrowserControl", [_control]]; _control }], + ["getTopBarControl", compileFinal { + private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; + if (isNull _display) exitWith { controlNull }; + + _display displayCtrl 1002 + }], + ["getBottomBarControl", compileFinal { + private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; + if (isNull _display) exitWith { controlNull }; + + _display displayCtrl 1003 + }], + ["getMapControl", compileFinal { + private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; + if (isNull _display) exitWith { controlNull }; + + _display displayCtrl 1001 + }], + ["getDispatcherControl", compileFinal { + private _display = uiNamespace getVariable [QGVAR(Display), displayNull]; + if (isNull _display) exitWith { controlNull }; + + _display displayCtrl 1006 + }], ["hasOpenScreen", compileFinal { private _screen = _self call ["getScreen", []]; private _control = _self call ["getActiveBrowserControl", []]; !(isNull _control) && { _screen call ["isReady", []] } }], + ["isDispatcher", compileFinal { + if (isNil QGVAR(CADRepository)) exitWith { false }; + + private _session = GVAR(CADRepository) getOrDefault ["session", createHashMap]; + _session getOrDefault ["isDispatcher", false] + }], + ["applyLayout", compileFinal { + private _mode = if (isNil QGVAR(CADRepository)) then { + "operations" + } else { + GVAR(CADRepository) getOrDefault ["mode", "operations"] + }; + + private _mapCtrl = _self call ["getMapControl", []]; + private _bottomBarCtrl = _self call ["getBottomBarControl", []]; + private _sidePanelCtrl = _self call ["getActiveBrowserControl", []]; + private _dispatcherCtrl = _self call ["getDispatcherControl", []]; + + if !(isNull _mapCtrl) then { _mapCtrl ctrlShow (_mode isEqualTo "operations"); }; + if !(isNull _bottomBarCtrl) then { _bottomBarCtrl ctrlShow true; }; + if !(isNull _sidePanelCtrl) then { _sidePanelCtrl ctrlShow (_mode isEqualTo "operations"); }; + if !(isNull _dispatcherCtrl) then { _dispatcherCtrl ctrlShow (_mode isEqualTo "dispatch"); }; + + _self call ["refreshTopBarState", []]; + _self call ["refreshDispatcher", []]; + true + }], + ["setMode", compileFinal { + params [["_mode", "operations", [""]]]; + + if (isNil QGVAR(CADRepository)) exitWith { false }; + + private _targetMode = _mode; + if !(_targetMode in ["operations", "dispatch"]) then { + _targetMode = "operations"; + }; + + if (_targetMode isEqualTo "dispatch" && !(_self call ["isDispatcher", []])) then { + _targetMode = "operations"; + }; + + GVAR(CADRepository) call ["setMode", [_targetMode]]; + _self call ["applyLayout", []] + }], + ["refreshTopBarState", compileFinal { + if !(_self getOrDefault ["topBarReady", false]) exitWith { false }; + + if (isNil QGVAR(CADRepository)) exitWith { false }; + + private _topBarCtrl = _self call ["getTopBarControl", []]; + if (isNull _topBarCtrl) exitWith { false }; + + private _session = +(GVAR(CADRepository) getOrDefault ["session", createHashMap]); + private _currentGroup = GVAR(CADRepository) call ["getCurrentGroup", []]; + private _payload = createHashMapFromArray [ + ["mode", GVAR(CADRepository) getOrDefault ["mode", "operations"]], + ["session", _session], + ["currentGroup", _currentGroup] + ]; + + _topBarCtrl ctrlWebBrowserAction ["ExecJS", format [ + "window.cadTopbar && window.cadTopbar.receiveState(%1);", + toJSON _payload + ]]; + true + }], + ["refreshDispatcher", compileFinal { + if !(_self getOrDefault ["dispatcherReady", false]) exitWith { false }; + if (isNil QGVAR(CADRepository)) exitWith { false }; + + private _dispatcherCtrl = _self call ["getDispatcherControl", []]; + if (isNull _dispatcherCtrl) exitWith { false }; + + private _payload = GVAR(CADRepository) call ["getHydratePayload", []]; + _dispatcherCtrl ctrlWebBrowserAction ["ExecJS", format [ + "window.cadDispatcher && window.cadDispatcher.receiveHydrate(%1);", + toJSON _payload + ]]; + true + }], ["handleReady", compileFinal { params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; @@ -52,8 +160,25 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["requestHydrate", []]; _self call ["refreshHydrate", []]; + _self call ["refreshTopBarState", []]; true }], + ["handleClose", compileFinal { + _self set ["dispatcherReady", false]; + _self set ["topBarReady", false]; + + private _screen = _self call ["getScreen", []]; + _screen call ["dispose", []]; + true + }], + ["handleTopBarReady", compileFinal { + _self set ["topBarReady", true]; + _self call ["refreshTopBarState", []] + }], + ["handleDispatcherReady", compileFinal { + _self set ["dispatcherReady", true]; + _self call ["refreshDispatcher", []] + }], ["requestHydrate", compileFinal { [SRPC(cad,requestHydrateCad), [getPlayerUID player]] call CFUNC(serverEvent); true @@ -90,6 +215,14 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ [SRPC(cad,requestUpdateCadGroupStatus), [getPlayerUID player, _groupID, _status]] call CFUNC(serverEvent); true }], + ["requestGroupRole", compileFinal { + params [["_groupID", "", [""]], ["_role", "", [""]]]; + + if (_groupID isEqualTo "" || { _role isEqualTo "" }) exitWith { false }; + + [SRPC(cad,requestUpdateCadGroupRole), [getPlayerUID player, _groupID, _role]] call CFUNC(serverEvent); + true + }], ["refreshHydrate", compileFinal { if (isNil QGVAR(CADRepository)) exitWith { false }; GVAR(CADRepository) call ["pushHydratePayload", [_self]] @@ -100,11 +233,29 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ if (isNil QGVAR(CADRepository)) exitWith { false }; GVAR(CADRepository) call ["setHydratePayload", [_payload]]; - _self call ["refreshHydrate", []] + if !(_self call ["isDispatcher", []]) then { + GVAR(CADRepository) call ["setMode", ["operations"]]; + }; + + _self call ["refreshHydrate", []]; + _self call ["refreshTopBarState", []]; + _self call ["refreshDispatcher", []]; + _self call ["applyLayout", []] }], ["handleAssignmentResponse", compileFinal { params [["_result", createHashMap, [createHashMap]]]; + if (_self getOrDefault ["dispatcherReady", false]) then { + private _dispatcherCtrl = _self call ["getDispatcherControl", []]; + if !(isNull _dispatcherCtrl) then { + _dispatcherCtrl ctrlWebBrowserAction ["ExecJS", format [ + "window.cadDispatcher && window.cadDispatcher.setStatus(%1, %2);", + str (_result getOrDefault ["message", "Task request processed."]), + str ([ "error", "success" ] select (_result getOrDefault ["success", false])) + ]]; + }; + }; + _self call ["sendEvent", ["cad::assignment::response", createHashMapFromArray [ ["message", _result getOrDefault ["message", "Task request processed."]], ["success", _result getOrDefault ["success", false]] @@ -113,6 +264,17 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["handleGroupUpdateResponse", compileFinal { params [["_result", createHashMap, [createHashMap]]]; + if (_self getOrDefault ["dispatcherReady", false]) then { + private _dispatcherCtrl = _self call ["getDispatcherControl", []]; + if !(isNull _dispatcherCtrl) then { + _dispatcherCtrl ctrlWebBrowserAction ["ExecJS", format [ + "window.cadDispatcher && window.cadDispatcher.setStatus(%1, %2);", + str (_result getOrDefault ["message", "Group update processed."]), + str ([ "error", "success" ] select (_result getOrDefault ["success", false])) + ]]; + }; + }; + _self call ["sendEvent", ["cad::group::response", createHashMapFromArray [ ["message", _result getOrDefault ["message", "Group update processed."]], ["success", _result getOrDefault ["success", false]] diff --git a/arma/client/addons/cad/functions/fnc_openUI.sqf b/arma/client/addons/cad/functions/fnc_openUI.sqf index 0d0804b..d648613 100644 --- a/arma/client/addons/cad/functions/fnc_openUI.sqf +++ b/arma/client/addons/cad/functions/fnc_openUI.sqf @@ -28,17 +28,19 @@ if (isNull _display) exitWith { private _topBarCtrl = _display displayCtrl 1002; private _bottomBarCtrl = _display displayCtrl 1003; private _sidePanelCtrl = _display displayCtrl 1005; +private _dispatcherCtrl = _display displayCtrl 1006; { _x ctrlAddEventHandler ["JSDialog", { params ["_control", "_isConfirmDialog", "_message"]; [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); }]; -} forEach [_topBarCtrl, _bottomBarCtrl, _sidePanelCtrl]; +} forEach [_topBarCtrl, _bottomBarCtrl, _sidePanelCtrl, _dispatcherCtrl]; _topBarCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\topbar.html)]; _bottomBarCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bottombar.html)]; _sidePanelCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\sidepanel.html)]; +_dispatcherCtrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\dispatcher.html)]; if !(isNil QGVAR(CADRepository)) then { GVAR(CADRepository) call ["setOpen", [true]]; diff --git a/arma/client/addons/cad/ui/RscMapUI.hpp b/arma/client/addons/cad/ui/RscMapUI.hpp index 37f599d..b323f68 100644 --- a/arma/client/addons/cad/ui/RscMapUI.hpp +++ b/arma/client/addons/cad/ui/RscMapUI.hpp @@ -6,15 +6,24 @@ class RscMapUI { fadeout = 0; duration = 1e+011; onLoad = "uiNamespace setVariable ['forge_client_cad_Display', _this select 0]; [_this select 0] call forge_client_cad_fnc_initUI;"; - onUnLoad = "uiNamespace setVariable ['forge_client_cad_Display', nil]; uiNamespace setVariable ['forge_client_cad_MapCtrl', nil]; uiNamespace setVariable ['forge_client_cad_TopBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_BottomBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_SidePanelCtrl', nil]; if !(isNil 'forge_client_cad_CADRepository') then { forge_client_cad_CADRepository set ['isOpen', false]; };"; + onUnLoad = "uiNamespace setVariable ['forge_client_cad_Display', nil]; uiNamespace setVariable ['forge_client_cad_MapCtrl', nil]; uiNamespace setVariable ['forge_client_cad_TopBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_BottomBarCtrl', nil]; uiNamespace setVariable ['forge_client_cad_SidePanelCtrl', nil]; uiNamespace setVariable ['forge_client_cad_DispatcherCtrl', nil]; if !(isNil 'forge_client_cad_CADRepository') then { forge_client_cad_CADRepository set ['isOpen', false]; };"; class controlsBackground { + class SurfaceBackground: RscText { + idc = -1; + x = "safeZoneX + (safeZoneW * 0.1)"; + y = "safeZoneY + (safeZoneH * 0.1)"; + w = "safeZoneW * 0.8"; + h = "safeZoneH * 0.8"; + colorBackground[] = {0.04, 0.06, 0.09, 0.96}; + }; + class MapControl: RscMapControl { idc = 1001; x = "safeZoneX + (safeZoneW * 0.1)"; // 10% margin (80% width centered) - y = "safeZoneY + (safeZoneH * 0.1) + 0.0926"; // 10% margin + 50px top bar + y = "safeZoneY + (safeZoneH * 0.1) + 0.10372"; // 10% margin + 56px visible top bar w = "safeZoneW * 0.8"; // 80% width - h = "(safeZoneH * 0.8) - 0.0926 - 0.0556"; // 80% height minus top and bottom bars + h = "(safeZoneH * 0.8) - 0.10372 - 0.0556"; // 80% height minus visible top and bottom bars // Map specific settings maxSatelliteAlpha = 0.85; @@ -61,7 +70,7 @@ class RscMapUI { x = "safeZoneX + (safeZoneW * 0.1)"; y = "safeZoneY + (safeZoneH * 0.1)"; w = "safeZoneW * 0.8"; - h = "0.0926"; // 50px + h = "0.24076"; // 130px, allows dropdowns to open over the map colorBackground[] = {0, 0, 0, 0}; }; @@ -81,9 +90,19 @@ class RscMapUI { type = 106; idc = 1005; x = "safeZoneX + (safeZoneW * 0.1) + (safeZoneW * 0.8) - 0.4630"; // Right edge of 80% box minus panel width - y = "safeZoneY + (safeZoneH * 0.1) + 0.0926"; // Below top bar + y = "safeZoneY + (safeZoneH * 0.1) + 0.10372"; // Below visible top bar w = "0.4630"; // ~250px width - h = "(safeZoneH * 0.8) - 0.0926 - 0.0556"; // Full height minus bars + h = "(safeZoneH * 0.8) - 0.10372 - 0.0556"; // Full height minus visible bars + colorBackground[] = {0, 0, 0, 0}; + }; + + class DispatcherBrowser: RscText { + type = 106; + idc = 1006; + x = "safeZoneX + (safeZoneW * 0.1)"; + y = "safeZoneY + (safeZoneH * 0.1) + 0.10372"; + w = "safeZoneW * 0.8"; + h = "(safeZoneH * 0.8) - 0.10372 - 0.0556"; colorBackground[] = {0, 0, 0, 0}; }; }; diff --git a/arma/client/addons/cad/ui/_site/bottombar.html b/arma/client/addons/cad/ui/_site/bottombar.html index 33fb1ec..57d4b5e 100644 --- a/arma/client/addons/cad/ui/_site/bottombar.html +++ b/arma/client/addons/cad/ui/_site/bottombar.html @@ -1 +1 @@ -Map Ready \ No newline at end of file +CAD Systems by IDS v1.0.0 \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-bottombar.css b/arma/client/addons/cad/ui/_site/cad-bottombar.css index 7133cd2..d6213e6 100644 --- a/arma/client/addons/cad/ui/_site/cad-bottombar.css +++ b/arma/client/addons/cad/ui/_site/cad-bottombar.css @@ -1 +1 @@ -body{-webkit-backdrop-filter:blur(18px);background:linear-gradient(90deg,#0e131bf5,#121720ed 55%,#0d1219f5);border-top:1px solid #ffffff24;justify-content:space-between;align-items:center;min-height:36px;padding:0 20px;display:flex;position:absolute;bottom:0;left:0;right:0;overflow:hidden;box-shadow:0 -12px 26px #0000003d}span{color:#f5f8ffcc;text-shadow:0 1px 10px #00000047;font-size:12px}#statusText{color:var(--accent);font-weight:600} \ No newline at end of file +body{-webkit-backdrop-filter:blur(18px);background:linear-gradient(90deg,#0e131bf5,#121720ed 55%,#0d1219f5);border-top:1px solid #ffffff24;justify-content:space-between;align-items:center;min-height:36px;padding:0 20px;display:flex;position:absolute;bottom:0;left:0;right:0;overflow:hidden;box-shadow:0 -12px 26px #0000003d}.footer-brand,.footer-version{color:#f5f8ffcc;text-shadow:0 1px 10px #00000047;font-size:12px}.footer-brand{color:var(--accent);letter-spacing:.08em;text-transform:uppercase;font-weight:600}.footer-version{color:#f5f8ff9e} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-bottombar.js b/arma/client/addons/cad/ui/_site/cad-bottombar.js index d39ab4b..7710154 100644 --- a/arma/client/addons/cad/ui/_site/cad-bottombar.js +++ b/arma/client/addons/cad/ui/_site/cad-bottombar.js @@ -1 +1 @@ -window.CADBottombar=window.CADBottombar||{}; \ No newline at end of file +window.CADBottombar=window.CADBottombar||{init:()=>!0},window.CADBottombar.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-dispatcher.css b/arma/client/addons/cad/ui/_site/cad-dispatcher.css new file mode 100644 index 0000000..e658608 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-dispatcher.css @@ -0,0 +1 @@ +html,body{background:radial-gradient(circle at 0 0,#29455d2e,#0000 30%),linear-gradient(#090e14f5,#0f161ffa);width:100%;height:100%;margin:0;padding:0;overflow:hidden}body{color:var(--text);font-family:var(--font)}.dispatch-shell{flex-direction:column;gap:14px;height:100%;padding:18px;display:flex}.dispatch-header{justify-content:space-between;align-items:center;gap:16px;display:flex}.dispatch-kicker{color:var(--accent);text-transform:uppercase;letter-spacing:.1em;margin:0 0 4px;font-size:11px;font-weight:700}.dispatch-header h2{margin:0;font-size:24px;font-weight:650}.dispatch-header button,.dispatch-btn,.dispatch-select{color:var(--text);background:#181f28e6;border:1px solid #ffffff1f}.dispatch-header button,.dispatch-btn{cursor:pointer;padding:10px 14px}.dispatch-btn-secondary{background:#352827eb}.dispatch-status{color:#e9f1f8c7;min-height:20px;font-size:13px}.dispatch-status[data-type=success]{color:#79d28a}.dispatch-status[data-type=error]{color:#ff8a80}.dispatch-metrics{grid-template-columns:repeat(4,minmax(0,1fr));gap:12px;display:grid}.metric-card{background:#0d131ab8;border:1px solid #ffffff14;padding:14px}.metric-label{color:#e9f1f899;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:11px;display:block}.metric-card strong{font-size:28px;font-weight:700}.dispatch-grid{flex:1;grid-template-columns:repeat(12,minmax(0,1fr));grid-auto-rows:minmax(0,1fr);gap:14px;min-height:0;display:grid}.dispatch-panel{background:#0b1118c7;border:1px solid #ffffff14;flex-direction:column;min-width:0;min-height:0;display:flex}.dispatch-panel-open{grid-column:span 5}.dispatch-panel-assigned{grid-column:span 7}.dispatch-panel-groups{grid-column:span 8}.dispatch-panel-activity{grid-column:span 4}.dispatch-panel-header{border-bottom:1px solid #ffffff14;justify-content:space-between;align-items:center;padding:12px 14px;display:flex}.dispatch-panel-header h3{text-transform:uppercase;letter-spacing:.08em;color:var(--accent);margin:0;font-size:13px}.dispatch-list{flex-direction:column;flex:1;gap:10px;padding:12px;display:flex;overflow:auto}.dispatch-card{background:#131a22b8;border:1px solid #ffffff0f;padding:12px}.dispatch-card-header,.dispatch-meta{justify-content:space-between;gap:10px;display:flex}.dispatch-card-header-actions{align-items:center;gap:8px;display:flex}.dispatch-card-header-main{align-items:center;gap:8px;min-width:0;display:flex}.dispatch-card-header{margin-bottom:8px}.dispatch-description{color:#f1f6fbd1;margin:0 0 10px;font-size:13px;line-height:1.45}.dispatch-meta{color:#e5edf4b3;margin-bottom:10px;font-size:12px}.dispatch-badge{color:var(--accent);text-transform:uppercase;background:#102b3db3;border:1px solid #5bbbff2e;padding:3px 7px;font-size:11px}.dispatch-icon-btn{width:32px;height:32px;color:var(--text);cursor:pointer;background:#181f28eb;border:1px solid #ffffff24;padding:0}.dispatch-icon-btn:hover{background:#202a34f5}.dispatch-actions{flex-direction:column;gap:8px;display:flex}.dispatch-actions-split{margin-top:10px}.dispatch-select{width:100%;padding:9px 10px}.placeholder-message{text-align:center;color:#e9f1f899;padding:18px}.dispatch-modal{z-index:30;position:fixed;inset:0}.dispatch-modal.is-hidden{display:none}.dispatch-modal-backdrop{background:#04080cb8;position:absolute;inset:0}.dispatch-modal-dialog{background:#0b1118fa;border:1px solid #ffffff1f;width:min(480px,100% - 48px);margin:72px auto 0;position:relative;box-shadow:0 24px 64px #0000006b}.dispatch-modal-header,.dispatch-modal-actions{justify-content:space-between;align-items:center;gap:12px;padding:14px 16px;display:flex}.dispatch-modal-header{border-bottom:1px solid #ffffff14}.dispatch-modal-header h3{margin:0;font-size:22px;font-weight:650}.dispatch-modal-body{padding:16px}.dispatch-meta-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;margin-bottom:18px;display:grid}.dispatch-meta-grid strong{margin-top:4px;font-size:14px;font-weight:600;display:block}.dispatch-modal-fields{gap:12px;display:grid}.dispatch-field{gap:6px;display:grid}.dispatch-field span{text-transform:uppercase;letter-spacing:.08em;color:#e9f1f8b3;font-size:12px;font-weight:650}.dispatch-modal-actions{border-top:1px solid #ffffff14;justify-content:flex-end} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-dispatcher.js b/arma/client/addons/cad/ui/_site/cad-dispatcher.js new file mode 100644 index 0000000..6da2ab3 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/cad-dispatcher.js @@ -0,0 +1 @@ +window.cadDispatcher={contracts:[],groups:[],activity:[],session:{},editingGroupId:"",statuses:["available","en_route","on_task","holding","danger","refit","offline"],roles:["infantry","recon","armor","air","logistics","support"],init(){document.getElementById("dispatcherRefreshBtn").addEventListener("click",()=>{this.setStatus("Refreshing board...","info"),window.mapUI.sendEvent("cad::refresh",{})}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(t){this.contracts=Array.isArray(t.contracts)?t.contracts:[],this.groups=Array.isArray(t.groups)?t.groups:[],this.activity=Array.isArray(t.activity)?t.activity:[],this.session=t.session&&"object"==typeof t.session?t.session:{};const e=document.getElementById("dispatcherStatusMessage");!e||e.dataset.type&&"info"!==e.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.render()},setStatus(t,e){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=t||"",s.dataset.type=e||"")},assignTask(t){const e=document.getElementById(`dispatcher-assign-group-${t}`);e&&e.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:t,groupID:e.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},openGroupModal(t){const e=this.groups.find(e=>e.groupId===t);e&&(this.editingGroupId=t,document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(t=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(t=>``).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const t=this.groups.find(t=>t.groupId===this.editingGroupId);t?(document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default"):this.closeGroupModal()},applyGroupUpdates(){if(!this.editingGroupId)return;const t=this.groups.find(t=>t.groupId===this.editingGroupId);if(!t)return void this.closeGroupModal();const e=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value;let n=!1;e&&e!==(t.role||"")&&(n=!0,this.setStatus("Updating group role...","info"),window.mapUI.sendEvent("cad::groups::role",{groupID:this.editingGroupId,role:e})),s&&s!==(t.status||"")&&(n=!0,this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:this.editingGroupId,status:s})),n||this.setStatus("No group changes to save.","info"),this.closeGroupModal()},buildGroupEditorButton:t=>`\n \n ⚙\n \n `,renderMetrics(){const t=this.contracts.filter(t=>"unassigned"!==(t.assignmentState||"unassigned")),e=this.contracts.filter(t=>"unassigned"===(t.assignmentState||"unassigned")),s=this.groups.filter(t=>"danger"===(t.status||""));document.getElementById("metricOpenContracts").textContent=e.length,document.getElementById("metricAssignedContracts").textContent=t.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricDangerGroups").textContent=s.length},renderOpenContracts(){const t=document.getElementById("dispatcherOpenContracts"),e=this.contracts.filter(t=>"unassigned"===(t.assignmentState||"unassigned"));if(!e.length)return void(t.innerHTML='

No open contracts.

');const s=this.groups.map(t=>``).join("");t.innerHTML=e.map(t=>{const e=t.taskId||t.taskID||"",n=Array.isArray(t.position)?t.position:[0,0,0];return`\n
\n
\n ${t.title||e}\n ${t.type||"task"}\n
\n

${t.description||""}

\n
\n Unassigned\n X: ${Math.round(n[0]||0)} Y: ${Math.round(n[1]||0)}\n
\n
\n \n \n
\n
\n `}).join("")},renderAssignedContracts(){const t=document.getElementById("dispatcherAssignedContracts"),e=this.contracts.filter(t=>"unassigned"!==(t.assignmentState||"unassigned"));e.length?t.innerHTML=e.map(t=>{const e=t.taskId||t.taskID||"",s=this.groups.find(e=>e.groupId===(t.assignedGroupId||""));return`\n
\n
\n ${t.title||e}\n ${t.assignmentState||"assigned"}\n
\n

${t.description||""}

\n
\n Group: ${s?s.callsign:t.assignedGroupId||"Unknown"}\n Type: ${t.type||"task"}\n
\n
\n `}).join(""):t.innerHTML='

No assigned contracts.

'},renderGroups(){const t=document.getElementById("dispatcherGroups");this.groups.length?t.innerHTML=this.groups.map(t=>`\n
\n
\n
\n ${t.callsign||t.groupId}\n ${t.role||"group"}\n
\n
\n ${this.buildGroupEditorButton(t.groupId)}\n
\n
\n
\n Leader: ${t.leaderName||"Unknown"}\n Status: ${t.status||"unknown"}\n
\n
\n Org: ${t.orgId||"default"}\n Task: ${t.currentTaskId||"None"}\n
\n
\n `).join(""):t.innerHTML='

No active groups available.

'},renderActivity(){const t=document.getElementById("dispatcherActivity");this.activity.length?t.innerHTML=this.activity.slice().reverse().slice(0,12).map(t=>`\n
\n
\n ${t.type||"activity"}\n ${Math.round(t.timestamp||0)}s\n
\n

${t.message||""}

\n
\n `).join(""):t.innerHTML='

No recent activity.

'},render(){this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}},window.cadDispatcher.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.css b/arma/client/addons/cad/ui/_site/cad-sidepanel.css index 112ce7f..eae1ee5 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.css +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.css @@ -1 +1 @@ -html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.task-toolbar{margin-bottom:10px}.cad-tabs{grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;display:grid}.cad-tab{color:#f3f6f9c7;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;background:#141b21e0;border:1px solid #ffffff24;padding:8px 10px;font-size:11px}.cad-tab:hover{color:#f3f6f9;background:#1f282ff0}.cad-tab.is-active{color:var(--accent);background:#0f283af5;border-color:#5bbbff6b}.cad-tab-panels{min-height:0}.cad-section{display:none}.cad-section.is-active{display:block}.cad-section-header{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:12px;font-weight:700}.task-toolbar button,.task-accept-btn,.task-secondary-btn,.cad-select{color:#f3f6f9;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-toolbar button,.task-accept-btn,.task-secondary-btn{cursor:pointer}.task-toolbar button:hover,.task-accept-btn:hover,.task-secondary-btn:hover{background:#2e3942f2}.task-toolbar button:disabled,.task-accept-btn:disabled,.task-secondary-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-action-stack,.task-action-row{flex-direction:column;gap:8px;display:flex}.task-action-row{flex-direction:row}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex}.task-secondary-btn{background:#3c302deb} \ No newline at end of file +html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.task-toolbar{margin-bottom:10px}.cad-tabs{grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;display:grid}.cad-tab{color:#f3f6f9c7;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;background:#141b21e0;border:1px solid #ffffff24;padding:8px 10px;font-size:11px}.cad-tab:hover{color:#f3f6f9;background:#1f282ff0}.cad-tab.is-active{color:var(--accent);background:#0f283af5;border-color:#5bbbff6b}.cad-tab-panels{min-height:0}.cad-section{display:none}.cad-section.is-active{display:block}.cad-section-header{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:12px;font-weight:700}.task-toolbar button,.task-accept-btn,.task-secondary-btn,.cad-select{color:#f3f6f9;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-toolbar button,.task-accept-btn,.task-secondary-btn{cursor:pointer}.task-toolbar button:hover,.task-accept-btn:hover,.task-secondary-btn:hover{background:#2e3942f2}.task-toolbar button:disabled,.task-accept-btn:disabled,.task-secondary-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-action-stack,.task-action-row{flex-direction:column;gap:8px;display:flex}.task-action-row{flex-direction:row}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex}.task-secondary-btn{background:#3c302deb}.roster-summary-card{background:#10171dd1;border:1px solid #ffffff14;padding:10px}.roster-member-card{background:#0c1014bd}.roster-leader-badge{color:var(--accent);letter-spacing:.06em;text-transform:uppercase;background:#0f283ad1;border:1px solid #5bbbff47;align-items:center;padding:2px 8px;font-size:10px;font-weight:700;display:inline-flex} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.js b/arma/client/addons/cad/ui/_site/cad-sidepanel.js index fa1bdc4..c27970a 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.js +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.js @@ -1 +1 @@ -window.cadTasks={contracts:[],groups:[],activity:[],session:{},activeTab:"contracts",statuses:["available","en_route","on_task","holding","danger","refit","offline"],init(){const s=document.getElementById("refreshCadBtn");s&&s.addEventListener("click",()=>this.refresh()),document.querySelectorAll(".cad-tab").forEach(s=>{s.addEventListener("click",()=>{this.setActiveTab(s.dataset.tab||"contracts")})}),window.ForgeBridge.on("cad::hydrate",s=>{this.setHydratePayload(s||{})}),window.ForgeBridge.on("cad::assignment::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.on("cad::group::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setActiveTab(s){this.activeTab=s||"contracts",document.querySelectorAll(".cad-tab").forEach(s=>{s.classList.toggle("is-active",s.dataset.tab===this.activeTab)}),document.querySelectorAll("[data-panel]").forEach(s=>{s.classList.toggle("is-active",s.dataset.panel===this.activeTab)})},setHydratePayload(s){this.contracts=Array.isArray(s.contracts)?s.contracts:[],this.groups=Array.isArray(s.groups)?s.groups:[],this.activity=Array.isArray(s.activity)?s.activity:[],this.session=s.session&&"object"==typeof s.session?s.session:{};const t=document.getElementById("cadStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.render()},setStatus(s,t){const e=document.getElementById("cadStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"")},handleServerResponse(s,t){this.setStatus(t||(s?"CAD update succeeded.":"CAD update failed."),s?"success":"error")},refresh(){this.setStatus("Refreshing board...","info"),window.mapUI.sendEvent("cad::refresh",{})},assignTask(s){const t=document.getElementById(`assign-group-${s}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:s,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},acknowledgeTask(s){this.setStatus("Acknowledging contract...","info"),window.mapUI.sendEvent("cad::tasks::acknowledge",{taskID:s})},declineTask(s){this.setStatus("Declining contract...","info"),window.mapUI.sendEvent("cad::tasks::decline",{taskID:s})},updateGroupStatus(s,t){this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:s,status:t})},getPlayerGroupId(){return this.session.groupId||""},canDispatch(){return!!this.session.isDispatcher},isLeader(){return!!this.session.isLeader},renderContracts(){const s=document.getElementById("taskList");if(!s)return;if(!this.contracts.length)return void(s.innerHTML='

No active contracts are available.

');const t=this.getPlayerGroupId();s.innerHTML=this.contracts.map(s=>{const e=s.taskId||s.taskID||"",a=Array.isArray(s.position)?s.position:[0,0,0],n=s.assignedGroupId||"",i=s.assignmentState||"unassigned",o=this.groups.find(s=>s.groupId===n),r=this.isLeader()&&n===t,c=this.groups.map(s=>``).join("");return`\n
\n
\n ${s.title||e}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${"unassigned"===i?"Available":`${i}: ${o?o.callsign:n}`}\n X: ${Math.round(a[0]||0)} Y: ${Math.round(a[1]||0)}\n
\n ${this.canDispatch()?`
\n \n \n
`:""}\n ${r&&"assigned"===i?`
\n \n \n
`:""}\n
\n `}).join("")},renderGroups(){const s=document.getElementById("groupList");if(!s)return;if(!this.groups.length)return void(s.innerHTML='

No active groups are available.

');const t=this.getPlayerGroupId();s.innerHTML=this.groups.map(s=>{const e=this.canDispatch()||this.isLeader()&&s.groupId===t,a=this.statuses.map(t=>``).join("");return`\n
\n
\n ${s.callsign||s.groupId}\n ${s.role||"group"}\n
\n
\n Leader: ${s.leaderName||"Unknown"}\n Status: ${s.status||"unknown"}\n
\n
\n Org: ${s.orgId||"default"}\n Task: ${s.currentTaskId||"None"}\n
\n ${e?`
\n \n \n
`:""}\n
\n `}).join("")},renderActivity(){const s=document.getElementById("activityList");s&&(this.activity.length?s.innerHTML=this.activity.slice().reverse().slice(0,8).map(s=>`\n
\n
\n ${s.type||"activity"}\n ${Math.round(s.timestamp||0)}s\n
\n

${s.message||""}

\n
\n `).join(""):s.innerHTML='

No recent activity.

')},render(){this.renderContracts(),this.renderGroups(),this.renderActivity(),this.setActiveTab(this.activeTab)}},window.cadTasks.init(); \ No newline at end of file +window.cadTasks={contracts:[],groups:[],activity:[],session:{},mode:"operations",activeTab:"contracts",statuses:["available","en_route","on_task","holding","danger","refit","offline"],roles:["infantry","recon","armor","air","logistics","support"],init(){const s=document.getElementById("refreshCadBtn");s&&s.addEventListener("click",()=>this.refresh()),document.querySelectorAll(".cad-tab").forEach(s=>{s.addEventListener("click",()=>{this.setActiveTab(s.dataset.tab||"contracts")})}),window.ForgeBridge.on("cad::hydrate",s=>{this.setHydratePayload(s||{})}),window.ForgeBridge.on("cad::assignment::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.on("cad::group::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setActiveTab(s){this.activeTab=s||"contracts",document.querySelectorAll(".cad-tab").forEach(s=>{s.classList.toggle("is-active",s.dataset.tab===this.activeTab)}),document.querySelectorAll("[data-panel]").forEach(s=>{s.classList.toggle("is-active",s.dataset.panel===this.activeTab)})},setHydratePayload(s){this.contracts=Array.isArray(s.contracts)?s.contracts:[],this.groups=Array.isArray(s.groups)?s.groups:[],this.activity=Array.isArray(s.activity)?s.activity:[],this.session=s.session&&"object"==typeof s.session?s.session:{},this.mode=s&&"string"==typeof s.mode?s.mode:"operations";const t=document.getElementById("cadStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.render()},setStatus(s,t){const e=document.getElementById("cadStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"")},handleServerResponse(s,t){this.setStatus(t||(s?"CAD update succeeded.":"CAD update failed."),s?"success":"error")},refresh(){this.setStatus("Refreshing board...","info"),window.mapUI.sendEvent("cad::refresh",{})},acknowledgeTask(s){this.setStatus("Acknowledging contract...","info"),window.mapUI.sendEvent("cad::tasks::acknowledge",{taskID:s})},declineTask(s){this.setStatus("Declining contract...","info"),window.mapUI.sendEvent("cad::tasks::decline",{taskID:s})},updateGroupStatus(s,t){this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:s,status:t})},updateGroupRole(s,t){this.setStatus("Updating group role...","info"),window.mapUI.sendEvent("cad::groups::role",{groupID:s,role:t})},getPlayerGroupId(){return this.session.groupId||""},getCurrentGroup(){const s=this.getPlayerGroupId();return this.groups.find(t=>t.groupId===s)||null},normalizeCollection:s=>Array.isArray(s)?s:s&&"object"==typeof s?Object.values(s):[],canDispatch(){return!!this.session.isDispatcher},isDispatchMode(){return"dispatch"===this.mode},isLeader(){return!!this.session.isLeader},renderContracts(){const s=document.getElementById("taskList");if(!s)return;const t=this.getPlayerGroupId(),e=this.contracts.filter(s=>(s.assignedGroupId||"")===t);e.length?s.innerHTML=e.map(s=>{const e=s.taskId||s.taskID||"",a=Array.isArray(s.position)?s.position:[0,0,0],n=s.assignedGroupId||"",r=s.assignmentState||"unassigned",i=this.groups.find(s=>s.groupId===n),o=this.isLeader()&&n===t;return`\n
\n
\n ${s.title||e}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${"unassigned"===r?"Available":`${r}: ${i?i.callsign:n}`}\n X: ${Math.round(a[0]||0)} Y: ${Math.round(a[1]||0)}\n
\n ${o&&"assigned"===r?`
\n \n \n
`:""}\n
\n `}).join(""):s.innerHTML='

No contract is currently assigned to your group.

'},renderRoster(){const s=document.getElementById("rosterList");if(!s)return;const t=this.getCurrentGroup();if(!t)return void(s.innerHTML='

Your group is not currently available.

');const e=this.normalizeCollection(t.members);e.length?s.innerHTML=`\n
\n
\n ${t.callsign||t.groupId||"Current Group"}\n ${e.length} member${1===e.length?"":"s"}\n
\n
\n Leader: ${t.leaderName||"Unknown"}\n Status: ${t.status||"unknown"}\n
\n
\n Role: ${t.role||"unassigned"}\n Task: ${t.currentTaskId||"None"}\n
\n
\n ${e.map(s=>{const t=(s.lifeState||"unknown").replaceAll("_"," "),e=s.isLeader?'Leader':"";return`\n
\n
\n ${s.name||"Unknown Operator"}\n ${t}\n
\n
\n ${s.uid||"No UID"}\n ${e}\n
\n
\n `}).join("")}\n `:s.innerHTML='

No roster members are currently available.

'},renderActivity(){const s=document.getElementById("activityList");s&&(this.activity.length?s.innerHTML=this.activity.slice().reverse().slice(0,8).map(s=>`\n
\n
\n ${s.type||"activity"}\n ${Math.round(s.timestamp||0)}s\n
\n

${s.message||""}

\n
\n `).join(""):s.innerHTML='

No recent activity.

')},render(){this.renderContracts(),this.renderRoster(),this.renderActivity(),this.setActiveTab(this.activeTab)}},window.cadTasks.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-topbar.css b/arma/client/addons/cad/ui/_site/cad-topbar.css index 59129ec..5ae3100 100644 --- a/arma/client/addons/cad/ui/_site/cad-topbar.css +++ b/arma/client/addons/cad/ui/_site/cad-topbar.css @@ -1 +1 @@ -body{-webkit-backdrop-filter:blur(18px);background:linear-gradient(90deg,#10161ff5,#131a24f0 55%,#0f141cf5);border-bottom:1px solid #ffffff24;justify-content:space-between;align-items:center;height:56px;padding:0 20px;display:flex;position:absolute;top:0;left:0;right:0;overflow:hidden;box-shadow:0 14px 28px #00000047}.logo{color:var(--accent);text-transform:uppercase;letter-spacing:.4px;text-shadow:0 1px 12px #00000059;font-size:16px;font-weight:650}.controls{align-items:center;gap:10px;display:flex}.search-input{color:var(--text);background:#ffffff14;border:1px solid #ffffff24;border-radius:999px;outline:none;width:250px;padding:10px 12px;font-size:13px;box-shadow:inset 0 1px #ffffff08}.search-input::placeholder{color:var(--muted2)}.search-input:focus{background:#ffffff1c;border-color:#68c4ff73}.info{color:#f5f8ffd6;font-size:12px;font-family:var(--font);text-shadow:0 1px 10px #00000047;gap:20px;display:flex} \ No newline at end of file +body{background:0 0;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;column-gap:16px;height:60px;padding:0 16px;display:grid;position:absolute;top:0;left:0;right:0;overflow:visible}body:before{content:"";height:60px;box-shadow:none;-webkit-backdrop-filter:blur(18px);z-index:0;pointer-events:none;background:linear-gradient(90deg,#10161ff5,#131a24f0 55%,#0f141cf5);border-bottom:none;position:absolute;inset:0 0 auto}body>*{z-index:1;position:relative}.logo{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;text-shadow:0 1px 12px #00000059;font-size:15px;font-weight:650}.header-main{align-items:center;gap:12px;min-width:0;display:flex}.title-block{flex-direction:column;flex:none;gap:1px;min-width:0;display:flex}.title-kicker{color:#dae3ec8f;text-transform:uppercase;letter-spacing:.12em;font-size:10px}.title-main{color:#f5f8ffeb;font-size:15px;font-weight:600}.operator-strip{flex:auto;align-items:center;gap:8px;min-width:0;display:flex}.operator-strip.is-hidden,.operator-controls.is-hidden{display:none}.operator-info{flex-direction:column;gap:0;min-width:88px;display:flex}.operator-label{color:#dae3ec80;text-transform:uppercase;letter-spacing:.12em;font-size:9px}.operator-info strong{color:#f5f8ffe6;font-size:12px;font-weight:550}.operator-controls{align-items:center;gap:6px;min-width:0;display:flex}.operator-select{min-width:92px;max-width:112px;color:var(--text);background:#0e141cf5;border:1px solid #ffffff24;padding:5px 8px;font-size:11px}.btn-operator{text-transform:uppercase;letter-spacing:.08em;min-width:84px;font-size:10px}.mode-controls{justify-self:end;align-items:center;gap:8px;display:flex}.mode-controls.is-hidden{display:none}.controls{justify-self:end;align-items:center;gap:8px;display:flex}.mode-text{color:#e9f1f8b8;text-transform:uppercase;letter-spacing:.1em;font-size:10px}.mode-switch{align-items:center;width:54px;height:28px;display:inline-flex;position:relative}.mode-switch input{opacity:0;pointer-events:none;position:absolute}.mode-slider{background:#161d27eb;border:1px solid #ffffff24;border-radius:999px;width:54px;height:28px;transition:border-color .16s,background .16s;position:relative;box-shadow:inset 0 1px 10px #00000038}.mode-slider:after{content:"";background:linear-gradient(#edf4fbfa,#bdcdddeb);border-radius:50%;width:20px;height:20px;transition:transform .16s,background .16s;position:absolute;top:3px;left:3px;box-shadow:0 4px 12px #00000042}.mode-switch input:checked+.mode-slider{background:#0e2538f2;border-color:#5bbbff6b}.mode-switch input:checked+.mode-slider:after{background:linear-gradient(#83d4fffa,#48aae7f0);transform:translate(26px)}.btn-close{min-width:42px}body[data-mode=operations]{pointer-events:none}body[data-mode=operations] .logo,body[data-mode=operations] .title-block,body[data-mode=operations] .operator-strip,body[data-mode=operations] .operator-controls,body[data-mode=operations] .mode-controls,body[data-mode=operations] .controls,body[data-mode=operations] .mode-switch,body[data-mode=operations] .mode-switch *,body[data-mode=operations] button,body[data-mode=operations] select,body[data-mode=operations] label{pointer-events:auto} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-topbar.js b/arma/client/addons/cad/ui/_site/cad-topbar.js index 91698ea..b347404 100644 --- a/arma/client/addons/cad/ui/_site/cad-topbar.js +++ b/arma/client/addons/cad/ui/_site/cad-topbar.js @@ -1 +1 @@ -document.getElementById("btnZoomIn").addEventListener("click",()=>{window.mapUI.sendEvent("map::zoomIn",null)}),document.getElementById("btnZoomOut").addEventListener("click",()=>{window.mapUI.sendEvent("map::zoomOut",null)}),document.getElementById("btnClose").addEventListener("click",()=>{window.mapUI.sendEvent("map::close",null)}),document.getElementById("searchBox").addEventListener("keypress",e=>{"Enter"===e.key&&window.mapUI.sendEvent("map::search",e.target.value)}); \ No newline at end of file +window.cadTopbar={mode:"operations",currentGroup:null,session:{},init(){document.getElementById("btnClose").addEventListener("click",()=>{window.mapUI.sendEvent("map::close",null)}),document.getElementById("modeToggle").addEventListener("change",e=>{window.mapUI.sendEvent("cad::mode::set",{mode:e.target.checked?"dispatch":"operations"})}),document.getElementById("operatorRoleBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::role",{groupID:this.currentGroup.groupId||"",role:document.getElementById("operatorRoleSelect").value})}),document.getElementById("operatorStatusBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::status",{groupID:this.currentGroup.groupId||"",status:document.getElementById("operatorStatusSelect").value})}),window.mapUI.sendEvent("cad::topbar::ready",{})},formatLocation(e){const t=Array.isArray(e?.position)?e.position:[0,0,0];return`X: ${Math.round(t[0]||0).toString().padStart(4,"0")} Y: ${Math.round(t[1]||0).toString().padStart(4,"0")}`},receiveState(e){this.session=e&&e.session&&"object"==typeof e.session?e.session:{},this.mode=e&&"string"==typeof e.mode?e.mode:"operations",this.currentGroup=e&&e.currentGroup&&"object"==typeof e.currentGroup?e.currentGroup:null;const t=document.getElementById("modeControls"),o=!!this.session.isDispatcher,r=!(!this.currentGroup||!this.session.isLeader&&!this.session.isDispatcher),n=document.getElementById("operatorStrip"),s=document.getElementById("operatorControls");t.classList.toggle("is-hidden",!o),n.classList.toggle("is-hidden","operations"!==this.mode||!this.currentGroup),s.classList.toggle("is-hidden",!r),document.body.dataset.mode=this.mode,document.body.dataset.dispatcher=o?"true":"false",document.getElementById("modeToggle").checked="dispatch"===this.mode,document.getElementById("operatorGroupName").textContent=this.currentGroup?this.currentGroup.callsign||this.currentGroup.groupId||"Current Group":"No Group",document.getElementById("operatorLocation").textContent=this.currentGroup?this.formatLocation(this.currentGroup):"Unavailable",this.currentGroup&&(document.getElementById("operatorRoleSelect").value=this.currentGroup.role||"infantry",document.getElementById("operatorStatusSelect").value=this.currentGroup.status||"available")}},window.cadTopbar.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/dispatcher.html b/arma/client/addons/cad/ui/_site/dispatcher.html new file mode 100644 index 0000000..f44f296 --- /dev/null +++ b/arma/client/addons/cad/ui/_site/dispatcher.html @@ -0,0 +1 @@ +

Dispatch Dashboard

Operational Board

Open Contracts 0
Assigned Contracts 0
Active Groups 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Activity Feed

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/sidepanel.html b/arma/client/addons/cad/ui/_site/sidepanel.html index 778ef42..7917e62 100644 --- a/arma/client/addons/cad/ui/_site/sidepanel.html +++ b/arma/client/addons/cad/ui/_site/sidepanel.html @@ -1 +1 @@ -

CAD System

Contracts

Loading contracts...

Groups

Loading groups...

Activity

No recent activity.

\ No newline at end of file +

CAD System

Contracts

Loading contracts...

Roster

Loading roster...

Activity

No recent activity.

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/topbar.html b/arma/client/addons/cad/ui/_site/topbar.html index 6eed3c9..4e31fcb 100644 --- a/arma/client/addons/cad/ui/_site/topbar.html +++ b/arma/client/addons/cad/ui/_site/topbar.html @@ -1 +1 @@ -
X: 0000 Y: 0000 Scale: 1:1000
\ No newline at end of file +
Cad Systems FORGE Command & Dispatch
\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/bottombar.html b/arma/client/addons/cad/ui/src/bottombar.html index b87d3cb..061c255 100644 --- a/arma/client/addons/cad/ui/src/bottombar.html +++ b/arma/client/addons/cad/ui/src/bottombar.html @@ -4,8 +4,8 @@ - Map Ready - + CAD Systems by IDS + v1.0.0 + + diff --git a/arma/client/addons/cad/ui/src/dispatcher.js b/arma/client/addons/cad/ui/src/dispatcher.js new file mode 100644 index 0000000..b29b427 --- /dev/null +++ b/arma/client/addons/cad/ui/src/dispatcher.js @@ -0,0 +1,393 @@ +window.cadDispatcher = { + contracts: [], + groups: [], + activity: [], + session: {}, + editingGroupId: "", + statuses: [ + "available", + "en_route", + "on_task", + "holding", + "danger", + "refit", + "offline", + ], + roles: ["infantry", "recon", "armor", "air", "logistics", "support"], + init() { + document + .getElementById("dispatcherRefreshBtn") + .addEventListener("click", () => { + this.setStatus("Refreshing board...", "info"); + window.mapUI.sendEvent("cad::refresh", {}); + }); + + document + .getElementById("dispatcherGroupModalCloseBtn") + .addEventListener("click", () => { + this.closeGroupModal(); + }); + + document + .getElementById("dispatcherGroupModalSaveBtn") + .addEventListener("click", () => { + this.applyGroupUpdates(); + }); + + document + .querySelector("#dispatcherGroupModal .dispatch-modal-backdrop") + .addEventListener("click", () => { + this.closeGroupModal(); + }); + + window.mapUI.sendEvent("cad::dispatcher::ready", {}); + }, + receiveHydrate(payload) { + this.contracts = Array.isArray(payload.contracts) + ? payload.contracts + : []; + this.groups = Array.isArray(payload.groups) ? payload.groups : []; + this.activity = Array.isArray(payload.activity) ? payload.activity : []; + this.session = + payload.session && typeof payload.session === "object" + ? payload.session + : {}; + + const statusEl = document.getElementById("dispatcherStatusMessage"); + if ( + statusEl && + (!statusEl.dataset.type || statusEl.dataset.type === "info") + ) { + this.setStatus("", ""); + } + + this.syncOpenModal(); + this.render(); + }, + setStatus(message, type) { + const statusEl = document.getElementById("dispatcherStatusMessage"); + if (!statusEl) { + return; + } + + statusEl.textContent = message || ""; + statusEl.dataset.type = type || ""; + }, + assignTask(taskID) { + const selector = document.getElementById( + `dispatcher-assign-group-${taskID}`, + ); + if (!selector || !selector.value) { + this.setStatus( + "Select a group before assigning a contract.", + "error", + ); + return; + } + + this.setStatus("Submitting assignment...", "info"); + window.mapUI.sendEvent("cad::tasks::assign", { + taskID: taskID, + groupID: selector.value, + note: "", + }); + }, + openGroupModal(groupID) { + const group = this.groups.find((entry) => entry.groupId === groupID); + if (!group) { + return; + } + + this.editingGroupId = groupID; + document.getElementById("dispatcherModalGroupCallsign").textContent = + group.callsign || group.groupId || "Unknown"; + document.getElementById("dispatcherModalGroupLeader").textContent = + group.leaderName || "Unknown"; + document.getElementById("dispatcherModalGroupTask").textContent = + group.currentTaskId || "None"; + document.getElementById("dispatcherModalGroupOrg").textContent = + group.orgId || "default"; + document.getElementById("dispatcherModalRoleSelect").innerHTML = + this.roles + .map( + (role) => + ``, + ) + .join(""); + document.getElementById("dispatcherModalStatusSelect").innerHTML = + this.statuses + .map( + (status) => + ``, + ) + .join(""); + + document + .getElementById("dispatcherGroupModal") + .classList.remove("is-hidden"); + }, + + closeGroupModal() { + this.editingGroupId = ""; + document + .getElementById("dispatcherGroupModal") + .classList.add("is-hidden"); + }, + + syncOpenModal() { + if (!this.editingGroupId) { + return; + } + + const group = this.groups.find( + (entry) => entry.groupId === this.editingGroupId, + ); + if (!group) { + this.closeGroupModal(); + return; + } + + document.getElementById("dispatcherModalGroupCallsign").textContent = + group.callsign || group.groupId || "Unknown"; + document.getElementById("dispatcherModalGroupLeader").textContent = + group.leaderName || "Unknown"; + document.getElementById("dispatcherModalGroupTask").textContent = + group.currentTaskId || "None"; + document.getElementById("dispatcherModalGroupOrg").textContent = + group.orgId || "default"; + }, + + applyGroupUpdates() { + if (!this.editingGroupId) { + return; + } + + const group = this.groups.find( + (entry) => entry.groupId === this.editingGroupId, + ); + if (!group) { + this.closeGroupModal(); + return; + } + + const roleValue = document.getElementById( + "dispatcherModalRoleSelect", + ).value; + const statusValue = document.getElementById( + "dispatcherModalStatusSelect", + ).value; + let hasChanges = false; + + if (roleValue && roleValue !== (group.role || "")) { + hasChanges = true; + this.setStatus("Updating group role...", "info"); + window.mapUI.sendEvent("cad::groups::role", { + groupID: this.editingGroupId, + role: roleValue, + }); + } + + if (statusValue && statusValue !== (group.status || "")) { + hasChanges = true; + this.setStatus("Updating group status...", "info"); + window.mapUI.sendEvent("cad::groups::status", { + groupID: this.editingGroupId, + status: statusValue, + }); + } + + if (!hasChanges) { + this.setStatus("No group changes to save.", "info"); + } + + this.closeGroupModal(); + }, + + buildGroupEditorButton(groupID) { + return ` + + `; + }, + renderMetrics() { + const assignedContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") !== "unassigned", + ); + const openContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") === "unassigned", + ); + const dangerGroups = this.groups.filter( + (group) => (group.status || "") === "danger", + ); + + document.getElementById("metricOpenContracts").textContent = + openContracts.length; + document.getElementById("metricAssignedContracts").textContent = + assignedContracts.length; + document.getElementById("metricActiveGroups").textContent = + this.groups.length; + document.getElementById("metricDangerGroups").textContent = + dangerGroups.length; + }, + renderOpenContracts() { + const container = document.getElementById("dispatcherOpenContracts"); + const openContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") === "unassigned", + ); + + if (!openContracts.length) { + container.innerHTML = + '

No open contracts.

'; + return; + } + + const groupOptions = this.groups + .map( + (group) => + ``, + ) + .join(""); + + container.innerHTML = openContracts + .map((task) => { + const taskId = task.taskId || task.taskID || ""; + const position = Array.isArray(task.position) + ? task.position + : [0, 0, 0]; + + return ` +
+
+ ${task.title || taskId} + ${task.type || "task"} +
+

${task.description || ""}

+
+ Unassigned + X: ${Math.round(position[0] || 0)} Y: ${Math.round(position[1] || 0)} +
+
+ + +
+
+ `; + }) + .join(""); + }, + renderAssignedContracts() { + const container = document.getElementById( + "dispatcherAssignedContracts", + ); + const assignedContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") !== "unassigned", + ); + + if (!assignedContracts.length) { + container.innerHTML = + '

No assigned contracts.

'; + return; + } + + container.innerHTML = assignedContracts + .map((task) => { + const taskId = task.taskId || task.taskID || ""; + const assignedGroup = this.groups.find( + (group) => group.groupId === (task.assignedGroupId || ""), + ); + + return ` +
+
+ ${task.title || taskId} + ${task.assignmentState || "assigned"} +
+

${task.description || ""}

+
+ Group: ${assignedGroup ? assignedGroup.callsign : task.assignedGroupId || "Unknown"} + Type: ${task.type || "task"} +
+
+ `; + }) + .join(""); + }, + renderGroups() { + const container = document.getElementById("dispatcherGroups"); + if (!this.groups.length) { + container.innerHTML = + '

No active groups available.

'; + return; + } + + container.innerHTML = this.groups + .map((group) => { + return ` +
+
+
+ ${group.callsign || group.groupId} + ${group.role || "group"} +
+
+ ${this.buildGroupEditorButton(group.groupId)} +
+
+
+ Leader: ${group.leaderName || "Unknown"} + Status: ${group.status || "unknown"} +
+
+ Org: ${group.orgId || "default"} + Task: ${group.currentTaskId || "None"} +
+
+ `; + }) + .join(""); + }, + renderActivity() { + const container = document.getElementById("dispatcherActivity"); + if (!this.activity.length) { + container.innerHTML = + '

No recent activity.

'; + return; + } + + container.innerHTML = this.activity + .slice() + .reverse() + .slice(0, 12) + .map( + (entry) => ` +
+
+ ${entry.type || "activity"} + ${Math.round(entry.timestamp || 0)}s +
+

${entry.message || ""}

+
+ `, + ) + .join(""); + }, + render() { + this.renderMetrics(); + this.renderOpenContracts(); + this.renderAssignedContracts(); + this.renderGroups(); + this.renderActivity(); + }, +}; + +window.cadDispatcher.init(); diff --git a/arma/client/addons/cad/ui/src/sidepanel.html b/arma/client/addons/cad/ui/src/sidepanel.html index 6457059..9fa2c33 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.html +++ b/arma/client/addons/cad/ui/src/sidepanel.html @@ -22,12 +22,12 @@ Contracts
-
-
Groups
-
+
+
Roster
+
-

Loading groups...

+

Loading roster...

diff --git a/arma/client/addons/cad/ui/src/sidepanel.js b/arma/client/addons/cad/ui/src/sidepanel.js index 950327d..48fc442 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.js +++ b/arma/client/addons/cad/ui/src/sidepanel.js @@ -3,6 +3,7 @@ window.cadTasks = { groups: [], activity: [], session: {}, + mode: "operations", activeTab: "contracts", statuses: [ "available", @@ -13,6 +14,7 @@ window.cadTasks = { "refit", "offline", ], + roles: ["infantry", "recon", "armor", "air", "logistics", "support"], init() { const refreshBtn = document.getElementById("refreshCadBtn"); if (refreshBtn) { @@ -66,6 +68,10 @@ window.cadTasks = { payload.session && typeof payload.session === "object" ? payload.session : {}; + this.mode = + payload && typeof payload.mode === "string" + ? payload.mode + : "operations"; const statusEl = document.getElementById("cadStatusMessage"); if ( @@ -97,23 +103,6 @@ window.cadTasks = { this.setStatus("Refreshing board...", "info"); window.mapUI.sendEvent("cad::refresh", {}); }, - assignTask(taskID) { - const selector = document.getElementById(`assign-group-${taskID}`); - if (!selector || !selector.value) { - this.setStatus( - "Select a group before assigning a contract.", - "error", - ); - return; - } - - this.setStatus("Submitting assignment...", "info"); - window.mapUI.sendEvent("cad::tasks::assign", { - taskID: taskID, - groupID: selector.value, - note: "", - }); - }, acknowledgeTask(taskID) { this.setStatus("Acknowledging contract...", "info"); window.mapUI.sendEvent("cad::tasks::acknowledge", { taskID: taskID }); @@ -129,12 +118,40 @@ window.cadTasks = { status: status, }); }, + updateGroupRole(groupID, role) { + this.setStatus("Updating group role...", "info"); + window.mapUI.sendEvent("cad::groups::role", { + groupID: groupID, + role: role, + }); + }, getPlayerGroupId() { return this.session.groupId || ""; }, + getCurrentGroup() { + const currentGroupId = this.getPlayerGroupId(); + return ( + this.groups.find((group) => group.groupId === currentGroupId) || + null + ); + }, + normalizeCollection(value) { + if (Array.isArray(value)) { + return value; + } + + if (value && typeof value === "object") { + return Object.values(value); + } + + return []; + }, canDispatch() { return !!this.session.isDispatcher; }, + isDispatchMode() { + return this.mode === "dispatch"; + }, isLeader() { return !!this.session.isLeader; }, @@ -144,14 +161,18 @@ window.cadTasks = { return; } - if (!this.contracts.length) { + const currentGroupId = this.getPlayerGroupId(); + const visibleContracts = this.contracts.filter( + (task) => (task.assignedGroupId || "") === currentGroupId, + ); + + if (!visibleContracts.length) { listEl.innerHTML = - '

No active contracts are available.

'; + '

No contract is currently assigned to your group.

'; return; } - const currentGroupId = this.getPlayerGroupId(); - listEl.innerHTML = this.contracts + listEl.innerHTML = visibleContracts .map((task) => { const taskId = task.taskId || task.taskID || ""; const position = Array.isArray(task.position) @@ -164,12 +185,6 @@ window.cadTasks = { ); const isAssignedToLeader = this.isLeader() && assignedGroupId === currentGroupId; - const groupOptions = this.groups - .map( - (group) => - ``, - ) - .join(""); return `
@@ -182,17 +197,6 @@ window.cadTasks = { ${assignmentState === "unassigned" ? "Available" : `${assignmentState}: ${assignedGroup ? assignedGroup.callsign : assignedGroupId}`} X: ${Math.round(position[0] || 0)} Y: ${Math.round(position[1] || 0)}
- ${ - this.canDispatch() - ? `
- - -
` - : "" - } ${ isAssignedToLeader && assignmentState === "assigned" ? `
@@ -206,59 +210,66 @@ window.cadTasks = { }) .join(""); }, - renderGroups() { - const listEl = document.getElementById("groupList"); + renderRoster() { + const listEl = document.getElementById("rosterList"); if (!listEl) { return; } - if (!this.groups.length) { + const currentGroup = this.getCurrentGroup(); + if (!currentGroup) { listEl.innerHTML = - '

No active groups are available.

'; + '

Your group is not currently available.

'; return; } - const currentGroupId = this.getPlayerGroupId(); - listEl.innerHTML = this.groups - .map((group) => { - const canUpdate = - this.canDispatch() || - (this.isLeader() && group.groupId === currentGroupId); - const statusOptions = this.statuses - .map( - (status) => - ``, - ) - .join(""); + const roster = this.normalizeCollection(currentGroup.members); - return ` -
+ if (!roster.length) { + listEl.innerHTML = + '

No roster members are currently available.

'; + return; + } + + listEl.innerHTML = ` +
+
+ ${currentGroup.callsign || currentGroup.groupId || "Current Group"} + ${roster.length} member${roster.length === 1 ? "" : "s"} +
+
+ Leader: ${currentGroup.leaderName || "Unknown"} + Status: ${currentGroup.status || "unknown"} +
+
+ Role: ${currentGroup.role || "unassigned"} + Task: ${currentGroup.currentTaskId || "None"} +
+
+ ${roster + .map((member) => { + const lifeState = ( + member.lifeState || "unknown" + ).replaceAll("_", " "); + const leaderBadge = member.isLeader + ? 'Leader' + : ""; + + return ` +
- ${group.callsign || group.groupId} - ${group.role || "group"} + ${member.name || "Unknown Operator"} + ${lifeState}
- Leader: ${group.leaderName || "Unknown"} - Status: ${group.status || "unknown"} + ${member.uid || "No UID"} + ${leaderBadge}
-
- Org: ${group.orgId || "default"} - Task: ${group.currentTaskId || "None"} -
- ${ - canUpdate - ? `
- - -
` - : "" - }
`; - }) - .join(""); + }) + .join("")} + `; }, renderActivity() { const listEl = document.getElementById("activityList"); @@ -291,7 +302,7 @@ window.cadTasks = { }, render() { this.renderContracts(); - this.renderGroups(); + this.renderRoster(); this.renderActivity(); this.setActiveTab(this.activeTab); }, diff --git a/arma/client/addons/cad/ui/src/styles/bottombar.css b/arma/client/addons/cad/ui/src/styles/bottombar.css index 99dcfd2..b9468e2 100644 --- a/arma/client/addons/cad/ui/src/styles/bottombar.css +++ b/arma/client/addons/cad/ui/src/styles/bottombar.css @@ -21,13 +21,20 @@ body { overflow: hidden; } -span { +.footer-brand, +.footer-version { color: rgba(245, 248, 255, 0.8); font-size: 12px; text-shadow: 0 1px 10px rgba(0, 0, 0, 0.28); } -#statusText { +.footer-brand { color: var(--accent); font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.footer-version { + color: rgba(245, 248, 255, 0.62); } diff --git a/arma/client/addons/cad/ui/src/styles/dispatcher.css b/arma/client/addons/cad/ui/src/styles/dispatcher.css new file mode 100644 index 0000000..5b50549 --- /dev/null +++ b/arma/client/addons/cad/ui/src/styles/dispatcher.css @@ -0,0 +1,339 @@ +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: + radial-gradient( + circle at top left, + rgba(41, 69, 93, 0.18), + transparent 30% + ), + linear-gradient(180deg, rgba(9, 14, 20, 0.96), rgba(15, 22, 31, 0.98)); +} + +body { + color: var(--text); + font-family: var(--font); +} + +.dispatch-shell { + height: 100%; + display: flex; + flex-direction: column; + padding: 18px; + gap: 14px; +} + +.dispatch-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.dispatch-kicker { + margin: 0 0 4px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.1em; + font-size: 11px; + font-weight: 700; +} + +.dispatch-header h2 { + margin: 0; + font-size: 24px; + font-weight: 650; +} + +.dispatch-header button, +.dispatch-btn, +.dispatch-select { + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(24, 31, 40, 0.9); + color: var(--text); +} + +.dispatch-header button, +.dispatch-btn { + padding: 10px 14px; + cursor: pointer; +} + +.dispatch-btn-secondary { + background: rgba(53, 40, 39, 0.92); +} + +.dispatch-status { + min-height: 20px; + font-size: 13px; + color: rgba(233, 241, 248, 0.78); +} + +.dispatch-status[data-type="success"] { + color: #79d28a; +} + +.dispatch-status[data-type="error"] { + color: #ff8a80; +} + +.dispatch-metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.metric-card { + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(13, 19, 26, 0.72); +} + +.metric-label { + display: block; + margin-bottom: 8px; + color: rgba(233, 241, 248, 0.6); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; +} + +.metric-card strong { + font-size: 28px; + font-weight: 700; +} + +.dispatch-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: 14px; + min-height: 0; +} + +.dispatch-panel { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(11, 17, 24, 0.78); + min-width: 0; +} + +.dispatch-panel-open { + grid-column: span 5; +} + +.dispatch-panel-assigned { + grid-column: span 7; +} + +.dispatch-panel-groups { + grid-column: span 8; +} + +.dispatch-panel-activity { + grid-column: span 4; +} + +.dispatch-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.dispatch-panel-header h3 { + margin: 0; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); +} + +.dispatch-list { + flex: 1; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; +} + +.dispatch-card { + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(19, 26, 34, 0.72); +} + +.dispatch-card-header, +.dispatch-meta { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.dispatch-card-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.dispatch-card-header-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.dispatch-card-header { + margin-bottom: 8px; +} + +.dispatch-description { + margin: 0 0 10px; + line-height: 1.45; + color: rgba(241, 246, 251, 0.82); + font-size: 13px; +} + +.dispatch-meta { + margin-bottom: 10px; + font-size: 12px; + color: rgba(229, 237, 244, 0.7); +} + +.dispatch-badge { + padding: 3px 7px; + border: 1px solid rgba(91, 187, 255, 0.18); + background: rgba(16, 43, 61, 0.7); + color: var(--accent); + font-size: 11px; + text-transform: uppercase; +} + +.dispatch-icon-btn { + width: 32px; + height: 32px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(24, 31, 40, 0.92); + color: var(--text); + cursor: pointer; +} + +.dispatch-icon-btn:hover { + background: rgba(32, 42, 52, 0.96); +} + +.dispatch-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dispatch-actions-split { + margin-top: 10px; +} + +.dispatch-select { + width: 100%; + padding: 9px 10px; +} + +.placeholder-message { + padding: 18px; + text-align: center; + color: rgba(233, 241, 248, 0.6); +} + +.dispatch-modal { + position: fixed; + inset: 0; + z-index: 30; +} + +.dispatch-modal.is-hidden { + display: none; +} + +.dispatch-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(4, 8, 12, 0.72); +} + +.dispatch-modal-dialog { + position: relative; + width: min(480px, calc(100% - 48px)); + margin: 72px auto 0; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(11, 17, 24, 0.98); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.42); +} + +.dispatch-modal-header, +.dispatch-modal-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; +} + +.dispatch-modal-header { + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.dispatch-modal-header h3 { + margin: 0; + font-size: 22px; + font-weight: 650; +} + +.dispatch-modal-body { + padding: 16px; +} + +.dispatch-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.dispatch-meta-grid strong { + display: block; + margin-top: 4px; + font-size: 14px; + font-weight: 600; +} + +.dispatch-modal-fields { + display: grid; + gap: 12px; +} + +.dispatch-field { + display: grid; + gap: 6px; +} + +.dispatch-field span { + font-size: 12px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(233, 241, 248, 0.7); +} + +.dispatch-modal-actions { + justify-content: flex-end; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} diff --git a/arma/client/addons/cad/ui/src/styles/sidepanel.css b/arma/client/addons/cad/ui/src/styles/sidepanel.css index 5224e84..5c3ce15 100644 --- a/arma/client/addons/cad/ui/src/styles/sidepanel.css +++ b/arma/client/addons/cad/ui/src/styles/sidepanel.css @@ -208,3 +208,26 @@ body { .task-secondary-btn { background: rgba(60, 48, 45, 0.92); } + +.roster-summary-card { + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(16, 23, 29, 0.82); +} + +.roster-member-card { + background: rgba(12, 16, 20, 0.74); +} + +.roster-leader-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border: 1px solid rgba(91, 187, 255, 0.28); + background: rgba(15, 40, 58, 0.82); + color: var(--accent); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} diff --git a/arma/client/addons/cad/ui/src/styles/topbar.css b/arma/client/addons/cad/ui/src/styles/topbar.css index 3649d05..e361389 100644 --- a/arma/client/addons/cad/ui/src/styles/topbar.css +++ b/arma/client/addons/cad/ui/src/styles/topbar.css @@ -3,65 +3,237 @@ body { top: 0; left: 0; right: 0; - height: 56px; - display: flex; + height: 60px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; align-items: center; - justify-content: space-between; - padding: 0 20px; + column-gap: 16px; + padding: 0 16px; + background: transparent; + overflow: visible; +} + +body::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 60px; background: linear-gradient( 90deg, rgba(16, 22, 31, 0.96), rgba(19, 26, 36, 0.94) 55%, rgba(15, 20, 28, 0.96) ); - border-bottom: 1px solid rgba(255, 255, 255, 0.14); - box-shadow: 0 14px 28px rgba(0, 0, 0, 0.28); + border-bottom: none; + box-shadow: none; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); - overflow: hidden; + z-index: 0; + pointer-events: none; +} + +body > * { + position: relative; + z-index: 1; } .logo { color: var(--accent); - font-size: 16px; + font-size: 15px; font-weight: 650; text-transform: uppercase; - letter-spacing: 0.4px; + letter-spacing: 0.08em; text-shadow: 0 1px 12px rgba(0, 0, 0, 0.35); } +.header-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.title-block { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 0 0 auto; +} + +.title-kicker { + color: rgba(218, 227, 236, 0.56); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.title-main { + color: rgba(245, 248, 255, 0.92); + font-size: 15px; + font-weight: 600; +} + +.operator-strip { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.operator-strip.is-hidden, +.operator-controls.is-hidden { + display: none; +} + +.operator-info { + display: flex; + flex-direction: column; + min-width: 88px; + gap: 0; +} + +.operator-label { + color: rgba(218, 227, 236, 0.5); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +.operator-info strong { + color: rgba(245, 248, 255, 0.9); + font-size: 12px; + font-weight: 550; +} + +.operator-controls { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.operator-select { + min-width: 92px; + max-width: 112px; + padding: 5px 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(14, 20, 28, 0.96); + color: var(--text); + font-size: 11px; +} + +.btn-operator { + min-width: 84px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.mode-controls { + display: flex; + gap: 8px; + align-items: center; + justify-self: end; +} + +.mode-controls.is-hidden { + display: none; +} + .controls { display: flex; - gap: 10px; + gap: 8px; + align-items: center; + justify-self: end; +} + +.mode-text { + color: rgba(233, 241, 248, 0.72); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.mode-switch { + position: relative; + width: 54px; + height: 28px; + display: inline-flex; align-items: center; } -.search-input { - background: rgba(255, 255, 255, 0.08); +.mode-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.mode-slider { + position: relative; + width: 54px; + height: 28px; border: 1px solid rgba(255, 255, 255, 0.14); - color: var(--text); - padding: 10px 12px; border-radius: 999px; - width: 250px; - outline: none; - font-size: 13px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); + background: rgba(22, 29, 39, 0.92); + box-shadow: inset 0 1px 10px rgba(0, 0, 0, 0.22); + transition: + border-color 0.16s ease, + background 0.16s ease; } -.search-input::placeholder { - color: var(--muted2); +.mode-slider::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + border-radius: 50%; + background: linear-gradient( + 180deg, + rgba(237, 244, 251, 0.98), + rgba(189, 205, 221, 0.92) + ); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.26); + transition: + transform 0.16s ease, + background 0.16s ease; } -.search-input:focus { - border-color: rgba(104, 196, 255, 0.45); - background: rgba(255, 255, 255, 0.11); +.mode-switch input:checked + .mode-slider { + border-color: rgba(91, 187, 255, 0.42); + background: rgba(14, 37, 56, 0.95); } -.info { - display: flex; - gap: 20px; - color: rgba(245, 248, 255, 0.84); - font-size: 12px; - font-family: var(--font); - text-shadow: 0 1px 10px rgba(0, 0, 0, 0.28); +.mode-switch input:checked + .mode-slider::after { + transform: translateX(26px); + background: linear-gradient( + 180deg, + rgba(131, 212, 255, 0.98), + rgba(72, 170, 231, 0.94) + ); +} + +.btn-close { + min-width: 42px; +} + +body[data-mode="operations"] { + pointer-events: none; +} + +body[data-mode="operations"] .logo, +body[data-mode="operations"] .title-block, +body[data-mode="operations"] .operator-strip, +body[data-mode="operations"] .operator-controls, +body[data-mode="operations"] .mode-controls, +body[data-mode="operations"] .controls, +body[data-mode="operations"] .mode-switch, +body[data-mode="operations"] .mode-switch *, +body[data-mode="operations"] button, +body[data-mode="operations"] select, +body[data-mode="operations"] label { + pointer-events: auto; } diff --git a/arma/client/addons/cad/ui/src/topbar.html b/arma/client/addons/cad/ui/src/topbar.html index 83f8ffa..0e0db35 100644 --- a/arma/client/addons/cad/ui/src/topbar.html +++ b/arma/client/addons/cad/ui/src/topbar.html @@ -5,20 +5,65 @@ -
- - - - +
+
+ Cad Systems + FORGE Command & Dispatch +
+
-
- X: 0000 Y: 0000 - Scale: 1:1000 + +
+
\ No newline at end of file +

Dispatch Dashboard

Operational Board

Open Contracts 0
Assigned Contracts 0
Active Groups 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Activity Feed

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/sidepanel.html b/arma/client/addons/cad/ui/_site/sidepanel.html index 7917e62..aa80c55 100644 --- a/arma/client/addons/cad/ui/_site/sidepanel.html +++ b/arma/client/addons/cad/ui/_site/sidepanel.html @@ -1 +1 @@ -

CAD System

Contracts

Loading contracts...

Roster

Loading roster...

Activity

No recent activity.

\ No newline at end of file +

CAD System

Contracts

Loading contracts...

Roster

Loading roster...

Activity

No recent activity.

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/topbar.html b/arma/client/addons/cad/ui/_site/topbar.html index 4e31fcb..d4f30c5 100644 --- a/arma/client/addons/cad/ui/_site/topbar.html +++ b/arma/client/addons/cad/ui/_site/topbar.html @@ -1 +1 @@ -
Cad Systems FORGE Command & Dispatch
\ No newline at end of file +
Cad Systems FORGE Command & Dispatch
\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/dispatcher.html b/arma/client/addons/cad/ui/src/dispatcher.html index 43def30..207ed7a 100644 --- a/arma/client/addons/cad/ui/src/dispatcher.html +++ b/arma/client/addons/cad/ui/src/dispatcher.html @@ -10,9 +10,6 @@

Dispatch Dashboard

Operational Board

-
diff --git a/arma/client/addons/cad/ui/src/dispatcher.js b/arma/client/addons/cad/ui/src/dispatcher.js index b29b427..8d298ba 100644 --- a/arma/client/addons/cad/ui/src/dispatcher.js +++ b/arma/client/addons/cad/ui/src/dispatcher.js @@ -15,13 +15,6 @@ window.cadDispatcher = { ], roles: ["infantry", "recon", "armor", "air", "logistics", "support"], init() { - document - .getElementById("dispatcherRefreshBtn") - .addEventListener("click", () => { - this.setStatus("Refreshing board...", "info"); - window.mapUI.sendEvent("cad::refresh", {}); - }); - document .getElementById("dispatcherGroupModalCloseBtn") .addEventListener("click", () => { diff --git a/arma/client/addons/cad/ui/src/sidepanel.html b/arma/client/addons/cad/ui/src/sidepanel.html index 9fa2c33..e6f8d4c 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.html +++ b/arma/client/addons/cad/ui/src/sidepanel.html @@ -8,9 +8,6 @@

CAD System

-
- -
+
- + +
\ No newline at end of file +
Cad Systems FORGE Command & Dispatch
\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/dispatcher.js b/arma/client/addons/cad/ui/src/dispatcher.js index 8d298ba..eb818f3 100644 --- a/arma/client/addons/cad/ui/src/dispatcher.js +++ b/arma/client/addons/cad/ui/src/dispatcher.js @@ -10,8 +10,7 @@ window.cadDispatcher = { "on_task", "holding", "danger", - "refit", - "offline", + "unavailable", ], roles: ["infantry", "recon", "armor", "air", "logistics", "support"], init() { diff --git a/arma/client/addons/cad/ui/src/sidepanel.js b/arma/client/addons/cad/ui/src/sidepanel.js index 7f8374d..ded4ca0 100644 --- a/arma/client/addons/cad/ui/src/sidepanel.js +++ b/arma/client/addons/cad/ui/src/sidepanel.js @@ -12,8 +12,7 @@ window.cadTasks = { "on_task", "holding", "danger", - "refit", - "offline", + "unavailable", ], roles: ["infantry", "recon", "armor", "air", "logistics", "support"], init() { diff --git a/arma/client/addons/cad/ui/src/topbar.html b/arma/client/addons/cad/ui/src/topbar.html index 3233ffc..8736261 100644 --- a/arma/client/addons/cad/ui/src/topbar.html +++ b/arma/client/addons/cad/ui/src/topbar.html @@ -41,8 +41,7 @@ - - + \n `,renderMetrics(){const t=this.contracts.filter(t=>"unassigned"!==(t.assignmentState||"unassigned")),e=this.contracts.filter(t=>"unassigned"===(t.assignmentState||"unassigned")),s=this.groups.filter(t=>"danger"===(t.status||""));document.getElementById("metricOpenContracts").textContent=e.length,document.getElementById("metricAssignedContracts").textContent=t.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricDangerGroups").textContent=s.length},renderOpenContracts(){const t=document.getElementById("dispatcherOpenContracts"),e=this.contracts.filter(t=>"unassigned"===(t.assignmentState||"unassigned"));if(!e.length)return void(t.innerHTML='

No open contracts.

');const s=this.groups.map(t=>``).join("");t.innerHTML=e.map(t=>{const e=t.taskId||t.taskID||"",n=Array.isArray(t.position)?t.position:[0,0,0];return`\n
\n
\n ${t.title||e}\n ${t.type||"task"}\n
\n

${t.description||""}

\n
\n Unassigned\n X: ${Math.round(n[0]||0)} Y: ${Math.round(n[1]||0)}\n
\n
\n \n \n
\n
\n `}).join("")},renderAssignedContracts(){const t=document.getElementById("dispatcherAssignedContracts"),e=this.contracts.filter(t=>"unassigned"!==(t.assignmentState||"unassigned"));e.length?t.innerHTML=e.map(t=>{const e=t.taskId||t.taskID||"",s=this.groups.find(e=>e.groupId===(t.assignedGroupId||""));return`\n
\n
\n ${t.title||e}\n ${t.assignmentState||"assigned"}\n
\n

${t.description||""}

\n
\n Group: ${s?s.callsign:t.assignedGroupId||"Unknown"}\n Type: ${t.type||"task"}\n
\n
\n `}).join(""):t.innerHTML='

No assigned contracts.

'},renderGroups(){const t=document.getElementById("dispatcherGroups");this.groups.length?t.innerHTML=this.groups.map(t=>`\n
\n
\n
\n ${t.callsign||t.groupId}\n ${t.role||"group"}\n
\n
\n ${this.buildGroupEditorButton(t.groupId)}\n
\n
\n
\n Leader: ${t.leaderName||"Unknown"}\n Status: ${t.status||"unknown"}\n
\n
\n Org: ${t.orgId||"default"}\n Task: ${t.currentTaskId||"None"}\n
\n
\n `).join(""):t.innerHTML='

No active groups available.

'},renderActivity(){const t=document.getElementById("dispatcherActivity");this.activity.length?t.innerHTML=this.activity.slice().reverse().slice(0,12).map(t=>`\n
\n
\n ${t.type||"activity"}\n ${Math.round(t.timestamp||0)}s\n
\n

${t.message||""}

\n
\n `).join(""):t.innerHTML='

No recent activity.

'},render(){this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}},window.cadDispatcher.init(); \ No newline at end of file +window.cadDispatcher={contracts:[],requests:[],groups:[],activity:[],session:{},editingGroupId:"",viewingRequestId:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],init(){document.getElementById("dispatcherCreateOrderBtn").addEventListener("click",()=>{this.openOrderModal()}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherOrderModalCloseBtn").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherOrderModalSaveBtn").addEventListener("click",()=>{this.createDispatchOrder()}),document.querySelector("#dispatcherOrderModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestModalDoneBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.querySelector("#dispatcherRequestModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{};const t=document.getElementById("dispatcherStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.syncOrderModal(),this.syncRequestModal(),this.render()},setStatus(e,t){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,n="danger"===(t.status||"")?0:1;if(s!==n)return s-n;const r=e.callsign||e.groupId||"",a=t.callsign||t.groupId||"";return r.localeCompare(a)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestTypeLabel(e){switch(e){case"medevac_9line":return"9-Line MEDEVAC";case"ace_lace":return"ACE/LACE";case"fire_support":return"Fire Support";case"air_support":return"Air Support";case"logreq":return"LOGREQ";default:return(e||"request").replaceAll("_"," ")}},buildGroupOptions(e){return this.getSortedGroups().map(t=>{const s=t.groupId||"";return``}).join("")},updateDangerAlert(){const e=document.getElementById("dispatcherDangerAlert");if(!e)return;const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("dispatcherRequestAlert");if(!e)return;const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},openOrderModal(){this.populateOrderModal(),document.getElementById("dispatcherOrderModal").classList.remove("is-hidden")},closeOrderModal(){document.getElementById("dispatcherOrderNoteInput").value="",document.getElementById("dispatcherOrderPrioritySelect").value="priority",document.getElementById("dispatcherOrderModal").classList.add("is-hidden")},openRequestModal(e){const t=this.requests.find(t=>t.requestId===e);t&&(this.viewingRequestId=e,this.populateRequestModal(t),document.getElementById("dispatcherRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.viewingRequestId="",document.getElementById("dispatcherRequestModal").classList.add("is-hidden")},syncRequestModal(){if(!this.viewingRequestId)return;const e=this.requests.find(e=>e.requestId===this.viewingRequestId);e?this.populateRequestModal(e):this.closeRequestModal()},formatRequestFieldLabel:e=>(e||"field").replaceAll("_"," ").replace(/\b\w/g,e=>e.toUpperCase()),formatRequestFieldValue(e){if(Array.isArray(e))return e.join(", ");if(e&&"object"==typeof e)return JSON.stringify(e);return String(e??"").trim()||"Not provided"},populateRequestModal(e){const t=e.fields&&"object"==typeof e.fields?Object.entries(e.fields):[],s=t.length?t.map(([e,t])=>`\n
\n ${this.formatRequestFieldLabel(e)}\n ${this.formatRequestFieldValue(t)}\n
\n `).join(""):'

No submitted fields.

';document.getElementById("dispatcherRequestTitle").textContent=e.title||e.requestId||"Support Request",document.getElementById("dispatcherRequestPriority").textContent=(e.priority||"priority").replaceAll("_"," "),document.getElementById("dispatcherRequestGroup").textContent=e.groupCallsign||e.groupId||"Unknown",document.getElementById("dispatcherRequestType").textContent=this.getRequestTypeLabel(e.type||"request"),document.getElementById("dispatcherRequestSummary").textContent=e.summary||"No summary provided.",document.getElementById("dispatcherRequestFields").innerHTML=s},populateOrderModal(e,t){const s=this.getSortedGroups(),n=document.getElementById("dispatcherOrderAssigneeSelect"),r=document.getElementById("dispatcherOrderTargetSelect");if(!n||!r)return;const a=e||s[0]?.groupId||"",i=t||s.find(e=>(e.groupId||"")!==a)?.groupId||s[0]?.groupId||"";n.innerHTML=this.buildGroupOptions(a),r.innerHTML=this.buildGroupOptions(i)},syncOrderModal(){const e=document.getElementById("dispatcherOrderModal");e&&!e.classList.contains("is-hidden")&&this.populateOrderModal(document.getElementById("dispatcherOrderAssigneeSelect")?.value||"",document.getElementById("dispatcherOrderTargetSelect")?.value||"")},createDispatchOrder(){const e=document.getElementById("dispatcherOrderAssigneeSelect").value,t=document.getElementById("dispatcherOrderTargetSelect").value,s=document.getElementById("dispatcherOrderPrioritySelect").value,n=document.getElementById("dispatcherOrderNoteInput").value;e&&t?e!==t?(this.setStatus("Creating dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::create",{assigneeGroupID:e,targetGroupID:t,note:n.trim(),priority:s}),this.closeOrderModal()):this.setStatus("Assignee and target groups must be different.","error"):this.setStatus("Select both an assignee and a target group.","error")},assignTask(e){const t=document.getElementById(`dispatcher-assign-group-${e}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:e,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},openGroupModal(e){const t=this.groups.find(t=>t.groupId===e);t&&(this.editingGroupId=e,document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(e=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>``).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);e?(document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default"):this.closeGroupModal()},applyGroupUpdates(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);if(!e)return void this.closeGroupModal();const t=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value,n=t&&t!==(e.role||"")?t:"",r=s&&s!==(e.status||"")?s:"";if(!(n||r))return this.setStatus("No group changes to save.","info"),void this.closeGroupModal();this.setStatus("Updating group profile...","info"),window.mapUI.sendEvent("cad::groups::profile",{groupID:this.editingGroupId,role:n,status:r}),this.closeGroupModal()},closeDispatchOrder(e){e&&(this.setStatus("Closing dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::close",{taskID:e}))},buildGroupEditorButton:e=>`\n \n ⚙\n \n `,buildCloseOrderButton:e=>`\n \n Close\n \n `,buildCloseRequestButton:e=>`\n \n Close\n \n `,closeSupportRequest(e){e&&(this.setStatus("Closing support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))},renderMetrics(){const e=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned")),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned")),s=this.requests.length,n=this.getSupportAlertRequests(),r=this.groups.filter(e=>"danger"===(e.status||""));document.getElementById("metricOpenContracts").textContent=t.length,document.getElementById("metricAssignedContracts").textContent=e.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricOpenRequests").textContent=s,document.getElementById("metricDangerGroups").textContent=r.length;const a=document.getElementById("metricDangerGroupsCard");a&&a.classList.toggle("is-danger",r.length>0);const i=document.getElementById("metricOpenRequestsCard");i&&i.classList.toggle("is-warning",n.length>0)},renderOpenContracts(){const e=document.getElementById("dispatcherOpenContracts"),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned"));if(!t.length)return void(e.innerHTML='

No open contracts.

');const s=this.buildGroupOptions("");e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",n=Array.isArray(e.position)?e.position:[0,0,0],r=this.groups.find(t=>t.groupId===(e.targetGroupId||""));return`\n
\n
\n ${e.title||t}\n ${this.formatTypeLabel(e)}\n
\n

${e.description||""}

\n
\n Unassigned\n ${window.mapUI.formatPosition(n)}\n
\n
\n Target: ${r?r.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n
\n \n \n
\n
\n `}).join("")},renderAssignedContracts(){const e=document.getElementById("dispatcherAssignedContracts"),t=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned"));t.length?e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=this.groups.find(t=>t.groupId===(e.assignedGroupId||"")),n=this.groups.find(t=>t.groupId===(e.targetGroupId||"")),r=this.isDispatchOrder(e);return`\n
\n
\n ${e.title||t}\n ${e.assignmentState||"assigned"}\n
\n

${e.description||""}

\n
\n Group: ${s?s.callsign:e.assignedGroupId||"Unknown"}\n Type: ${this.formatTypeLabel(e)}\n
\n
\n Target: ${n?n.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n ${r?`
${this.buildCloseOrderButton(t)}
`:""}\n
\n `}).join(""):e.innerHTML='

No assigned contracts.

'},renderGroups(){const e=document.getElementById("dispatcherGroups");this.groups.length?e.innerHTML=this.getSortedGroups().map(e=>{const t="danger"===(e.status||"");return`\n
\n
\n
\n ${e.callsign||e.groupId}\n ${e.role||"group"}\n ${t?'Danger':""}\n
\n
\n ${this.buildGroupEditorButton(e.groupId)}\n
\n
\n
\n Leader: ${e.leaderName||"Unknown"}\n Status: ${e.status||"unknown"}\n
\n
\n Org: ${e.orgId||"default"}\n Task: ${e.currentTaskId||"None"}\n
\n
\n `}).join(""):e.innerHTML='

No active groups available.

'},renderActivity(){const e=document.getElementById("dispatcherActivity"),t=this.requests.length?this.requests.map(e=>`\n
\n
\n ${e.title||e.requestId||"Support Request"}\n ${(e.priority||"priority").replaceAll("_"," ")}\n
\n

${e.summary||""}

\n
\n Group: ${e.groupCallsign||e.groupId||"Unknown"}\n ${this.getRequestTypeLabel(e.type||"request")}\n
\n
\n ${this.buildCloseRequestButton(e.requestId||"")}\n
\n
\n `).join(""):'

No active support requests.

',s=this.activity.length?this.activity.slice().reverse().slice(0,8).map(e=>`\n
\n
\n ${e.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):'

No recent activity.

';e.innerHTML=`\n
\n
Support Requests
\n ${t}\n
\n
\n
Recent Activity
\n ${s}\n
\n `},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}},window.cadDispatcher.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-shared.js b/arma/client/addons/cad/ui/_site/cad-shared.js index 75008e5..7032729 100644 --- a/arma/client/addons/cad/ui/_site/cad-shared.js +++ b/arma/client/addons/cad/ui/_site/cad-shared.js @@ -1 +1 @@ -window.mapUIState={layersPanelVisible:!0,sidePanelElement:null},window.mapUI={sendEvent(e,t){A3API.SendAlert(JSON.stringify({event:e,data:t}))},updateCoordinates(e,t){const n=document.getElementById("coordsDisplay");n&&(n.textContent=`X: ${Math.round(e).toString().padStart(4,"0")} Y: ${Math.round(t).toString().padStart(4,"0")}`)},updateScale(e){const t=document.getElementById("scaleDisplay");t&&(t.textContent=`Scale: 1:${Math.round(e)}`)},updateStatus(e){const t=document.getElementById("statusText");t&&(t.textContent=e)}},window.updateCoordinates=window.mapUI.updateCoordinates,window.updateScale=window.mapUI.updateScale,window.updateStatus=window.mapUI.updateStatus,window.ForgeBridge=window.ForgeBridge||{_handlers:{},on(e,t){this._handlers[e]=this._handlers[e]||[],this._handlers[e].push(t)},ready:e=>(window.mapUI.sendEvent("cad::ready",e||{}),!0),receive(e){if(!e||"object"!=typeof e)return;(this._handlers[e.event]||[]).forEach(t=>t(e.data||{}))},send:(e,t)=>(window.mapUI.sendEvent(e,t||{}),!0),close:e=>(window.mapUI.sendEvent("map::close",e||{}),!0)}; \ No newline at end of file +window.mapUIState={layersPanelVisible:!0,sidePanelElement:null},window.mapUI={formatGridCoordinate:t=>Math.round(Number(t)||0).toString().padStart(4,"0"),formatPosition(t){const e=Array.isArray(t)?t:[0,0,0];return`X: ${this.formatGridCoordinate(e[0])} Y: ${this.formatGridCoordinate(e[1])}`},sendEvent(t,e){A3API.SendAlert(JSON.stringify({event:t,data:e}))},updateCoordinates(t,e){const n=document.getElementById("coordsDisplay");n&&(n.textContent=this.formatPosition([t,e,0]))},updateScale(t){const e=document.getElementById("scaleDisplay");e&&(e.textContent=`Scale: 1:${Math.round(t)}`)},updateStatus(t){const e=document.getElementById("statusText");e&&(e.textContent=t)}},window.updateCoordinates=window.mapUI.updateCoordinates,window.updateScale=window.mapUI.updateScale,window.updateStatus=window.mapUI.updateStatus,window.ForgeBridge=window.ForgeBridge||{_handlers:{},on(t,e){this._handlers[t]=this._handlers[t]||[],this._handlers[t].push(e)},ready:t=>(window.mapUI.sendEvent("cad::ready",t||{}),!0),receive(t){if(!t||"object"!=typeof t)return;(this._handlers[t.event]||[]).forEach(e=>e(t.data||{}))},send:(t,e)=>(window.mapUI.sendEvent(t,e||{}),!0),close:t=>(window.mapUI.sendEvent("map::close",t||{}),!0)}; \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.css b/arma/client/addons/cad/ui/_site/cad-sidepanel.css index d58ae49..7f3b6d0 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.css +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.css @@ -1 +1 @@ -html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.cad-tabs{grid-template-columns:repeat(3,1fr);gap:6px;margin-bottom:12px;display:grid}.cad-tab{color:#f3f6f9c7;text-transform:uppercase;letter-spacing:.08em;cursor:pointer;background:#141b21e0;border:1px solid #ffffff24;padding:8px 10px;font-size:11px}.cad-tab:hover{color:#f3f6f9;background:#1f282ff0}.cad-tab.is-active{color:var(--accent);background:#0f283af5;border-color:#5bbbff6b}.cad-tab-panels{min-height:0}.cad-section{display:none}.cad-section.is-active{display:block}.cad-section-header{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:12px;font-weight:700}.task-accept-btn,.task-secondary-btn,.cad-select{color:#f3f6f9;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-accept-btn,.task-secondary-btn{cursor:pointer}.task-accept-btn:hover,.task-secondary-btn:hover{background:#2e3942f2}.task-accept-btn:disabled,.task-secondary-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.task-list{flex-direction:column;gap:10px;display:flex}.task-action-stack,.task-action-row{flex-direction:column;gap:8px;display:flex}.task-action-row{flex-direction:row}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex}.task-secondary-btn{background:#3c302deb}.roster-summary-card{background:#10171dd1;border:1px solid #ffffff14;padding:10px}.roster-member-card{background:#0c1014bd}.roster-leader-badge{color:var(--accent);letter-spacing:.06em;text-transform:uppercase;background:#0f283ad1;border:1px solid #5bbbff47;align-items:center;padding:2px 8px;font-size:10px;font-weight:700;display:inline-flex} \ No newline at end of file +html,body{background:var(--panel);border-left:1px solid var(--stroke);width:100%;height:100%;box-shadow:var(--shadow);-webkit-backdrop-filter:blur(12px);margin:0;padding:0;overflow:hidden}body{opacity:1;visibility:visible}.panel-header{border-bottom:1px solid var(--stroke);background:linear-gradient(#ffffff0d,#0000);justify-content:space-between;align-items:center;padding:14px;display:flex}.panel-header h3{color:var(--accent);text-transform:uppercase;letter-spacing:.8px;font-size:14px;font-weight:650}.panel-content{height:calc(100% - 56px);padding:14px;overflow:auto}.placeholder-message{text-align:center;padding:20px}.placeholder-message p{color:var(--muted);font-size:13px;font-style:italic}.cad-tabs{grid-template-columns:repeat(4,1fr);gap:5px;margin-bottom:12px;display:grid}.cad-tabs.is-two-col{grid-template-columns:repeat(2,1fr)}.cad-tabs.is-three-col{grid-template-columns:repeat(3,1fr)}.cad-tab{color:#f3f6f9c7;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap;cursor:pointer;background:#141b21e0;border:1px solid #ffffff24;min-width:0;padding:8px 7px;font-size:10px}.cad-tab:hover{color:#f3f6f9;background:#1f282ff0}.cad-tab.is-active{color:var(--accent);background:#0f283af5;border-color:#5bbbff6b}.cad-tab-panels{min-height:0}.cad-section{display:none}.cad-section.is-active{display:block}.cad-section-header{color:var(--accent);text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px;font-size:12px;font-weight:700}.task-accept-btn,.task-secondary-btn,.cad-select{color:#f3f6f9;background:#1e252be6;border:1px solid #fff3;width:100%;padding:8px 10px}.task-accept-btn,.task-secondary-btn{cursor:pointer}.task-accept-btn:hover,.task-secondary-btn:hover{background:#2e3942f2}.task-accept-btn:disabled,.task-secondary-btn:disabled{opacity:.55;cursor:default}.task-status-message{color:#cdd6dd;min-height:18px;margin-bottom:10px;font-size:12px}.task-status-message[data-type=success]{color:#79d28a}.task-status-message[data-type=error]{color:#ff8a80}.cad-modal{z-index:40;position:fixed;inset:0}.cad-modal.is-hidden{display:none}.cad-modal-backdrop{background:#04080cc2;position:absolute;inset:0}.cad-modal-dialog{background:#0b1118fa;border:1px solid #ffffff1f;width:min(480px,100% - 28px);margin:32px auto 0;position:relative;box-shadow:0 24px 64px #0000006b}.cad-modal-header,.cad-modal-actions{justify-content:space-between;align-items:center;gap:12px;padding:12px 14px;display:flex}.cad-modal-header{border-bottom:1px solid #ffffff14}.cad-modal-header h3{margin:4px 0 0;font-size:18px;font-weight:650}.cad-modal-body{max-height:62vh;padding:14px;overflow:auto}.cad-modal-fields{gap:10px;display:grid}.cad-field{gap:6px;display:grid}.cad-field span{text-transform:uppercase;letter-spacing:.08em;color:#e9f1f8b3;font-size:11px;font-weight:700}.cad-input,.cad-textarea{color:#f3f6f9;box-sizing:border-box;width:100%;font:inherit;background:#1e252be6;border:1px solid #fff3;padding:8px 10px}.cad-textarea{resize:vertical;min-height:74px}.cad-icon-btn{width:30px;height:30px;color:var(--text);cursor:pointer;background:#181f28eb;border:1px solid #ffffff24;padding:0}.cad-modal-actions{border-top:1px solid #ffffff14;justify-content:flex-end}.cad-danger-alert{color:#ffd4cf;letter-spacing:.06em;text-transform:uppercase;background:linear-gradient(90deg,#5c1212f0,#801d1dd1);border:1px solid #ff6b6b5c;margin-bottom:10px;padding:8px 10px;font-size:11px;font-weight:700;animation:1.35s ease-in-out infinite cad-danger-pulse}.cad-danger-alert.is-hidden{display:none}.cad-warning-alert{color:#ffe9b2;letter-spacing:.06em;text-transform:uppercase;background:linear-gradient(90deg,#59400cf0,#7d5c12d6);border:1px solid #f6c65466;margin-bottom:10px;padding:8px 10px;font-size:11px;font-weight:700;animation:1.35s ease-in-out infinite cad-warning-pulse}.cad-warning-alert.is-hidden{display:none}.task-list{flex-direction:column;gap:10px;display:flex}.cad-request-actions{gap:8px;display:grid}.cad-request-btn{text-align:left}.task-action-stack,.task-action-row{flex-direction:column;gap:8px;display:flex}.task-action-row{flex-direction:row}.task-card{background:#0c10149e;border:1px solid #ffffff14;padding:10px}.task-card.is-danger,.roster-summary-card.is-danger{background:linear-gradient(#451416c7,#1c1115eb);border-color:#ff6b6b57;animation:1.35s ease-in-out infinite cad-danger-pulse;box-shadow:inset 0 0 0 1px #ff6b6b1a}.task-card-header{justify-content:space-between;gap:8px;margin-bottom:8px;display:flex}.task-type{opacity:.7;text-transform:uppercase;font-size:11px}.task-description{margin:0 0 8px;font-size:12px;line-height:1.4}.task-meta{opacity:.8;justify-content:space-between;gap:8px;margin-bottom:8px;font-size:11px;display:flex}.task-secondary-btn{background:#3c302deb}.roster-summary-card{background:#10171dd1;border:1px solid #ffffff14;padding:10px}.task-alert-badge{color:#ffd8d1;letter-spacing:.08em;text-transform:uppercase;background:#5f1717e0;border:1px solid #ff6b6b70;align-items:center;padding:2px 8px;font-size:10px;font-weight:700;display:inline-flex}.roster-member-card{background:#0c1014bd}.dispatch-map-group-card{text-align:left;-webkit-appearance:none;appearance:none;width:100%;color:var(--text);font:inherit;cursor:pointer;border-radius:0;transition:border-color .12s,background .12s,transform .12s}.dispatch-map-group-card strong{color:var(--text)}.dispatch-map-group-card .task-type{color:var(--accent);opacity:.9}.dispatch-map-group-card .task-meta{color:var(--muted);opacity:1}.dispatch-map-group-card:hover{background:#121d26e6;border-color:#5bbbff42;transform:translate(-2px)}.dispatch-map-group-card.is-selected{background:#0f283aeb;border-color:#5bbbff85;box-shadow:inset 0 0 0 1px #5bbbff2e}.dispatch-map-group-card.is-danger:not(.is-selected){background:linear-gradient(#451416c7,#1c1115eb);border-color:#ff6b6b57}.dispatch-map-group-card.is-danger .task-meta,.roster-summary-card.is-danger .task-meta{color:#ffe8e4d1}.dispatch-map-card{text-align:left;-webkit-appearance:none;appearance:none;width:100%;color:var(--text);font:inherit;cursor:pointer;border-radius:0;transition:border-color .12s,background .12s,transform .12s}.dispatch-map-card strong{color:var(--text)}.dispatch-map-card .task-type{color:var(--accent);opacity:.9}.dispatch-map-card .task-description{color:var(--muted)}.dispatch-map-card .task-meta{color:var(--muted);opacity:1}.dispatch-map-card:hover{background:#121d26e6;border-color:#5bbbff42;transform:translate(-2px)}.dispatch-map-card.is-selected{background:#0f283aeb;border-color:#5bbbff85;box-shadow:inset 0 0 0 1px #5bbbff2e}.dispatch-map-card.is-warning:not(.is-selected){background:linear-gradient(#564011c7,#221b10eb);border-color:#f6c65457}.dispatch-map-card.is-warning .task-meta,.dispatch-map-card.is-warning .task-description{color:#fff3d6d6}.roster-leader-badge{color:var(--accent);letter-spacing:.06em;text-transform:uppercase;background:#0f283ad1;border:1px solid #5bbbff47;align-items:center;padding:2px 8px;font-size:10px;font-weight:700;display:inline-flex}@keyframes cad-danger-pulse{0%,to{box-shadow:inset 0 0 0 1px #ff6b6b14,0 0 #ff6b6b00}50%{box-shadow:inset 0 0 0 1px #ff8d8d38,0 0 14px #ff6b6b24}}@keyframes cad-warning-pulse{0%,to{box-shadow:inset 0 0 0 1px #f6c65414,0 0 #f6c65400}50%{box-shadow:inset 0 0 0 1px #fbd47638,0 0 18px #f6c65429}} \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-sidepanel.js b/arma/client/addons/cad/ui/_site/cad-sidepanel.js index c84d019..abba8a2 100644 --- a/arma/client/addons/cad/ui/_site/cad-sidepanel.js +++ b/arma/client/addons/cad/ui/_site/cad-sidepanel.js @@ -1 +1 @@ -window.cadTasks={contracts:[],groups:[],activity:[],session:{},mode:"operations",dispatchView:"board",activeTab:"contracts",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],init(){document.querySelectorAll(".cad-tab").forEach(s=>{s.addEventListener("click",()=>{this.setActiveTab(s.dataset.tab||"contracts")})}),window.ForgeBridge.on("cad::hydrate",s=>{this.setHydratePayload(s||{})}),window.ForgeBridge.on("cad::assignment::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.on("cad::group::response",s=>{this.handleServerResponse(!!s.success,s.message||"")}),window.ForgeBridge.ready({loaded:!0})},setActiveTab(s){this.activeTab=s||"contracts",document.querySelectorAll(".cad-tab").forEach(s=>{s.classList.toggle("is-active",s.dataset.tab===this.activeTab)}),document.querySelectorAll("[data-panel]").forEach(s=>{s.classList.toggle("is-active",s.dataset.panel===this.activeTab)})},syncLayoutState(){const s=document.querySelector(".cad-tabs"),t=document.getElementById("contractsPanel"),e=document.getElementById("rosterPanel"),a=document.getElementById("activityPanel"),n=e?.querySelector(".cad-section-header");if(this.isDispatchMapMode())return s&&(s.style.display="none"),t&&(t.classList.remove("is-active"),t.style.display="none"),a&&(a.classList.remove("is-active"),a.style.display="none"),e&&(e.classList.add("is-active"),e.style.display="block"),n&&(n.textContent="Active Groups"),void(this.activeTab="roster");s&&(s.style.display=""),t&&(t.style.display=""),a&&(a.style.display=""),e&&(e.style.display=""),n&&(n.textContent="Roster")},setHydratePayload(s){this.contracts=Array.isArray(s.contracts)?s.contracts:[],this.groups=Array.isArray(s.groups)?s.groups:[],this.activity=Array.isArray(s.activity)?s.activity:[],this.session=s.session&&"object"==typeof s.session?s.session:{},this.mode=s&&"string"==typeof s.mode?s.mode:"operations",this.dispatchView=s&&"string"==typeof s.dispatchView?s.dispatchView:"board";const t=document.getElementById("cadStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),"dispatch"===this.mode&&"map"===this.dispatchView&&(this.activeTab="roster"),this.render()},setStatus(s,t){const e=document.getElementById("cadStatusMessage");e&&(e.textContent=s||"",e.dataset.type=t||"")},handleServerResponse(s,t){this.setStatus(t||(s?"CAD update succeeded.":"CAD update failed."),s?"success":"error")},acknowledgeTask(s){this.setStatus("Acknowledging contract...","info"),window.mapUI.sendEvent("cad::tasks::acknowledge",{taskID:s})},declineTask(s){this.setStatus("Declining contract...","info"),window.mapUI.sendEvent("cad::tasks::decline",{taskID:s})},updateGroupStatus(s,t){this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:s,status:t})},updateGroupRole(s,t){this.setStatus("Updating group role...","info"),window.mapUI.sendEvent("cad::groups::role",{groupID:s,role:t})},getPlayerGroupId(){return this.session.groupId||""},getCurrentGroup(){const s=this.getPlayerGroupId();return this.groups.find(t=>t.groupId===s)||null},normalizeCollection:s=>Array.isArray(s)?s:s&&"object"==typeof s?Object.values(s):[],canDispatch(){return!!this.session.isDispatcher},isDispatchMode(){return"dispatch"===this.mode},isDispatchMapMode(){return"dispatch"===this.mode&&"map"===this.dispatchView},isLeader(){return!!this.session.isLeader},renderContracts(){const s=document.getElementById("taskList");if(!s)return;if(this.isDispatchMapMode())return void(s.innerHTML='

Use the dispatch board view to assign and review contracts.

');const t=this.getPlayerGroupId(),e=this.contracts.filter(s=>(s.assignedGroupId||"")===t);e.length?s.innerHTML=e.map(s=>{const e=s.taskId||s.taskID||"",a=Array.isArray(s.position)?s.position:[0,0,0],n=s.assignedGroupId||"",i=s.assignmentState||"unassigned",r=this.groups.find(s=>s.groupId===n),o=this.isLeader()&&n===t;return`\n
\n
\n ${s.title||e}\n ${s.type||"task"}\n
\n

${s.description||""}

\n
\n ${"unassigned"===i?"Available":`${i}: ${r?r.callsign:n}`}\n X: ${Math.round(a[0]||0)} Y: ${Math.round(a[1]||0)}\n
\n ${o&&"assigned"===i?`
\n \n \n
`:""}\n
\n `}).join(""):s.innerHTML='

No contract is currently assigned to your group.

'},renderRoster(){const s=document.getElementById("rosterList");if(!s)return;if(this.isDispatchMapMode())return this.groups.length?void(s.innerHTML=this.groups.map(s=>`\n
\n
\n ${s.callsign||s.groupId||"Unknown Group"}\n ${s.role||"group"}\n
\n
\n Leader: ${s.leaderName||"Unknown"}\n Status: ${s.status||"unknown"}\n
\n
\n Members: ${this.normalizeCollection(s.members).length}\n Task: ${s.currentTaskId||"None"}\n
\n
\n `).join("")):void(s.innerHTML='

No active groups are currently available.

');const t=this.getCurrentGroup();if(!t)return void(s.innerHTML='

Your group is not currently available.

');const e=this.normalizeCollection(t.members);e.length?s.innerHTML=`\n
\n
\n ${t.callsign||t.groupId||"Current Group"}\n ${e.length} member${1===e.length?"":"s"}\n
\n
\n Leader: ${t.leaderName||"Unknown"}\n Status: ${t.status||"unknown"}\n
\n
\n Role: ${t.role||"unassigned"}\n Task: ${t.currentTaskId||"None"}\n
\n
\n ${e.map(s=>{const t=(s.lifeState||"unknown").replaceAll("_"," "),e=s.isLeader?'Leader':"";return`\n
\n
\n ${s.name||"Unknown Operator"}\n ${t}\n
\n
\n ${s.uid||"No UID"}\n ${e}\n
\n
\n `}).join("")}\n `:s.innerHTML='

No roster members are currently available.

'},renderActivity(){const s=document.getElementById("activityList");s&&(this.activity.length?s.innerHTML=this.activity.slice().reverse().slice(0,8).map(s=>`\n
\n
\n ${s.type||"activity"}\n ${Math.round(s.timestamp||0)}s\n
\n

${s.message||""}

\n
\n `).join(""):s.innerHTML='

No recent activity.

')},render(){this.syncLayoutState(),this.renderContracts(),this.renderRoster(),this.renderActivity(),this.isDispatchMapMode()||this.setActiveTab(this.activeTab)}},window.cadTasks.init(); \ No newline at end of file +window.cadTasks={contracts:[],requests:[],groups:[],activity:[],session:{},mode:"operations",dispatchView:"board",activeTab:"contracts",selectedDispatchGroupId:"",selectedDispatchTaskId:"",selectedDispatchRequestId:"",focusStatusTimer:null,requestModalType:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],requestTypes:[{id:"medevac_9line",label:"9-Line MEDEVAC",defaultPriority:"emergency",fields:[{id:"pickup_location",label:"Line 1 Pickup Location",type:"text",defaultFromGroupPosition:!0},{id:"radio_freq",label:"Line 2 Radio / Call Sign",type:"text"},{id:"precedence",label:"Line 3 Precedence",type:"select",options:["urgent","urgent_surgical","priority","routine","convenience"]},{id:"special_equipment",label:"Line 4 Special Equipment",type:"select",options:["none","hoist","extraction","ventilator"]},{id:"patient_type",label:"Line 5 Patient Type",type:"select",options:["litter","ambulatory","mixed"]},{id:"security",label:"Line 6 Security",type:"select",options:["secure","possible_enemy","enemy_in_area","hot"]},{id:"marking",label:"Line 7 Marking",type:"select",options:["panels","smoke","ir","none","other"]},{id:"patient_nationality",label:"Line 8 Patient Nationality",type:"select",options:["coalition","civilian","enemy","epw","mixed"]},{id:"terrain",label:"Line 9 Terrain",type:"select",options:["flat","restricted","slope","rooftop","wooded"]}]},{id:"ace_lace",label:"ACE/LACE",defaultPriority:"routine",fields:[{id:"ammo",label:"Ammo",type:"textarea"},{id:"casualties",label:"Casualties",type:"textarea"},{id:"equipment",label:"Equipment",type:"textarea"},{id:"notes",label:"Notes",type:"textarea"}]},{id:"fire_support",label:"Fire Support",defaultPriority:"priority",fields:[{id:"target_location",label:"Target Location",type:"text",defaultFromGroupPosition:!0},{id:"target_description",label:"Target Description",type:"textarea"},{id:"requested_effect",label:"Requested Effect",type:"select",options:["suppress","destroy","illum","smoke","screen"]},{id:"ordnance",label:"Requested Ordnance",type:"text"},{id:"danger_close",label:"Danger Close",type:"select",options:["no","yes"]},{id:"remarks",label:"Remarks",type:"textarea"}]},{id:"air_support",label:"Air Support",defaultPriority:"priority",fields:[{id:"target_location",label:"Target Location",type:"text",defaultFromGroupPosition:!0},{id:"target_description",label:"Target Description",type:"textarea"},{id:"target_marking",label:"Target Marking",type:"select",options:["smoke","ir","laser","grid","visual"]},{id:"requested_effect",label:"Requested Effect",type:"select",options:["show_of_force","escort","suppress","destroy","recon"]},{id:"remarks",label:"Remarks",type:"textarea"}]},{id:"logreq",label:"LOGREQ",defaultPriority:"priority",fields:[{id:"category",label:"Category",type:"select",options:["ammo","medical","fuel","repair","vehicle","equipment","weapons","mixed"]},{id:"delivery_method",label:"Delivery Method",type:"select",options:["ground","airdrop","pickup","dispatch_discretion"]},{id:"delivery_location",label:"Delivery Location",type:"text",defaultFromGroupPosition:!0},{id:"requested_items",label:"Requested Items",type:"textarea"},{id:"quantity",label:"Quantity / Package",type:"text"},{id:"remarks",label:"Remarks",type:"textarea"}]}],init(){document.querySelectorAll(".cad-tab").forEach(e=>{e.addEventListener("click",()=>{this.setActiveTab(e.dataset.tab||"contracts")})}),document.getElementById("cadRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("cadRequestModalSaveBtn").addEventListener("click",()=>{this.submitSupportRequest()}),document.querySelector("#cadRequestModal .cad-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.ForgeBridge.on("cad::hydrate",e=>{this.setHydratePayload(e||{})}),window.ForgeBridge.on("cad::assignment::response",e=>{this.handleServerResponse(!!e.success,e.message||"")}),window.ForgeBridge.on("cad::group::response",e=>{this.handleServerResponse(!!e.success,e.message||"")}),window.ForgeBridge.on("cad::request::response",e=>{this.handleServerResponse(!!e.success,e.message||"")}),window.ForgeBridge.ready({loaded:!0})},setActiveTab(e){this.activeTab=e||"contracts",document.querySelectorAll(".cad-tab").forEach(e=>{e.classList.toggle("is-active",e.dataset.tab===this.activeTab)}),document.querySelectorAll("[data-panel]").forEach(e=>{e.classList.toggle("is-active",e.dataset.panel===this.activeTab)})},syncLayoutState(){const e=document.querySelector(".cad-tabs"),t=document.getElementById("tabContractsBtn"),s=document.getElementById("tabRosterBtn"),a=document.getElementById("tabRequestsBtn"),n=document.getElementById("tabActivityBtn"),i=document.getElementById("contractsPanel"),r=document.getElementById("rosterPanel"),o=document.getElementById("requestsPanel"),d=document.getElementById("activityPanel"),c=i?.querySelector(".cad-section-header"),l=r?.querySelector(".cad-section-header");if(this.isDispatchMapMode())return e&&(e.style.display="",e.classList.remove("is-two-col"),e.classList.add("is-three-col")),t&&(t.style.display=""),s&&(s.textContent="Groups"),n&&(n.style.display="none"),a&&(a.style.display=""),d&&(d.classList.remove("is-active"),d.style.display="none"),o&&(o.style.display=""),r&&(r.style.display=""),l&&(l.textContent="Active Groups"),i&&(i.style.display=""),c&&(c.textContent="Contracts"),void(["contracts","roster","requests"].includes(this.activeTab)||(this.activeTab="contracts"));e&&(e.style.display="",e.classList.remove("is-three-col"),e.classList.remove("is-two-col")),t&&(t.style.display=""),s&&(s.textContent="Roster"),n&&(n.style.display=""),a&&(a.style.display=""),i&&(i.style.display=""),d&&(d.style.display=""),o&&(o.style.display=""),r&&(r.style.display=""),l&&(l.textContent="Roster"),c&&(c.textContent="Contracts")},setHydratePayload(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{},this.mode=e&&"string"==typeof e.mode?e.mode:"operations",this.dispatchView=e&&"string"==typeof e.dispatchView?e.dispatchView:"board";const t=document.getElementById("cadStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.selectedDispatchGroupId&&!this.groups.some(e=>e.groupId===this.selectedDispatchGroupId)&&(this.selectedDispatchGroupId=""),this.selectedDispatchTaskId&&!this.contracts.some(e=>(e.taskId||e.taskID||"")===this.selectedDispatchTaskId)&&(this.selectedDispatchTaskId=""),this.selectedDispatchRequestId&&!this.requests.some(e=>(e.requestId||"")===this.selectedDispatchRequestId)&&(this.selectedDispatchRequestId=""),"dispatch"!==this.mode||"map"!==this.dispatchView||["contracts","roster","requests"].includes(this.activeTab)||(this.activeTab="contracts"),this.render()},setStatus(e,t){const s=document.getElementById("cadStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getCurrentGroupCoordinates(){const e=this.getCurrentGroup(),t=Array.isArray(e?.position)?e.position:[0,0,0];return window.mapUI.formatPosition(t)},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,a="danger"===(t.status||"")?0:1;if(s!==a)return s-a;const n=e.callsign||e.groupId||"",i=t.callsign||t.groupId||"";return n.localeCompare(i)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestDefinition(e){return this.requestTypes.find(t=>t.id===e)||null},getRequestTypeLabel(e){return this.getRequestDefinition(e)?.label||e},canSubmitSupportRequest(){return"operations"===this.mode&&this.isLeader()},openRequestModal(e){const t=this.getRequestDefinition(e);t&&(this.requestModalType=e,document.getElementById("cadRequestModalTitle").textContent=t.label,document.getElementById("cadRequestPrioritySelect").value=t.defaultPriority||"priority",this.renderRequestFields(t),document.getElementById("cadRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.requestModalType="",document.getElementById("cadRequestFields").innerHTML="",document.getElementById("cadRequestModal").classList.add("is-hidden")},renderRequestFields(e){const t=document.getElementById("cadRequestFields");if(!t||!e)return;const s=this.getCurrentGroupCoordinates();t.innerHTML=e.fields.map(e=>{const t=e.defaultFromGroupPosition?s:"";return"select"===e.type?`\n \n `:"textarea"===e.type?`\n \n `:`\n \n `}).join("")},submitSupportRequest(){const e=this.getRequestDefinition(this.requestModalType);if(!e)return;const t={};e.fields.forEach(e=>{const s=document.getElementById(`cadRequestField_${e.id}`);t[e.id]=s?String(s.value||"").trim():""});const s=document.getElementById("cadRequestPrioritySelect").value;this.setStatus("Submitting support request...","info"),window.mapUI.sendEvent("cad::supportRequest::submit",{type:e.id,fields:t,priority:s}),this.closeRequestModal()},closeSupportRequest(e){e&&(this.setStatus(this.isDispatchMode()?"Closing support request...":"Cancelling support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))},renderRequests(){const e=document.getElementById("requestList");if(!e)return;if(this.isDispatchMapMode()){const t=this.requests.slice().sort((e,t)=>{const s=e.title||e.requestId||"",a=t.title||t.requestId||"";return s.localeCompare(a)});return t.length?void(e.innerHTML=t.map(e=>{const t=e.requestId||"",s=Array.isArray(e.position)?e.position:[0,0,0];return`\n \n
\n ${e.title||t||"Support Request"}\n ${this.getRequestTypeLabel(e.type||"request")}\n
\n

${e.summary||""}

\n
\n Group: ${e.groupCallsign||e.groupId||"Unknown"}\n ${(e.priority||"priority").replaceAll("_"," ")}\n
\n
\n ${window.mapUI.formatPosition(s)}\n ${t||"request"}\n
\n \n `}).join("")):void(e.innerHTML='

No support requests are currently active.

')}const t=this.canSubmitSupportRequest()?`\n
\n ${this.requestTypes.map(e=>`\n \n ${e.label}\n \n `).join("")}\n
\n `:"";this.requests.length?e.innerHTML=`\n ${t}\n ${this.requests.map(e=>{const t=this.isLeader()&&(e.groupId||"")===this.getPlayerGroupId(),s=this.canDispatch()||t,a=this.isDispatchMode()?"Close":"Cancel";return`\n
\n
\n ${e.title||this.getRequestTypeLabel(e.type||"")}\n ${(e.priority||"priority").replaceAll("_"," ")}\n
\n

${e.summary||""}

\n
\n Group: ${e.groupCallsign||e.groupId||"Unknown"}\n ${this.getRequestTypeLabel(e.type||"")}\n
\n ${s?`
\n \n
`:""}\n
\n `}).join("")}\n `:e.innerHTML=`\n ${t}\n

No support requests are currently active.

\n `},updateDangerAlert(){const e=document.getElementById("cadDangerAlert");if(!e)return;if(!this.isDispatchMapMode())return e.textContent="",void e.classList.add("is-hidden");const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("cadRequestAlert");if(!e)return;if(!this.isDispatchMapMode())return e.textContent="",void e.classList.add("is-hidden");const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},clearFocusStatusSoon(e){this.focusStatusTimer&&window.clearTimeout(this.focusStatusTimer),this.focusStatusTimer=window.setTimeout(()=>{const t=document.getElementById("cadStatusMessage");t&&"info"===t.dataset.type&&t.textContent===e&&this.setStatus("","")},1800)},handleServerResponse(e,t){this.setStatus(t||(e?"CAD update succeeded.":"CAD update failed."),e?"success":"error")},acknowledgeTask(e){this.setStatus("Acknowledging contract...","info"),window.mapUI.sendEvent("cad::tasks::acknowledge",{taskID:e})},declineTask(e){this.setStatus("Declining contract...","info"),window.mapUI.sendEvent("cad::tasks::decline",{taskID:e})},updateGroupStatus(e,t){this.setStatus("Updating group status...","info"),window.mapUI.sendEvent("cad::groups::status",{groupID:e,status:t})},updateGroupRole(e,t){this.setStatus("Updating group role...","info"),window.mapUI.sendEvent("cad::groups::role",{groupID:e,role:t})},focusGroup(e){const t=this.groups.find(t=>t.groupId===e);if(!t)return void this.setStatus("Selected group is no longer available.","error");this.selectedDispatchGroupId=e,this.selectedDispatchTaskId="",this.selectedDispatchRequestId="";const s=`Centering map on ${t.callsign||t.groupId||"group"}...`;this.setStatus(s,"info"),this.clearFocusStatusSoon(s),window.mapUI.sendEvent("cad::groups::focus",{groupID:e}),this.render()},focusTask(e){const t=this.contracts.find(t=>(t.taskId||t.taskID||"")===e);if(!t)return void this.setStatus("Selected contract is no longer available.","error");this.selectedDispatchTaskId=e,this.selectedDispatchGroupId="",this.selectedDispatchRequestId="";const s=`Centering map on ${t.title||e}...`;this.setStatus(s,"info"),this.clearFocusStatusSoon(s),window.mapUI.sendEvent("cad::tasks::focus",{taskID:e}),this.render()},focusRequest(e){const t=this.requests.find(t=>(t.requestId||"")===e);if(!t)return void this.setStatus("Selected request is no longer available.","error");if((Array.isArray(t.position)?t.position:[]).length<2)return void this.setStatus("Selected request has no map position.","error");this.selectedDispatchRequestId=e,this.selectedDispatchGroupId="",this.selectedDispatchTaskId="";const s=`Centering map on ${t.title||e}...`;this.setStatus(s,"info"),this.clearFocusStatusSoon(s),window.mapUI.sendEvent("cad::requests::focus",{requestID:e}),this.render()},getPlayerGroupId(){return this.session.groupId||""},getCurrentGroup(){const e=this.getPlayerGroupId();return this.groups.find(t=>t.groupId===e)||null},normalizeCollection:e=>Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[],canDispatch(){return!!this.session.isDispatcher},isDispatchMode(){return"dispatch"===this.mode},isDispatchMapMode(){return"dispatch"===this.mode&&"map"===this.dispatchView},isLeader(){return!!this.session.isLeader},renderContracts(){const e=document.getElementById("taskList");if(!e)return;if(this.isDispatchMapMode()){if(!this.contracts.length)return void(e.innerHTML='

No contracts are currently available.

');const t=this.contracts.slice().sort((e,t)=>{const s="unassigned"===(e.assignmentState||"unassigned")?0:1,a="unassigned"===(t.assignmentState||"unassigned")?0:1;if(s!==a)return s-a;const n=e.taskId||e.taskID||"",i=t.taskId||t.taskID||"";return n.localeCompare(i)});return void(e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=Array.isArray(e.position)?e.position:[0,0,0],a=e.assignedGroupId||"",n=e.assignmentState||"unassigned",i=this.groups.find(e=>e.groupId===a),r=t===this.selectedDispatchTaskId,o="unassigned"===n?"Unassigned":`${n}: ${i?i.callsign:a||"Unknown"}`;return`\n \n
\n ${e.title||t}\n ${this.formatTypeLabel(e)}\n
\n

${e.description||""}

\n
\n ${o}\n ${window.mapUI.formatPosition(s)}\n
\n \n `}).join(""))}const t=this.getPlayerGroupId(),s=this.contracts.filter(e=>(e.assignedGroupId||"")===t);s.length?e.innerHTML=s.map(e=>{const s=e.taskId||e.taskID||"",a=Array.isArray(e.position)?e.position:[0,0,0],n=e.assignedGroupId||"",i=e.assignmentState||"unassigned",r=this.groups.find(e=>e.groupId===n),o=this.isLeader()&&n===t;return`\n
\n
\n ${e.title||s}\n ${this.formatTypeLabel(e)}\n
\n

${e.description||""}

\n
\n ${"unassigned"===i?"Available":`${i}: ${r?r.callsign:n}`}\n ${window.mapUI.formatPosition(a)}\n
\n ${o&&"assigned"===i?`
\n \n \n
`:""}\n
\n `}).join(""):e.innerHTML='

No contract is currently assigned to your group.

'},renderRoster(){const e=document.getElementById("rosterList");if(!e)return;if(this.isDispatchMapMode())return this.groups.length?void(e.innerHTML=this.getSortedGroups().map(e=>{const t=(e.groupId||"")===this.selectedDispatchGroupId,s="danger"===(e.status||"");return`\n \n
\n ${e.callsign||e.groupId||"Unknown Group"}\n ${e.role||"group"}\n ${s?'Danger':""}\n
\n
\n Leader: ${e.leaderName||"Unknown"}\n Status: ${e.status||"unknown"}\n
\n
\n Members: ${this.normalizeCollection(e.members).length}\n Task: ${e.currentTaskId||"None"}\n
\n \n `}).join("")):void(e.innerHTML='

No active groups are currently available.

');const t=this.getCurrentGroup();if(!t)return void(e.innerHTML='

Your group is not currently available.

');const s=this.normalizeCollection(t.members),a="danger"===(t.status||"");s.length?e.innerHTML=`\n
\n
\n ${t.callsign||t.groupId||"Current Group"}\n ${s.length} member${1===s.length?"":"s"}\n ${a?'Danger':""}\n
\n
\n Leader: ${t.leaderName||"Unknown"}\n Status: ${t.status||"unknown"}\n
\n
\n Role: ${t.role||"unassigned"}\n Task: ${t.currentTaskId||"None"}\n
\n
\n ${s.map(e=>{const t=(e.lifeState||"unknown").replaceAll("_"," "),s=e.isLeader?'Leader':"";return`\n
\n
\n ${e.name||"Unknown Operator"}\n ${t}\n
\n
\n ${e.uid||"No UID"}\n ${s}\n
\n
\n `}).join("")}\n `:e.innerHTML='

No roster members are currently available.

'},renderActivity(){const e=document.getElementById("activityList");e&&(this.activity.length?e.innerHTML=this.activity.slice().reverse().slice(0,8).map(e=>`\n
\n
\n ${e.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):e.innerHTML='

No recent activity.

')},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.syncLayoutState(),this.renderContracts(),this.renderRoster(),this.renderRequests(),this.renderActivity(),this.setActiveTab(this.activeTab)}},window.cadTasks.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/cad-topbar.js b/arma/client/addons/cad/ui/_site/cad-topbar.js index 919a290..6d3986f 100644 --- a/arma/client/addons/cad/ui/_site/cad-topbar.js +++ b/arma/client/addons/cad/ui/_site/cad-topbar.js @@ -1 +1 @@ -window.cadTopbar={mode:"operations",dispatchView:"board",currentGroup:null,session:{},init(){document.getElementById("btnClose").addEventListener("click",()=>{window.mapUI.sendEvent("map::close",null)}),document.getElementById("modeToggle").addEventListener("change",e=>{window.mapUI.sendEvent("cad::mode::set",{mode:e.target.checked?"dispatch":"operations"})}),document.getElementById("dispatchRefreshBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::refresh",{})}),document.getElementById("dispatchBoardBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::dispatchView::set",{dispatchView:"board"})}),document.getElementById("dispatchMapBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::dispatchView::set",{dispatchView:"map"})}),document.getElementById("operatorRoleBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::role",{groupID:this.currentGroup.groupId||"",role:document.getElementById("operatorRoleSelect").value})}),document.getElementById("operatorStatusBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::status",{groupID:this.currentGroup.groupId||"",status:document.getElementById("operatorStatusSelect").value})}),window.mapUI.sendEvent("cad::topbar::ready",{})},formatLocation(e){const t=Array.isArray(e?.position)?e.position:[0,0,0];return`X: ${Math.round(t[0]||0).toString().padStart(4,"0")} Y: ${Math.round(t[1]||0).toString().padStart(4,"0")}`},receiveState(e){this.session=e&&e.session&&"object"==typeof e.session?e.session:{},this.mode=e&&"string"==typeof e.mode?e.mode:"operations",this.dispatchView=e&&"string"==typeof e.dispatchView?e.dispatchView:"board",this.currentGroup=e&&e.currentGroup&&"object"==typeof e.currentGroup?e.currentGroup:null;const t=document.getElementById("modeControls"),o=!!this.session.isDispatcher,s=!(!this.currentGroup||!this.session.isLeader&&!this.session.isDispatcher),n=document.getElementById("operatorStrip"),d=document.getElementById("operatorControls"),r=document.getElementById("dispatchViewControls"),i=document.getElementById("dispatchRefreshBtn"),a=document.getElementById("dispatchBoardBtn"),c=document.getElementById("dispatchMapBtn");t.classList.toggle("is-hidden",!o),r.classList.toggle("is-hidden",!o||"dispatch"!==this.mode),n.classList.toggle("is-hidden","operations"!==this.mode||!this.currentGroup),d.classList.toggle("is-hidden",!s),document.body.dataset.mode=this.mode,document.body.dataset.dispatcher=o?"true":"false",document.getElementById("modeToggle").checked="dispatch"===this.mode,a.classList.toggle("is-active","board"===this.dispatchView),c.classList.toggle("is-active","map"===this.dispatchView),i.title="dispatch"===this.mode?"Refresh dispatch board":"Refresh CAD",i.setAttribute("aria-label","dispatch"===this.mode?"Refresh dispatch board":"Refresh CAD"),document.getElementById("operatorGroupName").textContent=this.currentGroup?this.currentGroup.callsign||this.currentGroup.groupId||"Current Group":"No Group",document.getElementById("operatorLocation").textContent=this.currentGroup?this.formatLocation(this.currentGroup):"Unavailable",this.currentGroup&&(document.getElementById("operatorRoleSelect").value=this.currentGroup.role||"infantry",document.getElementById("operatorStatusSelect").value=this.currentGroup.status||"available")}},window.cadTopbar.init(); \ No newline at end of file +window.cadTopbar={mode:"operations",dispatchView:"board",currentGroup:null,session:{},init(){document.getElementById("btnClose").addEventListener("click",()=>{window.mapUI.sendEvent("map::close",null)}),document.getElementById("modeToggle").addEventListener("change",e=>{window.mapUI.sendEvent("cad::mode::set",{mode:e.target.checked?"dispatch":"operations"})}),document.getElementById("dispatchRefreshBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::refresh",{})}),document.getElementById("dispatchBoardBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::dispatchView::set",{dispatchView:"board"})}),document.getElementById("dispatchMapBtn").addEventListener("click",()=>{window.mapUI.sendEvent("cad::dispatchView::set",{dispatchView:"map"})}),document.getElementById("operatorRoleBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::role",{groupID:this.currentGroup.groupId||"",role:document.getElementById("operatorRoleSelect").value})}),document.getElementById("operatorStatusBtn").addEventListener("click",()=>{this.currentGroup&&window.mapUI.sendEvent("cad::groups::status",{groupID:this.currentGroup.groupId||"",status:document.getElementById("operatorStatusSelect").value})}),window.mapUI.sendEvent("cad::topbar::ready",{})},formatLocation(e){const t=Array.isArray(e?.position)?e.position:[0,0,0];return window.mapUI.formatPosition(t)},receiveState(e){this.session=e&&e.session&&"object"==typeof e.session?e.session:{},this.mode=e&&"string"==typeof e.mode?e.mode:"operations",this.dispatchView=e&&"string"==typeof e.dispatchView?e.dispatchView:"board",this.currentGroup=e&&e.currentGroup&&"object"==typeof e.currentGroup?e.currentGroup:null;const t=document.getElementById("modeControls"),o=!!this.session.isDispatcher,s=!(!this.currentGroup||!this.session.isLeader&&!this.session.isDispatcher),n=document.getElementById("operatorStrip"),d=document.getElementById("operatorControls"),i=document.getElementById("dispatchViewControls"),r=document.getElementById("dispatchRefreshBtn"),a=document.getElementById("dispatchBoardBtn"),c=document.getElementById("dispatchMapBtn");t.classList.toggle("is-hidden",!o),i.classList.toggle("is-hidden",!o||"dispatch"!==this.mode),n.classList.toggle("is-hidden","operations"!==this.mode||!this.currentGroup),d.classList.toggle("is-hidden",!s),document.body.dataset.mode=this.mode,document.body.dataset.dispatcher=o?"true":"false",document.getElementById("modeToggle").checked="dispatch"===this.mode,a.classList.toggle("is-active","board"===this.dispatchView),c.classList.toggle("is-active","map"===this.dispatchView),r.title="dispatch"===this.mode?"Refresh dispatch board":"Refresh CAD",r.setAttribute("aria-label","dispatch"===this.mode?"Refresh dispatch board":"Refresh CAD"),document.getElementById("operatorGroupName").textContent=this.currentGroup?this.currentGroup.callsign||this.currentGroup.groupId||"Current Group":"No Group",document.getElementById("operatorLocation").textContent=this.currentGroup?this.formatLocation(this.currentGroup):"Unavailable",this.currentGroup&&(document.getElementById("operatorRoleSelect").value=this.currentGroup.role||"infantry",document.getElementById("operatorStatusSelect").value=this.currentGroup.status||"available")}},window.cadTopbar.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/dispatcher.html b/arma/client/addons/cad/ui/_site/dispatcher.html index b06c997..680ae6a 100644 --- a/arma/client/addons/cad/ui/_site/dispatcher.html +++ b/arma/client/addons/cad/ui/_site/dispatcher.html @@ -1 +1 @@ -

Dispatch Dashboard

Operational Board

Open Contracts 0
Assigned Contracts 0
Active Groups 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Activity Feed

\ No newline at end of file +

Dispatch Dashboard

Operational Board

Open Contracts 0
Assigned Contracts 0
Active Groups 0
Open Requests 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Requests & Activity

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/_site/sidepanel.html b/arma/client/addons/cad/ui/_site/sidepanel.html index aa80c55..5afd6e0 100644 --- a/arma/client/addons/cad/ui/_site/sidepanel.html +++ b/arma/client/addons/cad/ui/_site/sidepanel.html @@ -1 +1 @@ -

CAD System

Contracts

Loading contracts...

Roster

Loading roster...

Activity

No recent activity.

\ No newline at end of file +

CAD System

Contracts

Loading contracts...

Roster

Loading roster...

Support Requests

No support requests.

Activity

No recent activity.

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/dispatcher.html b/arma/client/addons/cad/ui/src/dispatcher.html index 207ed7a..51dd586 100644 --- a/arma/client/addons/cad/ui/src/dispatcher.html +++ b/arma/client/addons/cad/ui/src/dispatcher.html @@ -13,6 +13,14 @@
+ +
@@ -27,7 +35,11 @@ Active Groups 0
-
+
+ Open Requests + 0 +
+
Groups In Danger 0
@@ -37,6 +49,15 @@

Available Contracts

+
-

Activity Feed

+

Requests & Activity

@@ -146,6 +167,160 @@
+ + + +
\ No newline at end of file +

Dispatch Dashboard

Operational Board

Open Contracts 0
Assigned Contracts 0
Active Groups 0
Open Requests 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Requests & Activity

\ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/dispatcher.html b/arma/client/addons/cad/ui/src/dispatcher.html index 51dd586..bfce3dd 100644 --- a/arma/client/addons/cad/ui/src/dispatcher.html +++ b/arma/client/addons/cad/ui/src/dispatcher.html @@ -311,6 +311,13 @@
+ - `; - }, - buildCloseOrderButton(taskID) { - return ` - - `; - }, - buildCloseRequestButton(requestID) { - return ` - - `; - }, - closeSupportRequest(requestID) { - if (!requestID) { - return; - } - - this.setStatus("Closing support request...", "info"); - window.mapUI.sendEvent("cad::supportRequest::close", { - requestID: requestID, - }); - }, - renderMetrics() { - const assignedContracts = this.contracts.filter( - (entry) => (entry.assignmentState || "unassigned") !== "unassigned", - ); - const openContracts = this.contracts.filter( - (entry) => (entry.assignmentState || "unassigned") === "unassigned", - ); - const openRequests = this.requests.length; - const supportAlertRequests = this.getSupportAlertRequests(); - const dangerGroups = this.groups.filter( - (group) => (group.status || "") === "danger", - ); - - document.getElementById("metricOpenContracts").textContent = - openContracts.length; - document.getElementById("metricAssignedContracts").textContent = - assignedContracts.length; - document.getElementById("metricActiveGroups").textContent = - this.groups.length; - document.getElementById("metricOpenRequests").textContent = - openRequests; - document.getElementById("metricDangerGroups").textContent = - dangerGroups.length; - - const dangerMetricCard = document.getElementById( - "metricDangerGroupsCard", - ); - if (dangerMetricCard) { - dangerMetricCard.classList.toggle( - "is-danger", - dangerGroups.length > 0, - ); - } - - const requestMetricCard = document.getElementById( - "metricOpenRequestsCard", - ); - if (requestMetricCard) { - requestMetricCard.classList.toggle( - "is-warning", - supportAlertRequests.length > 0, - ); - } - }, - renderOpenContracts() { - const container = document.getElementById("dispatcherOpenContracts"); - const openContracts = this.contracts.filter( - (entry) => (entry.assignmentState || "unassigned") === "unassigned", - ); - - if (!openContracts.length) { - container.innerHTML = - '

No open contracts.

'; - return; - } - - const groupOptions = this.buildGroupOptions(""); - - container.innerHTML = openContracts - .map((task) => { - const taskId = task.taskId || task.taskID || ""; - const position = Array.isArray(task.position) - ? task.position - : [0, 0, 0]; - const targetGroup = this.groups.find( - (group) => group.groupId === (task.targetGroupId || ""), - ); - - return ` -
-
- ${task.title || taskId} - ${this.formatTypeLabel(task)} -
-

${task.description || ""}

-
- Unassigned - ${window.mapUI.formatPosition(position)} -
-
- Target: ${targetGroup ? targetGroup.callsign : task.targetGroupCallsign || "None"} - Priority: ${(task.priority || "priority").replaceAll("_", " ")} -
-
- - -
-
- `; - }) - .join(""); - }, - renderAssignedContracts() { - const container = document.getElementById( - "dispatcherAssignedContracts", - ); - const assignedContracts = this.contracts.filter( - (entry) => (entry.assignmentState || "unassigned") !== "unassigned", - ); - - if (!assignedContracts.length) { - container.innerHTML = - '

No assigned contracts.

'; - return; - } - - container.innerHTML = assignedContracts - .map((task) => { - const taskId = task.taskId || task.taskID || ""; - const assignedGroup = this.groups.find( - (group) => group.groupId === (task.assignedGroupId || ""), - ); - const targetGroup = this.groups.find( - (group) => group.groupId === (task.targetGroupId || ""), - ); - const isDispatchOrder = this.isDispatchOrder(task); - - return ` -
-
- ${task.title || taskId} - ${task.assignmentState || "assigned"} -
-

${task.description || ""}

-
- Group: ${assignedGroup ? assignedGroup.callsign : task.assignedGroupId || "Unknown"} - Type: ${this.formatTypeLabel(task)} -
-
- Target: ${targetGroup ? targetGroup.callsign : task.targetGroupCallsign || "None"} - Priority: ${(task.priority || "priority").replaceAll("_", " ")} -
- ${isDispatchOrder ? `
${this.buildCloseOrderButton(taskId)}
` : ""} -
- `; - }) - .join(""); - }, - renderGroups() { - const container = document.getElementById("dispatcherGroups"); - if (!this.groups.length) { - container.innerHTML = - '

No active groups available.

'; - return; - } - - container.innerHTML = this.getSortedGroups() - .map((group) => { - const isDanger = (group.status || "") === "danger"; - return ` -
-
-
- ${group.callsign || group.groupId} - ${group.role || "group"} - ${isDanger ? 'Danger' : ""} -
-
- ${this.buildGroupEditorButton(group.groupId)} -
-
-
- Leader: ${group.leaderName || "Unknown"} - Status: ${group.status || "unknown"} -
-
- Org: ${group.orgId || "default"} - Task: ${group.currentTaskId || "None"} -
-
- `; - }) - .join(""); - }, - renderActivity() { - const container = document.getElementById("dispatcherActivity"); - const requestsHTML = this.requests.length - ? this.requests - .map( - (request) => ` -
-
- ${request.title || request.requestId || "Support Request"} - ${(request.priority || "priority").replaceAll("_", " ")} -
-

${request.summary || ""}

-
- Group: ${request.groupCallsign || request.groupId || "Unknown"} - ${this.getRequestTypeLabel(request.type || "request")} -
-
- ${this.buildCloseRequestButton(request.requestId || "")} -
-
- `, - ) - .join("") - : '

No active support requests.

'; - - const activityHTML = this.activity.length - ? this.activity - .slice() - .reverse() - .slice(0, 8) - .map( - (entry) => ` -
-
- ${entry.type || "activity"} - ${Math.round(entry.timestamp || 0)}s -
-

${entry.message || ""}

-
- `, - ) - .join("") - : '

No recent activity.

'; - - container.innerHTML = ` -
-
Support Requests
- ${requestsHTML} -
-
-
Recent Activity
- ${activityHTML} -
- `; - }, - render() { - this.updateDangerAlert(); - this.updateRequestAlert(); - this.renderMetrics(); - this.renderOpenContracts(); - this.renderAssignedContracts(); - this.renderGroups(); - this.renderActivity(); - }, -}; - -window.cadDispatcher.init(); diff --git a/arma/client/addons/cad/ui/src/dispatcher/formatters.js b/arma/client/addons/cad/ui/src/dispatcher/formatters.js new file mode 100644 index 0000000..5377e45 --- /dev/null +++ b/arma/client/addons/cad/ui/src/dispatcher/formatters.js @@ -0,0 +1,103 @@ +window.cadDispatcherFormatters = { + getDangerGroups() { + return this.groups.filter((group) => (group.status || "") === "danger"); + }, + getSupportAlertRequests() { + return this.requests.filter((request) => + ["medevac_9line", "fire_support", "air_support"].includes( + request.type || "", + ), + ); + }, + buildSupportAlertMessage() { + const alertRequests = this.getSupportAlertRequests(); + if (!alertRequests.length) { + return ""; + } + + const labels = alertRequests.map((request) => { + const groupLabel = + request.groupCallsign || request.groupId || "Unknown Group"; + const typeLabel = this.getRequestTypeLabel( + request.type || "request", + ); + return `${groupLabel} ${typeLabel}`; + }); + + return `Support request alert: ${labels.join(", ")}`; + }, + getSortedGroups() { + return this.groups.slice().sort((left, right) => { + const leftDanger = (left.status || "") === "danger" ? 0 : 1; + const rightDanger = (right.status || "") === "danger" ? 0 : 1; + + if (leftDanger !== rightDanger) { + return leftDanger - rightDanger; + } + + const leftCallsign = left.callsign || left.groupId || ""; + const rightCallsign = right.callsign || right.groupId || ""; + return leftCallsign.localeCompare(rightCallsign); + }); + }, + isDispatchOrder(entry) { + return ( + !!entry.isDispatchOrder || (entry.type || "") === "dispatch_order" + ); + }, + formatTypeLabel(entry) { + const typeLabel = (entry.type || "task").replaceAll("_", " "); + return this.isDispatchOrder(entry) ? "dispatch order" : typeLabel; + }, + getRequestTypeLabel(typeID) { + switch (typeID) { + case "medevac_9line": + return "9-Line MEDEVAC"; + case "ace_lace": + return "ACE/LACE"; + case "fire_support": + return "Fire Support"; + case "air_support": + return "Air Support"; + case "logreq": + return "LOGREQ"; + default: + return (typeID || "request").replaceAll("_", " "); + } + }, + buildGroupOptions(selectedGroupID) { + return this.getSortedGroups() + .map((group) => { + const groupID = group.groupId || ""; + return ``; + }) + .join(""); + }, + formatRequestFieldLabel(fieldID) { + return (fieldID || "field") + .replaceAll("_", " ") + .replace(/\b\w/g, (character) => character.toUpperCase()); + }, + formatRequestFieldValue(value) { + if (Array.isArray(value)) { + return value.join(", "); + } + + if (value && typeof value === "object") { + return JSON.stringify(value); + } + + const text = String(value ?? "").trim(); + return text || "Not provided"; + }, + buildRequestOrderNote(request) { + const typeLabel = this.getRequestTypeLabel(request.type || "request"); + const groupLabel = + request.groupCallsign || request.groupId || "Unknown Group"; + const summary = (request.summary || "").trim(); + + return summary + ? `${typeLabel} requested by ${groupLabel}. ${summary}` + : `${typeLabel} requested by ${groupLabel}.`; + }, +}; diff --git a/arma/client/addons/cad/ui/src/dispatcher/index.js b/arma/client/addons/cad/ui/src/dispatcher/index.js new file mode 100644 index 0000000..6b6cc5a --- /dev/null +++ b/arma/client/addons/cad/ui/src/dispatcher/index.js @@ -0,0 +1,255 @@ +const dispatcherFormatters = window.cadDispatcherFormatters || {}; +const dispatcherModals = window.cadDispatcherModals || {}; +const dispatcherRender = window.cadDispatcherRender || {}; + +window.cadDispatcher = { + contracts: [], + requests: [], + groups: [], + activity: [], + session: {}, + editingGroupId: "", + viewingRequestId: "", + convertingRequestId: "", + statuses: [ + "available", + "en_route", + "on_task", + "holding", + "danger", + "unavailable", + ], + roles: ["infantry", "recon", "armor", "air", "logistics", "support"], + ...dispatcherFormatters, + ...dispatcherModals, + ...dispatcherRender, + init() { + document + .getElementById("dispatcherCreateOrderBtn") + .addEventListener("click", () => { + this.openOrderModal(); + }); + + document + .getElementById("dispatcherGroupModalCloseBtn") + .addEventListener("click", () => { + this.closeGroupModal(); + }); + + document + .getElementById("dispatcherGroupModalSaveBtn") + .addEventListener("click", () => { + this.applyGroupUpdates(); + }); + + document + .querySelector("#dispatcherGroupModal .dispatch-modal-backdrop") + .addEventListener("click", () => { + this.closeGroupModal(); + }); + + document + .getElementById("dispatcherOrderModalCloseBtn") + .addEventListener("click", () => { + this.closeOrderModal(); + }); + + document + .getElementById("dispatcherOrderModalSaveBtn") + .addEventListener("click", () => { + this.createDispatchOrder(); + }); + + document + .querySelector("#dispatcherOrderModal .dispatch-modal-backdrop") + .addEventListener("click", () => { + this.closeOrderModal(); + }); + + document + .getElementById("dispatcherRequestModalCloseBtn") + .addEventListener("click", () => { + this.closeRequestModal(); + }); + + document + .getElementById("dispatcherRequestModalDoneBtn") + .addEventListener("click", () => { + this.closeRequestModal(); + }); + + document + .getElementById("dispatcherRequestConvertBtn") + .addEventListener("click", () => { + this.convertViewedRequestToOrder(); + }); + + document + .querySelector("#dispatcherRequestModal .dispatch-modal-backdrop") + .addEventListener("click", () => { + this.closeRequestModal(); + }); + + window.mapUI.sendEvent("cad::dispatcher::ready", {}); + }, + receiveHydrate(payload) { + this.contracts = Array.isArray(payload.contracts) + ? payload.contracts + : []; + this.requests = Array.isArray(payload.requests) ? payload.requests : []; + this.groups = Array.isArray(payload.groups) ? payload.groups : []; + this.activity = Array.isArray(payload.activity) ? payload.activity : []; + this.session = + payload.session && typeof payload.session === "object" + ? payload.session + : {}; + + const statusEl = document.getElementById("dispatcherStatusMessage"); + if ( + statusEl && + (!statusEl.dataset.type || statusEl.dataset.type === "info") + ) { + this.setStatus("", ""); + } + + this.syncOpenModal(); + this.syncOrderModal(); + this.syncRequestModal(); + this.render(); + }, + setStatus(message, type) { + const statusEl = document.getElementById("dispatcherStatusMessage"); + if (!statusEl) { + return; + } + + statusEl.textContent = message || ""; + statusEl.dataset.type = type || ""; + }, + createDispatchOrder() { + const assigneeGroupID = document.getElementById( + "dispatcherOrderAssigneeSelect", + ).value; + const targetGroupID = document.getElementById( + "dispatcherOrderTargetSelect", + ).value; + const priority = document.getElementById( + "dispatcherOrderPrioritySelect", + ).value; + const note = document.getElementById("dispatcherOrderNoteInput").value; + + if (!assigneeGroupID || !targetGroupID) { + this.setStatus( + "Select both an assignee and a target group.", + "error", + ); + return; + } + + if (assigneeGroupID === targetGroupID) { + this.setStatus( + "Assignee and target groups must be different.", + "error", + ); + return; + } + + this.setStatus( + this.convertingRequestId + ? "Creating dispatch order from request..." + : "Creating dispatch order...", + "info", + ); + window.mapUI.sendEvent("cad::dispatchOrder::create", { + assigneeGroupID: assigneeGroupID, + targetGroupID: targetGroupID, + note: note.trim(), + priority: priority, + }); + + this.closeOrderModal(); + }, + assignTask(taskID) { + const selector = document.getElementById( + `dispatcher-assign-group-${taskID}`, + ); + if (!selector || !selector.value) { + this.setStatus( + "Select a group before assigning a contract.", + "error", + ); + return; + } + + this.setStatus("Submitting assignment...", "info"); + window.mapUI.sendEvent("cad::tasks::assign", { + taskID: taskID, + groupID: selector.value, + note: "", + }); + }, + applyGroupUpdates() { + if (!this.editingGroupId) { + return; + } + + const group = this.groups.find( + (entry) => entry.groupId === this.editingGroupId, + ); + if (!group) { + this.closeGroupModal(); + return; + } + + const roleValue = document.getElementById( + "dispatcherModalRoleSelect", + ).value; + const statusValue = document.getElementById( + "dispatcherModalStatusSelect", + ).value; + const nextRole = + roleValue && roleValue !== (group.role || "") ? roleValue : ""; + const nextStatus = + statusValue && statusValue !== (group.status || "") + ? statusValue + : ""; + const hasChanges = nextRole || nextStatus; + + if (!hasChanges) { + this.setStatus("No group changes to save.", "info"); + this.closeGroupModal(); + return; + } + + this.setStatus("Updating group profile...", "info"); + window.mapUI.sendEvent("cad::groups::profile", { + groupID: this.editingGroupId, + role: nextRole, + status: nextStatus, + }); + + this.closeGroupModal(); + }, + closeDispatchOrder(taskID) { + if (!taskID) { + return; + } + + this.setStatus("Closing dispatch order...", "info"); + window.mapUI.sendEvent("cad::dispatchOrder::close", { + taskID: taskID, + }); + }, + closeSupportRequest(requestID) { + if (!requestID) { + return; + } + + this.setStatus("Closing support request...", "info"); + window.mapUI.sendEvent("cad::supportRequest::close", { + requestID: requestID, + }); + }, +}; + +window.cadDispatcher.init(); diff --git a/arma/client/addons/cad/ui/src/dispatcher/modals.js b/arma/client/addons/cad/ui/src/dispatcher/modals.js new file mode 100644 index 0000000..6d641f0 --- /dev/null +++ b/arma/client/addons/cad/ui/src/dispatcher/modals.js @@ -0,0 +1,268 @@ +window.cadDispatcherModals = { + openOrderModal() { + this.convertingRequestId = ""; + this.populateOrderModal(); + document.getElementById("dispatcherOrderModalTitle").textContent = + "Create Support Order"; + document + .getElementById("dispatcherOrderModal") + .classList.remove("is-hidden"); + }, + closeOrderModal() { + this.convertingRequestId = ""; + document.getElementById("dispatcherOrderNoteInput").value = ""; + document.getElementById("dispatcherOrderPrioritySelect").value = + "priority"; + document.getElementById("dispatcherOrderModalTitle").textContent = + "Create Support Order"; + document + .getElementById("dispatcherOrderModal") + .classList.add("is-hidden"); + }, + openRequestModal(requestID) { + const request = this.requests.find( + (entry) => entry.requestId === requestID, + ); + if (!request) { + return; + } + + this.viewingRequestId = requestID; + this.populateRequestModal(request); + document + .getElementById("dispatcherRequestModal") + .classList.remove("is-hidden"); + }, + closeRequestModal() { + this.viewingRequestId = ""; + document + .getElementById("dispatcherRequestModal") + .classList.add("is-hidden"); + }, + syncRequestModal() { + if (!this.viewingRequestId) { + return; + } + + const request = this.requests.find( + (entry) => entry.requestId === this.viewingRequestId, + ); + if (!request) { + this.closeRequestModal(); + return; + } + + this.populateRequestModal(request); + }, + populateRequestModal(request) { + const fields = + request.fields && typeof request.fields === "object" + ? Object.entries(request.fields) + : []; + const fieldsHTML = fields.length + ? fields + .map( + ([fieldID, value]) => ` +
+ ${this.formatRequestFieldLabel(fieldID)} + ${this.formatRequestFieldValue(value)} +
+ `, + ) + .join("") + : '

No submitted fields.

'; + + document.getElementById("dispatcherRequestTitle").textContent = + request.title || request.requestId || "Support Request"; + document.getElementById("dispatcherRequestPriority").textContent = ( + request.priority || "priority" + ).replaceAll("_", " "); + document.getElementById("dispatcherRequestGroup").textContent = + request.groupCallsign || request.groupId || "Unknown"; + document.getElementById("dispatcherRequestType").textContent = + this.getRequestTypeLabel(request.type || "request"); + document.getElementById("dispatcherRequestSummary").textContent = + request.summary || "No summary provided."; + document.getElementById("dispatcherRequestFields").innerHTML = + fieldsHTML; + }, + convertRequestToOrder(requestID) { + const request = this.requests.find( + (entry) => (entry.requestId || "") === requestID, + ); + if (!request) { + this.setStatus("Selected request is no longer available.", "error"); + return; + } + + const targetGroupID = request.groupId || ""; + if (!targetGroupID) { + this.setStatus( + "Selected request has no owning group to target.", + "error", + ); + return; + } + + const targetGroup = this.groups.find( + (group) => (group.groupId || "") === targetGroupID, + ); + if (!targetGroup) { + this.setStatus( + "Selected request group is no longer available.", + "error", + ); + return; + } + + this.convertingRequestId = requestID; + this.populateOrderModal({ + selectedAssigneeID: + this.getSortedGroups().find( + (group) => (group.groupId || "") !== targetGroupID, + )?.groupId || "", + selectedTargetID: targetGroupID, + note: this.buildRequestOrderNote(request), + priority: request.priority || "priority", + }); + document.getElementById("dispatcherOrderModalTitle").textContent = + "Create Order From Request"; + document + .getElementById("dispatcherOrderModal") + .classList.remove("is-hidden"); + this.setStatus("Preparing dispatch order from request...", "info"); + }, + convertViewedRequestToOrder() { + if (!this.viewingRequestId) { + return; + } + + this.closeRequestModal(); + this.convertRequestToOrder(this.viewingRequestId); + }, + populateOrderModal(options = {}) { + const sortedGroups = this.getSortedGroups(); + const assigneeSelect = document.getElementById( + "dispatcherOrderAssigneeSelect", + ); + const targetSelect = document.getElementById( + "dispatcherOrderTargetSelect", + ); + const noteInput = document.getElementById("dispatcherOrderNoteInput"); + const prioritySelect = document.getElementById( + "dispatcherOrderPrioritySelect", + ); + if (!assigneeSelect || !targetSelect) { + return; + } + + const selectedAssigneeID = options.selectedAssigneeID || ""; + const selectedTargetID = options.selectedTargetID || ""; + const fallbackAssignee = + selectedAssigneeID || + sortedGroups.find( + (group) => (group.groupId || "") !== selectedTargetID, + )?.groupId || + sortedGroups[0]?.groupId || + ""; + const fallbackTarget = + selectedTargetID || + sortedGroups.find( + (group) => (group.groupId || "") !== fallbackAssignee, + )?.groupId || + sortedGroups[0]?.groupId || + ""; + + assigneeSelect.innerHTML = this.buildGroupOptions(fallbackAssignee); + targetSelect.innerHTML = this.buildGroupOptions(fallbackTarget); + if (noteInput) { + noteInput.value = options.note || ""; + } + if (prioritySelect) { + prioritySelect.value = options.priority || "priority"; + } + }, + syncOrderModal() { + const modalEl = document.getElementById("dispatcherOrderModal"); + if (!modalEl || modalEl.classList.contains("is-hidden")) { + return; + } + + this.populateOrderModal({ + selectedAssigneeID: + document.getElementById("dispatcherOrderAssigneeSelect") + ?.value || "", + selectedTargetID: + document.getElementById("dispatcherOrderTargetSelect")?.value || + "", + note: + document.getElementById("dispatcherOrderNoteInput")?.value || + "", + priority: + document.getElementById("dispatcherOrderPrioritySelect") + ?.value || "priority", + }); + }, + openGroupModal(groupID) { + const group = this.groups.find((entry) => entry.groupId === groupID); + if (!group) { + return; + } + + this.editingGroupId = groupID; + document.getElementById("dispatcherModalGroupCallsign").textContent = + group.callsign || group.groupId || "Unknown"; + document.getElementById("dispatcherModalGroupLeader").textContent = + group.leaderName || "Unknown"; + document.getElementById("dispatcherModalGroupTask").textContent = + group.currentTaskId || "None"; + document.getElementById("dispatcherModalGroupOrg").textContent = + group.orgId || "default"; + document.getElementById("dispatcherModalRoleSelect").innerHTML = + this.roles + .map( + (role) => + ``, + ) + .join(""); + document.getElementById("dispatcherModalStatusSelect").innerHTML = + this.statuses + .map( + (status) => + ``, + ) + .join(""); + + document + .getElementById("dispatcherGroupModal") + .classList.remove("is-hidden"); + }, + closeGroupModal() { + this.editingGroupId = ""; + document + .getElementById("dispatcherGroupModal") + .classList.add("is-hidden"); + }, + syncOpenModal() { + if (!this.editingGroupId) { + return; + } + + const group = this.groups.find( + (entry) => entry.groupId === this.editingGroupId, + ); + if (!group) { + this.closeGroupModal(); + return; + } + + document.getElementById("dispatcherModalGroupCallsign").textContent = + group.callsign || group.groupId || "Unknown"; + document.getElementById("dispatcherModalGroupLeader").textContent = + group.leaderName || "Unknown"; + document.getElementById("dispatcherModalGroupTask").textContent = + group.currentTaskId || "None"; + document.getElementById("dispatcherModalGroupOrg").textContent = + group.orgId || "default"; + }, +}; diff --git a/arma/client/addons/cad/ui/src/dispatcher/render.js b/arma/client/addons/cad/ui/src/dispatcher/render.js new file mode 100644 index 0000000..022745e --- /dev/null +++ b/arma/client/addons/cad/ui/src/dispatcher/render.js @@ -0,0 +1,325 @@ +window.cadDispatcherRender = { + updateDangerAlert() { + const alertEl = document.getElementById("dispatcherDangerAlert"); + if (!alertEl) { + return; + } + + const dangerGroups = this.getDangerGroups(); + if (!dangerGroups.length) { + alertEl.textContent = ""; + alertEl.classList.add("is-hidden"); + return; + } + + const callsigns = dangerGroups.map( + (group) => group.callsign || group.groupId || "Unknown Group", + ); + alertEl.textContent = `Danger alert active: ${callsigns.join(", ")}`; + alertEl.classList.remove("is-hidden"); + }, + updateRequestAlert() { + const alertEl = document.getElementById("dispatcherRequestAlert"); + if (!alertEl) { + return; + } + + const alertMessage = this.buildSupportAlertMessage(); + if (!alertMessage) { + alertEl.textContent = ""; + alertEl.classList.add("is-hidden"); + return; + } + + alertEl.textContent = alertMessage; + alertEl.classList.remove("is-hidden"); + }, + buildGroupEditorButton(groupID) { + return ` + + `; + }, + buildCloseOrderButton(taskID) { + return ` + + `; + }, + buildCloseRequestButton(requestID) { + return ` + + `; + }, + buildConvertRequestButton(requestID) { + return ` + + `; + }, + renderMetrics() { + const assignedContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") !== "unassigned", + ); + const openContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") === "unassigned", + ); + const openRequests = this.requests.length; + const supportAlertRequests = this.getSupportAlertRequests(); + const dangerGroups = this.groups.filter( + (group) => (group.status || "") === "danger", + ); + + document.getElementById("metricOpenContracts").textContent = + openContracts.length; + document.getElementById("metricAssignedContracts").textContent = + assignedContracts.length; + document.getElementById("metricActiveGroups").textContent = + this.groups.length; + document.getElementById("metricOpenRequests").textContent = + openRequests; + document.getElementById("metricDangerGroups").textContent = + dangerGroups.length; + + const dangerMetricCard = document.getElementById( + "metricDangerGroupsCard", + ); + if (dangerMetricCard) { + dangerMetricCard.classList.toggle( + "is-danger", + dangerGroups.length > 0, + ); + } + + const requestMetricCard = document.getElementById( + "metricOpenRequestsCard", + ); + if (requestMetricCard) { + requestMetricCard.classList.toggle( + "is-warning", + supportAlertRequests.length > 0, + ); + } + }, + renderOpenContracts() { + const container = document.getElementById("dispatcherOpenContracts"); + const openContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") === "unassigned", + ); + + if (!openContracts.length) { + container.innerHTML = + '

No open contracts.

'; + return; + } + + const groupOptions = this.buildGroupOptions(""); + + container.innerHTML = openContracts + .map((task) => { + const taskId = task.taskId || task.taskID || ""; + const position = Array.isArray(task.position) + ? task.position + : [0, 0, 0]; + const targetGroup = this.groups.find( + (group) => group.groupId === (task.targetGroupId || ""), + ); + + return ` +
+
+ ${task.title || taskId} + ${this.formatTypeLabel(task)} +
+

${task.description || ""}

+
+ Unassigned + ${window.mapUI.formatPosition(position)} +
+
+ Target: ${targetGroup ? targetGroup.callsign : task.targetGroupCallsign || "None"} + Priority: ${(task.priority || "priority").replaceAll("_", " ")} +
+
+ + +
+
+ `; + }) + .join(""); + }, + renderAssignedContracts() { + const container = document.getElementById( + "dispatcherAssignedContracts", + ); + const assignedContracts = this.contracts.filter( + (entry) => (entry.assignmentState || "unassigned") !== "unassigned", + ); + + if (!assignedContracts.length) { + container.innerHTML = + '

No assigned contracts.

'; + return; + } + + container.innerHTML = assignedContracts + .map((task) => { + const taskId = task.taskId || task.taskID || ""; + const assignedGroup = this.groups.find( + (group) => group.groupId === (task.assignedGroupId || ""), + ); + const targetGroup = this.groups.find( + (group) => group.groupId === (task.targetGroupId || ""), + ); + const isDispatchOrder = this.isDispatchOrder(task); + + return ` +
+
+ ${task.title || taskId} + ${task.assignmentState || "assigned"} +
+

${task.description || ""}

+
+ Group: ${assignedGroup ? assignedGroup.callsign : task.assignedGroupId || "Unknown"} + Type: ${this.formatTypeLabel(task)} +
+
+ Target: ${targetGroup ? targetGroup.callsign : task.targetGroupCallsign || "None"} + Priority: ${(task.priority || "priority").replaceAll("_", " ")} +
+ ${isDispatchOrder ? `
${this.buildCloseOrderButton(taskId)}
` : ""} +
+ `; + }) + .join(""); + }, + renderGroups() { + const container = document.getElementById("dispatcherGroups"); + if (!this.groups.length) { + container.innerHTML = + '

No active groups available.

'; + return; + } + + container.innerHTML = this.getSortedGroups() + .map((group) => { + const isDanger = (group.status || "") === "danger"; + return ` +
+
+
+ ${group.callsign || group.groupId} + ${group.role || "group"} + ${isDanger ? 'Danger' : ""} +
+
+ ${this.buildGroupEditorButton(group.groupId)} +
+
+
+ Leader: ${group.leaderName || "Unknown"} + Status: ${group.status || "unknown"} +
+
+ Org: ${group.orgId || "default"} + Task: ${group.currentTaskId || "None"} +
+
+ `; + }) + .join(""); + }, + renderActivity() { + const container = document.getElementById("dispatcherActivity"); + const requestsHTML = this.requests.length + ? this.requests + .map( + (request) => ` +
+
+ ${request.title || request.requestId || "Support Request"} + ${(request.priority || "priority").replaceAll("_", " ")} +
+

${request.summary || ""}

+
+ Group: ${request.groupCallsign || request.groupId || "Unknown"} + ${this.getRequestTypeLabel(request.type || "request")} +
+
+ ${this.buildConvertRequestButton(request.requestId || "")} + ${this.buildCloseRequestButton(request.requestId || "")} +
+
+ `, + ) + .join("") + : '

No active support requests.

'; + + const activityHTML = this.activity.length + ? this.activity + .slice() + .reverse() + .slice(0, 8) + .map( + (entry) => ` +
+
+ ${entry.type || "activity"} + ${Math.round(entry.timestamp || 0)}s +
+

${entry.message || ""}

+
+ `, + ) + .join("") + : '

No recent activity.

'; + + container.innerHTML = ` +
+
Support Requests
+ ${requestsHTML} +
+
+
Recent Activity
+ ${activityHTML} +
+ `; + }, + render() { + this.updateDangerAlert(); + this.updateRequestAlert(); + this.renderMetrics(); + this.renderOpenContracts(); + this.renderAssignedContracts(); + this.renderGroups(); + this.renderActivity(); + }, +}; diff --git a/arma/client/addons/cad/ui/ui.config.mjs b/arma/client/addons/cad/ui/ui.config.mjs index 57cb049..366f58e 100644 --- a/arma/client/addons/cad/ui/ui.config.mjs +++ b/arma/client/addons/cad/ui/ui.config.mjs @@ -23,7 +23,12 @@ export default { { name: "CAD dispatcher app", output: "cad-dispatcher.js", - sources: ["src/dispatcher.js"], + sources: [ + "src/dispatcher/formatters.js", + "src/dispatcher/modals.js", + "src/dispatcher/render.js", + "src/dispatcher/index.js", + ], }, { name: "CAD bottombar app", diff --git a/arma/server/addons/actor/functions/fnc_initActorStore.sqf b/arma/server/addons/actor/functions/fnc_initActorStore.sqf index a82e17d..5dbba52 100644 --- a/arma/server/addons/actor/functions/fnc_initActorStore.sqf +++ b/arma/server/addons/actor/functions/fnc_initActorStore.sqf @@ -4,12 +4,13 @@ * File: fnc_initActorStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-04-01 * Public: Yes * * Description: * Initializes the actor store for managing player actor data. - * Provides methods for creating, fetching, migrating, and validating actor data. + * Actor hot state is owned by the extension; SQF maintains a compatibility + * mirror for engine-adjacent consumers. * * Arguments: * None @@ -111,12 +112,112 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ GVAR(Registry) = createHashMap; ["INFO", "Actor Store Initialized!"] call EFUNC(common,log); }], + ["cacheActor", compileFinal { + params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]]]; + + if (_uid isEqualTo "" || { !(_actor isEqualType createHashMap) }) exitWith { createHashMap }; + + private _finalActor = GVAR(ActorModel) call ["migrate", [+_actor]]; + GVAR(Registry) set [_uid, _finalActor]; + _finalActor + }], + ["callHotActor", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Actor extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + _data + }], + ["loadHotActor", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _command = ["actor:hot:get", "actor:hot:init"] select _initialize; + private _actor = _self call ["callHotActor", [_command, [_uid]]]; + if (_actor isEqualTo createHashMap) exitWith { _actor }; + + _self call ["cacheActor", [_uid, _actor]] + }], + ["normalizeGetArgs", compileFinal { + params ["_rawArguments"]; + + if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { + [ + _rawArguments param [1, "", [""]], + _rawArguments param [2, "", [""]] + ] + }; + + [ + _rawArguments param [0, "", [""]], + _rawArguments param [1, "", [""]] + ] + }], + ["normalizeSetArgs", compileFinal { + params ["_rawArguments"]; + + if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { + [ + _rawArguments param [2, "", [""]], + _rawArguments param [3, "", [""]], + _rawArguments param [4, nil, [0, "", [], false, createHashMap, objNull, grpNull]], + _rawArguments param [5, false, [false]] + ] + }; + + [ + _rawArguments param [0, "", [""]], + _rawArguments param [1, "", [""]], + _rawArguments param [2, nil, [0, "", [], false, createHashMap, objNull, grpNull]], + _rawArguments param [3, false, [false]] + ] + }], + ["normalizeMSetArgs", compileFinal { + params ["_rawArguments"]; + + if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { + [ + _rawArguments param [2, "", [""]], + _rawArguments param [3, createHashMap, [createHashMap]], + _rawArguments param [4, false, [false]] + ] + }; + + [ + _rawArguments param [0, "", [""]], + _rawArguments param [1, createHashMap, [createHashMap]], + _rawArguments param [2, false, [false]] + ] + }], + ["normalizeUidArg", compileFinal { + params ["_rawArguments"]; + + if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { + _rawArguments param [1, "", [""]] + }; + + _rawArguments param [0, "", [""]] + }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); private _cached = GVAR(Registry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { [CRPC(actor,responseInitActor), [_cached], _player] call CFUNC(targetEvent); _cached }; + if !(isNil { _cached }) exitWith { + [CRPC(actor,responseInitActor), [_cached], _player] call CFUNC(targetEvent); + _cached + }; ["actor:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; if !(_isSuccess) exitWith { @@ -124,52 +225,132 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ private _fallbackActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; _fallbackActor set ["uid", _uid]; - _fallbackActor = GVAR(ActorModel) call ["migrate", [_fallbackActor]]; + _fallbackActor = _self call ["cacheActor", [_uid, _fallbackActor]]; - GVAR(Registry) set [_uid, _fallbackActor]; [CRPC(actor,responseInitActor), [_fallbackActor], _player] call CFUNC(targetEvent); - _fallbackActor }; private _finalActor = createHashMap; - if (_result == "true") then { - _finalActor = _self call ["fetch", ["actor:get", _uid]]; + _finalActor = _self call ["loadHotActor", [_uid, true]]; ["INFO", format ["Found actor for %1", _uid]] call EFUNC(common,log); } else { _finalActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; _finalActor set ["uid", _uid]; private _json = _self call ["toJSON", [_finalActor]]; - ["actor:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { + ["actor:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; + if (!_createSuccess) exitWith { ["ERROR", format ["Failed to create actor %1! Using fallback actor.", _uid]] call EFUNC(common,log); - _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; - GVAR(Registry) set [_uid, _finalActor]; + _finalActor = _self call ["cacheActor", [_uid, _finalActor]]; [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); - _finalActor }; + _finalActor = _self call ["loadHotActor", [_uid, true]]; ["INFO", format ["Created new actor for %1", _uid]] call EFUNC(common,log); }; - _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; - GVAR(Registry) set [_uid, _finalActor]; + if (_finalActor isEqualTo createHashMap) then { + _finalActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; + _finalActor set ["uid", _uid]; + }; + + _finalActor = _self call ["cacheActor", [_uid, _finalActor]]; [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); _finalActor }], + ["get", compileFinal { + call (_self get "normalizeGetArgs") params ["_uid", "_field"]; + + private _actor = _self call ["loadHotActor", [_uid, false]]; + if (_actor isEqualTo createHashMap) then { + _actor = _self call ["loadHotActor", [_uid, true]]; + }; + + if (_field isEqualTo "") exitWith { _actor }; + _actor getOrDefault [_field, nil] + }], + ["override", compileFinal { + params [ + ["_uid", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; + + if (_uid isEqualTo "" || { !(_data isEqualType createHashMap) }) exitWith { createHashMap }; + + private _actor = _self call ["callHotActor", ["actor:hot:override", [_uid, toJSON _data]]]; + if (_save && { _actor isNotEqualTo createHashMap }) then { + private _savedActor = _self call ["callHotActor", ["actor:hot:save", [_uid]]]; + if (_savedActor isNotEqualTo createHashMap) then { + _actor = _savedActor; + } else { + _actor = createHashMap; + }; + }; + + if (_actor isEqualTo createHashMap) exitWith { _actor }; + _self call ["cacheActor", [_uid, _actor]] + }], + ["set", compileFinal { + call (_self get "normalizeSetArgs") params ["_uid", "_field", "_value", "_sync"]; + + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _actor = _self call ["get", [_uid, ""]]; + if !(_actor isEqualType createHashMap) exitWith { createHashMap }; + + _actor set [_field, _value]; + private _updatedActor = _self call ["override", [_uid, _actor, _sync]]; + if !(_updatedActor isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedActor isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedActor getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + call (_self get "normalizeMSetArgs") params ["_uid", "_fieldValuePairs", "_sync"]; + + if (_uid isEqualTo "" || { !(_fieldValuePairs isEqualType createHashMap) }) exitWith { createHashMap }; + + private _actor = _self call ["get", [_uid, ""]]; + if !(_actor isEqualType createHashMap) exitWith { createHashMap }; + + { _actor set [_x, _y]; } forEach _fieldValuePairs; + private _updatedActor = _self call ["override", [_uid, _actor, _sync]]; + if !(_updatedActor isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedActor isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs + }], + ["save", compileFinal { + private _uid = call (_self get "normalizeUidArg"); + + if (_uid isEqualTo "") exitWith { createHashMap }; + private _actor = _self call ["callHotActor", ["actor:hot:save", [_uid]]]; + if (_actor isEqualTo createHashMap) exitWith { _actor }; + + _self call ["cacheActor", [_uid, _actor]] + }], + ["remove", compileFinal { + private _uid = call (_self get "normalizeUidArg"); + + if (_uid isEqualTo "") exitWith { false }; + + GVAR(Registry) deleteAt _uid; + ["actor:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + _isSuccess && { _result isEqualTo "OK" } + }], ["snapshot", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _existing = GVAR(Registry) getOrDefault [_uid, createHashMap]; - private _finalActor = +_existing; + private _finalActor = +(_self call ["get", [_uid, ""]]); - if (_finalActor isEqualTo createHashMap) then { + if (!(_finalActor isEqualType createHashMap) || (_finalActor isEqualTo createHashMap)) then { _finalActor = GVAR(ActorModel) call ["defaults", []]; _finalActor set ["uid", _uid]; }; @@ -187,10 +368,7 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ ["WARNING", format ["No player object found for %1 during actor snapshot, using cached values.", _uid]] call EFUNC(common,log); }; - _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; - GVAR(Registry) set [_uid, _finalActor]; - - _finalActor + _self call ["override", [_uid, _finalActor, false]] }] ]; diff --git a/arma/server/addons/bank/XEH_PREP.hpp b/arma/server/addons/bank/XEH_PREP.hpp index c0f781b..27385a0 100644 --- a/arma/server/addons/bank/XEH_PREP.hpp +++ b/arma/server/addons/bank/XEH_PREP.hpp @@ -1,6 +1,6 @@ PREP(initBank); PREP(initMessenger); PREP(initModel); +PREP(initPayloadBuilder); PREP(initSessionManager); PREP(initStore); -PREP(initValidator); diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index 141a418..c623f86 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -20,98 +20,47 @@ PREP_RECOMPILE_END; GVAR(BankStore) call ["hydrateSession", [_uid, _mode, _resetAuthorization]]; }] call CFUNC(addEventHandler); -[QGVAR(requestGetBank), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - - private _finalData = GVAR(BankStore) call ["get", [GVAR(Registry), _uid, _field]]; - if (_field isNotEqualTo "") then { - _finalData = createHashMapFromArray [[_field, _finalData]]; - }; - - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalData]]; -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetBank), { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Key!" }; - - private _hashMap = GVAR(BankStore) call ["set", [GVAR(Registry), "bank:update", _uid, _field, _value, _sync]]; - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _hashMap]]; -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetBank), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid field pairs!" }; - - private _hashMap = GVAR(BankStore) call ["mset", [GVAR(Registry), "bank:update", _uid, _fieldValuePairs, _sync]]; - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _hashMap]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestSaveBank), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - private _finalData = GVAR(BankStore) call ["save", [GVAR(Registry), "bank:update", _uid]]; + private _finalData = GVAR(BankStore) call ["save", [_uid]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalData]]; }] call CFUNC(addEventHandler); -[QGVAR(requestRemoveBank), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - GVAR(BankStore) call ["remove", [GVAR(Registry), _uid]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestDeposit), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - private _context = GVAR(BankValidator) call ["validateDeposit", [_uid, _amount]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankStore) call ["deposit", [_uid, _amount, _context]]; + GVAR(BankStore) call ["deposit", [_uid, _amount]]; }] call CFUNC(addEventHandler); [QGVAR(requestPayment), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - private _context = GVAR(BankValidator) call ["validatePayment", [_uid, _amount]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankStore) call ["payment", [_uid, _amount, _context]]; + GVAR(BankStore) call ["payment", [_uid, _amount]]; }] call CFUNC(addEventHandler); [QGVAR(requestSubmitPin), { params [["_uid", "", [""]], ["_pin", "", [""]]]; - private _context = GVAR(BankValidator) call ["validateSubmitPin", [_uid, _pin]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankSessionManager) call ["submitPin", [_uid, _context]]; + GVAR(BankSessionManager) call ["submitPin", [_uid, _pin]]; }] call CFUNC(addEventHandler); [QGVAR(requestTransfer), { params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; - private _context = GVAR(BankValidator) call ["validateTransfer", [_uid, _target, _from, _amount]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankStore) call ["transfer", [_uid, _target, _amount, _context]]; + GVAR(BankStore) call ["transfer", [_uid, _target, _amount, createHashMapFromArray [["sourceField", _from]]]]; }] call CFUNC(addEventHandler); [QGVAR(requestWithdraw), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - private _context = GVAR(BankValidator) call ["validateWithdraw", [_uid, _amount]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankStore) call ["withdraw", [_uid, _amount, _context]]; + GVAR(BankStore) call ["withdraw", [_uid, _amount]]; }] call CFUNC(addEventHandler); [QGVAR(requestDepositEarnings), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - private _context = GVAR(BankValidator) call ["validateDepositEarnings", [_uid, _amount]]; - if (_context isEqualTo false) exitWith {}; - GVAR(BankStore) call ["depositEarnings", [_uid, _amount, _context]]; + GVAR(BankStore) call ["depositEarnings", [_uid, _amount]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/bank/functions/fnc_initMessenger.sqf b/arma/server/addons/bank/functions/fnc_initMessenger.sqf index c3e0b90..eafb94f 100644 --- a/arma/server/addons/bank/functions/fnc_initMessenger.sqf +++ b/arma/server/addons/bank/functions/fnc_initMessenger.sqf @@ -4,7 +4,7 @@ * File: fnc_initMessenger.sqf * Author: IDSolutions * Date: 2026-03-16 - * Last Update: 2026-03-16 + * Last Update: 2026-04-02 * Public: No * * Description: @@ -25,7 +25,7 @@ #pragma hemtt ignore_variables ["_self"] GVAR(BankMessenger) = createHashMapObject [[ ["#type", "BankMessenger"], - ["buildClientAccountPatch", compileFinal { + ["buildAccountPatch", compileFinal { params [["_account", createHashMap, [createHashMap]]]; private _patch = createHashMap; @@ -45,10 +45,10 @@ GVAR(BankMessenger) = createHashMapObject [[ private _player = [_uid] call EFUNC(common,getPlayer); if (isNull _player) exitWith { false }; - [_event, [_self call ["buildClientAccountPatch", [_account]]], _player] call CFUNC(targetEvent); + [_event, [_self call ["buildAccountPatch", [_account]]], _player] call CFUNC(targetEvent); true }], - ["sendClientNotification", compileFinal { + ["sendNotification", compileFinal { params [["_uid", "", [""]], ["_type", "info", [""]], ["_title", "Bank", [""]], ["_message", "", [""]]]; if (_uid isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; @@ -59,7 +59,7 @@ GVAR(BankMessenger) = createHashMapObject [[ [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent); true }], - ["sendNotice", compileFinal { + ["sendAlert", compileFinal { params [["_uid", "", [""]], ["_type", "error", [""]], ["_message", "", [""]]]; if (_uid isEqualTo "" || { _message isEqualTo "" }) exitWith { false }; diff --git a/arma/server/addons/bank/functions/fnc_initModel.sqf b/arma/server/addons/bank/functions/fnc_initModel.sqf index 77b32eb..3642fe3 100644 --- a/arma/server/addons/bank/functions/fnc_initModel.sqf +++ b/arma/server/addons/bank/functions/fnc_initModel.sqf @@ -10,7 +10,7 @@ * Description: * Initializes the bank account data model. Provides default account * schema, player-based account creation, schema migration for - * existing accounts, and field-level validation. + * existing accounts. * * Parameter(s): * None @@ -61,30 +61,6 @@ GVAR(BankModel) = compileFinal createHashMapObject [[ } forEach _defaults; _account - }], - ["validate", compileFinal { - params [["_account", createHashMap, [createHashMap]]]; - - private _uid = _account getOrDefault ["uid", ""]; - private _name = _account getOrDefault ["name", ""]; - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - private _earnings = _account getOrDefault ["earnings", 0]; - private _pin = _account getOrDefault ["pin", 1234]; - - [_uid, _name, _bank, _cash, _earnings, _pin] try { - if (_uid isEqualTo "" || !(_uid isEqualType "")) then { throw "Invalid UID!"; }; - if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; - if (_bank < 0 || !(_bank isEqualType 0)) then { throw "Invalid Bank!"; }; - if (_cash < 0 || !(_cash isEqualType 0)) then { throw "Invalid Cash!"; }; - if (_earnings < 0 || !(_earnings isEqualType 0)) then { throw "Invalid Earnings!"; }; - if (_pin < 1000 || _pin > 9999 || !(_pin isEqualType 0)) then { throw "Invalid Pin!"; }; - } catch { - ["ERROR", format ["Failed to validate account %1!", _exception]] call EFUNC(common,log); - false - }; - - true }] ]]; diff --git a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf new file mode 100644 index 0000000..fcd4f49 --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf @@ -0,0 +1,105 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initPayloadBuilder.sqf + * Author: IDSolutions + * Date: 2026-04-02 + * Public: No + * + * Description: + * Initializes the bank payload builder for session/view shaping. + * Keeps hydrate/context construction out of BankStore so the store + * can focus on extension-backed account operations. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankPayloadBuilder) = createHashMapObject [[ + ["#type", "BankPayloadBuilder"], + ["buildOperationContext", compileFinal { + params [["_uid", "", [""]], ["_modeOverride", "", [""]]]; + + private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; + private _mode = if (_modeOverride isEqualTo "") then { + _session getOrDefault ["mode", "bank"] + } else { + GVAR(BankSessionManager) call ["resolveMode", [_modeOverride]] + }; + + createHashMapFromArray [ + ["mode", _mode], + ["atmAuthorized", _session getOrDefault ["atmAuthorized", false]] + ] + }], + ["buildTransferContext", compileFinal { + params [["_uid", "", [""]], ["_from", "", [""]]]; + + private _context = _self call ["buildOperationContext", [_uid]]; + _context set ["fromField", _from]; + _context + }], + ["resolveOrgState", compileFinal { + params [["_uid", "", [""]]]; + + private _defaultState = createHashMapFromArray [["funds", 0], ["name", ""]]; + if (_uid isEqualTo "") exitWith { _defaultState }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) then { + _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; + }; + if (_org isEqualTo createHashMap) exitWith { _defaultState }; + + createHashMapFromArray [["funds", _org getOrDefault ["funds", 0]], ["name", _org getOrDefault ["name", ""]]] + }], + ["buildTransferTargets", compileFinal { + params [["_sourceUid", "", [""]]]; + + private _targets = []; + { + if (isNull _x) then { continue; }; + private _targetUid = getPlayerUID _x; + private _targetName = name _x; + if (_targetUid isEqualTo "" || { _targetUid isEqualTo _sourceUid } || { _targetName isEqualTo "" }) then { continue; }; + _targets pushBack (createHashMapFromArray [["name", _targetName], ["uid", _targetUid]]); + } forEach allPlayers; + + private _targetPairs = _targets apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] }; + _targetPairs sort true; + _targetPairs apply { _x param [1, createHashMap] } + }], + ["buildHydratePayload", compileFinal { + params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _account = GVAR(BankStore) call ["get", [_uid, ""]]; + if (_account isEqualTo createHashMap) then { + _account = GVAR(BankStore) call ["init", [_uid]]; + }; + if (_account isEqualTo createHashMap) exitWith { createHashMap }; + + private _session = GVAR(BankSessionManager) call ["syncSessionMode", [_uid, _mode, _resetAuthorization]]; + private _orgState = _self call ["resolveOrgState", [_uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); + private _playerName = if (isNull _player) then { _account getOrDefault ["name", "Unknown"] } else { name _player }; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["atmAuthorized", _session getOrDefault ["atmAuthorized", false]], + ["mode", _session getOrDefault ["mode", "bank"]], + ["orgFunds", _orgState getOrDefault ["funds", 0]], + ["orgName", _orgState getOrDefault ["name", ""]], + ["playerName", _playerName], + ["transferTargets", _self call ["buildTransferTargets", [_uid]]], + ["uid", _uid] + ]], + ["account", GVAR(BankMessenger) call ["buildAccountPatch", [_account]]] + ] + }] +]]; + +GVAR(BankPayloadBuilder) diff --git a/arma/server/addons/bank/functions/fnc_initSessionManager.sqf b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf index 75ea131..dc9077e 100644 --- a/arma/server/addons/bank/functions/fnc_initSessionManager.sqf +++ b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf @@ -4,7 +4,7 @@ * File: fnc_initSessionManager.sqf * Author: IDSolutions * Date: 2026-03-16 - * Last Update: 2026-03-16 + * Last Update: 2026-04-02 * Public: No * * Description: @@ -82,10 +82,18 @@ GVAR(BankSessionManager) = createHashMapObject [[ ]]] }], ["submitPin", compileFinal { - params [["_uid", "", [""]], ["_context", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]], ["_pin", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + _self call ["setSessionState", [_uid, createHashMapFromArray [["atmAuthorized", false], ["mode", "atm"]]]]; + if !(GVAR(BankStore) call ["validatePin", [_uid, _pin]]) exitWith { + GVAR(BankStore) call ["hydrateSession", [_uid, "atm", false]]; + false + }; _self call ["setSessionState", [_uid, createHashMapFromArray [["atmAuthorized", true], ["mode", "atm"]]]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", "ATM access granted."]]; + GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", "ATM access granted."]]; GVAR(BankStore) call ["hydrateSession", [_uid, "atm", false]]; true }] diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf index b5b62af..8f7b9e0 100644 --- a/arma/server/addons/bank/functions/fnc_initStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -4,22 +4,13 @@ * File: fnc_initStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-03-16 + * Last Update: 2026-04-02 * Public: No * * Description: * Initializes the bank store for managing player bank accounts. - * Handles account lifecycle (init/fetch/create/migrate), transaction - * mutations, checkout charges, and session hydration. - * - * Parameter(s): - * None - * - * Returns: - * Bank store object [HASHMAP OBJECT] - * - * Example(s): - * call forge_server_bank_fnc_initStore + * Bank account truth lives in the extension hot cache; SQF handles + * session state, Arma-facing validation, and client messaging. */ #pragma hemtt ignore_variables ["_self"] @@ -27,76 +18,133 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "BankBaseStore"], ["#create", compileFinal { - GVAR(IndexRegistry) = createHashMap; - GVAR(Registry) = createHashMap; GVAR(SessionRegistry) = createHashMap; ["INFO", "Bank Store Initialized!"] call EFUNC(common,log); }], - ["buildChargeResult", compileFinal { - params [["_message", "Unable to process bank payment.", [""]]]; + ["normalizeAccount", compileFinal { + params [["_uid", "", [""]], ["_account", createHashMap, [createHashMap]], ["_playerName", "", [""]]]; - createHashMapFromArray [ - ["success", false], - ["message", _message], - ["patch", createHashMap] - ] + if (_uid isEqualTo "" || { !(_account isEqualType createHashMap) }) exitWith { createHashMap }; + + private _finalAccount = GVAR(BankModel) call ["migrate", [+_account]]; + if ((_finalAccount getOrDefault ["uid", ""]) isEqualTo "") then { + _finalAccount set ["uid", _uid]; + }; + if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "" && { _playerName isNotEqualTo "" }) then { + _finalAccount set ["name", _playerName]; + }; + + _finalAccount }], - ["buildHydratePayload", compileFinal { - params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; + ["callHotBank", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + private _envelope = _self call ["callHotBankEnvelope", [_function, _arguments]]; + _envelope getOrDefault ["data", createHashMap] + }], + ["callHotBankEnvelope", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + private _envelope = createHashMapFromArray [["data", createHashMap], ["error", ""]]; + + if (_function isEqualTo "") exitWith { _envelope }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + _envelope set ["error", format ["Bank backend call '%1' failed.", _function]]; + _envelope + }; + if !(_result isEqualType "") exitWith { + _envelope set ["error", format ["Bank backend call '%1' returned an invalid response.", _function]]; + _envelope + }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Bank extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + _envelope set ["error", _result select [7]]; + _envelope + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { + _envelope set ["error", format ["Bank backend call '%1' returned unreadable JSON.", _function]]; + _envelope + }; + + _envelope set ["data", _data]; + _envelope + }], + ["loadHotBank", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]], ["_playerName", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; - private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; - if (_account isEqualTo createHashMap) then { _account = _self call ["init", [_uid]]; }; - if (_account isEqualTo createHashMap) exitWith { createHashMap }; + private _command = ["bank:hot:get", "bank:hot:init"] select _initialize; + private _account = _self call ["callHotBank", [_command, [_uid]]]; + if (_account isEqualTo createHashMap) exitWith { _account }; - private _session = GVAR(BankSessionManager) call ["syncSessionMode", [_uid, _mode, _resetAuthorization]]; - private _orgState = _self call ["resolveOrgState", [_uid]]; - private _player = [_uid] call EFUNC(common,getPlayer); - private _playerName = if (isNull _player) then { - _account getOrDefault ["name", "Unknown"] - } else { - name _player + _self call ["normalizeAccount", [_uid, _account, _playerName]] + }], + ["finalizeMutation", compileFinal { + params [ + ["_uid", "", [""]], + ["_result", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; + + if (_uid isEqualTo "" || { _result isEqualTo createHashMap }) exitWith { createHashMap }; + + private _account = _result getOrDefault ["account", createHashMap]; + private _patch = _result getOrDefault ["patch", createHashMap]; + + if !(_patch isEqualType createHashMap) then { + _patch = createHashMap; }; - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["atmAuthorized", _session getOrDefault ["atmAuthorized", false]], - ["mode", _session getOrDefault ["mode", "bank"]], - ["orgFunds", _orgState getOrDefault ["funds", 0]], - ["orgName", _orgState getOrDefault ["name", ""]], - ["playerName", _playerName], - ["transferTargets", _self call ["buildTransferTargets", [_uid]]], - ["uid", _uid] - ]], - ["account", GVAR(BankMessenger) call ["buildClientAccountPatch", [_account]]] - ] + if (_save && { _account isNotEqualTo createHashMap }) then { + private _savedAccount = _self call ["callHotBank", ["bank:hot:save", [_uid]]]; + if (_savedAccount isEqualTo createHashMap) exitWith { createHashMap }; + _account = _savedAccount; + }; + + if (_account isNotEqualTo createHashMap) then { + _self call ["normalizeAccount", [_uid, _account, ""]]; + }; + + _patch }], - ["buildTransferTargets", compileFinal { - params [["_sourceUid", "", [""]]]; + ["runMutation", compileFinal { + params [ + ["_uid", "", [""]], + ["_command", "", [""]], + ["_arguments", [], [[]]], + ["_save", false, [false]], + ["_notification", "", [""]] + ]; - private _targets = []; - { - if (isNull _x) then { continue; }; + if (_uid isEqualTo "" || { _command isEqualTo "" }) exitWith { false }; - private _targetUid = getPlayerUID _x; - private _targetName = name _x; - if (_targetUid isEqualTo "" || { _targetUid isEqualTo _sourceUid } || { _targetName isEqualTo "" }) then { continue; }; + private _envelope = _self call ["callHotBankEnvelope", [_command, _arguments]]; + private _result = _envelope getOrDefault ["data", createHashMap]; + private _finalPatch = _self call ["finalizeMutation", [_uid, _result, _save]]; + if (_finalPatch isEqualTo createHashMap) exitWith { + private _message = _envelope getOrDefault ["error", "Bank operation failed."]; + if (_message isNotEqualTo "") then { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]]; + }; + false + }; - _targets pushBack (createHashMapFromArray [ - ["name", _targetName], - ["uid", _targetUid] - ]); - } forEach allPlayers; + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; + if (_notification isNotEqualTo "") then { + GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", _notification]]; + }; - private _targetPairs = _targets apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] }; - _targetPairs sort true; - _targetPairs apply { _x param [1, createHashMap] } + true }], ["chargeCheckout", compileFinal { params [["_uid", "", [""]], ["_source", "cash", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; - private _result = _self call ["buildChargeResult", []]; + private _result = createHashMapFromArray [["success", false], ["message", "Unable to process bank payment."], ["patch", createHashMap]]; private _field = switch (toLowerANSI _source) do { case "cash": { "cash" }; case "bank": { "bank" }; @@ -108,7 +156,7 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + private _account = _self call ["get", [_uid, ""]]; if (_account isEqualTo createHashMap) exitWith { _result set ["message", "Bank account data is unavailable for checkout."]; _result @@ -116,18 +164,14 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _balance = _account getOrDefault [_field, 0]; if (_balance < _amount) exitWith { - private _message = [ - "Bank balance cannot cover this checkout.", - "Cash on hand cannot cover this checkout." - ] select (_field isEqualTo "cash"); - - _result set ["message", _message]; + _result set ["message", ["Bank balance cannot cover this checkout.", "Cash on hand cannot cover this checkout."] select (_field isEqualTo "cash")]; _result }; private _patch = createHashMapFromArray [[_field, (_balance - _amount)]]; if (_commit) then { - _patch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + private _result = _self call ["callHotBank", ["bank:hot:patch", [_uid, toJSON _patch]]]; + _patch = _self call ["finalizeMutation", [_uid, _result, false]]; }; _result set ["success", true]; @@ -136,27 +180,23 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ _result }], ["deposit", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]], ["_amount", 0, [0]]]; - ["INFO", format ["Deposit %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _bank = _context getOrDefault ["bank", 0]; - private _cash = _context getOrDefault ["cash", 0]; - - private _patch = createHashMapFromArray [ - ["bank", (_bank + _amount)], - ["cash", (_cash - _amount)] - ]; - private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; - - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1", [_amount] call EFUNC(common,formatNumber)]]]; - true + _self call [ + "runMutation", + [ + _uid, + "bank:hot:deposit", + [_uid, str _amount, toJSON (GVAR(BankPayloadBuilder) call ["buildOperationContext", [_uid]])], + false, + format ["Deposited $%1", [_amount] call EFUNC(common,formatNumber)] + ] + ] }], ["hydrateSession", compileFinal { params [["_uid", "", [""]], ["_mode", "", [""]], ["_resetAuthorization", false, [false]]]; - private _payload = _self call ["buildHydratePayload", [_uid, _mode, _resetAuthorization]]; + private _payload = GVAR(BankPayloadBuilder) call ["buildHydratePayload", [_uid, _mode, _resetAuthorization]]; if (_payload isEqualTo createHashMap) exitWith { false }; private _player = [_uid] call EFUNC(common,getPlayer); @@ -172,12 +212,6 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _player = [_uid] call EFUNC(common,getPlayer); private _playerName = if (isNull _player) then { "Unknown" } else { name _player }; - private _cached = GVAR(Registry) getOrDefault [_uid, createHashMap]; - if (_cached isNotEqualTo createHashMap) exitWith { - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _cached, CRPC(bank,responseInitBank)]]; - _cached - }; - ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; if !(_isSuccess) exitWith { ["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log); @@ -188,17 +222,14 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ _fallbackAccount set ["name", _playerName]; }; - private _regEntry = createHashMapFromArray [["uid", _uid], ["name", _playerName]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - GVAR(Registry) set [_uid, _fallbackAccount]; - + _fallbackAccount = _self call ["normalizeAccount", [_uid, _fallbackAccount, _playerName]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _fallbackAccount, CRPC(bank,responseInitBank)]]; _fallbackAccount }; private _finalAccount = createHashMap; if (_result isEqualTo "true") then { - _finalAccount = _self call ["fetch", ["bank:get", _uid]]; + _finalAccount = _self call ["loadHotBank", [_uid, true, _playerName]]; ["INFO", format ["Found bank account for %1", _uid]] call EFUNC(common,log); } else { _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; @@ -212,137 +243,180 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ if (!_createSuccess) exitWith { ["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log); - private _regEntry = createHashMapFromArray [["uid", _uid], ["name", _playerName]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - GVAR(Registry) set [_uid, _finalAccount]; - + _finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]]; _finalAccount }; + _finalAccount = _self call ["loadHotBank", [_uid, true, _playerName]]; ["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log); }; - _finalAccount = GVAR(BankModel) call ["migrate", [_finalAccount]]; - if ((_finalAccount getOrDefault ["uid", ""]) isEqualTo "") then { + if (_finalAccount isEqualTo createHashMap) then { + _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; _finalAccount set ["uid", _uid]; - }; - if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then { - _finalAccount set ["name", _playerName]; + if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then { + _finalAccount set ["name", _playerName]; + }; }; - GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["uid", _uid], ["name", _playerName]]]; - GVAR(Registry) set [_uid, _finalAccount]; - + _finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]]; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]]; _finalAccount }], - ["payment", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + ["get", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]]]; - ["INFO", format ["Payment %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _bank = _context getOrDefault ["bank", 0]; - private _patch = createHashMapFromArray [["bank", (_bank + _amount)]]; - private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; - - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Paid $%1", [_amount] call EFUNC(common,formatNumber)]]]; - true - }], - ["resolveOrgState", compileFinal { - params [["_uid", "", [""]]]; - - private _defaultState = createHashMapFromArray [ - ["funds", 0], - ["name", ""] - ]; - if (_uid isEqualTo "") exitWith { _defaultState }; - - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - - private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) then { - _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; + private _account = _self call ["loadHotBank", [_uid, false, ""]]; + if (_account isEqualTo createHashMap) then { + _account = _self call ["loadHotBank", [_uid, true, ""]]; }; - if (_org isEqualTo createHashMap) exitWith { _defaultState }; - createHashMapFromArray [ - ["funds", _org getOrDefault ["funds", 0]], - ["name", _org getOrDefault ["name", ""]] + if (_field isEqualTo "") exitWith { _account }; + _account getOrDefault [_field, nil] + }], + ["set", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + _self call ["mset", [_uid, createHashMapFromArray [[_field, _value]], _sync]] + }], + ["mset", compileFinal { + params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; + + if (_uid isEqualTo "" || { !(_fieldValuePairs isEqualType createHashMap) }) exitWith { createHashMap }; + + private _result = _self call ["callHotBank", ["bank:hot:patch", [_uid, toJSON _fieldValuePairs]]]; + _self call ["finalizeMutation", [_uid, _result, _sync]] + }], + ["save", compileFinal { + params [["_uid", "", [""]]]; + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _account = _self call ["callHotBank", ["bank:hot:save", [_uid]]]; + if (_account isEqualTo createHashMap) exitWith { _account }; + + _self call ["normalizeAccount", [_uid, _account, ""]] + }], + ["payment", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + _self call [ + "runMutation", + [ + _uid, + "bank:hot:payment", + [_uid, str _amount], + false, + format ["Paid $%1", [_amount] call EFUNC(common,formatNumber)] + ] ] }], ["transfer", compileFinal { params [["_uid", "", [""]], ["_target", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; - private _account = _context getOrDefault ["account", createHashMap]; - private _targetAccount = _context getOrDefault ["targetAccount", createHashMap]; - private _sourceField = _context getOrDefault ["sourceField", "bank"]; - private _selected = _context getOrDefault ["sourceBalance", 0]; - private _targetBank = _context getOrDefault ["targetBank", 0]; + private _transferContext = GVAR(BankPayloadBuilder) call ["buildTransferContext", [_uid, _context getOrDefault ["sourceField", "bank"]]]; + private _envelope = _self call [ + "callHotBankEnvelope", + [ + "bank:hot:transfer", + [_uid, _target, str _amount, toJSON _transferContext] + ] + ]; + private _result = _envelope getOrDefault ["data", createHashMap]; + if (_result isEqualTo createHashMap) exitWith { false }; - private _sourcePatch = createHashMapFromArray [[_sourceField, (_selected - _amount)]]; - private _targetPatch = createHashMapFromArray [["bank", (_targetBank + _amount)]]; - private _finalSourcePatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _sourcePatch, false]]; - private _finalTargetPatch = _self call ["mset", [GVAR(Registry), "bank:update", _target, _targetPatch, false]]; + private _sourceAccount = _result getOrDefault ["sourceAccount", createHashMap]; + private _targetAccount = _result getOrDefault ["targetAccount", createHashMap]; + private _finalSourcePatch = _result getOrDefault ["sourcePatch", createHashMap]; + private _finalTargetPatch = _result getOrDefault ["targetPatch", createHashMap]; + + if ( + _finalSourcePatch isEqualTo createHashMap + || { _finalTargetPatch isEqualTo createHashMap } + ) exitWith { + private _message = _envelope getOrDefault ["error", "Bank transfer failed."]; + if (_message isNotEqualTo "") then { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]]; + }; + false + }; + + if (_sourceAccount isEqualType createHashMap && { _sourceAccount isNotEqualTo createHashMap }) then { + _self call ["normalizeAccount", [_uid, _sourceAccount, ""]]; + }; + if (_targetAccount isEqualType createHashMap && { _targetAccount isNotEqualTo createHashMap }) then { + _self call ["normalizeAccount", [_target, _targetAccount, ""]]; + }; GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalSourcePatch]]; GVAR(BankMessenger) call ["sendAccountSync", [_target, _finalTargetPatch]]; + private _contextTargetAccount = _context getOrDefault ["targetAccount", createHashMap]; + private _contextAccount = _context getOrDefault ["account", createHashMap]; private _targetPlayer = [_target] call EFUNC(common,getPlayer); - private _targetName = if (isNull _targetPlayer) then { - _targetAccount getOrDefault ["name", "Recipient"] - } else { - name _targetPlayer - }; + private _targetName = if (isNull _targetPlayer) then { _contextTargetAccount getOrDefault ["name", "Recipient"] } else { name _targetPlayer }; private _player = [_uid] call EFUNC(common,getPlayer); - private _playerName = if (isNull _player) then { - _account getOrDefault ["name", "Unknown"] - } else { - name _player + private _playerName = if (isNull _player) then { _contextAccount getOrDefault ["name", "Unknown"] } else { name _player }; + + GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", format ["Transferred $%1 to %2", [_amount] call EFUNC(common,formatNumber), _targetName]]]; + GVAR(BankMessenger) call ["sendNotification", [_target, "info", "Bank", format ["Received $%1 from %2", [_amount] call EFUNC(common,formatNumber), _playerName]]]; + true + }], + ["validatePin", compileFinal { + params [["_uid", "", [""]], ["_pin", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + private _enteredPin = _pin; + if !(_enteredPin isEqualType "") then { + _enteredPin = str _enteredPin; }; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Transferred $%1 to %2", [_amount] call EFUNC(common,formatNumber), _targetName]]]; - GVAR(BankMessenger) call ["sendClientNotification", [_target, "info", "Bank", format ["Received $%1 from %2", [_amount] call EFUNC(common,formatNumber), _playerName]]]; - true + private _envelope = _self call [ + "callHotBankEnvelope", + [ + "bank:hot:validate_pin", + [_uid, _enteredPin, toJSON (GVAR(BankPayloadBuilder) call ["buildOperationContext", [_uid, "atm"]])] + ] + ]; + + private _message = _envelope getOrDefault ["error", ""]; + if (_message isNotEqualTo "") then { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]]; + false + } else { + true + } }], ["withdraw", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]], ["_amount", 0, [0]]]; - ["INFO", format ["Withdraw %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _bank = _context getOrDefault ["bank", 0]; - private _cash = _context getOrDefault ["cash", 0]; - - private _patch = createHashMapFromArray [ - ["bank", (_bank - _amount)], - ["cash", (_cash + _amount)] - ]; - private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; - - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Withdrew $%1", [_amount] call EFUNC(common,formatNumber)]]]; - true + _self call [ + "runMutation", + [ + _uid, + "bank:hot:withdraw", + [_uid, str _amount, toJSON (GVAR(BankPayloadBuilder) call ["buildOperationContext", [_uid]])], + false, + format ["Withdrew $%1", [_amount] call EFUNC(common,formatNumber)] + ] + ] }], ["depositEarnings", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]], ["_amount", 0, [0]]]; - ["INFO", format ["Deposit Earnings %1, for %2", _amount, _uid]] call EFUNC(common,log); - - private _bank = _context getOrDefault ["bank", 0]; - private _earnings = _context getOrDefault ["earnings", 0]; - - private _patch = createHashMapFromArray [ - ["bank", (_bank + _amount)], - ["earnings", (_earnings - _amount)] - ]; - private _finalPatch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; - - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalPatch]]; - GVAR(BankMessenger) call ["sendClientNotification", [_uid, "info", "Bank", format ["Deposited $%1 from earnings", [_amount] call EFUNC(common,formatNumber)]]]; - true + _self call [ + "runMutation", + [ + _uid, + "bank:hot:deposit_earnings", + [_uid, str _amount, toJSON (GVAR(BankPayloadBuilder) call ["buildOperationContext", [_uid]])], + false, + format ["Deposited $%1 from earnings", [_amount] call EFUNC(common,formatNumber)] + ] + ] }] ]; diff --git a/arma/server/addons/bank/functions/fnc_initValidator.sqf b/arma/server/addons/bank/functions/fnc_initValidator.sqf deleted file mode 100644 index 0bf06da..0000000 --- a/arma/server/addons/bank/functions/fnc_initValidator.sqf +++ /dev/null @@ -1,259 +0,0 @@ -#include "..\script_component.hpp" - -/* - * File: fnc_validator.sqf - * Author: IDSolutions - * Date: 2026-03-16 - * Last Update: 2026-03-16 - * Public: No - * - * Description: - * Initializes the bank validator for pre-checking action payloads - * before they reach the bank store. Each method uses try/catch to - * validate inputs and state, sending a notice to the player on - * failure and returning false. On success returns a context hashmap - * containing resolved data (account, balances, etc.) for the store. - * - * Parameter(s): - * None - * - * Returns: - * Validator object [HASHMAP OBJECT] - * - * Example(s): - * call forge_server_bank_fnc_validator - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(BankValidator) = createHashMapObject [[ - ["#type", "BankValidator"], - ["resolveAccount", compileFinal { - params [["_uid", "", [""]]]; - - private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; - if (_account isEqualTo createHashMap) then { - throw "Bank account data is unavailable."; - }; - - _account - }], - ["validateDeposit", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - private _context = createHashMap; - - [_uid, _amount] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - if (_amount <= 0) then { throw "Enter a valid deposit amount." }; - - private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; - if ((_session getOrDefault ["mode", "bank"]) isEqualTo "atm") then { - if !(_session getOrDefault ["atmAuthorized", false]) then { - throw "ATM authorization is required before deposit."; - }; - }; - - private _account = _self call ["resolveAccount", [_uid]]; - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - - if (_cash < _amount) then { throw "Cash on hand cannot cover that deposit." }; - - _context set ["account", _account]; - _context set ["bank", _bank]; - _context set ["cash", _cash]; - } catch { - ["ERROR", format ["Deposit validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }], - ["validateWithdraw", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - private _context = createHashMap; - - [_uid, _amount] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - if (_amount <= 0) then { throw "Enter a valid withdrawal amount." }; - - private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; - if ((_session getOrDefault ["mode", "bank"]) isEqualTo "atm") then { - if !(_session getOrDefault ["atmAuthorized", false]) then { - throw "ATM authorization is required before withdrawal."; - }; - }; - - private _account = _self call ["resolveAccount", [_uid]]; - private _bank = _account getOrDefault ["bank", 0]; - private _cash = _account getOrDefault ["cash", 0]; - - if (_bank < _amount) then { throw "Bank balance cannot cover that withdrawal." }; - - _context set ["account", _account]; - _context set ["bank", _bank]; - _context set ["cash", _cash]; - } catch { - ["ERROR", format ["Withdraw validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }], - ["validateTransfer", compileFinal { - params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; - - private _context = createHashMap; - - [_uid, _target, _from, _amount] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - if (_uid isEqualTo _target) then { throw "You cannot transfer funds to yourself." }; - if (_amount <= 0) then { throw "Enter a valid transfer amount." }; - - private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; - if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "bank") then { - throw "Transfers are only available from the full bank interface."; - }; - - private _account = _self call ["resolveAccount", [_uid]]; - - private _targetAccount = GVAR(Registry) getOrDefault [_target, createHashMap]; - if (_targetAccount isEqualTo createHashMap) then { - _targetAccount = GVAR(BankStore) call ["init", [_target]]; - }; - if (_targetAccount isEqualTo createHashMap) then { - throw "Selected transfer recipient is unavailable."; - }; - - private _sourceField = ["bank", "cash"] select (toLowerANSI _from isEqualTo "cash"); - private _selected = _account getOrDefault [_sourceField, 0]; - if (_selected < _amount) then { - private _message = [ - "Bank balance cannot cover that transfer.", - "Cash on hand cannot cover that transfer." - ] select (_sourceField isEqualTo "cash"); - throw _message; - }; - - _context set ["account", _account]; - _context set ["targetAccount", _targetAccount]; - _context set ["sourceField", _sourceField]; - _context set ["sourceBalance", _selected]; - _context set ["targetBank", _targetAccount getOrDefault ["bank", 0]]; - } catch { - ["ERROR", format ["Transfer validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }], - ["validateDepositEarnings", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - private _context = createHashMap; - - [_uid, _amount] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - - private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; - if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "bank") then { - throw "Earnings deposits are only available from the full bank interface."; - }; - - if (_amount <= 0) then { throw "No earnings are available to deposit." }; - - private _account = _self call ["resolveAccount", [_uid]]; - private _bank = _account getOrDefault ["bank", 0]; - private _earnings = _account getOrDefault ["earnings", 0]; - - if (_earnings < _amount) then { throw "Pending earnings cannot cover that deposit request." }; - - _context set ["account", _account]; - _context set ["bank", _bank]; - _context set ["earnings", _earnings]; - } catch { - ["ERROR", format ["DepositEarnings validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }], - ["validatePayment", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - private _context = createHashMap; - - [_uid, _amount] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - if (_amount <= 0) then { throw "Enter a valid payment amount." }; - - private _account = _self call ["resolveAccount", [_uid]]; - private _bank = _account getOrDefault ["bank", 0]; - - _context set ["account", _account]; - _context set ["bank", _bank]; - } catch { - ["ERROR", format ["Payment validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }], - ["validateSubmitPin", compileFinal { - params [["_uid", "", [""]], ["_pin", "", [""]]]; - - private _context = createHashMap; - - [_uid, _pin] try { - if (_uid isEqualTo "") then { throw "Empty/Invalid UID!" }; - - private _session = GVAR(BankSessionManager) call ["getSessionState", [_uid]]; - if ((_session getOrDefault ["mode", "bank"]) isNotEqualTo "atm") then { - _session = GVAR(BankSessionManager) call ["setSessionState", [_uid, createHashMapFromArray [ - ["atmAuthorized", false], - ["mode", "atm"] - ]]]; - }; - - private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; - if (_account isEqualTo createHashMap) then { - _account = GVAR(BankStore) call ["init", [_uid]]; - }; - if (_account isEqualTo createHashMap) then { - throw "Bank account data is unavailable."; - }; - - private _enteredPin = _pin; - if !(_enteredPin isEqualType "") then { - _enteredPin = str _enteredPin; - }; - if ((count _enteredPin) isNotEqualTo 4) then { - throw "Enter your four-digit access PIN."; - }; - - private _accountPin = str (_account getOrDefault ["pin", 1234]); - if (_enteredPin isNotEqualTo _accountPin) then { - GVAR(BankSessionManager) call ["setSessionState", [_uid, createHashMapFromArray [["atmAuthorized", false]]]]; - throw "Incorrect PIN."; - }; - - _context set ["account", _account]; - _context set ["session", _session]; - } catch { - ["ERROR", format ["SubmitPin validation failed: %1", _exception]] call EFUNC(common,log); - GVAR(BankMessenger) call ["sendNotice", [_uid, "error", _exception]]; - GVAR(BankStore) call ["hydrateSession", [_uid, "atm", false]]; - }; - - if (_context isEqualTo createHashMap) exitWith { false }; - _context - }] -]]; - -GVAR(BankValidator) diff --git a/arma/server/addons/cad/functions/fnc_initPermissionService.sqf b/arma/server/addons/cad/functions/fnc_initPermissionService.sqf index 8a7f02d..07fea10 100644 --- a/arma/server/addons/cad/functions/fnc_initPermissionService.sqf +++ b/arma/server/addons/cad/functions/fnc_initPermissionService.sqf @@ -31,7 +31,7 @@ GVAR(PermissionServiceBaseClass) = compileFinal createHashMapFromArray [ if (_actor isEqualTo createHashMap) exitWith { false }; private _orgID = _actor getOrDefault ["organization", "default"]; - private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; if (_org isEqualTo createHashMap) exitWith { false }; private _owner = _org getOrDefault ["owner", ""]; diff --git a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf index 7225a09..d6edd84 100644 --- a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf @@ -69,9 +69,12 @@ GVAR(MEconomyStore) = createHashMapObject [[ 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,Registry) get _uid; + private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; + if (_account isEqualTo createHashMap) then { + _account = EGVAR(bank,BankStore) call ["init", [_uid]]; + }; - if (isNil "_account") exitWith { ["ERROR", format ["No account found for %1. UID: %2", (name _unit), _uid], nil, nil] call EFUNC(common,log); }; + 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"; diff --git a/arma/server/addons/extension/XEH_PREP.hpp b/arma/server/addons/extension/XEH_PREP.hpp index c9a683a..7fa64fc 100644 --- a/arma/server/addons/extension/XEH_PREP.hpp +++ b/arma/server/addons/extension/XEH_PREP.hpp @@ -1,2 +1,3 @@ PREP(extCall); PREP(setHandler); +PREP(transport); diff --git a/arma/server/addons/extension/functions/fnc_extCall.sqf b/arma/server/addons/extension/functions/fnc_extCall.sqf index b2bab27..e91ae1a 100644 --- a/arma/server/addons/extension/functions/fnc_extCall.sqf +++ b/arma/server/addons/extension/functions/fnc_extCall.sqf @@ -4,7 +4,7 @@ * File: fnc_extCall.sqf * Author: IDSolutions * Date: 2026-01-03 - * Last Update: 2026-01-03 + * Last Update: 2026-04-01 * Public: No * * Description: @@ -27,14 +27,91 @@ params [["_function", "", [""]], ["_arguments", [], [[]]]]; ["INFO", format ["Calling function: %1", _function], nil, nil] call EFUNC(common,log); private _functionLower = toLower _function; +private _chunkPrefix = "FORGE_TRANSPORT_CHUNK:"; +private _chunkPrefixLength = count toArray _chunkPrefix; +private _unsupportedRoutePrefix = "Error: Unsupported transport route"; +private _requestChunkSize = 12000; +private _transportResponseFunctions = [ + "actor:get", + "actor:create", + "actor:update", + "actor:hot:init", + "actor:hot:get", + "actor:hot:save", + "bank:get", + "bank:create", + "bank:update", + "bank:hot:init", + "bank:hot:get", + "bank:hot:save", + "cad:view:hydrate", + "cad:groups:build", + "cad:assignments:list", + "cad:orders:list", + "cad:requests:list", + "cad:activity:recent", + "org:members:get", + "org:assets:get", + "org:fleet:get" +]; private _requiresRedis = !(_functionLower in ["status", "version"]) && (_functionLower find "icom:" == 0) && (_functionLower find "terrain:" == 0); -if (_requiresRedis) then { - ("forge_server" callExtension ["status", []]) params ["_redisStatus", "_statusExtCode", "_statusArmaCode"]; +private _callExtensionCommand = { + params [["_command", "", [""]], ["_commandArguments", [], [[]]]]; + + ("forge_server" callExtension [_command, _commandArguments]) params [ + "_response", + "_responseExtCode", + "_responseArmaCode" + ]; + + private _responseSuccess = true; + + if (_responseArmaCode != 0 && _responseArmaCode != 301) then { + _responseSuccess = false; + + private _armaCodeMessage = createHashMapFromArray [ + [101, "SYNTAX_ERROR_WRONG_PARAMS_SIZE"], + [102, "SYNTAX_ERROR_WRONG_PARAMS_TYPE"], + [201, "PARAMS_ERROR_TOO_MANY_ARGS"], + [400, "EXTENSION_LOAD_FAILED"], + [403, "EXTENSION_BLOCKED_BY_BATTLEYE"], + [404, "EXTENSION_NOT_FOUND"] + ] getOrDefault [_responseArmaCode, format ["UNKNOWN_%1", _responseArmaCode]]; + + ["WARNING", format ["Arma error: %1", _armaCodeMessage], nil, nil] call EFUNC(common,log); + }; + + if (_responseExtCode != 0) then { + _responseSuccess = false; + + if (_responseExtCode == -1) exitWith { + ["WARNING", "Extension not available", nil, nil] call EFUNC(common,log); + [_response, false] + }; + + if (_responseExtCode == 9) exitWith { + ["WARNING", format ["Extension error: %1", _response], nil, nil] call EFUNC(common,log); + [_response, false] + }; + + ["WARNING", format ["Extension error: %1", _responseExtCode], nil, nil] call EFUNC(common,log); + }; + + [_response, _responseSuccess] +}; + +private _checkRedisAvailability = { + ("forge_server" callExtension ["status", []]) params [ + "_redisStatus", + "_statusExtCode", + "_statusArmaCode" + ]; private _statusSuccess = (_statusExtCode == 0) && (_statusArmaCode == 0 || _statusArmaCode == 301); + if (!_statusSuccess) exitWith { ["WARNING", "Unable to determine Redis status before extension call", nil, nil] call EFUNC(common,log); ["Error: Redis status check failed", false] @@ -44,32 +121,81 @@ if (_requiresRedis) then { ["WARNING", format ["Blocked extension call '%1' because Redis status is '%2'", _function, _redisStatus], nil, nil] call EFUNC(common,log); [format ["Error: Redis is %1", _redisStatus], false] }; + + ["", true] }; -("forge_server" callExtension [_function, _arguments]) params ["_result", "_extCode", "_armaCode"]; +private _buildTransportArgumentsJson = { + params [["_rawArguments", [], [[]]]]; -private _success = true; + private _stringArguments = _rawArguments apply { + if (_x isEqualType "") exitWith { _x }; + if (_x isEqualType true) exitWith { ["false", "true"] select _x }; + str _x + }; -if (_armaCode != 0 && _armaCode != 301) then { - _success = false; - private _armaCodeMessage = createHashMapFromArray [ - [101, "SYNTAX_ERROR_WRONG_PARAMS_SIZE"], - [102, "SYNTAX_ERROR_WRONG_PARAMS_TYPE"], - [201, "PARAMS_ERROR_TOO_MANY_ARGS"], - // [301, "EXECUTION_WARNING_TAKES_TOO_LONG"], - [400, "EXTENSION_LOAD_FAILED"], - [403, "EXTENSION_BLOCKED_BY_BATTLEYE"], - [404, "EXTENSION_NOT_FOUND"] - ] getOrDefault [_armaCode, format ["UNKNOWN_%1", _armaCode]]; - ["WARNING", format ["Arma error: %1", _armaCodeMessage], nil, nil] call EFUNC(common,log); + if !(_stringArguments isEqualType []) then { + _stringArguments = [_stringArguments]; + }; + + private _encodedArguments = []; + { + _encodedArguments pushBack (toJSON _x); + } forEach _stringArguments; + + format ["[%1]", _encodedArguments joinString ","] }; -if (_extCode != 0) then { - _success = false; - if (_extCode == -1) exitWith { ["WARNING", "Extension not available", nil, nil] call EFUNC(common,log); }; - if (_extCode == 9) exitWith { ["WARNING", format ["Extension error: %1", _result], nil, nil] call EFUNC(common,log); }; +if (_requiresRedis) exitWith { + [_function, _arguments] call _checkRedisAvailability params ["_redisResult", "_redisSuccess"]; + if (!_redisSuccess) exitWith { [_redisResult, false] }; - ["WARNING", format ["Extension error: %1", _extCode], nil, nil] call EFUNC(common,log); + if (_functionLower in ["status", "version"]) exitWith { + [_function, _arguments] call _callExtensionCommand + }; + + [_function, _arguments] call _callExtensionCommand }; -[_result, _success] +if (_functionLower in ["status", "version"]) exitWith { + [_function, _arguments] call _callExtensionCommand +}; + +private _argumentsJson = [_arguments] call _buildTransportArgumentsJson; +private _usesTransportResponse = _functionLower in _transportResponseFunctions; +private _usesChunkedRequest = (count toArray _argumentsJson) > _requestChunkSize; + +if !(_usesTransportResponse || { _usesChunkedRequest }) exitWith { + [_function, _arguments] call _callExtensionCommand +}; + +private _transportCommand = "transport:invoke"; +private _transportArguments = [_function, _argumentsJson]; + +if (_usesChunkedRequest) then { + ["stage", _function, _argumentsJson, _requestChunkSize, _callExtensionCommand] call FUNC(transport) params [ + "_stagedTransportCommand", + "_stagedTransportArguments", + "_stageSuccess" + ]; + + if (!_stageSuccess) exitWith { + ["Error: Failed to stage chunked extension request", false] + }; + + _transportCommand = _stagedTransportCommand; + _transportArguments = _stagedTransportArguments; +}; + +[_transportCommand, _transportArguments] call _callExtensionCommand params ["_result", "_success"]; + +if ( + _success + && { _result isEqualType "" } + && { (_result find _unsupportedRoutePrefix) == 0 } + && { !_usesChunkedRequest } +) exitWith { + [_function, _arguments] call _callExtensionCommand +}; + +["assemble", _result, _success, _chunkPrefix, _chunkPrefixLength, _callExtensionCommand] call FUNC(transport) diff --git a/arma/server/addons/extension/functions/fnc_transport.sqf b/arma/server/addons/extension/functions/fnc_transport.sqf new file mode 100644 index 0000000..a86494c --- /dev/null +++ b/arma/server/addons/extension/functions/fnc_transport.sqf @@ -0,0 +1,115 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_transport.sqf + * Author: IDSolutions + * Date: 2026-04-01 + * Public: No + * + * Description: + * Shared transport helper for staging oversized requests and assembling + * chunked responses. + * + * Parameter(s): + * 0: Mode + * "stage": 1=function, 2=argumentsJson, 3=chunkSize, 4=invoker + * "assemble": 1=response, 2=success, 3=chunkPrefix, 4=chunkPrefixLength, 5=invoker + * + * Returns: + * Depends on mode. + */ + +params [["_mode", "", [""]]]; + +switch (_mode) do { + case "stage": { + _this params [ + "_mode", + ["_transportFunction", "", [""]], + ["_argumentsJson", "", [""]], + ["_requestChunkSize", 12000, [0]], + ["_callExtensionCommand", {}, [{}]] + ]; + + private _transferID = format [ + "req_%1_%2", + floor (diag_tickTime * 1000), + floor (random 1000000000) + ]; + + for "_offset" from 0 to ((count toArray _argumentsJson) - 1) step _requestChunkSize do { + private _chunk = _argumentsJson select [_offset, _requestChunkSize]; + + ["transport:request:append", [_transferID, _chunk]] call _callExtensionCommand params [ + "_appendResult", + "_appendSuccess" + ]; + + if (!_appendSuccess || { !(_appendResult isEqualType "") } || { (_appendResult find "Error:") == 0 }) exitWith { + _transferID = ""; + }; + }; + + if (_transferID isEqualTo "") exitWith { + ["", [], false] + }; + + [ + "transport:invoke_stored", + [_transportFunction, _transferID], + true + ] + }; + + case "assemble": { + _this params [ + "_mode", + ["_response", "", [""]], + ["_responseSuccess", false, [true]], + ["_chunkPrefix", "", [""]], + ["_chunkPrefixLength", 0, [0]], + ["_callExtensionCommand", {}, [{}]] + ]; + + if !(_responseSuccess && { _response isEqualType "" } && { (_response find _chunkPrefix) == 0 }) exitWith { + [_response, _responseSuccess] + }; + + private _chunkEnvelope = fromJSON (_response select [_chunkPrefixLength]); + if !(_chunkEnvelope isEqualType createHashMap) exitWith { + ["Error: Invalid extension chunk envelope", false] + }; + + private _transferID = _chunkEnvelope getOrDefault ["transferId", ""]; + private _chunkCount = _chunkEnvelope getOrDefault ["chunkCount", 0]; + + if (_transferID isEqualTo "" || { !(_chunkCount isEqualType 0) } || { _chunkCount < 1 }) exitWith { + ["Error: Invalid extension chunk metadata", false] + }; + + private _assembledResponse = ""; + private _chunkReadSuccess = true; + + for "_index" from 0 to (_chunkCount - 1) do { + ["transport:response:get", [_transferID, str _index]] call _callExtensionCommand params [ + "_chunkResult", + "_chunkSuccess" + ]; + + if (!_chunkSuccess || { !(_chunkResult isEqualType "") } || { (_chunkResult find "Error:") == 0 }) exitWith { + _chunkReadSuccess = false; + _assembledResponse = "Error: Failed to retrieve chunked extension response"; + }; + + _assembledResponse = _assembledResponse + _chunkResult; + }; + + ["transport:response:clear", [_transferID]] call _callExtensionCommand; + + [_assembledResponse, _chunkReadSuccess] + }; + + default { + ["Error: Unsupported extension transport mode", false] + }; +}; diff --git a/arma/server/addons/garage/XEH_preInit.sqf b/arma/server/addons/garage/XEH_preInit.sqf index 109ae47..6f5982c 100644 --- a/arma/server/addons/garage/XEH_preInit.sqf +++ b/arma/server/addons/garage/XEH_preInit.sqf @@ -18,7 +18,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - private _finalData = GVAR(GarageStore) call ["get", [GVAR(Registry), "garage:get", _uid, _field]]; + private _finalData = GVAR(GarageStore) call ["get", [_uid, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); @@ -29,7 +29,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID or Key!" }; - private _hashMap = GVAR(GarageStore) call ["set", [GVAR(Registry), "garage:update", _uid, _key, _value, _sync]]; + private _hashMap = GVAR(GarageStore) call ["set", [_uid, _key, _value, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); @@ -41,7 +41,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid field pairs!" }; - private _hashMap = GVAR(GarageStore) call ["mset", [GVAR(Registry), "garage:update", _uid, _fieldValuePairs, _sync]]; + private _hashMap = GVAR(GarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); @@ -52,7 +52,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - private _finalData = GVAR(GarageStore) call ["save", [GVAR(Registry), "garage:update", _uid]]; + private _finalData = GVAR(GarageStore) call ["save", [_uid]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); @@ -62,7 +62,7 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - GVAR(GarageStore) call ["remove", [GVAR(Registry), _uid]]; + GVAR(GarageStore) call ["remove", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestStoreVehicle), { @@ -90,18 +90,15 @@ PREP_RECOMPILE_END; ["hit_points", fromJSON _hitPointsJson] ]); - ["garage:add", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { + private _garage = GVAR(GarageStore) call ["storeVehicle", [_uid, _payloadJson]]; + if (_garage isEqualTo createHashMap) exitWith { [CRPC(garage,responseGarageAction), [createHashMapFromArray [ ["action", "store"], ["success", false], - ["message", format ["Failed to store vehicle: %1", _result]] + ["message", "Failed to store vehicle."] ]], _player] call CFUNC(targetEvent); }; - private _garage = fromJSON _result; - GVAR(Registry) set [_uid, _garage]; - [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); [CRPC(garage,responseGarageAction), [createHashMapFromArray [ ["action", "store"], @@ -123,18 +120,15 @@ PREP_RECOMPILE_END; }; private _payloadJson = toJSON (createHashMapFromArray [["plate", _plate]]); - ["garage:remove", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { + private _garage = GVAR(GarageStore) call ["retrieveVehicle", [_uid, _payloadJson]]; + if (_garage isEqualTo createHashMap) exitWith { [CRPC(garage,responseGarageAction), [createHashMapFromArray [ ["action", "retrieve"], ["success", false], - ["message", format ["Failed to retrieve vehicle: %1", _result]] + ["message", "Failed to retrieve vehicle."] ]], _player] call CFUNC(targetEvent); }; - private _garage = fromJSON _result; - GVAR(Registry) set [_uid, _garage]; - [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); [CRPC(garage,responseGarageAction), [createHashMapFromArray [ ["action", "retrieve"], @@ -155,7 +149,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - private _finalData = GVAR(VGarageStore) call ["get", [GVAR(VGRegistry), "owned:garage:fetch", _uid, _field]]; + private _finalData = GVAR(VGarageStore) call ["get", [_uid, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); @@ -166,7 +160,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID or Key!" }; - private _hashMap = GVAR(VGarageStore) call ["set", [GVAR(VGRegistry), "", _uid, _key, _value, _sync]]; + private _hashMap = GVAR(VGarageStore) call ["set", [_uid, _key, _value, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); @@ -178,7 +172,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid field pairs!" }; - private _hashMap = GVAR(VGarageStore) call ["mset", [GVAR(VGRegistry), "", _uid, _fieldValuePairs, _sync]]; + private _hashMap = GVAR(VGarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); @@ -189,7 +183,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - private _finalData = GVAR(VGarageStore) call ["save", [GVAR(VGRegistry), "", _uid]]; + private _finalData = GVAR(VGarageStore) call ["save", [_uid]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); @@ -199,5 +193,5 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - GVAR(VGarageStore) call ["remove", [GVAR(VGRegistry), _uid]]; + GVAR(VGarageStore) call ["remove", [_uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf index b714040..1ae37d5 100644 --- a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf @@ -4,12 +4,12 @@ * File: fnc_initGarageStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-04-01 * Public: No * * Description: * Initializes the Garage store for managing player vehicles. - * Provides methods for syncing, saving, and applying vehicles to the player's garage. + * Garage hot state is owned by the extension; SQF acts as a thin bridge. * * Arguments: * None @@ -26,50 +26,151 @@ GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "GarageBaseStore"], ["#create", compileFinal { - GVAR(Registry) = createHashMap; ["INFO", "Garage Store Initialized!"] call EFUNC(common,log); }], + ["callHotGarage", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Garage extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + _data + }], + ["loadHotGarage", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _command = ["garage:hot:get", "garage:hot:init"] select _initialize; + _self call ["callHotGarage", [_command, [_uid]]] + }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(Registry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { [CRPC(garage,responseInitGarage), [_cached], _player] call CFUNC(targetEvent); _cached }; + if (isNull _player) exitWith { createHashMap }; - ["garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if garage %1 exists! Using fallback garage.", _uid]] call EFUNC(common,log); - - private _fallbackGarage = createHashMap; - GVAR(Registry) set [_uid, _fallbackGarage]; - [CRPC(garage,responseInitGarage), [_fallbackGarage], _player] call CFUNC(targetEvent); - - _fallbackGarage + private _garage = _self call ["loadHotGarage", [_uid, true]]; + if (_garage isEqualTo createHashMap) then { + ["ERROR", format ["Failed to initialize garage for %1! Using fallback garage.", _uid]] call EFUNC(common,log); }; - private _finalGarage = createHashMap; + [CRPC(garage,responseInitGarage), [_garage], _player] call CFUNC(targetEvent); + _garage + }], + ["get", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]]]; - if (_result == "true") then { - _finalGarage = _self call ["fetch", ["garage:get", _uid]]; - ["INFO", format ["Found garage for %1", _uid]] call EFUNC(common,log); - } else { - ["garage:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to create garage for %1! Using fallback garage.", _uid]] call EFUNC(common,log); + private _garage = _self call ["loadHotGarage", [_uid, false]]; + if (_garage isEqualTo createHashMap) then { + _garage = _self call ["loadHotGarage", [_uid, true]]; + }; - GVAR(Registry) set [_uid, _finalGarage]; - [CRPC(garage,responseInitGarage), [_finalGarage], _player] call CFUNC(targetEvent); + if (_field isEqualTo "") exitWith { _garage }; + _garage getOrDefault [_field, createHashMap] + }], + ["override", compileFinal { + params [ + ["_uid", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; - _finalGarage + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + + private _garage = _self call ["callHotGarage", ["garage:hot:override", [_uid, toJSON _data]]]; + if (_save && { _garage isNotEqualTo createHashMap }) then { + private _savedGarage = _self call ["callHotGarage", ["garage:hot:save", [_uid]]]; + if (_savedGarage isNotEqualTo createHashMap) then { + _garage = _savedGarage; + } else { + _garage = createHashMap; }; - - ["INFO", format ["Created new garage for %1", _uid]] call EFUNC(common,log); }; - GVAR(Registry) set [_uid, _finalGarage]; - [CRPC(garage,responseInitGarage), [_finalGarage], _player] call CFUNC(targetEvent); + _garage + }], + ["set", compileFinal { + params [ + ["_uid", "", [""]], + ["_field", "", [""]], + ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], + ["_sync", false, [false]] + ]; - _finalGarage + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _garage = _self call ["get", [_uid, ""]]; + if !(_garage isEqualType createHashMap) exitWith { createHashMap }; + + _garage set [_field, _value]; + private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; + if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedGarage getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + params [ + ["_uid", "", [""]], + ["_fieldValuePairs", createHashMap, [createHashMap]], + ["_sync", false, [false]] + ]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; + + private _garage = _self call ["get", [_uid, ""]]; + if !(_garage isEqualType createHashMap) exitWith { createHashMap }; + + { _garage set [_x, _y]; } forEach _fieldValuePairs; + private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; + if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs + }], + ["save", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + _self call ["callHotGarage", ["garage:hot:save", [_uid]]] + }], + ["remove", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + ["garage:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + _isSuccess && { _result isEqualTo "OK" } + }], + ["storeVehicle", compileFinal { + params [ + ["_uid", "", [""]], + ["_payloadJson", "", [""]] + ]; + + if (_uid isEqualTo "" || { _payloadJson isEqualTo "" }) exitWith { createHashMap }; + _self call ["callHotGarage", ["garage:hot:add", [_uid, _payloadJson]]] + }], + ["retrieveVehicle", compileFinal { + params [ + ["_uid", "", [""]], + ["_payloadJson", "", [""]] + ]; + + if (_uid isEqualTo "" || { _payloadJson isEqualTo "" }) exitWith { createHashMap }; + _self call ["callHotGarage", ["garage:hot:remove_vehicle", [_uid, _payloadJson]]] }] ]; diff --git a/arma/server/addons/garage/functions/fnc_initVGStore.sqf b/arma/server/addons/garage/functions/fnc_initVGStore.sqf index 70e7d5b..f4f34a8 100644 --- a/arma/server/addons/garage/functions/fnc_initVGStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initVGStore.sqf @@ -4,12 +4,12 @@ * File: fnc_initVGStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-04-01 * Public: No * * Description: * Initializes the Virtual Garage store for managing player vehicle unlocks. - * Provides methods for syncing, saving, and applying virtual vehicles to BIS Garage. + * Virtual garage hot state is owned by the extension; SQF acts as a thin bridge. * * Arguments: * None @@ -42,55 +42,134 @@ GVAR(VGBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "VGBaseStore"], ["#create", compileFinal { - GVAR(VGRegistry) = createHashMap; ["INFO", "VGarage Store Initialized!"] call EFUNC(common,log); }], + ["callHotVGarage", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["VGarage extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + _data + }], + ["loadHotVGarage", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _command = ["owned:garage:hot:fetch", "owned:garage:hot:init"] select _initialize; + _self call ["callHotVGarage", [_command, [_uid]]] + }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(VGRegistry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { - [CRPC(garage,responseInitVG), [_cached], _player] call CFUNC(targetEvent); - _cached + if (isNull _player) exitWith { createHashMap }; + + private _garage = _self call ["loadHotVGarage", [_uid, true]]; + if (_garage isEqualTo createHashMap) then { + _garage = GVAR(VGarageModel) call ["defaults", []]; + ["ERROR", format ["Failed to initialize virtual garage for %1! Using fallback virtual garage.", _uid]] call EFUNC(common,log); }; - ["owned:garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if virtual garage %1 exists! Using fallback virtual garage.", _uid]] call EFUNC(common,log); + [CRPC(garage,responseInitVG), [_garage], _player] call CFUNC(targetEvent); + _garage + }], + ["get", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]]]; - private _fallbackVGarage = GVAR(VGarageModel) call ["defaults", []]; - GVAR(VGRegistry) set [_uid, _fallbackVGarage]; - [CRPC(garage,responseInitVG), [_fallbackVGarage], _player] call CFUNC(targetEvent); - - _fallbackVGarage + private _garage = _self call ["loadHotVGarage", [_uid, false]]; + if (_garage isEqualTo createHashMap) then { + _garage = _self call ["loadHotVGarage", [_uid, true]]; }; - private _finalVGarage = createHashMap; + if (_field isEqualTo "") exitWith { _garage }; + _garage getOrDefault [_field, []] + }], + ["override", compileFinal { + params [ + ["_uid", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; - if (_result == "true") then { - _finalVGarage = _self call ["fetch", ["owned:garage:fetch", _uid]]; - ["INFO", format ["Found virtual garage for %1", _uid]] call EFUNC(common,log); - } else { - _finalVGarage = GVAR(VGarageModel) call ["defaults", []]; + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; - ["owned:garage:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to create virtual garage for %1! Using fallback virtual garage.", _uid]] call EFUNC(common,log); - - GVAR(VGRegistry) set [_uid, _finalVGarage]; - [CRPC(garage,responseInitVG), [_finalVGarage], _player] call CFUNC(targetEvent); - - _finalVGarage + private _garage = _self call ["callHotVGarage", ["owned:garage:hot:override", [_uid, toJSON _data]]]; + if (_save && { _garage isNotEqualTo createHashMap }) then { + private _savedGarage = _self call ["callHotVGarage", ["owned:garage:hot:save", [_uid]]]; + if (_savedGarage isNotEqualTo createHashMap) then { + _garage = _savedGarage; + } else { + _garage = createHashMap; }; - - ["INFO", format ["Created new virtual garage for %1", _uid]] call EFUNC(common,log); }; - GVAR(VGRegistry) set [_uid, _finalVGarage]; - [CRPC(garage,responseInitVG), [_finalVGarage], _player] call CFUNC(targetEvent); + _garage + }], + ["set", compileFinal { + params [ + ["_uid", "", [""]], + ["_field", "", [""]], + ["_value", nil, [[], "", 0, false, createHashMap]], + ["_sync", false, [false]] + ]; - _finalVGarage + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _garage = _self call ["loadHotVGarage", [_uid, false]]; + if !(_garage isEqualType createHashMap) exitWith { createHashMap }; + + _garage set [_field, _value]; + private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; + if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedGarage getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + params [ + ["_uid", "", [""]], + ["_fieldValuePairs", createHashMap, [createHashMap]], + ["_sync", false, [false]] + ]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; + + private _garage = _self call ["loadHotVGarage", [_uid, false]]; + if !(_garage isEqualType createHashMap) exitWith { createHashMap }; + + { _garage set [_x, _y]; } forEach _fieldValuePairs; + private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; + if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs + }], + ["save", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + _self call ["callHotVGarage", ["owned:garage:hot:save", [_uid]]] + }], + ["remove", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + ["owned:garage:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + _isSuccess && { _result isEqualTo "OK" } }], ["grantVehicles", compileFinal { params [["_uid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]]]; @@ -103,8 +182,11 @@ GVAR(VGBaseStore) = compileFinal createHashMapFromArray [ ["garage", createHashMap] ]; - private _defaultGarage = GVAR(VGarageModel) call ["defaults", []]; - private _garage = +(GVAR(VGRegistry) getOrDefault [_uid, _defaultGarage]); + private _garage = +(_self call ["loadHotVGarage", [_uid, false]]); + if (_garage isEqualTo createHashMap) then { + _garage = GVAR(VGarageModel) call ["defaults", []]; + }; + private _patch = createHashMap; private _granted = []; private _categoriesToSync = []; @@ -136,7 +218,18 @@ GVAR(VGBaseStore) = compileFinal createHashMapFromArray [ _patch set [_category, _garage getOrDefault [_category, []]]; } forEach _categoriesToSync; - if (_commit) then { GVAR(VGRegistry) set [_uid, _garage]; }; + if (_commit) then { + private _savedGarage = _self call ["override", [_uid, _garage, false]]; + if !(_savedGarage isEqualType createHashMap) exitWith { + _result set ["message", "Virtual garage cache update returned invalid data."]; + _result + }; + if (_savedGarage isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to update virtual garage cache."]; + _result + }; + _garage = _savedGarage; + }; _result set ["success", true]; _result set ["message", ""]; diff --git a/arma/server/addons/locker/XEH_preInit.sqf b/arma/server/addons/locker/XEH_preInit.sqf index a1b5d92..f9747e6 100644 --- a/arma/server/addons/locker/XEH_preInit.sqf +++ b/arma/server/addons/locker/XEH_preInit.sqf @@ -18,7 +18,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - private _finalData = GVAR(LockerStore) call ["get", [GVAR(Registry), "locker:get", _uid, _field]]; + private _finalData = GVAR(LockerStore) call ["get", [_uid, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); @@ -29,7 +29,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID or Field!" }; - private _hashMap = GVAR(LockerStore) call ["set", [GVAR(Registry), "locker:update", _uid, _field, _value, _sync]]; + private _hashMap = GVAR(LockerStore) call ["set", [_uid, _field, _value, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); @@ -41,7 +41,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid field pairs!" }; - private _hashMap = GVAR(LockerStore) call ["mset", [GVAR(Registry), "locker:update", _uid, _fieldValuePairs, _sync]]; + private _hashMap = GVAR(LockerStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); @@ -52,7 +52,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - private _finalData = GVAR(LockerStore) call ["save", [GVAR(Registry), "locker:update", _uid]]; + private _finalData = GVAR(LockerStore) call ["save", [_uid]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); @@ -62,10 +62,10 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]], ["_data", createHashMap, [createHashMap]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - GVAR(Registry) set [_uid, _data]; + private _finalData = GVAR(LockerStore) call ["override", [_uid, _data, false]]; private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(locker,responseSyncLocker), [_data], _player] call CFUNC(targetEvent); + [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveLocker), { @@ -87,7 +87,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - private _finalData = GVAR(VAStore) call ["get", [GVAR(VARegistry), "owned:locker:fetch", _uid, _field]]; + private _finalData = GVAR(VAStore) call ["get", [_uid, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); @@ -98,7 +98,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID or Field!" }; - private _hashMap = GVAR(VAStore) call ["set", [GVAR(VARegistry), "owned:locker:update", _uid, _field, _value, _sync]]; + private _hashMap = GVAR(VAStore) call ["set", [_uid, _field, _value, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); @@ -110,7 +110,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid field pairs!" }; - private _hashMap = GVAR(VAStore) call ["mset", [GVAR(VARegistry), "owned:locker:update", _uid, _fieldValuePairs, _sync]]; + private _hashMap = GVAR(VAStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); @@ -121,7 +121,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - private _finalData = GVAR(VAStore) call ["save", [GVAR(VARegistry), "owned:locker:update", _uid]]; + private _finalData = GVAR(VAStore) call ["save", [_uid]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); @@ -131,5 +131,5 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - GVAR(VAStore) call ["remove", [GVAR(VARegistry), _uid]]; + GVAR(VAStore) call ["remove", [_uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf index 3a9c5bf..ac4251a 100644 --- a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf @@ -4,12 +4,12 @@ * File: fnc_initLockerStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-02-13 + * Last Update: 2026-04-01 * Public: No * * Description: * Initializes the Locker store for managing player locker items. - * Provides methods for syncing, saving, and applying locker items to the player's locker. + * Locker hot state is owned by the extension; SQF acts as a thin bridge. * * Arguments: * None @@ -26,50 +26,133 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "LockerBaseStore"], ["#create", compileFinal { - GVAR(Registry) = createHashMap; ["INFO", "Locker Store Initialized!"] call EFUNC(common,log); }], + ["callHotLocker", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Locker extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + _data + }], + ["loadHotLocker", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _command = ["locker:hot:get", "locker:hot:init"] select _initialize; + _self call ["callHotLocker", [_command, [_uid]]] + }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(Registry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { [CRPC(locker,responseInitLocker), [_cached], _player] call CFUNC(targetEvent); _cached }; + if (isNull _player) exitWith { createHashMap }; - ["locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if locker %1 exists! Using fallback locker.", _uid]] call EFUNC(common,log); - - private _fallbackLocker = createHashMap; - GVAR(Registry) set [_uid, _fallbackLocker]; - [CRPC(locker,responseInitLocker), [_fallbackLocker], _player] call CFUNC(targetEvent); - - _fallbackLocker + private _locker = _self call ["loadHotLocker", [_uid, true]]; + if (_locker isEqualTo createHashMap) then { + ["ERROR", format ["Failed to initialize locker for %1! Using fallback locker.", _uid]] call EFUNC(common,log); }; - private _finalLocker = createHashMap; + [CRPC(locker,responseInitLocker), [_locker], _player] call CFUNC(targetEvent); + _locker + }], + ["get", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]]]; - if (_result == "true") then { - _finalLocker = _self call ["fetch", ["locker:get", _uid]]; - ["INFO", format ["Found locker for %1", _uid]] call EFUNC(common,log); - } else { - ["locker:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to create locker for %1! Using fallback locker.", _uid]] call EFUNC(common,log); + private _locker = _self call ["loadHotLocker", [_uid, false]]; + if (_locker isEqualTo createHashMap) then { + _locker = _self call ["loadHotLocker", [_uid, true]]; + }; - GVAR(Registry) set [_uid, _finalLocker]; - [CRPC(locker,responseInitLocker), [_finalLocker], _player] call CFUNC(targetEvent); + if (_field isEqualTo "") exitWith { _locker }; + _locker getOrDefault [_field, createHashMap] + }], + ["override", compileFinal { + params [ + ["_uid", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; - _finalLocker + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + + private _locker = _self call ["callHotLocker", ["locker:hot:override", [_uid, toJSON _data]]]; + if (_save && { _locker isNotEqualTo createHashMap }) then { + private _savedLocker = _self call ["callHotLocker", ["locker:hot:save", [_uid]]]; + if (_savedLocker isNotEqualTo createHashMap) then { + _locker = _savedLocker; + } else { + _locker = createHashMap; }; - - ["INFO", format ["Created new locker for %1", _uid]] call EFUNC(common,log); }; - GVAR(Registry) set [_uid, _finalLocker]; - [CRPC(locker,responseInitLocker), [_finalLocker], _player] call CFUNC(targetEvent); + _locker + }], + ["set", compileFinal { + params [ + ["_uid", "", [""]], + ["_field", "", [""]], + ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], + ["_sync", false, [false]] + ]; - _finalLocker + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _locker = _self call ["get", [_uid, ""]]; + if !(_locker isEqualType createHashMap) exitWith { createHashMap }; + + _locker set [_field, _value]; + private _updatedLocker = _self call ["override", [_uid, _locker, _sync]]; + if !(_updatedLocker isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedLocker isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedLocker getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + params [ + ["_uid", "", [""]], + ["_fieldValuePairs", createHashMap, [createHashMap]], + ["_sync", false, [false]] + ]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; + + private _locker = _self call ["get", [_uid, ""]]; + if !(_locker isEqualType createHashMap) exitWith { createHashMap }; + + { _locker set [_x, _y]; } forEach _fieldValuePairs; + private _updatedLocker = _self call ["override", [_uid, _locker, _sync]]; + if !(_updatedLocker isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedLocker isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs + }], + ["save", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + _self call ["callHotLocker", ["locker:hot:save", [_uid]]] + }], + ["remove", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + ["locker:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + _isSuccess && { _result isEqualTo "OK" } }], ["grantItems", compileFinal { params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; @@ -82,7 +165,7 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ ["locker", createHashMap] ]; - private _locker = +(GVAR(Registry) getOrDefault [_uid, createHashMap]); + private _locker = +(_self call ["get", [_uid, ""]]); private _patch = createHashMap; private _granted = []; @@ -124,7 +207,19 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", "Locker capacity would exceed 25 unique items. Clear space before checkout."]; _result }; - if (_commit) then { GVAR(Registry) set [_uid, _locker]; }; + + if (_commit) then { + private _savedLocker = _self call ["override", [_uid, _locker, false]]; + if !(_savedLocker isEqualType createHashMap) exitWith { + _result set ["message", "Locker cache update returned invalid data."]; + _result + }; + if (_savedLocker isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to update locker cache."]; + _result + }; + _locker = _savedLocker; + }; _result set ["success", true]; _result set ["message", ""]; diff --git a/arma/server/addons/locker/functions/fnc_initVAStore.sqf b/arma/server/addons/locker/functions/fnc_initVAStore.sqf index 385b120..acbd598 100644 --- a/arma/server/addons/locker/functions/fnc_initVAStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initVAStore.sqf @@ -4,12 +4,12 @@ * File: fnc_initVAStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-03-27 + * Last Update: 2026-04-01 * Public: No * * Description: * Initializes the Virtual Arsenal store for managing player arsenal unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. + * Virtual arsenal hot state is owned by the extension; SQF acts as a thin bridge. * * Arguments: * None @@ -40,55 +40,134 @@ GVAR(VABaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "VABaseStore"], ["#create", compileFinal { - GVAR(VARegistry) = createHashMap; ["INFO", "VArsenal Store Initialized!"] call EFUNC(common,log); }], + ["callHotVArsenal", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["VArsenal extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + _data + }], + ["loadHotVArsenal", compileFinal { + params [["_uid", "", [""]], ["_initialize", false, [false]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _command = ["owned:locker:hot:fetch", "owned:locker:hot:init"] select _initialize; + _self call ["callHotVArsenal", [_command, [_uid]]] + }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(VARegistry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { - [CRPC(locker,responseInitVA), [_cached], _player] call CFUNC(targetEvent); - _cached + if (isNull _player) exitWith { createHashMap }; + + private _arsenal = _self call ["loadHotVArsenal", [_uid, true]]; + if (_arsenal isEqualTo createHashMap) then { + _arsenal = GVAR(VArsenalModel) call ["defaults", []]; + ["ERROR", format ["Failed to initialize virtual arsenal for %1! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log); }; - ["owned:locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if virtual arsenal %1 exists! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log); + [CRPC(locker,responseInitVA), [_arsenal], _player] call CFUNC(targetEvent); + _arsenal + }], + ["get", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]]]; - private _fallbackVArsenal = GVAR(VArsenalModel) call ["defaults", []]; - GVAR(VARegistry) set [_uid, _fallbackVArsenal]; - [CRPC(locker,responseInitVA), [_fallbackVArsenal], _player] call CFUNC(targetEvent); - - _fallbackVArsenal + private _arsenal = _self call ["loadHotVArsenal", [_uid, false]]; + if (_arsenal isEqualTo createHashMap) then { + _arsenal = _self call ["loadHotVArsenal", [_uid, true]]; }; - private _finalVArsenal = createHashMap; + if (_field isEqualTo "") exitWith { _arsenal }; + _arsenal getOrDefault [_field, []] + }], + ["override", compileFinal { + params [ + ["_uid", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; - if (_result == "true") then { - _finalVArsenal = _self call ["fetch", ["owned:locker:fetch", _uid]]; - ["INFO", format ["Found virtual arsenal for %1", _uid]] call EFUNC(common,log); - } else { - _finalVArsenal = GVAR(VArsenalModel) call ["defaults", []]; + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; - ["owned:locker:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to create virtual arsenal for %1! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log); - - GVAR(VARegistry) set [_uid, _finalVArsenal]; - [CRPC(locker,responseInitVA), [_finalVArsenal], _player] call CFUNC(targetEvent); - - _finalVArsenal + private _arsenal = _self call ["callHotVArsenal", ["owned:locker:hot:override", [_uid, toJSON _data]]]; + if (_save && { _arsenal isNotEqualTo createHashMap }) then { + private _savedArsenal = _self call ["callHotVArsenal", ["owned:locker:hot:save", [_uid]]]; + if (_savedArsenal isNotEqualTo createHashMap) then { + _arsenal = _savedArsenal; + } else { + _arsenal = createHashMap; }; - - ["INFO", format ["Created new virtual arsenal for %1", _uid]] call EFUNC(common,log); }; - GVAR(VARegistry) set [_uid, _finalVArsenal]; - [CRPC(locker,responseInitVA), [_finalVArsenal], _player] call CFUNC(targetEvent); + _arsenal + }], + ["set", compileFinal { + params [ + ["_uid", "", [""]], + ["_field", "", [""]], + ["_value", nil, [[], "", 0, false, createHashMap]], + ["_sync", false, [false]] + ]; - _finalVArsenal + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _arsenal = _self call ["get", [_uid, ""]]; + if !(_arsenal isEqualType createHashMap) exitWith { createHashMap }; + + _arsenal set [_field, _value]; + private _updatedArsenal = _self call ["override", [_uid, _arsenal, _sync]]; + if !(_updatedArsenal isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedArsenal isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedArsenal getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + params [ + ["_uid", "", [""]], + ["_fieldValuePairs", createHashMap, [createHashMap]], + ["_sync", false, [false]] + ]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; + + private _arsenal = _self call ["get", [_uid, ""]]; + if !(_arsenal isEqualType createHashMap) exitWith { createHashMap }; + + { _arsenal set [_x, _y]; } forEach _fieldValuePairs; + private _updatedArsenal = _self call ["override", [_uid, _arsenal, _sync]]; + if !(_updatedArsenal isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedArsenal isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs + }], + ["save", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + _self call ["callHotVArsenal", ["owned:locker:hot:save", [_uid]]] + }], + ["remove", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + ["owned:locker:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + _isSuccess && { _result isEqualTo "OK" } }], ["unlockItems", compileFinal { params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; @@ -100,8 +179,10 @@ GVAR(VABaseStore) = compileFinal createHashMapFromArray [ ["arsenal", createHashMap] ]; - private _defaultArsenal = GVAR(VArsenalModel) call ["defaults", []]; - private _arsenal = +(GVAR(VARegistry) getOrDefault [_uid, _defaultArsenal]); + private _arsenal = +(_self call ["get", [_uid, ""]]); + if (_arsenal isEqualTo createHashMap) then { + _arsenal = GVAR(VArsenalModel) call ["defaults", []]; + }; private _patch = createHashMap; private _categoriesToSync = []; @@ -129,7 +210,18 @@ GVAR(VABaseStore) = compileFinal createHashMapFromArray [ _patch set [_category, _categoryUnlocks]; } forEach _categoriesToSync; - if (_commit) then { GVAR(VARegistry) set [_uid, _arsenal]; }; + if (_commit) then { + private _savedArsenal = _self call ["override", [_uid, _arsenal, false]]; + if !(_savedArsenal isEqualType createHashMap) exitWith { + _result set ["message", "Virtual arsenal cache update returned invalid data."]; + _result + }; + if (_savedArsenal isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to update virtual arsenal cache."]; + _result + }; + _arsenal = _savedArsenal; + }; _result set ["success", true]; _result set ["message", ""]; diff --git a/arma/server/addons/main/XEH_PREP.hpp b/arma/server/addons/main/XEH_PREP.hpp index 2e61ebc..3a1cf90 100644 --- a/arma/server/addons/main/XEH_PREP.hpp +++ b/arma/server/addons/main/XEH_PREP.hpp @@ -1 +1,2 @@ PREP(initStores); +PREP(saveHotState); diff --git a/arma/server/addons/main/XEH_preInit.sqf b/arma/server/addons/main/XEH_preInit.sqf index 5e34c8a..929f1ff 100644 --- a/arma/server/addons/main/XEH_preInit.sqf +++ b/arma/server/addons/main/XEH_preInit.sqf @@ -63,4 +63,16 @@ addMissionEventHandler ["PlayerConnected", { addMissionEventHandler ["PlayerDisconnected", { params ["_id", "_uid", "_name", "_jip", "_owner", "_idStr"]; + + if (_uid isEqualTo "") exitWith {}; + + [_uid] call FUNC(saveHotState); +}]; + +addMissionEventHandler ["Ended", { + [""] call FUNC(saveHotState); +}]; + +addMissionEventHandler ["MPEnded", { + [""] call FUNC(saveHotState); }]; diff --git a/arma/server/addons/main/functions/fnc_initStores.sqf b/arma/server/addons/main/functions/fnc_initStores.sqf index 1d37eb8..801605a 100644 --- a/arma/server/addons/main/functions/fnc_initStores.sqf +++ b/arma/server/addons/main/functions/fnc_initStores.sqf @@ -26,8 +26,8 @@ if (isNil QEGVAR(actor,ActorStore)) then { call EFUNC(actor,initActorStore); }; if (isNil QEGVAR(bank,BankSessionManager)) then { call EFUNC(bank,initSessionManager); }; if (isNil QEGVAR(bank,BankMessenger)) then { call EFUNC(bank,initMessenger); }; if (isNil QEGVAR(bank,BankModel)) then { call EFUNC(bank,initModel); }; +if (isNil QEGVAR(bank,BankPayloadBuilder)) then { call EFUNC(bank,initPayloadBuilder); }; if (isNil QEGVAR(bank,BankStore)) then { call EFUNC(bank,initStore); }; -if (isNil QEGVAR(bank,BankValidator)) then { call EFUNC(bank,initValidator); }; // Garage if (isNil QEGVAR(garage,GarageStore)) then { call EFUNC(garage,initGarageStore); }; diff --git a/arma/server/addons/main/functions/fnc_saveHotState.sqf b/arma/server/addons/main/functions/fnc_saveHotState.sqf new file mode 100644 index 0000000..5fbfb98 --- /dev/null +++ b/arma/server/addons/main/functions/fnc_saveHotState.sqf @@ -0,0 +1,84 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_saveHotState.sqf + * Author: IDSolutions + * Date: 2026-04-01 + * Public: No + * + * Description: + * Flushes extension-backed hot state for a single UID or every known UID. + * + * Arguments: + * 0: UID to flush. Empty string flushes all known players. + * + * Return Value: + * True if the flush routine completed. + */ + +params [["_uid", "", [""]]]; + +private _uids = []; +if (_uid isEqualTo "") then { + { + if (isNull _x) then { continue; }; + private _playerUid = getPlayerUID _x; + if (_playerUid isNotEqualTo "") then { + _uids pushBackUnique _playerUid; + }; + } forEach allPlayers; + + if !(isNil QEGVAR(actor,Registry)) then { + { + if (_x isNotEqualTo "") then { + _uids pushBackUnique _x; + }; + } forEach keys EGVAR(actor,Registry); + }; +} else { + _uids pushBack _uid; +}; + +{ + private _flushUid = _x; + if (_flushUid isEqualTo "") then { continue; }; + + private _orgID = "default"; + if !(isNil QEGVAR(org,OrgStore)) then { + _orgID = EGVAR(org,OrgStore) call ["resolveOrgIdForUid", [_flushUid]]; + if (_orgID isEqualTo "") then { + _orgID = "default"; + }; + }; + + if !(isNil QEGVAR(actor,ActorStore)) then { + EGVAR(actor,ActorStore) call ["snapshot", [_flushUid]]; + EGVAR(actor,ActorStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(bank,BankStore)) then { + EGVAR(bank,BankStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(locker,LockerStore)) then { + EGVAR(locker,LockerStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(locker,VAStore)) then { + EGVAR(locker,VAStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(garage,GarageStore)) then { + EGVAR(garage,GarageStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(garage,VGarageStore)) then { + EGVAR(garage,VGarageStore) call ["save", [_flushUid]]; + }; + + if !(isNil QEGVAR(org,OrgStore)) then { + EGVAR(org,OrgStore) call ["saveById", [_orgID]]; + }; +} forEach _uids; + +true diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 0d56d12..1c098d2 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -57,9 +57,8 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - private _index = GVAR(IndexRegistry) get _uid; - private _key = _index get "orgID"; - private _finalData = GVAR(OrgStore) call ["get", [GVAR(Registry), _key, _field]]; + private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; + private _finalData = GVAR(OrgStore) call ["get", [_key, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(org,responseSyncOrg), [_finalData], _player] call CFUNC(targetEvent); @@ -70,9 +69,8 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID or Field!" }; - private _index = GVAR(IndexRegistry) get _uid; - private _key = _index get "orgID"; - GVAR(OrgStore) call ["set", [GVAR(Registry), "org:update", _key, _field, _value, _sync]]; + private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; + GVAR(OrgStore) call ["set", [_key, _field, _value, _sync]]; }] call CFUNC(addEventHandler); [QGVAR(requestMSetOrg), { @@ -81,10 +79,9 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid field pairs!" }; - private _index = GVAR(IndexRegistry) get _uid; - private _key = _index get "orgID"; + private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _key, _fieldValuePairs, _sync]]; + GVAR(OrgStore) call ["mset", [_key, _fieldValuePairs, _sync]]; }] call CFUNC(addEventHandler); [QGVAR(requestAssignCreditLine), { @@ -125,8 +122,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - private _index = GVAR(IndexRegistry) get _uid; - private _key = _index get "orgID"; + private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; GVAR(OrgStore) call ["saveById", [_key]]; }] call CFUNC(addEventHandler); @@ -135,8 +131,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - private _index = GVAR(IndexRegistry) get _uid; - private _key = _index get "orgID"; + private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; GVAR(OrgStore) call ["delete", [_key]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 038d6d4..67fc084 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -4,12 +4,12 @@ * File: fnc_initOrgStore.sqf * Author: IDSolutions * Date: 2026-02-13 - * Last Update: 2026-03-13 + * Last Update: 2026-04-01 * Public: Yes * * Description: * Initializes the org store for managing player organizations. - * Provides methods for creating, fetching, and updating organizations. + * Org hot state is owned by the extension; SQF acts as the bridge. * * Arguments: * None @@ -118,8 +118,6 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "OrgBaseStore"], ["#create", compileFinal { - GVAR(IndexRegistry) = createHashMap; - GVAR(Registry) = createHashMap; ["INFO", "Org Store Initialized!"] call EFUNC(common,log); ["org:exists", ["default"]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; @@ -137,34 +135,170 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["fleet", createHashMap], ["members", createHashMap] ]; - GVAR(Registry) set ["default", _defaultOrg]; - _defaultOrg }; - private _defaultOrg = createHashMap; - if (_result == "true") then { - _defaultOrg = _self call ["fetch", ["org:get", "default"]]; - } else { - _defaultOrg set ["id", "default"]; - _defaultOrg set ["owner", "server"]; - _defaultOrg set ["name", "Forge Dynamics"]; - _defaultOrg set ["funds", 200000]; - _defaultOrg set ["reputation", 0]; - _defaultOrg set ["credit_lines", createHashMap]; + if (_result != "true") then { + private _defaultOrg = createHashMapFromArray [ + ["id", "default"], + ["owner", "server"], + ["name", "Forge Dynamics"], + ["funds", 200000], + ["reputation", 0], + ["credit_lines", createHashMap], + ["assets", createHashMap], + ["fleet", createHashMap], + ["members", createHashMap] + ]; private _defaultJson = _self call ["toJSON", [_defaultOrg]]; ["org:create", ["default", _defaultJson]] call EFUNC(extension,extCall); }; - _defaultOrg = GVAR(OrgModel) call ["migrate", [_defaultOrg]]; - private _defaultAssets = _self call ["fetch", ["org:assets:get", "default"]]; - if !(_defaultAssets isEqualType createHashMap) then { _defaultAssets = createHashMap; }; - _defaultOrg set ["assets", _defaultAssets]; - private _defaultFleet = _self call ["fetch", ["org:fleet:get", "default"]]; - if !(_defaultFleet isEqualType createHashMap) then { _defaultFleet = createHashMap; }; - _defaultOrg set ["fleet", _defaultFleet]; - GVAR(Registry) set ["default", _defaultOrg]; + private _loadedDefaultOrg = _self call ["loadHotOrg", ["default", true]]; + if (_loadedDefaultOrg isEqualTo createHashMap) then { + _loadedDefaultOrg = createHashMapFromArray [ + ["id", "default"], + ["owner", "server"], + ["name", "Forge Dynamics"], + ["funds", 200000], + ["reputation", 0], + ["credit_lines", createHashMap], + ["assets", createHashMap], + ["fleet", createHashMap], + ["members", createHashMap] + ]; + }; + + _loadedDefaultOrg + }], + ["callHotOrg", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Org extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + + _self call ["syncHotOrg", [_data]] + }], + ["syncHotOrg", compileFinal { + params [["_org", createHashMap, [createHashMap]]]; + + if !(_org isEqualType createHashMap) exitWith { createHashMap }; + + private _migratedOrg = GVAR(OrgModel) call ["migrate", [+_org]]; + private _orgID = _migratedOrg getOrDefault ["id", ""]; + if (_orgID isEqualTo "") exitWith { createHashMap }; + + _migratedOrg + }], + ["resolveOrgIdForUid", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { "default" }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + _orgID + }], + ["loadForUid", compileFinal { + params [["_uid", "", [""]]]; + private _orgID = _self call ["resolveOrgIdForUid", [_uid]]; + _self call ["loadById", [_orgID]] + }], + ["loadHotOrg", compileFinal { + params [["_orgID", "", [""]], ["_initialize", false, [false]]]; + + if (_orgID isEqualTo "") exitWith { createHashMap }; + + private _command = ["org:hot:get", "org:hot:init"] select _initialize; + _self call ["callHotOrg", [_command, [_orgID]]] + }], + ["get", compileFinal { + params [["_orgID", "", [""]], ["_field", "", [""]]]; + + private _org = _self call ["loadHotOrg", [_orgID, false]]; + if (_org isEqualTo createHashMap) then { + _org = _self call ["loadHotOrg", [_orgID, true]]; + }; + + if (_field isEqualTo "") exitWith { _org }; + _org getOrDefault [_field, createHashMap] + }], + ["override", compileFinal { + params [ + ["_orgID", "", [""]], + ["_org", createHashMap, [createHashMap]], + ["_save", false, [false]] + ]; + + if (_orgID isEqualTo "") exitWith { createHashMap }; + if !(_org isEqualType createHashMap) exitWith { createHashMap }; + + private _normalizedOrg = +_org; + _normalizedOrg set ["id", _normalizedOrg getOrDefault ["id", _orgID]]; + + private _result = _self call ["callHotOrg", ["org:hot:override", [_orgID, toJSON _normalizedOrg]]]; + if (_save && { _result isNotEqualTo createHashMap }) then { + private _savedOrg = _self call ["callHotOrg", ["org:hot:save", [_orgID]]]; + if (_savedOrg isNotEqualTo createHashMap) then { + _result = _savedOrg; + } else { + _result = createHashMap; + }; + }; + + _result + }], + ["set", compileFinal { + params [ + ["_orgID", "", [""]], + ["_field", "", [""]], + ["_value", nil, [[], "", 0, false, createHashMap]], + ["_sync", false, [false]] + ]; + + if (_orgID isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; + + private _org = _self call ["get", [_orgID, ""]]; + if !(_org isEqualType createHashMap) exitWith { createHashMap }; + + _org set [_field, _value]; + private _updatedOrg = _self call ["override", [_orgID, _org, _sync]]; + if !(_updatedOrg isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedOrg isEqualTo createHashMap) exitWith { createHashMap }; + + createHashMapFromArray [[_field, _updatedOrg getOrDefault [_field, _value]]] + }], + ["mset", compileFinal { + params [ + ["_orgID", "", [""]], + ["_fieldValuePairs", createHashMap, [createHashMap]], + ["_sync", false, [false]] + ]; + + if (_orgID isEqualTo "") exitWith { createHashMap }; + if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; + + private _org = _self call ["get", [_orgID, ""]]; + if !(_org isEqualType createHashMap) exitWith { createHashMap }; + + { _org set [_x, _y]; } forEach _fieldValuePairs; + private _updatedOrg = _self call ["override", [_orgID, _org, _sync]]; + if !(_updatedOrg isEqualType createHashMap) exitWith { createHashMap }; + if (_updatedOrg isEqualTo createHashMap) exitWith { createHashMap }; + + +_fieldValuePairs }], ["verifyMember", compileFinal { GVAR(OrgMembershipService) call ["verifyMember", _this] @@ -194,7 +328,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - GVAR(Registry) deleteAt _orgID; + ["org:hot:remove", [_orgID]] call EFUNC(extension,extCall); _result set ["success", true]; _result }], @@ -232,7 +366,6 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ // before shaping the portal payload. This prevents stale org caches from // omitting the current member while still resolving owner metadata. _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; - GVAR(Registry) set [_orgID, _org, true]; private _name = _org getOrDefault ["name", ""]; private _id = _org getOrDefault ["id", _orgID]; @@ -357,33 +490,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_orgID isEqualTo "") exitWith { createHashMap }; - private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = _self call ["loadById", [_orgID]]; - }; - if (_org isEqualTo createHashMap) exitWith { createHashMap }; - - private _coreOrg = createHashMapFromArray [ - ["id", _org getOrDefault ["id", _orgID]], - ["owner", _org getOrDefault ["owner", ""]], - ["name", _org getOrDefault ["name", ""]], - ["funds", _org getOrDefault ["funds", 0]], - ["reputation", _org getOrDefault ["reputation", 0]], - ["credit_lines", _org getOrDefault ["credit_lines", createHashMap]] - ]; - - private _coreJson = _self call ["toJSON", [_coreOrg]]; - ["org:update", [_orgID, _coreJson]] call EFUNC(extension,extCall); - - private _assets = _org getOrDefault ["assets", createHashMap]; - private _assetsJson = _self call ["toJSON", [_assets]]; - ["org:assets:update", [_orgID, _assetsJson]] call EFUNC(extension,extCall); - - private _fleet = _org getOrDefault ["fleet", createHashMap]; - private _fleetJson = _self call ["toJSON", [_fleet]]; - ["org:fleet:update", [_orgID, _fleetJson]] call EFUNC(extension,extCall); - - _org + _self call ["callHotOrg", ["org:hot:save", [_orgID]]] }], ["addAssets", compileFinal { params [["_requesterUid", "", [""]], ["_assets", [], [[]]], ["_commit", false, [false]], ["_orgID", "", [""]]]; @@ -408,10 +515,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; - private _org = GVAR(Registry) getOrDefault [_resolvedOrgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = _self call ["loadById", [_resolvedOrgID]]; - }; + private _org = _self call ["loadById", [_resolvedOrgID]]; if (_org isEqualTo createHashMap) exitWith { _result set ["message", "Organization data is unavailable for asset updates."]; _result @@ -437,17 +541,10 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _assetMap set [_category, _categoryMap]; } forEach _assets; - private _patch = _self call ["mset", [ - GVAR(Registry), - "org:update", - _resolvedOrgID, - createHashMapFromArray [["assets", _assetMap]], - false - ]]; - - if (_commit) then { - private _assetJson = _self call ["toJSON", [_assetMap]]; - ["org:assets:update", [_resolvedOrgID, _assetJson]] call EFUNC(extension,extCall); + private _patch = _self call ["mset", [_resolvedOrgID, createHashMapFromArray [["assets", _assetMap]], false]]; + if (_patch isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to update organization asset cache."]; + _result }; _result set ["success", true]; @@ -479,10 +576,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; - private _org = GVAR(Registry) getOrDefault [_resolvedOrgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = _self call ["loadById", [_resolvedOrgID]]; - }; + private _org = _self call ["loadById", [_resolvedOrgID]]; if (_org isEqualTo createHashMap) exitWith { _result set ["message", "Organization data is unavailable for fleet updates."]; _result @@ -518,17 +612,10 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _fleetIndex = _fleetIndex + 1; } forEach _vehicles; - private _patch = _self call ["mset", [ - GVAR(Registry), - "org:update", - _resolvedOrgID, - createHashMapFromArray [["fleet", _fleet]], - false - ]]; - - if (_commit) then { - private _fleetJson = _self call ["toJSON", [_fleet]]; - ["org:fleet:update", [_resolvedOrgID, _fleetJson]] call EFUNC(extension,extCall); + private _patch = _self call ["mset", [_resolvedOrgID, createHashMapFromArray [["fleet", _fleet]], false]]; + if (_patch isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to update organization fleet cache."]; + _result }; _result set ["success", true]; @@ -542,43 +629,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_orgID isEqualTo "") exitWith { createHashMap }; - private _cachedOrg = GVAR(Registry) getOrDefault [_orgID, createHashMap]; - if (_cachedOrg isNotEqualTo createHashMap) exitWith { _cachedOrg }; - - ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_existsResult", "_existsSuccess"]; - if (!_existsSuccess || { _existsResult isNotEqualTo "true" }) exitWith { createHashMap }; - - private _org = _self call ["fetch", ["org:get", _orgID]]; - if (_org isEqualTo createHashMap) exitWith { _org }; - - _org = GVAR(OrgModel) call ["migrate", [_org]]; - private _assets = _self call ["fetch", ["org:assets:get", _orgID]]; - if !(_assets isEqualType createHashMap) then { - _assets = createHashMap; - }; - _org set ["assets", _assets]; - private _fleet = _self call ["fetch", ["org:fleet:get", _orgID]]; - if !(_fleet isEqualType createHashMap) then { - _fleet = createHashMap; - }; - _org set ["fleet", _fleet]; - - private _memberRows = _self call ["fetch", ["org:members:get", _orgID]]; - if !(_memberRows isEqualType []) then { - _memberRows = []; - }; - - private _memberMap = createHashMap; - { - private _memberUid = _x getOrDefault ["uid", ""]; - if (_memberUid isNotEqualTo "") then { - _memberMap set [_memberUid, _x]; - }; - } forEach _memberRows; - - _org set ["members", _memberMap]; - GVAR(Registry) set [_orgID, _org, true]; - _org + _self call ["loadHotOrg", [_orgID, true]] }], ["register", compileFinal { params [["_uid", "", [""]], ["_orgName", "", [""]]]; @@ -651,10 +702,36 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; }; - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, true]]; - GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; - GVAR(Registry) set [_orgID, _org, true]; + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, false]]; + private _updatedActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; + if ( + !(_updatedActor isEqualType createHashMap) + || { _updatedActor isEqualTo createHashMap } + || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } + ) then { + private _forcedActor = +_actor; + if !(_forcedActor isEqualType createHashMap) then { + _forcedActor = EGVAR(actor,ActorModel) call ["defaults", []]; + _forcedActor set ["uid", _uid]; + }; + _forcedActor set ["organization", _orgID]; + _updatedActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; + if (_updatedActor isEqualType createHashMap && { _updatedActor isNotEqualTo createHashMap }) then { + _actorPatch = createHashMapFromArray [["organization", _orgID]]; + }; + }; + + if ( + !(_updatedActor isEqualType createHashMap) + || { _updatedActor isEqualTo createHashMap } + || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } + ) exitWith { + _result set ["message", "Failed to assign the player to the new organization."]; + _result + }; + + _org = _self call ["override", [_orgID, _org, false]]; _result set ["success", true]; _result set ["org", _org]; _result set ["actorPatch", _actorPatch]; @@ -670,53 +747,18 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _orgID = "default"; }; - private _cachedOrg = GVAR(Registry) getOrDefault [_orgID, nil]; - if !(isNil { _cachedOrg }) exitWith { - private _cachedOwner = _cachedOrg getOrDefault ["owner", ""]; - if (_orgID isEqualTo "default" || { _cachedOwner isEqualTo _uid }) then { - _cachedOrg = _self call ["verifyMember", [_cachedOrg, _orgID, _uid, _player, _actor]]; - }; - GVAR(Registry) set [_orgID, _cachedOrg, true]; - [CRPC(org,responseInitOrg), [_cachedOrg], _player] call CFUNC(targetEvent); - - _cachedOrg - }; - - ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check for org %1! Using fallback org.", _orgID]] call EFUNC(common,log); - - private _fallbackOrg = GVAR(Registry) getOrDefault ["default", createHashMap]; - GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; - - if (_orgID isEqualTo "default") then { - _fallbackOrg = _self call ["verifyMember", [_fallbackOrg, _orgID, _uid, _player, _actor]]; - }; - - GVAR(Registry) set [_orgID, _fallbackOrg, true]; - [CRPC(org,responseInitOrg), [_fallbackOrg], _player] call CFUNC(targetEvent); - - _fallbackOrg - }; - - private _finalOrg = createHashMap; - if (_result == "true") then { - _finalOrg = _self call ["loadById", [_orgID]]; - ["INFO", format ["Found org for %1", _orgID]] call EFUNC(common,log); - } else { + private _finalOrg = _self call ["loadById", [_orgID]]; + if (_finalOrg isEqualTo createHashMap) then { ["WARNING", format ["No existing org found for %1, using default org.", _uid]] call EFUNC(common,log); - _finalOrg = GVAR(Registry) getOrDefault ["default", createHashMap]; + _finalOrg = _self call ["loadById", ["default"]]; _orgID = "default"; }; - GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; - private _finalOwner = _finalOrg getOrDefault ["owner", ""]; if (_orgID isEqualTo "default" || { _finalOwner isEqualTo _uid }) then { _finalOrg = _self call ["verifyMember", [_finalOrg, _orgID, _uid, _player, _actor]]; }; - GVAR(Registry) set [_orgID, _finalOrg, true]; [CRPC(org,responseInitOrg), [_finalOrg], _player] call CFUNC(targetEvent); _finalOrg diff --git a/arma/server/addons/org/functions/fnc_memberService.sqf b/arma/server/addons/org/functions/fnc_memberService.sqf index 7e47105..a25f9f9 100644 --- a/arma/server/addons/org/functions/fnc_memberService.sqf +++ b/arma/server/addons/org/functions/fnc_memberService.sqf @@ -36,6 +36,7 @@ GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ private _updatedMembers = +_members; _updatedMembers set [_uid, createHashMapFromArray [["uid", _uid], ["name", _memberName]]]; _org set ["members", _updatedMembers]; + _org = GVAR(OrgStore) call ["override", [_orgID, _org, false]]; _org }], @@ -48,7 +49,6 @@ GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ if (_org isEqualTo createHashMap) exitWith { _org }; _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; - GVAR(Registry) set [_orgID, _org, true]; _org }], @@ -69,7 +69,7 @@ GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ private _updatedMembers = +(_org getOrDefault ["members", createHashMap]); _updatedMembers deleteAt _uid; _org set ["members", _updatedMembers]; - GVAR(Registry) set [_orgID, _org, true]; + _org = GVAR(OrgStore) call ["override", [_orgID, _org, false]]; _org }], @@ -88,15 +88,45 @@ GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ }; private _resolvedActor = EGVAR(actor,Registry) getOrDefault [_uid, _actor]; - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", "default", true]]; - private _defaultActor = EGVAR(actor,Registry) getOrDefault [_uid, _resolvedActor]; + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", "default", false]]; + private _defaultActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; + + if !(_defaultActor isEqualType createHashMap) then { + _defaultActor = +_resolvedActor; + }; + + if ( + (_defaultActor isEqualTo createHashMap) + || { toLowerANSI (_defaultActor getOrDefault ["organization", ""]) isNotEqualTo "default" } + ) then { + private _forcedActor = +_resolvedActor; + if (_forcedActor isEqualTo createHashMap) then { + _forcedActor = EGVAR(actor,ActorModel) call ["defaults", []]; + _forcedActor set ["uid", _uid]; + }; + + _forcedActor set ["organization", "default"]; + _defaultActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; + if (_defaultActor isEqualType createHashMap && { _defaultActor isNotEqualTo createHashMap }) then { + _actorPatch = createHashMapFromArray [["organization", "default"]]; + }; + }; + + if ( + !(_defaultActor isEqualType createHashMap) + || { _defaultActor isEqualTo createHashMap } + || { toLowerANSI (_defaultActor getOrDefault ["organization", ""]) isNotEqualTo "default" } + ) exitWith { + _result set ["message", "Failed to restore default organization membership."]; + _result + }; + private _defaultOrg = _self call ["addMember", ["default", _uid, _resolvedPlayer, _defaultActor]]; if (_defaultOrg isEqualTo createHashMap) exitWith { _result set ["message", "Failed to restore default organization membership."]; _result }; - GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", "default"]]]; _result set ["success", true]; _result set ["actorPatch", _actorPatch]; _result diff --git a/arma/server/addons/org/functions/fnc_treasuryService.sqf b/arma/server/addons/org/functions/fnc_treasuryService.sqf index dfc46d2..f8a0803 100644 --- a/arma/server/addons/org/functions/fnc_treasuryService.sqf +++ b/arma/server/addons/org/functions/fnc_treasuryService.sqf @@ -86,7 +86,7 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ ["amount", _amount] ]]; - private _patch = GVAR(OrgStore) call ["set", [GVAR(Registry), "org:update", _orgID, "credit_lines", _creditLines, true]]; + private _patch = GVAR(OrgStore) call ["set", [_orgID, "credit_lines", _creditLines, false]]; private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; _result set ["success", true]; @@ -103,7 +103,7 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ private _orgID = _requesterActor getOrDefault ["organization", "default"]; if (_orgID isEqualTo "") then { _orgID = "default"; }; - private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; if (_org isEqualTo createHashMap) exitWith { _result set ["message", "Organization data is unavailable for checkout."]; _result @@ -125,7 +125,7 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ }; private _patch = createHashMapFromArray [["funds", (_funds - _amount)]]; - if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; }; + if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [_orgID, _patch, false]]; }; _result set ["success", true]; _result set ["message", ""]; @@ -147,7 +147,7 @@ GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ _creditLines set [_requesterUid, _memberCredit]; private _patch = createHashMapFromArray [["credit_lines", _creditLines]]; - if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; }; + if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [_orgID, _patch, false]]; }; _result set ["success", true]; _result set ["message", ""]; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index b7019dd..1e4b188 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -40,7 +40,7 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ private _isDefaultOrg = false; private _isDefaultOrgCeo = false; - private _bankAccount = EGVAR(bank,Registry) getOrDefault [_uid, createHashMap]; + private _bankAccount = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; if (_bankAccount isEqualTo createHashMap) then { _bankAccount = EGVAR(bank,BankStore) call ["init", [_uid]]; }; diff --git a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf index 0812355..51f8b14 100644 --- a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf +++ b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf @@ -96,10 +96,7 @@ private _syncOrgPatch = { }; if (_funds > 0) then { - private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; - }; + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; if (_org isEqualTo createHashMap) then { ["ERROR", format ["Failed to load organization %1 for task %2 funds reward.", _orgID, _taskID]] call EFUNC(common,log); @@ -108,8 +105,6 @@ if (_funds > 0) then { private _patch = EGVAR(org,OrgStore) call [ "set", [ - EGVAR(org,Registry), - "org:update", _orgID, "funds", ((_org getOrDefault ["funds", 0]) + _funds), @@ -203,7 +198,7 @@ if (count _vehicles > 0) then { if (_success) then { private _orgName = ""; - private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; if (_org isNotEqualTo createHashMap) then { _orgName = _org getOrDefault ["name", _orgID]; }; diff --git a/arma/server/addons/task/functions/fnc_handler.sqf b/arma/server/addons/task/functions/fnc_handler.sqf index 73416f9..0349b27 100644 --- a/arma/server/addons/task/functions/fnc_handler.sqf +++ b/arma/server/addons/task/functions/fnc_handler.sqf @@ -35,11 +35,7 @@ if (_minRating > 0) then { private _orgID = _requesterActor getOrDefault ["organization", "default"]; if (_orgID isEqualTo "") then { _orgID = "default"; }; - private _org = EGVAR(org,Registry) getOrDefault [_orgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; - }; - + private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; private _orgReputation = _org getOrDefault ["reputation", 0]; if (_orgReputation < _minRating) exitWith { private _message = format ["Organization reputation of %1 does not meet the minimum required reputation of %2.", _orgReputation, _minRating]; diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf index 84a9aa0..e102283 100644 --- a/arma/server/addons/task/functions/fnc_initTaskStore.sqf +++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf @@ -351,11 +351,7 @@ GVAR(TaskStore) = createHashMapObject [[ private _resolvedOrgID = _ownership getOrDefault ["orgID", ""]; if (_resolvedOrgID isEqualTo "") exitWith { _result }; - private _org = EGVAR(org,Registry) getOrDefault [_resolvedOrgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = EGVAR(org,OrgStore) call ["loadById", [_resolvedOrgID]]; - }; - + private _org = EGVAR(org,OrgStore) call ["loadById", [_resolvedOrgID]]; private _memberUids = []; if (_org isNotEqualTo createHashMap) then { _memberUids = EGVAR(org,OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]; @@ -448,32 +444,39 @@ GVAR(TaskStore) = createHashMapObject [[ private _participantSnapshots = +(_participantRegistry getOrDefault [_taskID, createHashMap]); if (_participantSnapshots isEqualTo createHashMap) exitWith { _result }; + private _rewardContext = _self call ["resolveRewardContext", [_taskID]]; private _participantUids = keys _participantSnapshots; + if (_participantUids isEqualTo [] && { _delta > 0 }) then { + private _requesterUid = _rewardContext getOrDefault ["requesterUid", ""]; + if (_requesterUid isNotEqualTo "") then { + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + if (!isNull _requesterPlayer) then { + _participantUids pushBack _requesterUid; + _participantSnapshots set [_requesterUid, createHashMapFromArray [ + ["startRating", rating _requesterPlayer] + ]]; + _participantRegistry set [_taskID, _participantSnapshots]; + _self set ["participantRegistry", _participantRegistry]; + ["WARNING", format ["Task %1 had no tracked participants at payout time; falling back to requester %2 for personal earnings.", _taskID, _requesterUid]] call EFUNC(common,log); + }; + }; + }; if (_participantUids isEqualTo []) exitWith { _result }; private _orgIds = []; private _contributions = createHashMap; private _totalContribution = 0; - { - private _uid = _x; - private _player = [_uid] call EFUNC(common,getPlayer); - if (isNull _player) then { continue; }; + if (_delta > 0) then { + { + private _uid = _x; + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; - private _snapshot = _participantSnapshots getOrDefault [_uid, createHashMap]; - private _startRating = _snapshot getOrDefault ["startRating", rating _player]; - private _ratingDelta = (rating _player) - _startRating; - private _contribution = _ratingDelta max 0; - - if (_delta < 0) then { - _contribution = (0 - _ratingDelta) max 0; - }; - - if (_contribution <= 0) then { continue; }; - - _contributions set [_uid, _contribution]; - _totalContribution = _totalContribution + _contribution; - } forEach _participantUids; + _contributions set [_uid, 1]; + _totalContribution = _totalContribution + 1; + } forEach _participantUids; + }; if (_totalContribution <= 0) exitWith { _self call ["clearTask", [_taskID]]; @@ -496,7 +499,7 @@ GVAR(TaskStore) = createHashMapObject [[ private _contribution = _contributions getOrDefault [_uid, 0]; if (_contribution <= 0) then { continue; }; - private _account = EGVAR(bank,Registry) getOrDefault [_uid, createHashMap]; + private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; if (_account isEqualTo createHashMap) then { _account = EGVAR(bank,BankStore) call ["init", [_uid]]; }; @@ -509,26 +512,22 @@ GVAR(TaskStore) = createHashMapObject [[ private _patch = EGVAR(bank,BankStore) call [ "mset", [ - EGVAR(bank,Registry), - "bank:update", _uid, createHashMapFromArray [["earnings", (_earnings + _earningsDelta)]], false ] ]; + if !(_patch isEqualType createHashMap) then { continue; }; + if (_patch isEqualTo createHashMap) then { continue; }; EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]]; }; }; } forEach _participantUids; - private _rewardContext = _self call ["resolveRewardContext", [_taskID]]; private _ownerOrgID = _rewardContext getOrDefault ["orgID", ""]; if (_ownerOrgID isNotEqualTo "") then { - private _org = EGVAR(org,Registry) getOrDefault [_ownerOrgID, createHashMap]; - if (_org isEqualTo createHashMap) then { - _org = EGVAR(org,OrgStore) call ["loadById", [_ownerOrgID]]; - }; + private _org = EGVAR(org,OrgStore) call ["loadById", [_ownerOrgID]]; if (_org isNotEqualTo createHashMap) then { private _reputation = _org getOrDefault ["reputation", 0]; @@ -536,8 +535,6 @@ GVAR(TaskStore) = createHashMapObject [[ private _patch = EGVAR(org,OrgStore) call [ "set", [ - EGVAR(org,Registry), - "org:update", _ownerOrgID, "reputation", _nextReputation, diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index f11f103..1158926 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -4,8 +4,8 @@ //! Handles SQF command mapping and parameter validation. use arma_rs::{CallContext, Group}; -use forge_repositories::RedisActorRepository; -use forge_services::ActorService; +use forge_repositories::{InMemoryActorHotRepository, RedisActorRepository}; +use forge_services::{ActorHotStateService, ActorService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; @@ -21,6 +21,14 @@ static ACTOR_SERVICE: LazyLock, InMemoryActorHotRepository>, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisActorRepository::new(redis_client); + let hot_repository = InMemoryActorHotRepository::new(); + ActorHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for actor operations. /// @@ -32,6 +40,86 @@ pub fn group() -> Group { .command("update", update_actor) .command("exists", actor_exists) .command("delete", delete_actor) + .group( + "hot", + Group::new() + .command("init", init_hot_actor) + .command("get", get_hot_actor) + .command("override", override_hot_actor) + .command("save", save_hot_actor) + .command("remove", remove_hot_actor), + ) +} + +fn serialize_hot_actor(actor: forge_models::Actor) -> String { + match serde_json::to_string(&actor) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot actor: {}", error), + } +} + +pub(crate) fn init_hot_actor(call_context: CallContext, key: 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), + }; + + match HOT_ACTOR_SERVICE.init_actor(resolved_uid) { + Ok(actor) => serialize_hot_actor(actor), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn get_hot_actor(call_context: CallContext, key: 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), + }; + + match HOT_ACTOR_SERVICE.get_actor(resolved_uid) { + Ok(actor) => serialize_hot_actor(actor), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn override_hot_actor( + call_context: CallContext, + key: String, + json_data: 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), + }; + + match HOT_ACTOR_SERVICE.override_actor(resolved_uid, json_data) { + Ok(actor) => serialize_hot_actor(actor), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_actor(call_context: CallContext, key: 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), + }; + + match HOT_ACTOR_SERVICE.save_actor(resolved_uid) { + Ok(saved_actor) => serialize_hot_actor(saved_actor), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_actor(call_context: CallContext, key: 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), + }; + + match HOT_ACTOR_SERVICE.remove_actor(resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } } /// Retrieves an actor by key/UID. diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index 5e3d85e..3f591a5 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -4,8 +4,12 @@ //! Handles SQF command mapping and parameter validation. use arma_rs::{CallContext, Group}; -use forge_repositories::RedisBankRepository; -use forge_services::BankService; +use forge_models::{ + BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, + BankTransferResult, +}; +use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository}; +use forge_services::{BankHotStateService, BankService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; @@ -21,6 +25,14 @@ static BANK_SERVICE: LazyLock, InMemoryBankHotRepository>, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisBankRepository::new(redis_client); + let hot_repository = InMemoryBankHotRepository::new(); + BankHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for bank operations. /// @@ -32,6 +44,286 @@ pub fn group() -> Group { .command("update", update_bank) .command("exists", bank_exists) .command("delete", delete_bank) + .group( + "hot", + Group::new() + .command("init", init_hot_bank) + .command("get", get_hot_bank) + .command("override", override_hot_bank) + .command("patch", patch_hot_bank) + .command("deposit", deposit_hot_bank) + .command("withdraw", withdraw_hot_bank) + .command("payment", payment_hot_bank) + .command("deposit_earnings", deposit_earnings_hot_bank) + .command("transfer", transfer_hot_bank) + .command("validate_pin", validate_pin_hot_bank) + .command("save", save_hot_bank) + .command("remove", remove_hot_bank), + ) +} + +fn serialize_hot_bank(bank: forge_models::Bank) -> String { + match serde_json::to_string(&bank) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot bank: {}", error), + } +} + +fn serialize_hot_bank_mutation(result: BankMutationResult) -> String { + match serde_json::to_string(&result) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot bank mutation: {}", error), + } +} + +fn serialize_hot_bank_transfer(result: BankTransferResult) -> String { + match serde_json::to_string(&result) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot bank transfer: {}", error), + } +} + +fn parse_amount(amount: String, label: &str) -> Result { + amount + .parse::() + .map_err(|error| format!("Invalid {} amount '{}': {}", label, amount, error)) +} + +fn parse_operation_context(json_context: String) -> Result { + serde_json::from_str(&json_context) + .map_err(|error| format!("Invalid bank operation context: {}", error)) +} + +fn parse_transfer_context(json_context: String) -> Result { + serde_json::from_str(&json_context) + .map_err(|error| format!("Invalid bank transfer context: {}", error)) +} + +fn parse_pin_context(json_context: String) -> Result { + serde_json::from_str(&json_context) + .map_err(|error| format!("Invalid bank PIN context: {}", error)) +} + +pub(crate) fn init_hot_bank(call_context: CallContext, key: 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), + }; + + match HOT_BANK_SERVICE.init_bank(resolved_uid) { + Ok(bank) => serialize_hot_bank(bank), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn get_hot_bank(call_context: CallContext, key: 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), + }; + + match HOT_BANK_SERVICE.get_bank(resolved_uid) { + Ok(bank) => serialize_hot_bank(bank), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn override_hot_bank( + call_context: CallContext, + key: String, + json_data: 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), + }; + + match HOT_BANK_SERVICE.override_bank(resolved_uid.clone(), json_data) { + Ok(bank) => serialize_hot_bank(bank), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn patch_hot_bank(call_context: CallContext, key: String, json_patch: 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), + }; + + match HOT_BANK_SERVICE.patch_bank(resolved_uid, json_patch) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn deposit_hot_bank( + call_context: CallContext, + key: String, + amount: 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 amount = match parse_amount(amount, "deposit") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + let context = match parse_operation_context(json_context) { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.deposit(resolved_uid, amount, context) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn withdraw_hot_bank( + call_context: CallContext, + key: String, + amount: 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 amount = match parse_amount(amount, "withdraw") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + let context = match parse_operation_context(json_context) { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.withdraw(resolved_uid, amount, context) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn payment_hot_bank(call_context: CallContext, key: String, amount: 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 amount = match parse_amount(amount, "payment") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.payment(resolved_uid, amount) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn deposit_earnings_hot_bank( + call_context: CallContext, + key: String, + amount: 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 amount = match parse_amount(amount, "deposit earnings") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + let context = match parse_operation_context(json_context) { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.deposit_earnings(resolved_uid, amount, context) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn transfer_hot_bank( + call_context: CallContext, + source_key: String, + target_key: String, + amount: String, + json_context: String, +) -> String { + let resolved_source_uid = match resolve_uid(&source_key, &call_context) { + Some(uid) => uid, + None => return format!("Error: Failed to resolve UID for key: {}", source_key), + }; + let resolved_target_uid = match resolve_uid(&target_key, &call_context) { + Some(uid) => uid, + None => return format!("Error: Failed to resolve UID for key: {}", target_key), + }; + let amount = match parse_amount(amount, "transfer") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + let context = match parse_transfer_context(json_context) { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.transfer(resolved_source_uid, resolved_target_uid, context, amount) { + Ok(result) => serialize_hot_bank_transfer(result), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn validate_pin_hot_bank( + call_context: CallContext, + key: String, + 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.validate_pin(resolved_uid, pin, context) { + Ok(_) => "{}".to_string(), + 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, + None => return format!("Error: Failed to resolve UID for key: {}", key), + }; + + match HOT_BANK_SERVICE.save_bank(resolved_uid) { + Ok(saved_bank) => serialize_hot_bank(saved_bank), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_bank(call_context: CallContext, key: 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), + }; + + match HOT_BANK_SERVICE.remove_bank(resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } } /// Retrieves an bank by key/UID. diff --git a/arma/server/extension/src/cad.rs b/arma/server/extension/src/cad.rs index b9492c7..5984e99 100644 --- a/arma/server/extension/src/cad.rs +++ b/arma/server/extension/src/cad.rs @@ -63,107 +63,107 @@ pub fn group() -> Group { .group("view", Group::new().command("hydrate", hydrate_view)) } -fn append_activity(json_data: String) -> String { +pub(crate) fn append_activity(json_data: String) -> String { serialize_ok(CAD_SERVICE.append_activity(json_data)) } -fn recent_activity(limit: String) -> String { +pub(crate) fn recent_activity(limit: String) -> String { serialize_json(CAD_SERVICE.recent_activity(limit)) } -fn list_assignments() -> String { +pub(crate) fn list_assignments() -> String { serialize_json(CAD_SERVICE.list_assignments()) } -fn assign_assignment(entry_id: String, json_data: String) -> String { +pub(crate) fn assign_assignment(entry_id: String, json_data: String) -> String { serialize_json(CAD_SERVICE.assign_assignment(entry_id, json_data)) } -fn acknowledge_assignment(entry_id: String, json_data: String) -> String { +pub(crate) fn acknowledge_assignment(entry_id: String, json_data: String) -> String { serialize_json(CAD_SERVICE.acknowledge_assignment(entry_id, json_data)) } -fn decline_assignment(entry_id: String, json_data: String) -> String { +pub(crate) fn decline_assignment(entry_id: String, json_data: String) -> String { serialize_json(CAD_SERVICE.decline_assignment(entry_id, json_data)) } -fn upsert_assignment(entry_id: String, json_data: String) -> String { +pub(crate) fn upsert_assignment(entry_id: String, json_data: String) -> String { serialize_ok(CAD_SERVICE.upsert_assignment(entry_id, json_data)) } -fn delete_assignment(entry_id: String) -> String { +pub(crate) fn delete_assignment(entry_id: String) -> String { serialize_ok(CAD_SERVICE.delete_assignment(entry_id)) } -fn list_orders() -> String { +pub(crate) fn list_orders() -> String { serialize_json(CAD_SERVICE.list_orders()) } -fn create_order(json_data: String) -> String { +pub(crate) fn create_order(json_data: String) -> String { serialize_json(CAD_SERVICE.create_order(json_data)) } -fn create_order_from_context(json_data: String) -> String { +pub(crate) fn create_order_from_context(json_data: String) -> String { serialize_json(CAD_SERVICE.create_order_from_context(json_data)) } -fn close_order(entry_id: String) -> String { +pub(crate) fn close_order(entry_id: String) -> String { serialize_json(CAD_SERVICE.close_order(entry_id)) } -fn upsert_order(entry_id: String, json_data: String) -> String { +pub(crate) fn upsert_order(entry_id: String, json_data: String) -> String { serialize_ok(CAD_SERVICE.upsert_order(entry_id, json_data)) } -fn delete_order(entry_id: String) -> String { +pub(crate) fn delete_order(entry_id: String) -> String { serialize_ok(CAD_SERVICE.delete_order(entry_id)) } -fn list_requests() -> String { +pub(crate) fn list_requests() -> String { serialize_json(CAD_SERVICE.list_requests()) } -fn submit_request(json_data: String) -> String { +pub(crate) fn submit_request(json_data: String) -> String { serialize_json(CAD_SERVICE.submit_request(json_data)) } -fn submit_request_from_context(json_data: String) -> String { +pub(crate) fn submit_request_from_context(json_data: String) -> String { serialize_json(CAD_SERVICE.submit_request_from_context(json_data)) } -fn close_request(entry_id: String) -> String { +pub(crate) fn close_request(entry_id: String) -> String { serialize_json(CAD_SERVICE.close_request(entry_id)) } -fn upsert_request(entry_id: String, json_data: String) -> String { +pub(crate) fn upsert_request(entry_id: String, json_data: String) -> String { serialize_ok(CAD_SERVICE.upsert_request(entry_id, json_data)) } -fn delete_request(entry_id: String) -> String { +pub(crate) fn delete_request(entry_id: String) -> String { serialize_ok(CAD_SERVICE.delete_request(entry_id)) } -fn list_profiles() -> String { +pub(crate) fn list_profiles() -> String { serialize_json(CAD_SERVICE.list_profiles()) } -fn update_profile_from_context(json_data: String) -> String { +pub(crate) fn update_profile_from_context(json_data: String) -> String { serialize_json(CAD_SERVICE.update_profile_from_context(json_data)) } -fn upsert_profile(entry_id: String, json_data: String) -> String { +pub(crate) fn upsert_profile(entry_id: String, json_data: String) -> String { serialize_ok(CAD_SERVICE.upsert_profile(entry_id, json_data)) } -fn delete_profile(entry_id: String) -> String { +pub(crate) fn delete_profile(entry_id: String) -> String { serialize_ok(CAD_SERVICE.delete_profile(entry_id)) } -fn build_groups(json_data: String) -> String { +pub(crate) fn build_groups(json_data: String) -> String { serialize_json(CAD_SERVICE.build_groups(json_data)) } -fn hydrate_view(json_data: String) -> String { +pub(crate) fn hydrate_view(json_data: String) -> String { serialize_json(CAD_SERVICE.build_hydrate_payload(json_data)) } diff --git a/arma/server/extension/src/garage.rs b/arma/server/extension/src/garage.rs index c1488a2..b7b9414 100644 --- a/arma/server/extension/src/garage.rs +++ b/arma/server/extension/src/garage.rs @@ -4,8 +4,8 @@ use arma_rs::{CallContext, Group}; use forge_models::Vehicle; -use forge_repositories::RedisGarageRepository; -use forge_services::GarageService; +use forge_repositories::{InMemoryGarageHotRepository, RedisGarageRepository}; +use forge_services::{GarageHotStateService, GarageService}; use std::collections::HashMap; use std::sync::LazyLock; @@ -20,6 +20,14 @@ static GARAGE_SERVICE: LazyLock, InMemoryGarageHotRepository>, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisGarageRepository::new(redis_client); + let hot_repository = InMemoryGarageHotRepository::new(); + GarageHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for garage operations. /// @@ -34,6 +42,148 @@ pub fn group() -> Group { .command("remove", remove_vehicle) .command("delete", delete_garage) .command("exists", garage_exists) + .group( + "hot", + Group::new() + .command("init", init_hot_garage) + .command("get", get_hot_garage) + .command("override", override_hot_garage) + .command("save", save_hot_garage) + .command("remove", remove_hot_garage) + .command("add", add_hot_vehicle) + .command("remove_vehicle", remove_hot_vehicle), + ) +} + +fn serialize_hot_vehicles(garage: forge_models::garage::Garage) -> String { + match serde_json::to_string(&garage.vehicles) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot garage: {}", error), + } +} + +pub(crate) fn init_hot_garage(call_context: CallContext, key: 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), + }; + + match HOT_GARAGE_SERVICE.init_garage(resolved_uid) { + Ok(garage) => serialize_hot_vehicles(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn get_hot_garage(call_context: CallContext, key: String) -> String { + init_hot_garage(call_context, key) +} + +pub(crate) fn override_hot_garage( + call_context: CallContext, + key: String, + json_data: 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 vehicles: HashMap = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid JSON data: {}", error), + }; + + match HOT_GARAGE_SERVICE.override_garage(resolved_uid, vehicles) { + Ok(garage) => serialize_hot_vehicles(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_garage(call_context: CallContext, key: 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), + }; + + match HOT_GARAGE_SERVICE.save_garage(resolved_uid) { + Ok(saved_garage) => serialize_hot_vehicles(saved_garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_garage(call_context: CallContext, key: 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), + }; + + match HOT_GARAGE_SERVICE.remove_garage(resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn add_hot_vehicle(call_context: CallContext, key: String, json_data: 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 data: serde_json::Value = match serde_json::from_str(&json_data) { + Ok(d) => d, + Err(error) => return format!("Error: Invalid JSON data: {}", error), + }; + + let classname = match data.get("classname").and_then(|v| v.as_str()) { + Some(c) => c.to_string(), + None => return "Error: Missing or invalid classname".to_string(), + }; + let fuel = match data.get("fuel").and_then(|v| v.as_f64()) { + Some(f) => f, + None => return "Error: Missing or invalid fuel".to_string(), + }; + let damage = match data.get("damage").and_then(|v| v.as_f64()) { + Some(d) => d, + None => return "Error: Missing or invalid damage".to_string(), + }; + let hit_points_json = match data.get("hit_points") { + Some(hp) => match serde_json::to_string(hp) { + Ok(s) => s, + Err(error) => return format!("Error: Failed to serialize hit_points: {}", error), + }, + None => return "Error: Missing hit_points".to_string(), + }; + + match HOT_GARAGE_SERVICE.add_vehicle(resolved_uid, classname, fuel, damage, hit_points_json) { + Ok(garage) => serialize_hot_vehicles(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_vehicle( + call_context: CallContext, + key: String, + json_data: 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 data: serde_json::Value = match serde_json::from_str(&json_data) { + Ok(d) => d, + Err(error) => return format!("Error: Invalid JSON data: {}", error), + }; + + let plate = match data.get("plate").and_then(|v| v.as_str()) { + Some(p) => p.to_string(), + None => return "Error: Missing or invalid plate".to_string(), + }; + + match HOT_GARAGE_SERVICE.remove_vehicle(resolved_uid, plate) { + Ok(garage) => serialize_hot_vehicles(garage), + Err(error) => format!("Error: {}", error), + } } /// Creates a new empty garage for a player. diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index 23d1d98..a44717c 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -23,6 +23,7 @@ mod log; pub mod org; pub mod redis; pub mod terrain; +pub mod transport; pub mod v_garage; pub mod v_locker; @@ -70,6 +71,7 @@ fn init() -> Extension { .group("locker", locker::group()) .group("org", org::group()) .group("terrain", terrain::group()) + .group("transport", transport::group()) .group( "owned", Group::new() diff --git a/arma/server/extension/src/locker.rs b/arma/server/extension/src/locker.rs index c20b3c4..3244f78 100644 --- a/arma/server/extension/src/locker.rs +++ b/arma/server/extension/src/locker.rs @@ -1,7 +1,7 @@ use arma_rs::{CallContext, Group}; use forge_models::locker::Item; -use forge_repositories::RedisLockerRepository; -use forge_services::LockerService; +use forge_repositories::{InMemoryLockerHotRepository, RedisLockerRepository}; +use forge_services::{LockerHotStateService, LockerService}; use std::collections::HashMap; use std::sync::LazyLock; @@ -15,6 +15,14 @@ static LOCKER_SERVICE: LazyLock, InMemoryLockerHotRepository>, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisLockerRepository::new(redis_client); + let hot_repository = InMemoryLockerHotRepository::new(); + LockerHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for locker operations. /// @@ -29,6 +37,83 @@ pub fn group() -> Group { .command("remove", remove_item) .command("delete", delete_locker) .command("exists", locker_exists) + .group( + "hot", + Group::new() + .command("init", init_hot_locker) + .command("get", get_hot_locker) + .command("override", override_hot_locker) + .command("save", save_hot_locker) + .command("remove", remove_hot_locker), + ) +} + +fn serialize_hot_items(locker: forge_models::locker::Locker) -> String { + match serde_json::to_string(&locker.items) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot locker: {}", error), + } +} + +pub(crate) fn init_hot_locker(call_context: CallContext, key: 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), + }; + + match HOT_LOCKER_SERVICE.init_locker(resolved_uid) { + Ok(locker) => serialize_hot_items(locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn get_hot_locker(call_context: CallContext, key: String) -> String { + init_hot_locker(call_context, key) +} + +pub(crate) fn override_hot_locker( + call_context: CallContext, + key: String, + json_data: 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 items: std::collections::HashMap = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid JSON data: {}", error), + }; + + match HOT_LOCKER_SERVICE.override_locker(resolved_uid, items) { + Ok(locker) => serialize_hot_items(locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_locker(call_context: CallContext, key: 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), + }; + + match HOT_LOCKER_SERVICE.save_locker(resolved_uid) { + Ok(saved_locker) => serialize_hot_items(saved_locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_locker(call_context: CallContext, key: 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), + }; + + match HOT_LOCKER_SERVICE.remove_locker(resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } } /// Creates a new empty locker for a player. diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index 2f4d7f4..3c3eae7 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -4,8 +4,9 @@ //! Handles SQF command mapping and parameter validation. use arma_rs::Group; -use forge_repositories::RedisOrgRepository; -use forge_services::OrgService; +use forge_models::HotOrgRecord; +use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository}; +use forge_services::{OrgHotStateService, OrgService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; @@ -20,6 +21,14 @@ static ORG_SERVICE: LazyLock let repository = RedisOrgRepository::new(redis_client); OrgService::new(repository) }); +static HOT_ORG_SERVICE: LazyLock< + OrgHotStateService, InMemoryOrgHotRepository>, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisOrgRepository::new(redis_client); + let hot_repository = InMemoryOrgHotRepository::new(); + OrgHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for organization operations. /// @@ -31,6 +40,15 @@ pub fn group() -> Group { .command("update", update_org) .command("exists", org_exists) .command("delete", delete_org) + .group( + "hot", + Group::new() + .command("init", init_hot_org) + .command("get", get_hot_org) + .command("override", override_hot_org) + .command("save", save_hot_org) + .command("remove", remove_hot_org), + ) .group( "assets", Group::new() @@ -52,6 +70,53 @@ pub fn group() -> Group { ) } +fn serialize_hot_org(org: HotOrgRecord) -> String { + match serde_json::to_string(&org) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot org: {}", error), + } +} + +pub(crate) fn init_hot_org(org_id: String) -> String { + match HOT_ORG_SERVICE.init_org(org_id) { + Ok(org) => serialize_hot_org(org), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn get_hot_org(org_id: String) -> String { + match HOT_ORG_SERVICE.get_org(org_id) { + Ok(org) => serialize_hot_org(org), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn override_hot_org(org_id: String, json_data: String) -> String { + let hot_org: HotOrgRecord = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org JSON: {}", error), + }; + + match HOT_ORG_SERVICE.override_org(org_id, hot_org) { + Ok(org) => serialize_hot_org(org), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_org(org_id: String) -> String { + match HOT_ORG_SERVICE.save_org(org_id) { + Ok(org) => serialize_hot_org(org), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_org(org_id: String) -> String { + match HOT_ORG_SERVICE.remove_org(org_id) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } +} + // ============================================================================ // Organization Asset Operations // ============================================================================ diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs new file mode 100644 index 0000000..4e2f6c6 --- /dev/null +++ b/arma/server/extension/src/transport.rs @@ -0,0 +1,951 @@ +//! Shared transport helpers for oversized extension requests and responses. +//! +//! This module provides a routed invoke path that accepts JSON-encoded string +//! arguments, supports request staging for large payloads, and stores oversized +//! responses in memory for chunked retrieval by SQF. + +use arma_rs::{CallContext, Group}; +use serde::Serialize; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex as StdMutex}; + +use crate::{actor, bank, cad, garage, locker, org, v_garage, v_locker}; + +const CHUNK_PREFIX: &str = "FORGE_TRANSPORT_CHUNK:"; +const RESPONSE_CHUNK_SIZE: usize = 12_000; +const UNSUPPORTED_ROUTE_PREFIX: &str = "Unsupported transport route"; + +static REQUEST_STORE: LazyLock>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); +static RESPONSE_STORE: LazyLock>>> = + LazyLock::new(|| StdMutex::new(HashMap::new())); +static TRANSFER_SEQUENCE: AtomicU64 = AtomicU64::new(1); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ChunkEnvelope { + transfer_id: String, + chunk_count: usize, + total_size: usize, +} + +pub fn group() -> Group { + Group::new() + .command("invoke", invoke) + .command("invoke_stored", invoke_stored) + .group( + "request", + Group::new() + .command("append", append_request_chunk) + .command("clear", clear_request_chunks), + ) + .group( + "response", + Group::new() + .command("get", get_response_chunk) + .command("clear", clear_response_chunks), + ) +} + +fn append_request_chunk(transfer_id: String, chunk: String) -> String { + let mut store = REQUEST_STORE.lock().unwrap(); + store.entry(transfer_id).or_default().push_str(&chunk); + "OK".to_string() +} + +fn clear_request_chunks(transfer_id: String) -> String { + REQUEST_STORE.lock().unwrap().remove(&transfer_id); + "OK".to_string() +} + +fn get_response_chunk(transfer_id: String, index: String) -> String { + let chunk_index = match index.parse::() { + Ok(value) => value, + Err(error) => return format!("Error: Invalid response chunk index: {error}"), + }; + + let store = RESPONSE_STORE.lock().unwrap(); + let Some(chunks) = store.get(&transfer_id) else { + return format!("Error: Response transfer '{transfer_id}' was not found"); + }; + + chunks.get(chunk_index).cloned().unwrap_or_else(|| { + format!( + "Error: Response chunk {} was not found for '{}'", + chunk_index, transfer_id + ) + }) +} + +fn clear_response_chunks(transfer_id: String) -> String { + RESPONSE_STORE.lock().unwrap().remove(&transfer_id); + "OK".to_string() +} + +fn invoke(call_context: CallContext, function_name: String, arguments_json: String) -> String { + invoke_internal(call_context, function_name, arguments_json) +} + +fn invoke_stored(call_context: CallContext, function_name: String, transfer_id: String) -> String { + let Some(arguments_json) = REQUEST_STORE.lock().unwrap().remove(&transfer_id) else { + return format!("Error: Request transfer '{transfer_id}' was not found"); + }; + + invoke_internal(call_context, function_name, arguments_json) +} + +fn invoke_internal( + call_context: CallContext, + function_name: String, + arguments_json: String, +) -> String { + let arguments: Vec = match parse_transport_arguments(&arguments_json) { + Ok(value) => value, + Err(error) => return format!("Error: Invalid transport arguments JSON: {error}"), + }; + + let result = match route_command(call_context, &function_name, arguments) { + Ok(value) => value, + Err(error) => format!("Error: {error}"), + }; + + chunk_response_if_needed(result) +} + +fn parse_transport_arguments(arguments_json: &str) -> Result, String> { + let value: serde_json::Value = + serde_json::from_str(arguments_json).map_err(|error| error.to_string())?; + parse_transport_argument_value(value) +} + +fn parse_transport_argument_value(value: serde_json::Value) -> Result, String> { + match value { + serde_json::Value::Array(values) => Ok(values + .into_iter() + .map(|entry| match entry { + serde_json::Value::String(string_value) => string_value, + other => other.to_string(), + }) + .collect()), + serde_json::Value::String(value) => { + let trimmed = value.trim(); + if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.eq("null") { + if let Ok(nested_value) = serde_json::from_str::(trimmed) { + return parse_transport_argument_value(nested_value); + } + } + + Ok(vec![value]) + } + serde_json::Value::Null => Ok(Vec::new()), + other => Err(format!("expected string or array but received {}", other)), + } +} + +fn route_command( + call_context: CallContext, + function_name: &str, + arguments: Vec, +) -> Result { + match function_name { + "actor:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::get_actor(call_context, arguments[0].clone())) + } + "actor:create" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(actor::create_actor( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "actor:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(actor::update_actor( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "actor:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::actor_exists(call_context, arguments[0].clone())) + } + "actor:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::delete_actor(call_context, arguments[0].clone())) + } + "actor:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::init_hot_actor(call_context, arguments[0].clone())) + } + "actor:hot:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::get_hot_actor(call_context, arguments[0].clone())) + } + "actor:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(actor::override_hot_actor( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "actor:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::save_hot_actor(call_context, arguments[0].clone())) + } + "actor:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(actor::remove_hot_actor(call_context, arguments[0].clone())) + } + "bank:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::get_bank(call_context, arguments[0].clone())) + } + "bank:create" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(bank::create_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "bank:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(bank::update_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "bank:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::bank_exists(call_context, arguments[0].clone())) + } + "bank:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::delete_bank(call_context, arguments[0].clone())) + } + "bank:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::init_hot_bank(call_context, arguments[0].clone())) + } + "bank:hot:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::get_hot_bank(call_context, arguments[0].clone())) + } + "bank:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(bank::override_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "bank:hot:patch" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(bank::patch_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "bank:hot:deposit" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(bank::deposit_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "bank:hot:withdraw" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(bank::withdraw_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "bank:hot:payment" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(bank::payment_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "bank:hot:deposit_earnings" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(bank::deposit_earnings_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "bank:hot:transfer" => { + expect_arg_count(function_name, &arguments, 4)?; + Ok(bank::transfer_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + arguments[3].clone(), + )) + } + "bank:hot:validate_pin" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(bank::validate_pin_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "bank:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::save_hot_bank(call_context, arguments[0].clone())) + } + "bank:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(bank::remove_hot_bank(call_context, arguments[0].clone())) + } + "org:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_org(arguments[0].clone())) + } + "org:create" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::create_org(arguments[0].clone(), arguments[1].clone())) + } + "org:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::update_org(arguments[0].clone(), arguments[1].clone())) + } + "org:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::org_exists(arguments[0].clone())) + } + "org:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::delete_org(arguments[0].clone())) + } + "org:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::init_hot_org(arguments[0].clone())) + } + "org:hot:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_hot_org(arguments[0].clone())) + } + "org:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::override_hot_org( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "org:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::save_hot_org(arguments[0].clone())) + } + "org:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::remove_hot_org(arguments[0].clone())) + } + "org:assets:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_assets(arguments[0].clone())) + } + "org:assets:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::update_assets( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "org:fleet:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_fleet(arguments[0].clone())) + } + "org:fleet:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::update_fleet( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "org:members:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_members(arguments[0].clone())) + } + "org:members:add" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::add_member(arguments[0].clone(), arguments[1].clone())) + } + "org:members:remove" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::remove_member( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:create" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::create_garage(call_context, arguments[0].clone())) + } + "garage:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::get_garage(call_context, arguments[0].clone())) + } + "garage:add" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::add_vehicle( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::update_garage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:patch" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::patch_vehicle( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:remove" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::remove_vehicle( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::delete_garage(call_context, arguments[0].clone())) + } + "garage:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::garage_exists(call_context, arguments[0].clone())) + } + "garage:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::init_hot_garage(call_context, arguments[0].clone())) + } + "garage:hot:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::get_hot_garage(call_context, arguments[0].clone())) + } + "garage:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::override_hot_garage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::save_hot_garage(call_context, arguments[0].clone())) + } + "garage:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(garage::remove_hot_garage( + call_context, + arguments[0].clone(), + )) + } + "garage:hot:add" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::add_hot_vehicle( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "garage:hot:remove_vehicle" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(garage::remove_hot_vehicle( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:create" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::create_locker(call_context, arguments[0].clone())) + } + "locker:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::get_locker(call_context, arguments[0].clone())) + } + "locker:add" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(locker::add_item( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:update" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(locker::update_locker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:patch" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(locker::patch_item( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:remove" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(locker::remove_item( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::delete_locker(call_context, arguments[0].clone())) + } + "locker:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::locker_exists(call_context, arguments[0].clone())) + } + "locker:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::init_hot_locker(call_context, arguments[0].clone())) + } + "locker:hot:get" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::get_hot_locker(call_context, arguments[0].clone())) + } + "locker:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(locker::override_hot_locker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "locker:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::save_hot_locker(call_context, arguments[0].clone())) + } + "locker:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(locker::remove_hot_locker( + call_context, + arguments[0].clone(), + )) + } + "owned:garage:create" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::create_vgarage(call_context, arguments[0].clone())) + } + "owned:garage:fetch" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::fetch_vgarage(call_context, arguments[0].clone())) + } + "owned:garage:get" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_garage::get_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:garage:add" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_garage::add_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:garage:remove" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_garage::remove_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:garage:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::delete_vgarage(call_context, arguments[0].clone())) + } + "owned:garage:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::vgarage_exists(call_context, arguments[0].clone())) + } + "owned:garage:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::init_hot_vgarage( + call_context, + arguments[0].clone(), + )) + } + "owned:garage:hot:fetch" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::fetch_hot_vgarage( + call_context, + arguments[0].clone(), + )) + } + "owned:garage:hot:get" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_garage::get_hot_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:garage:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_garage::override_hot_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:garage:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::save_hot_vgarage( + call_context, + arguments[0].clone(), + )) + } + "owned:garage:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_garage::remove_hot_vgarage( + call_context, + arguments[0].clone(), + )) + } + "owned:garage:hot:add" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_garage::add_hot_vgarage( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:garage:hot:remove_item" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_garage::remove_hot_vgarage_item( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:locker:create" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::create_vlocker(call_context, arguments[0].clone())) + } + "owned:locker:fetch" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::fetch_vlocker(call_context, arguments[0].clone())) + } + "owned:locker:get" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_locker::get_vlocker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:locker:add" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_locker::add_vlocker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:locker:remove" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(v_locker::remove_vlocker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } + "owned:locker:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::delete_vlocker(call_context, arguments[0].clone())) + } + "owned:locker:exists" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::vlocker_exists(call_context, arguments[0].clone())) + } + "owned:locker:hot:init" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::init_hot_vlocker( + call_context, + arguments[0].clone(), + )) + } + "owned:locker:hot:fetch" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::fetch_hot_vlocker( + call_context, + arguments[0].clone(), + )) + } + "owned:locker:hot:get" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_locker::get_hot_vlocker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:locker:hot:override" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(v_locker::override_hot_vlocker( + call_context, + arguments[0].clone(), + arguments[1].clone(), + )) + } + "owned:locker:hot:save" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::save_hot_vlocker( + call_context, + arguments[0].clone(), + )) + } + "owned:locker:hot:remove" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(v_locker::remove_hot_vlocker( + call_context, + arguments[0].clone(), + )) + } + "cad:activity:append" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::append_activity(arguments[0].clone())) + } + "cad:activity:recent" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::recent_activity(arguments[0].clone())) + } + "cad:assignments:list" => { + expect_arg_count(function_name, &arguments, 0)?; + Ok(cad::list_assignments()) + } + "cad:assignments:assign" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::assign_assignment( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:assignments:acknowledge" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::acknowledge_assignment( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:assignments:decline" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::decline_assignment( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:assignments:upsert" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::upsert_assignment( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:assignments:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::delete_assignment(arguments[0].clone())) + } + "cad:orders:list" => { + expect_arg_count(function_name, &arguments, 0)?; + Ok(cad::list_orders()) + } + "cad:orders:create" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::create_order(arguments[0].clone())) + } + "cad:orders:create_from_context" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::create_order_from_context(arguments[0].clone())) + } + "cad:orders:close" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::close_order(arguments[0].clone())) + } + "cad:orders:upsert" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::upsert_order( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:orders:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::delete_order(arguments[0].clone())) + } + "cad:requests:list" => { + expect_arg_count(function_name, &arguments, 0)?; + Ok(cad::list_requests()) + } + "cad:requests:submit" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::submit_request(arguments[0].clone())) + } + "cad:requests:submit_from_context" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::submit_request_from_context(arguments[0].clone())) + } + "cad:requests:close" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::close_request(arguments[0].clone())) + } + "cad:requests:upsert" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::upsert_request( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:requests:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::delete_request(arguments[0].clone())) + } + "cad:profiles:list" => { + expect_arg_count(function_name, &arguments, 0)?; + Ok(cad::list_profiles()) + } + "cad:profiles:update_from_context" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::update_profile_from_context(arguments[0].clone())) + } + "cad:profiles:upsert" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(cad::upsert_profile( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "cad:profiles:delete" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::delete_profile(arguments[0].clone())) + } + "cad:groups:build" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::build_groups(arguments[0].clone())) + } + "cad:view:hydrate" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(cad::hydrate_view(arguments[0].clone())) + } + _ => Err(format!( + "{UNSUPPORTED_ROUTE_PREFIX} for function '{function_name}'" + )), + } +} + +fn expect_arg_count( + function_name: &str, + arguments: &[String], + expected_count: usize, +) -> Result<(), String> { + if arguments.len() == expected_count { + return Ok(()); + } + + Err(format!( + "Transport route '{}' expected {} arguments but received {}", + function_name, + expected_count, + arguments.len() + )) +} + +fn chunk_response_if_needed(result: String) -> String { + if result.len() <= RESPONSE_CHUNK_SIZE { + return result; + } + + let transfer_id = next_transfer_id("rsp"); + let chunks = split_string_chunks(&result, RESPONSE_CHUNK_SIZE); + let envelope = ChunkEnvelope { + transfer_id: transfer_id.clone(), + chunk_count: chunks.len(), + total_size: result.len(), + }; + + RESPONSE_STORE.lock().unwrap().insert(transfer_id, chunks); + + format!( + "{CHUNK_PREFIX}{}", + serde_json::to_string(&envelope) + .unwrap_or_else(|error| format!("{{\"error\":\"{error}\"}}")) + ) +} + +fn next_transfer_id(prefix: &str) -> String { + let sequence = TRANSFER_SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("{prefix}_{sequence}") +} + +fn split_string_chunks(input: &str, max_bytes: usize) -> Vec { + if input.is_empty() { + return vec![String::new()]; + } + + let mut chunks = Vec::new(); + let mut chunk_start = 0usize; + let mut chunk_len = 0usize; + + for (index, character) in input.char_indices() { + let char_len = character.len_utf8(); + if chunk_len > 0 && chunk_len + char_len > max_bytes { + chunks.push(input[chunk_start..index].to_string()); + chunk_start = index; + chunk_len = 0; + } + + chunk_len += char_len; + } + + chunks.push(input[chunk_start..].to_string()); + chunks +} diff --git a/arma/server/extension/src/v_garage.rs b/arma/server/extension/src/v_garage.rs index 17506e9..34900ac 100644 --- a/arma/server/extension/src/v_garage.rs +++ b/arma/server/extension/src/v_garage.rs @@ -1,7 +1,7 @@ use arma_rs::{CallContext, Group}; -use forge_models::VehicleCategory; -use forge_repositories::RedisVGarageRepository; -use forge_services::VGarageService; +use forge_models::{VGarage, VehicleCategory}; +use forge_repositories::{InMemoryVGarageHotRepository, RedisVGarageRepository}; +use forge_services::{VGarageHotStateService, VGarageService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; @@ -14,6 +14,17 @@ static VGARAGE_SERVICE: LazyLock, + InMemoryVGarageHotRepository, + >, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisVGarageRepository::new(redis_client); + let hot_repository = InMemoryVGarageHotRepository::new(); + VGarageHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for virtual garage operations. /// @@ -27,6 +38,180 @@ pub fn group() -> Group { .command("remove", remove_vgarage) .command("delete", delete_vgarage) .command("exists", vgarage_exists) + .group( + "hot", + Group::new() + .command("init", init_hot_vgarage) + .command("fetch", fetch_hot_vgarage) + .command("get", get_hot_vgarage) + .command("override", override_hot_vgarage) + .command("save", save_hot_vgarage) + .command("remove", remove_hot_vgarage) + .command("add", add_hot_vgarage) + .command("remove_item", remove_hot_vgarage_item), + ) +} + +fn serialize_hot_vgarage(garage: VGarage) -> String { + match serde_json::to_string(&garage) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot virtual garage: {}", error), + } +} + +pub(crate) fn init_hot_vgarage(call_context: CallContext, key: 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), + }; + + match HOT_VGARAGE_SERVICE.init_garage(&resolved_uid) { + Ok(garage) => serialize_hot_vgarage(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn fetch_hot_vgarage(call_context: CallContext, key: String) -> String { + init_hot_vgarage(call_context, key) +} + +pub(crate) fn get_hot_vgarage(call_context: CallContext, key: String, field: 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 items = match HOT_VGARAGE_SERVICE.get_garage(&resolved_uid, &field) { + Ok(items) => items, + Err(error) => return format!("Error: {}", error), + }; + match serde_json::to_string(&items) { + Ok(json) => json, + Err(error) => format!( + "Error: Failed to serialize hot virtual garage field: {}", + error + ), + } +} + +pub(crate) fn override_hot_vgarage( + call_context: CallContext, + key: String, + json_data: 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 garage: VGarage = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid virtual garage JSON: {}", error), + }; + + match HOT_VGARAGE_SERVICE.override_garage(&resolved_uid, garage) { + Ok(garage) => serialize_hot_vgarage(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_vgarage(call_context: CallContext, key: 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), + }; + + match HOT_VGARAGE_SERVICE.save_garage(&resolved_uid) { + Ok(garage) => serialize_hot_vgarage(garage), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_vgarage(call_context: CallContext, key: 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), + }; + + match HOT_VGARAGE_SERVICE.remove_hot_garage(&resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn add_hot_vgarage( + call_context: CallContext, + key: String, + category: String, + classnames_json: 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 category_enum = match category.to_lowercase().as_str() { + "cars" => VehicleCategory::Cars, + "armor" => VehicleCategory::Armor, + "helis" => VehicleCategory::Helis, + "planes" => VehicleCategory::Planes, + "naval" => VehicleCategory::Naval, + "other" => VehicleCategory::Other, + _ => { + return format!( + "Error: Invalid category '{}'. Valid options: cars, armor, helis, planes, naval, other", + category + ); + } + }; + + let classnames: Vec = match serde_json::from_str(&classnames_json) { + Ok(names) => names, + Err(error) => return format!("Error: Invalid JSON array: {}", error), + }; + + match HOT_VGARAGE_SERVICE.add_garage(&resolved_uid, category_enum, classnames) { + Ok(garage) => match serde_json::to_string(&garage.get(category_enum)) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize category: {}", error), + }, + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_vgarage_item( + call_context: CallContext, + key: String, + category: String, + classname: 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 category_enum = match category.to_lowercase().as_str() { + "cars" => VehicleCategory::Cars, + "armor" => VehicleCategory::Armor, + "heli" | "helis" => VehicleCategory::Helis, + "planes" => VehicleCategory::Planes, + "naval" => VehicleCategory::Naval, + "other" => VehicleCategory::Other, + _ => { + return format!( + "Error: Invalid category '{}'. Valid options: cars, armor, helis, planes, naval, other", + category + ); + } + }; + + match HOT_VGARAGE_SERVICE.remove_garage(&resolved_uid, category_enum, &classname) { + Ok(garage) => match serde_json::to_string(&garage.get(category_enum)) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize category: {}", error), + }, + Err(error) => format!("Error: {}", error), + } } /// Creates a new empty virtual garage for a player. diff --git a/arma/server/extension/src/v_locker.rs b/arma/server/extension/src/v_locker.rs index 11be05e..f863f67 100644 --- a/arma/server/extension/src/v_locker.rs +++ b/arma/server/extension/src/v_locker.rs @@ -1,7 +1,7 @@ use arma_rs::{CallContext, Group}; -use forge_models::EquipmentCategory; -use forge_repositories::RedisVLockerRepository; -use forge_services::VLockerService; +use forge_models::{EquipmentCategory, VLocker}; +use forge_repositories::{InMemoryVLockerHotRepository, RedisVLockerRepository}; +use forge_services::{VLockerHotStateService, VLockerService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; @@ -14,6 +14,17 @@ static VLOCKER_SERVICE: LazyLock, + InMemoryVLockerHotRepository, + >, +> = LazyLock::new(|| { + let redis_client = ExtensionRedisClient::new(); + let repository = RedisVLockerRepository::new(redis_client); + let hot_repository = InMemoryVLockerHotRepository::new(); + VLockerHotStateService::new(repository, hot_repository) +}); /// Creates the Arma 3 command group for virtual locker operations. /// @@ -27,6 +38,104 @@ pub fn group() -> Group { .command("remove", remove_vlocker) .command("delete", delete_vlocker) .command("exists", vlocker_exists) + .group( + "hot", + Group::new() + .command("init", init_hot_vlocker) + .command("fetch", fetch_hot_vlocker) + .command("get", get_hot_vlocker) + .command("override", override_hot_vlocker) + .command("save", save_hot_vlocker) + .command("remove", remove_hot_vlocker), + ) +} + +fn serialize_hot_vlocker(locker: VLocker) -> String { + match serde_json::to_string(&locker) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize hot virtual locker: {}", error), + } +} + +pub(crate) fn init_hot_vlocker(call_context: CallContext, key: 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), + }; + + match HOT_VLOCKER_SERVICE.init_locker(&resolved_uid) { + Ok(locker) => serialize_hot_vlocker(locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn fetch_hot_vlocker(call_context: CallContext, key: String) -> String { + init_hot_vlocker(call_context, key) +} + +pub(crate) fn get_hot_vlocker(call_context: CallContext, key: String, field: 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 items = match HOT_VLOCKER_SERVICE.get_locker(&resolved_uid, &field) { + Ok(items) => items, + Err(error) => return format!("Error: {}", error), + }; + + match serde_json::to_string(&items) { + Ok(json) => json, + Err(error) => format!( + "Error: Failed to serialize hot virtual locker field: {}", + error + ), + } +} + +pub(crate) fn override_hot_vlocker( + call_context: CallContext, + key: String, + json_data: 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 locker: VLocker = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid virtual locker JSON: {}", error), + }; + + match HOT_VLOCKER_SERVICE.override_locker(&resolved_uid, locker) { + Ok(locker) => serialize_hot_vlocker(locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn save_hot_vlocker(call_context: CallContext, key: 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), + }; + + match HOT_VLOCKER_SERVICE.save_locker(&resolved_uid) { + Ok(locker) => serialize_hot_vlocker(locker), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn remove_hot_vlocker(call_context: CallContext, key: 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), + }; + + match HOT_VLOCKER_SERVICE.remove_locker(&resolved_uid) { + Ok(_) => "OK".to_string(), + Err(error) => format!("Error: {}", error), + } } /// Creates a new empty virtual locker for a player. diff --git a/lib/models/src/bank.rs b/lib/models/src/bank.rs index 009fc82..2e33d15 100644 --- a/lib/models/src/bank.rs +++ b/lib/models/src/bank.rs @@ -1,6 +1,7 @@ use arma_rs::{FromArma, IntoArma}; use forge_shared::BankValidationError; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bank { @@ -13,6 +14,43 @@ pub struct Bank { pub transactions: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankMutationResult { + pub account: Bank, + pub patch: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankTransferResult { + pub source_account: Bank, + pub source_patch: HashMap, + pub target_account: Bank, + pub target_patch: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankOperationContext { + pub mode: String, + pub atm_authorized: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankTransferContext { + pub mode: String, + pub atm_authorized: bool, + pub from_field: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankPinContext { + pub mode: String, +} + impl Bank { pub fn new>(uid: S, name: S, pin: u64) -> Result { let bank = Self { diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 0b5f1e0..9f1f008 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -8,7 +8,10 @@ pub mod v_garage; pub mod v_locker; pub use actor::Actor; -pub use bank::Bank; +pub use bank::{ + Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, + BankTransferResult, +}; pub use cad::{ CadActivityEntry, CadAssignmentMutationResult, CadDispatchOrderContextSeed, CadDispatchOrderCreateSeed, CadDispatchOrderMutationResult, CadGroupBuildSeed, @@ -17,6 +20,6 @@ pub use cad::{ }; pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; -pub use org::{CreditLineSummary, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; +pub use org::{CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index 7683b29..c792967 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -48,6 +48,23 @@ pub struct MemberSummary { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HotOrgRecord { + pub id: String, + pub owner: String, + pub name: String, + pub funds: f64, + pub reputation: i64, + #[serde(default)] + pub credit_lines: HashMap, + #[serde(default)] + pub assets: HashMap>, + #[serde(default)] + pub fleet: HashMap, + #[serde(default)] + pub members: HashMap, +} + impl Org { pub fn new>(id: S, owner: S, name: S) -> Result { let org = Self { @@ -128,6 +145,41 @@ impl Org { } } +impl HotOrgRecord { + pub fn from_parts( + org: Org, + assets: HashMap>, + fleet: HashMap, + members: Vec, + ) -> Self { + Self { + id: org.id, + owner: org.owner, + name: org.name, + funds: org.funds, + reputation: org.reputation, + credit_lines: org.credit_lines, + assets, + fleet, + members: members + .into_iter() + .map(|member| (member.uid.clone(), member)) + .collect(), + } + } + + pub fn into_org(self) -> Org { + Org { + id: self.id, + owner: self.owner, + name: self.name, + funds: self.funds, + reputation: self.reputation, + credit_lines: self.credit_lines, + } + } +} + impl FromArma for Org { fn from_arma(s: String) -> Result { serde_json::from_str(&s) diff --git a/lib/models/src/v_locker.rs b/lib/models/src/v_locker.rs index 4ad24e4..2f68ffc 100644 --- a/lib/models/src/v_locker.rs +++ b/lib/models/src/v_locker.rs @@ -29,6 +29,7 @@ impl VLocker { "G_Combat".to_string(), "H_Cap_blk_ION".to_string(), "H_HelmetB".to_string(), + "ACE_EarPlugs".to_string(), "ItemCompass".to_string(), "ItemGPS".to_string(), "ItemMap".to_string(), diff --git a/lib/repositories/src/actor.rs b/lib/repositories/src/actor.rs index c576a3f..cca7867 100644 --- a/lib/repositories/src/actor.rs +++ b/lib/repositories/src/actor.rs @@ -7,6 +7,8 @@ use forge_models::Actor; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for actor data operations. /// @@ -30,6 +32,48 @@ pub trait ActorRepository: Send + Sync { fn exists(&self, id: &str) -> Result; } +pub trait ActorHotRepository: Send + Sync { + fn get(&self, id: &str) -> Result, String>; + fn save(&self, actor: &Actor) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryActorHotRepository { + state: Arc>>, +} + +impl InMemoryActorHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl ActorHotRepository for InMemoryActorHotRepository { + fn get(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(id).cloned()) + .map_err(|_| "Actor hot state lock poisoned.".to_string()) + } + + fn save(&self, actor: &Actor) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Actor hot state lock poisoned.".to_string())? + .insert(actor.uid.clone(), actor.clone()); + Ok(()) + } + + fn delete(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Actor hot state lock poisoned.".to_string())? + .remove(id); + Ok(()) + } +} + /// Redis-based implementation of the ActorRepository trait. /// /// This implementation uses Redis hash maps to store actor data, providing diff --git a/lib/repositories/src/bank.rs b/lib/repositories/src/bank.rs index 0189c94..a1f557d 100644 --- a/lib/repositories/src/bank.rs +++ b/lib/repositories/src/bank.rs @@ -7,6 +7,8 @@ use forge_models::Bank; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for bank data operations. /// @@ -30,6 +32,48 @@ pub trait BankRepository: Send + Sync { fn exists(&self, id: &str) -> Result; } +pub trait BankHotRepository: Send + Sync { + fn get(&self, id: &str) -> Result, String>; + fn save(&self, bank: &Bank) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryBankHotRepository { + state: Arc>>, +} + +impl InMemoryBankHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl BankHotRepository for InMemoryBankHotRepository { + fn get(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(id).cloned()) + .map_err(|_| "Bank hot state lock poisoned.".to_string()) + } + + fn save(&self, bank: &Bank) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Bank hot state lock poisoned.".to_string())? + .insert(bank.uid.clone(), bank.clone()); + Ok(()) + } + + fn delete(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Bank hot state lock poisoned.".to_string())? + .remove(id); + Ok(()) + } +} + /// Redis-based implementation of the BankRepository trait. /// /// This implementation uses Redis hash maps to store bank data, providing diff --git a/lib/repositories/src/garage.rs b/lib/repositories/src/garage.rs index eadab6e..fc1e864 100644 --- a/lib/repositories/src/garage.rs +++ b/lib/repositories/src/garage.rs @@ -6,6 +6,7 @@ use forge_models::{Garage, Vehicle}; use forge_shared::RedisClient; use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for garage data operations. pub trait GarageRepository: Send + Sync { @@ -25,6 +26,48 @@ pub trait GarageRepository: Send + Sync { fn exists(&self, uid: &str) -> Result; } +pub trait GarageHotRepository: Send + Sync { + fn get(&self, uid: &str) -> Result, String>; + fn save(&self, garage: &Garage, uid: &str) -> Result<(), String>; + fn delete(&self, uid: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryGarageHotRepository { + state: Arc>>, +} + +impl InMemoryGarageHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl GarageHotRepository for InMemoryGarageHotRepository { + fn get(&self, uid: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(uid).cloned()) + .map_err(|_| "Garage hot state lock poisoned.".to_string()) + } + + fn save(&self, garage: &Garage, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Garage hot state lock poisoned.".to_string())? + .insert(uid.to_string(), garage.clone()); + Ok(()) + } + + fn delete(&self, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Garage hot state lock poisoned.".to_string())? + .remove(uid); + Ok(()) + } +} + /// Redis-based implementation of the GarageRepository trait. /// /// Stores each player's garage as a single JSON string array with the key format `garage:{uid}`. diff --git a/lib/repositories/src/lib.rs b/lib/repositories/src/lib.rs index b55af91..baeb8ea 100644 --- a/lib/repositories/src/lib.rs +++ b/lib/repositories/src/lib.rs @@ -7,14 +7,24 @@ pub mod org; pub mod v_garage; pub mod v_locker; -pub use actor::{ActorRepository, RedisActorRepository}; -pub use bank::{BankRepository, RedisBankRepository}; +pub use actor::{ + ActorHotRepository, ActorRepository, InMemoryActorHotRepository, RedisActorRepository, +}; +pub use bank::{BankHotRepository, BankRepository, InMemoryBankHotRepository, RedisBankRepository}; pub use cad::{CadRepository, InMemoryCadRepository}; -pub use garage::{GarageRepository, RedisGarageRepository}; -pub use locker::{LockerRepository, RedisLockerRepository}; -pub use org::{OrgRepository, RedisOrgRepository}; -pub use v_garage::{RedisVGarageRepository, VGarageRepository}; -pub use v_locker::{RedisVLockerRepository, VLockerRepository}; +pub use garage::{ + GarageHotRepository, GarageRepository, InMemoryGarageHotRepository, RedisGarageRepository, +}; +pub use locker::{ + InMemoryLockerHotRepository, LockerHotRepository, LockerRepository, RedisLockerRepository, +}; +pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository, RedisOrgRepository}; +pub use v_garage::{ + InMemoryVGarageHotRepository, RedisVGarageRepository, VGarageHotRepository, VGarageRepository, +}; +pub use v_locker::{ + InMemoryVLockerHotRepository, RedisVLockerRepository, VLockerHotRepository, VLockerRepository, +}; // Re-export RedisClient from shared library for convenience pub use forge_shared::RedisClient; diff --git a/lib/repositories/src/locker.rs b/lib/repositories/src/locker.rs index 7559a74..73724f8 100644 --- a/lib/repositories/src/locker.rs +++ b/lib/repositories/src/locker.rs @@ -6,6 +6,7 @@ use forge_models::{Item, Locker}; use forge_shared::RedisClient; use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for locker data operations. pub trait LockerRepository: Send + Sync { @@ -25,6 +26,48 @@ pub trait LockerRepository: Send + Sync { fn exists(&self, uid: &str) -> Result; } +pub trait LockerHotRepository: Send + Sync { + fn get(&self, uid: &str) -> Result, String>; + fn save(&self, locker: &Locker, uid: &str) -> Result<(), String>; + fn delete(&self, uid: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryLockerHotRepository { + state: Arc>>, +} + +impl InMemoryLockerHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl LockerHotRepository for InMemoryLockerHotRepository { + fn get(&self, uid: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(uid).cloned()) + .map_err(|_| "Locker hot state lock poisoned.".to_string()) + } + + fn save(&self, locker: &Locker, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Locker hot state lock poisoned.".to_string())? + .insert(uid.to_string(), locker.clone()); + Ok(()) + } + + fn delete(&self, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Locker hot state lock poisoned.".to_string())? + .remove(uid); + Ok(()) + } +} + /// Redis-based implementation of the LockerRepository trait. /// /// Stores each player's locker as a single JSON string array with the key format `locker:{uid}`. diff --git a/lib/repositories/src/org.rs b/lib/repositories/src/org.rs index cd854f9..eef8441 100644 --- a/lib/repositories/src/org.rs +++ b/lib/repositories/src/org.rs @@ -5,9 +5,10 @@ //! //! For full documentation and examples, see the [crate README](../README.md). -use forge_models::{MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; +use forge_models::{HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for organization data operations. /// @@ -63,6 +64,48 @@ pub trait OrgRepository: Send + Sync { ) -> Result<(), String>; } +pub trait OrgHotRepository: Send + Sync { + fn get(&self, id: &str) -> Result, String>; + fn save(&self, org: &HotOrgRecord) -> Result<(), String>; + fn delete(&self, id: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryOrgHotRepository { + state: Arc>>, +} + +impl InMemoryOrgHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl OrgHotRepository for InMemoryOrgHotRepository { + fn get(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(id).cloned()) + .map_err(|_| "Org hot state lock poisoned.".to_string()) + } + + fn save(&self, org: &HotOrgRecord) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Org hot state lock poisoned.".to_string())? + .insert(org.id.clone(), org.clone()); + Ok(()) + } + + fn delete(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Org hot state lock poisoned.".to_string())? + .remove(id); + Ok(()) + } +} + /// Redis-based implementation of the OrgRepository trait. /// /// Uses Redis hash maps for organization data providing diff --git a/lib/repositories/src/v_garage.rs b/lib/repositories/src/v_garage.rs index 1a3751e..71bba6f 100644 --- a/lib/repositories/src/v_garage.rs +++ b/lib/repositories/src/v_garage.rs @@ -11,6 +11,8 @@ use forge_models::VGarage; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for virtual garage data operations. pub trait VGarageRepository: Send + Sync { @@ -34,6 +36,48 @@ pub trait VGarageRepository: Send + Sync { fn exists(&self, uid: &str) -> Result; } +pub trait VGarageHotRepository: Send + Sync { + fn get(&self, uid: &str) -> Result, String>; + fn save(&self, garage: &VGarage, uid: &str) -> Result<(), String>; + fn delete(&self, uid: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryVGarageHotRepository { + state: Arc>>, +} + +impl InMemoryVGarageHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl VGarageHotRepository for InMemoryVGarageHotRepository { + fn get(&self, uid: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(uid).cloned()) + .map_err(|_| "Virtual garage hot state lock poisoned.".to_string()) + } + + fn save(&self, garage: &VGarage, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Virtual garage hot state lock poisoned.".to_string())? + .insert(uid.to_string(), garage.clone()); + Ok(()) + } + + fn delete(&self, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Virtual garage hot state lock poisoned.".to_string())? + .remove(uid); + Ok(()) + } +} + /// Redis-based implementation of the VGarageRepository trait. /// /// Stores each player's virtual garage as a Redis hash with six fields: diff --git a/lib/repositories/src/v_locker.rs b/lib/repositories/src/v_locker.rs index 23bb442..83c50a9 100644 --- a/lib/repositories/src/v_locker.rs +++ b/lib/repositories/src/v_locker.rs @@ -9,6 +9,8 @@ use forge_models::VLocker; use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; /// Repository trait defining the contract for virtual locker data operations. pub trait VLockerRepository: Send + Sync { @@ -32,6 +34,48 @@ pub trait VLockerRepository: Send + Sync { fn exists(&self, uid: &str) -> Result; } +pub trait VLockerHotRepository: Send + Sync { + fn get(&self, uid: &str) -> Result, String>; + fn save(&self, locker: &VLocker, uid: &str) -> Result<(), String>; + fn delete(&self, uid: &str) -> Result<(), String>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryVLockerHotRepository { + state: Arc>>, +} + +impl InMemoryVLockerHotRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl VLockerHotRepository for InMemoryVLockerHotRepository { + fn get(&self, uid: &str) -> Result, String> { + self.state + .read() + .map(|state| state.get(uid).cloned()) + .map_err(|_| "Virtual locker hot state lock poisoned.".to_string()) + } + + fn save(&self, locker: &VLocker, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Virtual locker hot state lock poisoned.".to_string())? + .insert(uid.to_string(), locker.clone()); + Ok(()) + } + + fn delete(&self, uid: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Virtual locker hot state lock poisoned.".to_string())? + .remove(uid); + Ok(()) + } +} + /// Redis-based implementation of the VLockerRepository trait. /// /// Stores each player's virtual locker as a Redis hash with four fields: diff --git a/lib/services/src/actor.rs b/lib/services/src/actor.rs index 16a780f..6f693b4 100644 --- a/lib/services/src/actor.rs +++ b/lib/services/src/actor.rs @@ -6,7 +6,7 @@ //! For full documentation, architecture, and examples, see the [crate README](../README.md). use forge_models::Actor; -use forge_repositories::ActorRepository; +use forge_repositories::{ActorHotRepository, ActorRepository}; use forge_shared::{generate_email, generate_phone_number}; /// Service layer implementation for actor business logic and operations. @@ -24,6 +24,64 @@ pub struct ActorService { repository: R, } +pub struct ActorHotStateService { + service: ActorService, + repository: H, +} + +impl ActorHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: ActorService::new(repository), + repository: hot_repository, + } + } + + pub fn init_actor(&self, key: String) -> Result { + if let Some(actor) = self.repository.get(&key)? { + return Ok(actor); + } + + let actor = self.service.get_actor(key)?; + self.repository.save(&actor)?; + Ok(actor) + } + + pub fn get_actor(&self, key: String) -> Result { + self.init_actor(key) + } + + pub fn override_actor(&self, key: String, json_data: String) -> Result { + let mut actor: Actor = + serde_json::from_str(&json_data).map_err(|e| format!("Invalid Actor JSON: {}", e))?; + + actor.uid = key; + actor + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + + self.repository.save(&actor)?; + Ok(actor) + } + + pub fn save_actor(&self, key: String) -> Result { + let actor = self + .repository + .get(&key)? + .ok_or_else(|| format!("Actor with UID '{}' not found in hot state", key))?; + let actor_json = serde_json::to_string(&actor) + .map_err(|e| format!("Failed to serialize actor: {}", e))?; + + let saved_actor = self.service.update_actor(key, actor_json)?; + self.repository.save(&saved_actor)?; + Ok(saved_actor) + } + + pub fn remove_actor(&self, key: String) -> Result<(), String> { + self.repository.delete(&key) + } +} + impl ActorService { /// Creates a new actor service with the provided repository. /// diff --git a/lib/services/src/bank.rs b/lib/services/src/bank.rs index 452c817..193bcf0 100644 --- a/lib/services/src/bank.rs +++ b/lib/services/src/bank.rs @@ -5,8 +5,13 @@ //! //! For full documentation, architecture, and examples, see the [crate README](../README.md). -use forge_models::Bank; -use forge_repositories::BankRepository; +use forge_models::{ + Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, + BankTransferResult, +}; +use forge_repositories::{BankHotRepository, BankRepository}; +use serde_json::{Value, json}; +use std::collections::HashMap; /// Service layer implementation for bank business logic and operations. /// @@ -23,6 +28,371 @@ pub struct BankService { repository: R, } +pub struct BankHotStateService { + service: BankService, + repository: H, +} + +impl BankHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: BankService::new(repository), + repository: hot_repository, + } + } + + pub fn init_bank(&self, key: String) -> Result { + if let Some(bank) = self.repository.get(&key)? { + return Ok(bank); + } + + let bank = self.service.get_bank(key)?; + self.repository.save(&bank)?; + Ok(bank) + } + + pub fn get_bank(&self, key: String) -> Result { + self.init_bank(key) + } + + pub fn override_bank(&self, key: String, json_data: String) -> Result { + let mut bank: Bank = + serde_json::from_str(&json_data).map_err(|e| format!("Invalid Bank JSON: {}", e))?; + + bank.uid = key; + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + + self.repository.save(&bank)?; + Ok(bank) + } + + pub fn patch_bank( + &self, + key: String, + json_patch: String, + ) -> Result { + let patch_value: Value = + serde_json::from_str(&json_patch).map_err(|e| format!("Invalid patch JSON: {}", e))?; + let patch_object = patch_value + .as_object() + .ok_or_else(|| "Patch data must be a JSON object".to_string())?; + + let mut bank = self.get_bank(key.clone())?; + let mut patch = HashMap::new(); + + for (field, value) in patch_object { + apply_bank_field(&mut bank, field, value)?; + patch.insert(field.clone(), current_bank_field_value(&bank, field)?); + } + + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + self.repository.save(&bank)?; + + Ok(BankMutationResult { + account: bank, + patch, + }) + } + + pub fn deposit( + &self, + key: String, + amount: f64, + context: BankOperationContext, + ) -> Result { + if amount <= 0.0 { + return Err("Deposit amount must be greater than zero".to_string()); + } + validate_atm_access(&context, "deposit")?; + + let mut bank = self.get_bank(key)?; + if bank.cash < amount { + return Err("Cash on hand cannot cover that deposit.".to_string()); + } + + bank.cash -= amount; + bank.bank += amount; + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + self.repository.save(&bank)?; + + Ok(BankMutationResult { + account: bank.clone(), + patch: build_patch(&bank, &["bank", "cash"])?, + }) + } + + pub fn withdraw( + &self, + key: String, + amount: f64, + context: BankOperationContext, + ) -> Result { + if amount <= 0.0 { + return Err("Withdrawal amount must be greater than zero".to_string()); + } + validate_atm_access(&context, "withdrawal")?; + + let mut bank = self.get_bank(key)?; + if bank.bank < amount { + return Err("Bank balance cannot cover that withdrawal.".to_string()); + } + + bank.bank -= amount; + bank.cash += amount; + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + self.repository.save(&bank)?; + + Ok(BankMutationResult { + account: bank.clone(), + patch: build_patch(&bank, &["bank", "cash"])?, + }) + } + + pub fn payment(&self, key: String, amount: f64) -> Result { + if amount <= 0.0 { + return Err("Payment amount must be greater than zero".to_string()); + } + + let mut bank = self.get_bank(key)?; + bank.bank += amount; + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + self.repository.save(&bank)?; + + Ok(BankMutationResult { + account: bank.clone(), + patch: build_patch(&bank, &["bank"])?, + }) + } + + pub fn deposit_earnings( + &self, + key: String, + amount: f64, + context: BankOperationContext, + ) -> Result { + if amount <= 0.0 { + return Err("Deposit earnings amount must be greater than zero".to_string()); + } + validate_bank_mode(&context, "Earnings deposits")?; + + let mut bank = self.get_bank(key)?; + if bank.earnings < amount { + return Err("Pending earnings cannot cover that deposit request.".to_string()); + } + + bank.bank += amount; + bank.earnings -= amount; + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + self.repository.save(&bank)?; + + Ok(BankMutationResult { + account: bank.clone(), + patch: build_patch(&bank, &["bank", "earnings"])?, + }) + } + + pub fn transfer( + &self, + source_key: String, + target_key: String, + context: BankTransferContext, + amount: f64, + ) -> Result { + if amount <= 0.0 { + return Err("Transfer amount must be greater than zero".to_string()); + } + validate_bank_mode( + &BankOperationContext { + mode: context.mode.clone(), + atm_authorized: context.atm_authorized, + }, + "Transfers", + )?; + if source_key == target_key { + return Err("You cannot transfer funds to yourself.".to_string()); + } + + let mut source_account = self.get_bank(source_key)?; + let mut target_account = self.get_bank(target_key)?; + let source_field = match context.from_field.trim().to_ascii_lowercase().as_str() { + "cash" => "cash", + _ => "bank", + }; + + let source_balance = match source_field { + "cash" => source_account.cash, + _ => source_account.bank, + }; + if source_balance < amount { + return Err(match source_field { + "cash" => "Cash on hand cannot cover that transfer.".to_string(), + _ => "Bank balance cannot cover that transfer.".to_string(), + }); + } + + match source_field { + "cash" => source_account.cash -= amount, + _ => source_account.bank -= amount, + } + target_account.bank += amount; + + source_account + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + target_account + .validate() + .map_err(|e| format!("Validation failed: {}", e))?; + + self.repository.save(&source_account)?; + self.repository.save(&target_account)?; + + Ok(BankTransferResult { + source_patch: build_patch(&source_account, &[source_field])?, + source_account, + target_patch: build_patch(&target_account, &["bank"])?, + target_account, + }) + } + + pub fn validate_pin( + &self, + key: String, + pin: String, + context: BankPinContext, + ) -> Result<(), String> { + if !context.mode.eq_ignore_ascii_case("atm") { + return Err("PIN entry is only available from an ATM session.".to_string()); + } + + if pin.len() != 4 || !pin.chars().all(|character| character.is_ascii_digit()) { + return Err("Enter your four-digit access PIN.".to_string()); + } + + let bank = self.get_bank(key)?; + if pin != bank.pin.to_string() { + return Err("Incorrect PIN.".to_string()); + } + + Ok(()) + } + + pub fn save_bank(&self, key: String) -> Result { + let bank = self + .repository + .get(&key)? + .ok_or_else(|| format!("Bank with UID '{}' not found in hot state", key))?; + let bank_json = + serde_json::to_string(&bank).map_err(|e| format!("Failed to serialize bank: {}", e))?; + + let saved_bank = self.service.update_bank(key, bank_json)?; + self.repository.save(&saved_bank)?; + Ok(saved_bank) + } + + pub fn remove_bank(&self, key: String) -> Result<(), String> { + self.repository.delete(&key) + } +} + +fn apply_bank_field(bank: &mut Bank, field: &str, value: &Value) -> Result<(), String> { + match field { + "uid" => Ok(()), + "name" => { + bank.name = value + .as_str() + .ok_or_else(|| "Name must be a string".to_string())? + .to_string(); + Ok(()) + } + "bank" => { + bank.bank = value + .as_f64() + .ok_or_else(|| "Bank balance must be a number".to_string())?; + Ok(()) + } + "cash" => { + bank.cash = value + .as_f64() + .ok_or_else(|| "Cash must be a number".to_string())?; + Ok(()) + } + "earnings" => { + bank.earnings = value + .as_f64() + .ok_or_else(|| "Earnings must be a number".to_string())?; + Ok(()) + } + "pin" => { + bank.pin = value + .as_u64() + .ok_or_else(|| "PIN must be a number".to_string())?; + Ok(()) + } + "transactions" => { + let values = value + .as_array() + .ok_or_else(|| "Transactions must be an array".to_string())?; + bank.transactions = values + .iter() + .map(|entry| { + entry + .as_str() + .map(|item| item.to_string()) + .ok_or_else(|| "Transactions must contain strings".to_string()) + }) + .collect::, _>>()?; + Ok(()) + } + _ => Err(format!("Unknown field: {}", field)), + } +} + +fn current_bank_field_value(bank: &Bank, field: &str) -> Result { + match field { + "uid" => Ok(json!(bank.uid)), + "name" => Ok(json!(bank.name)), + "bank" => Ok(json!(bank.bank)), + "cash" => Ok(json!(bank.cash)), + "earnings" => Ok(json!(bank.earnings)), + "pin" => Ok(json!(bank.pin)), + "transactions" => Ok(json!(bank.transactions)), + _ => Err(format!("Unknown field: {}", field)), + } +} + +fn build_patch(bank: &Bank, fields: &[&str]) -> Result, String> { + let mut patch = HashMap::new(); + for field in fields { + patch.insert((*field).to_string(), current_bank_field_value(bank, field)?); + } + Ok(patch) +} + +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)); + } + + Ok(()) +} + +fn validate_bank_mode(context: &BankOperationContext, action: &str) -> Result<(), String> { + if !context.mode.eq_ignore_ascii_case("bank") { + return Err(format!( + "{} are only available from the full bank interface.", + action + )); + } + + Ok(()) +} + impl BankService { /// Creates a new bank service with the provided repository. /// diff --git a/lib/services/src/garage.rs b/lib/services/src/garage.rs index 3b55f68..3e14f90 100644 --- a/lib/services/src/garage.rs +++ b/lib/services/src/garage.rs @@ -3,7 +3,7 @@ //! Handles validation, storage, and retrieval of player vehicle garages. use forge_models::garage::{Garage, HitPoints, Vehicle}; -use forge_repositories::GarageRepository; +use forge_repositories::{GarageHotRepository, GarageRepository}; use std::collections::HashMap; use uuid::Uuid; @@ -12,6 +12,11 @@ pub struct GarageService { repository: R, } +pub struct GarageHotStateService { + service: GarageService, + repository: H, +} + impl GarageService { /// Creates a new garage service with the provided repository. pub fn new(repository: R) -> Self { @@ -170,3 +175,86 @@ impl GarageService { self.repository.exists(&key) } } + +impl GarageHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: GarageService::new(repository), + repository: hot_repository, + } + } + + pub fn init_garage(&self, uid: String) -> Result { + if let Some(garage) = self.repository.get(&uid)? { + return Ok(garage); + } + + let garage = match self.service.get_garage(uid.clone()) { + Ok(garage) => garage, + Err(_) => self.service.create_garage(uid.clone())?, + }; + self.repository.save(&garage, &uid)?; + Ok(garage) + } + + pub fn get_garage(&self, uid: String) -> Result { + self.init_garage(uid) + } + + pub fn override_garage( + &self, + uid: String, + vehicles: HashMap, + ) -> Result { + for vehicle in vehicles.values() { + vehicle + .validate() + .map_err(|e| format!("Validation failed for vehicle {}: {}", vehicle.plate, e))?; + } + + let garage = Garage { vehicles }; + if garage.vehicles.len() > 5 { + return Err("Garage exceeds maximum capacity of 5 vehicles.".to_string()); + } + + self.repository.save(&garage, &uid)?; + Ok(garage) + } + + pub fn save_garage(&self, uid: String) -> Result { + let garage = self + .repository + .get(&uid)? + .ok_or_else(|| format!("No garage found for player '{}'", uid))?; + let saved = self + .service + .update_garage(uid.clone(), garage.vehicles.clone())?; + self.repository.save(&saved, &uid)?; + Ok(saved) + } + + pub fn add_vehicle( + &self, + uid: String, + classname: String, + fuel: f64, + damage: f64, + hit_points_json: String, + ) -> Result { + let garage = + self.service + .add_vehicle(uid.clone(), classname, fuel, damage, hit_points_json)?; + self.repository.save(&garage, &uid)?; + Ok(garage) + } + + pub fn remove_vehicle(&self, uid: String, plate: String) -> Result { + let garage = self.service.remove_vehicle(uid.clone(), plate)?; + self.repository.save(&garage, &uid)?; + Ok(garage) + } + + pub fn remove_garage(&self, uid: String) -> Result<(), String> { + self.repository.delete(&uid) + } +} diff --git a/lib/services/src/lib.rs b/lib/services/src/lib.rs index 66827d9..6259d74 100644 --- a/lib/services/src/lib.rs +++ b/lib/services/src/lib.rs @@ -7,11 +7,11 @@ pub mod org; pub mod v_garage; pub mod v_locker; -pub use actor::ActorService; -pub use bank::BankService; +pub use actor::{ActorHotStateService, ActorService}; +pub use bank::{BankHotStateService, BankService}; pub use cad::{CadStateService, CadViewService}; -pub use garage::GarageService; -pub use locker::LockerService; -pub use org::OrgService; -pub use v_garage::VGarageService; -pub use v_locker::VLockerService; +pub use garage::{GarageHotStateService, GarageService}; +pub use locker::{LockerHotStateService, LockerService}; +pub use org::{OrgHotStateService, OrgService}; +pub use v_garage::{VGarageHotStateService, VGarageService}; +pub use v_locker::{VLockerHotStateService, VLockerService}; diff --git a/lib/services/src/locker.rs b/lib/services/src/locker.rs index af401b0..fded255 100644 --- a/lib/services/src/locker.rs +++ b/lib/services/src/locker.rs @@ -3,7 +3,7 @@ //! Handles validation, storage, and retrieval of player item lockers. use forge_models::locker::{Item, Locker}; -use forge_repositories::LockerRepository; +use forge_repositories::{LockerHotRepository, LockerRepository}; use std::collections::HashMap; /// Service layer implementation for locker business logic and operations. @@ -11,6 +11,11 @@ pub struct LockerService { repository: R, } +pub struct LockerHotStateService { + service: LockerService, + repository: H, +} + impl LockerService { /// Creates a new locker service with the provided repository. pub fn new(repository: R) -> Self { @@ -141,3 +146,59 @@ impl LockerService { self.repository.exists(&uid) } } + +impl LockerHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: LockerService::new(repository), + repository: hot_repository, + } + } + + pub fn init_locker(&self, uid: String) -> Result { + if let Some(locker) = self.repository.get(&uid)? { + return Ok(locker); + } + + let locker = match self.service.get_locker(uid.clone()) { + Ok(locker) => locker, + Err(_) => self.service.create_locker(uid.clone())?, + }; + self.repository.save(&locker, &uid)?; + Ok(locker) + } + + pub fn get_locker(&self, uid: String) -> Result { + self.init_locker(uid) + } + + pub fn override_locker( + &self, + uid: String, + items: HashMap, + ) -> Result { + let locker = Locker { items }; + if locker.items.len() > 25 { + return Err("Locker exceeds maximum capacity of 25 items.".to_string()); + } + + self.repository.save(&locker, &uid)?; + Ok(locker) + } + + pub fn save_locker(&self, uid: String) -> Result { + let locker = self + .repository + .get(&uid)? + .ok_or_else(|| format!("No locker found for player '{}'", uid))?; + let saved = self + .service + .update_locker(uid.clone(), locker.items.clone())?; + self.repository.save(&saved, &uid)?; + Ok(saved) + } + + pub fn remove_locker(&self, uid: String) -> Result<(), String> { + self.repository.delete(&uid) + } +} diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index d2855c8..c83235d 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -5,9 +5,11 @@ //! //! For full documentation, architecture, and examples, see the [crate README](../README.md). -use forge_models::{CreditLineSummary, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; -use forge_repositories::OrgRepository; -use std::collections::HashMap; +use forge_models::{ + CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry, +}; +use forge_repositories::{OrgHotRepository, OrgRepository}; +use std::collections::{HashMap, HashSet}; /// Service layer implementation for organization business logic and operations. /// @@ -24,6 +26,11 @@ pub struct OrgService { repository: R, } +pub struct OrgHotStateService { + service: OrgService, + repository: H, +} + impl OrgService { fn normalize_org_value( mut org_value: serde_json::Value, @@ -310,3 +317,89 @@ impl OrgService { Ok(fleet) } } + +impl OrgHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: OrgService::new(repository), + repository: hot_repository, + } + } + + pub fn init_org(&self, id: String) -> Result { + if let Some(org) = self.repository.get(&id)? { + return Ok(org); + } + + let hot_org = self.hydrate_org(&id)?; + self.repository.save(&hot_org)?; + Ok(hot_org) + } + + pub fn get_org(&self, id: String) -> Result { + self.init_org(id) + } + + pub fn override_org( + &self, + id: String, + mut hot_org: HotOrgRecord, + ) -> Result { + hot_org.id = id; + self.repository.save(&hot_org)?; + Ok(hot_org) + } + + pub fn save_org(&self, id: String) -> Result { + let hot_org = self + .repository + .get(&id)? + .ok_or_else(|| format!("Organization with ID '{}' not found", id))?; + + let core_org = hot_org.clone().into_org(); + let current_members = self + .service + .get_members(id.clone())? + .into_iter() + .map(|member| member.uid) + .collect::>(); + let target_members = hot_org.members.keys().cloned().collect::>(); + + if self.service.org_exists(id.clone())? { + self.service.repository.update(&core_org)?; + } else { + self.service.repository.create(&core_org)?; + } + + self.service + .repository + .update_assets(&id, &hot_org.assets)?; + self.service.repository.update_fleet(&id, &hot_org.fleet)?; + + for member_uid in target_members.difference(¤t_members) { + self.service.repository.add_member(&id, member_uid)?; + } + + for member_uid in current_members.difference(&target_members) { + self.service.repository.remove_member(&id, member_uid)?; + } + + self.repository.save(&hot_org)?; + Ok(hot_org) + } + + pub fn remove_org(&self, id: String) -> Result<(), String> { + self.repository.delete(&id) + } + + fn hydrate_org(&self, id: &str) -> Result { + let org = self + .service + .get_org(id.to_string()) + .map_err(|error| format!("Organization with ID '{}' not found: {}", id, error))?; + let assets = self.service.get_assets(id.to_string())?; + let fleet = self.service.get_fleet(id.to_string())?; + let members = self.service.get_members(id.to_string())?; + Ok(HotOrgRecord::from_parts(org, assets, fleet, members)) + } +} diff --git a/lib/services/src/v_garage.rs b/lib/services/src/v_garage.rs index 7d8f468..c8563d7 100644 --- a/lib/services/src/v_garage.rs +++ b/lib/services/src/v_garage.rs @@ -4,7 +4,7 @@ //! validation, and orchestration. use forge_models::{VGarage, VehicleCategory}; -use forge_repositories::VGarageRepository; +use forge_repositories::{VGarageHotRepository, VGarageRepository}; /// Service layer implementation for virtual garage business logic and operations. /// @@ -22,6 +22,11 @@ pub struct VGarageService { repository: R, } +pub struct VGarageHotStateService { + service: VGarageService, + repository: H, +} + impl VGarageService { /// Creates a new garage service with the provided repository. /// @@ -54,6 +59,11 @@ impl VGarageService { } } + pub fn update_garage(&self, uid: &str, garage: &VGarage) -> Result { + self.repository.update(uid, garage)?; + Ok(garage.clone()) + } + /// Retrieves a specific field from a player's virtual garage. /// /// Fields: "cars", "armor", "heli", "planes", "naval", "other" @@ -122,3 +132,87 @@ impl VGarageService { self.repository.exists(uid) } } + +impl VGarageHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: VGarageService::new(repository), + repository: hot_repository, + } + } + + pub fn init_garage(&self, uid: &str) -> Result { + if let Some(garage) = self.repository.get(uid)? { + return Ok(garage); + } + + let garage = match self.service.fetch_garage(uid) { + Ok(garage) => garage, + Err(_) => self.service.create_garage(uid)?, + }; + self.repository.save(&garage, uid)?; + Ok(garage) + } + + pub fn fetch_garage(&self, uid: &str) -> Result { + self.init_garage(uid) + } + + pub fn get_garage(&self, uid: &str, field: &str) -> Result, String> { + let garage = self.init_garage(uid)?; + Ok(match field.to_lowercase().as_str() { + "cars" => garage.cars, + "armor" => garage.armor, + "helis" | "heli" => garage.helis, + "planes" => garage.planes, + "naval" => garage.naval, + "other" => garage.other, + _ => Vec::new(), + }) + } + + pub fn override_garage(&self, uid: &str, garage: VGarage) -> Result { + self.repository.save(&garage, uid)?; + Ok(garage) + } + + pub fn save_garage(&self, uid: &str) -> Result { + let garage = self + .repository + .get(uid)? + .ok_or_else(|| format!("No garage found for player '{}'", uid))?; + let saved = if self.service.garage_exists(uid)? { + self.service.update_garage(uid, &garage)? + } else { + self.service.create_garage(uid)? + }; + self.repository.save(&saved, uid)?; + Ok(saved) + } + + pub fn add_garage( + &self, + uid: &str, + category: VehicleCategory, + classnames: Vec, + ) -> Result { + let garage = self.service.add_garage(uid, category, classnames)?; + self.repository.save(&garage, uid)?; + Ok(garage) + } + + pub fn remove_garage( + &self, + uid: &str, + category: VehicleCategory, + classname: &str, + ) -> Result { + let garage = self.service.remove_garage(uid, category, classname)?; + self.repository.save(&garage, uid)?; + Ok(garage) + } + + pub fn remove_hot_garage(&self, uid: &str) -> Result<(), String> { + self.repository.delete(uid) + } +} diff --git a/lib/services/src/v_locker.rs b/lib/services/src/v_locker.rs index b6cf193..59ac643 100644 --- a/lib/services/src/v_locker.rs +++ b/lib/services/src/v_locker.rs @@ -4,7 +4,7 @@ //! validation, and orchestration. use forge_models::{EquipmentCategory, VLocker}; -use forge_repositories::VLockerRepository; +use forge_repositories::{VLockerHotRepository, VLockerRepository}; /// Service layer implementation for virtual locker business logic and operations. /// @@ -22,6 +22,11 @@ pub struct VLockerService { repository: R, } +pub struct VLockerHotStateService { + service: VLockerService, + repository: H, +} + impl VLockerService { /// Creates a new locker service with the provided repository. /// @@ -54,6 +59,11 @@ impl VLockerService { } } + pub fn update_locker(&self, uid: &str, locker: &VLocker) -> Result { + self.repository.update(uid, locker)?; + Ok(locker.clone()) + } + /// Retrieves a specific field from a player's virtual locker. /// /// Fields: "items", "weapons", "magazines", "backpacks" @@ -122,3 +132,63 @@ impl VLockerService { self.repository.exists(uid) } } + +impl VLockerHotStateService { + pub fn new(repository: R, hot_repository: H) -> Self { + Self { + service: VLockerService::new(repository), + repository: hot_repository, + } + } + + pub fn init_locker(&self, uid: &str) -> Result { + if let Some(locker) = self.repository.get(uid)? { + return Ok(locker); + } + + let locker = match self.service.fetch_locker(uid) { + Ok(locker) => locker, + Err(_) => self.service.create_locker(uid)?, + }; + self.repository.save(&locker, uid)?; + Ok(locker) + } + + pub fn fetch_locker(&self, uid: &str) -> Result { + self.init_locker(uid) + } + + pub fn get_locker(&self, uid: &str, field: &str) -> Result, String> { + let locker = self.init_locker(uid)?; + Ok(match field.to_lowercase().as_str() { + "items" => locker.items, + "weapons" => locker.weapons, + "magazines" => locker.magazines, + "backpacks" => locker.backpacks, + _ => Vec::new(), + }) + } + + pub fn override_locker(&self, uid: &str, locker: VLocker) -> Result { + self.repository.save(&locker, uid)?; + Ok(locker) + } + + pub fn save_locker(&self, uid: &str) -> Result { + let locker = self + .repository + .get(uid)? + .ok_or_else(|| format!("No locker found for player '{}'", uid))?; + let saved = if self.service.locker_exists(uid)? { + self.service.update_locker(uid, &locker)? + } else { + self.service.create_locker(uid)? + }; + self.repository.save(&saved, uid)?; + Ok(saved) + } + + pub fn remove_locker(&self, uid: &str) -> Result<(), String> { + self.repository.delete(uid) + } +} From ffbfc70be8f144a0f1334e47db8f6b7e4a975bea Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 09:54:32 -0500 Subject: [PATCH 11/19] Add checkout mutation and defer hot state saves - Replace bank payment flow with a checkout mutation using explicit source context - Return backend errors to players instead of silently falling back to local state - Queue hot state persistence for actors, garages, lockers, orgs, and owned assets --- arma/server/addons/bank/XEH_preInit.sqf | 6 - .../bank/functions/fnc_initPayloadBuilder.sqf | 8 ++ .../addons/bank/functions/fnc_initStore.sqf | 122 +++++++++--------- arma/server/extension/src/actor.rs | 10 +- arma/server/extension/src/bank.rs | 64 +++++---- arma/server/extension/src/garage.rs | 10 +- arma/server/extension/src/lib.rs | 17 ++- arma/server/extension/src/locker.rs | 10 +- arma/server/extension/src/org.rs | 8 +- arma/server/extension/src/transport.rs | 17 +-- arma/server/extension/src/v_garage.rs | 10 +- arma/server/extension/src/v_locker.rs | 10 +- lib/models/src/bank.rs | 7 + lib/models/src/lib.rs | 4 +- lib/services/src/bank.rs | 66 +++++++--- 15 files changed, 235 insertions(+), 134 deletions(-) diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index c623f86..9e2fd5c 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -35,12 +35,6 @@ PREP_RECOMPILE_END; GVAR(BankStore) call ["deposit", [_uid, _amount]]; }] call CFUNC(addEventHandler); -[QGVAR(requestPayment), { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - GVAR(BankStore) call ["payment", [_uid, _amount]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestSubmitPin), { params [["_uid", "", [""]], ["_pin", "", [""]]]; diff --git a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf index fcd4f49..517bff3 100644 --- a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf @@ -37,6 +37,14 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[ _context set ["fromField", _from]; _context }], + ["buildCheckoutContext", compileFinal { + params [["_source", "bank", [""]], ["_commit", false, [false]]]; + + createHashMapFromArray [ + ["commit", _commit], + ["sourceField", toLowerANSI _source] + ] + }], ["resolveOrgState", compileFinal { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf index 8f7b9e0..4ad374c 100644 --- a/arma/server/addons/bank/functions/fnc_initStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -145,35 +145,23 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]], ["_source", "cash", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; private _result = createHashMapFromArray [["success", false], ["message", "Unable to process bank payment."], ["patch", createHashMap]]; - private _field = switch (toLowerANSI _source) do { - case "cash": { "cash" }; - case "bank": { "bank" }; - default { "" }; - }; + if (_uid isEqualTo "") exitWith { _result }; - if (_field isEqualTo "") exitWith { - _result set ["message", "Selected bank payment source is unsupported."]; + private _checkoutContext = GVAR(BankPayloadBuilder) call ["buildCheckoutContext", [_source, _commit]]; + private _envelope = _self call [ + "callHotBankEnvelope", + [ + "bank:hot:charge_checkout", + [_uid, str _amount, toJSON _checkoutContext] + ] + ]; + private _mutationResult = _envelope getOrDefault ["data", createHashMap]; + private _patch = _self call ["finalizeMutation", [_uid, _mutationResult, false]]; + if (_patch isEqualTo createHashMap) exitWith { + _result set ["message", _envelope getOrDefault ["error", "Bank checkout payment failed."]]; _result }; - private _account = _self call ["get", [_uid, ""]]; - if (_account isEqualTo createHashMap) exitWith { - _result set ["message", "Bank account data is unavailable for checkout."]; - _result - }; - - private _balance = _account getOrDefault [_field, 0]; - if (_balance < _amount) exitWith { - _result set ["message", ["Bank balance cannot cover this checkout.", "Cash on hand cannot cover this checkout."] select (_field isEqualTo "cash")]; - _result - }; - - private _patch = createHashMapFromArray [[_field, (_balance - _amount)]]; - if (_commit) then { - private _result = _self call ["callHotBank", ["bank:hot:patch", [_uid, toJSON _patch]]]; - _patch = _self call ["finalizeMutation", [_uid, _result, false]]; - }; - _result set ["success", true]; _result set ["message", ""]; _result set ["patch", _patch]; @@ -214,17 +202,19 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _playerName = if (isNull _player) then { "Unknown" } else { name _player }; ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; if !(_isSuccess) exitWith { - ["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log); - - private _fallbackAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; - _fallbackAccount set ["uid", _uid]; - if ((_fallbackAccount getOrDefault ["name", ""]) isEqualTo "") then { - _fallbackAccount set ["name", _playerName]; - }; - - _fallbackAccount = _self call ["normalizeAccount", [_uid, _fallbackAccount, _playerName]]; - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _fallbackAccount, CRPC(bank,responseInitBank)]]; - _fallbackAccount + ["ERROR", format ["Failed to check if bank account %1 exists in backend.", _uid]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend is unavailable right now."]]; + createHashMap + }; + if !(_result isEqualType "") exitWith { + ["ERROR", format ["Bank exists check for %1 returned an invalid response.", _uid]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend returned an invalid response."]]; + createHashMap + }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Bank exists check for %1 failed: %2", _uid, _result]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _result select [7]]]; + createHashMap }; private _finalAccount = createHashMap; @@ -241,23 +231,29 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ private _json = _self call ["toJSON", [_finalAccount]]; ["bank:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; if (!_createSuccess) exitWith { - ["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log); - - _finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]]; - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalAccount, CRPC(bank,responseInitBank)]]; - _finalAccount + ["ERROR", format ["Failed to create bank account %1 in backend.", _uid]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Failed to create bank account in backend."]]; + createHashMap + }; + if !(_createResult isEqualType "") exitWith { + ["ERROR", format ["Bank create for %1 returned an invalid response.", _uid]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank backend returned an invalid create response."]]; + createHashMap + }; + if ((_createResult find "Error:") == 0) exitWith { + ["ERROR", format ["Bank create for %1 failed: %2", _uid, _createResult]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _createResult select [7]]]; + createHashMap }; _finalAccount = _self call ["loadHotBank", [_uid, true, _playerName]]; ["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log); }; - if (_finalAccount isEqualTo createHashMap) then { - _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; - _finalAccount set ["uid", _uid]; - if ((_finalAccount getOrDefault ["name", ""]) isEqualTo "") then { - _finalAccount set ["name", _playerName]; - }; + if (_finalAccount isEqualTo createHashMap) exitWith { + ["ERROR", format ["Failed to initialize bank hot state for %1.", _uid]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank account hot state could not be initialized."]]; + createHashMap }; _finalAccount = _self call ["normalizeAccount", [_uid, _finalAccount, _playerName]]; @@ -294,25 +290,17 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; - private _account = _self call ["callHotBank", ["bank:hot:save", [_uid]]]; - if (_account isEqualTo createHashMap) exitWith { _account }; + private _envelope = _self call ["callHotBankEnvelope", ["bank:hot:save", [_uid]]]; + private _account = _envelope getOrDefault ["data", createHashMap]; + if (_account isEqualTo createHashMap) exitWith { + private _message = _envelope getOrDefault ["error", "Bank save failed."]; + ["ERROR", format ["Failed to save bank account %1: %2", _uid, _message]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]]; + createHashMap + }; _self call ["normalizeAccount", [_uid, _account, ""]] }], - ["payment", compileFinal { - params [["_uid", "", [""]], ["_amount", 0, [0]]]; - - _self call [ - "runMutation", - [ - _uid, - "bank:hot:payment", - [_uid, str _amount], - false, - format ["Paid $%1", [_amount] call EFUNC(common,formatNumber)] - ] - ] - }], ["transfer", compileFinal { params [["_uid", "", [""]], ["_target", "", [""]], ["_amount", 0, [0]], ["_context", createHashMap, [createHashMap]]]; @@ -325,7 +313,13 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ ] ]; private _result = _envelope getOrDefault ["data", createHashMap]; - if (_result isEqualTo createHashMap) exitWith { false }; + if (_result isEqualTo createHashMap) exitWith { + private _message = _envelope getOrDefault ["error", "Bank transfer failed."]; + if (_message isNotEqualTo "") then { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _message]]; + }; + false + }; private _sourceAccount = _result getOrDefault ["sourceAccount", createHashMap]; private _targetAccount = _result getOrDefault ["targetAccount", createHashMap]; diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index 1158926..e621056 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -9,6 +9,7 @@ use forge_services::{ActorHotStateService, ActorService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -104,8 +105,13 @@ pub(crate) fn save_hot_actor(call_context: CallContext, key: String) -> String { None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_ACTOR_SERVICE.save_actor(resolved_uid) { - Ok(saved_actor) => serialize_hot_actor(saved_actor), + match HOT_ACTOR_SERVICE.get_actor(resolved_uid.clone()) { + Ok(actor) => { + enqueue_persistence_task("actor", move || { + HOT_ACTOR_SERVICE.save_actor(resolved_uid).map(|_| ()) + }); + serialize_hot_actor(actor) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index 3f591a5..dce90e0 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -5,14 +5,15 @@ use arma_rs::{CallContext, Group}; use forge_models::{ - BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, - BankTransferResult, + BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext, + BankTransferContext, BankTransferResult, }; use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository}; use forge_services::{BankHotStateService, BankService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -51,9 +52,9 @@ pub fn group() -> Group { .command("get", get_hot_bank) .command("override", override_hot_bank) .command("patch", patch_hot_bank) + .command("charge_checkout", charge_checkout_hot_bank) .command("deposit", deposit_hot_bank) .command("withdraw", withdraw_hot_bank) - .command("payment", payment_hot_bank) .command("deposit_earnings", deposit_earnings_hot_bank) .command("transfer", transfer_hot_bank) .command("validate_pin", validate_pin_hot_bank) @@ -99,6 +100,11 @@ fn parse_transfer_context(json_context: String) -> Result Result { + serde_json::from_str(&json_context) + .map_err(|error| format!("Invalid bank checkout context: {}", error)) +} + fn parse_pin_context(json_context: String) -> Result { serde_json::from_str(&json_context) .map_err(|error| format!("Invalid bank PIN context: {}", error)) @@ -156,6 +162,32 @@ pub(crate) fn patch_hot_bank(call_context: CallContext, key: String, json_patch: } } +pub(crate) fn charge_checkout_hot_bank( + call_context: CallContext, + key: String, + amount: 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 amount = match parse_amount(amount, "checkout") { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + let context = match parse_checkout_context(json_context) { + Ok(value) => value, + Err(error) => return format!("Error: {}", error), + }; + + match HOT_BANK_SERVICE.charge_checkout(resolved_uid, amount, context) { + Ok(result) => serialize_hot_bank_mutation(result), + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn deposit_hot_bank( call_context: CallContext, key: String, @@ -208,23 +240,6 @@ pub(crate) fn withdraw_hot_bank( } } -pub(crate) fn payment_hot_bank(call_context: CallContext, key: String, amount: 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 amount = match parse_amount(amount, "payment") { - Ok(value) => value, - Err(error) => return format!("Error: {}", error), - }; - - match HOT_BANK_SERVICE.payment(resolved_uid, amount) { - Ok(result) => serialize_hot_bank_mutation(result), - Err(error) => format!("Error: {}", error), - } -} - pub(crate) fn deposit_earnings_hot_bank( call_context: CallContext, key: String, @@ -308,8 +323,13 @@ pub(crate) fn save_hot_bank(call_context: CallContext, key: String) -> String { None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_BANK_SERVICE.save_bank(resolved_uid) { - Ok(saved_bank) => serialize_hot_bank(saved_bank), + match HOT_BANK_SERVICE.get_bank(resolved_uid.clone()) { + Ok(bank) => { + enqueue_persistence_task("bank", move || { + HOT_BANK_SERVICE.save_bank(resolved_uid).map(|_| ()) + }); + serialize_hot_bank(bank) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/garage.rs b/arma/server/extension/src/garage.rs index b7b9414..dcc6204 100644 --- a/arma/server/extension/src/garage.rs +++ b/arma/server/extension/src/garage.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -105,8 +106,13 @@ pub(crate) fn save_hot_garage(call_context: CallContext, key: String) -> String None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_GARAGE_SERVICE.save_garage(resolved_uid) { - Ok(saved_garage) => serialize_hot_vehicles(saved_garage), + match HOT_GARAGE_SERVICE.get_garage(resolved_uid.clone()) { + Ok(garage) => { + enqueue_persistence_task("garage", move || { + HOT_GARAGE_SERVICE.save_garage(resolved_uid).map(|_| ()) + }); + serialize_hot_vehicles(garage) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index a44717c..4c1fabe 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -37,7 +37,7 @@ static CONTEXT: LazyLock>> = LazyLock::new(|| TokioR static REDIS_POOL: OnceLock = OnceLock::new(); /// Global multi-threaded Tokio runtime used to execute async operations from /// command handlers and startup tasks. -static RUNTIME: LazyLock = LazyLock::new(|| { +pub(crate) static RUNTIME: LazyLock = LazyLock::new(|| { Builder::new_multi_thread() .enable_all() .build() @@ -54,6 +54,21 @@ enum ConnectionState { static CONNECTION_STATE: LazyLock> = LazyLock::new(|| StdRwLock::new(ConnectionState::Initializing)); +pub(crate) fn enqueue_persistence_task(module: &'static str, job: F) +where + F: FnOnce() -> Result<(), String> + Send + 'static, +{ + RUNTIME.spawn_blocking(move || { + if let Err(error) = job() { + crate::log::log( + module, + "ERROR", + &format!("Async persistence failed: {}", error), + ); + } + }); +} + #[arma] /// Initializes the extension, registers commands/groups, and asynchronously /// creates the Redis connection pool on the global runtime. diff --git a/arma/server/extension/src/locker.rs b/arma/server/extension/src/locker.rs index 3244f78..99ea271 100644 --- a/arma/server/extension/src/locker.rs +++ b/arma/server/extension/src/locker.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -98,8 +99,13 @@ pub(crate) fn save_hot_locker(call_context: CallContext, key: String) -> String None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_LOCKER_SERVICE.save_locker(resolved_uid) { - Ok(saved_locker) => serialize_hot_items(saved_locker), + match HOT_LOCKER_SERVICE.get_locker(resolved_uid.clone()) { + Ok(locker) => { + enqueue_persistence_task("locker", move || { + HOT_LOCKER_SERVICE.save_locker(resolved_uid).map(|_| ()) + }); + serialize_hot_items(locker) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index 3c3eae7..ac2fe06 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -10,6 +10,7 @@ use forge_services::{OrgHotStateService, OrgService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::log::log; /// Global organization service instance. @@ -104,8 +105,11 @@ pub(crate) fn override_hot_org(org_id: String, json_data: String) -> String { } pub(crate) fn save_hot_org(org_id: String) -> String { - match HOT_ORG_SERVICE.save_org(org_id) { - Ok(org) => serialize_hot_org(org), + match HOT_ORG_SERVICE.get_org(org_id.clone()) { + Ok(org) => { + enqueue_persistence_task("org", move || HOT_ORG_SERVICE.save_org(org_id).map(|_| ())); + serialize_hot_org(org) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index 4e2f6c6..1ebbfe2 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -253,6 +253,15 @@ fn route_command( arguments[1].clone(), )) } + "bank:hot:charge_checkout" => { + expect_arg_count(function_name, &arguments, 3)?; + Ok(bank::charge_checkout_hot_bank( + call_context, + arguments[0].clone(), + arguments[1].clone(), + arguments[2].clone(), + )) + } "bank:hot:deposit" => { expect_arg_count(function_name, &arguments, 3)?; Ok(bank::deposit_hot_bank( @@ -271,14 +280,6 @@ fn route_command( arguments[2].clone(), )) } - "bank:hot:payment" => { - expect_arg_count(function_name, &arguments, 2)?; - Ok(bank::payment_hot_bank( - call_context, - arguments[0].clone(), - arguments[1].clone(), - )) - } "bank:hot:deposit_earnings" => { expect_arg_count(function_name, &arguments, 3)?; Ok(bank::deposit_earnings_hot_bank( diff --git a/arma/server/extension/src/v_garage.rs b/arma/server/extension/src/v_garage.rs index 34900ac..5e55594 100644 --- a/arma/server/extension/src/v_garage.rs +++ b/arma/server/extension/src/v_garage.rs @@ -5,6 +5,7 @@ use forge_services::{VGarageHotStateService, VGarageService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -121,8 +122,13 @@ pub(crate) fn save_hot_vgarage(call_context: CallContext, key: String) -> String None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_VGARAGE_SERVICE.save_garage(&resolved_uid) { - Ok(garage) => serialize_hot_vgarage(garage), + match HOT_VGARAGE_SERVICE.fetch_garage(&resolved_uid) { + Ok(garage) => { + enqueue_persistence_task("owned_garage", move || { + HOT_VGARAGE_SERVICE.save_garage(&resolved_uid).map(|_| ()) + }); + serialize_hot_vgarage(garage) + } Err(error) => format!("Error: {}", error), } } diff --git a/arma/server/extension/src/v_locker.rs b/arma/server/extension/src/v_locker.rs index f863f67..22d06be 100644 --- a/arma/server/extension/src/v_locker.rs +++ b/arma/server/extension/src/v_locker.rs @@ -5,6 +5,7 @@ use forge_services::{VLockerHotStateService, VLockerService}; use std::sync::LazyLock; use crate::adapters::ExtensionRedisClient; +use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; @@ -120,8 +121,13 @@ pub(crate) fn save_hot_vlocker(call_context: CallContext, key: String) -> String None => return format!("Error: Failed to resolve UID for key: {}", key), }; - match HOT_VLOCKER_SERVICE.save_locker(&resolved_uid) { - Ok(locker) => serialize_hot_vlocker(locker), + match HOT_VLOCKER_SERVICE.fetch_locker(&resolved_uid) { + Ok(locker) => { + enqueue_persistence_task("owned_locker", move || { + HOT_VLOCKER_SERVICE.save_locker(&resolved_uid).map(|_| ()) + }); + serialize_hot_vlocker(locker) + } Err(error) => format!("Error: {}", error), } } diff --git a/lib/models/src/bank.rs b/lib/models/src/bank.rs index 2e33d15..fcabed4 100644 --- a/lib/models/src/bank.rs +++ b/lib/models/src/bank.rs @@ -45,6 +45,13 @@ pub struct BankTransferContext { pub from_field: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BankCheckoutContext { + pub source_field: String, + pub commit: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BankPinContext { diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 9f1f008..4bc404c 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -9,8 +9,8 @@ pub mod v_locker; pub use actor::Actor; pub use bank::{ - Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, - BankTransferResult, + Bank, BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext, + BankTransferContext, BankTransferResult, }; pub use cad::{ CadActivityEntry, CadAssignmentMutationResult, CadDispatchOrderContextSeed, diff --git a/lib/services/src/bank.rs b/lib/services/src/bank.rs index 193bcf0..9d1f00e 100644 --- a/lib/services/src/bank.rs +++ b/lib/services/src/bank.rs @@ -6,8 +6,8 @@ //! For full documentation, architecture, and examples, see the [crate README](../README.md). use forge_models::{ - Bank, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, - BankTransferResult, + Bank, BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext, + BankTransferContext, BankTransferResult, }; use forge_repositories::{BankHotRepository, BankRepository}; use serde_json::{Value, json}; @@ -96,6 +96,51 @@ impl BankHotStateService { }) } + pub fn charge_checkout( + &self, + key: String, + amount: f64, + context: BankCheckoutContext, + ) -> Result { + if amount <= 0.0 { + return Err("Checkout amount must be greater than zero".to_string()); + } + + let mut bank = self.get_bank(key)?; + let source_field = match context.source_field.trim().to_ascii_lowercase().as_str() { + "cash" => "cash", + "bank" => "bank", + _ => return Err("Selected bank payment source is unsupported.".to_string()), + }; + + let source_balance = match source_field { + "cash" => bank.cash, + _ => bank.bank, + }; + if source_balance < amount { + return Err(match source_field { + "cash" => "Cash on hand cannot cover this checkout.".to_string(), + _ => "Bank balance cannot cover this checkout.".to_string(), + }); + } + + match source_field { + "cash" => bank.cash -= amount, + _ => bank.bank -= amount, + } + + bank.validate() + .map_err(|e| format!("Validation failed: {}", e))?; + if context.commit { + self.repository.save(&bank)?; + } + + Ok(BankMutationResult { + account: bank.clone(), + patch: build_patch(&bank, &[source_field])?, + }) + } + pub fn deposit( &self, key: String, @@ -152,23 +197,6 @@ impl BankHotStateService { }) } - pub fn payment(&self, key: String, amount: f64) -> Result { - if amount <= 0.0 { - return Err("Payment amount must be greater than zero".to_string()); - } - - let mut bank = self.get_bank(key)?; - bank.bank += amount; - bank.validate() - .map_err(|e| format!("Validation failed: {}", e))?; - self.repository.save(&bank)?; - - Ok(BankMutationResult { - account: bank.clone(), - patch: build_patch(&bank, &["bank"])?, - }) - } - pub fn deposit_earnings( &self, key: String, From 445a114c1cdd41520592fd3a4ed9954ba7704d9a Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 13:56:49 -0500 Subject: [PATCH 12/19] Refactor org and store services around shared payloads - Add shared store payload handling in server and extension code - Remove obsolete org member and treasury service split - Update client repositories and portal UI/store hydration --- .../bank/functions/fnc_initRepository.sqf | 4 - .../org/functions/fnc_initRepository.sqf | 4 - arma/client/addons/org/ui/_site/org-ui.js | 2 +- arma/client/addons/org/ui/src/portal/data.js | 54 +- arma/client/addons/org/ui/src/portal/store.js | 51 +- arma/server/addons/bank/XEH_preInit.sqf | 9 - arma/server/addons/garage/XEH_preInit.sqf | 81 --- .../garage/functions/fnc_initGarageStore.sqf | 81 --- .../garage/functions/fnc_initVGStore.sqf | 148 ---- arma/server/addons/locker/XEH_preInit.sqf | 81 --- .../locker/functions/fnc_initLockerStore.sqf | 133 ---- .../locker/functions/fnc_initVAStore.sqf | 141 ---- .../addons/main/functions/fnc_initStores.sqf | 1 + arma/server/addons/org/XEH_PREP.hpp | 3 +- arma/server/addons/org/XEH_preInit.sqf | 50 -- .../addons/org/functions/fnc_initOrgStore.sqf | 670 ++++++++--------- .../org/functions/fnc_initPayloadBuilder.sqf | 213 ++++++ .../org/functions/fnc_memberService.sqf | 273 ------- .../org/functions/fnc_treasuryService.sqf | 166 ----- .../functions/fnc_initCatalogService.sqf | 41 +- .../store/functions/fnc_initStoreStore.sqf | 208 +++--- arma/server/extension/src/actor.rs | 8 + arma/server/extension/src/bank.rs | 7 + arma/server/extension/src/garage.rs | 8 + arma/server/extension/src/lib.rs | 2 + arma/server/extension/src/locker.rs | 7 + arma/server/extension/src/org.rs | 130 +++- arma/server/extension/src/store.rs | 37 + arma/server/extension/src/transport.rs | 42 ++ arma/server/extension/src/v_garage.rs | 7 + arma/server/extension/src/v_locker.rs | 7 + lib/models/src/lib.rs | 12 +- lib/models/src/org.rs | 111 +++ lib/models/src/store.rs | 72 ++ lib/services/src/lib.rs | 2 + lib/services/src/org.rs | 503 ++++++++++++- lib/services/src/store.rs | 685 ++++++++++++++++++ 37 files changed, 2396 insertions(+), 1658 deletions(-) create mode 100644 arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf delete mode 100644 arma/server/addons/org/functions/fnc_memberService.sqf delete mode 100644 arma/server/addons/org/functions/fnc_treasuryService.sqf create mode 100644 arma/server/extension/src/store.rs create mode 100644 lib/models/src/store.rs create mode 100644 lib/services/src/store.rs diff --git a/arma/client/addons/bank/functions/fnc_initRepository.sqf b/arma/client/addons/bank/functions/fnc_initRepository.sqf index c91ecf2..a3efc61 100644 --- a/arma/client/addons/bank/functions/fnc_initRepository.sqf +++ b/arma/client/addons/bank/functions/fnc_initRepository.sqf @@ -37,10 +37,6 @@ GVAR(BankRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["markLoaded", compileFinal { if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; true - }], - ["save", compileFinal { - [SRPC(bank,requestSaveBank), [getPlayerUID player]] call CFUNC(serverEvent); - _self set ["lastSave", time]; }] ]; diff --git a/arma/client/addons/org/functions/fnc_initRepository.sqf b/arma/client/addons/org/functions/fnc_initRepository.sqf index f8a6631..1a6ca54 100644 --- a/arma/client/addons/org/functions/fnc_initRepository.sqf +++ b/arma/client/addons/org/functions/fnc_initRepository.sqf @@ -37,10 +37,6 @@ GVAR(OrgRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["markLoaded", compileFinal { if !(_self getOrDefault ["isLoaded", false]) then { _self set ["isLoaded", true]; }; true - }], - ["save", compileFinal { - [SRPC(org,requestSaveOrg), [getPlayerUID player]] call CFUNC(serverEvent); - _self set ["lastSave", time]; }] ]; diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js index 375a7ff..58c7569 100644 --- a/arma/client/addons/org/ui/_site/org-ui.js +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -1 +1 @@ -!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.amount||0,member:e.memberName||"",uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,e.portalData.creditLines||[]),a(this.portalData.members,e.portalData.members||[]),a(this.portalData.fleet,e.portalData.fleet||[]),a(this.portalData.assets,e.portalData.assets||[]),a(this.portalData.activity,e.portalData.activity||[]),a(this.portalData.roadmap,e.portalData.roadmap||[]),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...n.members||[]]),this.setCreditLines([...n.creditLines||[]]),this.setFleet([...n.fleet||[]]),this.setAssets([...n.assets||[]]),this.setActivity([...n.activity||[]])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount"),n("strong",null,i.formatCurrency(e.amount)))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,w),n("span",null,t.length>0?"Open the Credit Lines tab to review assigned members and amounts.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file +!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.amount||0,member:e.memberName||"",uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount"),n("strong",null,i.formatCurrency(e.amount)))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,w),n("span",null,t.length>0?"Open the Credit Lines tab to review assigned members and amounts.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/org/ui/src/portal/data.js b/arma/client/addons/org/ui/src/portal/data.js index 72b4c3b..a544baf 100644 --- a/arma/client/addons/org/ui/src/portal/data.js +++ b/arma/client/addons/org/ui/src/portal/data.js @@ -19,6 +19,45 @@ target.splice(0, target.length, ...cloneValue(source)); } + function normalizeRecord(value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + + if (Array.isArray(value)) { + const isEntryArray = value.every( + (entry) => + Array.isArray(entry) && + entry.length >= 2 && + typeof entry[0] === "string", + ); + + if (isEntryArray) { + return Object.fromEntries(value); + } + } + + if (typeof value === "string" && value.trim() !== "") { + try { + return normalizeRecord(JSON.parse(value)); + } catch (_error) { + return value; + } + } + + return value; + } + + function normalizeCollection(value) { + const source = Array.isArray(value) + ? value + : value && typeof value === "object" + ? Object.values(value) + : []; + + return source.map(normalizeRecord).filter(Boolean); + } + OrgPortal.data = { portalData: { org: Object.assign( @@ -80,25 +119,28 @@ this.portalData.reputation = payload.portalData.reputation || 0; replaceArray( this.portalData.creditLines, - payload.portalData.creditLines || [], + normalizeCollection(payload.portalData.creditLines), ); replaceArray( this.portalData.members, - payload.portalData.members || [], + normalizeCollection(payload.portalData.members), + ); + replaceArray( + this.portalData.fleet, + normalizeCollection(payload.portalData.fleet), ); - replaceArray(this.portalData.fleet, payload.portalData.fleet || []); replaceArray( this.portalData.assets, - payload.portalData.assets || [], + normalizeCollection(payload.portalData.assets), ); replaceArray( this.portalData.activity, - payload.portalData.activity || [], + normalizeCollection(payload.portalData.activity), ); replaceArray( this.portalData.roadmap, - payload.portalData.roadmap || [], + normalizeCollection(payload.portalData.roadmap), ); replaceObject(this.session, payload.session || {}); diff --git a/arma/client/addons/org/ui/src/portal/store.js b/arma/client/addons/org/ui/src/portal/store.js index d17a366..abdb361 100644 --- a/arma/client/addons/org/ui/src/portal/store.js +++ b/arma/client/addons/org/ui/src/portal/store.js @@ -3,6 +3,45 @@ const { createSignal } = window.RegistryApp.runtime; const { portalData } = OrgPortal.data; + function normalizeRecord(value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value; + } + + if (Array.isArray(value)) { + const isEntryArray = value.every( + (entry) => + Array.isArray(entry) && + entry.length >= 2 && + typeof entry[0] === "string", + ); + + if (isEntryArray) { + return Object.fromEntries(value); + } + } + + if (typeof value === "string" && value.trim() !== "") { + try { + return normalizeRecord(JSON.parse(value)); + } catch (_error) { + return value; + } + } + + return value; + } + + function normalizeCollection(value) { + const source = Array.isArray(value) + ? value + : value && typeof value === "object" + ? Object.values(value) + : []; + + return source.map(normalizeRecord).filter(Boolean); + } + class OrgPortalStore { constructor() { [this.getFunds, this.setFunds] = createSignal(portalData.funds); @@ -37,11 +76,13 @@ this.setFunds(nextPortalData.funds || 0); this.setReputation(nextPortalData.reputation || 0); - this.setMembers([...(nextPortalData.members || [])]); - this.setCreditLines([...(nextPortalData.creditLines || [])]); - this.setFleet([...(nextPortalData.fleet || [])]); - this.setAssets([...(nextPortalData.assets || [])]); - this.setActivity([...(nextPortalData.activity || [])]); + this.setMembers([...normalizeCollection(nextPortalData.members)]); + this.setCreditLines([ + ...normalizeCollection(nextPortalData.creditLines), + ]); + this.setFleet([...normalizeCollection(nextPortalData.fleet)]); + this.setAssets([...normalizeCollection(nextPortalData.assets)]); + this.setActivity([...normalizeCollection(nextPortalData.activity)]); } } diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index 9e2fd5c..c29e294 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -20,15 +20,6 @@ PREP_RECOMPILE_END; GVAR(BankStore) call ["hydrateSession", [_uid, _mode, _resetAuthorization]]; }] call CFUNC(addEventHandler); -[QGVAR(requestSaveBank), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - - private _finalData = GVAR(BankStore) call ["save", [_uid]]; - GVAR(BankMessenger) call ["sendAccountSync", [_uid, _finalData]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestDeposit), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; diff --git a/arma/server/addons/garage/XEH_preInit.sqf b/arma/server/addons/garage/XEH_preInit.sqf index 6f5982c..8222cf3 100644 --- a/arma/server/addons/garage/XEH_preInit.sqf +++ b/arma/server/addons/garage/XEH_preInit.sqf @@ -13,40 +13,6 @@ PREP_RECOMPILE_END; GVAR(GarageStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); -[QGVAR(requestGetGarage), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - - private _finalData = GVAR(GarageStore) call ["get", [_uid, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetGarage), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID or Key!" }; - - private _hashMap = GVAR(GarageStore) call ["set", [_uid, _key, _value, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetGarage), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid field pairs!" }; - - private _hashMap = GVAR(GarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - [QGVAR(requestSaveGarage), { params [["_uid", "", [""]]]; @@ -58,13 +24,6 @@ PREP_RECOMPILE_END; [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestRemoveGarage), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - GVAR(GarageStore) call ["remove", [_uid]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestStoreVehicle), { params [ ["_uid", "", [""]], @@ -144,40 +103,6 @@ PREP_RECOMPILE_END; GVAR(VGarageStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); -[QGVAR(requestGetVG), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - - private _finalData = GVAR(VGarageStore) call ["get", [_uid, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetVG), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID or Key!" }; - - private _hashMap = GVAR(VGarageStore) call ["set", [_uid, _key, _value, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetVG), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid field pairs!" }; - - private _hashMap = GVAR(VGarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - [QGVAR(requestSaveVG), { params [["_uid", "", [""]]]; @@ -189,9 +114,3 @@ PREP_RECOMPILE_END; [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestRemoveVG), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - GVAR(VGarageStore) call ["remove", [_uid]]; -}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf index 1ae37d5..6e35c3c 100644 --- a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf @@ -67,93 +67,12 @@ GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [ [CRPC(garage,responseInitGarage), [_garage], _player] call CFUNC(targetEvent); _garage }], - ["get", compileFinal { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - private _garage = _self call ["loadHotGarage", [_uid, false]]; - if (_garage isEqualTo createHashMap) then { - _garage = _self call ["loadHotGarage", [_uid, true]]; - }; - - if (_field isEqualTo "") exitWith { _garage }; - _garage getOrDefault [_field, createHashMap] - }], - ["override", compileFinal { - params [ - ["_uid", "", [""]], - ["_data", createHashMap, [createHashMap]], - ["_save", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_data isEqualType createHashMap) exitWith { createHashMap }; - - private _garage = _self call ["callHotGarage", ["garage:hot:override", [_uid, toJSON _data]]]; - if (_save && { _garage isNotEqualTo createHashMap }) then { - private _savedGarage = _self call ["callHotGarage", ["garage:hot:save", [_uid]]]; - if (_savedGarage isNotEqualTo createHashMap) then { - _garage = _savedGarage; - } else { - _garage = createHashMap; - }; - }; - - _garage - }], - ["set", compileFinal { - params [ - ["_uid", "", [""]], - ["_field", "", [""]], - ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; - - private _garage = _self call ["get", [_uid, ""]]; - if !(_garage isEqualType createHashMap) exitWith { createHashMap }; - - _garage set [_field, _value]; - private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; - if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; - - createHashMapFromArray [[_field, _updatedGarage getOrDefault [_field, _value]]] - }], - ["mset", compileFinal { - params [ - ["_uid", "", [""]], - ["_fieldValuePairs", createHashMap, [createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; - - private _garage = _self call ["get", [_uid, ""]]; - if !(_garage isEqualType createHashMap) exitWith { createHashMap }; - - { _garage set [_x, _y]; } forEach _fieldValuePairs; - private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; - if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; - - +_fieldValuePairs - }], ["save", compileFinal { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; _self call ["callHotGarage", ["garage:hot:save", [_uid]]] }], - ["remove", compileFinal { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { false }; - - ["garage:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - _isSuccess && { _result isEqualTo "OK" } - }], ["storeVehicle", compileFinal { params [ ["_uid", "", [""]], diff --git a/arma/server/addons/garage/functions/fnc_initVGStore.sqf b/arma/server/addons/garage/functions/fnc_initVGStore.sqf index f4f34a8..a17b9c5 100644 --- a/arma/server/addons/garage/functions/fnc_initVGStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initVGStore.sqf @@ -84,159 +84,11 @@ GVAR(VGBaseStore) = compileFinal createHashMapFromArray [ [CRPC(garage,responseInitVG), [_garage], _player] call CFUNC(targetEvent); _garage }], - ["get", compileFinal { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - private _garage = _self call ["loadHotVGarage", [_uid, false]]; - if (_garage isEqualTo createHashMap) then { - _garage = _self call ["loadHotVGarage", [_uid, true]]; - }; - - if (_field isEqualTo "") exitWith { _garage }; - _garage getOrDefault [_field, []] - }], - ["override", compileFinal { - params [ - ["_uid", "", [""]], - ["_data", createHashMap, [createHashMap]], - ["_save", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_data isEqualType createHashMap) exitWith { createHashMap }; - - private _garage = _self call ["callHotVGarage", ["owned:garage:hot:override", [_uid, toJSON _data]]]; - if (_save && { _garage isNotEqualTo createHashMap }) then { - private _savedGarage = _self call ["callHotVGarage", ["owned:garage:hot:save", [_uid]]]; - if (_savedGarage isNotEqualTo createHashMap) then { - _garage = _savedGarage; - } else { - _garage = createHashMap; - }; - }; - - _garage - }], - ["set", compileFinal { - params [ - ["_uid", "", [""]], - ["_field", "", [""]], - ["_value", nil, [[], "", 0, false, createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; - - private _garage = _self call ["loadHotVGarage", [_uid, false]]; - if !(_garage isEqualType createHashMap) exitWith { createHashMap }; - - _garage set [_field, _value]; - private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; - if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; - - createHashMapFromArray [[_field, _updatedGarage getOrDefault [_field, _value]]] - }], - ["mset", compileFinal { - params [ - ["_uid", "", [""]], - ["_fieldValuePairs", createHashMap, [createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; - - private _garage = _self call ["loadHotVGarage", [_uid, false]]; - if !(_garage isEqualType createHashMap) exitWith { createHashMap }; - - { _garage set [_x, _y]; } forEach _fieldValuePairs; - private _updatedGarage = _self call ["override", [_uid, _garage, _sync]]; - if !(_updatedGarage isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedGarage isEqualTo createHashMap) exitWith { createHashMap }; - - +_fieldValuePairs - }], ["save", compileFinal { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; _self call ["callHotVGarage", ["owned:garage:hot:save", [_uid]]] - }], - ["remove", compileFinal { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { false }; - - ["owned:garage:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - _isSuccess && { _result isEqualTo "OK" } - }], - ["grantVehicles", compileFinal { - params [["_uid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", "Virtual garage grant failed."], - ["patch", createHashMap], - ["granted", []], - ["garage", createHashMap] - ]; - - private _garage = +(_self call ["loadHotVGarage", [_uid, false]]); - if (_garage isEqualTo createHashMap) then { - _garage = GVAR(VGarageModel) call ["defaults", []]; - }; - - private _patch = createHashMap; - private _granted = []; - private _categoriesToSync = []; - - { - private _className = _x getOrDefault ["classname", ""]; - private _category = toLowerANSI (_x getOrDefault ["category", ""]); - - if (_className isEqualTo "") exitWith { - _result set ["message", "Vehicle checkout entry was missing a classname."]; - }; - - if !(_category in ["cars", "armor", "helis", "planes", "naval", "other"]) exitWith { - _result set ["message", format ["Vehicle category '%1' is unsupported.", _category]]; - }; - - private _categoryUnlocks = +(_garage getOrDefault [_category, []]); - _categoryUnlocks pushBackUnique _className; - _garage set [_category, _categoryUnlocks]; - _categoriesToSync pushBackUnique _category; - _granted pushBack (createHashMapFromArray [ - ["classname", _className], - ["category", _category] - ]); - } forEach _vehicles; - - { - private _category = _x; - _patch set [_category, _garage getOrDefault [_category, []]]; - } forEach _categoriesToSync; - - if (_commit) then { - private _savedGarage = _self call ["override", [_uid, _garage, false]]; - if !(_savedGarage isEqualType createHashMap) exitWith { - _result set ["message", "Virtual garage cache update returned invalid data."]; - _result - }; - if (_savedGarage isEqualTo createHashMap) exitWith { - _result set ["message", "Failed to update virtual garage cache."]; - _result - }; - _garage = _savedGarage; - }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["granted", _granted]; - _result set ["garage", _garage]; - _result }] ]; diff --git a/arma/server/addons/locker/XEH_preInit.sqf b/arma/server/addons/locker/XEH_preInit.sqf index f9747e6..bfd7343 100644 --- a/arma/server/addons/locker/XEH_preInit.sqf +++ b/arma/server/addons/locker/XEH_preInit.sqf @@ -13,40 +13,6 @@ PREP_RECOMPILE_END; GVAR(LockerStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); -[QGVAR(requestGetLocker), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - - private _finalData = GVAR(LockerStore) call ["get", [_uid, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetLocker), { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID or Field!" }; - - private _hashMap = GVAR(LockerStore) call ["set", [_uid, _field, _value, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetLocker), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid field pairs!" }; - - private _hashMap = GVAR(LockerStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - [QGVAR(requestSaveLocker), { params [["_uid", "", [""]]]; @@ -68,13 +34,6 @@ PREP_RECOMPILE_END; [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestRemoveLocker), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - GVAR(LockerStore) call ["remove", [_uid]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestInitVA), { params [["_uid", "", [""]]]; @@ -82,40 +41,6 @@ PREP_RECOMPILE_END; GVAR(VAStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); -[QGVAR(requestGetVA), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - - private _finalData = GVAR(VAStore) call ["get", [_uid, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetVA), { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID or Field!" }; - - private _hashMap = GVAR(VAStore) call ["set", [_uid, _field, _value, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetVA), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid field pairs!" }; - - private _hashMap = GVAR(VAStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - [QGVAR(requestSaveVA), { params [["_uid", "", [""]]]; @@ -127,9 +52,3 @@ PREP_RECOMPILE_END; [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestRemoveVA), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - GVAR(VAStore) call ["remove", [_uid]]; -}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf index ac4251a..a98e8e7 100644 --- a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf @@ -67,17 +67,6 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ [CRPC(locker,responseInitLocker), [_locker], _player] call CFUNC(targetEvent); _locker }], - ["get", compileFinal { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - private _locker = _self call ["loadHotLocker", [_uid, false]]; - if (_locker isEqualTo createHashMap) then { - _locker = _self call ["loadHotLocker", [_uid, true]]; - }; - - if (_field isEqualTo "") exitWith { _locker }; - _locker getOrDefault [_field, createHashMap] - }], ["override", compileFinal { params [ ["_uid", "", [""]], @@ -100,133 +89,11 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ _locker }], - ["set", compileFinal { - params [ - ["_uid", "", [""]], - ["_field", "", [""]], - ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; - - private _locker = _self call ["get", [_uid, ""]]; - if !(_locker isEqualType createHashMap) exitWith { createHashMap }; - - _locker set [_field, _value]; - private _updatedLocker = _self call ["override", [_uid, _locker, _sync]]; - if !(_updatedLocker isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedLocker isEqualTo createHashMap) exitWith { createHashMap }; - - createHashMapFromArray [[_field, _updatedLocker getOrDefault [_field, _value]]] - }], - ["mset", compileFinal { - params [ - ["_uid", "", [""]], - ["_fieldValuePairs", createHashMap, [createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; - - private _locker = _self call ["get", [_uid, ""]]; - if !(_locker isEqualType createHashMap) exitWith { createHashMap }; - - { _locker set [_x, _y]; } forEach _fieldValuePairs; - private _updatedLocker = _self call ["override", [_uid, _locker, _sync]]; - if !(_updatedLocker isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedLocker isEqualTo createHashMap) exitWith { createHashMap }; - - +_fieldValuePairs - }], ["save", compileFinal { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; _self call ["callHotLocker", ["locker:hot:save", [_uid]]] - }], - ["remove", compileFinal { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { false }; - - ["locker:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - _isSuccess && { _result isEqualTo "OK" } - }], - ["grantItems", compileFinal { - params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", "Locker grant failed."], - ["patch", createHashMap], - ["granted", []], - ["locker", createHashMap] - ]; - - private _locker = +(_self call ["get", [_uid, ""]]); - private _patch = createHashMap; - private _granted = []; - - { - private _className = _x getOrDefault ["classname", ""]; - private _category = toLowerANSI (_x getOrDefault ["category", ""]); - private _quantity = floor ((_x getOrDefault ["quantity", 0]) max 0); - private _lockerCategory = switch (_category) do { - case "item"; - case "attachment": { "item" }; - case "weapon": { "weapon" }; - case "magazine": { "magazine" }; - case "backpack": { "backpack" }; - default { "" }; - }; - - if (_className isEqualTo "" || { _lockerCategory isEqualTo "" } || { _quantity <= 0 }) then { - ["WARN", format ["Skipping invalid locker grant entry: %1 (category: %2)", _className, _category]] call EFUNC(common,log); - } else { - private _entry = +(_locker getOrDefault [_className, createHashMap]); - private _amount = _entry getOrDefault ["amount", 0]; - private _updatedEntry = createHashMapFromArray [ - ["amount", (_amount + _quantity)], - ["classname", _className], - ["category", _lockerCategory] - ]; - - _locker set [_className, _updatedEntry]; - _patch set [_className, _updatedEntry]; - _granted pushBack (createHashMapFromArray [ - ["classname", _className], - ["category", _lockerCategory], - ["quantity", _quantity] - ]); - }; - } forEach _items; - - if ((count (keys _locker)) > 25) exitWith { - _result set ["message", "Locker capacity would exceed 25 unique items. Clear space before checkout."]; - _result - }; - - if (_commit) then { - private _savedLocker = _self call ["override", [_uid, _locker, false]]; - if !(_savedLocker isEqualType createHashMap) exitWith { - _result set ["message", "Locker cache update returned invalid data."]; - _result - }; - if (_savedLocker isEqualTo createHashMap) exitWith { - _result set ["message", "Failed to update locker cache."]; - _result - }; - _locker = _savedLocker; - }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["granted", _granted]; - _result set ["locker", _locker]; - _result }] ]; diff --git a/arma/server/addons/locker/functions/fnc_initVAStore.sqf b/arma/server/addons/locker/functions/fnc_initVAStore.sqf index acbd598..567010b 100644 --- a/arma/server/addons/locker/functions/fnc_initVAStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initVAStore.sqf @@ -82,152 +82,11 @@ GVAR(VABaseStore) = compileFinal createHashMapFromArray [ [CRPC(locker,responseInitVA), [_arsenal], _player] call CFUNC(targetEvent); _arsenal }], - ["get", compileFinal { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - private _arsenal = _self call ["loadHotVArsenal", [_uid, false]]; - if (_arsenal isEqualTo createHashMap) then { - _arsenal = _self call ["loadHotVArsenal", [_uid, true]]; - }; - - if (_field isEqualTo "") exitWith { _arsenal }; - _arsenal getOrDefault [_field, []] - }], - ["override", compileFinal { - params [ - ["_uid", "", [""]], - ["_data", createHashMap, [createHashMap]], - ["_save", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_data isEqualType createHashMap) exitWith { createHashMap }; - - private _arsenal = _self call ["callHotVArsenal", ["owned:locker:hot:override", [_uid, toJSON _data]]]; - if (_save && { _arsenal isNotEqualTo createHashMap }) then { - private _savedArsenal = _self call ["callHotVArsenal", ["owned:locker:hot:save", [_uid]]]; - if (_savedArsenal isNotEqualTo createHashMap) then { - _arsenal = _savedArsenal; - } else { - _arsenal = createHashMap; - }; - }; - - _arsenal - }], - ["set", compileFinal { - params [ - ["_uid", "", [""]], - ["_field", "", [""]], - ["_value", nil, [[], "", 0, false, createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; - - private _arsenal = _self call ["get", [_uid, ""]]; - if !(_arsenal isEqualType createHashMap) exitWith { createHashMap }; - - _arsenal set [_field, _value]; - private _updatedArsenal = _self call ["override", [_uid, _arsenal, _sync]]; - if !(_updatedArsenal isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedArsenal isEqualTo createHashMap) exitWith { createHashMap }; - - createHashMapFromArray [[_field, _updatedArsenal getOrDefault [_field, _value]]] - }], - ["mset", compileFinal { - params [ - ["_uid", "", [""]], - ["_fieldValuePairs", createHashMap, [createHashMap]], - ["_sync", false, [false]] - ]; - - if (_uid isEqualTo "") exitWith { createHashMap }; - if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; - - private _arsenal = _self call ["get", [_uid, ""]]; - if !(_arsenal isEqualType createHashMap) exitWith { createHashMap }; - - { _arsenal set [_x, _y]; } forEach _fieldValuePairs; - private _updatedArsenal = _self call ["override", [_uid, _arsenal, _sync]]; - if !(_updatedArsenal isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedArsenal isEqualTo createHashMap) exitWith { createHashMap }; - - +_fieldValuePairs - }], ["save", compileFinal { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; _self call ["callHotVArsenal", ["owned:locker:hot:save", [_uid]]] - }], - ["remove", compileFinal { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { false }; - - ["owned:locker:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - _isSuccess && { _result isEqualTo "OK" } - }], - ["unlockItems", compileFinal { - params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", "VA unlock failed."], - ["patch", createHashMap], - ["arsenal", createHashMap] - ]; - - private _arsenal = +(_self call ["get", [_uid, ""]]); - if (_arsenal isEqualTo createHashMap) then { - _arsenal = GVAR(VArsenalModel) call ["defaults", []]; - }; - private _patch = createHashMap; - private _categoriesToSync = []; - - { - private _item = _x; - private _className = _item getOrDefault ["classname", ""]; - private _category = toLowerANSI (_item getOrDefault ["category", ""]); - private _arsenalCategory = switch (_category) do { - case "item": { "items" }; - case "weapon": { "weapons" }; - case "magazine": { "magazines" }; - case "backpack": { "backpacks" }; - default { "items" }; - }; - - private _categoryUnlocks = +(_arsenal getOrDefault [_arsenalCategory, []]); - _categoryUnlocks pushBackUnique _className; - _arsenal set [_arsenalCategory, _categoryUnlocks]; - _categoriesToSync pushBackUnique _arsenalCategory; - } forEach _items; - - { - private _category = _x; - private _categoryUnlocks = _arsenal getOrDefault [_category, []]; - _patch set [_category, _categoryUnlocks]; - } forEach _categoriesToSync; - - if (_commit) then { - private _savedArsenal = _self call ["override", [_uid, _arsenal, false]]; - if !(_savedArsenal isEqualType createHashMap) exitWith { - _result set ["message", "Virtual arsenal cache update returned invalid data."]; - _result - }; - if (_savedArsenal isEqualTo createHashMap) exitWith { - _result set ["message", "Failed to update virtual arsenal cache."]; - _result - }; - _arsenal = _savedArsenal; - }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["arsenal", _arsenal]; - _result }] ]; diff --git a/arma/server/addons/main/functions/fnc_initStores.sqf b/arma/server/addons/main/functions/fnc_initStores.sqf index 801605a..69f5259 100644 --- a/arma/server/addons/main/functions/fnc_initStores.sqf +++ b/arma/server/addons/main/functions/fnc_initStores.sqf @@ -42,6 +42,7 @@ if (isNil QEGVAR(locker,LockerStore)) then { call EFUNC(locker,initLockerStore); if (isNil QEGVAR(locker,VAStore)) then { call EFUNC(locker,initVAStore); }; // Org +if (isNil QEGVAR(org,OrgPayloadBuilder)) then { call EFUNC(org,initPayloadBuilder); }; if (isNil QEGVAR(org,OrgStore)) then { call EFUNC(org,initOrgStore); }; // Store diff --git a/arma/server/addons/org/XEH_PREP.hpp b/arma/server/addons/org/XEH_PREP.hpp index dc78ebd..6a5b9f9 100644 --- a/arma/server/addons/org/XEH_PREP.hpp +++ b/arma/server/addons/org/XEH_PREP.hpp @@ -1,3 +1,2 @@ +PREP(initPayloadBuilder); PREP(initOrgStore); -PREP(memberService); -PREP(treasuryService); diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 1c098d2..fcc4c80 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -52,38 +52,6 @@ PREP_RECOMPILE_END; [CRPC(org,responseCreateOrg), [_result], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestGetOrg), { - params [["_uid", "", [""]], ["_field", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - - private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - private _finalData = GVAR(OrgStore) call ["get", [_key, _field]]; - private _player = [_uid] call EFUNC(common,getPlayer); - - [CRPC(org,responseSyncOrg), [_finalData], _player] call CFUNC(targetEvent); -}] call CFUNC(addEventHandler); - -[QGVAR(requestSetOrg), { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID or Field!" }; - - private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - GVAR(OrgStore) call ["set", [_key, _field, _value, _sync]]; -}] call CFUNC(addEventHandler); - -[QGVAR(requestMSetOrg), { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid field pairs!" }; - - private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - - GVAR(OrgStore) call ["mset", [_key, _fieldValuePairs, _sync]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestAssignCreditLine), { params [ ["_uid", "", [""]], @@ -117,24 +85,6 @@ PREP_RECOMPILE_END; ]], _requester] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); -[QGVAR(requestSaveOrg), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - - private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - GVAR(OrgStore) call ["saveById", [_key]]; -}] call CFUNC(addEventHandler); - -[QGVAR(requestRemoveOrg), { - params [["_uid", "", [""]]]; - - if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - - private _key = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; - GVAR(OrgStore) call ["delete", [_key]]; -}] call CFUNC(addEventHandler); - [QGVAR(requestLeaveOrg), { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 67fc084..cf7ad81 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -21,9 +21,6 @@ * call forge_server_org_fnc_initOrgStore */ -if (isNil QGVAR(OrgMembershipService)) then { call FUNC(memberService); }; -if (isNil QGVAR(OrgTreasuryService)) then { call FUNC(treasuryService); }; - #pragma hemtt ignore_variables ["_self"] GVAR(OrgModel) = compileFinal createHashMapObject [[ ["#type", "OrgModel"], @@ -190,6 +187,31 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _self call ["syncHotOrg", [_data]] }], + ["callHotOrgEnvelope", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { createHashMap }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { createHashMap }; + if !(_result isEqualType "") exitWith { createHashMap }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Org extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + createHashMap + }; + + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { createHashMap }; + + if ("org" in _data) then { + private _syncedOrg = _self call ["syncHotOrg", [_data getOrDefault ["org", createHashMap]]]; + if (_syncedOrg isNotEqualTo createHashMap) then { + _data set ["org", _syncedOrg]; + }; + }; + + _data + }], ["syncHotOrg", compileFinal { params [["_org", createHashMap, [createHashMap]]]; @@ -211,10 +233,48 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_orgID isEqualTo "") then { _orgID = "default"; }; _orgID }], - ["loadForUid", compileFinal { - params [["_uid", "", [""]]]; - private _orgID = _self call ["resolveOrgIdForUid", [_uid]]; - _self call ["loadById", [_orgID]] + ["resolveActorName", compileFinal { + params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; + + private _memberName = _actor getOrDefault ["name", ""]; + if (_memberName isEqualTo "" && { _player isNotEqualTo objNull }) then { + _memberName = name _player; + }; + if (_memberName isEqualTo "") then { _memberName = "Unknown"; }; + _memberName + }], + ["applyActorOrganization", compileFinal { + params [["_uid", "", [""]], ["_orgID", "", [""]], ["_actor", createHashMap, [createHashMap]]]; + + if (_uid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { createHashMap }; + + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, false]]; + private _updatedActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; + if ( + !(_updatedActor isEqualType createHashMap) + || { _updatedActor isEqualTo createHashMap } + || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } + ) then { + private _forcedActor = +_actor; + if !(_forcedActor isEqualType createHashMap) then { + _forcedActor = EGVAR(actor,ActorModel) call ["defaults", []]; + _forcedActor set ["uid", _uid]; + }; + + _forcedActor set ["organization", _orgID]; + _updatedActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; + if (_updatedActor isEqualType createHashMap && { _updatedActor isNotEqualTo createHashMap }) then { + _actorPatch = createHashMapFromArray [["organization", _orgID]]; + }; + }; + + if ( + !(_updatedActor isEqualType createHashMap) + || { _updatedActor isEqualTo createHashMap } + || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } + ) exitWith { createHashMap }; + + _actorPatch }], ["loadHotOrg", compileFinal { params [["_orgID", "", [""]], ["_initialize", false, [false]]]; @@ -235,80 +295,6 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_field isEqualTo "") exitWith { _org }; _org getOrDefault [_field, createHashMap] }], - ["override", compileFinal { - params [ - ["_orgID", "", [""]], - ["_org", createHashMap, [createHashMap]], - ["_save", false, [false]] - ]; - - if (_orgID isEqualTo "") exitWith { createHashMap }; - if !(_org isEqualType createHashMap) exitWith { createHashMap }; - - private _normalizedOrg = +_org; - _normalizedOrg set ["id", _normalizedOrg getOrDefault ["id", _orgID]]; - - private _result = _self call ["callHotOrg", ["org:hot:override", [_orgID, toJSON _normalizedOrg]]]; - if (_save && { _result isNotEqualTo createHashMap }) then { - private _savedOrg = _self call ["callHotOrg", ["org:hot:save", [_orgID]]]; - if (_savedOrg isNotEqualTo createHashMap) then { - _result = _savedOrg; - } else { - _result = createHashMap; - }; - }; - - _result - }], - ["set", compileFinal { - params [ - ["_orgID", "", [""]], - ["_field", "", [""]], - ["_value", nil, [[], "", 0, false, createHashMap]], - ["_sync", false, [false]] - ]; - - if (_orgID isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; - - private _org = _self call ["get", [_orgID, ""]]; - if !(_org isEqualType createHashMap) exitWith { createHashMap }; - - _org set [_field, _value]; - private _updatedOrg = _self call ["override", [_orgID, _org, _sync]]; - if !(_updatedOrg isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedOrg isEqualTo createHashMap) exitWith { createHashMap }; - - createHashMapFromArray [[_field, _updatedOrg getOrDefault [_field, _value]]] - }], - ["mset", compileFinal { - params [ - ["_orgID", "", [""]], - ["_fieldValuePairs", createHashMap, [createHashMap]], - ["_sync", false, [false]] - ]; - - if (_orgID isEqualTo "") exitWith { createHashMap }; - if !(_fieldValuePairs isEqualType createHashMap) exitWith { createHashMap }; - - private _org = _self call ["get", [_orgID, ""]]; - if !(_org isEqualType createHashMap) exitWith { createHashMap }; - - { _org set [_x, _y]; } forEach _fieldValuePairs; - private _updatedOrg = _self call ["override", [_orgID, _org, _sync]]; - if !(_updatedOrg isEqualType createHashMap) exitWith { createHashMap }; - if (_updatedOrg isEqualTo createHashMap) exitWith { createHashMap }; - - +_fieldValuePairs - }], - ["verifyMember", compileFinal { - GVAR(OrgMembershipService) call ["verifyMember", _this] - }], - ["addMember", compileFinal { - GVAR(OrgMembershipService) call ["addMember", _this] - }], - ["removeMember", compileFinal { - GVAR(OrgMembershipService) call ["removeMember", _this] - }], ["delete", compileFinal { params [["_orgID", "", [""]]]; @@ -332,158 +318,213 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["success", true]; _result }], - ["restoreDefaultMembership", compileFinal { - GVAR(OrgMembershipService) call ["restoreDefaultMembership", _this] + ["ensureMember", compileFinal { + params [["_orgID", "", [""]], ["_uid", "", [""]], ["_memberName", "", [""]]]; + + if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { createHashMap }; + + private _context = createHashMapFromArray [ + ["orgId", _orgID], + ["memberUid", _uid], + ["memberName", _memberName] + ]; + + _self call ["callHotOrg", ["org:hot:ensure_member", [toJSON _context]]] }], ["leave", compileFinal { - GVAR(OrgMembershipService) call ["leave", _this] + params [["_uid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["actorPatch", createHashMap], + ["notification", []] + ]; + + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required."]; + _result + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + private _memberName = _self call ["resolveActorName", [_uid, _player, _actor]]; + private _context = createHashMapFromArray [ + ["requesterUid", _uid], + ["requesterName", _memberName], + ["orgId", _orgID] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:leave", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to leave the organization."]; + _result + }; + + private _actorOrg = _envelope getOrDefault ["actorOrganization", "default"]; + private _actorPatch = _self call ["applyActorOrganization", [_uid, _actorOrg, _actor]]; + if (_actorPatch isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to restore default organization membership."]; + _result + }; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "You returned to the default organization."]]; + _result set ["actorPatch", _actorPatch]; + _result set ["notification", ["info", "Organization Left", _result get "message", 6000]]; + _result }], ["disband", compileFinal { - GVAR(OrgMembershipService) call ["disband", _this] + params [["_uid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["members", []] + ]; + + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required."]; + _result + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + private _memberName = _self call ["resolveActorName", [_uid, _player, _actor]]; + private _context = createHashMapFromArray [ + ["requesterUid", _uid], + ["requesterName", _memberName], + ["orgId", _orgID] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:disband", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to disband organization."]; + _result + }; + + private _memberResults = []; + { + private _memberUid = _x getOrDefault ["uid", ""]; + if (_memberUid isEqualTo "") then { continue; }; + + private _memberActor = EGVAR(actor,Registry) getOrDefault [_memberUid, createHashMap]; + private _actorPatch = _self call ["applyActorOrganization", [_memberUid, _x getOrDefault ["actorOrganization", "default"], _memberActor]]; + if (_actorPatch isEqualTo createHashMap) then { + ["WARNING", format ["Failed to restore actor organization for %1 after org disband.", _memberUid]] call EFUNC(common,log); + }; + + private _responseMessage = _x getOrDefault ["message", _envelope getOrDefault ["message", "Organization disbanded."]]; + private _notificationParams = [ + ["warning", "Organization Disbanded", _responseMessage, 6000], + ["success", "Organization Disbanded", _responseMessage, 6000] + ] select (_x getOrDefault ["requester", false]); + + _memberResults pushBack (createHashMapFromArray [ + ["uid", _memberUid], + ["requester", _x getOrDefault ["requester", false]], + ["message", _responseMessage], + ["notification", _notificationParams], + ["actorPatch", _actorPatch] + ]); + } forEach (_envelope getOrDefault ["members", []]); + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Organization disbanded."]]; + _result set ["members", _memberResults]; + _result }], ["assignCreditLine", compileFinal { - GVAR(OrgTreasuryService) call ["assignCreditLine", _this] + params [["_requesterUid", "", [""]], ["_memberUid", "", [""]], ["_memberName", "", [""]], ["_amount", 0, [0]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if (_requesterUid isEqualTo "" || { _memberUid isEqualTo "" } || { _amount <= 0 }) exitWith { + _result set ["message", "A valid requester, member, and credit amount are required."]; + _result + }; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _requesterIsDefaultOrgCeo = ( + _requesterPlayer isNotEqualTo objNull + && { _orgID isEqualTo "default" } + && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" } + ); + + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _orgID], + ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], + ["memberUid", _memberUid], + ["memberName", _memberName], + ["amount", _amount] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:assign_credit_line", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to assign credit line."]; + _result + }; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Credit line assigned."]]; + _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; + _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; + _result }], ["buildPortalPayload", compileFinal { params [["_uid", "", [""]]]; - if (_uid isEqualTo "") exitWith { createHashMap }; - - private _player = [_uid] call EFUNC(common,getPlayer); - if (isNull _player) exitWith { createHashMap }; - - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - - private _org = _self call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) then { - _org = _self call ["init", [_uid]]; - }; - if (_org isEqualTo createHashMap) exitWith { createHashMap }; - - // Ensure the requesting player's membership is present in the cached roster - // before shaping the portal payload. This prevents stale org caches from - // omitting the current member while still resolving owner metadata. - _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; - - private _name = _org getOrDefault ["name", ""]; - private _id = _org getOrDefault ["id", _orgID]; - private _ownerUid = _org getOrDefault ["owner", ""]; - private _funds = _org getOrDefault ["funds", 0]; - private _reputation = _org getOrDefault ["reputation", 0]; - private _creditLinesRaw = _org getOrDefault ["credit_lines", createHashMap]; - private _assetsRaw = _org getOrDefault ["assets", createHashMap]; - private _fleetRaw = _org getOrDefault ["fleet", createHashMap]; - private _membersRaw = _org getOrDefault ["members", createHashMap]; - private _isDefaultOrg = (_org getOrDefault ["default", false]) - || { toLower _id isEqualTo "default" } - || { toLower _ownerUid isEqualTo "server" }; - - private _playerName = name _player; - private _playerVar = vehicleVarName _player; - private _sessionRole = "Member"; - private _sessionIsCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; - private _ownerName = ["", "Server"] select (toLower _ownerUid isEqualTo "server"); - - private _membersList = []; - { - private _memberData = _y; - private _memberName = _memberData getOrDefault ["name", "Unknown"]; - private _memberUid = _memberData getOrDefault ["uid", ""]; - - if (_memberUid isEqualTo _ownerUid && { _ownerName isEqualTo "" }) then { _ownerName = _memberName; }; - if (_memberUid isEqualTo _uid) then { _sessionRole = "Member"; }; - - _membersList pushBack (createHashMapFromArray [ - ["uid", _memberUid], - ["name", _memberName] - ]); - } forEach _membersRaw; - - if (_ownerName isEqualTo "" && { _ownerUid isEqualTo _uid }) then { _ownerName = _playerName; }; - if (_ownerName isEqualTo "" && { _ownerUid isNotEqualTo "" }) then { _ownerName = "Unknown Owner"; }; - if (_ownerUid isEqualTo _uid) then { _sessionRole = "Leader"; }; - - private _assetsList = []; - { - private _category = _x; - { - private _assetData = _y; - private _className = _assetData getOrDefault ["classname", ""]; - private _displayName = _className; - { - private _cfg = _x >> _className; - if (isClass _cfg) exitWith { - private _resolvedName = getText (_cfg >> "displayName"); - if (_resolvedName isNotEqualTo "") then { _displayName = _resolvedName; }; - }; - } forEach [ - configFile >> "CfgWeapons", - configFile >> "CfgMagazines", - configFile >> "CfgVehicles", - configFile >> "CfgGlasses" - ]; - - _assetsList pushBack (createHashMapFromArray [ - ["name", _displayName], - ["type", _assetData getOrDefault ["type", _category]], - ["quantity", str (_assetData getOrDefault ["quantity", 0])] - ]); - } forEach _y; - } forEach _assetsRaw; - - private _fleetList = []; - { - private _vehicleData = _y; - _fleetList pushBack (createHashMapFromArray [ - ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], - ["type", _vehicleData getOrDefault ["type", "other"]], - ["status", _vehicleData getOrDefault ["status", "Unknown"]], - ["damage", _vehicleData getOrDefault ["damage", "0%"]] - ]); - } forEach _fleetRaw; - - private _creditLinesList = []; - { - private _creditLineData = _y; - _creditLinesList pushBack (createHashMapFromArray [ - ["uid", _creditLineData getOrDefault ["uid", _x]], - ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], - ["amount", _creditLineData getOrDefault ["amount", 0]] - ]); - } forEach _creditLinesRaw; - - createHashMapFromArray [ - ["session", createHashMapFromArray [ - ["actorName", _playerName], - ["actorUid", _uid], - ["role", _sessionRole], - ["ceo", _sessionIsCeo] - ]], - ["portalData", createHashMapFromArray [ - ["org", createHashMapFromArray [ - ["name", _name], - ["tag", _id], - ["owner", _ownerName], - ["ownerUid", _ownerUid], - ["isDefault", _isDefaultOrg] - ]], - ["funds", _funds], - ["reputation", _reputation], - ["creditLines", _creditLinesList], - ["members", _membersList], - ["fleet", _fleetList], - ["assets", _assetsList], - ["activity", []] - ]] - ] - }], - ["buildChargeResult", compileFinal { - GVAR(OrgTreasuryService) call ["buildChargeResult", _this] + GVAR(OrgPayloadBuilder) call ["buildPortalPayload", [_uid]] }], ["chargeCheckout", compileFinal { - GVAR(OrgTreasuryService) call ["chargeCheckout", _this] + params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to process organization payment."], + ["patch", createHashMap], + ["memberUids", []] + ]; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _requesterIsDefaultOrgCeo = ( + _requesterPlayer isNotEqualTo objNull + && { _orgID isEqualTo "default" } + && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" } + ); + + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _orgID], + ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], + ["source", _source], + ["amount", _amount], + ["commit", _commit] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:charge_checkout", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { _result }; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", ""]]; + _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; + _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; + _result }], ["saveById", compileFinal { params [["_orgID", "", [""]]]; @@ -515,42 +556,29 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; - private _org = _self call ["loadById", [_resolvedOrgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Organization data is unavailable for asset updates."]; - _result + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _resolvedOrgID], + ["commit", _commit] + ]; + private _assetSeeds = _assets apply { + createHashMapFromArray [ + ["classname", _x getOrDefault ["classname", ""]], + ["category", toLowerANSI (_x getOrDefault ["category", "items"])], + ["quantity", floor ((_x getOrDefault ["quantity", 0]) max 0)] + ] }; - private _assetMap = +(_org getOrDefault ["assets", createHashMap]); - - { - private _className = _x getOrDefault ["classname", ""]; - private _category = toLowerANSI (_x getOrDefault ["category", "items"]); - private _quantity = floor ((_x getOrDefault ["quantity", 0]) max 0); - if (_className isEqualTo "" || { _quantity <= 0 }) then { continue; }; - - private _categoryMap = +(_assetMap getOrDefault [_category, createHashMap]); - private _assetEntry = +(_categoryMap getOrDefault [_className, createHashMap]); - - private _existingQuantity = _assetEntry getOrDefault ["quantity", 0]; - _categoryMap set [_className, createHashMapFromArray [ - ["classname", _className], - ["type", _category], - ["quantity", (_existingQuantity + _quantity)] - ]]; - _assetMap set [_category, _categoryMap]; - } forEach _assets; - - private _patch = _self call ["mset", [_resolvedOrgID, createHashMapFromArray [["assets", _assetMap]], false]]; - if (_patch isEqualTo createHashMap) exitWith { + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:add_assets", [toJSON _context, toJSON _assetSeeds]]]; + if (_envelope isEqualTo createHashMap) exitWith { _result set ["message", "Failed to update organization asset cache."]; _result }; _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["memberUids", GVAR(OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]]; + _result set ["message", _envelope getOrDefault ["message", ""]]; + _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; + _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; _result }], ["addFleetVehicles", compileFinal { @@ -576,52 +604,28 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; - private _org = _self call ["loadById", [_resolvedOrgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Organization data is unavailable for fleet updates."]; - _result + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _resolvedOrgID], + ["commit", _commit] + ]; + private _fleetSeeds = _vehicles apply { + createHashMapFromArray [ + ["classname", _x getOrDefault ["classname", ""]], + ["category", toLowerANSI (_x getOrDefault ["category", "other"])] + ] }; - private _fleet = +(_org getOrDefault ["fleet", createHashMap]); - private _fleetIndex = count (keys _fleet); - - { - private _className = _x getOrDefault ["classname", ""]; - private _category = toLowerANSI (_x getOrDefault ["category", "other"]); - if (_className isEqualTo "") exitWith { - _result set ["message", "Vehicle fleet entry was missing a classname."]; - }; - - private _fleetKey = format ["%1_%2", _className, _fleetIndex]; - while { _fleetKey in (keys _fleet) } do { - _fleetIndex = _fleetIndex + 1; - _fleetKey = format ["%1_%2", _className, _fleetIndex]; - }; - - private _displayName = getText (configFile >> "CfgVehicles" >> _className >> "displayName"); - if (_displayName isEqualTo "") then { _displayName = _className; }; - - _fleet set [_fleetKey, createHashMapFromArray [ - ["classname", _className], - ["name", _displayName], - ["type", _category], - ["status", "Ready"], - ["damage", "0%"] - ]]; - - _fleetIndex = _fleetIndex + 1; - } forEach _vehicles; - - private _patch = _self call ["mset", [_resolvedOrgID, createHashMapFromArray [["fleet", _fleet]], false]]; - if (_patch isEqualTo createHashMap) exitWith { + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:add_fleet", [toJSON _context, toJSON _fleetSeeds]]]; + if (_envelope isEqualTo createHashMap) exitWith { _result set ["message", "Failed to update organization fleet cache."]; _result }; _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["memberUids", GVAR(OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]]; + _result set ["message", _envelope getOrDefault ["message", ""]]; + _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; + _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; _result }], ["loadById", compileFinal { @@ -646,13 +650,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _player = [_uid] call EFUNC(common,getPlayer); private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; private _existingOrgID = _actor getOrDefault ["organization", ""]; - if (_existingOrgID isNotEqualTo "" && { toLower _existingOrgID isNotEqualTo "default" }) exitWith { - _result set ["message", "Player already belongs to an organization."]; - _result - }; private _orgID = _actor getOrDefault ["phone_number", ""]; if (_orgID isEqualTo "") exitWith { @@ -660,80 +659,29 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_existsResult", "_existsSuccess"]; - if (!_existsSuccess) exitWith { - _result set ["message", "Unable to verify organization ID availability."]; - _result - }; - - if (_existsResult isEqualTo "true") exitWith { - _result set ["message", "An organization already exists for this phone number."]; - _result - }; - - private _org = createHashMapFromArray [ - ["id", _orgID], - ["owner", _uid], - ["name", _orgName], - ["funds", 0], - ["reputation", 0], - ["credit_lines", createHashMap], - ["members", createHashMap] + private _context = createHashMapFromArray [ + ["requesterUid", _uid], + ["requesterName", _self call ["resolveActorName", [_uid, [_uid] call EFUNC(common,getPlayer), _actor]]], + ["orgId", _orgID], + ["orgName", _orgName], + ["existingOrgId", _existingOrgID] ]; - private _json = _self call ["toJSON", [_org]]; - ["org:create", [_orgID, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; - if (!_createSuccess) exitWith { - _result set ["message", format ["Failed to create organization: %1", _createResult]]; + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:register", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Organization registration failed."]; _result }; - if (_createResult isNotEqualTo "") then { - _org = _self call ["toHashMap", [_createResult]]; - }; - - _org set ["members", createHashMap]; - _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; - - if (toLower _existingOrgID isEqualTo "default") then { - private _defaultOrg = _self call ["removeMember", ["default", _uid]]; - if (_defaultOrg isEqualTo createHashMap) then { - ["WARNING", format ["Failed to remove %1 from default org members after creating org %2.", _uid, _orgID]] call EFUNC(common,log); - }; - }; - - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, false]]; - private _updatedActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; - if ( - !(_updatedActor isEqualType createHashMap) - || { _updatedActor isEqualTo createHashMap } - || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } - ) then { - private _forcedActor = +_actor; - if !(_forcedActor isEqualType createHashMap) then { - _forcedActor = EGVAR(actor,ActorModel) call ["defaults", []]; - _forcedActor set ["uid", _uid]; - }; - - _forcedActor set ["organization", _orgID]; - _updatedActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; - if (_updatedActor isEqualType createHashMap && { _updatedActor isNotEqualTo createHashMap }) then { - _actorPatch = createHashMapFromArray [["organization", _orgID]]; - }; - }; - - if ( - !(_updatedActor isEqualType createHashMap) - || { _updatedActor isEqualTo createHashMap } - || { (_updatedActor getOrDefault ["organization", ""]) isNotEqualTo _orgID } - ) exitWith { + private _actorPatch = _self call ["applyActorOrganization", [_uid, _envelope getOrDefault ["actorOrganization", _orgID], _actor]]; + if (_actorPatch isEqualTo createHashMap) exitWith { _result set ["message", "Failed to assign the player to the new organization."]; _result }; - _org = _self call ["override", [_orgID, _org, false]]; _result set ["success", true]; - _result set ["org", _org]; + _result set ["message", _envelope getOrDefault ["message", ""]]; + _result set ["org", _envelope getOrDefault ["org", createHashMap]]; _result set ["actorPatch", _actorPatch]; _result }], @@ -754,9 +702,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _orgID = "default"; }; - private _finalOwner = _finalOrg getOrDefault ["owner", ""]; - if (_orgID isEqualTo "default" || { _finalOwner isEqualTo _uid }) then { - _finalOrg = _self call ["verifyMember", [_finalOrg, _orgID, _uid, _player, _actor]]; + private _verifiedOrg = _self call ["ensureMember", [_orgID, _uid, _self call ["resolveActorName", [_uid, _player, _actor]]]]; + if (_verifiedOrg isNotEqualTo createHashMap) then { + _finalOrg = _verifiedOrg; }; [CRPC(org,responseInitOrg), [_finalOrg], _player] call CFUNC(targetEvent); diff --git a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf new file mode 100644 index 0000000..2a049f1 --- /dev/null +++ b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf @@ -0,0 +1,213 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initPayloadBuilder.sqf + * Author: IDSolutions + * Date: 2026-04-02 + * Public: No + * + * Description: + * Initializes the org payload builder for portal/read-model shaping. + * Keeps hydrate construction out of OrgStore so the store can focus on + * extension-backed org operations and actor coordination. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(OrgPayloadBuilder) = createHashMapObject [[ + ["#type", "OrgPayloadBuilder"], + ["resolveOrgForUid", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _orgID = GVAR(OrgStore) call ["resolveOrgIdForUid", [_uid]]; + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) then { + _org = GVAR(OrgStore) call ["init", [_uid]]; + }; + + _org + }], + ["resolveOwnerName", compileFinal { + params [["_ownerUid", "", [""]], ["_uid", "", [""]], ["_playerName", "", [""]], ["_membersRaw", createHashMap, [createHashMap]]]; + + private _ownerName = ["", "Server"] select (toLowerANSI _ownerUid isEqualTo "server"); + { + private _memberData = _y; + private _memberUid = _memberData getOrDefault ["uid", ""]; + if (_memberUid isEqualTo _ownerUid && { _ownerName isEqualTo "" }) exitWith { + _ownerName = _memberData getOrDefault ["name", "Unknown"]; + }; + } forEach _membersRaw; + + if (_ownerName isEqualTo "" && { _ownerUid isEqualTo _uid }) then { _ownerName = _playerName; }; + if (_ownerName isEqualTo "" && { _ownerUid isNotEqualTo "" }) then { _ownerName = "Unknown Owner"; }; + if !(_ownerName isEqualType "") then { _ownerName = str _ownerName; }; + _ownerName + }], + ["buildMembersList", compileFinal { + params [["_membersRaw", createHashMap, [createHashMap]], ["_uid", "", [""]], ["_ownerUid", "", [""]]]; + + private _sessionRole = "Member"; + private _membersList = []; + + { + private _memberData = _y; + private _memberName = _memberData getOrDefault ["name", "Unknown"]; + private _memberUid = _memberData getOrDefault ["uid", ""]; + + if (_memberUid isEqualTo _uid) then { _sessionRole = "Member"; }; + if (_memberUid isEqualTo _ownerUid) then { _sessionRole = ["Member", "Leader"] select (_ownerUid isEqualTo _uid); }; + + _membersList pushBack [ + ["uid", _memberUid], + ["name", _memberName] + ]; + } forEach _membersRaw; + + createHashMapFromArray [ + ["members", _membersList], + ["sessionRole", _sessionRole] + ] + }], + ["resolveDisplayName", compileFinal { + params [["_className", "", [""]], ["_configRoots", [], [[]]]]; + + if (_className isEqualTo "") exitWith { "" }; + + private _displayName = _className; + { + private _cfg = _x >> _className; + if (isClass _cfg) exitWith { + private _resolvedName = getText (_cfg >> "displayName"); + if (_resolvedName isNotEqualTo "") then { _displayName = _resolvedName; }; + }; + } forEach _configRoots; + + _displayName + }], + ["buildAssetsList", compileFinal { + params [["_assetsRaw", createHashMap, [createHashMap]]]; + + private _assetsList = []; + { + private _category = _x; + { + private _assetData = _y; + private _className = _assetData getOrDefault ["classname", ""]; + private _displayName = _self call ["resolveDisplayName", [_className, [ + configFile >> "CfgWeapons", + configFile >> "CfgMagazines", + configFile >> "CfgVehicles", + configFile >> "CfgGlasses" + ]]]; + + _assetsList pushBack [ + ["name", _displayName], + ["type", _assetData getOrDefault ["type", _category]], + ["quantity", str (_assetData getOrDefault ["quantity", 0])] + ]; + } forEach _y; + } forEach _assetsRaw; + + _assetsList + }], + ["buildFleetList", compileFinal { + params [["_fleetRaw", createHashMap, [createHashMap]]]; + + private _fleetList = []; + { + private _vehicleData = _y; + _fleetList pushBack [ + ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], + ["type", _vehicleData getOrDefault ["type", "other"]], + ["status", _vehicleData getOrDefault ["status", "Unknown"]], + ["damage", _vehicleData getOrDefault ["damage", "0%"]] + ]; + } forEach _fleetRaw; + + _fleetList + }], + ["buildCreditLinesList", compileFinal { + params [["_creditLinesRaw", createHashMap, [createHashMap]]]; + + private _creditLinesList = []; + { + private _creditLineData = _y; + _creditLinesList pushBack [ + ["uid", _creditLineData getOrDefault ["uid", _x]], + ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], + ["amount", _creditLineData getOrDefault ["amount", 0]] + ]; + } forEach _creditLinesRaw; + + _creditLinesList + }], + ["buildPortalPayload", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { createHashMap }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (isNull _player) exitWith { createHashMap }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = _self call ["resolveOrgForUid", [_uid]]; + if (_org isEqualTo createHashMap) exitWith { createHashMap }; + + private _verifiedOrg = GVAR(OrgStore) call ["ensureMember", [_orgID, _uid, GVAR(OrgStore) call ["resolveActorName", [_uid, _player, _actor]]]]; + if (_verifiedOrg isNotEqualTo createHashMap) then { _org = _verifiedOrg; }; + + private _name = _org getOrDefault ["name", ""]; + private _id = _org getOrDefault ["id", _orgID]; + private _ownerUid = _org getOrDefault ["owner", ""]; + private _funds = _org getOrDefault ["funds", 0]; + private _reputation = _org getOrDefault ["reputation", 0]; + private _creditLinesRaw = _org getOrDefault ["credit_lines", createHashMap]; + private _assetsRaw = _org getOrDefault ["assets", createHashMap]; + private _fleetRaw = _org getOrDefault ["fleet", createHashMap]; + private _membersRaw = _org getOrDefault ["members", createHashMap]; + private _isDefaultOrg = (_org getOrDefault ["default", false]) + || { toLowerANSI _id isEqualTo "default" } + || { toLowerANSI _ownerUid isEqualTo "server" }; + + private _playerName = name _player; + private _playerVar = vehicleVarName _player; + private _sessionIsCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; + private _memberShape = _self call ["buildMembersList", [_membersRaw, _uid, _ownerUid]]; + private _sessionRole = _memberShape getOrDefault ["sessionRole", "Member"]; + private _ownerName = _self call ["resolveOwnerName", [_ownerUid, _uid, _playerName, _membersRaw]]; + + if (_ownerUid isEqualTo _uid) then { _sessionRole = "Leader"; }; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", _playerName], + ["actorUid", _uid], + ["role", _sessionRole], + ["ceo", _sessionIsCeo] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", _name], + ["tag", _id], + ["owner", _ownerName], + ["ownerUid", _ownerUid], + ["isDefault", _isDefaultOrg] + ]], + ["funds", _funds], + ["reputation", _reputation], + ["creditLines", _self call ["buildCreditLinesList", [_creditLinesRaw]]], + ["members", _memberShape getOrDefault ["members", []]], + ["fleet", _self call ["buildFleetList", [_fleetRaw]]], + ["assets", _self call ["buildAssetsList", [_assetsRaw]]], + ["activity", []] + ]] + ] + }] +]]; + +GVAR(OrgPayloadBuilder) diff --git a/arma/server/addons/org/functions/fnc_memberService.sqf b/arma/server/addons/org/functions/fnc_memberService.sqf deleted file mode 100644 index a25f9f9..0000000 --- a/arma/server/addons/org/functions/fnc_memberService.sqf +++ /dev/null @@ -1,273 +0,0 @@ -#include "..\script_component.hpp" - -#pragma hemtt ignore_variables ["_self"] -GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ - ["#type", "OrgMembershipService"], - ["buildMembershipResult", compileFinal { - params [["_message", "", [""]]]; - - createHashMapFromArray [ - ["success", false], - ["message", _message], - ["actorPatch", createHashMap] - ] - }], - ["verifyMember", compileFinal { - params [["_org", createHashMap, [createHashMap]], ["_orgID", "", [""]], ["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; - - if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { _org }; - - private _members = _org getOrDefault ["members", createHashMap]; - if ((_members getOrDefault [_uid, objNull]) isNotEqualTo objNull) exitWith { _org }; - - ["org:members:add", [_orgID, _uid]] call EFUNC(extension,extCall) params ["_memberResult", "_memberSuccess"]; - if (!_memberSuccess) then { - ["WARNING", format ["Failed to add %1 to org %2 members: %3", _uid, _orgID, _memberResult]] call EFUNC(common,log); - }; - - private _memberName = _actor getOrDefault ["name", ""]; - if (_memberName isEqualTo "" && { _player isNotEqualTo objNull }) then { - _memberName = name _player; - }; - if (_memberName isEqualTo "") then { - _memberName = "Unknown"; - }; - - private _updatedMembers = +_members; - _updatedMembers set [_uid, createHashMapFromArray [["uid", _uid], ["name", _memberName]]]; - _org set ["members", _updatedMembers]; - _org = GVAR(OrgStore) call ["override", [_orgID, _org, false]]; - - _org - }], - ["addMember", compileFinal { - params [["_orgID", "", [""]], ["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; - - if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { createHashMap }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { _org }; - - _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; - - _org - }], - ["removeMember", compileFinal { - params [["_orgID", "", [""]], ["_uid", "", [""]]]; - - if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { createHashMap }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { _org }; - - ["org:members:remove", [_orgID, _uid]] call EFUNC(extension,extCall) params ["_memberResult", "_memberSuccess"]; - if (!_memberSuccess) exitWith { - ["WARNING", format ["Failed to remove %1 from org %2 members: %3", _uid, _orgID, _memberResult]] call EFUNC(common,log); - createHashMap - }; - - private _updatedMembers = +(_org getOrDefault ["members", createHashMap]); - _updatedMembers deleteAt _uid; - _org set ["members", _updatedMembers]; - _org = GVAR(OrgStore) call ["override", [_orgID, _org, false]]; - - _org - }], - ["restoreDefaultMembership", compileFinal { - params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; - - private _result = _self call ["buildMembershipResult", []]; - if (_uid isEqualTo "") exitWith { - _result set ["message", "A valid player UID is required."]; - _result - }; - - private _resolvedPlayer = _player; - if (_resolvedPlayer isEqualTo objNull) then { - _resolvedPlayer = [_uid] call EFUNC(common,getPlayer); - }; - - private _resolvedActor = EGVAR(actor,Registry) getOrDefault [_uid, _actor]; - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", "default", false]]; - private _defaultActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; - - if !(_defaultActor isEqualType createHashMap) then { - _defaultActor = +_resolvedActor; - }; - - if ( - (_defaultActor isEqualTo createHashMap) - || { toLowerANSI (_defaultActor getOrDefault ["organization", ""]) isNotEqualTo "default" } - ) then { - private _forcedActor = +_resolvedActor; - if (_forcedActor isEqualTo createHashMap) then { - _forcedActor = EGVAR(actor,ActorModel) call ["defaults", []]; - _forcedActor set ["uid", _uid]; - }; - - _forcedActor set ["organization", "default"]; - _defaultActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; - if (_defaultActor isEqualType createHashMap && { _defaultActor isNotEqualTo createHashMap }) then { - _actorPatch = createHashMapFromArray [["organization", "default"]]; - }; - }; - - if ( - !(_defaultActor isEqualType createHashMap) - || { _defaultActor isEqualTo createHashMap } - || { toLowerANSI (_defaultActor getOrDefault ["organization", ""]) isNotEqualTo "default" } - ) exitWith { - _result set ["message", "Failed to restore default organization membership."]; - _result - }; - - private _defaultOrg = _self call ["addMember", ["default", _uid, _resolvedPlayer, _defaultActor]]; - if (_defaultOrg isEqualTo createHashMap) exitWith { - _result set ["message", "Failed to restore default organization membership."]; - _result - }; - - _result set ["success", true]; - _result set ["actorPatch", _actorPatch]; - _result - }], - ["leave", compileFinal { - params [["_uid", "", [""]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", ""], - ["actorPatch", createHashMap], - ["notification", []] - ]; - - if (_uid isEqualTo "") exitWith { - _result set ["message", "A valid player UID is required."]; - _result - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", ""]; - if (_orgID isEqualTo "" || { toLower _orgID isEqualTo "default" }) exitWith { - _result set ["message", "You are already assigned to the default organization."]; - _result - }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Unable to load organization data for leave request."]; - _result - }; - - private _ownerUid = _org getOrDefault ["owner", ""]; - if (_ownerUid isEqualTo _uid) exitWith { - _result set ["message", "Organization owners must disband the organization instead of leaving it."]; - _result - }; - - private _orgName = _org getOrDefault ["name", "Organization"]; - private _updatedOrg = _self call ["removeMember", [_orgID, _uid]]; - if (_updatedOrg isEqualTo createHashMap) exitWith { - _result set ["message", "Failed to remove you from the organization roster."]; - _result - }; - - private _defaultResult = _self call ["restoreDefaultMembership", [_uid, _player, _actor]]; - if !(_defaultResult getOrDefault ["success", false]) exitWith { - _result set ["message", _defaultResult getOrDefault ["message", "Failed to restore default organization membership."]]; - _result - }; - - private _message = format ["You left %1 and returned to the default organization.", _orgName]; - _result set ["success", true]; - _result set ["message", _message]; - _result set ["actorPatch", _defaultResult getOrDefault ["actorPatch", createHashMap]]; - _result set ["notification", ["info", "Organization Left", _message, 6000]]; - _result - }], - ["disband", compileFinal { - params [["_uid", "", [""]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", ""], - ["members", []] - ]; - - if (_uid isEqualTo "") exitWith { - _result set ["message", "A valid player UID is required."]; - _result - }; - - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", ""]; - if (_orgID isEqualTo "" || { toLower _orgID isEqualTo "default" }) exitWith { - _result set ["message", "Only active player organizations can be disbanded."]; - _result - }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Unable to load organization data for disbanding."]; - _result - }; - - private _ownerUid = _org getOrDefault ["owner", ""]; - if (_ownerUid isEqualTo "" || { _ownerUid isNotEqualTo _uid }) exitWith { - _result set ["message", "Only the organization owner can disband this organization."]; - _result - }; - - private _orgName = _org getOrDefault ["name", "Organization"]; - private _memberMap = _org getOrDefault ["members", createHashMap]; - private _memberUids = keys _memberMap; - if !(_uid in _memberUids) then { - _memberUids pushBack _uid; - }; - - private _deleteResult = GVAR(OrgStore) call ["delete", [_orgID]]; - if !(_deleteResult getOrDefault ["success", false]) exitWith { - _result set ["message", _deleteResult getOrDefault ["message", "Failed to disband organization."]]; - _result - }; - - private _memberResults = []; - { - private _memberUid = _x; - if (_memberUid isNotEqualTo "") then { - private _memberPlayer = [_memberUid] call EFUNC(common,getPlayer); - private _memberActor = EGVAR(actor,Registry) getOrDefault [_memberUid, createHashMap]; - private _defaultResult = _self call ["restoreDefaultMembership", [_memberUid, _memberPlayer, _memberActor]]; - if !(_defaultResult getOrDefault ["success", false]) then { - ["WARNING", format ["Failed to restore default org for %1 after disbanding %2: %3", _memberUid, _orgID, _defaultResult getOrDefault ["message", "Unknown error."]]] call EFUNC(common,log); - }; - - private _responseMessage = [ - format ["%1 has been disbanded.", _orgName], - format ["Your organization, %1, has been disbanded.", _orgName] - ] select (_memberUid isEqualTo _uid); - - private _notificationParams = [ - ["warning", "Organization Disbanded", _responseMessage, 6000], - ["success", "Organization Disbanded", _responseMessage, 6000] - ] select (_memberUid isEqualTo _uid); - - _memberResults pushBack (createHashMapFromArray [ - ["uid", _memberUid], - ["requester", _memberUid isEqualTo _uid], - ["message", _responseMessage], - ["notification", _notificationParams], - ["actorPatch", _defaultResult getOrDefault ["actorPatch", createHashMap]] - ]); - }; - } forEach _memberUids; - - _result set ["success", true]; - _result set ["message", format ["%1 has been disbanded.", _orgName]]; - _result set ["members", _memberResults]; - _result - }] -]; - -GVAR(OrgMembershipService) = createHashMapObject [GVAR(OrgMembershipServiceBase)]; diff --git a/arma/server/addons/org/functions/fnc_treasuryService.sqf b/arma/server/addons/org/functions/fnc_treasuryService.sqf deleted file mode 100644 index f8a0803..0000000 --- a/arma/server/addons/org/functions/fnc_treasuryService.sqf +++ /dev/null @@ -1,166 +0,0 @@ -#include "..\script_component.hpp" - -#pragma hemtt ignore_variables ["_self"] -GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ - ["#type", "OrgTreasuryService"], - ["buildChargeResult", compileFinal { - params [["_message", "Unable to process organization payment.", [""]]]; - - createHashMapFromArray [ - ["success", false], - ["message", _message], - ["patch", createHashMap], - ["memberUids", []] - ] - }], - ["resolveOrgMemberUids", compileFinal { - params [["_org", createHashMap, [createHashMap]], ["_requesterUid", "", [""]]]; - - private _memberUids = keys (_org getOrDefault ["members", createHashMap]); - if (_requesterUid isNotEqualTo "" && { !(_requesterUid in _memberUids) }) then { - _memberUids pushBack _requesterUid; - }; - - _memberUids - }], - ["canManageTreasury", compileFinal { - params [["_orgID", "", [""]], ["_org", createHashMap, [createHashMap]], ["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]]]; - - private _ownerUid = _org getOrDefault ["owner", ""]; - private _isDefaultOrg = (_orgID isEqualTo "default") || { toLowerANSI _ownerUid isEqualTo "server" }; - private _isDefaultOrgCeo = _isDefaultOrg - && { _requesterPlayer isNotEqualTo objNull } - && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" }; - - (_ownerUid isEqualTo _requesterUid) || _isDefaultOrgCeo - }], - ["assignCreditLine", compileFinal { - params [["_requesterUid", "", [""]], ["_memberUid", "", [""]], ["_memberName", "", [""]], ["_amount", 0, [0]]]; - - private _result = createHashMapFromArray [ - ["success", false], - ["message", ""], - ["patch", createHashMap], - ["memberUids", []] - ]; - - if ( - _requesterUid isEqualTo "" - || { _memberUid isEqualTo "" } - || { _amount <= 0 } - ) exitWith { - _result set ["message", "A valid requester, member, and credit amount are required."]; - _result - }; - - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Unable to load organization data for credit line assignment."]; - _result - }; - - private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); - if !(_self call ["canManageTreasury", [_orgID, _org, _requesterUid, _requesterPlayer]]) exitWith { - _result set ["message", "Only the organization leader or CEO can manage treasury actions."]; - _result - }; - - private _members = _org getOrDefault ["members", createHashMap]; - private _memberRecord = _members getOrDefault [_memberUid, createHashMap]; - if (_memberRecord isEqualTo createHashMap) exitWith { - _result set ["message", "Selected member was not found in the organization roster."]; - _result - }; - - private _resolvedMemberName = _memberRecord getOrDefault ["name", _memberName]; - if (_resolvedMemberName isEqualTo "") then { _resolvedMemberName = _memberName; }; - - private _creditLines = +(_org getOrDefault ["credit_lines", createHashMap]); - _creditLines set [_memberUid, createHashMapFromArray [ - ["uid", _memberUid], - ["name", _resolvedMemberName], - ["amount", _amount] - ]]; - - private _patch = GVAR(OrgStore) call ["set", [_orgID, "credit_lines", _creditLines, false]]; - private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; - - _result set ["success", true]; - _result set ["message", format ["Credit line of $%1 assigned to %2.", [_amount] call EFUNC(common,formatNumber), _resolvedMemberName]]; - _result set ["patch", _patch]; - _result set ["memberUids", _memberUids]; - _result - }], - ["chargeCheckout", compileFinal { - params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; - - private _result = _self call ["buildChargeResult", []]; - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - - private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; - if (_org isEqualTo createHashMap) exitWith { - _result set ["message", "Organization data is unavailable for checkout."]; - _result - }; - - private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; - - switch (toLowerANSI _source) do { - case "org_funds": { - if !(_self call ["canManageTreasury", [_orgID, _org, _requesterUid, _requesterPlayer]]) exitWith { - _result set ["message", "Only the organization leader or CEO can charge org funds."]; - _result - }; - - private _funds = _org getOrDefault ["funds", 0]; - if (_funds < _amount) exitWith { - _result set ["message", "Organization funds cannot cover this checkout."]; - _result - }; - - private _patch = createHashMapFromArray [["funds", (_funds - _amount)]]; - if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [_orgID, _patch, false]]; }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["memberUids", _memberUids]; - _result - }; - case "credit_line": { - private _creditLines = +(_org getOrDefault ["credit_lines", createHashMap]); - private _memberCredit = +(_creditLines getOrDefault [_requesterUid, createHashMap]); - private _creditAmount = _memberCredit getOrDefault ["amount", 0]; - if (_creditAmount < _amount) exitWith { - _result set ["message", "Assigned credit line cannot cover this checkout."]; - _result - }; - - _memberCredit set ["uid", _requesterUid]; - _memberCredit set ["amount", (_creditAmount - _amount)]; - _creditLines set [_requesterUid, _memberCredit]; - - private _patch = createHashMapFromArray [["credit_lines", _creditLines]]; - if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [_orgID, _patch, false]]; }; - - _result set ["success", true]; - _result set ["message", ""]; - _result set ["patch", _patch]; - _result set ["memberUids", _memberUids]; - _result - }; - default { - _result set ["message", "Selected organization payment source is unsupported."]; - _result - }; - }; - }] -]; - -GVAR(OrgTreasuryService) = createHashMapObject [GVAR(OrgTreasuryServiceBase)]; diff --git a/arma/server/addons/store/functions/fnc_initCatalogService.sqf b/arma/server/addons/store/functions/fnc_initCatalogService.sqf index 8eb6b52..a27f364 100644 --- a/arma/server/addons/store/functions/fnc_initCatalogService.sqf +++ b/arma/server/addons/store/functions/fnc_initCatalogService.sqf @@ -399,12 +399,20 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ _resolved }], - ["calculateCheckoutTotal", compileFinal { + ["buildCheckoutRequest", compileFinal { params [["_items", [], [[]]], ["_vehicles", [], [[]]]]; - private _result = createHashMapFromArray [["success", false], ["total", 0], ["message", "Checkout total must be greater than zero."]]; + private _result = createHashMapFromArray [ + ["success", false], + ["total", 0], + ["message", "Checkout total must be greater than zero."], + ["items", []], + ["vehicles", []] + ]; private _total = 0; private _message = ""; + private _resolvedItems = []; + private _resolvedVehicles = []; { if (_message isEqualTo "") then { @@ -419,7 +427,14 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ if (_catalogEntry isEqualTo createHashMap) then { _message = format ["Unsupported store item: %1", _className]; } else { - _total = _total + ((_catalogEntry getOrDefault ["priceValue", 0]) * _quantity); + private _priceValue = _catalogEntry getOrDefault ["priceValue", 0]; + _total = _total + (_priceValue * _quantity); + _resolvedItems pushBack (createHashMapFromArray [ + ["classname", _className], + ["category", _catalogEntry getOrDefault ["category", "item"]], + ["priceValue", _priceValue], + ["quantity", _quantity] + ]); }; }; }; @@ -436,7 +451,13 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ if (_catalogEntry isEqualTo createHashMap) then { _message = format ["Unsupported store vehicle: %1", _className]; } else { - _total = _total + (_catalogEntry getOrDefault ["priceValue", 0]); + private _priceValue = _catalogEntry getOrDefault ["priceValue", 0]; + _total = _total + _priceValue; + _resolvedVehicles pushBack (createHashMapFromArray [ + ["classname", _className], + ["category", _catalogEntry getOrDefault ["category", _x getOrDefault ["category", "other"]]], + ["priceValue", _priceValue] + ]); }; }; }; @@ -452,7 +473,19 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ _result set ["success", true]; _result set ["total", floor _total]; _result set ["message", ""]; + _result set ["items", _resolvedItems]; + _result set ["vehicles", _resolvedVehicles]; _result + }], + ["calculateCheckoutTotal", compileFinal { + params [["_items", [], [[]]], ["_vehicles", [], [[]]]]; + + private _checkout = _self call ["buildCheckoutRequest", [_items, _vehicles]]; + createHashMapFromArray [ + ["success", _checkout getOrDefault ["success", false]], + ["total", _checkout getOrDefault ["total", 0]], + ["message", _checkout getOrDefault ["message", "Checkout total must be greater than zero."]] + ] }] ]; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index 1e4b188..8a06965 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -160,45 +160,92 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)] }], - ["applyPaymentPatch", compileFinal { - params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_paymentMethod", "cash", [""]], ["_total", 0, [0]], ["_commit", false, [false]]]; + ["callCheckoutBackendEnvelope", compileFinal { + params [["_context", createHashMap, [createHashMap]]]; - private _result = _self call ["buildResult", ["Unable to process payment.", _paymentMethod]]; - private _payment = switch (toLowerANSI _paymentMethod) do { - case "cash"; - case "bank": { - EGVAR(bank,BankStore) call ["chargeCheckout", [_uid, _paymentMethod, _total, _commit]] - }; - case "org_funds"; - case "credit_line": { - EGVAR(org,OrgStore) call ["chargeCheckout", [_uid, _player, _paymentMethod, _total, _commit]] - }; - default { - createHashMapFromArray [ - ["success", false], - ["message", "Selected payment source is unsupported."], - ["patch", createHashMap], - ["memberUids", []] - ] - }; + private _envelope = createHashMapFromArray [["data", createHashMap], ["error", ""]]; + if (_context isEqualTo createHashMap) exitWith { + _envelope set ["error", "Checkout request was invalid."]; + _envelope }; - if !(_payment getOrDefault ["success", false]) exitWith { - _result set ["message", _payment getOrDefault ["message", "Unable to process payment."]]; - _result + ["store:checkout", [toJSON _context]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + _envelope set ["error", "Store backend call failed."]; + _envelope + }; + if !(_result isEqualType "") exitWith { + _envelope set ["error", "Store backend returned an invalid response."]; + _envelope + }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Store extension checkout failed: %1", _result]] call EFUNC(common,log); + _envelope set ["error", _result select [7]]; + _envelope }; - private _patch = _payment getOrDefault ["patch", createHashMap]; - if ((_paymentMethod isEqualTo "cash") || { _paymentMethod isEqualTo "bank" }) then { - _result set ["bankPatch", _patch]; - } else { - _result set ["orgPatch", _patch]; - _result set ["orgTargetUids", _payment getOrDefault ["memberUids", []]]; + private _data = fromJSON _result; + if !(_data isEqualType createHashMap) exitWith { + _envelope set ["error", "Store backend returned unreadable JSON."]; + _envelope }; - _result set ["success", true]; - _result set ["message", ""]; - _result + _envelope set ["data", _data]; + _envelope + }], + ["buildCheckoutContext", compileFinal { + params [ + ["_uid", "", [""]], + ["_player", objNull, [objNull]], + ["_paymentMethod", "cash", [""]], + ["_items", [], [[]]], + ["_vehicles", [], [[]]] + ]; + + if (_uid isEqualTo "" || { isNull _player }) exitWith { createHashMap }; + + private _orgID = EGVAR(org,OrgStore) call ["resolveOrgIdForUid", [_uid]]; + private _requesterIsDefaultOrgCeo = ( + _orgID isEqualTo "default" + && { toLowerANSI (vehicleVarName _player) isEqualTo "ceo" } + ); + + createHashMapFromArray [ + ["requesterUid", _uid], + ["requesterName", name _player], + ["orgId", _orgID], + ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], + ["paymentMethod", toLowerANSI _paymentMethod], + ["items", _items], + ["vehicles", _vehicles] + ] + }], + ["syncCheckoutResult", compileFinal { + params [["_player", objNull, [objNull]], ["_result", createHashMap, [createHashMap]]]; + + if (isNull _player || { _result isEqualTo createHashMap }) exitWith { false }; + + private _lockerPatch = _result getOrDefault ["lockerPatch", createHashMap]; + private _vaPatch = _result getOrDefault ["vaPatch", createHashMap]; + private _vgPatch = _result getOrDefault ["vgaragePatch", createHashMap]; + private _bankPatch = _result getOrDefault ["bankPatch", createHashMap]; + private _orgPatch = _result getOrDefault ["orgPatch", createHashMap]; + + if (keys _lockerPatch isNotEqualTo []) then { [CRPC(locker,responseSyncLocker), [_lockerPatch], _player] call CFUNC(targetEvent); }; + if (keys _vaPatch isNotEqualTo []) then { [CRPC(locker,responseSyncVA), [_vaPatch], _player] call CFUNC(targetEvent); }; + if (keys _vgPatch isNotEqualTo []) then { [CRPC(garage,responseSyncVG), [_vgPatch], _player] call CFUNC(targetEvent); }; + if (keys _bankPatch isNotEqualTo []) then { [CRPC(bank,responseSyncBank), [_bankPatch], _player] call CFUNC(targetEvent); }; + + if (keys _orgPatch isNotEqualTo []) then { + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [_orgPatch], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach (_result getOrDefault ["orgTargetUids", []]); + }; + + true }], ["checkout", compileFinal { params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_payloadJson", "", [""]]]; @@ -219,8 +266,8 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _priceResult = GVAR(StoreCatalogService) call ["calculateCheckoutTotal", [_items, _vehicles]]; - private _totalPrice = _priceResult getOrDefault ["total", 0]; + private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles]]; + private _totalPrice = _checkoutRequest getOrDefault ["total", 0]; _result set ["paymentMethod", _paymentMethod]; _result set ["chargedTotal", _totalPrice]; @@ -230,90 +277,41 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ _result }; - if !(_priceResult getOrDefault ["success", false]) exitWith { - _result set ["message", _priceResult getOrDefault ["message", "Checkout total must be greater than zero."]]; + if !(_checkoutRequest getOrDefault ["success", false]) exitWith { + _result set ["message", _checkoutRequest getOrDefault ["message", "Checkout total must be greater than zero."]]; _result }; - private _lockerPreview = EGVAR(locker,LockerStore) call ["grantItems", [_uid, _items, false]]; - if !(_lockerPreview getOrDefault ["success", false]) exitWith { - _result set ["message", _lockerPreview getOrDefault ["message", "Locker grant failed."]]; + private _checkoutContext = _self call ["buildCheckoutContext", [ + _uid, + _player, + _paymentMethod, + _checkoutRequest getOrDefault ["items", []], + _checkoutRequest getOrDefault ["vehicles", []] + ]]; + if (_checkoutContext isEqualTo createHashMap) exitWith { + _result set ["message", "Checkout request context was invalid."]; _result }; - private _vaPreview = EGVAR(locker,VAStore) call ["unlockItems", [_uid, _items, false]]; - if !(_vaPreview getOrDefault ["success", false]) exitWith { - _result set ["message", _vaPreview getOrDefault ["message", "VA unlock failed."]]; + private _envelope = _self call ["callCheckoutBackendEnvelope", [_checkoutContext]]; + private _backendResult = _envelope getOrDefault ["data", createHashMap]; + if (_backendResult isEqualTo createHashMap) exitWith { + _result set ["message", _envelope getOrDefault ["error", "Checkout failed."]]; _result }; - private _vgPreview = EGVAR(garage,VGarageStore) call ["grantVehicles", [_uid, _vehicles, false]]; - if !(_vgPreview getOrDefault ["success", false]) exitWith { - _result set ["message", _vgPreview getOrDefault ["message", "Vehicle unlock failed."]]; - _result - }; - - private _orgFleetPreview = createHashMapFromArray [["success", true], ["message", ""], ["patch", createHashMap], ["memberUids", []]]; - if (_paymentMethod isEqualTo "org_funds" && { _vehicles isNotEqualTo [] }) then { - _orgFleetPreview = EGVAR(org,OrgStore) call ["addFleetVehicles", [_uid, _vehicles, false]]; - if !(_orgFleetPreview getOrDefault ["success", false]) exitWith { - _result set ["message", _orgFleetPreview getOrDefault ["message", "Organization fleet update failed."]]; - _result - }; - }; - - _result set ["lockerGranted", _lockerPreview getOrDefault ["granted", []]]; - _result set ["vehicleGranted", _vgPreview getOrDefault ["granted", []]]; - - private _paymentPreview = _self call ["applyPaymentPatch", [_uid, _player, _paymentMethod, _totalPrice, false]]; - if !(_paymentPreview getOrDefault ["success", false]) exitWith { - _result set ["message", _paymentPreview getOrDefault ["message", "Payment failed."]]; - _result - }; - - private _payment = _self call ["applyPaymentPatch", [_uid, _player, _paymentMethod, _totalPrice, true]]; - private _lockerResult = EGVAR(locker,LockerStore) call ["grantItems", [_uid, _items, true]]; - private _vaResult = EGVAR(locker,VAStore) call ["unlockItems", [_uid, _items, true]]; - private _vgResult = EGVAR(garage,VGarageStore) call ["grantVehicles", [_uid, _vehicles, true]]; - private _orgFleetResult = createHashMapFromArray [["success", true], ["message", ""], ["patch", createHashMap], ["memberUids", []]]; - if (_paymentMethod isEqualTo "org_funds" && { _vehicles isNotEqualTo [] }) then { - _orgFleetResult = EGVAR(org,OrgStore) call ["addFleetVehicles", [_uid, _vehicles, true]]; - }; - - private _lockerPatch = _lockerResult getOrDefault ["patch", createHashMap]; - private _vaPatch = _vaResult getOrDefault ["patch", createHashMap]; - private _vgPatch = _vgResult getOrDefault ["patch", createHashMap]; - if (keys _lockerPatch isNotEqualTo []) then { [CRPC(locker,responseSyncLocker), [_lockerPatch], _player] call CFUNC(targetEvent); }; - if (keys _vaPatch isNotEqualTo []) then { [CRPC(locker,responseSyncVA), [_vaPatch], _player] call CFUNC(targetEvent); }; - if (keys _vgPatch isNotEqualTo []) then { [CRPC(garage,responseSyncVG), [_vgPatch], _player] call CFUNC(targetEvent); }; - - private _bankPatch = _payment getOrDefault ["bankPatch", createHashMap]; - if (keys _bankPatch isNotEqualTo []) then { [CRPC(bank,responseSyncBank), [_bankPatch], _player] call CFUNC(targetEvent); }; - - private _orgPatch = _payment getOrDefault ["orgPatch", createHashMap]; - private _orgFleetPatch = _orgFleetResult getOrDefault ["patch", createHashMap]; - if (keys _orgFleetPatch isNotEqualTo []) then { { _orgPatch set [_x, _y]; } forEach _orgFleetPatch; }; - if (keys _orgPatch isNotEqualTo []) then { - private _orgTargetUids = _payment getOrDefault ["orgTargetUids", []]; - { - if !(_x in _orgTargetUids) then { _orgTargetUids pushBack _x; }; - } forEach (_orgFleetResult getOrDefault ["memberUids", []]); - - { - private _memberPlayer = [_x] call EFUNC(common,getPlayer); - if (_memberPlayer isNotEqualTo objNull) then { [CRPC(org,responseSyncOrg), [_orgPatch], _memberPlayer] call CFUNC(targetEvent); }; - } forEach _orgTargetUids; - }; + _self call ["syncCheckoutResult", [_player, _backendResult]]; _result set ["success", true]; - _result set ["message", format [ + _result set ["message", _backendResult getOrDefault ["message", format [ "Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s).", _self call ["formatCurrency", [_totalPrice]], - count (_lockerResult getOrDefault ["granted", []]), - count (_vgResult getOrDefault ["granted", []]) - ]]; - _result set ["lockerGranted", _lockerResult getOrDefault ["granted", []]]; - _result set ["vehicleGranted", _vgResult getOrDefault ["granted", []]]; + count (_backendResult getOrDefault ["lockerGranted", []]), + count (_backendResult getOrDefault ["vehicleGranted", []]) + ]]]; + _result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]]; + _result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]]; _result }] ]; diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index e621056..53f3a68 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -31,6 +31,14 @@ static HOT_ACTOR_SERVICE: LazyLock< ActorHotStateService::new(repository, hot_repository) }); +#[allow(dead_code)] +pub(crate) fn hot_service() -> &'static ActorHotStateService< + RedisActorRepository, + InMemoryActorHotRepository, +> { + &HOT_ACTOR_SERVICE +} + /// Creates the Arma 3 command group for actor operations. /// /// Registers commands: `get`, `exists`, `create`, `update`, `delete`. diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index dce90e0..64f6492 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -35,6 +35,13 @@ static HOT_BANK_SERVICE: LazyLock< BankHotStateService::new(repository, hot_repository) }); +pub(crate) fn hot_service() -> &'static BankHotStateService< + RedisBankRepository, + InMemoryBankHotRepository, +> { + &HOT_BANK_SERVICE +} + /// Creates the Arma 3 command group for bank operations. /// /// Registers commands: `get`, `exists`, `create`, `update`, `delete`. diff --git a/arma/server/extension/src/garage.rs b/arma/server/extension/src/garage.rs index dcc6204..a5e8a67 100644 --- a/arma/server/extension/src/garage.rs +++ b/arma/server/extension/src/garage.rs @@ -30,6 +30,14 @@ static HOT_GARAGE_SERVICE: LazyLock< GarageHotStateService::new(repository, hot_repository) }); +#[allow(dead_code)] +pub(crate) fn hot_service() -> &'static GarageHotStateService< + RedisGarageRepository, + InMemoryGarageHotRepository, +> { + &HOT_GARAGE_SERVICE +} + /// Creates the Arma 3 command group for garage operations. /// /// Registers commands: `create`, `get`, `add`, `update`, `remove`, `delete`, `exists`. diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index 4c1fabe..c9a93ef 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -22,6 +22,7 @@ pub mod locker; mod log; pub mod org; pub mod redis; +pub mod store; pub mod terrain; pub mod transport; pub mod v_garage; @@ -85,6 +86,7 @@ fn init() -> Extension { .group("icom", icom::group()) .group("locker", locker::group()) .group("org", org::group()) + .group("store", store::group()) .group("terrain", terrain::group()) .group("transport", transport::group()) .group( diff --git a/arma/server/extension/src/locker.rs b/arma/server/extension/src/locker.rs index 99ea271..91d8e10 100644 --- a/arma/server/extension/src/locker.rs +++ b/arma/server/extension/src/locker.rs @@ -25,6 +25,13 @@ static HOT_LOCKER_SERVICE: LazyLock< LockerHotStateService::new(repository, hot_repository) }); +pub(crate) fn hot_service() -> &'static LockerHotStateService< + RedisLockerRepository, + InMemoryLockerHotRepository, +> { + &HOT_LOCKER_SERVICE +} + /// Creates the Arma 3 command group for locker operations. /// /// Registers commands: `create`, `get`, `add`, `update`, `remove`, `delete`, `exists`. diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index ac2fe06..77f0c10 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -4,7 +4,11 @@ //! Handles SQF command mapping and parameter validation. use arma_rs::Group; -use forge_models::HotOrgRecord; +use forge_models::{ + HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgDisbandResult, + OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgLeaveResult, + OrgRegisterContext, +}; use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository}; use forge_services::{OrgHotStateService, OrgService}; use std::sync::LazyLock; @@ -31,6 +35,11 @@ static HOT_ORG_SERVICE: LazyLock< OrgHotStateService::new(repository, hot_repository) }); +pub(crate) fn hot_service() +-> &'static OrgHotStateService, InMemoryOrgHotRepository> { + &HOT_ORG_SERVICE +} + /// Creates the Arma 3 command group for organization operations. /// /// Registers commands: `get`, `exists`, `create`, `update`, `delete`. @@ -47,6 +56,14 @@ pub fn group() -> Group { .command("init", init_hot_org) .command("get", get_hot_org) .command("override", override_hot_org) + .command("ensure_member", ensure_hot_org_member) + .command("register", register_hot_org) + .command("assign_credit_line", assign_credit_line_hot_org) + .command("charge_checkout", charge_checkout_hot_org) + .command("add_assets", add_assets_hot_org) + .command("add_fleet", add_fleet_hot_org) + .command("leave", leave_hot_org) + .command("disband", disband_hot_org) .command("save", save_hot_org) .command("remove", remove_hot_org), ) @@ -78,6 +95,13 @@ fn serialize_hot_org(org: HotOrgRecord) -> String { } } +fn serialize_result(value: &T, label: &str) -> String { + match serde_json::to_string(value) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize {}: {}", label, error), + } +} + pub(crate) fn init_hot_org(org_id: String) -> String { match HOT_ORG_SERVICE.init_org(org_id) { Ok(org) => serialize_hot_org(org), @@ -104,6 +128,110 @@ pub(crate) fn override_hot_org(org_id: String, json_data: String) -> String { } } +pub(crate) fn ensure_hot_org_member(json_data: String) -> String { + let context: OrgEnsureMemberContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid ensure-member JSON: {}", error), + }; + + match HOT_ORG_SERVICE.ensure_member(context) { + Ok(org) => serialize_hot_org(org), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn register_hot_org(json_data: String) -> String { + let context: OrgRegisterContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid register org JSON: {}", error), + }; + + match HOT_ORG_SERVICE.register_org(context) { + Ok(result) => serialize_result(&result, "org register result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn assign_credit_line_hot_org(json_data: String) -> String { + let context: OrgCreditLineContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org credit-line JSON: {}", error), + }; + + match HOT_ORG_SERVICE.assign_credit_line(context) { + Ok(result) => serialize_result(&result, "org mutation result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn charge_checkout_hot_org(json_data: String) -> String { + let context: OrgCheckoutContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org checkout JSON: {}", error), + }; + + match HOT_ORG_SERVICE.charge_checkout(context) { + Ok(result) => serialize_result(&result, "org mutation result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn add_assets_hot_org(context_json: String, assets_json: String) -> String { + let context: OrgGrantContext = match serde_json::from_str(&context_json) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org asset context JSON: {}", error), + }; + let assets: Vec = match serde_json::from_str(&assets_json) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org asset seed JSON: {}", error), + }; + + match HOT_ORG_SERVICE.add_assets(context, assets) { + Ok(result) => serialize_result(&result, "org mutation result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn add_fleet_hot_org(context_json: String, fleet_json: String) -> String { + let context: OrgGrantContext = match serde_json::from_str(&context_json) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org fleet context JSON: {}", error), + }; + let fleet: Vec = match serde_json::from_str(&fleet_json) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org fleet seed JSON: {}", error), + }; + + match HOT_ORG_SERVICE.add_fleet_vehicles(context, fleet) { + Ok(result) => serialize_result(&result, "org mutation result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn leave_hot_org(json_data: String) -> String { + let context: OrgLeaveContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org leave JSON: {}", error), + }; + + match HOT_ORG_SERVICE.leave_org(context) { + Ok(result) => serialize_result::(&result, "org leave result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn disband_hot_org(json_data: String) -> String { + let context: OrgLeaveContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org disband JSON: {}", error), + }; + + match HOT_ORG_SERVICE.disband_org(context) { + Ok(result) => serialize_result::(&result, "org disband result"), + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn save_hot_org(org_id: String) -> String { match HOT_ORG_SERVICE.get_org(org_id.clone()) { Ok(org) => { diff --git a/arma/server/extension/src/store.rs b/arma/server/extension/src/store.rs new file mode 100644 index 0000000..7842ca7 --- /dev/null +++ b/arma/server/extension/src/store.rs @@ -0,0 +1,37 @@ +use arma_rs::Group; +use forge_models::{StoreCheckoutContext, StoreCheckoutResult}; +use forge_services::StoreService; + +pub fn group() -> Group { + Group::new().command("checkout", checkout) +} + +fn serialize_result(result: &StoreCheckoutResult) -> String { + match serde_json::to_string(result) { + Ok(json) => json, + Err(error) => format!( + "Error: Failed to serialize store checkout result: {}", + error + ), + } +} + +pub fn checkout(json_data: String) -> String { + let context: StoreCheckoutContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid store checkout JSON: {}", error), + }; + + let service = StoreService::new( + crate::bank::hot_service(), + crate::org::hot_service(), + crate::locker::hot_service(), + crate::v_locker::hot_service(), + crate::v_garage::hot_service(), + ); + + match service.checkout(context) { + Ok(result) => serialize_result(&result), + Err(error) => format!("Error: {}", error), + } +} diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index 1ebbfe2..b1af67d 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -351,6 +351,44 @@ fn route_command( arguments[1].clone(), )) } + "org:hot:ensure_member" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::ensure_hot_org_member(arguments[0].clone())) + } + "org:hot:register" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::register_hot_org(arguments[0].clone())) + } + "org:hot:assign_credit_line" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::assign_credit_line_hot_org(arguments[0].clone())) + } + "org:hot:charge_checkout" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::charge_checkout_hot_org(arguments[0].clone())) + } + "org:hot:add_assets" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::add_assets_hot_org( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "org:hot:add_fleet" => { + expect_arg_count(function_name, &arguments, 2)?; + Ok(org::add_fleet_hot_org( + arguments[0].clone(), + arguments[1].clone(), + )) + } + "org:hot:leave" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::leave_hot_org(arguments[0].clone())) + } + "org:hot:disband" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::disband_hot_org(arguments[0].clone())) + } "org:hot:save" => { expect_arg_count(function_name, &arguments, 1)?; Ok(org::save_hot_org(arguments[0].clone())) @@ -396,6 +434,10 @@ fn route_command( arguments[1].clone(), )) } + "store:checkout" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(crate::store::checkout(arguments[0].clone())) + } "garage:create" => { expect_arg_count(function_name, &arguments, 1)?; Ok(garage::create_garage(call_context, arguments[0].clone())) diff --git a/arma/server/extension/src/v_garage.rs b/arma/server/extension/src/v_garage.rs index 5e55594..1382d63 100644 --- a/arma/server/extension/src/v_garage.rs +++ b/arma/server/extension/src/v_garage.rs @@ -27,6 +27,13 @@ static HOT_VGARAGE_SERVICE: LazyLock< VGarageHotStateService::new(repository, hot_repository) }); +pub(crate) fn hot_service() -> &'static VGarageHotStateService< + RedisVGarageRepository, + InMemoryVGarageHotRepository, +> { + &HOT_VGARAGE_SERVICE +} + /// Creates the Arma 3 command group for virtual garage operations. /// /// Registers commands: `create`, `fetch`, `get`, `add`, `remove`, `delete`, `exists`. diff --git a/arma/server/extension/src/v_locker.rs b/arma/server/extension/src/v_locker.rs index 22d06be..7064e47 100644 --- a/arma/server/extension/src/v_locker.rs +++ b/arma/server/extension/src/v_locker.rs @@ -27,6 +27,13 @@ static HOT_VLOCKER_SERVICE: LazyLock< VLockerHotStateService::new(repository, hot_repository) }); +pub(crate) fn hot_service() -> &'static VLockerHotStateService< + RedisVLockerRepository, + InMemoryVLockerHotRepository, +> { + &HOT_VLOCKER_SERVICE +} + /// Creates the Arma 3 command group for virtual locker operations. /// /// Registers commands: `create`, `fetch`, `get`, `add`, `remove`, `delete`, `exists`. diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 4bc404c..0267e91 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -4,6 +4,7 @@ pub mod cad; pub mod garage; pub mod locker; pub mod org; +pub mod store; pub mod v_garage; pub mod v_locker; @@ -20,6 +21,15 @@ pub use cad::{ }; pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; -pub use org::{CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry}; +pub use org::{ + CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, + OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, + OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, + OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, +}; +pub use store::{ + StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, + StoreGrantedItem, StoreGrantedVehicle, +}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index c792967..c2c026e 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -65,6 +65,117 @@ pub struct HotOrgRecord { pub members: HashMap, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgEnsureMemberContext { + pub org_id: String, + pub member_uid: String, + pub member_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgRegisterContext { + pub requester_uid: String, + pub requester_name: String, + pub org_id: String, + pub org_name: String, + pub existing_org_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgRegisterResult { + pub org: HotOrgRecord, + pub actor_organization: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgCreditLineContext { + pub requester_uid: String, + pub org_id: String, + pub requester_is_default_org_ceo: bool, + pub member_uid: String, + pub member_name: String, + pub amount: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgCheckoutContext { + pub requester_uid: String, + pub org_id: String, + pub requester_is_default_org_ceo: bool, + pub source: String, + pub amount: f64, + pub commit: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgAssetGrantSeed { + pub classname: String, + pub category: String, + pub quantity: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgFleetGrantSeed { + pub classname: String, + pub category: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgGrantContext { + pub requester_uid: String, + pub org_id: String, + pub commit: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgMutationResult { + pub org: HotOrgRecord, + pub patch: HashMap, + pub member_uids: Vec, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgLeaveContext { + pub requester_uid: String, + pub requester_name: String, + pub org_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgLeaveResult { + pub actor_organization: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgDisbandMemberResult { + pub uid: String, + pub requester: bool, + pub actor_organization: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgDisbandResult { + pub message: String, + pub members: Vec, +} + impl Org { pub fn new>(id: S, owner: S, name: S) -> Result { let org = Self { diff --git a/lib/models/src/store.rs b/lib/models/src/store.rs new file mode 100644 index 0000000..9665c5c --- /dev/null +++ b/lib/models/src/store.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreCheckoutItemSeed { + pub classname: String, + pub category: String, + pub price_value: f64, + pub quantity: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreCheckoutVehicleSeed { + pub classname: String, + pub category: String, + pub price_value: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreCheckoutContext { + pub requester_uid: String, + pub requester_name: String, + pub org_id: String, + pub requester_is_default_org_ceo: bool, + pub payment_method: String, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub vehicles: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreGrantedItem { + pub classname: String, + pub category: String, + pub quantity: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreGrantedVehicle { + pub classname: String, + pub category: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoreCheckoutResult { + pub charged_total: f64, + pub payment_method: String, + pub message: String, + #[serde(default)] + pub locker_granted: Vec, + #[serde(default)] + pub vehicle_granted: Vec, + #[serde(default)] + pub locker_patch: HashMap, + #[serde(default)] + pub va_patch: HashMap, + #[serde(default)] + pub vgarage_patch: HashMap, + #[serde(default)] + pub bank_patch: HashMap, + #[serde(default)] + pub org_patch: HashMap, + #[serde(default)] + pub org_target_uids: Vec, +} diff --git a/lib/services/src/lib.rs b/lib/services/src/lib.rs index 6259d74..61fa35a 100644 --- a/lib/services/src/lib.rs +++ b/lib/services/src/lib.rs @@ -4,6 +4,7 @@ pub mod cad; pub mod garage; pub mod locker; pub mod org; +pub mod store; pub mod v_garage; pub mod v_locker; @@ -13,5 +14,6 @@ pub use cad::{CadStateService, CadViewService}; pub use garage::{GarageHotStateService, GarageService}; pub use locker::{LockerHotStateService, LockerService}; pub use org::{OrgHotStateService, OrgService}; +pub use store::StoreService; pub use v_garage::{VGarageHotStateService, VGarageService}; pub use v_locker::{VLockerHotStateService, VLockerService}; diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index c83235d..590427a 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -6,9 +6,13 @@ //! For full documentation, architecture, and examples, see the [crate README](../README.md). use forge_models::{ - CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgFleetEntry, + CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, + OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, + OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, + OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, }; use forge_repositories::{OrgHotRepository, OrgRepository}; +use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; /// Service layer implementation for organization business logic and operations. @@ -328,6 +332,19 @@ impl OrgHotStateService { pub fn init_org(&self, id: String) -> Result { if let Some(org) = self.repository.get(&id)? { + if !org.members.is_empty() || !org.assets.is_empty() || !org.fleet.is_empty() { + return Ok(org); + } + + let hydrated_org = self.hydrate_org(&id)?; + if !hydrated_org.members.is_empty() + || !hydrated_org.assets.is_empty() + || !hydrated_org.fleet.is_empty() + { + self.repository.save(&hydrated_org)?; + return Ok(hydrated_org); + } + return Ok(org); } @@ -392,6 +409,428 @@ impl OrgHotStateService { self.repository.delete(&id) } + pub fn ensure_member(&self, context: OrgEnsureMemberContext) -> Result { + if context.org_id.trim().is_empty() || context.member_uid.trim().is_empty() { + return Err("A valid organization and member UID are required.".to_string()); + } + + let mut org = self.get_org(context.org_id)?; + if !org.members.contains_key(&context.member_uid) { + let member_name = if context.member_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.member_name + }; + org.members.insert( + context.member_uid.clone(), + MemberSummary { + uid: context.member_uid, + name: member_name, + }, + ); + self.repository.save(&org)?; + } + + Ok(org) + } + + pub fn register_org(&self, context: OrgRegisterContext) -> Result { + if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() { + return Err("A valid requester and organization ID are required.".to_string()); + } + if context.org_name.trim().is_empty() { + return Err("Organization name cannot be empty.".to_string()); + } + if !context.existing_org_id.trim().is_empty() + && !context.existing_org_id.eq_ignore_ascii_case("default") + { + return Err("Player already belongs to an organization.".to_string()); + } + if self.service.org_exists(context.org_id.clone())? + || self.repository.get(&context.org_id)?.is_some() + { + return Err("An organization already exists for this phone number.".to_string()); + } + + let org = Org { + id: context.org_id.clone(), + owner: context.requester_uid.clone(), + name: context.org_name, + funds: 0.0, + reputation: 0, + credit_lines: HashMap::new(), + }; + org.validate() + .map_err(|error| format!("Validation failed: {}", error))?; + + let json_data = serde_json::to_string(&org) + .map_err(|error| format!("Failed to serialize org: {}", error))?; + let persisted_org = self.service.create_org(context.org_id.clone(), json_data)?; + let mut hot_org = + HotOrgRecord::from_parts(persisted_org, HashMap::new(), HashMap::new(), Vec::new()); + hot_org.members.insert( + context.requester_uid.clone(), + MemberSummary { + uid: context.requester_uid.clone(), + name: if context.requester_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.requester_name + }, + }, + ); + self.repository.save(&hot_org)?; + + if context.existing_org_id.eq_ignore_ascii_case("default") { + let mut default_org = self.init_org("default".to_string())?; + default_org.members.remove(&context.requester_uid); + self.repository.save(&default_org)?; + } + + Ok(OrgRegisterResult { + org: hot_org, + actor_organization: context.org_id, + message: String::new(), + }) + } + + pub fn assign_credit_line( + &self, + context: OrgCreditLineContext, + ) -> Result { + if context.requester_uid.trim().is_empty() + || context.member_uid.trim().is_empty() + || context.org_id.trim().is_empty() + { + return Err("A valid requester, member, and organization are required.".to_string()); + } + if context.amount <= 0.0 { + return Err("A valid credit amount is required.".to_string()); + } + + let mut org = self.get_org(context.org_id)?; + if !can_manage_treasury( + &org, + &context.requester_uid, + context.requester_is_default_org_ceo, + ) { + return Err( + "Only the organization leader or CEO can manage treasury actions.".to_string(), + ); + } + + let member_record = org + .members + .get(&context.member_uid) + .cloned() + .ok_or_else(|| { + "Selected member was not found in the organization roster.".to_string() + })?; + let member_name = if context.member_name.trim().is_empty() { + member_record.name + } else { + context.member_name + }; + + org.credit_lines.insert( + context.member_uid.clone(), + CreditLineSummary { + uid: context.member_uid.clone(), + name: member_name.clone(), + amount: context.amount, + }, + ); + self.repository.save(&org)?; + + Ok(OrgMutationResult { + patch: build_org_patch(&org, &["credit_lines"])?, + member_uids: resolve_member_uids(&org, Some(&context.requester_uid)), + message: format!( + "Credit line of ${} assigned to {}.", + format_currency(context.amount), + member_name + ), + org, + }) + } + + pub fn charge_checkout( + &self, + context: OrgCheckoutContext, + ) -> Result { + if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() { + return Err("A valid requester and organization are required.".to_string()); + } + if context.amount <= 0.0 { + return Err("Checkout amount must be greater than zero.".to_string()); + } + + let mut org = self.get_org(context.org_id)?; + let member_uids = resolve_member_uids(&org, Some(&context.requester_uid)); + + match context.source.trim().to_ascii_lowercase().as_str() { + "org_funds" => { + if !can_manage_treasury( + &org, + &context.requester_uid, + context.requester_is_default_org_ceo, + ) { + return Err( + "Only the organization leader or CEO can charge org funds.".to_string() + ); + } + if org.funds < context.amount { + return Err("Organization funds cannot cover this checkout.".to_string()); + } + + org.funds -= context.amount; + self.repository.save(&org)?; + + Ok(OrgMutationResult { + patch: build_org_patch(&org, &["funds"])?, + member_uids, + message: String::new(), + org, + }) + } + "credit_line" => { + let mut credit_line = org + .credit_lines + .get(&context.requester_uid) + .cloned() + .ok_or_else(|| { + "Assigned credit line cannot cover this checkout.".to_string() + })?; + + if credit_line.amount < context.amount { + return Err("Assigned credit line cannot cover this checkout.".to_string()); + } + + credit_line.amount -= context.amount; + org.credit_lines + .insert(context.requester_uid.clone(), credit_line); + self.repository.save(&org)?; + + Ok(OrgMutationResult { + patch: build_org_patch(&org, &["credit_lines"])?, + member_uids, + message: String::new(), + org, + }) + } + _ => Err("Selected organization payment source is unsupported.".to_string()), + } + } + + pub fn add_assets( + &self, + context: OrgGrantContext, + assets: Vec, + ) -> Result { + if context.org_id.trim().is_empty() { + return Err("A valid organization is required for asset updates.".to_string()); + } + if assets.is_empty() { + let org = self.get_org(context.org_id)?; + return Ok(OrgMutationResult { + org, + patch: HashMap::new(), + member_uids: Vec::new(), + message: String::new(), + }); + } + + let mut org = self.get_org(context.org_id)?; + for asset in assets { + if asset.classname.trim().is_empty() || asset.quantity <= 0 { + continue; + } + let category = asset.category.trim().to_ascii_lowercase(); + let category_assets = org.assets.entry(category.clone()).or_default(); + let entry = category_assets + .entry(asset.classname.clone()) + .or_insert_with(|| OrgAssetEntry { + classname: asset.classname.clone(), + asset_type: category.clone(), + quantity: 0, + }); + entry.quantity += asset.quantity; + } + + self.repository.save(&org)?; + + Ok(OrgMutationResult { + patch: build_org_patch(&org, &["assets"])?, + member_uids: resolve_member_uids(&org, Some(&context.requester_uid)), + message: String::new(), + org, + }) + } + + pub fn add_fleet_vehicles( + &self, + context: OrgGrantContext, + vehicles: Vec, + ) -> Result { + if context.org_id.trim().is_empty() { + return Err("A valid organization is required for fleet updates.".to_string()); + } + if vehicles.is_empty() { + let org = self.get_org(context.org_id)?; + return Ok(OrgMutationResult { + org, + patch: HashMap::new(), + member_uids: Vec::new(), + message: String::new(), + }); + } + + let mut org = self.get_org(context.org_id)?; + let mut fleet_index = org.fleet.len(); + for vehicle in vehicles { + if vehicle.classname.trim().is_empty() { + continue; + } + let fleet_type = vehicle.category.trim().to_ascii_lowercase(); + let mut fleet_key = format!("{}_{}", vehicle.classname, fleet_index); + while org.fleet.contains_key(&fleet_key) { + fleet_index += 1; + fleet_key = format!("{}_{}", vehicle.classname, fleet_index); + } + + org.fleet.insert( + fleet_key, + OrgFleetEntry { + classname: vehicle.classname.clone(), + name: vehicle.classname, + fleet_type, + status: "Ready".to_string(), + damage: "0%".to_string(), + }, + ); + fleet_index += 1; + } + + self.repository.save(&org)?; + + Ok(OrgMutationResult { + patch: build_org_patch(&org, &["fleet"])?, + member_uids: resolve_member_uids(&org, Some(&context.requester_uid)), + message: String::new(), + org, + }) + } + + pub fn leave_org(&self, context: OrgLeaveContext) -> Result { + if context.requester_uid.trim().is_empty() { + return Err("A valid player UID is required.".to_string()); + } + if context.org_id.trim().is_empty() || context.org_id.eq_ignore_ascii_case("default") { + return Err("You are already assigned to the default organization.".to_string()); + } + + let mut org = self.get_org(context.org_id)?; + if org.owner == context.requester_uid { + return Err( + "Organization owners must disband the organization instead of leaving it." + .to_string(), + ); + } + + let org_name = org.name.clone(); + org.members.remove(&context.requester_uid); + self.repository.save(&org)?; + + let mut default_org = self.init_org("default".to_string())?; + let requester_uid = context.requester_uid.clone(); + default_org.members.insert( + requester_uid.clone(), + MemberSummary { + uid: requester_uid, + name: if context.requester_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.requester_name + }, + }, + ); + self.repository.save(&default_org)?; + + Ok(OrgLeaveResult { + actor_organization: "default".to_string(), + message: format!( + "You left {} and returned to the default organization.", + org_name + ), + }) + } + + pub fn disband_org(&self, context: OrgLeaveContext) -> Result { + if context.requester_uid.trim().is_empty() { + return Err("A valid player UID is required.".to_string()); + } + if context.org_id.trim().is_empty() || context.org_id.eq_ignore_ascii_case("default") { + return Err("Only active player organizations can be disbanded.".to_string()); + } + + let org = self.get_org(context.org_id.clone())?; + if org.owner != context.requester_uid { + return Err("Only the organization owner can disband this organization.".to_string()); + } + + let org_name = org.name.clone(); + let mut default_org = self.init_org("default".to_string())?; + let mut member_results = Vec::new(); + let mut seen = HashSet::new(); + + for (member_uid, member) in &org.members { + if seen.insert(member_uid.clone()) { + default_org + .members + .insert(member_uid.clone(), member.clone()); + member_results.push(OrgDisbandMemberResult { + uid: member_uid.clone(), + requester: member_uid == &context.requester_uid, + actor_organization: "default".to_string(), + message: if member_uid == &context.requester_uid { + format!("Your organization, {}, has been disbanded.", org_name) + } else { + format!("{} has been disbanded.", org_name) + }, + }); + } + } + + if seen.insert(context.requester_uid.clone()) { + default_org.members.insert( + context.requester_uid.clone(), + MemberSummary { + uid: context.requester_uid.clone(), + name: if context.requester_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.requester_name + }, + }, + ); + member_results.push(OrgDisbandMemberResult { + uid: context.requester_uid, + requester: true, + actor_organization: "default".to_string(), + message: format!("Your organization, {}, has been disbanded.", org_name), + }); + } + + self.repository.save(&default_org)?; + self.service.delete_org(context.org_id.clone())?; + self.repository.delete(&context.org_id)?; + + Ok(OrgDisbandResult { + message: format!("{} has been disbanded.", org_name), + members: member_results, + }) + } + fn hydrate_org(&self, id: &str) -> Result { let org = self .service @@ -403,3 +842,65 @@ impl OrgHotStateService { Ok(HotOrgRecord::from_parts(org, assets, fleet, members)) } } + +fn can_manage_treasury( + org: &HotOrgRecord, + requester_uid: &str, + requester_is_default_org_ceo: bool, +) -> bool { + org.owner == requester_uid + || ((org.id.eq_ignore_ascii_case("default") || org.owner.eq_ignore_ascii_case("server")) + && requester_is_default_org_ceo) +} + +fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec { + let mut member_uids = org.members.keys().cloned().collect::>(); + if let Some(uid) = requester_uid { + if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) { + member_uids.push(uid.to_string()); + } + } + member_uids +} + +fn build_org_patch(org: &HotOrgRecord, fields: &[&str]) -> Result, String> { + let mut patch = HashMap::new(); + for field in fields { + patch.insert((*field).to_string(), current_org_field_value(org, field)?); + } + Ok(patch) +} + +fn current_org_field_value(org: &HotOrgRecord, field: &str) -> Result { + match field { + "id" => Ok(json!(org.id)), + "owner" => Ok(json!(org.owner)), + "name" => Ok(json!(org.name)), + "funds" => Ok(json!(org.funds)), + "reputation" => Ok(json!(org.reputation)), + "credit_lines" => serde_json::to_value(&org.credit_lines) + .map_err(|error| format!("Failed to serialize org credit lines: {}", error)), + "assets" => serde_json::to_value(&org.assets) + .map_err(|error| format!("Failed to serialize org assets: {}", error)), + "fleet" => serde_json::to_value(&org.fleet) + .map_err(|error| format!("Failed to serialize org fleet: {}", error)), + "members" => serde_json::to_value(&org.members) + .map_err(|error| format!("Failed to serialize org members: {}", error)), + _ => Err(format!("Unknown field: {}", field)), + } +} + +fn format_currency(amount: f64) -> String { + let rounded = amount.max(0.0).round() as i64; + let digits = rounded.to_string(); + let mut formatted = String::new(); + + for (index, character) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(character); + } + + formatted.chars().rev().collect() +} diff --git a/lib/services/src/store.rs b/lib/services/src/store.rs new file mode 100644 index 0000000..643b3c2 --- /dev/null +++ b/lib/services/src/store.rs @@ -0,0 +1,685 @@ +use forge_models::{ + Bank, BankCheckoutContext, BankMutationResult, EquipmentCategory, HotOrgRecord, Item, Locker, + OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem, + StoreGrantedVehicle, VGarage, VLocker, VehicleCategory, +}; +use forge_repositories::{ + BankHotRepository, BankRepository, LockerHotRepository, LockerRepository, OrgHotRepository, + OrgRepository, VGarageHotRepository, VGarageRepository, VLockerHotRepository, + VLockerRepository, +}; +use serde_json::json; +use std::collections::HashMap; + +use crate::{ + BankHotStateService, LockerHotStateService, OrgHotStateService, VGarageHotStateService, + VLockerHotStateService, +}; + +pub trait StoreBankBackend { + fn get_bank(&self, uid: &str) -> Result; + fn preview_checkout( + &self, + uid: &str, + amount: f64, + source: &str, + ) -> Result; + fn override_bank(&self, uid: &str, bank: &Bank) -> Result; +} + +pub trait StoreOrgBackend { + fn get_org(&self, org_id: &str) -> Result; + fn override_org(&self, org_id: &str, org: HotOrgRecord) -> Result; +} + +pub trait StoreLockerBackend { + fn get_locker(&self, uid: &str) -> Result; + fn override_locker(&self, uid: &str, items: HashMap) -> Result; +} + +pub trait StoreVLockerBackend { + fn fetch_locker(&self, uid: &str) -> Result; + fn override_locker(&self, uid: &str, locker: VLocker) -> Result; +} + +pub trait StoreVGarageBackend { + fn fetch_garage(&self, uid: &str) -> Result; + fn override_garage(&self, uid: &str, garage: VGarage) -> Result; +} + +impl StoreBankBackend for BankHotStateService { + fn get_bank(&self, uid: &str) -> Result { + BankHotStateService::get_bank(self, uid.to_string()) + } + + fn preview_checkout( + &self, + uid: &str, + amount: f64, + source: &str, + ) -> Result { + BankHotStateService::charge_checkout( + self, + uid.to_string(), + amount, + BankCheckoutContext { + source_field: source.to_string(), + commit: false, + }, + ) + } + + fn override_bank(&self, uid: &str, bank: &Bank) -> Result { + let json = serde_json::to_string(bank) + .map_err(|error| format!("Invalid bank override JSON: {}", error))?; + BankHotStateService::override_bank(self, uid.to_string(), json) + } +} + +impl StoreBankBackend for &BankHotStateService { + fn get_bank(&self, uid: &str) -> Result { + BankHotStateService::get_bank(self, uid.to_string()) + } + + fn preview_checkout( + &self, + uid: &str, + amount: f64, + source: &str, + ) -> Result { + BankHotStateService::charge_checkout( + self, + uid.to_string(), + amount, + BankCheckoutContext { + source_field: source.to_string(), + commit: false, + }, + ) + } + + fn override_bank(&self, uid: &str, bank: &Bank) -> Result { + let json = serde_json::to_string(bank) + .map_err(|error| format!("Invalid bank override JSON: {}", error))?; + BankHotStateService::override_bank(self, uid.to_string(), json) + } +} + +impl StoreOrgBackend for OrgHotStateService { + fn get_org(&self, org_id: &str) -> Result { + OrgHotStateService::get_org(self, org_id.to_string()) + } + + fn override_org(&self, org_id: &str, org: HotOrgRecord) -> Result { + OrgHotStateService::override_org(self, org_id.to_string(), org) + } +} + +impl StoreOrgBackend for &OrgHotStateService { + fn get_org(&self, org_id: &str) -> Result { + OrgHotStateService::get_org(self, org_id.to_string()) + } + + fn override_org(&self, org_id: &str, org: HotOrgRecord) -> Result { + OrgHotStateService::override_org(self, org_id.to_string(), org) + } +} + +impl StoreLockerBackend + for LockerHotStateService +{ + fn get_locker(&self, uid: &str) -> Result { + LockerHotStateService::get_locker(self, uid.to_string()) + } + + fn override_locker(&self, uid: &str, items: HashMap) -> Result { + LockerHotStateService::override_locker(self, uid.to_string(), items) + } +} + +impl StoreLockerBackend + for &LockerHotStateService +{ + fn get_locker(&self, uid: &str) -> Result { + LockerHotStateService::get_locker(self, uid.to_string()) + } + + fn override_locker(&self, uid: &str, items: HashMap) -> Result { + LockerHotStateService::override_locker(self, uid.to_string(), items) + } +} + +impl StoreVLockerBackend + for VLockerHotStateService +{ + fn fetch_locker(&self, uid: &str) -> Result { + VLockerHotStateService::fetch_locker(self, uid) + } + + fn override_locker(&self, uid: &str, locker: VLocker) -> Result { + VLockerHotStateService::override_locker(self, uid, locker) + } +} + +impl StoreVLockerBackend + for &VLockerHotStateService +{ + fn fetch_locker(&self, uid: &str) -> Result { + VLockerHotStateService::fetch_locker(self, uid) + } + + fn override_locker(&self, uid: &str, locker: VLocker) -> Result { + VLockerHotStateService::override_locker(self, uid, locker) + } +} + +impl StoreVGarageBackend + for VGarageHotStateService +{ + fn fetch_garage(&self, uid: &str) -> Result { + VGarageHotStateService::fetch_garage(self, uid) + } + + fn override_garage(&self, uid: &str, garage: VGarage) -> Result { + VGarageHotStateService::override_garage(self, uid, garage) + } +} + +impl StoreVGarageBackend + for &VGarageHotStateService +{ + fn fetch_garage(&self, uid: &str) -> Result { + VGarageHotStateService::fetch_garage(self, uid) + } + + fn override_garage(&self, uid: &str, garage: VGarage) -> Result { + VGarageHotStateService::override_garage(self, uid, garage) + } +} + +pub struct StoreService { + bank: B, + org: O, + locker: L, + vlocker: VL, + vgarage: VG, +} + +impl StoreService { + pub fn new(bank: B, org: O, locker: L, vlocker: VL, vgarage: VG) -> Self { + Self { + bank, + org, + locker, + vlocker, + vgarage, + } + } +} + +impl StoreService +where + B: StoreBankBackend, + O: StoreOrgBackend, + L: StoreLockerBackend, + VL: StoreVLockerBackend, + VG: StoreVGarageBackend, +{ + pub fn checkout(&self, context: StoreCheckoutContext) -> Result { + if context.requester_uid.trim().is_empty() { + return Err("A valid requester UID is required.".to_string()); + } + if context.items.is_empty() && context.vehicles.is_empty() { + return Err("Add at least one item before checkout.".to_string()); + } + + let charged_total = checkout_total(&context); + if charged_total <= 0.0 { + return Err("Checkout total must be greater than zero.".to_string()); + } + + let requester_uid = context.requester_uid.trim(); + let payment_method = context.payment_method.trim().to_ascii_lowercase(); + + let original_locker = self.locker.get_locker(requester_uid)?; + let original_vlocker = self.vlocker.fetch_locker(requester_uid)?; + let original_vgarage = self.vgarage.fetch_garage(requester_uid)?; + + let mut next_locker = original_locker.clone(); + let mut next_vlocker = original_vlocker.clone(); + let mut next_vgarage = original_vgarage.clone(); + + let mut locker_patch = HashMap::new(); + let mut va_patch = HashMap::new(); + let mut vgarage_patch = HashMap::new(); + let mut locker_granted = Vec::new(); + let mut vehicle_granted = Vec::new(); + let mut va_categories_changed: Vec<&str> = Vec::new(); + let mut vgarage_categories_changed: Vec<&str> = Vec::new(); + + for item_seed in &context.items { + if item_seed.classname.trim().is_empty() || item_seed.quantity == 0 { + return Err("Checkout contains an invalid item entry.".to_string()); + } + + let locker_category = resolve_locker_category(&item_seed.category)?; + let arsenal_category = resolve_arsenal_category(&item_seed.category)?; + + let existing_amount = next_locker + .items + .get(&item_seed.classname) + .map(|entry| entry.amount) + .unwrap_or(0); + let updated_item = Item { + category: locker_category.to_string(), + classname: item_seed.classname.clone(), + amount: existing_amount.saturating_add(item_seed.quantity), + }; + + next_locker + .items + .insert(item_seed.classname.clone(), updated_item.clone()); + locker_patch.insert( + item_seed.classname.clone(), + serde_json::to_value(&updated_item) + .map_err(|error| format!("Failed to serialize locker patch: {}", error))?, + ); + locker_granted.push(StoreGrantedItem { + classname: item_seed.classname.clone(), + category: locker_category.to_string(), + quantity: item_seed.quantity, + }); + + match arsenal_category { + EquipmentCategory::Items => { + push_unique(&mut next_vlocker.items, &item_seed.classname); + push_unique_str(&mut va_categories_changed, "items"); + } + EquipmentCategory::Weapons => { + push_unique(&mut next_vlocker.weapons, &item_seed.classname); + push_unique_str(&mut va_categories_changed, "weapons"); + } + EquipmentCategory::Magazines => { + push_unique(&mut next_vlocker.magazines, &item_seed.classname); + push_unique_str(&mut va_categories_changed, "magazines"); + } + EquipmentCategory::Backpacks => { + push_unique(&mut next_vlocker.backpacks, &item_seed.classname); + push_unique_str(&mut va_categories_changed, "backpacks"); + } + } + } + + if next_locker.items.len() > 25 { + return Err( + "Locker capacity would exceed 25 unique items. Clear space before checkout." + .to_string(), + ); + } + + for category in va_categories_changed { + match category { + "items" => { + va_patch.insert(category.to_string(), json!(next_vlocker.items)); + } + "weapons" => { + va_patch.insert(category.to_string(), json!(next_vlocker.weapons)); + } + "magazines" => { + va_patch.insert(category.to_string(), json!(next_vlocker.magazines)); + } + "backpacks" => { + va_patch.insert(category.to_string(), json!(next_vlocker.backpacks)); + } + _ => {} + } + } + + for vehicle_seed in &context.vehicles { + if vehicle_seed.classname.trim().is_empty() { + return Err("Vehicle checkout entry was missing a classname.".to_string()); + } + + let vehicle_category = resolve_vehicle_category(&vehicle_seed.category)?; + match vehicle_category { + VehicleCategory::Cars => { + push_unique(&mut next_vgarage.cars, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "cars"); + } + VehicleCategory::Armor => { + push_unique(&mut next_vgarage.armor, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "armor"); + } + VehicleCategory::Helis => { + push_unique(&mut next_vgarage.helis, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "helis"); + } + VehicleCategory::Planes => { + push_unique(&mut next_vgarage.planes, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "planes"); + } + VehicleCategory::Naval => { + push_unique(&mut next_vgarage.naval, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "naval"); + } + VehicleCategory::Other => { + push_unique(&mut next_vgarage.other, &vehicle_seed.classname); + push_unique_str(&mut vgarage_categories_changed, "other"); + } + } + + vehicle_granted.push(StoreGrantedVehicle { + classname: vehicle_seed.classname.clone(), + category: vehicle_seed.category.clone(), + }); + } + + for category in vgarage_categories_changed { + match category { + "cars" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.cars)); + } + "armor" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.armor)); + } + "helis" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.helis)); + } + "planes" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.planes)); + } + "naval" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.naval)); + } + "other" => { + vgarage_patch.insert(category.to_string(), json!(next_vgarage.other)); + } + _ => {} + } + } + + let mut bank_patch = HashMap::new(); + let mut final_bank = None; + let mut original_bank = None; + + let mut org_patch = HashMap::new(); + let mut org_target_uids = Vec::new(); + let mut final_org = None; + let mut original_org = None; + + match payment_method.as_str() { + "cash" | "bank" => { + original_bank = Some(self.bank.get_bank(requester_uid)?); + let preview = self.bank.preview_checkout( + requester_uid, + charged_total, + payment_method.as_str(), + )?; + bank_patch = preview.patch.clone(); + final_bank = Some(preview.account); + } + "org_funds" | "credit_line" => { + if context.org_id.trim().is_empty() { + return Err("A valid organization is required for this checkout.".to_string()); + } + + let mut org = self.org.get_org(&context.org_id)?; + original_org = Some(org.clone()); + + match payment_method.as_str() { + "org_funds" => { + if !can_manage_treasury( + &org, + requester_uid, + context.requester_is_default_org_ceo, + ) { + return Err( + "Only the organization leader or CEO can charge org funds." + .to_string(), + ); + } + if org.funds < charged_total { + return Err( + "Organization funds cannot cover this checkout.".to_string() + ); + } + org.funds -= charged_total; + org_patch.insert("funds".to_string(), json!(org.funds)); + } + "credit_line" => { + let credit_line = + org.credit_lines.get_mut(requester_uid).ok_or_else(|| { + "Assigned credit line cannot cover this checkout.".to_string() + })?; + if credit_line.amount < charged_total { + return Err( + "Assigned credit line cannot cover this checkout.".to_string() + ); + } + + credit_line.amount -= charged_total; + org_patch.insert("credit_lines".to_string(), json!(org.credit_lines)); + } + _ => unreachable!(), + } + + if payment_method == "org_funds" && !context.vehicles.is_empty() { + add_org_fleet_vehicles(&mut org, &context.vehicles); + org_patch.insert("fleet".to_string(), json!(org.fleet)); + } + + org_target_uids = resolve_member_uids(&org, Some(requester_uid)); + final_org = Some(org); + } + _ => return Err("Selected payment source is unsupported.".to_string()), + } + + let mut locker_saved = false; + let mut vlocker_saved = false; + let mut vgarage_saved = false; + let mut org_saved = false; + + let commit_result = (|| -> Result<(), String> { + if !locker_patch.is_empty() { + self.locker + .override_locker(requester_uid, next_locker.items.clone())?; + locker_saved = true; + } + + if !va_patch.is_empty() { + self.vlocker + .override_locker(requester_uid, next_vlocker.clone())?; + vlocker_saved = true; + } + + if !vgarage_patch.is_empty() { + self.vgarage + .override_garage(requester_uid, next_vgarage.clone())?; + vgarage_saved = true; + } + + if let Some(org) = final_org.clone() { + self.org.override_org(&context.org_id, org)?; + org_saved = true; + } + + if let Some(bank) = final_bank.as_ref() { + self.bank.override_bank(requester_uid, bank)?; + } + + Ok(()) + })(); + + if let Err(error) = commit_result { + if org_saved { + if let Some(org) = original_org { + let org_id = org.id.clone(); + let _ = self.org.override_org(&org_id, org); + } + } + if vgarage_saved { + let _ = self + .vgarage + .override_garage(requester_uid, original_vgarage); + } + if vlocker_saved { + let _ = self + .vlocker + .override_locker(requester_uid, original_vlocker); + } + if locker_saved { + let _ = self + .locker + .override_locker(requester_uid, original_locker.items); + } + if let Some(bank) = original_bank { + let _ = self.bank.override_bank(requester_uid, &bank); + } + return Err(error); + } + + Ok(StoreCheckoutResult { + charged_total, + payment_method, + message: format!( + "Checkout completed. {} charged, {} locker grant(s), {} vehicle unlock(s).", + format_currency(charged_total), + locker_granted.len(), + vehicle_granted.len() + ), + locker_granted, + vehicle_granted, + locker_patch, + va_patch, + vgarage_patch, + bank_patch, + org_patch, + org_target_uids, + }) + } +} + +fn checkout_total(context: &StoreCheckoutContext) -> f64 { + let item_total = context + .items + .iter() + .map(|entry| entry.price_value.max(0.0) * f64::from(entry.quantity)) + .sum::(); + let vehicle_total = context + .vehicles + .iter() + .map(|entry| entry.price_value.max(0.0)) + .sum::(); + + (item_total + vehicle_total).floor() +} + +fn resolve_locker_category(category: &str) -> Result<&'static str, String> { + match category.trim().to_ascii_lowercase().as_str() { + "item" | "attachment" => Ok("item"), + "weapon" => Ok("weapon"), + "magazine" => Ok("magazine"), + "backpack" => Ok("backpack"), + other => Err(format!("Store item category '{}' is unsupported.", other)), + } +} + +fn resolve_arsenal_category(category: &str) -> Result { + match category.trim().to_ascii_lowercase().as_str() { + "item" | "attachment" => Ok(EquipmentCategory::Items), + "weapon" => Ok(EquipmentCategory::Weapons), + "magazine" => Ok(EquipmentCategory::Magazines), + "backpack" => Ok(EquipmentCategory::Backpacks), + other => Err(format!("Store item category '{}' is unsupported.", other)), + } +} + +fn resolve_vehicle_category(category: &str) -> Result { + match category.trim().to_ascii_lowercase().as_str() { + "cars" => Ok(VehicleCategory::Cars), + "armor" => Ok(VehicleCategory::Armor), + "helis" | "heli" => Ok(VehicleCategory::Helis), + "planes" => Ok(VehicleCategory::Planes), + "naval" => Ok(VehicleCategory::Naval), + "other" => Ok(VehicleCategory::Other), + other => Err(format!("Vehicle category '{}' is unsupported.", other)), + } +} + +fn push_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|entry| entry == value) { + values.push(value.to_string()); + } +} + +fn push_unique_str<'a>(values: &mut Vec<&'a str>, value: &'a str) { + if !values.contains(&value) { + values.push(value); + } +} + +fn can_manage_treasury( + org: &HotOrgRecord, + requester_uid: &str, + requester_is_default_org_ceo: bool, +) -> bool { + org.owner == requester_uid + || ((org.id.eq_ignore_ascii_case("default") || org.owner.eq_ignore_ascii_case("server")) + && requester_is_default_org_ceo) +} + +fn resolve_member_uids(org: &HotOrgRecord, requester_uid: Option<&str>) -> Vec { + let mut member_uids = org.members.keys().cloned().collect::>(); + if let Some(uid) = requester_uid { + if !uid.is_empty() && !member_uids.iter().any(|member_uid| member_uid == uid) { + member_uids.push(uid.to_string()); + } + } + member_uids +} + +fn add_org_fleet_vehicles( + org: &mut HotOrgRecord, + vehicles: &[forge_models::StoreCheckoutVehicleSeed], +) { + let mut fleet_index = org.fleet.len(); + for vehicle in vehicles { + if vehicle.classname.trim().is_empty() { + continue; + } + + let fleet_type = vehicle.category.trim().to_ascii_lowercase(); + let mut fleet_key = format!("{}_{}", vehicle.classname, fleet_index); + while org.fleet.contains_key(&fleet_key) { + fleet_index += 1; + fleet_key = format!("{}_{}", vehicle.classname, fleet_index); + } + + org.fleet.insert( + fleet_key, + OrgFleetEntry { + classname: vehicle.classname.clone(), + name: vehicle.classname.clone(), + fleet_type, + status: "Ready".to_string(), + damage: "0%".to_string(), + }, + ); + fleet_index += 1; + } +} + +fn format_currency(amount: f64) -> String { + let rounded = amount.max(0.0).round() as i64; + let digits = rounded.to_string(); + let mut formatted = String::new(); + + for (index, character) in digits.chars().rev().enumerate() { + if index > 0 && index % 3 == 0 { + formatted.push(','); + } + formatted.push(character); + } + + format!("${}", formatted.chars().rev().collect::()) +} From b8dd3ef651892bdedafa64fe61d8b6d2b0eb378c Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 15:35:39 -0500 Subject: [PATCH 13/19] Add task and request payload plumbing to CAD dispatcher - Thread request data through UI bridge and dispatcher events - Add task models, repositories, services, and extension wiring - Include submitted request fields in converted order notes --- .../cad/functions/fnc_handleUIEvents.sqf | 4 +- .../addons/cad/functions/fnc_initUIBridge.sqf | 5 +- .../addons/cad/ui/_site/cad-dispatcher.js | 2 +- .../cad/ui/src/dispatcher/formatters.js | 21 +- .../addons/cad/ui/src/dispatcher/index.js | 19 + .../addons/cad/ui/src/dispatcher/modals.js | 3 +- arma/server/addons/cad/XEH_preInit.sqf | 5 +- .../fnc_initAssignmentRepository.sqf | 8 +- .../task/functions/fnc_handleTaskRewards.sqf | 23 +- .../task/functions/fnc_initTaskStore.sqf | 332 +++++++-------- arma/server/extension/src/lib.rs | 2 + arma/server/extension/src/task.rs | 123 ++++++ lib/models/Cargo.toml | 3 +- lib/models/src/cad.rs | 10 + lib/models/src/lib.rs | 4 + lib/models/src/task.rs | 57 +++ lib/repositories/src/lib.rs | 2 + lib/repositories/src/task.rs | 204 ++++++++++ lib/services/src/cad.rs | 80 +++- lib/services/src/lib.rs | 2 + lib/services/src/task.rs | 379 ++++++++++++++++++ 21 files changed, 1102 insertions(+), 186 deletions(-) create mode 100644 arma/server/extension/src/task.rs create mode 100644 lib/models/src/task.rs create mode 100644 lib/repositories/src/task.rs create mode 100644 lib/services/src/task.rs diff --git a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf index af2abdf..6357e9d 100644 --- a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf @@ -77,14 +77,16 @@ switch (_event) do { private _targetGroupID = ""; private _note = ""; private _priority = "priority"; + private _request = createHashMap; if (_data isEqualType createHashMap) then { _assigneeGroupID = _data getOrDefault ["assigneeGroupID", ""]; _targetGroupID = _data getOrDefault ["targetGroupID", ""]; _note = _data getOrDefault ["note", ""]; _priority = _data getOrDefault ["priority", "priority"]; + _request = _data getOrDefault ["request", createHashMap]; }; - GVAR(CADUIBridge) call ["requestCreateDispatchOrder", [_assigneeGroupID, _targetGroupID, _note, _priority]]; + GVAR(CADUIBridge) call ["requestCreateDispatchOrder", [_assigneeGroupID, _targetGroupID, _note, _priority, _request]]; }; case "cad::supportRequest::submit": { private _type = ""; diff --git a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf index 0394bb5..8e27f70 100644 --- a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf @@ -218,12 +218,13 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [ ["_assigneeGroupID", "", [""]], ["_targetGroupID", "", [""]], ["_note", "", [""]], - ["_priority", "priority", [""]] + ["_priority", "priority", [""]], + ["_request", createHashMap, [createHashMap]] ]; if (_assigneeGroupID isEqualTo "" || { _targetGroupID isEqualTo "" }) exitWith { false }; - [SRPC(cad,requestCreateCadDispatchOrder), [getPlayerUID player, _assigneeGroupID, _targetGroupID, _note, _priority]] call CFUNC(serverEvent); + [SRPC(cad,requestCreateCadDispatchOrder), [getPlayerUID player, _assigneeGroupID, _targetGroupID, _note, _priority, _request]] call CFUNC(serverEvent); true }], ["requestSubmitSupportRequest", compileFinal { diff --git a/arma/client/addons/cad/ui/_site/cad-dispatcher.js b/arma/client/addons/cad/ui/_site/cad-dispatcher.js index 6f86b75..d9c1de4 100644 --- a/arma/client/addons/cad/ui/_site/cad-dispatcher.js +++ b/arma/client/addons/cad/ui/_site/cad-dispatcher.js @@ -1 +1 @@ -window.cadDispatcherFormatters={getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,n="danger"===(t.status||"")?0:1;if(s!==n)return s-n;const r=e.callsign||e.groupId||"",i=t.callsign||t.groupId||"";return r.localeCompare(i)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestTypeLabel(e){switch(e){case"medevac_9line":return"9-Line MEDEVAC";case"ace_lace":return"ACE/LACE";case"fire_support":return"Fire Support";case"air_support":return"Air Support";case"logreq":return"LOGREQ";default:return(e||"request").replaceAll("_"," ")}},buildGroupOptions(e){return this.getSortedGroups().map(t=>{const s=t.groupId||"";return``}).join("")},formatRequestFieldLabel:e=>(e||"field").replaceAll("_"," ").replace(/\b\w/g,e=>e.toUpperCase()),formatRequestFieldValue(e){if(Array.isArray(e))return e.join(", ");if(e&&"object"==typeof e)return JSON.stringify(e);return String(e??"").trim()||"Not provided"},buildRequestOrderNote(e){const t=this.getRequestTypeLabel(e.type||"request"),s=e.groupCallsign||e.groupId||"Unknown Group",n=(e.summary||"").trim();return n?`${t} requested by ${s}. ${n}`:`${t} requested by ${s}.`}},window.cadDispatcherModals={openOrderModal(){this.convertingRequestId="",this.populateOrderModal(),document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden")},closeOrderModal(){this.convertingRequestId="",document.getElementById("dispatcherOrderNoteInput").value="",document.getElementById("dispatcherOrderPrioritySelect").value="priority",document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.add("is-hidden")},openRequestModal(e){const t=this.requests.find(t=>t.requestId===e);t&&(this.viewingRequestId=e,this.populateRequestModal(t),document.getElementById("dispatcherRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.viewingRequestId="",document.getElementById("dispatcherRequestModal").classList.add("is-hidden")},syncRequestModal(){if(!this.viewingRequestId)return;const e=this.requests.find(e=>e.requestId===this.viewingRequestId);e?this.populateRequestModal(e):this.closeRequestModal()},populateRequestModal(e){const t=e.fields&&"object"==typeof e.fields?Object.entries(e.fields):[],s=t.length?t.map(([e,t])=>`\n
\n ${this.formatRequestFieldLabel(e)}\n ${this.formatRequestFieldValue(t)}\n
\n `).join(""):'

No submitted fields.

';document.getElementById("dispatcherRequestTitle").textContent=e.title||e.requestId||"Support Request",document.getElementById("dispatcherRequestPriority").textContent=(e.priority||"priority").replaceAll("_"," "),document.getElementById("dispatcherRequestGroup").textContent=e.groupCallsign||e.groupId||"Unknown",document.getElementById("dispatcherRequestType").textContent=this.getRequestTypeLabel(e.type||"request"),document.getElementById("dispatcherRequestSummary").textContent=e.summary||"No summary provided.",document.getElementById("dispatcherRequestFields").innerHTML=s},convertRequestToOrder(e){const t=this.requests.find(t=>(t.requestId||"")===e);if(!t)return void this.setStatus("Selected request is no longer available.","error");const s=t.groupId||"";if(!s)return void this.setStatus("Selected request has no owning group to target.","error");this.groups.find(e=>(e.groupId||"")===s)?(this.convertingRequestId=e,this.populateOrderModal({selectedAssigneeID:this.getSortedGroups().find(e=>(e.groupId||"")!==s)?.groupId||"",selectedTargetID:s,note:this.buildRequestOrderNote(t),priority:t.priority||"priority"}),document.getElementById("dispatcherOrderModalTitle").textContent="Create Order From Request",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden"),this.setStatus("Preparing dispatch order from request...","info")):this.setStatus("Selected request group is no longer available.","error")},convertViewedRequestToOrder(){this.viewingRequestId&&(this.closeRequestModal(),this.convertRequestToOrder(this.viewingRequestId))},populateOrderModal(e={}){const t=this.getSortedGroups(),s=document.getElementById("dispatcherOrderAssigneeSelect"),n=document.getElementById("dispatcherOrderTargetSelect"),r=document.getElementById("dispatcherOrderNoteInput"),i=document.getElementById("dispatcherOrderPrioritySelect");if(!s||!n)return;const d=e.selectedAssigneeID||"",a=e.selectedTargetID||"",o=d||t.find(e=>(e.groupId||"")!==a)?.groupId||t[0]?.groupId||"",c=a||t.find(e=>(e.groupId||"")!==o)?.groupId||t[0]?.groupId||"";s.innerHTML=this.buildGroupOptions(o),n.innerHTML=this.buildGroupOptions(c),r&&(r.value=e.note||""),i&&(i.value=e.priority||"priority")},syncOrderModal(){const e=document.getElementById("dispatcherOrderModal");e&&!e.classList.contains("is-hidden")&&this.populateOrderModal({selectedAssigneeID:document.getElementById("dispatcherOrderAssigneeSelect")?.value||"",selectedTargetID:document.getElementById("dispatcherOrderTargetSelect")?.value||"",note:document.getElementById("dispatcherOrderNoteInput")?.value||"",priority:document.getElementById("dispatcherOrderPrioritySelect")?.value||"priority"})},openGroupModal(e){const t=this.groups.find(t=>t.groupId===e);t&&(this.editingGroupId=e,document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(e=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>``).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);e?(document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default"):this.closeGroupModal()}},window.cadDispatcherRender={updateDangerAlert(){const e=document.getElementById("dispatcherDangerAlert");if(!e)return;const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("dispatcherRequestAlert");if(!e)return;const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},buildGroupEditorButton:e=>`\n \n ⚙\n \n `,buildCloseOrderButton:e=>`\n \n Close\n \n `,buildCloseRequestButton:e=>`\n \n Close\n \n `,buildConvertRequestButton:e=>`\n \n Convert to Order\n \n `,renderMetrics(){const e=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned")),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned")),s=this.requests.length,n=this.getSupportAlertRequests(),r=this.groups.filter(e=>"danger"===(e.status||""));document.getElementById("metricOpenContracts").textContent=t.length,document.getElementById("metricAssignedContracts").textContent=e.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricOpenRequests").textContent=s,document.getElementById("metricDangerGroups").textContent=r.length;const i=document.getElementById("metricDangerGroupsCard");i&&i.classList.toggle("is-danger",r.length>0);const d=document.getElementById("metricOpenRequestsCard");d&&d.classList.toggle("is-warning",n.length>0)},renderOpenContracts(){const e=document.getElementById("dispatcherOpenContracts"),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned"));if(!t.length)return void(e.innerHTML='

No open contracts.

');const s=this.buildGroupOptions("");e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",n=Array.isArray(e.position)?e.position:[0,0,0],r=this.groups.find(t=>t.groupId===(e.targetGroupId||""));return`\n
\n
\n ${e.title||t}\n ${this.formatTypeLabel(e)}\n
\n

${e.description||""}

\n
\n Unassigned\n ${window.mapUI.formatPosition(n)}\n
\n
\n Target: ${r?r.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n
\n \n \n
\n
\n `}).join("")},renderAssignedContracts(){const e=document.getElementById("dispatcherAssignedContracts"),t=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned"));t.length?e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=this.groups.find(t=>t.groupId===(e.assignedGroupId||"")),n=this.groups.find(t=>t.groupId===(e.targetGroupId||"")),r=this.isDispatchOrder(e);return`\n
\n
\n ${e.title||t}\n ${e.assignmentState||"assigned"}\n
\n

${e.description||""}

\n
\n Group: ${s?s.callsign:e.assignedGroupId||"Unknown"}\n Type: ${this.formatTypeLabel(e)}\n
\n
\n Target: ${n?n.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n ${r?`
${this.buildCloseOrderButton(t)}
`:""}\n
\n `}).join(""):e.innerHTML='

No assigned contracts.

'},renderGroups(){const e=document.getElementById("dispatcherGroups");this.groups.length?e.innerHTML=this.getSortedGroups().map(e=>{const t="danger"===(e.status||"");return`\n
\n
\n
\n ${e.callsign||e.groupId}\n ${e.role||"group"}\n ${t?'Danger':""}\n
\n
\n ${this.buildGroupEditorButton(e.groupId)}\n
\n
\n
\n Leader: ${e.leaderName||"Unknown"}\n Status: ${e.status||"unknown"}\n
\n
\n Org: ${e.orgId||"default"}\n Task: ${e.currentTaskId||"None"}\n
\n
\n `}).join(""):e.innerHTML='

No active groups available.

'},renderActivity(){const e=document.getElementById("dispatcherActivity"),t=this.requests.length?this.requests.map(e=>`\n
\n
\n ${e.title||e.requestId||"Support Request"}\n ${(e.priority||"priority").replaceAll("_"," ")}\n
\n

${e.summary||""}

\n
\n Group: ${e.groupCallsign||e.groupId||"Unknown"}\n ${this.getRequestTypeLabel(e.type||"request")}\n
\n
\n ${this.buildConvertRequestButton(e.requestId||"")}\n ${this.buildCloseRequestButton(e.requestId||"")}\n
\n
\n `).join(""):'

No active support requests.

',s=this.activity.length?this.activity.slice().reverse().slice(0,8).map(e=>`\n
\n
\n ${e.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):'

No recent activity.

';e.innerHTML=`\n
\n
Support Requests
\n ${t}\n
\n
\n
Recent Activity
\n ${s}\n
\n `},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}};const dispatcherFormatters=window.cadDispatcherFormatters||{},dispatcherModals=window.cadDispatcherModals||{},dispatcherRender=window.cadDispatcherRender||{};window.cadDispatcher={contracts:[],requests:[],groups:[],activity:[],session:{},editingGroupId:"",viewingRequestId:"",convertingRequestId:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],...dispatcherFormatters,...dispatcherModals,...dispatcherRender,init(){document.getElementById("dispatcherCreateOrderBtn").addEventListener("click",()=>{this.openOrderModal()}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherOrderModalCloseBtn").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherOrderModalSaveBtn").addEventListener("click",()=>{this.createDispatchOrder()}),document.querySelector("#dispatcherOrderModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestModalDoneBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestConvertBtn").addEventListener("click",()=>{this.convertViewedRequestToOrder()}),document.querySelector("#dispatcherRequestModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{};const t=document.getElementById("dispatcherStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.syncOrderModal(),this.syncRequestModal(),this.render()},setStatus(e,t){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},createDispatchOrder(){const e=document.getElementById("dispatcherOrderAssigneeSelect").value,t=document.getElementById("dispatcherOrderTargetSelect").value,s=document.getElementById("dispatcherOrderPrioritySelect").value,n=document.getElementById("dispatcherOrderNoteInput").value;e&&t?e!==t?(this.setStatus(this.convertingRequestId?"Creating dispatch order from request...":"Creating dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::create",{assigneeGroupID:e,targetGroupID:t,note:n.trim(),priority:s}),this.closeOrderModal()):this.setStatus("Assignee and target groups must be different.","error"):this.setStatus("Select both an assignee and a target group.","error")},assignTask(e){const t=document.getElementById(`dispatcher-assign-group-${e}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:e,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},applyGroupUpdates(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);if(!e)return void this.closeGroupModal();const t=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value,n=t&&t!==(e.role||"")?t:"",r=s&&s!==(e.status||"")?s:"";if(!(n||r))return this.setStatus("No group changes to save.","info"),void this.closeGroupModal();this.setStatus("Updating group profile...","info"),window.mapUI.sendEvent("cad::groups::profile",{groupID:this.editingGroupId,role:n,status:r}),this.closeGroupModal()},closeDispatchOrder(e){e&&(this.setStatus("Closing dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::close",{taskID:e}))},closeSupportRequest(e){e&&(this.setStatus("Closing support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))}},window.cadDispatcher.init(); \ No newline at end of file +window.cadDispatcherFormatters={getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,n="danger"===(t.status||"")?0:1;if(s!==n)return s-n;const r=e.callsign||e.groupId||"",i=t.callsign||t.groupId||"";return r.localeCompare(i)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestTypeLabel(e){switch(e){case"medevac_9line":return"9-Line MEDEVAC";case"ace_lace":return"ACE/LACE";case"fire_support":return"Fire Support";case"air_support":return"Air Support";case"logreq":return"LOGREQ";default:return(e||"request").replaceAll("_"," ")}},buildGroupOptions(e){return this.getSortedGroups().map(t=>{const s=t.groupId||"";return``}).join("")},formatRequestFieldLabel:e=>(e||"field").replaceAll("_"," ").replace(/\b\w/g,e=>e.toUpperCase()),formatRequestFieldValue(e){if(Array.isArray(e))return e.join(", ");if(e&&"object"==typeof e)return JSON.stringify(e);return String(e??"").trim()||"Not provided"},buildRequestOrderNote(e){const t=this.getRequestTypeLabel(e.type||"request"),s=e.groupCallsign||e.groupId||"Unknown Group",n=(e.summary||"").trim(),r=e.fields&&"object"==typeof e.fields?Object.entries(e.fields).map(([e,t])=>{const s=this.formatRequestFieldValue(t);return"Not provided"===s?"":`${this.formatRequestFieldLabel(e)} ${s}`}).filter(Boolean):[],i=r.length?r:[n].filter(Boolean);return i.length?`${t} requested by ${s}. ${i.join(" | ")}`:`${t} requested by ${s}.`}},window.cadDispatcherModals={openOrderModal(){this.convertingRequestId="",this.populateOrderModal(),document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden")},closeOrderModal(){this.convertingRequestId="",document.getElementById("dispatcherOrderNoteInput").value="",document.getElementById("dispatcherOrderPrioritySelect").value="priority",document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.add("is-hidden")},openRequestModal(e){const t=this.requests.find(t=>t.requestId===e);t&&(this.viewingRequestId=e,this.populateRequestModal(t),document.getElementById("dispatcherRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.viewingRequestId="",document.getElementById("dispatcherRequestModal").classList.add("is-hidden")},syncRequestModal(){if(!this.viewingRequestId)return;const e=this.requests.find(e=>e.requestId===this.viewingRequestId);e?this.populateRequestModal(e):this.closeRequestModal()},populateRequestModal(e){const t=e.fields&&"object"==typeof e.fields?Object.entries(e.fields):[],s=t.length?t.map(([e,t])=>`\n
\n ${this.formatRequestFieldLabel(e)}\n ${this.formatRequestFieldValue(t)}\n
\n `).join(""):'

No submitted fields.

';document.getElementById("dispatcherRequestTitle").textContent=e.title||e.requestId||"Support Request",document.getElementById("dispatcherRequestPriority").textContent=(e.priority||"priority").replaceAll("_"," "),document.getElementById("dispatcherRequestGroup").textContent=e.groupCallsign||e.groupId||"Unknown",document.getElementById("dispatcherRequestType").textContent=this.getRequestTypeLabel(e.type||"request"),document.getElementById("dispatcherRequestSummary").textContent=e.summary||"No summary provided.",document.getElementById("dispatcherRequestFields").innerHTML=s},convertRequestToOrder(e){const t=this.requests.find(t=>(t.requestId||"")===e);if(!t)return void this.setStatus("Selected request is no longer available.","error");const s=t.groupId||"";if(!s)return void this.setStatus("Selected request has no owning group to target.","error");this.groups.find(e=>(e.groupId||"")===s)?(this.convertingRequestId=e,this.populateOrderModal({selectedAssigneeID:this.getSortedGroups().find(e=>(e.groupId||"")!==s)?.groupId||"",selectedTargetID:s,note:this.buildRequestOrderNote(t),priority:t.priority||"priority"}),document.getElementById("dispatcherOrderModalTitle").textContent="Create Order From Request",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden"),this.setStatus("Preparing dispatch order from request...","info")):this.setStatus("Selected request group is no longer available.","error")},convertViewedRequestToOrder(){if(!this.viewingRequestId)return;const e=this.viewingRequestId;this.closeRequestModal(),this.convertRequestToOrder(e)},populateOrderModal(e={}){const t=this.getSortedGroups(),s=document.getElementById("dispatcherOrderAssigneeSelect"),n=document.getElementById("dispatcherOrderTargetSelect"),r=document.getElementById("dispatcherOrderNoteInput"),i=document.getElementById("dispatcherOrderPrioritySelect");if(!s||!n)return;const d=e.selectedAssigneeID||"",a=e.selectedTargetID||"",o=d||t.find(e=>(e.groupId||"")!==a)?.groupId||t[0]?.groupId||"",c=a||t.find(e=>(e.groupId||"")!==o)?.groupId||t[0]?.groupId||"";s.innerHTML=this.buildGroupOptions(o),n.innerHTML=this.buildGroupOptions(c),r&&(r.value=e.note||""),i&&(i.value=e.priority||"priority")},syncOrderModal(){const e=document.getElementById("dispatcherOrderModal");e&&!e.classList.contains("is-hidden")&&this.populateOrderModal({selectedAssigneeID:document.getElementById("dispatcherOrderAssigneeSelect")?.value||"",selectedTargetID:document.getElementById("dispatcherOrderTargetSelect")?.value||"",note:document.getElementById("dispatcherOrderNoteInput")?.value||"",priority:document.getElementById("dispatcherOrderPrioritySelect")?.value||"priority"})},openGroupModal(e){const t=this.groups.find(t=>t.groupId===e);t&&(this.editingGroupId=e,document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(e=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>``).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);e?(document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default"):this.closeGroupModal()}},window.cadDispatcherRender={updateDangerAlert(){const e=document.getElementById("dispatcherDangerAlert");if(!e)return;const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("dispatcherRequestAlert");if(!e)return;const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},buildGroupEditorButton:e=>`\n \n ⚙\n \n `,buildCloseOrderButton:e=>`\n \n Close\n \n `,buildCloseRequestButton:e=>`\n \n Close\n \n `,buildConvertRequestButton:e=>`\n \n Convert to Order\n \n `,renderMetrics(){const e=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned")),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned")),s=this.requests.length,n=this.getSupportAlertRequests(),r=this.groups.filter(e=>"danger"===(e.status||""));document.getElementById("metricOpenContracts").textContent=t.length,document.getElementById("metricAssignedContracts").textContent=e.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricOpenRequests").textContent=s,document.getElementById("metricDangerGroups").textContent=r.length;const i=document.getElementById("metricDangerGroupsCard");i&&i.classList.toggle("is-danger",r.length>0);const d=document.getElementById("metricOpenRequestsCard");d&&d.classList.toggle("is-warning",n.length>0)},renderOpenContracts(){const e=document.getElementById("dispatcherOpenContracts"),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned"));if(!t.length)return void(e.innerHTML='

No open contracts.

');const s=this.buildGroupOptions("");e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",n=Array.isArray(e.position)?e.position:[0,0,0],r=this.groups.find(t=>t.groupId===(e.targetGroupId||""));return`\n
\n
\n ${e.title||t}\n ${this.formatTypeLabel(e)}\n
\n

${e.description||""}

\n
\n Unassigned\n ${window.mapUI.formatPosition(n)}\n
\n
\n Target: ${r?r.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n
\n \n \n
\n
\n `}).join("")},renderAssignedContracts(){const e=document.getElementById("dispatcherAssignedContracts"),t=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned"));t.length?e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=this.groups.find(t=>t.groupId===(e.assignedGroupId||"")),n=this.groups.find(t=>t.groupId===(e.targetGroupId||"")),r=this.isDispatchOrder(e);return`\n
\n
\n ${e.title||t}\n ${e.assignmentState||"assigned"}\n
\n

${e.description||""}

\n
\n Group: ${s?s.callsign:e.assignedGroupId||"Unknown"}\n Type: ${this.formatTypeLabel(e)}\n
\n
\n Target: ${n?n.callsign:e.targetGroupCallsign||"None"}\n Priority: ${(e.priority||"priority").replaceAll("_"," ")}\n
\n ${r?`
${this.buildCloseOrderButton(t)}
`:""}\n
\n `}).join(""):e.innerHTML='

No assigned contracts.

'},renderGroups(){const e=document.getElementById("dispatcherGroups");this.groups.length?e.innerHTML=this.getSortedGroups().map(e=>{const t="danger"===(e.status||"");return`\n
\n
\n
\n ${e.callsign||e.groupId}\n ${e.role||"group"}\n ${t?'Danger':""}\n
\n
\n ${this.buildGroupEditorButton(e.groupId)}\n
\n
\n
\n Leader: ${e.leaderName||"Unknown"}\n Status: ${e.status||"unknown"}\n
\n
\n Org: ${e.orgId||"default"}\n Task: ${e.currentTaskId||"None"}\n
\n
\n `}).join(""):e.innerHTML='

No active groups available.

'},renderActivity(){const e=document.getElementById("dispatcherActivity"),t=this.requests.length?this.requests.map(e=>`\n
\n
\n ${e.title||e.requestId||"Support Request"}\n ${(e.priority||"priority").replaceAll("_"," ")}\n
\n

${e.summary||""}

\n
\n Group: ${e.groupCallsign||e.groupId||"Unknown"}\n ${this.getRequestTypeLabel(e.type||"request")}\n
\n
\n ${this.buildConvertRequestButton(e.requestId||"")}\n ${this.buildCloseRequestButton(e.requestId||"")}\n
\n
\n `).join(""):'

No active support requests.

',s=this.activity.length?this.activity.slice().reverse().slice(0,8).map(e=>`\n
\n
\n ${e.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):'

No recent activity.

';e.innerHTML=`\n
\n
Support Requests
\n ${t}\n
\n
\n
Recent Activity
\n ${s}\n
\n `},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}};const dispatcherFormatters=window.cadDispatcherFormatters||{},dispatcherModals=window.cadDispatcherModals||{},dispatcherRender=window.cadDispatcherRender||{};window.cadDispatcher={contracts:[],requests:[],groups:[],activity:[],session:{},editingGroupId:"",viewingRequestId:"",convertingRequestId:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],...dispatcherFormatters,...dispatcherModals,...dispatcherRender,init(){document.getElementById("dispatcherCreateOrderBtn").addEventListener("click",()=>{this.openOrderModal()}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherOrderModalCloseBtn").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherOrderModalSaveBtn").addEventListener("click",()=>{this.createDispatchOrder()}),document.querySelector("#dispatcherOrderModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestModalDoneBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestConvertBtn").addEventListener("click",()=>{this.convertViewedRequestToOrder()}),document.querySelector("#dispatcherRequestModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{};const t=document.getElementById("dispatcherStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.syncOrderModal(),this.syncRequestModal(),this.render()},setStatus(e,t){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},createDispatchOrder(){const e=document.getElementById("dispatcherOrderAssigneeSelect").value,t=document.getElementById("dispatcherOrderTargetSelect").value,s=document.getElementById("dispatcherOrderPrioritySelect").value,n=document.getElementById("dispatcherOrderNoteInput").value,r=this.convertingRequestId&&this.requests.find(e=>(e.requestId||"")===this.convertingRequestId)||null;e&&t?e!==t?(this.setStatus(this.convertingRequestId?"Creating dispatch order from request...":"Creating dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::create",{assigneeGroupID:e,targetGroupID:t,note:n.trim(),priority:s,request:r?{requestId:r.requestId||"",type:r.type||"",title:r.title||"",summary:r.summary||"",fields:r.fields&&"object"==typeof r.fields?r.fields:{}}:{}}),this.closeOrderModal()):this.setStatus("Assignee and target groups must be different.","error"):this.setStatus("Select both an assignee and a target group.","error")},assignTask(e){const t=document.getElementById(`dispatcher-assign-group-${e}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:e,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},applyGroupUpdates(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);if(!e)return void this.closeGroupModal();const t=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value,n=t&&t!==(e.role||"")?t:"",r=s&&s!==(e.status||"")?s:"";if(!(n||r))return this.setStatus("No group changes to save.","info"),void this.closeGroupModal();this.setStatus("Updating group profile...","info"),window.mapUI.sendEvent("cad::groups::profile",{groupID:this.editingGroupId,role:n,status:r}),this.closeGroupModal()},closeDispatchOrder(e){e&&(this.setStatus("Closing dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::close",{taskID:e}))},closeSupportRequest(e){e&&(this.setStatus("Closing support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))}},window.cadDispatcher.init(); \ No newline at end of file diff --git a/arma/client/addons/cad/ui/src/dispatcher/formatters.js b/arma/client/addons/cad/ui/src/dispatcher/formatters.js index 5377e45..c78a92b 100644 --- a/arma/client/addons/cad/ui/src/dispatcher/formatters.js +++ b/arma/client/addons/cad/ui/src/dispatcher/formatters.js @@ -95,9 +95,26 @@ window.cadDispatcherFormatters = { const groupLabel = request.groupCallsign || request.groupId || "Unknown Group"; const summary = (request.summary || "").trim(); + const fieldDetails = + request.fields && typeof request.fields === "object" + ? Object.entries(request.fields) + .map(([fieldID, value]) => { + const fieldValue = + this.formatRequestFieldValue(value); + if (fieldValue === "Not provided") { + return ""; + } - return summary - ? `${typeLabel} requested by ${groupLabel}. ${summary}` + return `${this.formatRequestFieldLabel(fieldID)} ${fieldValue}`; + }) + .filter(Boolean) + : []; + const details = fieldDetails.length + ? fieldDetails + : [summary].filter(Boolean); + + return details.length + ? `${typeLabel} requested by ${groupLabel}. ${details.join(" | ")}` : `${typeLabel} requested by ${groupLabel}.`; }, }; diff --git a/arma/client/addons/cad/ui/src/dispatcher/index.js b/arma/client/addons/cad/ui/src/dispatcher/index.js index 6b6cc5a..a41293c 100644 --- a/arma/client/addons/cad/ui/src/dispatcher/index.js +++ b/arma/client/addons/cad/ui/src/dispatcher/index.js @@ -137,6 +137,12 @@ window.cadDispatcher = { "dispatcherOrderPrioritySelect", ).value; const note = document.getElementById("dispatcherOrderNoteInput").value; + const sourceRequest = this.convertingRequestId + ? this.requests.find( + (entry) => + (entry.requestId || "") === this.convertingRequestId, + ) || null + : null; if (!assigneeGroupID || !targetGroupID) { this.setStatus( @@ -165,6 +171,19 @@ window.cadDispatcher = { targetGroupID: targetGroupID, note: note.trim(), priority: priority, + request: sourceRequest + ? { + requestId: sourceRequest.requestId || "", + type: sourceRequest.type || "", + title: sourceRequest.title || "", + summary: sourceRequest.summary || "", + fields: + sourceRequest.fields && + typeof sourceRequest.fields === "object" + ? sourceRequest.fields + : {}, + } + : {}, }); this.closeOrderModal(); diff --git a/arma/client/addons/cad/ui/src/dispatcher/modals.js b/arma/client/addons/cad/ui/src/dispatcher/modals.js index 6d641f0..e053169 100644 --- a/arma/client/addons/cad/ui/src/dispatcher/modals.js +++ b/arma/client/addons/cad/ui/src/dispatcher/modals.js @@ -137,8 +137,9 @@ window.cadDispatcherModals = { return; } + const requestID = this.viewingRequestId; this.closeRequestModal(); - this.convertRequestToOrder(this.viewingRequestId); + this.convertRequestToOrder(requestID); }, populateOrderModal(options = {}) { const sortedGroups = this.getSortedGroups(); diff --git a/arma/server/addons/cad/XEH_preInit.sqf b/arma/server/addons/cad/XEH_preInit.sqf index eef8a47..187b250 100644 --- a/arma/server/addons/cad/XEH_preInit.sqf +++ b/arma/server/addons/cad/XEH_preInit.sqf @@ -45,7 +45,8 @@ call FUNC(initCadStore); ["_assigneeGroupID", "", [""]], ["_targetGroupID", "", [""]], ["_note", "", [""]], - ["_priority", "priority", [""]] + ["_priority", "priority", [""]], + ["_request", createHashMap, [createHashMap]] ]; if (_assigneeGroupID isEqualTo "" || { _targetGroupID isEqualTo "" }) exitWith { @@ -57,7 +58,7 @@ call FUNC(initCadStore); "Invalid CAD dispatch order payload.", CRPC(cad,responseCadAssignment), "createDispatchOrder", - [_uid, _assigneeGroupID, _targetGroupID, _note, _priority], + [_uid, _assigneeGroupID, _targetGroupID, _note, _priority, _request], true, false ]]; diff --git a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf index d48591a..cb3d5be 100644 --- a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf @@ -243,7 +243,8 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["_assigneeGroupID", "", [""]], ["_targetGroupID", "", [""]], ["_note", "", [""]], - ["_priority", "priority", [""]] + ["_priority", "priority", [""]], + ["_request", createHashMap, [createHashMap]] ]; private _result = createHashMapFromArray [ @@ -305,6 +306,11 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["targetPosition", +(_targetGroup getOrDefault ["position", []])], ["createdByUid", _requesterUid], ["createdByName", ["Dispatcher", name _requesterPlayer] select (_requesterPlayer isNotEqualTo objNull)], + ["requestId", _request getOrDefault ["requestId", ""]], + ["requestType", _request getOrDefault ["type", ""]], + ["requestTitle", _request getOrDefault ["title", ""]], + ["requestSummary", _request getOrDefault ["summary", ""]], + ["requestFields", +(_request getOrDefault ["fields", createHashMap])], ["note", _note], ["priority", _finalPriority], ["createdAt", serverTime] diff --git a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf index 51f8b14..e0e83bf 100644 --- a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf +++ b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf @@ -102,18 +102,25 @@ if (_funds > 0) then { ["ERROR", format ["Failed to load organization %1 for task %2 funds reward.", _orgID, _taskID]] call EFUNC(common,log); _success = false; } else { - private _patch = EGVAR(org,OrgStore) call [ - "set", + private _nextFunds = (_org getOrDefault ["funds", 0]) + _funds; + _org set ["funds", _nextFunds]; + private _updatedOrg = EGVAR(org,OrgStore) call [ + "callHotOrg", [ - _orgID, - "funds", - ((_org getOrDefault ["funds", 0]) + _funds), - false + "org:hot:override", + [_orgID, toJSON _org] ] ]; - [_patch] call _syncOrgPatch; - _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)]; + if (_updatedOrg isEqualTo createHashMap) then { + ["ERROR", format ["Failed to update organization %1 funds for task %2.", _orgID, _taskID]] call EFUNC(common,log); + _success = false; + } else { + private _patch = createHashMapFromArray [["funds", _nextFunds]]; + + [_patch] call _syncOrgPatch; + _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)]; + }; }; }; diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf index e102283..f0410f9 100644 --- a/arma/server/addons/task/functions/fnc_initTaskStore.sqf +++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf @@ -22,11 +22,6 @@ GVAR(TaskStore) = createHashMapObject [[ ["#type", "TaskStore"], ["#create", compileFinal { _self set ["participantRegistry", createHashMap]; - _self set ["defuseRegistry", createHashMap]; - _self set ["taskOwnershipRegistry", createHashMap]; - _self set ["taskStatusRegistry", createHashMap]; - _self set ["completedTaskStatusRegistry", createHashMap]; - _self set ["taskCatalogRegistry", createHashMap]; _self set ["taskEntityRegistries", createHashMapFromArray [ ["cargo", createHashMap], ["hostages", createHashMap], @@ -36,6 +31,55 @@ GVAR(TaskStore) = createHashMapObject [[ ["shooters", createHashMap], ["targets", createHashMap] ]]; + + ["task:reset", []] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if ( + !_isSuccess + || { !(_result isEqualType "") } + || { (_result find "Error:") == 0 } + ) then { + ["WARNING", "Failed to reset task backend state during task store initialization."] call EFUNC(common,log); + }; + }], + ["callTaskStateEnvelope", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + private _envelope = createHashMapFromArray [ + ["success", false], + ["error", ""] + ]; + + if (_function isEqualTo "") exitWith { _envelope }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !_isSuccess exitWith { + _envelope set ["error", format ["Task backend call '%1' failed.", _function]]; + _envelope + }; + if !(_result isEqualType "") exitWith { + _envelope set ["error", format ["Task backend call '%1' returned an invalid response.", _function]]; + _envelope + }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Task extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + _envelope set ["error", _result select [7]]; + _envelope + }; + + _envelope set ["success", true]; + if (_result isNotEqualTo "") then { + _envelope set ["data", fromJSON _result]; + }; + + _envelope + }], + ["callTaskState", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]], ["_fallback", nil]]; + + private _envelope = _self call ["callTaskStateEnvelope", [_function, _arguments]]; + if !(_envelope getOrDefault ["success", false]) exitWith { _fallback }; + + _envelope getOrDefault ["data", _fallback] }], ["bindTaskOwnership", compileFinal { params [["_taskID", "", [""]], ["_requesterUid", "", [""]]]; @@ -52,51 +96,46 @@ GVAR(TaskStore) = createHashMapObject [[ _result }; - if (_requesterUid isEqualTo "") exitWith { - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - _taskOwnershipRegistry set [_taskID, createHashMapFromArray [ - ["requesterUid", ""], - ["orgID", "default"] - ]]; - _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; + private _orgID = "default"; - _result set ["success", true]; - _result set ["message", "No requester UID provided. Bound task to default organization."]; - _result + if (_requesterUid isNotEqualTo "") then { + private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + if (_actor isEqualTo createHashMap) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; + }; + + if (_actor isEqualTo createHashMap) exitWith { + _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; + _result + }; + + _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; }; - private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - if (_actor isEqualTo createHashMap) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; - }; - - if (_actor isEqualTo createHashMap) exitWith { - _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; - _result - }; - - private _orgID = _actor getOrDefault ["organization", ""]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - _taskOwnershipRegistry set [_taskID, createHashMapFromArray [ + private _context = createHashMapFromArray [ ["requesterUid", _requesterUid], - ["orgID", _orgID] - ]]; - _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; - - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - private _catalogEntry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]); - if (_catalogEntry isNotEqualTo createHashMap) then { - _catalogEntry set ["requesterUid", _requesterUid]; - _catalogEntry set ["orgID", _orgID]; - _catalogEntry set ["accepted", true]; - _taskCatalogRegistry set [_taskID, _catalogEntry]; - _self set ["taskCatalogRegistry", _taskCatalogRegistry]; + ["orgId", _orgID] + ]; + private _envelope = _self call [ + "callTaskStateEnvelope", + [ + "task:ownership:bind", + [_taskID, toJSON _context] + ] + ]; + if !(_envelope getOrDefault ["success", false]) exitWith { + _result set ["message", _envelope getOrDefault ["error", "Failed to bind task ownership."]]; + _result }; + private _bindResult = _envelope getOrDefault ["data", createHashMap]; _result set ["success", true]; - _result set ["orgID", _orgID]; + _result set ["message", _bindResult getOrDefault [ + "message", + ["No requester UID provided. Bound task to default organization.", "Task ownership updated."] select (_requesterUid isNotEqualTo "") + ]]; + _result set ["orgID", _bindResult getOrDefault ["orgId", _orgID]]; _result }], ["releaseTaskOwnership", compileFinal { @@ -104,45 +143,26 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { false }; - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - _taskOwnershipRegistry deleteAt _taskID; - _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; - - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - private _catalogEntry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]); - if (_catalogEntry isNotEqualTo createHashMap) then { - _catalogEntry set ["requesterUid", ""]; - _catalogEntry set ["orgID", "default"]; - _catalogEntry set ["accepted", false]; - _taskCatalogRegistry set [_taskID, _catalogEntry]; - _self set ["taskCatalogRegistry", _taskCatalogRegistry]; - }; - - true + private _envelope = _self call ["callTaskStateEnvelope", ["task:ownership:release", [_taskID]]]; + _envelope getOrDefault ["success", false] }], ["registerTaskCatalogEntry", compileFinal { params [["_taskID", "", [""]], ["_entry", createHashMap, [createHashMap]]]; if (_taskID isEqualTo "" || { _entry isEqualTo createHashMap }) exitWith { false }; - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - _taskCatalogRegistry set [_taskID, +_entry]; - _self set ["taskCatalogRegistry", _taskCatalogRegistry]; - true + private _envelope = _self call [ + "callTaskStateEnvelope", + [ + "task:catalog:upsert", + [_taskID, toJSON _entry] + ] + ]; + _envelope getOrDefault ["success", false] }], ["getActiveTaskCatalog", compileFinal { - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; - private _entries = []; - - { - if ((_taskStatusRegistry getOrDefault [_x, ""]) isNotEqualTo "active") then { continue; }; - - private _entry = +_y; - _entry set ["taskID", _x]; - _entry set ["status", "active"]; - _entries pushBack _entry; - } forEach _taskCatalogRegistry; + private _entries = _self call ["callTaskState", ["task:catalog:active", [], []]]; + if !(_entries isEqualType []) exitWith { [] }; _entries }], @@ -160,45 +180,43 @@ GVAR(TaskStore) = createHashMapObject [[ _result }; - if ((_self call ["getTaskStatus", [_taskID]]) isNotEqualTo "active") exitWith { - _result set ["message", "Task is no longer active."]; + private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + if (_actor isEqualTo createHashMap) then { + _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; + }; + if (_actor isEqualTo createHashMap) exitWith { + _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; _result }; - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - private _entry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]); - if (_entry isEqualTo createHashMap) exitWith { - _result set ["message", "Task does not exist."]; + private _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _orgID] + ]; + private _envelope = _self call [ + "callTaskStateEnvelope", + [ + "task:ownership:accept", + [_taskID, toJSON _context] + ] + ]; + if !(_envelope getOrDefault ["success", false]) exitWith { + _result set ["message", _envelope getOrDefault ["error", "Unable to accept task."]]; _result }; - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap]; - private _currentRequesterUid = _ownership getOrDefault ["requesterUid", ""]; - - if (_currentRequesterUid isNotEqualTo "" && { _currentRequesterUid isNotEqualTo _requesterUid }) exitWith { - _result set ["message", "Task has already been accepted."]; - _result set ["entry", _entry]; - _result + private _acceptResult = _envelope getOrDefault ["data", createHashMap]; + private _entry = _acceptResult getOrDefault ["entry", createHashMap]; + if !(_entry isEqualType createHashMap) then { + _entry = createHashMap; }; - private _bindResult = _self call ["bindTaskOwnership", [_taskID, _requesterUid]]; - if !(_bindResult getOrDefault ["success", false]) exitWith { - _result set ["message", _bindResult getOrDefault ["message", "Failed to bind task ownership."]]; - _result - }; - - private _updatedTaskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - private _updatedEntry = +(_updatedTaskCatalogRegistry getOrDefault [_taskID, _entry]); - _updatedEntry set ["accepted", true]; - _updatedEntry set ["requesterUid", _requesterUid]; - _updatedEntry set ["orgID", _bindResult getOrDefault ["orgID", "default"]]; - _updatedTaskCatalogRegistry set [_taskID, _updatedEntry]; - _self set ["taskCatalogRegistry", _updatedTaskCatalogRegistry]; - _result set ["success", true]; - _result set ["message", "Task accepted."]; - _result set ["entry", _updatedEntry]; + _result set ["message", _acceptResult getOrDefault ["message", "Task accepted."]]; + _result set ["entry", _entry]; _result }], ["setTaskStatus", compileFinal { @@ -206,42 +224,28 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "" || { _status isEqualTo "" }) exitWith { false }; - private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; - private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; - _taskStatusRegistry set [_taskID, _status]; - if (_status in ["succeeded", "failed"]) then { - _completedTaskStatusRegistry set [_taskID, _status]; - } else { - _completedTaskStatusRegistry deleteAt _taskID; - }; - _self set ["taskStatusRegistry", _taskStatusRegistry]; - _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry]; - true + [(_self call ["callTaskState", ["task:status:set", [_taskID, _status], false]])] params [["_statusResult", false, [false]]]; + + _statusResult }], ["getTaskStatus", compileFinal { params [["_taskID", "", [""]]]; if (_taskID isEqualTo "") exitWith { "" }; - private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; - private _status = _taskStatusRegistry getOrDefault [_taskID, ""]; - if (_status isNotEqualTo "") exitWith { _status }; + private _status = _self call ["callTaskState", ["task:status:get", [_taskID], ""]]; + if !(_status isEqualType "") exitWith { "" }; - private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; - _completedTaskStatusRegistry getOrDefault [_taskID, ""] + _status }], ["clearTaskStatus", compileFinal { params [["_taskID", "", [""]]]; if (_taskID isEqualTo "") exitWith { false }; - private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; - private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap]; - _taskStatusRegistry deleteAt _taskID; - _completedTaskStatusRegistry deleteAt _taskID; - _self set ["taskStatusRegistry", _taskStatusRegistry]; - _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry]; - true + [(_self call ["callTaskState", ["task:status:clear", [_taskID], false]])] params [["_statusResult", false, [false]]]; + + _statusResult }], ["registerTaskEntity", compileFinal { params [["_registryKey", "", [""]], ["_taskID", "", [""]], ["_entity", objNull, [objNull]]]; @@ -343,18 +347,23 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { _result }; - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap]; - if (_ownership isEqualTo createHashMap) exitWith { _result }; + private _rewardState = _self call ["callTaskState", ["task:ownership:reward_context", [_taskID], createHashMap]]; + if (_rewardState isEqualTo createHashMap) exitWith { _result }; - private _requesterUid = _ownership getOrDefault ["requesterUid", ""]; - private _resolvedOrgID = _ownership getOrDefault ["orgID", ""]; + private _requesterUid = _rewardState getOrDefault ["requesterUid", ""]; + private _resolvedOrgID = _rewardState getOrDefault ["orgId", ""]; if (_resolvedOrgID isEqualTo "") exitWith { _result }; private _org = EGVAR(org,OrgStore) call ["loadById", [_resolvedOrgID]]; private _memberUids = []; if (_org isNotEqualTo createHashMap) then { - _memberUids = EGVAR(org,OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]; + private _members = _org getOrDefault ["members", createHashMap]; + if (_members isEqualType createHashMap) then { + _memberUids = keys _members; + }; + if (_requesterUid isNotEqualTo "" && { !(_requesterUid in _memberUids) }) then { + _memberUids pushBack _requesterUid; + }; }; _result set ["requesterUid", _requesterUid]; @@ -367,10 +376,8 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { 0 }; - private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; - private _nextCount = 1 + (_defuseRegistry getOrDefault [_taskID, 0]); - _defuseRegistry set [_taskID, _nextCount]; - _self set ["defuseRegistry", _defuseRegistry]; + private _nextCount = _self call ["callTaskState", ["task:defuse:increment", [_taskID], 0]]; + if !(_nextCount isEqualType 0) exitWith { 0 }; _nextCount }], @@ -379,8 +386,10 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { 0 }; - private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; - _defuseRegistry getOrDefault [_taskID, 0] + private _defuseCount = _self call ["callTaskState", ["task:defuse:get", [_taskID], 0]]; + if !(_defuseCount isEqualType 0) exitWith { 0 }; + + _defuseCount }], ["notifyParticipants", compileFinal { params [ @@ -410,22 +419,9 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { false }; private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; - private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap]; - private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap]; - private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap]; - private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap]; - _participantRegistry deleteAt _taskID; - _defuseRegistry deleteAt _taskID; - _taskOwnershipRegistry deleteAt _taskID; - _taskStatusRegistry deleteAt _taskID; - _taskCatalogRegistry deleteAt _taskID; - _self set ["participantRegistry", _participantRegistry]; - _self set ["defuseRegistry", _defuseRegistry]; - _self set ["taskOwnershipRegistry", _taskOwnershipRegistry]; - _self set ["taskStatusRegistry", _taskStatusRegistry]; - _self set ["taskCatalogRegistry", _taskCatalogRegistry]; + _self call ["callTaskState", ["task:clear", [_taskID], false]]; _self call ["clearTaskEntities", [_taskID]]; true }], @@ -532,24 +528,28 @@ GVAR(TaskStore) = createHashMapObject [[ if (_org isNotEqualTo createHashMap) then { private _reputation = _org getOrDefault ["reputation", 0]; private _nextReputation = round (_reputation + _delta); - private _patch = EGVAR(org,OrgStore) call [ - "set", + _org set ["reputation", _nextReputation]; + private _updatedOrg = EGVAR(org,OrgStore) call [ + "callHotOrg", [ - _ownerOrgID, - "reputation", - _nextReputation, - false + "org:hot:override", + [_ownerOrgID, toJSON _org] ] ]; - private _memberUids = _rewardContext getOrDefault ["memberUids", []]; - { - private _player = [_x] call EFUNC(common,getPlayer); - if (isNull _player) then { continue; }; - [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent); - } forEach _memberUids; + if (_updatedOrg isNotEqualTo createHashMap) then { + private _patch = createHashMapFromArray [["reputation", _nextReputation]]; + private _memberUids = _rewardContext getOrDefault ["memberUids", []]; + { + private _player = [_x] call EFUNC(common,getPlayer); + if (isNull _player) then { continue; }; + [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent); + } forEach _memberUids; - _orgIds = [_ownerOrgID]; + _orgIds = [_ownerOrgID]; + } else { + ["ERROR", format ["Failed to update organization %1 reputation for task %2.", _ownerOrgID, _taskID]] call EFUNC(common,log); + }; }; }; diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index c9a93ef..c6c775c 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -23,6 +23,7 @@ mod log; pub mod org; pub mod redis; pub mod store; +pub mod task; pub mod terrain; pub mod transport; pub mod v_garage; @@ -87,6 +88,7 @@ fn init() -> Extension { .group("locker", locker::group()) .group("org", org::group()) .group("store", store::group()) + .group("task", task::group()) .group("terrain", terrain::group()) .group("transport", transport::group()) .group( diff --git a/arma/server/extension/src/task.rs b/arma/server/extension/src/task.rs new file mode 100644 index 0000000..233d3dc --- /dev/null +++ b/arma/server/extension/src/task.rs @@ -0,0 +1,123 @@ +//! Task hot-state operations for the Arma 3 server extension. +//! +//! The extension owns portable task metadata while SQF keeps Arma-only runtime +//! state such as entity references and participant tracking. + +use arma_rs::Group; +use forge_repositories::InMemoryTaskRepository; +use forge_services::TaskStateService; +use serde::Serialize; +use std::sync::LazyLock; + +static TASK_SERVICE: LazyLock> = + LazyLock::new(|| TaskStateService::new(InMemoryTaskRepository::new())); + +pub fn group() -> Group { + Group::new() + .command("reset", reset) + .group( + "catalog", + Group::new() + .command("active", list_active_catalog) + .command("get", get_catalog_entry) + .command("upsert", upsert_catalog_entry) + .command("delete", delete_catalog_entry), + ) + .group( + "ownership", + Group::new() + .command("bind", bind_ownership) + .command("release", release_ownership) + .command("accept", accept_task) + .command("reward_context", reward_context), + ) + .group( + "status", + Group::new() + .command("set", set_status) + .command("get", get_status) + .command("clear", clear_status), + ) + .group( + "defuse", + Group::new() + .command("increment", increment_defuse_count) + .command("get", get_defuse_count), + ) + .command("clear", clear_task) +} + +pub(crate) fn list_active_catalog() -> String { + serialize_json(TASK_SERVICE.list_active_catalog()) +} + +pub(crate) fn reset() -> String { + serialize_json(TASK_SERVICE.reset()) +} + +pub(crate) fn get_catalog_entry(entry_id: String) -> String { + serialize_json(TASK_SERVICE.get_catalog_entry(entry_id)) +} + +pub(crate) fn upsert_catalog_entry(entry_id: String, json_data: String) -> String { + serialize_json(TASK_SERVICE.upsert_catalog_entry(entry_id, json_data)) +} + +pub(crate) fn delete_catalog_entry(entry_id: String) -> String { + serialize_ok(TASK_SERVICE.delete_catalog_entry(entry_id)) +} + +pub(crate) fn bind_ownership(entry_id: String, json_data: String) -> String { + serialize_json(TASK_SERVICE.bind_ownership(entry_id, json_data)) +} + +pub(crate) fn release_ownership(entry_id: String) -> String { + serialize_json(TASK_SERVICE.release_ownership(entry_id)) +} + +pub(crate) fn accept_task(entry_id: String, json_data: String) -> String { + serialize_json(TASK_SERVICE.accept_task(entry_id, json_data)) +} + +pub(crate) fn reward_context(entry_id: String) -> String { + serialize_json(TASK_SERVICE.get_reward_context(entry_id)) +} + +pub(crate) fn set_status(entry_id: String, status: String) -> String { + serialize_json(TASK_SERVICE.set_status(entry_id, status)) +} + +pub(crate) fn get_status(entry_id: String) -> String { + serialize_json(TASK_SERVICE.get_status(entry_id)) +} + +pub(crate) fn clear_status(entry_id: String) -> String { + serialize_json(TASK_SERVICE.clear_status(entry_id)) +} + +pub(crate) fn increment_defuse_count(entry_id: String) -> String { + serialize_json(TASK_SERVICE.increment_defuse_count(entry_id)) +} + +pub(crate) fn get_defuse_count(entry_id: String) -> String { + serialize_json(TASK_SERVICE.get_defuse_count(entry_id)) +} + +pub(crate) fn clear_task(entry_id: String) -> String { + serialize_json(TASK_SERVICE.clear_task(entry_id)) +} + +fn serialize_json(result: Result) -> String { + match result { + Ok(value) => serde_json::to_string(&value) + .unwrap_or_else(|error| format!("Error: Failed to serialize task state: {error}")), + Err(error) => format!("Error: {error}"), + } +} + +fn serialize_ok(result: Result<(), String>) -> String { + match result { + Ok(()) => "true".to_string(), + Err(error) => format!("Error: {error}"), + } +} diff --git a/lib/models/Cargo.toml b/lib/models/Cargo.toml index 0e4470c..f0f9a26 100644 --- a/lib/models/Cargo.toml +++ b/lib/models/Cargo.toml @@ -12,10 +12,11 @@ serde_json = { workspace = true, optional = true } forge-shared = { path = "../shared" } [features] -default = ["actor", "bank", "member", "org"] +default = ["actor", "bank", "member", "org", "task"] actor = ["arma-rs", "serde_json"] bank = ["arma-rs", "serde_json"] member = ["arma-rs", "serde_json"] org = ["arma-rs", "serde_json"] +task = ["arma-rs", "serde_json"] arma-rs = ["arma-rs/serde_json"] diff --git a/lib/models/src/cad.rs b/lib/models/src/cad.rs index da8f153..ee58e7c 100644 --- a/lib/models/src/cad.rs +++ b/lib/models/src/cad.rs @@ -58,6 +58,16 @@ pub struct CadDispatchOrderContextSeed { #[serde(default)] pub created_by_name: String, #[serde(default)] + pub request_id: String, + #[serde(default)] + pub request_type: String, + #[serde(default)] + pub request_title: String, + #[serde(default)] + pub request_summary: String, + #[serde(default)] + pub request_fields: CadRecord, + #[serde(default)] pub note: String, #[serde(default)] pub priority: String, diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 0267e91..53d7179 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -5,6 +5,7 @@ pub mod garage; pub mod locker; pub mod org; pub mod store; +pub mod task; pub mod v_garage; pub mod v_locker; @@ -31,5 +32,8 @@ pub use store::{ StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, StoreGrantedItem, StoreGrantedVehicle, }; +pub use task::{ + TaskJsonMap, TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext, +}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/task.rs b/lib/models/src/task.rs new file mode 100644 index 0000000..75237ba --- /dev/null +++ b/lib/models/src/task.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub type TaskJsonMap = Map; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(transparent)] +pub struct TaskRecord { + pub fields: TaskJsonMap, +} + +impl TaskRecord { + pub fn into_value(self) -> Value { + Value::Object(self.fields) + } + + pub fn to_value(&self) -> Value { + Value::Object(self.fields.clone()) + } + + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TaskOwnershipContext { + #[serde(default)] + pub requester_uid: String, + #[serde(default)] + pub org_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TaskOwnershipMutationResult { + #[serde(default)] + pub task_id: String, + #[serde(default)] + pub requester_uid: String, + #[serde(default)] + pub org_id: String, + #[serde(default)] + pub entry: Value, + #[serde(default)] + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TaskRewardContext { + #[serde(default)] + pub requester_uid: String, + #[serde(default)] + pub org_id: String, +} diff --git a/lib/repositories/src/lib.rs b/lib/repositories/src/lib.rs index baeb8ea..2481c2a 100644 --- a/lib/repositories/src/lib.rs +++ b/lib/repositories/src/lib.rs @@ -4,6 +4,7 @@ pub mod cad; pub mod garage; pub mod locker; pub mod org; +pub mod task; pub mod v_garage; pub mod v_locker; @@ -19,6 +20,7 @@ pub use locker::{ InMemoryLockerHotRepository, LockerHotRepository, LockerRepository, RedisLockerRepository, }; pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository, RedisOrgRepository}; +pub use task::{InMemoryTaskRepository, TaskRepository}; pub use v_garage::{ InMemoryVGarageHotRepository, RedisVGarageRepository, VGarageHotRepository, VGarageRepository, }; diff --git a/lib/repositories/src/task.rs b/lib/repositories/src/task.rs new file mode 100644 index 0000000..cdd09b3 --- /dev/null +++ b/lib/repositories/src/task.rs @@ -0,0 +1,204 @@ +use forge_models::{TaskOwnershipContext, TaskRecord}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +pub trait TaskRepository: Send + Sync { + fn reset(&self) -> Result<(), String>; + + fn list_catalog(&self) -> Result, String>; + fn get_catalog_entry(&self, id: &str) -> Result, String>; + fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String>; + fn delete_catalog_entry(&self, id: &str) -> Result<(), String>; + + fn get_ownership(&self, id: &str) -> Result, String>; + fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String>; + fn delete_ownership(&self, id: &str) -> Result<(), String>; + + fn list_active_statuses(&self) -> Result, String>; + fn get_active_status(&self, id: &str) -> Result, String>; + fn set_active_status(&self, id: String, status: String) -> Result<(), String>; + fn delete_active_status(&self, id: &str) -> Result<(), String>; + + fn get_completed_status(&self, id: &str) -> Result, String>; + fn set_completed_status(&self, id: String, status: String) -> Result<(), String>; + fn delete_completed_status(&self, id: &str) -> Result<(), String>; + + fn increment_defuse_count(&self, id: &str) -> Result; + fn get_defuse_count(&self, id: &str) -> Result; + fn clear_defuse_count(&self, id: &str) -> Result<(), String>; +} + +#[derive(Debug, Default)] +struct TaskState { + catalog: HashMap, + ownership: HashMap, + active_statuses: HashMap, + completed_statuses: HashMap, + defuse_counts: HashMap, +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryTaskRepository { + state: Arc>, +} + +impl InMemoryTaskRepository { + pub fn new() -> Self { + Self::default() + } +} + +impl TaskRepository for InMemoryTaskRepository { + fn reset(&self) -> Result<(), String> { + let mut state = self + .state + .write() + .map_err(|_| "Task state lock poisoned.".to_string())?; + state.catalog.clear(); + state.ownership.clear(); + state.active_statuses.clear(); + state.completed_statuses.clear(); + state.defuse_counts.clear(); + Ok(()) + } + + fn list_catalog(&self) -> Result, String> { + self.state + .read() + .map(|state| state.catalog.clone()) + .map_err(|_| "Task catalog state lock poisoned.".to_string()) + } + + fn get_catalog_entry(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.catalog.get(id).cloned()) + .map_err(|_| "Task catalog state lock poisoned.".to_string()) + } + + fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task catalog state lock poisoned.".to_string())? + .catalog + .insert(id, entry); + Ok(()) + } + + fn delete_catalog_entry(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task catalog state lock poisoned.".to_string())? + .catalog + .remove(id); + Ok(()) + } + + fn get_ownership(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.ownership.get(id).cloned()) + .map_err(|_| "Task ownership state lock poisoned.".to_string()) + } + + fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task ownership state lock poisoned.".to_string())? + .ownership + .insert(id, ownership); + Ok(()) + } + + fn delete_ownership(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task ownership state lock poisoned.".to_string())? + .ownership + .remove(id); + Ok(()) + } + + fn list_active_statuses(&self) -> Result, String> { + self.state + .read() + .map(|state| state.active_statuses.clone()) + .map_err(|_| "Task status state lock poisoned.".to_string()) + } + + fn get_active_status(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.active_statuses.get(id).cloned()) + .map_err(|_| "Task status state lock poisoned.".to_string()) + } + + fn set_active_status(&self, id: String, status: String) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task status state lock poisoned.".to_string())? + .active_statuses + .insert(id, status); + Ok(()) + } + + fn delete_active_status(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task status state lock poisoned.".to_string())? + .active_statuses + .remove(id); + Ok(()) + } + + fn get_completed_status(&self, id: &str) -> Result, String> { + self.state + .read() + .map(|state| state.completed_statuses.get(id).cloned()) + .map_err(|_| "Task completed status state lock poisoned.".to_string()) + } + + fn set_completed_status(&self, id: String, status: String) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task completed status state lock poisoned.".to_string())? + .completed_statuses + .insert(id, status); + Ok(()) + } + + fn delete_completed_status(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task completed status state lock poisoned.".to_string())? + .completed_statuses + .remove(id); + Ok(()) + } + + fn increment_defuse_count(&self, id: &str) -> Result { + let mut state = self + .state + .write() + .map_err(|_| "Task defuse state lock poisoned.".to_string())?; + let next_count = 1 + state.defuse_counts.get(id).copied().unwrap_or_default(); + state.defuse_counts.insert(id.to_string(), next_count); + Ok(next_count) + } + + fn get_defuse_count(&self, id: &str) -> Result { + self.state + .read() + .map(|state| state.defuse_counts.get(id).copied().unwrap_or_default()) + .map_err(|_| "Task defuse state lock poisoned.".to_string()) + } + + fn clear_defuse_count(&self, id: &str) -> Result<(), String> { + self.state + .write() + .map_err(|_| "Task defuse state lock poisoned.".to_string())? + .defuse_counts + .remove(id); + Ok(()) + } +} diff --git a/lib/services/src/cad.rs b/lib/services/src/cad.rs index 181e990..a165af0 100644 --- a/lib/services/src/cad.rs +++ b/lib/services/src/cad.rs @@ -253,6 +253,26 @@ impl CadStateService { "createdByName".to_string(), Value::String(created_by_name.clone()), ), + ( + "sourceRequestId".to_string(), + Value::String(seed.request_id.clone()), + ), + ( + "sourceRequestType".to_string(), + Value::String(seed.request_type.clone()), + ), + ( + "sourceRequestTitle".to_string(), + Value::String(seed.request_title.clone()), + ), + ( + "sourceRequestSummary".to_string(), + Value::String(seed.request_summary.clone()), + ), + ( + "sourceRequestFields".to_string(), + seed.request_fields.to_value(), + ), ("createdAt".to_string(), Value::from(seed.created_at)), ("note".to_string(), Value::String(seed.note.clone())), ("isDispatchOrder".to_string(), Value::Bool(true)), @@ -755,8 +775,11 @@ impl CadStateService { .unwrap_or_else(|| "unknown".to_string()) ), "logreq" => format!( - "Category {} | Delivery {} | Location {}", + "Category {} | Requested {} | Quantity {} | Delivery {} | Location {}", Self::string_field(fields, "category").unwrap_or_else(|| "mixed".to_string()), + Self::string_field(fields, "requested_items") + .unwrap_or_else(|| "unspecified".to_string()), + Self::string_field(fields, "quantity").unwrap_or_else(|| "unspecified".to_string()), Self::string_field(fields, "delivery_method") .unwrap_or_else(|| "dispatch discretion".to_string()), Self::string_field(fields, "delivery_location") @@ -1031,6 +1054,61 @@ mod tests { ); } + #[test] + fn create_order_from_context_persists_source_request_metadata() { + let repository = InMemoryCadRepository::new(); + let service = CadStateService::new(repository.clone()); + + let result = service + .create_order_from_context( + r#"{ + "assigneeGroupId": "bravo", + "assigneeGroupCallsign": "Bravo 1-1", + "targetGroupId": "alpha", + "targetGroupCallsign": "Alpha 1-1", + "targetPosition": [1000, 2000, 0], + "createdByUid": "dispatcher-1", + "createdByName": "Dispatch", + "requestId": "cad-request:7", + "requestType": "logreq", + "requestTitle": "LOGREQ | Alpha 1-1", + "requestSummary": "Category ammo | Requested MX rifle ammo", + "requestFields": { + "category": "ammo", + "requested_items": "MX rifle ammo", + "quantity": "4 crates" + }, + "note": "LOGREQ requested by Alpha 1-1. Requested Items MX rifle ammo | Quantity 4 crates", + "priority": "priority", + "createdAt": 123.45 + }"# + .to_string(), + ) + .expect("create order from context should succeed"); + + let stored_order = repository + .get_order(&result.task_id) + .expect("get order should succeed") + .expect("order should exist"); + + assert_eq!( + stored_order.fields.get("sourceRequestId"), + Some(&Value::String("cad-request:7".to_string())) + ); + assert_eq!( + stored_order.fields.get("sourceRequestType"), + Some(&Value::String("logreq".to_string())) + ); + assert_eq!( + stored_order.fields.get("sourceRequestFields"), + Some(&serde_json::json!({ + "category": "ammo", + "requested_items": "MX rifle ammo", + "quantity": "4 crates" + })) + ); + } + #[test] fn decline_assignment_returns_record_and_removes_state() { let repository = InMemoryCadRepository::new(); diff --git a/lib/services/src/lib.rs b/lib/services/src/lib.rs index 61fa35a..070143d 100644 --- a/lib/services/src/lib.rs +++ b/lib/services/src/lib.rs @@ -5,6 +5,7 @@ pub mod garage; pub mod locker; pub mod org; pub mod store; +pub mod task; pub mod v_garage; pub mod v_locker; @@ -15,5 +16,6 @@ pub use garage::{GarageHotStateService, GarageService}; pub use locker::{LockerHotStateService, LockerService}; pub use org::{OrgHotStateService, OrgService}; pub use store::StoreService; +pub use task::TaskStateService; pub use v_garage::{VGarageHotStateService, VGarageService}; pub use v_locker::{VLockerHotStateService, VLockerService}; diff --git a/lib/services/src/task.rs b/lib/services/src/task.rs new file mode 100644 index 0000000..292367f --- /dev/null +++ b/lib/services/src/task.rs @@ -0,0 +1,379 @@ +use forge_models::{ + TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext, +}; +use forge_repositories::TaskRepository; +use serde_json::Value; + +pub struct TaskStateService { + repository: R, +} + +impl TaskStateService { + pub fn new(repository: R) -> Self { + Self { repository } + } + + pub fn reset(&self) -> Result { + self.repository.reset()?; + Ok(true) + } + + pub fn upsert_catalog_entry( + &self, + entry_id: String, + json_data: String, + ) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let mut entry = Self::parse_record(&json_data)?; + Self::normalize_catalog_entry(&mut entry, &entry_id); + self.repository + .save_catalog_entry(entry_id, entry.clone())?; + Ok(entry) + } + + pub fn get_catalog_entry(&self, entry_id: String) -> Result, String> { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository + .get_catalog_entry(&entry_id) + .map(|entry| entry.map(TaskRecord::into_value)) + } + + pub fn delete_catalog_entry(&self, entry_id: String) -> Result<(), String> { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository.delete_catalog_entry(&entry_id) + } + + pub fn list_active_catalog(&self) -> Result, String> { + let catalog = self.repository.list_catalog()?; + let active_statuses = self.repository.list_active_statuses()?; + let mut active_entries = Vec::new(); + + for (task_id, status) in active_statuses { + if status != "active" { + continue; + } + + let Some(entry) = catalog.get(&task_id) else { + continue; + }; + + let mut entry = entry.fields.clone(); + entry.insert("taskId".to_string(), Value::String(task_id.clone())); + entry.insert("taskID".to_string(), Value::String(task_id)); + entry.insert("status".to_string(), Value::String(status)); + active_entries.push(Value::Object(entry)); + } + + Ok(active_entries) + } + + pub fn bind_ownership( + &self, + entry_id: String, + json_data: String, + ) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let mut ownership = Self::parse_ownership_context(&json_data)?; + if ownership.org_id.trim().is_empty() { + ownership.org_id = "default".to_string(); + } + + self.repository + .save_ownership(entry_id.clone(), ownership.clone())?; + let entry = self.patch_catalog_ownership( + &entry_id, + true, + &ownership.requester_uid, + &ownership.org_id, + )?; + + Ok(TaskOwnershipMutationResult { + task_id: entry_id, + requester_uid: ownership.requester_uid, + org_id: ownership.org_id, + entry, + message: "Task ownership updated.".to_string(), + }) + } + + pub fn release_ownership( + &self, + entry_id: String, + ) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let ownership = self + .repository + .get_ownership(&entry_id)? + .unwrap_or_default(); + self.repository.delete_ownership(&entry_id)?; + let entry = self.patch_catalog_ownership(&entry_id, false, "", "default")?; + + Ok(TaskOwnershipMutationResult { + task_id: entry_id, + requester_uid: ownership.requester_uid, + org_id: ownership.org_id, + entry, + message: "Task ownership released.".to_string(), + }) + } + + pub fn accept_task( + &self, + entry_id: String, + json_data: String, + ) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let ownership = Self::parse_ownership_context(&json_data)?; + if ownership.requester_uid.trim().is_empty() { + return Err("Missing task ID or requester UID.".to_string()); + } + + if self.get_status(entry_id.clone())? != "active" { + return Err("Task is no longer active.".to_string()); + } + + if let Some(existing) = self.repository.get_ownership(&entry_id)? + && !existing.requester_uid.trim().is_empty() + && existing.requester_uid != ownership.requester_uid + { + return Err("Task has already been accepted.".to_string()); + } + + let mut result = self.bind_ownership( + entry_id, + serde_json::to_string(&ownership) + .map_err(|error| format!("Failed to serialize task ownership: {error}"))?, + )?; + result.message = "Task accepted.".to_string(); + Ok(result) + } + + pub fn set_status(&self, entry_id: String, status: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let final_status = Self::validate_status(status)?; + self.repository + .set_active_status(entry_id.clone(), final_status.clone())?; + if matches!(final_status.as_str(), "succeeded" | "failed") { + self.repository + .set_completed_status(entry_id, final_status)?; + } else { + self.repository.delete_completed_status(&entry_id)?; + } + + Ok(true) + } + + pub fn get_status(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + if let Some(status) = self.repository.get_active_status(&entry_id)? { + return Ok(status); + } + + Ok(self + .repository + .get_completed_status(&entry_id)? + .unwrap_or_default()) + } + + pub fn clear_status(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository.delete_active_status(&entry_id)?; + self.repository.delete_completed_status(&entry_id)?; + Ok(true) + } + + pub fn get_reward_context(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + let ownership = self + .repository + .get_ownership(&entry_id)? + .unwrap_or_default(); + Ok(TaskRewardContext { + requester_uid: ownership.requester_uid, + org_id: ownership.org_id, + }) + } + + pub fn increment_defuse_count(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository.increment_defuse_count(&entry_id) + } + + pub fn get_defuse_count(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository.get_defuse_count(&entry_id) + } + + pub fn clear_task(&self, entry_id: String) -> Result { + let entry_id = Self::validate_entry_id(entry_id)?; + self.repository.delete_catalog_entry(&entry_id)?; + self.repository.delete_ownership(&entry_id)?; + self.repository.delete_active_status(&entry_id)?; + self.repository.delete_completed_status(&entry_id)?; + self.repository.clear_defuse_count(&entry_id)?; + Ok(true) + } + + fn patch_catalog_ownership( + &self, + entry_id: &str, + accepted: bool, + requester_uid: &str, + org_id: &str, + ) -> Result { + let Some(mut entry) = self.repository.get_catalog_entry(entry_id)? else { + return Ok(Value::Null); + }; + + entry + .fields + .insert("accepted".to_string(), Value::Bool(accepted)); + entry.fields.insert( + "requesterUid".to_string(), + Value::String(requester_uid.to_string()), + ); + entry + .fields + .insert("orgID".to_string(), Value::String(org_id.to_string())); + Self::normalize_catalog_entry(&mut entry, entry_id); + self.repository + .save_catalog_entry(entry_id.to_string(), entry.clone())?; + Ok(entry.into_value()) + } + + fn normalize_catalog_entry(entry: &mut TaskRecord, entry_id: &str) { + let fields = &mut entry.fields; + fields + .entry("accepted".to_string()) + .or_insert(Value::Bool(false)); + fields + .entry("requesterUid".to_string()) + .or_insert(Value::String(String::new())); + fields + .entry("orgID".to_string()) + .or_insert(Value::String("default".to_string())); + fields + .entry("taskId".to_string()) + .or_insert(Value::String(entry_id.to_string())); + fields + .entry("taskID".to_string()) + .or_insert(Value::String(entry_id.to_string())); + } + + fn validate_entry_id(entry_id: String) -> Result { + if entry_id.trim().is_empty() { + return Err("Task ID is required.".to_string()); + } + + Ok(entry_id) + } + + fn validate_status(status: String) -> Result { + if status.trim().is_empty() { + return Err("Task status is required.".to_string()); + } + + Ok(status) + } + + fn parse_record(json_data: &str) -> Result { + serde_json::from_str::(json_data) + .map_err(|error| format!("Invalid task JSON: {error}")) + } + + fn parse_ownership_context(json_data: &str) -> Result { + serde_json::from_str::(json_data) + .map_err(|error| format!("Invalid task ownership JSON: {error}")) + } +} + +#[cfg(test)] +mod tests { + use super::TaskStateService; + use forge_repositories::{InMemoryTaskRepository, TaskRepository}; + use serde_json::Value; + + #[test] + fn bind_ownership_updates_catalog_entry() { + let repository = InMemoryTaskRepository::new(); + let service = TaskStateService::new(repository.clone()); + + service + .upsert_catalog_entry("task-1".to_string(), r#"{"title":"Attack"}"#.to_string()) + .expect("catalog upsert should succeed"); + + let result = service + .bind_ownership( + "task-1".to_string(), + r#"{"requesterUid":"uid-1","orgId":"org-1"}"#.to_string(), + ) + .expect("bind should succeed"); + + assert_eq!(result.requester_uid, "uid-1"); + assert_eq!(result.org_id, "org-1"); + assert_eq!( + result.entry.get("accepted").and_then(Value::as_bool), + Some(true) + ); + + let stored = repository + .get_catalog_entry("task-1") + .expect("catalog lookup should succeed") + .expect("catalog entry should exist"); + assert_eq!( + stored.fields.get("requesterUid").and_then(Value::as_str), + Some("uid-1") + ); + } + + #[test] + fn get_status_falls_back_to_completed_status() { + let repository = InMemoryTaskRepository::new(); + let service = TaskStateService::new(repository.clone()); + + service + .set_status("task-1".to_string(), "failed".to_string()) + .expect("status update should succeed"); + repository + .delete_active_status("task-1") + .expect("active status delete should succeed"); + + assert_eq!( + service + .get_status("task-1".to_string()) + .expect("status lookup should succeed"), + "failed" + ); + } + + #[test] + fn list_active_catalog_only_returns_active_entries() { + let service = TaskStateService::new(InMemoryTaskRepository::new()); + + service + .upsert_catalog_entry( + "task-active".to_string(), + r#"{"title":"Active"}"#.to_string(), + ) + .expect("active catalog upsert should succeed"); + service + .upsert_catalog_entry("task-done".to_string(), r#"{"title":"Done"}"#.to_string()) + .expect("done catalog upsert should succeed"); + service + .set_status("task-active".to_string(), "active".to_string()) + .expect("active status update should succeed"); + service + .set_status("task-done".to_string(), "succeeded".to_string()) + .expect("done status update should succeed"); + + let active_catalog = service + .list_active_catalog() + .expect("active catalog should build"); + + assert_eq!(active_catalog.len(), 1); + assert_eq!( + active_catalog[0].get("taskId").and_then(Value::as_str), + Some("task-active") + ); + } +} From 5ded3a60e57621a3a32710cacef0d6032cbffdfb Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 16:41:10 -0500 Subject: [PATCH 14/19] Add credit line repayment to bank UI - Wire bank client and server for credit line repayment requests - Show credit line balance and repay action in the banking view - Extend org/bank payloads and models with credit line fields --- .../bank/functions/fnc_handleUIEvents.sqf | 5 + .../bank/functions/fnc_initUIBridge.sqf | 7 + arma/client/addons/bank/ui/_site/bank-ui.js | 2 +- arma/client/addons/bank/ui/src/bridge.js | 3 + arma/client/addons/bank/ui/src/data.js | 7 + .../addons/bank/ui/src/pages/BankView.js | 67 +++++++ .../addons/bank/ui/src/registry/events.js | 20 +++ arma/client/addons/org/ui/_site/org-ui.js | 2 +- arma/client/addons/org/ui/src/bridge.js | 12 +- .../ui/src/components/portal/treasuryCard.js | 86 ++++++++- arma/server/addons/bank/XEH_preInit.sqf | 6 + .../bank/functions/fnc_initPayloadBuilder.sqf | 42 ++++- .../addons/bank/functions/fnc_initStore.sqf | 77 ++++++++ .../addons/org/functions/fnc_initOrgStore.sqf | 65 +++++++ .../org/functions/fnc_initPayloadBuilder.sqf | 11 +- .../store/functions/fnc_initStoreStore.sqf | 12 +- arma/server/extension/src/org.rs | 18 +- arma/server/extension/src/transport.rs | 4 + lib/models/src/lib.rs | 9 +- lib/models/src/org.rs | 90 +++++++++- lib/services/src/org.rs | 165 +++++++++++++++--- lib/services/src/store.rs | 18 +- 22 files changed, 688 insertions(+), 40 deletions(-) diff --git a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf index 68237d0..4d5982b 100644 --- a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf @@ -68,6 +68,11 @@ switch (_event) do { GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]]; }; }; + case "bank::repayCreditLine::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleRepayCreditLineRequest", [_data]]; + }; + }; case "bank::pin::request": { if !(isNil QGVAR(BankUIBridge)) then { GVAR(BankUIBridge) call ["handleSubmitPinRequest", [_data]]; diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf index 40df814..59d57da 100644 --- a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -63,6 +63,13 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ [SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent); true }], + ["handleRepayCreditLineRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _amount = floor (_data getOrDefault ["amount", 0]); + [SRPC(bank,requestRepayCreditLine), [getPlayerUID player, _amount]] call CFUNC(serverEvent); + true + }], ["handleHydrateResponse", compileFinal { params [["_data", createHashMap, [createHashMap]], ["_event", "bank::hydrate", [""]]]; diff --git a/arma/client/addons/bank/ui/_site/bank-ui.js b/arma/client/addons/bank/ui/_site/bank-ui.js index 2922620..b733bec 100644 --- a/arma/client/addons/bank/ui/_site/bank-ui.js +++ b/arma/client/addons/bank/ui/_site/bank-ui.js @@ -1 +1 @@ -!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={atmAuthorized:!1,mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyAccountPatch(n){const e=Object.assign({},this.account,n||{});a(this.account,Object.assign({},t,e))},applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=Boolean(n?.session?.atmAuthorized),a=this.getMode(),s=this.getAtmView(),i=this.getPendingAction();if(this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"===e)return t?"deposit"===i||"withdraw"===i||"pin"===s||"atm"!==a?void this.setAtmView("menu"):void this.setAtmView(s):void this.setAtmView("pin");this.setAtmView("dashboard")}syncAccountPatch(){this.setPendingAction(""),this.setAccountVersion(this.getAccountVersion()+1)}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});t.on("bank::hydrate",function(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}),t.on("bank::sync",function(t){n.data.applyAccountPatch(t),e.syncAccountPatch()}),t.on("bank::notice",t=>{e.finishAction(),n.actions&&n.actions.showNotice(t.type||"error",t.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestSubmitPin:n=>t.send("bank::pin::request",n),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function s(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function i(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestDeposit)return s("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!o.requestDeposit({amount:i})||(e.finishAction(),s("error","Deposit bridge is unavailable."),!1)}function o(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestWithdraw)return s("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!o.requestWithdraw({amount:i})||(e.finishAction(),s("error","Withdraw bridge is unavailable."),!1)}function r(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:r,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return s("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return s("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,e){return"deposit"===String(n||"").trim().toLowerCase()?i(e):o(e)},requestDeposit:i,requestDepositEarnings:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestDepositEarnings?(e.startAction("depositearnings"),!!o.requestDepositEarnings({amount:i})||(e.finishAction(),s("error","Earnings bridge is unavailable."),!1)):(s("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,i){const o=a(i),r=String(t||"").trim(),c=n.bridge;return c&&"function"==typeof c.requestTransfer?(e.startAction("transfer"),!!c.requestTransfer({amount:o,from:"bank",target:r})||(e.finishAction(),s("error","Transfer bridge is unavailable."),!1)):(s("error","Transfer bridge is unavailable."),!1)},requestWithdraw:o,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:s,submitCustomAmount:function(n){const t=a(e.getCustomAmount()),r=String(n||"").trim().toLowerCase();if(t<=0)return s("error","Enter a valid transaction amount."),!1;const c="deposit"===r?i(t):o(t);return c&&e.setCustomAmount(""),c},submitPin:function(){const t=String(e.getEnteredPin()||""),a=n.bridge;return a&&"function"==typeof a.requestSubmitPin?(e.startAction("pin"),a.requestSubmitPin({pin:t})?(r(),!0):(e.finishAction(),s("error","PIN bridge is unavailable."),!1)):(s("error","PIN bridge is unavailable."),!1)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file +!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={atmAuthorized:!1,creditLine:{amountDue:0,approvedAmount:0,availableAmount:0,interestRate:.1,outstandingPrincipal:0},mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyAccountPatch(n){const e=Object.assign({},this.account,n||{});a(this.account,Object.assign({},t,e))},applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=Boolean(n?.session?.atmAuthorized),a=this.getMode(),s=this.getAtmView(),i=this.getPendingAction();if(this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"===e)return t?"deposit"===i||"withdraw"===i||"pin"===s||"atm"!==a?void this.setAtmView("menu"):void this.setAtmView(s):void this.setAtmView("pin");this.setAtmView("dashboard")}syncAccountPatch(){this.setPendingAction(""),this.setAccountVersion(this.getAccountVersion()+1)}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});t.on("bank::hydrate",function(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}),t.on("bank::sync",function(t){n.data.applyAccountPatch(t),e.syncAccountPatch()}),t.on("bank::notice",t=>{e.finishAction(),n.actions&&n.actions.showNotice(t.type||"error",t.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRepayCreditLine:n=>t.send("bank::repayCreditLine::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestSubmitPin:n=>t.send("bank::pin::request",n),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function s(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function i(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestDeposit)return s("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!o.requestDeposit({amount:i})||(e.finishAction(),s("error","Deposit bridge is unavailable."),!1)}function o(t){const i=a(t),o=n.bridge;if(!o||"function"!=typeof o.requestWithdraw)return s("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!o.requestWithdraw({amount:i})||(e.finishAction(),s("error","Withdraw bridge is unavailable."),!1)}function r(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:r,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return s("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return s("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,e){return"deposit"===String(n||"").trim().toLowerCase()?i(e):o(e)},requestDeposit:i,requestDepositEarnings:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestDepositEarnings?(e.startAction("depositearnings"),!!o.requestDepositEarnings({amount:i})||(e.finishAction(),s("error","Earnings bridge is unavailable."),!1)):(s("error","Earnings bridge is unavailable."),!1)},requestRepayCreditLine:function(t){const i=a(t),o=n.bridge;return o&&"function"==typeof o.requestRepayCreditLine?(e.startAction("repaycreditline"),!!o.requestRepayCreditLine({amount:i})||(e.finishAction(),s("error","Credit repayment bridge is unavailable."),!1)):(s("error","Credit repayment bridge is unavailable."),!1)},requestTransfer:function(t,i){const o=a(i),r=String(t||"").trim(),c=n.bridge;return c&&"function"==typeof c.requestTransfer?(e.startAction("transfer"),!!c.requestTransfer({amount:o,from:"bank",target:r})||(e.finishAction(),s("error","Transfer bridge is unavailable."),!1)):(s("error","Transfer bridge is unavailable."),!1)},requestWithdraw:o,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:s,submitCustomAmount:function(n){const t=a(e.getCustomAmount()),r=String(n||"").trim().toLowerCase();if(t<=0)return s("error","Enter a valid transaction amount."),!1;const c="deposit"===r?i(t):o(t);return c&&e.setCustomAmount(""),c},submitPin:function(){const t=String(e.getEnteredPin()||""),a=n.bridge;return a&&"function"==typeof a.requestSubmitPin?(e.startAction("pin"),a.requestSubmitPin({pin:t})?(r(),!0):(e.finishAction(),s("error","PIN bridge is unavailable."),!1)):(s("error","PIN bridge is unavailable."),!1)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:d}=n.componentFns;function m(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return m(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":""),c("Credit Due",r(i.creditLine?.amountDue||0),Number(i.creditLine?.amountDue||0)>0?`Outstanding principal ${r(i.creditLine?.outstandingPrincipal||0)} at ${Math.round(100*Number(i.creditLine?.interestRate||0))}% interest.`:"No active credit repayment is currently due.",Number(i.creditLine?.amountDue||0)>0?"warning":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Credit"),e("h2",{className:"bank-section-title"},"Repay Org Credit"))),e("div",{className:"bank-form-stack"},e("p",{className:"bank-card-copy"},Number(i.creditLine?.amountDue||0)>0?`Outstanding due ${r(i.creditLine.amountDue||0)}. Available reserved credit ${r(i.creditLine.availableAmount||0)}.`:"No repayment is currently due on the assigned organization credit line."),e("input",{id:"bank-credit-line-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter repayment amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("repaycreditline")||Number(i.creditLine?.amountDue||0)<=0,onClick:()=>{a.requestRepayCreditLine(l("bank-credit-line-amount"))&&o("bank-credit-line-amount")}},u("repaycreditline")?"Posting Repayment...":"Repay Credit Line"))))},n.componentFns.BankSupportSection=function(){return m(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return m(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),d())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let d="Terminal Access",m="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":d="ATM Menu",m="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":d="Withdraw Cash",m="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":d="Deposit Cash",m="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":d="Custom Withdraw",m="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":d="Custom Deposit",m="Enter the exact deposit amount.",b=u("deposit");break;case"balance":d="Available Balance",m="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},d)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},m),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/bank/ui/src/bridge.js b/arma/client/addons/bank/ui/src/bridge.js index 425cfd6..fab579f 100644 --- a/arma/client/addons/bank/ui/src/bridge.js +++ b/arma/client/addons/bank/ui/src/bridge.js @@ -43,6 +43,9 @@ requestDepositEarnings(payload) { return bridge.send("bank::depositEarnings::request", payload); }, + requestRepayCreditLine(payload) { + return bridge.send("bank::repayCreditLine::request", payload); + }, requestRefresh() { return bridge.send("bank::refresh", {}); }, diff --git a/arma/client/addons/bank/ui/src/data.js b/arma/client/addons/bank/ui/src/data.js index 50e0df3..5398487 100644 --- a/arma/client/addons/bank/ui/src/data.js +++ b/arma/client/addons/bank/ui/src/data.js @@ -3,6 +3,13 @@ const defaultSession = { atmAuthorized: false, + creditLine: { + amountDue: 0, + approvedAmount: 0, + availableAmount: 0, + interestRate: 0.1, + outstandingPrincipal: 0, + }, mode: "bank", orgFunds: 0, orgName: "", diff --git a/arma/client/addons/bank/ui/src/pages/BankView.js b/arma/client/addons/bank/ui/src/pages/BankView.js index e3f8f0a..bdbdc87 100644 --- a/arma/client/addons/bank/ui/src/pages/BankView.js +++ b/arma/client/addons/bank/ui/src/pages/BankView.js @@ -89,6 +89,16 @@ "Reference value pulled from the organization treasury.", session.orgFunds > 0 ? "success" : "", ), + metricCard( + "Credit Due", + formatCurrency(session.creditLine?.amountDue || 0), + Number(session.creditLine?.amountDue || 0) > 0 + ? `Outstanding principal ${formatCurrency(session.creditLine?.outstandingPrincipal || 0)} at ${Math.round(Number(session.creditLine?.interestRate || 0) * 100)}% interest.` + : "No active credit repayment is currently due.", + Number(session.creditLine?.amountDue || 0) > 0 + ? "warning" + : "", + ), ), ); } @@ -238,6 +248,63 @@ ), ), ), + h( + "section", + { className: "bank-page-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Credit"), + h( + "h2", + { className: "bank-section-title" }, + "Repay Org Credit", + ), + ), + ), + h( + "div", + { className: "bank-form-stack" }, + h( + "p", + { className: "bank-card-copy" }, + Number(session.creditLine?.amountDue || 0) > 0 + ? `Outstanding due ${formatCurrency(session.creditLine.amountDue || 0)}. Available reserved credit ${formatCurrency(session.creditLine.availableAmount || 0)}.` + : "No repayment is currently due on the assigned organization credit line.", + ), + h("input", { + id: "bank-credit-line-amount", + className: "bank-input", + type: "number", + min: "1", + placeholder: "Enter repayment amount", + }), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + disabled: + pending("repaycreditline") || + Number(session.creditLine?.amountDue || 0) <= 0, + onClick: () => { + const sent = actions.requestRepayCreditLine( + readInputValue("bank-credit-line-amount"), + ); + if (sent) { + clearInputValue("bank-credit-line-amount"); + } + }, + }, + pending("repaycreditline") + ? "Posting Repayment..." + : "Repay Credit Line", + ), + ), + ), ); } diff --git a/arma/client/addons/bank/ui/src/registry/events.js b/arma/client/addons/bank/ui/src/registry/events.js index 70c5413..7ccde92 100644 --- a/arma/client/addons/bank/ui/src/registry/events.js +++ b/arma/client/addons/bank/ui/src/registry/events.js @@ -130,6 +130,25 @@ return true; } + function requestRepayCreditLine(amountValue) { + const amount = normalizeAmount(amountValue); + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestRepayCreditLine !== "function") { + showNotice("error", "Credit repayment bridge is unavailable."); + return false; + } + + store.startAction("repaycreditline"); + const sent = bridge.requestRepayCreditLine({ amount }); + if (!sent) { + store.finishAction(); + showNotice("error", "Credit repayment bridge is unavailable."); + return false; + } + + return true; + } + function appendPinDigit(digit) { const nextDigit = String(digit || "").trim(); if (!nextDigit) { @@ -259,6 +278,7 @@ requestAtmAmount, requestDeposit, requestDepositEarnings, + requestRepayCreditLine, requestTransfer, requestWithdraw, selectAtmView, diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js index 58c7569..38b3707 100644 --- a/arma/client/addons/org/ui/_site/org-ui.js +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -1 +1 @@ -!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.amount||0,member:e.memberName||"",uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount"),n("strong",null,i.formatCurrency(e.amount)))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,w),n("span",null,t.length>0?"Open the Credit Lines tab to review assigned members and amounts.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file +!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.availableAmount||e.amount||0,amountDue:e.amountDue||0,approvedAmount:e.approvedAmount||e.availableAmount||e.amount||0,availableAmount:e.availableAmount||e.amount||0,interestRate:e.interestRate||.1,member:e.memberName||"",outstandingPrincipal:e.outstandingPrincipal||0,uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=t.reduce((e,n)=>e+Number(n.availableAmount||n.amount||0),0),h=t.reduce((e,n)=>e+Number(n.amountDue||0),0),y=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Available"),n("strong",null,i.formatCurrency(e.availableAmount||e.amount))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount Due"),n("strong",null,i.formatCurrency(e.amountDue))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Interest"),n("strong",null,`${Math.round(100*Number(e.interestRate||0))}%`))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`)),n("div",null,n("span",{className:"org-meta-label"},"Reserved Credit"),n("strong",null,i.formatCurrency(w))),n("div",null,n("span",{className:"org-meta-label"},"Outstanding Due"),n("strong",null,i.formatCurrency(h)))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,y),n("span",null,t.length>0?"Open the Credit Lines tab to review reserved balances, due amounts, and member exposure.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/org/ui/src/bridge.js b/arma/client/addons/org/ui/src/bridge.js index cfa10ae..d5fab25 100644 --- a/arma/client/addons/org/ui/src/bridge.js +++ b/arma/client/addons/org/ui/src/bridge.js @@ -136,8 +136,18 @@ OrgPortal.store.setCreditLines((currentLines) => { const nextLine = { - amount: payloadData.amount || 0, + amount: payloadData.availableAmount || payloadData.amount || 0, + amountDue: payloadData.amountDue || 0, + approvedAmount: + payloadData.approvedAmount || + payloadData.availableAmount || + payloadData.amount || + 0, + availableAmount: + payloadData.availableAmount || payloadData.amount || 0, + interestRate: payloadData.interestRate || 0.1, member: payloadData.memberName || "", + outstandingPrincipal: payloadData.outstandingPrincipal || 0, uid: payloadData.memberUid || "", }; const matchIndex = currentLines.findIndex( diff --git a/arma/client/addons/org/ui/src/components/portal/treasuryCard.js b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js index 0de5144..6b95c49 100644 --- a/arma/client/addons/org/ui/src/components/portal/treasuryCard.js +++ b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js @@ -215,6 +215,15 @@ ${scopeSelector} .org-credit-line-empty { const allowTreasuryActions = getters.canManageTreasury(); const activeTab = getTreasuryTab(); const isMenuOpen = getTreasuryMenuOpen(); + const totalReserved = creditLines.reduce( + (sum, line) => + sum + Number(line.availableAmount || line.amount || 0), + 0, + ); + const totalDue = creditLines.reduce( + (sum, line) => sum + Number(line.amountDue || 0), + 0, + ); const activeCreditLabel = creditLines.length === 1 ? "1 active credit line" @@ -331,16 +340,59 @@ ${scopeSelector} .org-credit-line-empty { className: "org-credit-line-label", }, - "Amount", + "Available", ), h( "strong", null, getters.formatCurrency( - line.amount, + line.availableAmount || + line.amount, ), ), ), + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Amount Due", + ), + h( + "strong", + null, + getters.formatCurrency( + line.amountDue, + ), + ), + ), + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Interest", + ), + h( + "strong", + null, + `${Math.round(Number(line.interestRate || 0) * 100)}%`, + ), + ), ), ), ) @@ -379,6 +431,34 @@ ${scopeSelector} .org-credit-line-empty { ), h("strong", null, `${reputation}`), ), + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Reserved Credit", + ), + h( + "strong", + null, + getters.formatCurrency(totalReserved), + ), + ), + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Outstanding Due", + ), + h( + "strong", + null, + getters.formatCurrency(totalDue), + ), + ), ), allowTreasuryActions ? h( @@ -432,7 +512,7 @@ ${scopeSelector} .org-credit-line-empty { "span", null, creditLines.length > 0 - ? "Open the Credit Lines tab to review assigned members and amounts." + ? "Open the Credit Lines tab to review reserved balances, due amounts, and member exposure." : "Assign a credit line to create the first approved member limit.", ), ), diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index c29e294..33960da 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -49,3 +49,9 @@ PREP_RECOMPILE_END; GVAR(BankStore) call ["depositEarnings", [_uid, _amount]]; }] call CFUNC(addEventHandler); + +[QGVAR(requestRepayCreditLine), { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + GVAR(BankStore) call ["repayCreditLine", [_uid, _amount]]; +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf index 517bff3..6cd9bcd 100644 --- a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf @@ -48,7 +48,18 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[ ["resolveOrgState", compileFinal { params [["_uid", "", [""]]]; - private _defaultState = createHashMapFromArray [["funds", 0], ["name", ""]]; + private _defaultCreditLine = createHashMapFromArray [ + ["approvedAmount", 0], + ["availableAmount", 0], + ["outstandingPrincipal", 0], + ["interestRate", 0.1], + ["amountDue", 0] + ]; + private _defaultState = createHashMapFromArray [ + ["funds", 0], + ["name", ""], + ["creditLine", _defaultCreditLine] + ]; if (_uid isEqualTo "") exitWith { _defaultState }; private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; @@ -61,7 +72,33 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[ }; if (_org isEqualTo createHashMap) exitWith { _defaultState }; - createHashMapFromArray [["funds", _org getOrDefault ["funds", 0]], ["name", _org getOrDefault ["name", ""]]] + private _creditLines = _org getOrDefault ["credit_lines", createHashMap]; + if !(_creditLines isEqualType createHashMap) then { + _creditLines = createHashMap; + }; + + private _creditLine = _creditLines getOrDefault [_uid, createHashMap]; + if !(_creditLine isEqualType createHashMap) then { + _creditLine = createHashMap; + }; + + createHashMapFromArray [ + ["funds", _org getOrDefault ["funds", 0]], + ["name", _org getOrDefault ["name", ""]], + ["creditLine", createHashMapFromArray [ + ["approvedAmount", _creditLine getOrDefault [ + "approved_amount", + _creditLine getOrDefault ["amount", 0] + ]], + ["availableAmount", _creditLine getOrDefault [ + "available_amount", + _creditLine getOrDefault ["amount", 0] + ]], + ["outstandingPrincipal", _creditLine getOrDefault ["outstanding_principal", 0]], + ["interestRate", _creditLine getOrDefault ["interest_rate", 0.1]], + ["amountDue", _creditLine getOrDefault ["amount_due", 0]] + ]] + ] }], ["buildTransferTargets", compileFinal { params [["_sourceUid", "", [""]]]; @@ -101,6 +138,7 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[ ["mode", _session getOrDefault ["mode", "bank"]], ["orgFunds", _orgState getOrDefault ["funds", 0]], ["orgName", _orgState getOrDefault ["name", ""]], + ["creditLine", _orgState getOrDefault ["creditLine", createHashMap]], ["playerName", _playerName], ["transferTargets", _self call ["buildTransferTargets", [_uid]]], ["uid", _uid] diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf index 4ad374c..507f236 100644 --- a/arma/server/addons/bank/functions/fnc_initStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -167,6 +167,83 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ _result set ["patch", _patch]; _result }], + ["repayCreditLine", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + if (_uid isEqualTo "" || { _amount <= 0 }) exitWith { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Enter a valid repayment amount."]]; + false + }; + + private _originalAccount = _self call ["loadHotBank", [_uid, false, ""]]; + if (_originalAccount isEqualTo createHashMap) then { + _originalAccount = _self call ["loadHotBank", [_uid, true, ""]]; + }; + if (_originalAccount isEqualTo createHashMap) exitWith { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank account could not be loaded."]]; + false + }; + + private _checkoutContext = GVAR(BankPayloadBuilder) call ["buildCheckoutContext", ["bank", false]]; + private _previewEnvelope = _self call [ + "callHotBankEnvelope", + [ + "bank:hot:charge_checkout", + [_uid, str _amount, toJSON _checkoutContext] + ] + ]; + private _previewResult = _previewEnvelope getOrDefault ["data", createHashMap]; + private _bankPatch = _self call ["finalizeMutation", [_uid, _previewResult, false]]; + if (_bankPatch isEqualTo createHashMap) exitWith { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _previewEnvelope getOrDefault ["error", "Credit repayment could not be funded from the bank account."]]]; + false + }; + + private _nextAccount = _previewResult getOrDefault ["account", createHashMap]; + if (_nextAccount isEqualTo createHashMap) exitWith { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", "Bank repayment preview returned an invalid account state."]]; + false + }; + + private _overrideEnvelope = _self call [ + "callHotBankEnvelope", + ["bank:hot:override", [_uid, _self call ["toJSON", [_nextAccount]]]] + ]; + if ((_overrideEnvelope getOrDefault ["data", createHashMap]) isEqualTo createHashMap) exitWith { + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _overrideEnvelope getOrDefault ["error", "Credit repayment could not reserve bank funds."]]]; + false + }; + + private _orgResult = EGVAR(org,OrgStore) call ["repayCreditLine", [_uid, _amount]]; + if !(_orgResult getOrDefault ["success", false]) exitWith { + private _rollbackEnvelope = _self call [ + "callHotBankEnvelope", + ["bank:hot:override", [_uid, _self call ["toJSON", [_originalAccount]]]] + ]; + if ((_rollbackEnvelope getOrDefault ["data", createHashMap]) isEqualTo createHashMap) then { + ["ERROR", format ["Failed to roll back bank state for %1 after org credit repayment failure.", _uid]] call EFUNC(common,log); + }; + + GVAR(BankMessenger) call ["sendAlert", [_uid, "error", _orgResult getOrDefault ["message", "Credit repayment failed."]]]; + false + }; + + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _bankPatch]]; + GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", _orgResult getOrDefault ["message", format ["Repaid $%1 toward the organization credit line.", [_amount] call EFUNC(common,formatNumber)]]]]; + + private _orgPatch = _orgResult getOrDefault ["patch", createHashMap]; + if (_orgPatch isNotEqualTo createHashMap) then { + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [_orgPatch], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach (_orgResult getOrDefault ["memberUids", []]); + }; + + _self call ["hydrateSession", [_uid, "", false]]; + true + }], ["deposit", compileFinal { params [["_uid", "", [""]], ["_amount", 0, [0]]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index cf7ad81..230564e 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -83,6 +83,34 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ _org set ["assets", _migratedAssets]; + private _creditLines = _org getOrDefault ["credit_lines", createHashMap]; + if !(_creditLines isEqualType createHashMap) then { + _creditLines = createHashMap; + }; + + { + if !(_y isEqualType createHashMap) then { continue; }; + + private _line = +_y; + private _legacyAmount = _line getOrDefault ["amount", 0]; + private _approvedAmount = _line getOrDefault ["approved_amount", _legacyAmount]; + private _availableAmount = _line getOrDefault ["available_amount", _approvedAmount]; + private _outstandingPrincipal = _line getOrDefault ["outstanding_principal", 0]; + private _interestRate = _line getOrDefault ["interest_rate", 0.1]; + private _amountDue = _line getOrDefault ["amount_due", 0]; + + _line set ["uid", _line getOrDefault ["uid", _x]]; + _line set ["approved_amount", _approvedAmount]; + _line set ["available_amount", _availableAmount]; + _line set ["outstanding_principal", _outstandingPrincipal]; + _line set ["interest_rate", _interestRate]; + _line set ["amount_due", _amountDue]; + _line set ["amount", _availableAmount]; + _creditLines set [_x, _line]; + } forEach _creditLines; + + _org set ["credit_lines", _creditLines]; + _org }], ["validate", compileFinal { @@ -483,6 +511,43 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; _result }], + ["repayCreditLine", compileFinal { + params [["_requesterUid", "", [""]], ["_amount", 0, [0]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if (_requesterUid isEqualTo "" || { _amount <= 0 }) exitWith { + _result set ["message", "A valid repayment amount is required."]; + _result + }; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["orgId", _orgID], + ["amount", _amount] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:repay_credit_line", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to apply credit repayment."]; + _result + }; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Credit repayment posted."]]; + _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; + _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; + _result + }], ["buildPortalPayload", compileFinal { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf index 2a049f1..19fe960 100644 --- a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf @@ -134,10 +134,19 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[ private _creditLinesList = []; { private _creditLineData = _y; + private _availableAmount = _creditLineData getOrDefault [ + "available_amount", + _creditLineData getOrDefault ["amount", 0] + ]; _creditLinesList pushBack [ ["uid", _creditLineData getOrDefault ["uid", _x]], ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], - ["amount", _creditLineData getOrDefault ["amount", 0]] + ["approvedAmount", _creditLineData getOrDefault ["approved_amount", _availableAmount]], + ["availableAmount", _availableAmount], + ["outstandingPrincipal", _creditLineData getOrDefault ["outstanding_principal", 0]], + ["interestRate", _creditLineData getOrDefault ["interest_rate", 0.1]], + ["amountDue", _creditLineData getOrDefault ["amount_due", 0]], + ["amount", _availableAmount] ]; } forEach _creditLinesRaw; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index 8a06965..61fb751 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -29,6 +29,7 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ private _budget = 50000; private _creditLine = 0; + private _creditLineDue = 0; private _cashBalance = 0; private _bankBalance = 0; private _orgFunds = 0; @@ -72,7 +73,11 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ if (_orgCreditLines isEqualType createHashMap) then { private _playerCreditLine = _orgCreditLines getOrDefault [_uid, createHashMap]; if (_playerCreditLine isEqualType createHashMap) then { - _creditLine = _playerCreditLine getOrDefault ["amount", 0]; + _creditLine = _playerCreditLine getOrDefault [ + "available_amount", + _playerCreditLine getOrDefault ["amount", 0] + ]; + _creditLineDue = _playerCreditLine getOrDefault ["amount_due", 0]; }; }; @@ -113,7 +118,10 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ ["enabled", _creditLine > 0], ["detail", [ "No approved credit line is assigned to this member.", - "Use the approved procurement credit line." + format [ + "Use the approved procurement credit line. Outstanding due: $%1.", + [_creditLineDue] call EFUNC(common,formatNumber) + ] ] select (_creditLine > 0)] ] ]; diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index 77f0c10..e0456ad 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -5,7 +5,8 @@ use arma_rs::Group; use forge_models::{ - HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgDisbandResult, + HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, + OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandResult, OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgLeaveResult, OrgRegisterContext, }; @@ -59,6 +60,7 @@ pub fn group() -> Group { .command("ensure_member", ensure_hot_org_member) .command("register", register_hot_org) .command("assign_credit_line", assign_credit_line_hot_org) + .command("repay_credit_line", repay_credit_line_hot_org) .command("charge_checkout", charge_checkout_hot_org) .command("add_assets", add_assets_hot_org) .command("add_fleet", add_fleet_hot_org) @@ -176,6 +178,20 @@ pub(crate) fn charge_checkout_hot_org(json_data: String) -> String { } } +pub(crate) fn repay_credit_line_hot_org(json_data: String) -> String { + let context: OrgCreditLineRepaymentContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org credit repayment JSON: {}", error), + }; + + match HOT_ORG_SERVICE.repay_credit_line(context) { + Ok(result) => { + serialize_result::(&result, "org credit repayment result") + } + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn add_assets_hot_org(context_json: String, assets_json: String) -> String { let context: OrgGrantContext = match serde_json::from_str(&context_json) { Ok(data) => data, diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index b1af67d..0232ba4 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -363,6 +363,10 @@ fn route_command( expect_arg_count(function_name, &arguments, 1)?; Ok(org::assign_credit_line_hot_org(arguments[0].clone())) } + "org:hot:repay_credit_line" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::repay_credit_line_hot_org(arguments[0].clone())) + } "org:hot:charge_checkout" => { expect_arg_count(function_name, &arguments, 1)?; Ok(org::charge_checkout_hot_org(arguments[0].clone())) diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 53d7179..243a5a5 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -23,10 +23,11 @@ pub use cad::{ pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; pub use org::{ - CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, - OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, - OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, - OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, + CreditLineSummary, DEFAULT_CREDIT_LINE_INTEREST_RATE, HotOrgRecord, MemberSummary, Org, + OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, + OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult, + OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, + OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, }; pub use store::{ StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index c2c026e..bda9235 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -3,13 +3,34 @@ use forge_shared::OrgValidationError; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +pub const DEFAULT_CREDIT_LINE_INTEREST_RATE: f64 = 0.10; + +fn round_currency(value: f64) -> f64 { + (value.max(0.0) * 100.0).round() / 100.0 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreditLineSummary { pub uid: String, pub name: String, + #[serde(default)] + pub approved_amount: f64, + #[serde(default)] + pub available_amount: f64, + #[serde(default)] + pub outstanding_principal: f64, + #[serde(default = "default_credit_line_interest_rate")] + pub interest_rate: f64, + #[serde(default)] + pub amount_due: f64, + #[serde(default)] pub amount: f64, } +fn default_credit_line_interest_rate() -> f64 { + DEFAULT_CREDIT_LINE_INTEREST_RATE +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OrgAssetEntry { pub classname: String, @@ -113,6 +134,14 @@ pub struct OrgCheckoutContext { pub commit: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgCreditLineRepaymentContext { + pub requester_uid: String, + pub org_id: String, + pub amount: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrgAssetGrantSeed { @@ -145,6 +174,19 @@ pub struct OrgMutationResult { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgCreditLineRepaymentResult { + pub org: HotOrgRecord, + pub patch: HashMap, + pub member_uids: Vec, + pub paid_amount: f64, + pub principal_paid: f64, + pub interest_paid: f64, + pub remaining_amount_due: f64, + pub message: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrgLeaveContext { @@ -241,7 +283,12 @@ impl Org { )); } - if credit_line.amount < 0.0 { + if credit_line.approved_amount < 0.0 + || credit_line.available_amount < 0.0 + || credit_line.outstanding_principal < 0.0 + || credit_line.amount_due < 0.0 + || credit_line.amount < 0.0 + { return Err(OrgValidationError::NegativeCreditLine( resolved_uid.to_string(), )); @@ -254,6 +301,12 @@ impl Org { pub fn id(&self) -> &str { &self.id } + + pub fn normalize_credit_lines(&mut self) { + for credit_line in self.credit_lines.values_mut() { + credit_line.normalize(); + } + } } impl HotOrgRecord { @@ -280,14 +333,47 @@ impl HotOrgRecord { } pub fn into_org(self) -> Org { - Org { + let mut org = Org { id: self.id, owner: self.owner, name: self.name, funds: self.funds, reputation: self.reputation, credit_lines: self.credit_lines, + }; + org.normalize_credit_lines(); + org + } +} + +impl CreditLineSummary { + pub fn normalize(&mut self) { + let legacy_amount = round_currency(self.amount); + + self.approved_amount = round_currency(self.approved_amount); + self.available_amount = round_currency(self.available_amount); + self.outstanding_principal = round_currency(self.outstanding_principal); + self.amount_due = round_currency(self.amount_due); + + if self.approved_amount <= 0.0 && self.available_amount <= 0.0 && legacy_amount > 0.0 { + self.approved_amount = legacy_amount; + self.available_amount = legacy_amount; + } else if self.approved_amount <= 0.0 && self.available_amount > 0.0 { + self.approved_amount = self.available_amount; + } else if self.available_amount <= 0.0 && self.approved_amount > 0.0 { + self.available_amount = self.approved_amount; } + + if self.interest_rate <= 0.0 { + self.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE; + } + + if self.amount_due <= 0.0 && self.outstanding_principal > 0.0 { + self.amount_due = + round_currency(self.outstanding_principal * (1.0 + self.interest_rate)); + } + + self.amount = self.available_amount; } } diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 590427a..96e00e1 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -6,10 +6,11 @@ //! For full documentation, architecture, and examples, see the [crate README](../README.md). use forge_models::{ - CreditLineSummary, HotOrgRecord, MemberSummary, Org, OrgAssetEntry, OrgAssetGrantSeed, - OrgCheckoutContext, OrgCreditLineContext, OrgDisbandMemberResult, OrgDisbandResult, - OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, - OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, + CreditLineSummary, DEFAULT_CREDIT_LINE_INTEREST_RATE, HotOrgRecord, MemberSummary, Org, + OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, + OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult, + OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, + OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, }; use forge_repositories::{OrgHotRepository, OrgRepository}; use serde_json::{Value, json}; @@ -58,7 +59,10 @@ impl OrgService { ); } - serde_json::from_value::(org_value).map_err(|e| format!("Invalid Org JSON: {}", e)) + let mut org = serde_json::from_value::(org_value) + .map_err(|e| format!("Invalid Org JSON: {}", e))?; + org.normalize_credit_lines(); + Ok(org) } /// Creates a new organization service with the provided repository. @@ -94,9 +98,12 @@ impl OrgService { } pub fn get_org(&self, key: String) -> Result { - self.repository + let mut org = self + .repository .get_by_id(&key)? - .ok_or_else(|| format!("Organization with ID '{}' not found", key)) + .ok_or_else(|| format!("Organization with ID '{}' not found", key))?; + org.normalize_credit_lines(); + Ok(org) } /// Updates an existing organization with new data from JSON. @@ -191,6 +198,7 @@ impl OrgService { } // Validate the updated organization before committing changes + updated_org.normalize_credit_lines(); updated_org .validate() .map_err(|e| format!("Validation failed: {}", e))?; @@ -532,23 +540,50 @@ impl OrgHotStateService { context.member_name }; - org.credit_lines.insert( - context.member_uid.clone(), - CreditLineSummary { + let mut credit_line = org + .credit_lines + .get(&context.member_uid) + .cloned() + .unwrap_or_else(|| CreditLineSummary { uid: context.member_uid.clone(), name: member_name.clone(), - amount: context.amount, - }, - ); + 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(); + + let next_reserved_amount = round_currency(context.amount); + let previous_reserved_amount = round_currency(credit_line.available_amount); + let treasury_delta = round_currency(next_reserved_amount - previous_reserved_amount); + if treasury_delta > 0.0 && org.funds < treasury_delta { + return Err("Organization funds cannot cover that credit assignment.".to_string()); + } + + org.funds = round_currency(org.funds - treasury_delta); + credit_line.uid = context.member_uid.clone(); + credit_line.name = member_name.clone(); + credit_line.approved_amount = next_reserved_amount; + credit_line.available_amount = next_reserved_amount; + credit_line.amount = next_reserved_amount; + if credit_line.interest_rate <= 0.0 { + credit_line.interest_rate = DEFAULT_CREDIT_LINE_INTEREST_RATE; + } + + org.credit_lines + .insert(context.member_uid.clone(), credit_line); self.repository.save(&org)?; Ok(OrgMutationResult { - patch: build_org_patch(&org, &["credit_lines"])?, + patch: build_org_patch(&org, &["funds", "credit_lines"])?, member_uids: resolve_member_uids(&org, Some(&context.requester_uid)), message: format!( - "Credit line of ${} assigned to {}.", - format_currency(context.amount), - member_name + "Credit line for {} set to ${}.", + member_name, + format_currency(next_reserved_amount) ), org, }) @@ -602,11 +637,22 @@ impl OrgHotStateService { "Assigned credit line cannot cover this checkout.".to_string() })?; - if credit_line.amount < context.amount { + credit_line.normalize(); + + if credit_line.available_amount < context.amount { return Err("Assigned credit line cannot cover this checkout.".to_string()); } - credit_line.amount -= context.amount; + let charged_amount = round_currency(context.amount); + credit_line.available_amount = + round_currency(credit_line.available_amount - charged_amount); + credit_line.approved_amount = credit_line.available_amount; + credit_line.outstanding_principal = + round_currency(credit_line.outstanding_principal + charged_amount); + credit_line.amount_due = round_currency( + credit_line.amount_due + (charged_amount * (1.0 + credit_line.interest_rate)), + ); + credit_line.amount = credit_line.available_amount; org.credit_lines .insert(context.requester_uid.clone(), credit_line); self.repository.save(&org)?; @@ -622,6 +668,81 @@ impl OrgHotStateService { } } + pub fn repay_credit_line( + &self, + context: OrgCreditLineRepaymentContext, + ) -> Result { + if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() { + return Err("A valid requester and organization are required.".to_string()); + } + if context.amount <= 0.0 { + return Err("Repayment amount must be greater than zero.".to_string()); + } + + let mut org = self.get_org(context.org_id)?; + let member_uids = resolve_member_uids(&org, Some(&context.requester_uid)); + let mut credit_line = org + .credit_lines + .get(&context.requester_uid) + .cloned() + .ok_or_else(|| "No active credit line is assigned to this member.".to_string())?; + credit_line.normalize(); + + if credit_line.amount_due <= 0.0 { + return Err("This credit line has no outstanding balance.".to_string()); + } + + let paid_amount = round_currency(context.amount.min(credit_line.amount_due)); + let principal_paid = if paid_amount >= credit_line.amount_due { + credit_line.outstanding_principal + } else { + round_currency( + paid_amount * (credit_line.outstanding_principal / credit_line.amount_due), + ) + .min(credit_line.outstanding_principal) + .min(paid_amount) + }; + let interest_paid = round_currency(paid_amount - principal_paid); + + credit_line.outstanding_principal = + round_currency(credit_line.outstanding_principal - principal_paid); + credit_line.amount_due = round_currency(credit_line.amount_due - paid_amount); + if credit_line.outstanding_principal <= 0.0 { + credit_line.outstanding_principal = 0.0; + } + if credit_line.amount_due <= 0.0 { + credit_line.amount_due = 0.0; + } + credit_line.amount = credit_line.available_amount; + + org.funds = round_currency(org.funds + paid_amount); + org.credit_lines + .insert(context.requester_uid.clone(), credit_line.clone()); + self.repository.save(&org)?; + + Ok(OrgCreditLineRepaymentResult { + patch: build_org_patch(&org, &["funds", "credit_lines"])?, + member_uids, + paid_amount, + principal_paid, + interest_paid, + remaining_amount_due: credit_line.amount_due, + message: if credit_line.amount_due > 0.0 { + format!( + "Credit repayment posted. ${} paid with ${} still due.", + format_currency(paid_amount), + format_currency(credit_line.amount_due) + ) + } else { + format!( + "Credit repayment posted. ${} cleared the outstanding balance.", + format_currency(paid_amount) + ) + }, + org, + }) + } + pub fn add_assets( &self, context: OrgGrantContext, @@ -891,7 +1012,7 @@ fn current_org_field_value(org: &HotOrgRecord, field: &str) -> Result String { - let rounded = amount.max(0.0).round() as i64; + let rounded = round_currency(amount).round() as i64; let digits = rounded.to_string(); let mut formatted = String::new(); @@ -904,3 +1025,7 @@ fn format_currency(amount: f64) -> String { formatted.chars().rev().collect() } + +fn round_currency(amount: f64) -> f64 { + (amount.max(0.0) * 100.0).round() / 100.0 +} diff --git a/lib/services/src/store.rs b/lib/services/src/store.rs index 643b3c2..b0e06e1 100644 --- a/lib/services/src/store.rs +++ b/lib/services/src/store.rs @@ -451,13 +451,23 @@ where org.credit_lines.get_mut(requester_uid).ok_or_else(|| { "Assigned credit line cannot cover this checkout.".to_string() })?; - if credit_line.amount < charged_total { + credit_line.normalize(); + if credit_line.available_amount < charged_total { return Err( "Assigned credit line cannot cover this checkout.".to_string() ); } - credit_line.amount -= charged_total; + credit_line.available_amount = + round_currency(credit_line.available_amount - charged_total); + credit_line.approved_amount = credit_line.available_amount; + credit_line.outstanding_principal = + round_currency(credit_line.outstanding_principal + charged_total); + credit_line.amount_due = round_currency( + credit_line.amount_due + + (charged_total * (1.0 + credit_line.interest_rate)), + ); + credit_line.amount = credit_line.available_amount; org_patch.insert("credit_lines".to_string(), json!(org.credit_lines)); } _ => unreachable!(), @@ -683,3 +693,7 @@ fn format_currency(amount: f64) -> String { format!("${}", formatted.chars().rev().collect::()) } + +fn round_currency(amount: f64) -> f64 { + (amount.max(0.0) * 100.0).round() / 100.0 +} From 1d54cc70c3fa25b67bfd59a5139325fe531ff148 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 5 Apr 2026 10:05:48 -0500 Subject: [PATCH 15/19] Move hot state into transient extension-backed stores - Remove in-SQF registry mirroring for actor, bank, CAD, org, and task state - Add validation harness and persistence warnings for hot-state flows - Treat CAD and task operational state as restart-scoped --- arma/server/addons/actor/XEH_preInit.sqf | 10 +- .../actor/functions/fnc_initActorStore.sqf | 150 ++++++------ .../bank/functions/fnc_initPayloadBuilder.sqf | 5 +- .../bank/functions/fnc_initSessionManager.sqf | 11 +- .../addons/bank/functions/fnc_initStore.sqf | 25 +- .../functions/fnc_initActivityRepository.sqf | 44 +--- .../fnc_initAssignmentRepository.sqf | 211 ++++++++++------- .../addons/cad/functions/fnc_initCadStore.sqf | 20 +- .../cad/functions/fnc_initGroupRepository.sqf | 49 +--- .../functions/fnc_initPermissionService.sqf | 5 +- .../functions/fnc_initPersistenceService.sqf | 4 + .../functions/fnc_initRequestRepository.sqf | 28 +-- .../functions/fnc_initFEconomyStore.sqf | 10 +- .../extension/functions/fnc_extCall.sqf | 1 + .../locker/functions/fnc_initVAStore.sqf | 4 +- arma/server/addons/main/XEH_PREP.hpp | 1 + arma/server/addons/main/XEH_preInit.sqf | 2 - .../addons/main/functions/fnc_initStores.sqf | 3 + .../functions/fnc_initValidationHarness.sqf | 213 ++++++++++++++++++ .../main/functions/fnc_saveHotState.sqf | 4 +- .../addons/org/functions/fnc_initOrgStore.sqf | 112 +++++---- .../org/functions/fnc_initPayloadBuilder.sqf | 6 +- .../store/functions/fnc_initStoreStore.sqf | 93 +++++++- arma/server/addons/task/README.md | 5 + .../task/functions/fnc_handleTaskRewards.sqf | 29 ++- .../addons/task/functions/fnc_handler.sqf | 9 +- .../task/functions/fnc_initTaskStore.sqf | 71 ++++-- .../task/functions/fnc_missionManager.sqf | 20 +- arma/server/extension/src/actor.rs | 11 + arma/server/extension/src/cad.rs | 4 + arma/server/extension/src/task.rs | 3 + arma/server/extension/src/transport.rs | 4 + lib/models/src/actor.rs | 4 +- lib/models/src/v_locker.rs | 2 +- lib/repositories/src/actor.rs | 8 + lib/services/README.md | 8 + lib/services/src/actor.rs | 16 +- 37 files changed, 814 insertions(+), 391 deletions(-) create mode 100644 arma/server/addons/main/functions/fnc_initValidationHarness.sqf diff --git a/arma/server/addons/actor/XEH_preInit.sqf b/arma/server/addons/actor/XEH_preInit.sqf index 46cf2f8..d47f97b 100644 --- a/arma/server/addons/actor/XEH_preInit.sqf +++ b/arma/server/addons/actor/XEH_preInit.sqf @@ -18,7 +18,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - private _finalData = GVAR(ActorStore) call ["get", [GVAR(Registry), _uid, _field]]; + private _finalData = GVAR(ActorStore) call ["get", [_uid, _field]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(actor,responseSyncActor), [_finalData], _player] call CFUNC(targetEvent); @@ -29,7 +29,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID or Key!" }; - private _hashMap = GVAR(ActorStore) call ["set", [GVAR(Registry), "actor:update", _uid, _field, _value, _sync]]; + private _hashMap = GVAR(ActorStore) call ["set", [_uid, _field, _value, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(actor,responseSyncActor), [_hashMap], _player] call CFUNC(targetEvent); @@ -41,7 +41,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid field pairs!" }; - private _hashMap = GVAR(ActorStore) call ["mset", [GVAR(Registry), "actor:update", _uid, _fieldValuePairs, _sync]]; + private _hashMap = GVAR(ActorStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(actor,responseSyncActor), [_hashMap], _player] call CFUNC(targetEvent); @@ -53,7 +53,7 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; GVAR(ActorStore) call ["snapshot", [_uid]]; - private _finalData = GVAR(ActorStore) call ["save", [GVAR(Registry), "actor:update", _uid]]; + private _finalData = GVAR(ActorStore) call ["save", [_uid]]; private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(actor,responseSyncActor), [_finalData], _player] call CFUNC(targetEvent); @@ -63,5 +63,5 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - GVAR(ActorStore) call ["remove", [GVAR(Registry), _uid]]; + GVAR(ActorStore) call ["remove", [_uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/actor/functions/fnc_initActorStore.sqf b/arma/server/addons/actor/functions/fnc_initActorStore.sqf index 5dbba52..7a83da8 100644 --- a/arma/server/addons/actor/functions/fnc_initActorStore.sqf +++ b/arma/server/addons/actor/functions/fnc_initActorStore.sqf @@ -9,8 +9,8 @@ * * Description: * Initializes the actor store for managing player actor data. - * Actor hot state is owned by the extension; SQF maintains a compatibility - * mirror for engine-adjacent consumers. + * Actor hot state is owned by the extension; SQF acts as a thin bridge for + * engine-adjacent reads, snapshots, and response fan-out. * * Arguments: * None @@ -38,7 +38,7 @@ GVAR(ActorModel) = compileFinal createHashMapObject [[ _actor set ["state", "HEALTHY"]; _actor set ["phone_number", ""]; _actor set ["email", ""]; - _actor set ["organization", ""]; + _actor set ["organization", "default"]; _actor set ["holster", true]; _actor @@ -109,7 +109,6 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "ActorBaseStore"], ["#create", compileFinal { - GVAR(Registry) = createHashMap; ["INFO", "Actor Store Initialized!"] call EFUNC(common,log); }], ["cacheActor", compileFinal { @@ -117,9 +116,7 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "" || { !(_actor isEqualType createHashMap) }) exitWith { createHashMap }; - private _finalActor = GVAR(ActorModel) call ["migrate", [+_actor]]; - GVAR(Registry) set [_uid, _finalActor]; - _finalActor + GVAR(ActorModel) call ["migrate", [+_actor]] }], ["callHotActor", compileFinal { params [["_function", "", [""]], ["_arguments", [], [[]]]]; @@ -138,6 +135,20 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ if !(_data isEqualType createHashMap) exitWith { createHashMap }; _data }], + ["listHotUids", compileFinal { + ["actor:hot:keys", []] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { [] }; + if !(_result isEqualType "") exitWith { [] }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Actor extension call '%1' failed: %2", "actor:hot:keys", _result]] call EFUNC(common,log); + [] + }; + + private _uids = fromJSON _result; + if !(_uids isEqualType []) exitWith { [] }; + + _uids select { _x isEqualType "" && { _x isNotEqualTo "" } } + }], ["loadHotActor", compileFinal { params [["_uid", "", [""]], ["_initialize", false, [false]]]; @@ -149,75 +160,10 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _self call ["cacheActor", [_uid, _actor]] }], - ["normalizeGetArgs", compileFinal { - params ["_rawArguments"]; - - if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { - [ - _rawArguments param [1, "", [""]], - _rawArguments param [2, "", [""]] - ] - }; - - [ - _rawArguments param [0, "", [""]], - _rawArguments param [1, "", [""]] - ] - }], - ["normalizeSetArgs", compileFinal { - params ["_rawArguments"]; - - if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { - [ - _rawArguments param [2, "", [""]], - _rawArguments param [3, "", [""]], - _rawArguments param [4, nil, [0, "", [], false, createHashMap, objNull, grpNull]], - _rawArguments param [5, false, [false]] - ] - }; - - [ - _rawArguments param [0, "", [""]], - _rawArguments param [1, "", [""]], - _rawArguments param [2, nil, [0, "", [], false, createHashMap, objNull, grpNull]], - _rawArguments param [3, false, [false]] - ] - }], - ["normalizeMSetArgs", compileFinal { - params ["_rawArguments"]; - - if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { - [ - _rawArguments param [2, "", [""]], - _rawArguments param [3, createHashMap, [createHashMap]], - _rawArguments param [4, false, [false]] - ] - }; - - [ - _rawArguments param [0, "", [""]], - _rawArguments param [1, createHashMap, [createHashMap]], - _rawArguments param [2, false, [false]] - ] - }], - ["normalizeUidArg", compileFinal { - params ["_rawArguments"]; - - if ((_rawArguments param [0, createHashMap]) isEqualType createHashMap) exitWith { - _rawArguments param [1, "", [""]] - }; - - _rawArguments param [0, "", [""]] - }], ["init", compileFinal { params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _cached = GVAR(Registry) getOrDefault [_uid, nil]; - if !(isNil { _cached }) exitWith { - [CRPC(actor,responseInitActor), [_cached], _player] call CFUNC(targetEvent); - _cached - }; ["actor:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; if !(_isSuccess) exitWith { @@ -264,7 +210,7 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _finalActor }], ["get", compileFinal { - call (_self get "normalizeGetArgs") params ["_uid", "_field"]; + params [["_uid", "", [""]], ["_field", "", [""]]]; private _actor = _self call ["loadHotActor", [_uid, false]]; if (_actor isEqualTo createHashMap) then { @@ -274,6 +220,50 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ if (_field isEqualTo "") exitWith { _actor }; _actor getOrDefault [_field, nil] }], + ["load", compileFinal { + params [["_uid", "", [""]]]; + + private _actor = _self call ["get", [_uid, ""]]; + if !(_actor isEqualType createHashMap) exitWith { createHashMap }; + + _actor + }], + ["getFieldOrDefault", compileFinal { + params [["_uid", "", [""]], ["_field", "", [""]], ["_default", nil]]; + + if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { _default }; + + private _actor = _self call ["load", [_uid]]; + if !(_actor isEqualType createHashMap) exitWith { _default }; + if (_actor isEqualTo createHashMap) exitWith { _default }; + + _actor getOrDefault [_field, _default] + }], + ["getOrganization", compileFinal { + params [["_uid", "", [""]], ["_default", "default", [""]]]; + + private _orgID = _self call ["getFieldOrDefault", [_uid, "organization", _default]]; + if !(_orgID isEqualType "") exitWith { _default }; + if (_orgID isEqualTo "") exitWith { _default }; + + _orgID + }], + ["getName", compileFinal { + params [["_uid", "", [""]], ["_default", "", [""]]]; + + private _name = _self call ["getFieldOrDefault", [_uid, "name", _default]]; + if !(_name isEqualType "") exitWith { _default }; + + _name + }], + ["getPhoneNumber", compileFinal { + params [["_uid", "", [""]], ["_default", "", [""]]]; + + private _phoneNumber = _self call ["getFieldOrDefault", [_uid, "phone_number", _default]]; + if !(_phoneNumber isEqualType "") exitWith { _default }; + + _phoneNumber + }], ["override", compileFinal { params [ ["_uid", "", [""]], @@ -297,7 +287,12 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _self call ["cacheActor", [_uid, _actor]] }], ["set", compileFinal { - call (_self get "normalizeSetArgs") params ["_uid", "_field", "_value", "_sync"]; + params [ + ["_uid", "", [""]], + ["_field", "", [""]], + ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], + ["_sync", false, [false]] + ]; if (_uid isEqualTo "" || { _field isEqualTo "" }) exitWith { createHashMap }; @@ -312,7 +307,7 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ createHashMapFromArray [[_field, _updatedActor getOrDefault [_field, _value]]] }], ["mset", compileFinal { - call (_self get "normalizeMSetArgs") params ["_uid", "_fieldValuePairs", "_sync"]; + params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; if (_uid isEqualTo "" || { !(_fieldValuePairs isEqualType createHashMap) }) exitWith { createHashMap }; @@ -327,7 +322,7 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ +_fieldValuePairs }], ["save", compileFinal { - private _uid = call (_self get "normalizeUidArg"); + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { createHashMap }; private _actor = _self call ["callHotActor", ["actor:hot:save", [_uid]]]; @@ -336,11 +331,10 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _self call ["cacheActor", [_uid, _actor]] }], ["remove", compileFinal { - private _uid = call (_self get "normalizeUidArg"); + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { false }; - GVAR(Registry) deleteAt _uid; ["actor:hot:remove", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; _isSuccess && { _result isEqualTo "OK" } }], diff --git a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf index 6cd9bcd..24f8615 100644 --- a/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf @@ -62,10 +62,7 @@ GVAR(BankPayloadBuilder) = createHashMapObject [[ ]; if (_uid isEqualTo "") exitWith { _defaultState }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; if (_org isEqualTo createHashMap) then { _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; diff --git a/arma/server/addons/bank/functions/fnc_initSessionManager.sqf b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf index dc9077e..7d4774b 100644 --- a/arma/server/addons/bank/functions/fnc_initSessionManager.sqf +++ b/arma/server/addons/bank/functions/fnc_initSessionManager.sqf @@ -24,16 +24,20 @@ #pragma hemtt ignore_variables ["_self"] GVAR(BankSessionManager) = createHashMapObject [[ ["#type", "BankSessionManager"], + ["#create", compileFinal { + _self set ["sessions", createHashMap]; + }], ["getSessionState", compileFinal { params [["_uid", "", [""]]]; - private _session = GVAR(SessionRegistry) getOrDefault [_uid, createHashMap]; + private _sessions = _self getOrDefault ["sessions", createHashMap]; + private _session = _sessions getOrDefault [_uid, createHashMap]; if (_session isEqualTo createHashMap) then { _session = createHashMapFromArray [ ["atmAuthorized", false], ["mode", "bank"] ]; - GVAR(SessionRegistry) set [_uid, _session]; + _sessions set [_uid, _session]; }; _session @@ -44,9 +48,10 @@ GVAR(BankSessionManager) = createHashMapObject [[ if (_uid isEqualTo "") exitWith { createHashMap }; private _session = +(_self call ["getSessionState", [_uid]]); + private _sessions = _self getOrDefault ["sessions", createHashMap]; { _session set [_x, _y]; } forEach _fieldValuePairs; - GVAR(SessionRegistry) set [_uid, _session]; + _sessions set [_uid, _session]; _session }], ["resolveMode", compileFinal { diff --git a/arma/server/addons/bank/functions/fnc_initStore.sqf b/arma/server/addons/bank/functions/fnc_initStore.sqf index 507f236..d04e37b 100644 --- a/arma/server/addons/bank/functions/fnc_initStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initStore.sqf @@ -18,7 +18,6 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], ["#type", "BankBaseStore"], ["#create", compileFinal { - GVAR(SessionRegistry) = createHashMap; ["INFO", "Bank Store Initialized!"] call EFUNC(common,log); }], ["normalizeAccount", compileFinal { @@ -228,6 +227,17 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ false }; + private _persistenceFailures = []; + private _savedBank = _self call ["save", [_uid]]; + if (_savedBank isEqualTo createHashMap) then { + _persistenceFailures pushBack "bank"; + }; + + private _orgPersistenceMessage = _orgResult getOrDefault ["persistenceMessage", ""]; + if !(_orgResult getOrDefault ["persisted", false]) then { + _persistenceFailures pushBack "organization"; + }; + GVAR(BankMessenger) call ["sendAccountSync", [_uid, _bankPatch]]; GVAR(BankMessenger) call ["sendNotification", [_uid, "info", "Bank", _orgResult getOrDefault ["message", format ["Repaid $%1 toward the organization credit line.", [_amount] call EFUNC(common,formatNumber)]]]]; @@ -241,6 +251,19 @@ GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ } forEach (_orgResult getOrDefault ["memberUids", []]); }; + if (_persistenceFailures isNotEqualTo []) then { + private _warning = format [ + "Credit repayment posted, but durable save failed for: %1.", + _persistenceFailures joinString ", " + ]; + if (_orgPersistenceMessage isNotEqualTo "") then { + _warning = format ["%1 %2", _warning, _orgPersistenceMessage]; + }; + + ["ERROR", format ["Credit repayment for %1 completed with persistence failures: %2", _uid, _persistenceFailures joinString ", "]] call EFUNC(common,log); + GVAR(BankMessenger) call ["sendAlert", [_uid, "warning", _warning]]; + }; + _self call ["hydrateSession", [_uid, "", false]]; true }], diff --git a/arma/server/addons/cad/functions/fnc_initActivityRepository.sqf b/arma/server/addons/cad/functions/fnc_initActivityRepository.sqf index 3fab742..fa9b05b 100644 --- a/arma/server/addons/cad/functions/fnc_initActivityRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initActivityRepository.sqf @@ -22,48 +22,19 @@ #pragma hemtt ignore_variables ["_self"] GVAR(ActivityRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["#type", "CadActivityRepositoryBaseClass"], - ["#create", compileFinal { - _self set ["activityRegistry", []]; - _self set ["persistenceLoaded", false]; - }], - ["restorePersistedActivity", compileFinal { - if (_self getOrDefault ["persistenceLoaded", false]) exitWith { true }; - - private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; - if (_persistenceService isEqualTo createHashMap) exitWith { false }; - - private _result = _persistenceService call ["loadActivity", []]; - if !(_result getOrDefault ["success", false]) exitWith { false }; - - _self set ["activityRegistry", +(_result getOrDefault ["data", []])]; - _self set ["persistenceLoaded", true]; - true - }], ["appendEntry", compileFinal { params [["_entry", createHashMap, [createHashMap]]]; if (_entry isEqualTo createHashMap) exitWith { false }; - - _self call ["restorePersistedActivity", []]; - - private _activityRegistry = +(_self getOrDefault ["activityRegistry", []]); private _finalEntry = +_entry; if ((_finalEntry getOrDefault ["timestamp", -1]) < 0) then { _finalEntry set ["timestamp", serverTime]; }; - _activityRegistry pushBack _finalEntry; - - if ((count _activityRegistry) > 50) then { - _activityRegistry deleteRange [0, (count _activityRegistry) - 50]; - }; - - _self set ["activityRegistry", _activityRegistry]; private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; - if (_persistenceService isNotEqualTo createHashMap) then { - _persistenceService call ["appendActivity", [_finalEntry]]; - }; - true + if (_persistenceService isEqualTo createHashMap) exitWith { false }; + + _persistenceService call ["appendActivity", [_finalEntry]] }], ["appendActivity", compileFinal { params [ @@ -85,8 +56,13 @@ GVAR(ActivityRepositoryBaseClass) = compileFinal createHashMapFromArray [ _self call ["appendEntry", [_entry]] }], ["getActivity", compileFinal { - _self call ["restorePersistedActivity", []]; - +(_self getOrDefault ["activityRegistry", []]) + private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; + if (_persistenceService isEqualTo createHashMap) exitWith { [] }; + + private _result = _persistenceService call ["loadActivity", []]; + if !(_result getOrDefault ["success", false]) exitWith { [] }; + + +(_result getOrDefault ["data", []]) }] ]; diff --git a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf index cb3d5be..b8ab1ae 100644 --- a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf @@ -24,15 +24,51 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["#type", "CadAssignmentRepositoryBaseClass"], ["#create", compileFinal { - _self set ["assignmentRegistry", createHashMap]; - _self set ["dispatchOrderRegistry", createHashMap]; - _self set ["persistenceLoaded", false]; + _self set ["ownershipHydrated", false]; + }], + ["loadState", compileFinal { + private _result = createHashMapFromArray [ + ["success", false], + ["assignments", createHashMap], + ["dispatchOrders", createHashMap] + ]; + + private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; + if (_persistenceService isEqualTo createHashMap) exitWith { _result }; + + private _assignmentsResult = _persistenceService call ["loadAssignments", []]; + if !(_assignmentsResult getOrDefault ["success", false]) exitWith { _result }; + + private _ordersResult = _persistenceService call ["loadDispatchOrders", []]; + if !(_ordersResult getOrDefault ["success", false]) exitWith { _result }; + + private _assignmentRegistry = +(_assignmentsResult getOrDefault ["data", createHashMap]); + private _dispatchOrderRegistry = +(_ordersResult getOrDefault ["data", createHashMap]); + + if !(_self getOrDefault ["ownershipHydrated", false]) then { + { + if ((_y getOrDefault ["state", ""]) isNotEqualTo "acknowledged") then { continue; }; + if ((_y getOrDefault ["acknowledgedByUid", ""]) isEqualTo "") then { continue; }; + if ((_dispatchOrderRegistry getOrDefault [_x, createHashMap]) isNotEqualTo createHashMap) then { continue; }; + if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; + + EGVAR(task,TaskStore) call ["bindTaskOwnership", [_x, _y getOrDefault ["acknowledgedByUid", ""]]]; + } forEach _assignmentRegistry; + + _self set ["ownershipHydrated", true]; + }; + + _result set ["success", true]; + _result set ["assignments", _assignmentRegistry]; + _result set ["dispatchOrders", _dispatchOrderRegistry]; + _result }], ["pruneAssignments", compileFinal { - _self call ["restorePersistedState", []]; + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { 0 }; - private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; - private _dispatchOrderRegistry = _self getOrDefault ["dispatchOrderRegistry", createHashMap]; + private _assignmentRegistry = _state getOrDefault ["assignments", createHashMap]; + private _dispatchOrderRegistry = _state getOrDefault ["dispatchOrders", createHashMap]; private _keysToRemove = []; { @@ -46,12 +82,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ }; } forEach _assignmentRegistry; - { - _assignmentRegistry deleteAt _x; - } forEach _keysToRemove; - - _self set ["assignmentRegistry", _assignmentRegistry]; - private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; if (_persistenceService isNotEqualTo createHashMap) then { { @@ -62,50 +92,73 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ count _keysToRemove }], ["getAssignments", compileFinal { - _self call ["restorePersistedState", []]; - values (_self getOrDefault ["assignmentRegistry", createHashMap]) + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { [] }; + + values (_state getOrDefault ["assignments", createHashMap]) }], ["isDispatchOrder", compileFinal { params [["_taskID", "", [""]]]; if (_taskID isEqualTo "") exitWith { false }; - ((_self getOrDefault ["dispatchOrderRegistry", createHashMap]) getOrDefault [_taskID, createHashMap]) isNotEqualTo createHashMap + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { false }; + + ((_state getOrDefault ["dispatchOrders", createHashMap]) getOrDefault [_taskID, createHashMap]) isNotEqualTo createHashMap }], - ["restorePersistedState", compileFinal { - if (_self getOrDefault ["persistenceLoaded", false]) exitWith { true }; + ["getAssignmentByTaskId", compileFinal { + params [["_taskID", "", [""]]]; - private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; - if (_persistenceService isEqualTo createHashMap) exitWith { false }; + if (_taskID isEqualTo "") exitWith { createHashMap }; - private _assignmentsResult = _persistenceService call ["loadAssignments", []]; - if !(_assignmentsResult getOrDefault ["success", false]) exitWith { false }; + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { createHashMap }; - private _ordersResult = _persistenceService call ["loadDispatchOrders", []]; - if !(_ordersResult getOrDefault ["success", false]) exitWith { false }; + +((_state getOrDefault ["assignments", createHashMap]) getOrDefault [_taskID, createHashMap]) + }], + ["getDispatchOrderByTaskId", compileFinal { + params [["_taskID", "", [""]]]; - private _assignmentRegistry = +(_assignmentsResult getOrDefault ["data", createHashMap]); - private _dispatchOrderRegistry = +(_ordersResult getOrDefault ["data", createHashMap]); + if (_taskID isEqualTo "") exitWith { createHashMap }; - _self set ["assignmentRegistry", _assignmentRegistry]; - _self set ["dispatchOrderRegistry", _dispatchOrderRegistry]; - _self set ["persistenceLoaded", true]; + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { createHashMap }; + + +((_state getOrDefault ["dispatchOrders", createHashMap]) getOrDefault [_taskID, createHashMap]) + }], + ["getCurrentTaskIdForGroup", compileFinal { + params [["_groupID", "", [""]]]; + + if (_groupID isEqualTo "") exitWith { "" }; + + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { "" }; + + private _assignmentRegistry = _state getOrDefault ["assignments", createHashMap]; + private _dispatchOrderRegistry = _state getOrDefault ["dispatchOrders", createHashMap]; + private _taskID = ""; { - if ((_y getOrDefault ["state", ""]) isNotEqualTo "acknowledged") then { continue; }; - if (((_y getOrDefault ["acknowledgedByUid", ""]) isEqualTo "")) then { continue; }; - if ((_dispatchOrderRegistry getOrDefault [_x, createHashMap]) isNotEqualTo createHashMap) then { continue; }; - if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; - EGVAR(task,TaskStore) call ["bindTaskOwnership", [_x, _y getOrDefault ["acknowledgedByUid", ""]]]; + if ((_y getOrDefault ["groupId", ""]) isNotEqualTo _groupID) then { continue; }; + if !((_y getOrDefault ["state", ""]) in ["assigned", "acknowledged"]) then { continue; }; + + private _dispatchOrder = +(_dispatchOrderRegistry getOrDefault [_x, createHashMap]); + if (_dispatchOrder isEqualTo createHashMap) then { + if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; + _taskID = _x; + } else { + _taskID = _dispatchOrder getOrDefault ["title", _x]; + }; } forEach _assignmentRegistry; - true + _taskID }], ["buildDispatchOrderEntry", compileFinal { params [ ["_taskID", "", [""]], ["_order", createHashMap, [createHashMap]], - ["_assignmentRegistry", createHashMap, [createHashMap]], + ["_assignment", createHashMap, [createHashMap]], ["_groupRepository", createHashMap, [createHashMap]] ]; @@ -127,7 +180,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ }; }; - private _assignment = _assignmentRegistry getOrDefault [_taskID, createHashMap]; _entry set ["taskId", _taskID]; _entry set ["taskID", _taskID]; _entry set ["type", _entry getOrDefault ["type", "dispatch_order"]]; @@ -136,6 +188,23 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _entry set ["assignmentState", [_assignment getOrDefault ["state", ""], "unassigned"] select (_assignment isEqualTo createHashMap)]; _entry }], + ["buildDispatchOrderEntryForTask", compileFinal { + params [ + ["_taskID", "", [""]], + ["_groupRepository", createHashMap, [createHashMap]] + ]; + + if (_taskID isEqualTo "") exitWith { createHashMap }; + + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { createHashMap }; + + private _order = +((_state getOrDefault ["dispatchOrders", createHashMap]) getOrDefault [_taskID, createHashMap]); + if (_order isEqualTo createHashMap) exitWith { createHashMap }; + + private _assignment = +((_state getOrDefault ["assignments", createHashMap]) getOrDefault [_taskID, createHashMap]); + _self call ["buildDispatchOrderEntry", [_taskID, _order, _assignment, _groupRepository]] + }], ["assignTaskToGroup", compileFinal { params [ ["_requesterUid", "", [""]], @@ -150,16 +219,20 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["assignment", createHashMap] ]; - _self call ["restorePersistedState", []]; - private _permissionService = _self getOrDefault ["permissionService", createHashMap]; if !(_permissionService call ["canDispatch", [_requesterUid]]) exitWith { _result set ["message", "You are not authorized to assign contracts."]; _result }; - private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; - private _dispatchOrderRegistry = _self getOrDefault ["dispatchOrderRegistry", createHashMap]; + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { + _result set ["message", "CAD extension state is unavailable."]; + _result + }; + + private _assignmentRegistry = _state getOrDefault ["assignments", createHashMap]; + private _dispatchOrderRegistry = _state getOrDefault ["dispatchOrders", createHashMap]; private _isDispatchOrder = (_dispatchOrderRegistry getOrDefault [_taskID, createHashMap]) isNotEqualTo createHashMap; if (!_isDispatchOrder && { (EGVAR(task,TaskStore) call ["getTaskStatus", [_taskID]]) isNotEqualTo "active" }) exitWith { @@ -221,9 +294,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _result }; - _assignmentRegistry set [_taskID, _assignment]; - _self set ["assignmentRegistry", _assignmentRegistry]; - private _activityEntry = +(_assignData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { private _activityRepository = _self getOrDefault ["activityRepository", createHashMap]; @@ -235,6 +305,9 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _result set ["assignment", _assignment]; _result set ["leaderUid", _leaderUid]; _result set ["isDispatchOrder", _isDispatchOrder]; + if (_isDispatchOrder) then { + _result set ["order", +(_dispatchOrderRegistry getOrDefault [_taskID, createHashMap])]; + }; _result }], ["createDispatchOrder", compileFinal { @@ -254,8 +327,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["order", createHashMap] ]; - _self call ["restorePersistedState", []]; - private _permissionService = _self getOrDefault ["permissionService", createHashMap]; if !(_permissionService call ["canDispatch", [_requesterUid]]) exitWith { _result set ["message", "You are not authorized to create dispatch orders."]; @@ -337,14 +408,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _result }; - private _dispatchOrderRegistry = _self getOrDefault ["dispatchOrderRegistry", createHashMap]; - _dispatchOrderRegistry set [_taskID, _order]; - _self set ["dispatchOrderRegistry", _dispatchOrderRegistry]; - - private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; - _assignmentRegistry set [_taskID, _assignment]; - _self set ["assignmentRegistry", _assignmentRegistry]; - private _activityEntry = +(_createData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { private _activityRepository = _self getOrDefault ["activityRepository", createHashMap]; @@ -368,23 +431,25 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["assignment", createHashMap] ]; - _self call ["restorePersistedState", []]; - private _permissionService = _self getOrDefault ["permissionService", createHashMap]; if !(_permissionService call ["canDispatch", [_requesterUid]]) exitWith { _result set ["message", "You are not authorized to close dispatch orders."]; _result }; - private _dispatchOrderRegistry = _self getOrDefault ["dispatchOrderRegistry", createHashMap]; - private _order = +(_dispatchOrderRegistry getOrDefault [_taskID, createHashMap]); + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { + _result set ["message", "CAD extension state is unavailable."]; + _result + }; + + private _order = +((_state getOrDefault ["dispatchOrders", createHashMap]) getOrDefault [_taskID, createHashMap]); if (_order isEqualTo createHashMap) exitWith { _result set ["message", "Dispatch order could not be resolved."]; _result }; - private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; - private _assignment = +(_assignmentRegistry getOrDefault [_taskID, createHashMap]); + private _assignment = +((_state getOrDefault ["assignments", createHashMap]) getOrDefault [_taskID, createHashMap]); private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; if (_persistenceService isEqualTo createHashMap) exitWith { @@ -399,14 +464,8 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ }; private _closeData = +(_closeResult getOrDefault ["data", createHashMap]); - _order = +(_closeData getOrDefault ["order", _order]); _assignment = +(_closeData getOrDefault ["assignment", _assignment]); - _dispatchOrderRegistry deleteAt _taskID; - _self set ["dispatchOrderRegistry", _dispatchOrderRegistry]; - _assignmentRegistry deleteAt _taskID; - _self set ["assignmentRegistry", _assignmentRegistry]; - private _activityEntry = +(_closeData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { _activityEntry set ["actorUid", _requesterUid]; @@ -430,12 +489,14 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ ]; private _transition = _this param [2, "acknowledge", [""]]; + private _state = _self call ["loadState", []]; + if !(_state getOrDefault ["success", false]) exitWith { + _result set ["message", "CAD extension state is unavailable."]; + _result + }; - _self call ["restorePersistedState", []]; - - private _assignmentRegistry = _self getOrDefault ["assignmentRegistry", createHashMap]; - private _assignment = +(_assignmentRegistry getOrDefault [_taskID, createHashMap]); - private _isDispatchOrder = _self call ["isDispatchOrder", [_taskID]]; + private _assignment = +((_state getOrDefault ["assignments", createHashMap]) getOrDefault [_taskID, createHashMap]); + private _isDispatchOrder = ((_state getOrDefault ["dispatchOrders", createHashMap]) getOrDefault [_taskID, createHashMap]) isNotEqualTo createHashMap; if (_assignment isEqualTo createHashMap) exitWith { _result set ["message", "Task is not assigned."]; _result @@ -508,16 +569,6 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _result }; - switch (_transition) do { - case "decline": { - _assignmentRegistry deleteAt _taskID; - }; - default { - _assignmentRegistry set [_taskID, _assignment]; - }; - }; - _self set ["assignmentRegistry", _assignmentRegistry]; - private _activityEntry = +(_transitionData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { if (_isDispatchOrder) then { diff --git a/arma/server/addons/cad/functions/fnc_initCadStore.sqf b/arma/server/addons/cad/functions/fnc_initCadStore.sqf index 07db1f0..ad70578 100644 --- a/arma/server/addons/cad/functions/fnc_initCadStore.sqf +++ b/arma/server/addons/cad/functions/fnc_initCadStore.sqf @@ -10,6 +10,11 @@ * Initializes the CAD store as a coordinator over activity, group, * assignment, and permission domain objects. * + * CAD operational state is extension-backed but intentionally transient. + * Orders, requests, assignments, hydrate state, and recent activity are + * scoped to the active server/mission lifecycle and start fresh after a + * restart. + * * Arguments: * None * @@ -133,17 +138,13 @@ GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [ private _leaderUid = _result getOrDefault ["leaderUid", ""]; if (_leaderUid isEqualTo "") exitWith { false }; + private _assignmentRepository = _self get "AssignmentRepository"; private _message = if (_result getOrDefault ["isDispatchOrder", false]) then { private _order = _result getOrDefault ["order", createHashMap]; if (_order isEqualTo createHashMap) then { private _assignment = _result getOrDefault ["assignment", createHashMap]; private _taskID = _assignment getOrDefault ["taskId", ""]; - _order = (_self get "AssignmentRepository") call ["buildDispatchOrderEntry", [ - _taskID, - ((_self get "AssignmentRepository") getOrDefault ["dispatchOrderRegistry", createHashMap]) getOrDefault [_taskID, createHashMap], - (_self get "AssignmentRepository") getOrDefault ["assignmentRegistry", createHashMap], - _self get "GroupRepository" - ]]; + _order = _assignmentRepository call ["buildDispatchOrderEntryForTask", [_taskID, _self get "GroupRepository"]]; }; format ["Dispatch order assigned: %1. Open CAD to review and acknowledge.", _order getOrDefault ["title", "Dispatch Order"]] @@ -203,15 +204,10 @@ GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [ private _permissionService = _self get "PermissionService"; private _groupRepository = _self get "GroupRepository"; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - if (_actor isEqualTo createHashMap && { _uid isNotEqualTo "" }) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_uid]]; - }; - private _groupID = _groupRepository call ["getPlayerGroupId", [_uid]]; private _session = createHashMapFromArray [ ["uid", _uid], - ["orgId", _actor getOrDefault ["organization", "default"]], + ["orgId", EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]], ["isDispatcher", _permissionService call ["canDispatch", [_uid]]], ["groupId", _groupID], ["isLeader", _groupRepository call ["isGroupLeader", [_uid, _groupID]]] diff --git a/arma/server/addons/cad/functions/fnc_initGroupRepository.sqf b/arma/server/addons/cad/functions/fnc_initGroupRepository.sqf index 3a119a7..9d6f8dc 100644 --- a/arma/server/addons/cad/functions/fnc_initGroupRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initGroupRepository.sqf @@ -24,8 +24,6 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["#type", "CadGroupRepositoryBaseClass"], ["#create", compileFinal { - _self set ["groupRegistry", createHashMap]; - _self set ["groupProfileRegistry", createHashMap]; _self set ["validStatuses", [ "available", "en_route", @@ -63,31 +61,11 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ if (_groupID isEqualTo "") exitWith { "" }; private _assignmentRepository = _self getOrDefault ["assignmentRepository", createHashMap]; - private _assignmentRegistry = _assignmentRepository getOrDefault ["assignmentRegistry", createHashMap]; - private _dispatchOrderRegistry = _assignmentRepository getOrDefault ["dispatchOrderRegistry", createHashMap]; - private _taskID = ""; + if (_assignmentRepository isEqualTo createHashMap) exitWith { "" }; - { - if ((_y getOrDefault ["groupId", ""]) isNotEqualTo _groupID) then { continue; }; - if !((_y getOrDefault ["state", ""]) in ["assigned", "acknowledged"]) then { continue; }; - private _dispatchOrder = +(_dispatchOrderRegistry getOrDefault [_x, createHashMap]); - if (_dispatchOrder isEqualTo createHashMap) then { - if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; - _taskID = _x; - } else { - _taskID = _dispatchOrder getOrDefault ["title", _x]; - }; - - } forEach _assignmentRegistry; - - _taskID + _assignmentRepository call ["getCurrentTaskIdForGroup", [_groupID]] }], ["syncGroups", compileFinal { - private _assignmentRepository = _self getOrDefault ["assignmentRepository", createHashMap]; - if (_assignmentRepository isNotEqualTo createHashMap) then { - _assignmentRepository call ["restorePersistedState", []]; - }; - private _liveGroups = []; { @@ -106,16 +84,9 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ if (_groupID isEqualTo "") then { continue; }; private _leaderUid = getPlayerUID _leader; - private _actor = EGVAR(actor,Registry) getOrDefault [_leaderUid, createHashMap]; - if (_actor isEqualTo createHashMap && { _leaderUid isNotEqualTo "" }) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_leaderUid]]; - }; - - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_leaderUid]]; private _memberUids = []; private _memberRoster = []; - { private _memberUid = getPlayerUID _x; private _memberState = toLowerANSI (lifeState _x); @@ -158,7 +129,6 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ }; private _nextRegistry = createHashMap; - private _profileRegistry = createHashMap; { if !(_x isEqualType createHashMap) then { continue; }; private _groupID = _x getOrDefault ["groupId", ""]; @@ -166,15 +136,8 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ private _groupRecord = +_x; _nextRegistry set [_groupID, _groupRecord]; - _profileRegistry set [_groupID, createHashMapFromArray [ - ["groupId", _groupID], - ["role", _groupRecord getOrDefault ["role", "infantry"]], - ["status", _groupRecord getOrDefault ["status", "available"]] - ]]; } forEach _mergedGroups; - _self set ["groupProfileRegistry", _profileRegistry]; - _self set ["groupRegistry", _nextRegistry]; _nextRegistry }], ["getGroupRecord", compileFinal { @@ -309,12 +272,6 @@ GVAR(GroupRepositoryBaseClass) = compileFinal createHashMapFromArray [ _groupRecord set ["status", _profile getOrDefault ["status", _groupRecord getOrDefault ["status", "available"]]]; _groupRecord set ["lastUpdate", serverTime]; - private _profileRegistry = _self getOrDefault ["groupProfileRegistry", createHashMap]; - _groupRegistry set [_groupID, _groupRecord]; - _self set ["groupRegistry", _groupRegistry]; - _profileRegistry set [_groupID, _profile]; - _self set ["groupProfileRegistry", _profileRegistry]; - private _activityEntry = +(_profileData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { private _activityRepository = _self getOrDefault ["activityRepository", createHashMap]; diff --git a/arma/server/addons/cad/functions/fnc_initPermissionService.sqf b/arma/server/addons/cad/functions/fnc_initPermissionService.sqf index 07fea10..f27f215 100644 --- a/arma/server/addons/cad/functions/fnc_initPermissionService.sqf +++ b/arma/server/addons/cad/functions/fnc_initPermissionService.sqf @@ -27,10 +27,7 @@ GVAR(PermissionServiceBaseClass) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "") exitWith { false }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - if (_actor isEqualTo createHashMap) exitWith { false }; - - private _orgID = _actor getOrDefault ["organization", "default"]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; if (_org isEqualTo createHashMap) exitWith { false }; diff --git a/arma/server/addons/cad/functions/fnc_initPersistenceService.sqf b/arma/server/addons/cad/functions/fnc_initPersistenceService.sqf index dd4c053..e1ad1e1 100644 --- a/arma/server/addons/cad/functions/fnc_initPersistenceService.sqf +++ b/arma/server/addons/cad/functions/fnc_initPersistenceService.sqf @@ -10,6 +10,10 @@ * Initializes the CAD extension-state service that bridges live SQF * state to the Rust extension for hot CAD storage and recent history. * + * This is a live operational cache, not a durable persistence layer. + * CAD extension state is expected to reset with the current server or + * mission lifecycle. + * * Arguments: * None * diff --git a/arma/server/addons/cad/functions/fnc_initRequestRepository.sqf b/arma/server/addons/cad/functions/fnc_initRequestRepository.sqf index 7a6f89b..eeedad4 100644 --- a/arma/server/addons/cad/functions/fnc_initRequestRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initRequestRepository.sqf @@ -24,8 +24,6 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["#type", "CadRequestRepositoryBaseClass"], ["#create", compileFinal { - _self set ["requestRegistry", createHashMap]; - _self set ["persistenceLoaded", false]; _self set ["validTypes", [ "medevac_9line", "ace_lace", @@ -39,20 +37,14 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ "emergency" ]]; }], - ["restorePersistedState", compileFinal { - if (_self getOrDefault ["persistenceLoaded", false]) exitWith { true }; - + ["loadRequestRegistry", compileFinal { private _persistenceService = _self getOrDefault ["persistenceService", createHashMap]; - if (_persistenceService isEqualTo createHashMap) exitWith { false }; + if (_persistenceService isEqualTo createHashMap) exitWith { createHashMap }; private _result = _persistenceService call ["loadRequests", []]; - if !(_result getOrDefault ["success", false]) exitWith { false }; + if !(_result getOrDefault ["success", false]) exitWith { createHashMap }; - private _requestRegistry = +(_result getOrDefault ["data", createHashMap]); - - _self set ["requestRegistry", _requestRegistry]; - _self set ["persistenceLoaded", true]; - true + +(_result getOrDefault ["data", createHashMap]) }], ["submitRequest", compileFinal { params [ @@ -68,8 +60,6 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["request", createHashMap] ]; - _self call ["restorePersistedState", []]; - private _finalType = toLowerANSI _type; if !(_finalType in (_self getOrDefault ["validTypes", []])) exitWith { _result set ["message", "Invalid support request type."]; @@ -132,10 +122,6 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ _result }; - private _requestRegistry = _self getOrDefault ["requestRegistry", createHashMap]; - _requestRegistry set [_requestID, _request]; - _self set ["requestRegistry", _requestRegistry]; - private _activityEntry = +(_submitData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { private _activityRepository = _self getOrDefault ["activityRepository", createHashMap]; @@ -156,9 +142,7 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ ["request", createHashMap] ]; - _self call ["restorePersistedState", []]; - - private _requestRegistry = _self getOrDefault ["requestRegistry", createHashMap]; + private _requestRegistry = _self call ["loadRequestRegistry", []]; private _request = +(_requestRegistry getOrDefault [_requestID, createHashMap]); if (_request isEqualTo createHashMap) exitWith { _result set ["message", "Support request could not be resolved."]; @@ -188,8 +172,6 @@ GVAR(RequestRepositoryBaseClass) = compileFinal createHashMapFromArray [ private _closeData = +(_closeResult getOrDefault ["data", createHashMap]); _request = +(_closeData getOrDefault ["request", _request]); - _requestRegistry deleteAt _requestID; - _self set ["requestRegistry", _requestRegistry]; private _activityEntry = +(_closeData getOrDefault ["activity", createHashMap]); if (_activityEntry isNotEqualTo createHashMap) then { diff --git a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf index 830cee0..613ef18 100644 --- a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf @@ -25,7 +25,7 @@ GVAR(FEconomyStore) = createHashMapObject [[ ["#type", "IFuelEconomy"], ["#create", { GVAR(FuelCost) = 5; - GVAR(FuelRegistry) = createHashMap; + _self set ["fuelRegistry", createHashMap]; ["INFO", "Fuel Store Initialized!", nil, nil] call EFUNC(common,log); }], @@ -34,15 +34,17 @@ GVAR(FEconomyStore) = createHashMapObject [[ private _index = netId _target; private _uid = getPlayerUID _unit; + private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap]; - GVAR(FuelRegistry) set [_index, _uid]; + _fuelRegistry set [_index, _uid]; SETVAR(_target,liters,0); }], ["stop", { params ["_source", "_target"]; private _index = netId _target; - private _uid = GVAR(FuelRegistry) get _index; + private _fuelRegistry = _self getOrDefault ["fuelRegistry", createHashMap]; + private _uid = _fuelRegistry get _index; private _player = [_uid] call EFUNC(common,getPlayer); private _totalLiters = GETVAR(_target,liters,0); @@ -51,7 +53,7 @@ GVAR(FEconomyStore) = createHashMapObject [[ private _formattedTotalLiters = _totalLiters toFixed 2; [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L
Total Cost: $%2", _formattedTotalLiters, _formattedTotalCost]], _player] call CFUNC(targetEvent); - GVAR(FuelRegistry) deleteAt _index; + _fuelRegistry deleteAt _index; }] ]]; diff --git a/arma/server/addons/extension/functions/fnc_extCall.sqf b/arma/server/addons/extension/functions/fnc_extCall.sqf index e91ae1a..7d7ec29 100644 --- a/arma/server/addons/extension/functions/fnc_extCall.sqf +++ b/arma/server/addons/extension/functions/fnc_extCall.sqf @@ -37,6 +37,7 @@ private _transportResponseFunctions = [ "actor:update", "actor:hot:init", "actor:hot:get", + "actor:hot:keys", "actor:hot:save", "bank:get", "bank:create", diff --git a/arma/server/addons/locker/functions/fnc_initVAStore.sqf b/arma/server/addons/locker/functions/fnc_initVAStore.sqf index 567010b..ecbc234 100644 --- a/arma/server/addons/locker/functions/fnc_initVAStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initVAStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initVAStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-04-01 + * Last Update: 2026-04-05 * Public: No * * Description: @@ -28,7 +28,7 @@ GVAR(VArsenalModel) = compileFinal createHashMapObject [[ private _vArsenal = createHashMap; _vArsenal set ["backpacks", ["B_AssaultPack_rgr"]]; - _vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_IG_Guerrilla_6_1", "V_TacVest_oli", "ACE_EarPlugs"]]; + _vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_BG_Guerrilla_6_1", "V_TacVest_oli", "ACE_EarPlugs"]]; _vArsenal set ["magazines", ["16Rnd_9x21_Mag", "30Rnd_65x39_caseless_black_mag", "Chemlight_blue", "Chemlight_green", "Chemlight_red", "Chemlight_yellow", "HandGrenade", "SmokeShell", "SmokeShellBlue", "SmokeShellGreen", "SmokeShellOrange", "SmokeShellPurple", "SmokeShellRed", "SmokeShellYellow"]]; _vArsenal set ["weapons", ["arifle_MX_F", "hgun_P07_F"]]; diff --git a/arma/server/addons/main/XEH_PREP.hpp b/arma/server/addons/main/XEH_PREP.hpp index 3a1cf90..f4df82a 100644 --- a/arma/server/addons/main/XEH_PREP.hpp +++ b/arma/server/addons/main/XEH_PREP.hpp @@ -1,2 +1,3 @@ PREP(initStores); +PREP(initValidationHarness); PREP(saveHotState); diff --git a/arma/server/addons/main/XEH_preInit.sqf b/arma/server/addons/main/XEH_preInit.sqf index 929f1ff..be9e270 100644 --- a/arma/server/addons/main/XEH_preInit.sqf +++ b/arma/server/addons/main/XEH_preInit.sqf @@ -4,8 +4,6 @@ PREP_RECOMPILE_START; #include "XEH_PREP.hpp" PREP_RECOMPILE_END; -GVAR(PlayerBootstrapRegistry) = createHashMap; - ["forge_icom_event", { params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]]]; diff --git a/arma/server/addons/main/functions/fnc_initStores.sqf b/arma/server/addons/main/functions/fnc_initStores.sqf index 69f5259..d1b9275 100644 --- a/arma/server/addons/main/functions/fnc_initStores.sqf +++ b/arma/server/addons/main/functions/fnc_initStores.sqf @@ -47,3 +47,6 @@ if (isNil QEGVAR(org,OrgStore)) then { call EFUNC(org,initOrgStore); }; // Store if (isNil QEGVAR(store,StoreStore)) then { call EFUNC(store,initStoreStore); }; + +// Validation Harness +if (isNil QGVAR(ValidationHarness)) then { call FUNC(initValidationHarness); }; diff --git a/arma/server/addons/main/functions/fnc_initValidationHarness.sqf b/arma/server/addons/main/functions/fnc_initValidationHarness.sqf new file mode 100644 index 0000000..786531b --- /dev/null +++ b/arma/server/addons/main/functions/fnc_initValidationHarness.sqf @@ -0,0 +1,213 @@ +#include "..\script_component.hpp" + +/* + * Author: IDSolutions + * Initializes the server-side validation harness for targeted runtime smoke + * checks around high-risk multi-module flows. + * + * Arguments: + * None + * + * Return Value: + * Validation harness object + * + * Example: + * call forge_server_main_fnc_initValidationHarness; + * + * Public: No + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(ValidationHarness) = createHashMapObject [[ + ["#type", "ValidationHarness"], + ["buildResult", compileFinal { + params [ + ["_action", "", [""]], + ["_success", false, [false]], + ["_message", "", [""]], + ["_data", createHashMap, [createHashMap]] + ]; + + createHashMapFromArray [ + ["action", _action], + ["success", _success], + ["message", _message], + ["data", _data] + ] + }], + ["logResult", compileFinal { + params [["_result", createHashMap, [createHashMap]]]; + + if (_result isEqualTo createHashMap) exitWith { _result }; + + private _level = ["WARNING", "INFO"] select (_result getOrDefault ["success", false]); + private _action = _result getOrDefault ["action", "validation"]; + private _message = _result getOrDefault ["message", ""]; + [_level, format ["Validation harness '%1': %2", _action, _message]] call EFUNC(common,log); + + _result + }], + ["normalizeMapArg", compileFinal { + params [ + ["_value", createHashMap, [createHashMap, ""]], + ["_fallback", createHashMap, [createHashMap]] + ]; + + if (_value isEqualType createHashMap) exitWith { +_value }; + if !(_value isEqualType "") exitWith { +_fallback }; + if (_value isEqualTo "") exitWith { +_fallback }; + + private _parsed = fromJSON _value; + if !(_parsed isEqualType createHashMap) exitWith { +_fallback }; + + _parsed + }], + ["run", compileFinal { + params [["_action", "", [""]], ["_arguments", [], [[]]]]; + + private _actionLower = toLowerANSI _action; + if (_actionLower isEqualTo "") exitWith { + _self call ["logResult", [_self call ["buildResult", ["unknown", false, "A validation action is required.", createHashMap]]]] + }; + + switch (_actionLower) do { + case "save_hot_state": { + _arguments params [["_uid", "", [""]]]; + + private _success = [_uid] call FUNC(saveHotState); + private _message = [ + format ["Hot-state save failed for '%1'.", _uid], + format ["Hot-state save completed for '%1'.", [_uid, "all hot state"] select (_uid isEqualTo "")] + ] select _success; + + _self call ["logResult", [_self call ["buildResult", [ + _actionLower, + _success, + _message, + createHashMapFromArray [["uid", _uid]] + ]]]] + }; + case "store_checkout": { + _arguments params [["_uid", "", [""]], ["_payload", createHashMap, [createHashMap, ""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_uid isEqualTo "" || { isNull _player }) exitWith { + _self call ["logResult", [_self call ["buildResult", [_actionLower, false, "A valid online player UID is required for store checkout validation.", createHashMap]]]] + }; + + private _payloadMap = _self call ["normalizeMapArg", [_payload, createHashMap]]; + if (_payloadMap isEqualTo createHashMap) exitWith { + _self call ["logResult", [_self call ["buildResult", [_actionLower, false, "Store checkout validation payload was invalid.", createHashMap]]]] + }; + + private _result = EGVAR(store,StoreStore) call ["checkout", [_uid, _player, toJSON _payloadMap]]; + private _success = _result getOrDefault ["success", false]; + private _message = _result getOrDefault ["message", "Store checkout validation completed."]; + + _self call ["logResult", [_self call ["buildResult", [_actionLower, _success, _message, _result]]]] + }; + case "org_assign_credit_line": { + _arguments params [ + ["_requesterUid", "", [""]], + ["_memberUid", "", [""]], + ["_memberName", "", [""]], + ["_amount", 0, [0]] + ]; + + private _result = EGVAR(org,OrgStore) call ["assignCreditLine", [_requesterUid, _memberUid, _memberName, _amount]]; + private _success = _result getOrDefault ["success", false]; + private _message = _result getOrDefault ["message", "Credit line validation completed."]; + + _self call ["logResult", [_self call ["buildResult", [_actionLower, _success, _message, _result]]]] + }; + case "bank_credit_repayment": { + _arguments params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + if (_uid isEqualTo "") exitWith { + _self call ["logResult", [_self call ["buildResult", [_actionLower, false, "A valid UID is required for bank credit repayment validation.", createHashMap]]]] + }; + + private _beforeAccount = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; + private _beforeOrgState = EGVAR(bank,BankPayloadBuilder) call ["resolveOrgState", [_uid]]; + private _success = EGVAR(bank,BankStore) call ["repayCreditLine", [_uid, _amount]]; + private _afterAccount = EGVAR(bank,BankStore) call ["get", [_uid, ""]]; + private _afterOrgState = EGVAR(bank,BankPayloadBuilder) call ["resolveOrgState", [_uid]]; + + private _message = [ + format ["Bank credit repayment validation failed for %1.", _uid], + format ["Bank credit repayment validation completed for %1.", _uid] + ] select _success; + + _self call ["logResult", [_self call ["buildResult", [ + _actionLower, + _success, + _message, + createHashMapFromArray [ + ["beforeAccount", _beforeAccount], + ["afterAccount", _afterAccount], + ["beforeOrgState", _beforeOrgState], + ["afterOrgState", _afterOrgState] + ] + ]]]] + }; + case "task_reward_context": { + _arguments params [["_taskID", "", [""]]]; + + private _context = EGVAR(task,TaskStore) call ["resolveRewardContext", [_taskID]]; + private _success = _taskID isNotEqualTo "" && { (_context getOrDefault ["orgID", ""]) isNotEqualTo "" }; + private _message = [ + format ["No reward context was available for task %1.", _taskID], + format ["Resolved reward context for task %1.", _taskID] + ] select _success; + + _self call ["logResult", [_self call ["buildResult", [_actionLower, _success, _message, _context]]]] + }; + case "task_apply_rating": { + _arguments params [["_taskID", "", [""]], ["_delta", 0, [0]]]; + + private _result = EGVAR(task,TaskStore) call ["applyRatingOutcome", [_taskID, _delta]]; + private _success = _result getOrDefault ["success", true]; + private _message = [ + _result getOrDefault ["message", format ["Task rating validation failed for %1.", _taskID]], + format ["Task rating validation completed for %1.", _taskID] + ] select _success; + + _self call ["logResult", [_self call ["buildResult", [_actionLower, _success, _message, _result]]]] + }; + case "task_apply_rewards": { + _arguments params [["_taskID", "", [""]], ["_rewards", createHashMap, [createHashMap, ""]]]; + + private _rewardsMap = _self call ["normalizeMapArg", [_rewards, createHashMap]]; + if (_taskID isEqualTo "" || { _rewardsMap isEqualTo createHashMap }) exitWith { + _self call ["logResult", [_self call ["buildResult", [_actionLower, false, "Task reward validation requires a task ID and reward payload.", createHashMap]]]] + }; + + private _rewardContext = EGVAR(task,TaskStore) call ["resolveRewardContext", [_taskID]]; + private _beforeOrg = EGVAR(org,OrgStore) call ["loadById", [_rewardContext getOrDefault ["orgID", ""]]]; + private _success = [_taskID, _rewardsMap] call EFUNC(task,handleTaskRewards); + private _afterOrg = EGVAR(org,OrgStore) call ["loadById", [_rewardContext getOrDefault ["orgID", ""]]]; + + private _message = [ + format ["Task reward validation failed for %1.", _taskID], + format ["Task reward validation completed for %1.", _taskID] + ] select _success; + + _self call ["logResult", [_self call ["buildResult", [ + _actionLower, + _success, + _message, + createHashMapFromArray [ + ["rewardContext", _rewardContext], + ["beforeOrg", _beforeOrg], + ["afterOrg", _afterOrg] + ] + ]]]] + }; + default { + _self call ["logResult", [_self call ["buildResult", [_actionLower, false, format ["Unknown validation action '%1'.", _actionLower], createHashMap]]]] + }; + }; + }] +]]; + +GVAR(ValidationHarness) diff --git a/arma/server/addons/main/functions/fnc_saveHotState.sqf b/arma/server/addons/main/functions/fnc_saveHotState.sqf index 5fbfb98..dc819da 100644 --- a/arma/server/addons/main/functions/fnc_saveHotState.sqf +++ b/arma/server/addons/main/functions/fnc_saveHotState.sqf @@ -28,12 +28,12 @@ if (_uid isEqualTo "") then { }; } forEach allPlayers; - if !(isNil QEGVAR(actor,Registry)) then { + if !(isNil QEGVAR(actor,ActorStore)) then { { if (_x isNotEqualTo "") then { _uids pushBackUnique _x; }; - } forEach keys EGVAR(actor,Registry); + } forEach (EGVAR(actor,ActorStore) call ["listHotUids", []]); }; } else { _uids pushBack _uid; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 230564e..f0de6a0 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initOrgStore.sqf * Author: IDSolutions * Date: 2026-02-13 - * Last Update: 2026-04-01 + * Last Update: 2026-04-04 * Public: Yes * * Description: @@ -256,10 +256,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "") exitWith { "default" }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - _orgID + EGVAR(actor,ActorStore) call ["getOrganization", [_uid]] }], ["resolveActorName", compileFinal { params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; @@ -276,8 +273,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { createHashMap }; - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, false]]; - private _updatedActor = EGVAR(actor,ActorStore) call ["get", [_uid, ""]]; + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [_uid, "organization", _orgID, false]]; + private _updatedActor = EGVAR(actor,ActorStore) call ["load", [_uid]]; if ( !(_updatedActor isEqualType createHashMap) || { _updatedActor isEqualTo createHashMap } @@ -375,8 +372,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; private _player = [_uid] call EFUNC(common,getPlayer); - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; + private _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _memberName = _self call ["resolveActorName", [_uid, _player, _actor]]; private _context = createHashMapFromArray [ ["requesterUid", _uid], @@ -418,8 +415,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; private _player = [_uid] call EFUNC(common,getPlayer); - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; + private _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _memberName = _self call ["resolveActorName", [_uid, _player, _actor]]; private _context = createHashMapFromArray [ ["requesterUid", _uid], @@ -438,7 +435,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ private _memberUid = _x getOrDefault ["uid", ""]; if (_memberUid isEqualTo "") then { continue; }; - private _memberActor = EGVAR(actor,Registry) getOrDefault [_memberUid, createHashMap]; + private _memberActor = EGVAR(actor,ActorStore) call ["load", [_memberUid]]; private _actorPatch = _self call ["applyActorOrganization", [_memberUid, _x getOrDefault ["actorOrganization", "default"], _memberActor]]; if (_actorPatch isEqualTo createHashMap) then { ["WARNING", format ["Failed to restore actor organization for %1 after org disband.", _memberUid]] call EFUNC(common,log); @@ -471,7 +468,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["success", false], ["message", ""], ["patch", createHashMap], - ["memberUids", []] + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] ]; if (_requesterUid isEqualTo "" || { _memberUid isEqualTo "" } || { _amount <= 0 }) exitWith { @@ -479,10 +478,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); private _requesterIsDefaultOrgCeo = ( _requesterPlayer isNotEqualTo objNull @@ -509,7 +505,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", _envelope getOrDefault ["message", "Credit line assigned."]]; _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; - _result + _self call ["persistMutationResult", [_orgID, _result, "Credit line assignment"]] }], ["repayCreditLine", compileFinal { params [["_requesterUid", "", [""]], ["_amount", 0, [0]]]; @@ -518,7 +514,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["success", false], ["message", ""], ["patch", createHashMap], - ["memberUids", []] + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] ]; if (_requesterUid isEqualTo "" || { _amount <= 0 }) exitWith { @@ -526,10 +524,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; private _context = createHashMapFromArray [ ["requesterUid", _requesterUid], ["orgId", _orgID], @@ -546,13 +541,38 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", _envelope getOrDefault ["message", "Credit repayment posted."]]; _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; - _result + _self call ["persistMutationResult", [_orgID, _result, "Credit repayment"]] }], ["buildPortalPayload", compileFinal { params [["_uid", "", [""]]]; GVAR(OrgPayloadBuilder) call ["buildPortalPayload", [_uid]] }], + ["persistMutationResult", compileFinal { + params [ + ["_orgID", "", [""]], + ["_result", createHashMap, [createHashMap]], + ["_actionLabel", "Organization update", [""]] + ]; + + if (_orgID isEqualTo "" || { _result isEqualTo createHashMap }) exitWith { _result }; + + if !(_result getOrDefault ["success", false]) exitWith { _result }; + + _result set ["persisted", false]; + _result set ["persistenceMessage", ""]; + + private _savedOrg = _self call ["saveById", [_orgID]]; + if (_savedOrg isEqualTo createHashMap) exitWith { + private _message = format ["%1 applied, but durable save failed for organization %2.", _actionLabel, _orgID]; + ["ERROR", _message] call EFUNC(common,log); + _result set ["persistenceMessage", _message]; + _result + }; + + _result set ["persisted", true]; + _result + }], ["chargeCheckout", compileFinal { params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; @@ -560,13 +580,12 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["success", false], ["message", "Unable to process organization payment."], ["patch", createHashMap], - ["memberUids", []] + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] ]; - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; private _requesterIsDefaultOrgCeo = ( _requesterPlayer isNotEqualTo objNull && { _orgID isEqualTo "default" } @@ -589,7 +608,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", _envelope getOrDefault ["message", ""]]; _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; - _result + _self call ["persistMutationResult", [_orgID, _result, "Organization checkout charge"]] }], ["saveById", compileFinal { params [["_orgID", "", [""]]]; @@ -605,7 +624,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["success", false], ["message", "Unable to update organization assets."], ["patch", createHashMap], - ["memberUids", []] + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] ]; if (_assets isEqualTo []) exitWith { @@ -616,8 +637,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ private _resolvedOrgID = _orgID; if (_resolvedOrgID isEqualTo "") then { - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - _resolvedOrgID = _requesterActor getOrDefault ["organization", "default"]; + _resolvedOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; @@ -644,7 +664,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", _envelope getOrDefault ["message", ""]]; _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; - _result + _self call ["persistMutationResult", [_resolvedOrgID, _result, "Organization asset update"]] }], ["addFleetVehicles", compileFinal { params [["_requesterUid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]], ["_orgID", "", [""]]]; @@ -653,7 +673,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["success", false], ["message", "Unable to update organization fleet."], ["patch", createHashMap], - ["memberUids", []] + ["memberUids", []], + ["persisted", false], + ["persistenceMessage", ""] ]; if (_vehicles isEqualTo []) exitWith { @@ -664,8 +686,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ private _resolvedOrgID = _orgID; if (_resolvedOrgID isEqualTo "") then { - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - _resolvedOrgID = _requesterActor getOrDefault ["organization", "default"]; + _resolvedOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; }; if (_resolvedOrgID isEqualTo "") then { _resolvedOrgID = "default"; }; @@ -691,7 +712,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result set ["message", _envelope getOrDefault ["message", ""]]; _result set ["patch", _envelope getOrDefault ["patch", createHashMap]]; _result set ["memberUids", _envelope getOrDefault ["memberUids", []]]; - _result + _self call ["persistMutationResult", [_resolvedOrgID, _result, "Organization fleet update"]] }], ["loadById", compileFinal { params [["_orgID", "", [""]]]; @@ -715,10 +736,9 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _result }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _existingOrgID = _actor getOrDefault ["organization", ""]; - - private _orgID = _actor getOrDefault ["phone_number", ""]; + private _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + private _existingOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid, ""]]; + private _orgID = EGVAR(actor,ActorStore) call ["getPhoneNumber", [_uid]]; if (_orgID isEqualTo "") exitWith { _result set ["message", "Player phone number was not available for organization registration."]; _result @@ -754,12 +774,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]]]; private _player = [_uid] call EFUNC(common,getPlayer); - private _actor = EGVAR(actor,Registry) get _uid; - private _orgID = _actor get "organization"; - if (_orgID isEqualTo "") then { - _orgID = "default"; - }; - + private _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _finalOrg = _self call ["loadById", [_orgID]]; if (_finalOrg isEqualTo createHashMap) then { ["WARNING", format ["No existing org found for %1, using default org.", _uid]] call EFUNC(common,log); diff --git a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf index 19fe960..3e91122 100644 --- a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf @@ -160,10 +160,8 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[ private _player = [_uid] call EFUNC(common,getPlayer); if (isNull _player) exitWith { createHashMap }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgID = _actor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _actor = EGVAR(actor,ActorStore) call ["load", [_uid]]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _org = _self call ["resolveOrgForUid", [_uid]]; if (_org isEqualTo createHashMap) exitWith { createHashMap }; diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf index 61fb751..b77f94f 100644 --- a/arma/server/addons/store/functions/fnc_initStoreStore.sqf +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initStoreStore.sqf * Author: IDSolutions * Date: 2026-03-12 - * Last Update: 2026-03-14 + * Last Update: 2026-04-04 * Public: No * * Description: @@ -50,10 +50,7 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ _bankBalance = _bankAccount getOrDefault ["bank", 0]; }; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - private _orgId = _actor getOrDefault ["organization", "default"]; - if (_orgId isEqualTo "") then { _orgId = "default"; }; - + private _orgId = EGVAR(actor,ActorStore) call ["getOrganization", [_uid]]; private _org = EGVAR(org,OrgStore) call ["loadById", [_orgId]]; if (_org isEqualTo createHashMap) then { _org = EGVAR(org,OrgStore) call ["loadById", ["default"]]; @@ -160,7 +157,10 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ ["vehicleGranted", []], ["bankPatch", createHashMap], ["orgPatch", createHashMap], - ["orgTargetUids", []] + ["orgTargetUids", []], + ["persistenceSucceeded", false], + ["persistenceFailures", []], + ["persistenceMessage", ""] ] }], ["formatCurrency", compileFinal { @@ -255,6 +255,69 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ true }], + ["persistCheckoutState", compileFinal { + params [ + ["_uid", "", [""]], + ["_orgID", "", [""]], + ["_backendResult", createHashMap, [createHashMap]] + ]; + + private _result = createHashMapFromArray [ + ["success", true], + ["failures", []], + ["message", ""] + ]; + + if (_uid isEqualTo "" || { _backendResult isEqualTo createHashMap }) exitWith { + _result set ["success", false]; + _result set ["failures", ["checkout"]]; + _result set ["message", "Checkout persistence context was invalid."]; + _result + }; + + private _persistenceFailures = []; + + if ((keys (_backendResult getOrDefault ["lockerPatch", createHashMap])) isNotEqualTo []) then { + if ((EGVAR(locker,LockerStore) call ["save", [_uid]]) isEqualTo createHashMap) then { + _persistenceFailures pushBack "locker"; + }; + }; + + if ((keys (_backendResult getOrDefault ["vaPatch", createHashMap])) isNotEqualTo []) then { + if ((EGVAR(locker,VAStore) call ["save", [_uid]]) isEqualTo createHashMap) then { + _persistenceFailures pushBack "virtual_arsenal"; + }; + }; + + if ((keys (_backendResult getOrDefault ["vgaragePatch", createHashMap])) isNotEqualTo []) then { + if ((EGVAR(garage,VGarageStore) call ["save", [_uid]]) isEqualTo createHashMap) then { + _persistenceFailures pushBack "virtual_garage"; + }; + }; + + if ((keys (_backendResult getOrDefault ["bankPatch", createHashMap])) isNotEqualTo []) then { + if ((EGVAR(bank,BankStore) call ["save", [_uid]]) isEqualTo createHashMap) then { + _persistenceFailures pushBack "bank"; + }; + }; + + if (_orgID isNotEqualTo "" && { (keys (_backendResult getOrDefault ["orgPatch", createHashMap])) isNotEqualTo [] }) then { + if ((EGVAR(org,OrgStore) call ["saveById", [_orgID]]) isEqualTo createHashMap) then { + _persistenceFailures pushBack "organization"; + }; + }; + + if (_persistenceFailures isNotEqualTo []) then { + _result set ["success", false]; + _result set ["failures", _persistenceFailures]; + _result set ["message", format [ + "Checkout completed, but durable save failed for: %1.", + _persistenceFailures joinString ", " + ]]; + }; + + _result + }], ["checkout", compileFinal { params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_payloadJson", "", [""]]]; @@ -310,6 +373,14 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ }; _self call ["syncCheckoutResult", [_player, _backendResult]]; + private _persistenceResult = _self call [ + "persistCheckoutState", + [ + _uid, + _checkoutContext getOrDefault ["orgId", ""], + _backendResult + ] + ]; _result set ["success", true]; _result set ["message", _backendResult getOrDefault ["message", format [ @@ -320,6 +391,16 @@ GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ ]]]; _result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]]; _result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]]; + _result set ["persistenceSucceeded", _persistenceResult getOrDefault ["success", false]]; + _result set ["persistenceFailures", _persistenceResult getOrDefault ["failures", []]]; + _result set ["persistenceMessage", _persistenceResult getOrDefault ["message", ""]]; + + if !(_persistenceResult getOrDefault ["success", false]) then { + private _warning = _persistenceResult getOrDefault ["message", "Checkout completed with persistence failures."]; + ["ERROR", format ["Store checkout for %1 completed with persistence failures: %2", _uid, (_persistenceResult getOrDefault ["failures", []]) joinString ", "]] call EFUNC(common,log); + _result set ["message", format ["%1 %2", _result get "message", _warning]]; + }; + _result }] ]; diff --git a/arma/server/addons/task/README.md b/arma/server/addons/task/README.md index 3ea76e3..ef67658 100644 --- a/arma/server/addons/task/README.md +++ b/arma/server/addons/task/README.md @@ -3,6 +3,10 @@ ## Overview The task addon is a server-owned mission/task system for Forge. It manages task execution, task-owned state, participant tracking, contribution-based player earnings, and org-owned rewards. +Task operational state is mission-scoped. The extension-backed task catalog, +ownership, status, and defuse state are reset on task store startup, so the +system intentionally starts clean after each server or mission restart. + ## Responsibilities - spawn and monitor task flows on the server - track per-task entities through `TaskStore` @@ -95,6 +99,7 @@ If you want the accepting player's org to own the task rewards, use `fnc_handler - the dynamic mission manager in `fnc_missionManager.sqf` is now limited to attack missions only - it starts server-owned tasks through `fnc_handler.sqf` and binds them to the `default` org - task lifecycle for the mission manager is tracked through `TaskStore` status entries +- task backend state is intentionally transient and resets with the active server/mission lifecycle - task rewards are org-owned, not player-owned - participant notifications are sent through the notifications module, not through local server UI diff --git a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf index e0e83bf..1ba8f83 100644 --- a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf +++ b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf @@ -51,6 +51,7 @@ if (_orgID isEqualTo "") exitWith { private _success = true; private _funds = _rewards getOrDefault ["funds", 0]; private _rewardMessages = []; +private _failureMessages = []; private _resolveRewardLabel = { params [["_className", "", [""]]]; @@ -115,8 +116,15 @@ if (_funds > 0) then { if (_updatedOrg isEqualTo createHashMap) then { ["ERROR", format ["Failed to update organization %1 funds for task %2.", _orgID, _taskID]] call EFUNC(common,log); _success = false; + _failureMessages pushBack "org funds update"; } else { private _patch = createHashMapFromArray [["funds", _nextFunds]]; + private _savedOrg = EGVAR(org,OrgStore) call ["saveById", [_orgID]]; + if (_savedOrg isEqualTo createHashMap) then { + ["ERROR", format ["Task %1 updated organization %2 funds, but durable save failed.", _taskID, _orgID]] call EFUNC(common,log); + _success = false; + _failureMessages pushBack "org funds persistence"; + }; [_patch] call _syncOrgPatch; _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)]; @@ -141,8 +149,15 @@ private _grantOrgAssets = { if !(_grantResult getOrDefault ["success", false]) then { ["ERROR", format ["Failed to award %1 assets for task %2: %3", _category, _taskID, _grantResult getOrDefault ["message", "Unknown error."]]] call EFUNC(common,log); _success = false; + _failureMessages pushBack format ["%1 asset update", _category]; } else { [_grantResult getOrDefault ["patch", createHashMap]] call _syncOrgPatch; + if !(_grantResult getOrDefault ["persisted", false]) then { + private _persistenceMessage = _grantResult getOrDefault ["persistenceMessage", format ["%1 assets updated, but durable save failed.", _category]]; + ["ERROR", format ["Task %1 %2", _taskID, _persistenceMessage]] call EFUNC(common,log); + _success = false; + _failureMessages pushBack format ["%1 asset persistence", _category]; + }; private _labels = _items apply { [_x] call _resolveRewardLabel }; _rewardMessages pushBack format ["%1: %2", _category, _labels joinString ", "]; }; @@ -171,8 +186,15 @@ private _grantOrgFleet = { if !(_fleetResult getOrDefault ["success", false]) then { ["ERROR", format ["Failed to award vehicle rewards for task %2: %1", _fleetResult getOrDefault ["message", "Unknown error."], _taskID]] call EFUNC(common,log); _success = false; + _failureMessages pushBack "fleet update"; } else { [_fleetResult getOrDefault ["patch", createHashMap]] call _syncOrgPatch; + if !(_fleetResult getOrDefault ["persisted", false]) then { + private _persistenceMessage = _fleetResult getOrDefault ["persistenceMessage", "Fleet updated, but durable save failed."]; + ["ERROR", format ["Task %1 %2", _taskID, _persistenceMessage]] call EFUNC(common,log); + _success = false; + _failureMessages pushBack "fleet persistence"; + }; private _labels = _vehicles apply { [_x] call _resolveRewardLabel }; _rewardMessages pushBack format ["vehicles: %1", _labels joinString ", "]; }; @@ -219,7 +241,12 @@ if (_success) then { ["INFO", _message] call EFUNC(common,log); ["success", "Tasks", _message] call _notifyMembers; } else { - ["warning", "Tasks", format ["Task %1 completed, but one or more org rewards failed to apply.", _taskID]] call _notifyMembers; + private _warningMessage = format ["Task %1 completed, but one or more org rewards failed to apply.", _taskID]; + if (_failureMessages isNotEqualTo []) then { + _warningMessage = format ["%1 Failed areas: %2.", _warningMessage, _failureMessages joinString ", "]; + }; + + ["warning", "Tasks", _warningMessage] call _notifyMembers; }; _success diff --git a/arma/server/addons/task/functions/fnc_handler.sqf b/arma/server/addons/task/functions/fnc_handler.sqf index 0349b27..cffe3a1 100644 --- a/arma/server/addons/task/functions/fnc_handler.sqf +++ b/arma/server/addons/task/functions/fnc_handler.sqf @@ -27,14 +27,7 @@ if (_minRating > 0) then { if (_requesterUid isEqualTo "") then { ["WARNING", format ["Task %1 requires minimum reputation %2 but no requester UID was provided, skipping reputation gate.", _taskType, _minRating]] call EFUNC(common,log); } else { - private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - if (_requesterActor isEqualTo createHashMap) then { - _requesterActor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; - }; - - private _orgID = _requesterActor getOrDefault ["organization", "default"]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; - + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; private _org = EGVAR(org,OrgStore) call ["loadById", [_orgID]]; private _orgReputation = _org getOrDefault ["reputation", 0]; if (_orgReputation < _minRating) exitWith { diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf index f0410f9..5de0c4e 100644 --- a/arma/server/addons/task/functions/fnc_initTaskStore.sqf +++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf @@ -5,6 +5,10 @@ * Initializes the task store for task entity tracking, participant * contribution tracking, and task outcome application. * + * Task metadata is extension-backed but intentionally transient. The + * task backend is reset when this store is created so task/catalog/status + * state starts clean for each server or mission lifecycle. + * * Arguments: * None * @@ -32,6 +36,8 @@ GVAR(TaskStore) = createHashMapObject [[ ["targets", createHashMap] ]]; + // Task extension state is mission-scoped and intentionally reset on + // startup rather than being treated as durable account data. ["task:reset", []] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; if ( !_isSuccess @@ -99,18 +105,14 @@ GVAR(TaskStore) = createHashMapObject [[ private _orgID = "default"; if (_requesterUid isNotEqualTo "") then { - private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - if (_actor isEqualTo createHashMap) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; - }; + private _actor = EGVAR(actor,ActorStore) call ["load", [_requesterUid]]; if (_actor isEqualTo createHashMap) exitWith { _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; _result }; - _orgID = _actor getOrDefault ["organization", ""]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; + _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; }; private _context = createHashMapFromArray [ @@ -166,6 +168,14 @@ GVAR(TaskStore) = createHashMapObject [[ _entries }], + ["hasTaskCatalogEntry", compileFinal { + params [["_taskID", "", [""]]]; + + if (_taskID isEqualTo "") exitWith { false }; + + private _entry = _self call ["callTaskState", ["task:catalog:get", [_taskID], objNull]]; + _entry isEqualType createHashMap + }], ["acceptTask", compileFinal { params [["_taskID", "", [""]], ["_requesterUid", "", [""]]]; @@ -180,17 +190,13 @@ GVAR(TaskStore) = createHashMapObject [[ _result }; - private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; - if (_actor isEqualTo createHashMap) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]]; - }; + private _actor = EGVAR(actor,ActorStore) call ["load", [_requesterUid]]; if (_actor isEqualTo createHashMap) exitWith { _result set ["message", format ["Failed to load actor for %1.", _requesterUid]]; _result }; - private _orgID = _actor getOrDefault ["organization", ""]; - if (_orgID isEqualTo "") then { _orgID = "default"; }; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; private _context = createHashMapFromArray [ ["requesterUid", _requesterUid], @@ -418,6 +424,10 @@ GVAR(TaskStore) = createHashMapObject [[ if (_taskID isEqualTo "") exitWith { false }; + if !(isNil QGVAR(MissionManager)) then { + GVAR(MissionManager) call ["completeMission", [_taskID]]; + }; + private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap]; _participantRegistry deleteAt _taskID; _self set ["participantRegistry", _participantRegistry]; @@ -431,7 +441,11 @@ GVAR(TaskStore) = createHashMapObject [[ private _result = createHashMapFromArray [ ["participantUids", []], ["orgIds", []], - ["contributions", createHashMap] + ["contributions", createHashMap], + ["success", true], + ["mutationFailures", []], + ["persistenceFailures", []], + ["message", ""] ]; if (_taskID isEqualTo "" || { _delta isEqualTo 0 }) exitWith { _result }; @@ -462,6 +476,8 @@ GVAR(TaskStore) = createHashMapObject [[ private _orgIds = []; private _contributions = createHashMap; private _totalContribution = 0; + private _mutationFailures = []; + private _persistenceFailures = []; if (_delta > 0) then { { @@ -481,12 +497,7 @@ GVAR(TaskStore) = createHashMapObject [[ { private _uid = _x; - private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; - if (_actor isEqualTo createHashMap) then { - _actor = EGVAR(actor,ActorStore) call ["init", [_uid]]; - }; - - private _orgID = _actor getOrDefault ["organization", ""]; + private _orgID = EGVAR(actor,ActorStore) call ["getOrganization", [_uid, ""]]; if (_orgID isNotEqualTo "") then { _orgIds pushBackUnique _orgID; }; @@ -517,6 +528,10 @@ GVAR(TaskStore) = createHashMapObject [[ if (_patch isEqualTo createHashMap) then { continue; }; EGVAR(bank,BankMessenger) call ["sendAccountSync", [_uid, _patch]]; + if ((EGVAR(bank,BankStore) call ["save", [_uid]]) isEqualTo createHashMap) then { + _persistenceFailures pushBackUnique format ["bank:%1", _uid]; + ["ERROR", format ["Task %1 updated bank earnings for %2, but durable save failed.", _taskID, _uid]] call EFUNC(common,log); + }; }; }; } forEach _participantUids; @@ -547,8 +562,13 @@ GVAR(TaskStore) = createHashMapObject [[ } forEach _memberUids; _orgIds = [_ownerOrgID]; + if ((EGVAR(org,OrgStore) call ["saveById", [_ownerOrgID]]) isEqualTo createHashMap) then { + _persistenceFailures pushBackUnique format ["organization:%1", _ownerOrgID]; + ["ERROR", format ["Task %1 updated reputation for organization %2, but durable save failed.", _taskID, _ownerOrgID]] call EFUNC(common,log); + }; } else { ["ERROR", format ["Failed to update organization %1 reputation for task %2.", _ownerOrgID, _taskID]] call EFUNC(common,log); + _mutationFailures pushBackUnique format ["organization:%1", _ownerOrgID]; }; }; }; @@ -556,6 +576,19 @@ GVAR(TaskStore) = createHashMapObject [[ _result set ["participantUids", _participantUids]; _result set ["orgIds", _orgIds]; _result set ["contributions", _contributions]; + _result set ["success", (_mutationFailures isEqualTo []) && { _persistenceFailures isEqualTo [] }]; + _result set ["mutationFailures", _mutationFailures]; + _result set ["persistenceFailures", _persistenceFailures]; + if (_mutationFailures isNotEqualTo [] || { _persistenceFailures isNotEqualTo [] }) then { + private _messageParts = []; + if (_mutationFailures isNotEqualTo []) then { + _messageParts pushBack format ["mutation failures: %1", _mutationFailures joinString ", "]; + }; + if (_persistenceFailures isNotEqualTo []) then { + _messageParts pushBack format ["persistence failures: %1", _persistenceFailures joinString ", "]; + }; + _result set ["message", _messageParts joinString "; "]; + }; _result }] ]]; diff --git a/arma/server/addons/task/functions/fnc_missionManager.sqf b/arma/server/addons/task/functions/fnc_missionManager.sqf index a1aec02..e471b27 100644 --- a/arma/server/addons/task/functions/fnc_missionManager.sqf +++ b/arma/server/addons/task/functions/fnc_missionManager.sqf @@ -38,8 +38,25 @@ GVAR(MissionManagerBaseClass) = compileFinal createHashMapFromArray [ ["getMaxConcurrentMissions", compileFinal { private _maxConcurrent = _self getOrDefault ["maxConcurrentMissions", 1]; if (_maxConcurrent <= 0) then { _maxConcurrent = 1; }; + private _attackLocationCount = _self call ["getAttackLocationCount", []]; + if (_attackLocationCount > 0) then { + _maxConcurrent = _maxConcurrent min _attackLocationCount; + }; _maxConcurrent }], + ["getAttackLocationCount", compileFinal { + private _locationsConfig = _self getOrDefault ["locationsConfig", configNull]; + if (isNull _locationsConfig) exitWith { 0 }; + + private _count = 0; + { + if ("attack" in getArray (_x >> "suitable")) then { + _count = _count + 1; + }; + } forEach ("true" configClasses _locationsConfig); + + _count + }], ["getLocationReuseCooldown", compileFinal { private _missionConfig = _self getOrDefault ["missionConfig", configNull]; private _cooldown = getNumber (_missionConfig >> "locationReuseCooldown"); @@ -352,7 +369,8 @@ GVAR(MissionManager) = createHashMapObject [GVAR(MissionManagerBaseClass)]; [{ { private _status = GVAR(TaskStore) call ["getTaskStatus", [_x]]; - if (_status in ["succeeded", "failed"]) then { + private _hasCatalogEntry = GVAR(TaskStore) call ["hasTaskCatalogEntry", [_x]]; + if (_status in ["succeeded", "failed"] || { _status isEqualTo "" && { !_hasCatalogEntry } }) then { GVAR(MissionManager) call ["completeMission", [_x]]; GVAR(TaskStore) call ["clearTaskStatus", [_x]]; }; diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index 53f3a68..7bbdf68 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -54,6 +54,7 @@ pub fn group() -> Group { Group::new() .command("init", init_hot_actor) .command("get", get_hot_actor) + .command("keys", list_hot_actor_keys) .command("override", override_hot_actor) .command("save", save_hot_actor) .command("remove", remove_hot_actor), @@ -91,6 +92,16 @@ pub(crate) fn get_hot_actor(call_context: CallContext, key: String) -> String { } } +pub(crate) fn list_hot_actor_keys() -> String { + match HOT_ACTOR_SERVICE.list_actor_keys() { + Ok(keys) => match serde_json::to_string(&keys) { + Ok(json) => json, + Err(error) => format!("Error: Failed to serialize actor hot-state keys: {}", error), + }, + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn override_hot_actor( call_context: CallContext, key: String, diff --git a/arma/server/extension/src/cad.rs b/arma/server/extension/src/cad.rs index 5984e99..d6c4223 100644 --- a/arma/server/extension/src/cad.rs +++ b/arma/server/extension/src/cad.rs @@ -3,6 +3,10 @@ //! The extension owns the in-memory CAD state store, while the shared service //! layer handles mutation rules and hydrate shaping. This keeps the extension //! surface thin and aligned with the workspace architecture. +//! +//! CAD state is intentionally transient operational state. It follows the +//! active server or mission lifecycle and is not treated as durable player or +//! organization persistence. use arma_rs::Group; use forge_repositories::InMemoryCadRepository; diff --git a/arma/server/extension/src/task.rs b/arma/server/extension/src/task.rs index 233d3dc..fbe2e40 100644 --- a/arma/server/extension/src/task.rs +++ b/arma/server/extension/src/task.rs @@ -2,6 +2,9 @@ //! //! The extension owns portable task metadata while SQF keeps Arma-only runtime //! state such as entity references and participant tracking. +//! +//! This state is intentionally transient and is reset during server task-store +//! initialization so tasks start clean for each server or mission lifecycle. use arma_rs::Group; use forge_repositories::InMemoryTaskRepository; diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index 0232ba4..6e3b32c 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -185,6 +185,10 @@ fn route_command( expect_arg_count(function_name, &arguments, 1)?; Ok(actor::get_hot_actor(call_context, arguments[0].clone())) } + "actor:hot:keys" => { + expect_arg_count(function_name, &arguments, 0)?; + Ok(actor::list_hot_actor_keys()) + } "actor:hot:override" => { expect_arg_count(function_name, &arguments, 2)?; Ok(actor::override_hot_actor( diff --git a/lib/models/src/actor.rs b/lib/models/src/actor.rs index b6e7c2b..203eed1 100644 --- a/lib/models/src/actor.rs +++ b/lib/models/src/actor.rs @@ -61,7 +61,7 @@ impl Actor { state: "HEALTHY".to_string(), holster: true, rank: None, - organization: "".to_string(), + organization: "default".to_string(), }; actor.validate()?; @@ -171,7 +171,7 @@ impl FromArma for Actor { })?; if actor.organization.trim().is_empty() { - actor.organization = String::new(); + actor.organization = "default".to_string(); } Ok(actor) diff --git a/lib/models/src/v_locker.rs b/lib/models/src/v_locker.rs index 2f68ffc..f67de35 100644 --- a/lib/models/src/v_locker.rs +++ b/lib/models/src/v_locker.rs @@ -35,7 +35,7 @@ impl VLocker { "ItemMap".to_string(), "ItemRadio".to_string(), "ItemWatch".to_string(), - "U_IG_Guerrilla_6_1".to_string(), + "U_BG_Guerrilla_6_1".to_string(), "V_TacVest_oli".to_string(), ], weapons: vec!["arifle_MX_F".to_string(), "hgun_P07_F".to_string()], diff --git a/lib/repositories/src/actor.rs b/lib/repositories/src/actor.rs index cca7867..72107e1 100644 --- a/lib/repositories/src/actor.rs +++ b/lib/repositories/src/actor.rs @@ -34,6 +34,7 @@ pub trait ActorRepository: Send + Sync { pub trait ActorHotRepository: Send + Sync { fn get(&self, id: &str) -> Result, String>; + fn keys(&self) -> Result, String>; fn save(&self, actor: &Actor) -> Result<(), String>; fn delete(&self, id: &str) -> Result<(), String>; } @@ -57,6 +58,13 @@ impl ActorHotRepository for InMemoryActorHotRepository { .map_err(|_| "Actor hot state lock poisoned.".to_string()) } + fn keys(&self) -> Result, String> { + self.state + .read() + .map(|state| state.keys().cloned().collect()) + .map_err(|_| "Actor hot state lock poisoned.".to_string()) + } + fn save(&self, actor: &Actor) -> Result<(), String> { self.state .write() diff --git a/lib/services/README.md b/lib/services/README.md index 78b64a1..cf88b86 100644 --- a/lib/services/README.md +++ b/lib/services/README.md @@ -26,6 +26,14 @@ graph TD - **Error Handling:** Converts technical errors into business-friendly messages. - **Data Transformation:** Handles JSON parsing and model conversion. +## Operational State Policy + +Most hot-state services in Forge back durable player or organization records and +are expected to flush through the save path. `CAD` and `Task` are the current +exceptions: they are extension-backed operational state services that are +intentionally transient and restart clean with the active server or mission +lifecycle. + ## Actor Service The `ActorService` manages player lifecycle and state. diff --git a/lib/services/src/actor.rs b/lib/services/src/actor.rs index 6f693b4..040660b 100644 --- a/lib/services/src/actor.rs +++ b/lib/services/src/actor.rs @@ -42,7 +42,14 @@ impl ActorHotStateService { return Ok(actor); } - let actor = self.service.get_actor(key)?; + let actor = match self.service.repository.get_by_id(&key)? { + Some(actor) => actor, + None => { + let actor = Actor::new(key.clone()).map_err(|e| e.to_string())?; + self.service.repository.create(&actor)?; + actor + } + }; self.repository.save(&actor)?; Ok(actor) } @@ -77,6 +84,10 @@ impl ActorHotStateService { Ok(saved_actor) } + pub fn list_actor_keys(&self) -> Result, String> { + self.repository.keys() + } + pub fn remove_actor(&self, key: String) -> Result<(), String> { self.repository.delete(&key) } @@ -114,6 +125,9 @@ impl ActorService { if actor.email.is_empty() { actor.email = generate_email(&actor.phone_number); } + if actor.organization.trim().is_empty() { + actor.organization = "default".to_string(); + } // Validate before persisting actor From cd3e937cdc2ae552d076de6e8a0cace0b97d0782 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 5 Apr 2026 21:44:10 -0500 Subject: [PATCH 16/19] Hydrate missing actor and org records from live data - Require actor records to exist in storage before hot load - Fall back to player snapshots to fill missing actor fields and org defaults - Refresh org member names when a better value is available - Keep bootstrap extension calls on the direct path by default --- .../addons/garage/XEH_postInitClient.sqf | 2 +- .../addons/locker/XEH_postInitClient.sqf | 2 +- arma/client/addons/org/XEH_postInitClient.sqf | 2 +- .../actor/functions/fnc_initActorStore.sqf | 144 +++++++++++++++++- .../extension/functions/fnc_extCall.sqf | 18 ++- .../addons/org/functions/fnc_initOrgStore.sqf | 35 ++++- lib/services/src/actor.rs | 25 +-- lib/services/src/org.rs | 22 ++- 8 files changed, 212 insertions(+), 38 deletions(-) diff --git a/arma/client/addons/garage/XEH_postInitClient.sqf b/arma/client/addons/garage/XEH_postInitClient.sqf index 76cd623..e187c2d 100644 --- a/arma/client/addons/garage/XEH_postInitClient.sqf +++ b/arma/client/addons/garage/XEH_postInitClient.sqf @@ -55,7 +55,7 @@ if (isNil QGVAR(VGRepository)) then { call FUNC(initVGRepository); }; }] call CFUNC(addEventHandler); [{ - EGVAR(bank,BankRepository) get "isLoaded"; + EGVAR(actor,ActorRepository) get "isLoaded"; }, { [QGVAR(initGarage), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/locker/XEH_postInitClient.sqf b/arma/client/addons/locker/XEH_postInitClient.sqf index 20123fa..a0a878d 100644 --- a/arma/client/addons/locker/XEH_postInitClient.sqf +++ b/arma/client/addons/locker/XEH_postInitClient.sqf @@ -36,7 +36,7 @@ if (isNil QGVAR(VARepository)) then { call FUNC(initVARepository); }; }] call CFUNC(addEventHandler); [{ - EGVAR(garage,GarageRepository) get "isLoaded"; + EGVAR(actor,ActorRepository) get "isLoaded"; }, { [QGVAR(initLocker), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index 5ddcfc0..cd70afc 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -51,7 +51,7 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; }] call CFUNC(addEventHandler); [{ - EGVAR(locker,VARepository) get "isLoaded"; + EGVAR(actor,ActorRepository) get "isLoaded"; }, { [QGVAR(initOrg), []] call CFUNC(localEvent); }] call CFUNC(waitUntilAndExecute); diff --git a/arma/server/addons/actor/functions/fnc_initActorStore.sqf b/arma/server/addons/actor/functions/fnc_initActorStore.sqf index 7a83da8..d48a960 100644 --- a/arma/server/addons/actor/functions/fnc_initActorStore.sqf +++ b/arma/server/addons/actor/functions/fnc_initActorStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initActorStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-04-01 + * Last Update: 2026-04-05 * Public: Yes * * Description: @@ -153,12 +153,140 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]], ["_initialize", false, [false]]]; if (_uid isEqualTo "") exitWith { createHashMap }; + if (_initialize) then { + private _ensureResult = _self call ["ensurePersistentActor", [_uid]]; + if !(_ensureResult isEqualType true && { _ensureResult }) exitWith { createHashMap }; + }; private _command = ["actor:hot:get", "actor:hot:init"] select _initialize; private _actor = _self call ["callHotActor", [_command, [_uid]]]; if (_actor isEqualTo createHashMap) exitWith { _actor }; - _self call ["cacheActor", [_uid, _actor]] + _self call ["hydrateActorIfNeeded", [_uid, _actor, true]] + }], + ["ensurePersistentActor", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { false }; + + ["actor:exists", [_uid]] call EFUNC(extension,extCall) params ["_existsResult", "_existsSuccess"]; + if (!_existsSuccess || { !(_existsResult isEqualType "") }) exitWith { + ["ERROR", format ["Failed to verify persistent actor state for %1.", _uid]] call EFUNC(common,log); + false + }; + + if (_existsResult isEqualTo "true") exitWith { true }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = GVAR(ActorModel) call ["fromPlayer", [_player]]; + _actor set ["uid", _uid]; + + if ((_actor getOrDefault ["organization", ""]) isEqualTo "") then { + _actor set ["organization", "default"]; + }; + + private _json = _self call ["toJSON", [_actor]]; + ["actor:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; + + if (!_createSuccess || { !(_createResult isEqualType "") }) exitWith { + ["ERROR", format ["Failed to create actor %1 from server snapshot.", _uid]] call EFUNC(common,log); + false + }; + + if ((_createResult find "Error:") == 0) exitWith { + ["ERROR", format ["Actor create for %1 failed: %2", _uid, _createResult]] call EFUNC(common,log); + false + }; + + true + }], + ["hydrateActorIfNeeded", compileFinal { + params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]], ["_save", true, [false]]]; + + if (_uid isEqualTo "" || { !(_actor isEqualType createHashMap) } || { _actor isEqualTo createHashMap }) exitWith { + createHashMap + }; + + private _hydratedActor = GVAR(ActorModel) call ["migrate", [+_actor]]; + private _defaults = GVAR(ActorModel) call ["defaults", []]; + private _player = [_uid] call EFUNC(common,getPlayer); + private _needsPersist = false; + + if ((_hydratedActor getOrDefault ["uid", ""]) isEqualTo "") then { + _hydratedActor set ["uid", _uid]; + _needsPersist = true; + }; + if ((_hydratedActor getOrDefault ["organization", ""]) isEqualTo "") then { + _hydratedActor set ["organization", "default"]; + _needsPersist = true; + }; + + { + private _value = _hydratedActor getOrDefault [_x, ""]; + if !(_value isEqualType "") then { + _hydratedActor set [_x, _defaults getOrDefault [_x, ""]]; + _needsPersist = true; + }; + } forEach ["phone_number", "email"]; + + if (_player isNotEqualTo objNull) then { + private _snapshot = GVAR(ActorModel) call ["fromPlayer", [_player]]; + private _name = _hydratedActor getOrDefault ["name", ""]; + if ( + !(_name isEqualType "") + || { _name isEqualTo "" } + || { toLowerANSI _name isEqualTo "unknown" } + ) then { + _hydratedActor set ["name", _snapshot getOrDefault ["name", name _player]]; + _needsPersist = true; + }; + + private _position = _hydratedActor getOrDefault ["position", []]; + if !(_position isEqualType [] && { count _position isEqualTo 3 }) then { + _hydratedActor set ["position", _snapshot getOrDefault ["position", getPosASL _player]]; + _needsPersist = true; + }; + + private _direction = _hydratedActor getOrDefault ["direction", 0]; + if !(_direction isEqualType 0) then { + _hydratedActor set ["direction", _snapshot getOrDefault ["direction", getDir _player]]; + _needsPersist = true; + }; + + { + private _fieldValue = _hydratedActor getOrDefault [_x, ""]; + if (!(_fieldValue isEqualType "") || { _fieldValue isEqualTo "" }) then { + _hydratedActor set [_x, _snapshot getOrDefault [_x, _defaults getOrDefault [_x, ""]]]; + _needsPersist = true; + }; + } forEach ["stance", "rank", "state"]; + + private _loadout = _hydratedActor getOrDefault ["loadout", []]; + if !(_loadout isEqualType [] && { count _loadout > 0 }) then { + _hydratedActor set ["loadout", getUnitLoadout _player]; + _needsPersist = true; + }; + } else { + { + private _fieldValue = _hydratedActor getOrDefault [_x, ""]; + if (!(_fieldValue isEqualType "") || { _fieldValue isEqualTo "" }) then { + _hydratedActor set [_x, _defaults getOrDefault [_x, ""]]; + _needsPersist = true; + }; + } forEach ["stance", "rank", "state"]; + }; + + if !_needsPersist exitWith { + _self call ["cacheActor", [_uid, _hydratedActor]] + }; + + private _updatedActor = _self call ["override", [_uid, _hydratedActor, _save]]; + if (_updatedActor isEqualType createHashMap && { _updatedActor isNotEqualTo createHashMap }) exitWith { + _self call ["cacheActor", [_uid, _updatedActor]] + }; + + ["WARNING", format ["Failed to hydrate actor %1 from player snapshot.", _uid]] call EFUNC(common,log); + _self call ["cacheActor", [_uid, _hydratedActor]] }], ["init", compileFinal { params [["_uid", "", [""]]]; @@ -182,14 +310,11 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _finalActor = _self call ["loadHotActor", [_uid, true]]; ["INFO", format ["Found actor for %1", _uid]] call EFUNC(common,log); } else { - _finalActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; - _finalActor set ["uid", _uid]; - - private _json = _self call ["toJSON", [_finalActor]]; - ["actor:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; - if (!_createSuccess) exitWith { + if !(_self call ["ensurePersistentActor", [_uid]]) exitWith { ["ERROR", format ["Failed to create actor %1! Using fallback actor.", _uid]] call EFUNC(common,log); + _finalActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; + _finalActor set ["uid", _uid]; _finalActor = _self call ["cacheActor", [_uid, _finalActor]]; [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); _finalActor @@ -358,6 +483,9 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ _finalActor set ["rank", rank _player]; _finalActor set ["state", lifeState _player]; _finalActor set ["loadout", getUnitLoadout _player]; + if ((_finalActor getOrDefault ["organization", ""]) isEqualTo "") then { + _finalActor set ["organization", "default"]; + }; } else { ["WARNING", format ["No player object found for %1 during actor snapshot, using cached values.", _uid]] call EFUNC(common,log); }; diff --git a/arma/server/addons/extension/functions/fnc_extCall.sqf b/arma/server/addons/extension/functions/fnc_extCall.sqf index 7d7ec29..e7145b6 100644 --- a/arma/server/addons/extension/functions/fnc_extCall.sqf +++ b/arma/server/addons/extension/functions/fnc_extCall.sqf @@ -31,17 +31,18 @@ private _chunkPrefix = "FORGE_TRANSPORT_CHUNK:"; private _chunkPrefixLength = count toArray _chunkPrefix; private _unsupportedRoutePrefix = "Error: Unsupported transport route"; private _requestChunkSize = 12000; +// Keep bootstrap create/update calls on the direct extension path by default. +// Actor/bank initialization payloads are small enough for normal callExtension +// usage, and their correctness depends on preserving the native argument shape +// of [uid, json]. Transport remains available automatically for genuinely large +// requests through the chunked-request path below. private _transportResponseFunctions = [ "actor:get", - "actor:create", - "actor:update", "actor:hot:init", "actor:hot:get", "actor:hot:keys", "actor:hot:save", "bank:get", - "bank:create", - "bank:update", "bank:hot:init", "bank:hot:get", "bank:hot:save", @@ -127,7 +128,10 @@ private _checkRedisAvailability = { }; private _buildTransportArgumentsJson = { - params [["_rawArguments", [], [[]]]]; + private _rawArguments = _this; + if !(_rawArguments isEqualType []) then { + _rawArguments = [_rawArguments]; + }; private _stringArguments = _rawArguments apply { if (_x isEqualType "") exitWith { _x }; @@ -162,10 +166,12 @@ if (_functionLower in ["status", "version"]) exitWith { [_function, _arguments] call _callExtensionCommand }; -private _argumentsJson = [_arguments] call _buildTransportArgumentsJson; +private _argumentsJson = _arguments call _buildTransportArgumentsJson; private _usesTransportResponse = _functionLower in _transportResponseFunctions; private _usesChunkedRequest = (count toArray _argumentsJson) > _requestChunkSize; +// Most calls should stay direct unless they either need chunked response +// assembly or the request body is large enough to require staging. if !(_usesTransportResponse || { _usesChunkedRequest }) exitWith { [_function, _arguments] call _callExtensionCommand }; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index f0de6a0..dd4b2a0 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -262,7 +262,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; private _memberName = _actor getOrDefault ["name", ""]; - if (_memberName isEqualTo "" && { _player isNotEqualTo objNull }) then { + if ((_memberName isEqualTo "" || { toLowerANSI _memberName isEqualTo "unknown" }) && { _player isNotEqualTo objNull }) then { _memberName = name _player; }; if (_memberName isEqualTo "") then { _memberName = "Unknown"; }; @@ -273,7 +273,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { createHashMap }; - private _actorPatch = EGVAR(actor,ActorStore) call ["set", [_uid, "organization", _orgID, false]]; + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [_uid, "organization", _orgID, true]]; private _updatedActor = EGVAR(actor,ActorStore) call ["load", [_uid]]; if ( !(_updatedActor isEqualType createHashMap) @@ -287,7 +287,7 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ }; _forcedActor set ["organization", _orgID]; - _updatedActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, false]]; + _updatedActor = EGVAR(actor,ActorStore) call ["override", [_uid, _forcedActor, true]]; if (_updatedActor isEqualType createHashMap && { _updatedActor isNotEqualTo createHashMap }) then { _actorPatch = createHashMapFromArray [["organization", _orgID]]; }; @@ -752,12 +752,35 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["existingOrgId", _existingOrgID] ]; - private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:register", [toJSON _context]]]; - if (_envelope isEqualTo createHashMap) exitWith { - _result set ["message", "Organization registration failed."]; + ["org:hot:register", [toJSON _context]] call EFUNC(extension,extCall) params ["_rawResult", "_isSuccess"]; + if !_isSuccess exitWith { + _result set ["message", "Organization service was unavailable during registration."]; _result }; + if !(_rawResult isEqualType "") exitWith { + _result set ["message", "Organization service returned an invalid registration response."]; + _result + }; + + if ((_rawResult find "Error:") == 0) exitWith { + _result set ["message", _rawResult select [7]]; + _result + }; + + private _envelope = fromJSON _rawResult; + if !(_envelope isEqualType createHashMap) exitWith { + _result set ["message", "Organization service returned malformed registration data."]; + _result + }; + + if ("org" in _envelope) then { + private _syncedOrg = _self call ["syncHotOrg", [_envelope getOrDefault ["org", createHashMap]]]; + if (_syncedOrg isNotEqualTo createHashMap) then { + _envelope set ["org", _syncedOrg]; + }; + }; + private _actorPatch = _self call ["applyActorOrganization", [_uid, _envelope getOrDefault ["actorOrganization", _orgID], _actor]]; if (_actorPatch isEqualTo createHashMap) exitWith { _result set ["message", "Failed to assign the player to the new organization."]; diff --git a/lib/services/src/actor.rs b/lib/services/src/actor.rs index 040660b..103330b 100644 --- a/lib/services/src/actor.rs +++ b/lib/services/src/actor.rs @@ -42,20 +42,27 @@ impl ActorHotStateService { return Ok(actor); } - let actor = match self.service.repository.get_by_id(&key)? { - Some(actor) => actor, - None => { - let actor = Actor::new(key.clone()).map_err(|e| e.to_string())?; - self.service.repository.create(&actor)?; - actor - } - }; + let actor = self + .service + .repository + .get_by_id(&key)? + .ok_or_else(|| format!("Actor with UID '{}' was not found", key))?; self.repository.save(&actor)?; Ok(actor) } pub fn get_actor(&self, key: String) -> Result { - self.init_actor(key) + if let Some(actor) = self.repository.get(&key)? { + return Ok(actor); + } + + let actor = self + .service + .repository + .get_by_id(&key)? + .ok_or_else(|| format!("Actor with UID '{}' was not found", key))?; + self.repository.save(&actor)?; + Ok(actor) } pub fn override_actor(&self, key: String, json_data: String) -> Result { diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 96e00e1..dfbda93 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -423,12 +423,22 @@ impl OrgHotStateService { } let mut org = self.get_org(context.org_id)?; - if !org.members.contains_key(&context.member_uid) { - let member_name = if context.member_name.trim().is_empty() { - "Unknown".to_string() - } else { - context.member_name - }; + let member_name = if context.member_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.member_name + }; + let should_refresh_member_name = org + .members + .get(&context.member_uid) + .map(|member| { + let existing_name = member.name.trim(); + !member_name.eq_ignore_ascii_case("unknown") + && (existing_name.is_empty() || existing_name.eq_ignore_ascii_case("unknown")) + }) + .unwrap_or(false); + + if !org.members.contains_key(&context.member_uid) || should_refresh_member_name { org.members.insert( context.member_uid.clone(), MemberSummary { From 3599f802c8e66d7436d531422b1bd9894f221393 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 5 Apr 2026 21:57:45 -0500 Subject: [PATCH 17/19] Tighten actor cache loading flow - Clarify client loading status text - Document server-side actor hydration and snapshot recovery - Stop hot reads from forcing repository initialization --- arma/client/addons/actor/functions/fnc_initRepository.sqf | 2 +- arma/server/addons/actor/XEH_preInit.sqf | 2 -- arma/server/addons/actor/functions/fnc_initActorStore.sqf | 7 ++++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/arma/client/addons/actor/functions/fnc_initRepository.sqf b/arma/client/addons/actor/functions/fnc_initRepository.sqf index 71cdd8a..12a2b4d 100644 --- a/arma/client/addons/actor/functions/fnc_initRepository.sqf +++ b/arma/client/addons/actor/functions/fnc_initRepository.sqf @@ -33,7 +33,7 @@ GVAR(ActorRepositoryBaseClass) = compileFinal createHashMapFromArray [ [SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; - systemChat format ["Actor loaded for %1", name player]; + systemChat format ["Loading actor for %1", name player]; diag_log "[FORGE:Client:Actor] Actor Repository Initialized!"; }], ["save", compileFinal { diff --git a/arma/server/addons/actor/XEH_preInit.sqf b/arma/server/addons/actor/XEH_preInit.sqf index d47f97b..4fff430 100644 --- a/arma/server/addons/actor/XEH_preInit.sqf +++ b/arma/server/addons/actor/XEH_preInit.sqf @@ -4,8 +4,6 @@ PREP_RECOMPILE_START; #include "XEH_PREP.hpp" PREP_RECOMPILE_END; -// private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; - [QGVAR(requestInitActor), { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/actor/functions/fnc_initActorStore.sqf b/arma/server/addons/actor/functions/fnc_initActorStore.sqf index d48a960..2f19cc5 100644 --- a/arma/server/addons/actor/functions/fnc_initActorStore.sqf +++ b/arma/server/addons/actor/functions/fnc_initActorStore.sqf @@ -154,6 +154,8 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ if (_uid isEqualTo "") exitWith { createHashMap }; if (_initialize) then { + // Missing actors should be created explicitly from a server snapshot + // before the hot cache is initialized. private _ensureResult = _self call ["ensurePersistentActor", [_uid]]; if !(_ensureResult isEqualType true && { _ensureResult }) exitWith { createHashMap }; }; @@ -207,6 +209,8 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ createHashMap }; + // Hot actor reads can still surface older partial records. Repair them + // from the live player snapshot when possible and persist the result. private _hydratedActor = GVAR(ActorModel) call ["migrate", [+_actor]]; private _defaults = GVAR(ActorModel) call ["defaults", []]; private _player = [_uid] call EFUNC(common,getPlayer); @@ -338,9 +342,6 @@ GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ params [["_uid", "", [""]], ["_field", "", [""]]]; private _actor = _self call ["loadHotActor", [_uid, false]]; - if (_actor isEqualTo createHashMap) then { - _actor = _self call ["loadHotActor", [_uid, true]]; - }; if (_field isEqualTo "") exitWith { _actor }; _actor getOrDefault [_field, nil] From 8a31d456f168466dcbe4a66bada6ce9f8f958d37 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 5 Apr 2026 22:32:00 -0500 Subject: [PATCH 18/19] Add org invite request and response handling - Wire invite request, accept, and decline events through the UI bridge - Add client and server handlers for invite success and failure responses - Extend portal state and UI to support member invite actions --- arma/client/addons/org/XEH_postInitClient.sqf | 12 + .../org/functions/fnc_handleUIEvents.sqf | 9 + .../addons/org/functions/fnc_initUIBridge.sqf | 39 +++ arma/client/addons/org/ui/_site/org-ui.js | 2 +- arma/client/addons/org/ui/src/bridge.js | 98 ++++++++ .../ui/src/components/portal/membersCard.js | 149 ++++++++++- .../ui/src/components/portal/modalLayer.js | 69 ++++++ .../addons/org/ui/src/portal/actions.js | 79 ++++++ arma/client/addons/org/ui/src/portal/data.js | 10 + arma/client/addons/org/ui/src/portal/store.js | 11 + arma/server/addons/org/XEH_preInit.sqf | 121 +++++++++ .../addons/org/functions/fnc_initOrgStore.sqf | 232 +++++++++++++++++- .../org/functions/fnc_initPayloadBuilder.sqf | 65 +++++ arma/server/extension/src/org.rs | 56 ++++- arma/server/extension/src/transport.rs | 16 ++ lib/models/src/lib.rs | 4 +- lib/models/src/org.rs | 52 ++++ lib/repositories/src/org.rs | 8 + lib/services/src/org.rs | 182 +++++++++++++- 19 files changed, 1204 insertions(+), 10 deletions(-) diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index cd70afc..081d03a 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -50,6 +50,18 @@ if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; GVAR(OrgUIBridge) call ["handleCreditLineResponse", [_payload]]; }] call CFUNC(addEventHandler); +[QGVAR(responseInviteOrg), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleInviteResponse", [_payload]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseInviteDecision), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleInviteDecisionResponse", [_payload]]; +}] call CFUNC(addEventHandler); + [{ EGVAR(actor,ActorRepository) get "isLoaded"; }, { diff --git a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf index 6563ac6..16746de 100644 --- a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf @@ -46,6 +46,15 @@ switch (_event) do { case "org::credit::request": { GVAR(OrgUIBridge) call ["requestCreditLine", [_data]]; }; + case "org::invite::request": { + GVAR(OrgUIBridge) call ["requestInvite", [_data]]; + }; + case "org::invite::accept": { + GVAR(OrgUIBridge) call ["requestAcceptInvite", [_data]]; + }; + case "org::invite::decline": { + GVAR(OrgUIBridge) call ["requestDeclineInvite", [_data]]; + }; case "org::ready": { GVAR(OrgUIBridge) call ["handleReady", [_control]]; }; diff --git a/arma/client/addons/org/functions/fnc_initUIBridge.sqf b/arma/client/addons/org/functions/fnc_initUIBridge.sqf index a5593df..dc4f455 100644 --- a/arma/client/addons/org/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/org/functions/fnc_initUIBridge.sqf @@ -161,6 +161,26 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ }; }; }], + ["handleInviteResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = [ + "org::invite::failure", + "org::invite::success" + ] select (_payload getOrDefault ["success", false]); + + _self call ["sendEvent", [_eventName, _payload]]; + }], + ["handleInviteDecisionResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = [ + "org::invite::decision::failure", + "org::invite::decision::success" + ] select (_payload getOrDefault ["success", false]); + + _self call ["sendEvent", [_eventName, _payload]]; + }], ["requestDisband", compileFinal { [SRPC(org,requestDisbandOrg), [getPlayerUID player]] call CFUNC(serverEvent); }], @@ -176,6 +196,25 @@ GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ [SRPC(org,requestAssignCreditLine), [getPlayerUID player, _memberUid, _memberName, _amount]] call CFUNC(serverEvent); }], + ["requestInvite", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _targetUid = _data getOrDefault ["targetUid", ""]; + private _targetName = _data getOrDefault ["targetName", ""]; + [SRPC(org,requestInviteOrgMember), [getPlayerUID player, _targetUid, _targetName]] call CFUNC(serverEvent); + }], + ["requestAcceptInvite", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _orgID = _data getOrDefault ["orgId", ""]; + [SRPC(org,requestAcceptOrgInvite), [getPlayerUID player, _orgID]] call CFUNC(serverEvent); + }], + ["requestDeclineInvite", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _orgID = _data getOrDefault ["orgId", ""]; + [SRPC(org,requestDeclineOrgInvite), [getPlayerUID player, _orgID]] call CFUNC(serverEvent); + }], ["refreshPortal", compileFinal { _self call ["requestHydrate", ["org::sync"]] }] diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js index 38b3707..a0307e5 100644 --- a/arma/client/addons/org/ui/_site/org-ui.js +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -1 +1 @@ -!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.availableAmount||e.amount||0,amountDue:e.amountDue||0,approvedAmount:e.approvedAmount||e.availableAmount||e.amount||0,availableAmount:e.availableAmount||e.amount||0,interestRate:e.interestRate||.1,member:e.memberName||"",outstandingPrincipal:e.outstandingPrincipal||0,uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=t.reduce((e,n)=>e+Number(n.availableAmount||n.amount||0),0),h=t.reduce((e,n)=>e+Number(n.amountDue||0),0),y=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Available"),n("strong",null,i.formatCurrency(e.availableAmount||e.amount))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount Due"),n("strong",null,i.formatCurrency(e.amountDue))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Interest"),n("strong",null,`${Math.round(100*Number(e.interestRate||0))}%`))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`)),n("div",null,n("span",{className:"org-meta-label"},"Reserved Credit"),n("strong",null,i.formatCurrency(w))),n("div",null,n("span",{className:"org-meta-label"},"Outstanding Due"),n("strong",null,i.formatCurrency(h)))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,y),n("span",null,t.length>0?"Open the Credit Lines tab to review reserved balances, due amounts, and member exposure.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file +!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::invite::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite sent.")}),r.on("org::invite::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to send organization invite.")}),r.on("org::invite::decision::success",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite updated.")}),r.on("org::invite::decision::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to update organization invite.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.availableAmount||e.amount||0,amountDue:e.amountDue||0,approvedAmount:e.approvedAmount||e.availableAmount||e.amount||0,availableAmount:e.availableAmount||e.amount||0,interestRate:e.interestRate||.1,member:e.memberName||"",outstandingPrincipal:e.outstandingPrincipal||0,uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},requestInvitePlayer:function(e){if(t("org::invite::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestAcceptInvite:function(e){if(t("org::invite::accept",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestDeclineInvite:function(e){if(t("org::invite::decline",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],pendingInvites:[],inviteablePlayers:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.pendingInvites,i(e.portalData.pendingInvites)),a(this.portalData.inviteablePlayers,i(e.portalData.inviteablePlayers)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getPendingInvites,this.setPendingInvites]=n([...r.pendingInvites]),[this.getInviteablePlayers,this.setInviteablePlayers]=n([...r.inviteablePlayers]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setPendingInvites([...a(n.pendingInvites)]),this.setInviteablePlayers([...a(n.inviteablePlayers)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?"invite"!==e||t.canManageMembers()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}sendInvite(e){if(!t.canManageMembers())return this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."),!1;const n=r.getInviteablePlayers().find(n=>String(n.uid||"")===String(e));if(!n)return this.showTreasuryNotice("error","Select an online player to invite."),!1;const a=window.RegistryApp?window.RegistryApp.bridge:null;return a&&"function"==typeof a.requestInvitePlayer?a.requestInvitePlayer({targetUid:String(n.uid||""),targetName:String(n.name||"")}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}acceptInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestAcceptInvite?n.requestAcceptInvite({orgId:e}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}declineInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestDeclineInvite?n.requestDeclineInvite({orgId:e}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),b=a.getMembers().length,f=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${b} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",f,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),b=c(),f=g(),w=t.reduce((e,n)=>e+Number(n.availableAmount||n.amount||0),0),v=t.reduce((e,n)=>e+Number(n.amountDue||0),0),h=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),f?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===b?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Available"),n("strong",null,i.formatCurrency(e.availableAmount||e.amount))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount Due"),n("strong",null,i.formatCurrency(e.amountDue))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Interest"),n("strong",null,`${Math.round(100*Number(e.interestRate||0))}%`))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`)),n("div",null,n("span",{className:"org-meta-label"},"Reserved Credit"),n("strong",null,i.formatCurrency(w))),n("div",null,n("span",{className:"org-meta-label"},"Outstanding Due"),n("strong",null,i.formatCurrency(v)))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,h),n("span",null,t.length>0?"Open the Credit Lines tab to review reserved balances, due amounts, and member exposure.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-members-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${s} .org-members-section {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n margin-bottom: 1.25rem;\n}\n\n${s} .org-members-section h4 {\n margin: 0;\n font-size: 0.85rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n${s} .org-name-copy {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .org-name-meta {\n font-size: 0.8rem;\n color: var(--text-muted);\n}\n\n${s} .org-inline-actions {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-left: auto;\n}\n\n${s} .org-members-empty {\n margin: 0;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${s} .org-members-head {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button,\n ${s} .org-inline-actions {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=t.getPendingInvites(),c=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},n("div",{className:"org-members-head"},n("div",{className:"org-members-section"},n("h4",null,"Pending Invites"),0===d.length?n("p",{className:"org-members-empty"},"No incoming organization invites."):null),c?n("button",{type:"button",className:"org-secondary-btn",onClick:()=>o.openModal("invite")},"Invite Player"):null),...d.map(e=>n("article",{className:"org-name-row"},n("div",{className:"org-name-copy"},n("strong",null,e.orgName||"Unknown Organization"),n("span",{className:"org-name-meta"},"Invited by ",e.inviterName||"Unknown")),n("div",{className:"org-inline-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>o.declineInvite(String(e.orgId||""))},"Decline"),n("button",{type:"button",onClick:()=>o.acceptInvite(String(e.orgId||""))},"Accept")))),n("div",{className:"org-members-section"},n("h4",null,"Roster")),...s.map(e=>{const r=c&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("div",{className:"org-name-copy"},n("strong",null,e.name),e.uid?n("span",{className:"org-name-meta"},e.uid):null),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=t.getInviteablePlayers(),l=0===i.length?{disabled:!0}:{},d=0===s.length?{disabled:!0}:{};let c="",m=null;return"payroll"===o.type?(c="Run Payroll",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(c="Send Funds",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...l},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(c="Assign Credit Line",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...l},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"invite"===o.type?(c="Invite Player",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Online Player"),n("select",{id:"org-invite-player",...d},...s.map(e=>n("option",{value:e.uid},e.name||e.uid)))),0===s.length?n("p",null,"No eligible online players are currently available for invites."):null,n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...d,onClick:()=>{a.sendInvite(String(a.getInputValue("org-invite-player")||""))&&a.closeModal()}},"Send Invite")))):"disband"===o.type?(c="Disband Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(c="Leave Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:c,body:m,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let b;return"home"===c?b=s():"create"===c&&(b=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),b),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/org/ui/src/bridge.js b/arma/client/addons/org/ui/src/bridge.js index d5fab25..5ae91ab 100644 --- a/arma/client/addons/org/ui/src/bridge.js +++ b/arma/client/addons/org/ui/src/bridge.js @@ -80,6 +80,57 @@ return false; } + function requestInvitePlayer(payload) { + const sent = sendEvent("org::invite::request", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma organization invite bridge is unavailable.", + ); + } + + return false; + } + + function requestAcceptInvite(payload) { + const sent = sendEvent("org::invite::accept", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma organization invite bridge is unavailable.", + ); + } + + return false; + } + + function requestDeclineInvite(payload) { + const sent = sendEvent("org::invite::decline", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma organization invite bridge is unavailable.", + ); + } + + return false; + } + bridge.on("org::login::success", (payloadData) => { store.completeLogin(payloadData); }); @@ -128,6 +179,50 @@ } }); + bridge.on("org::invite::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Organization invite sent.", + ); + } + }); + + bridge.on("org::invite::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to send organization invite.", + ); + } + }); + + bridge.on("org::invite::decision::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Organization invite updated.", + ); + } + }); + + bridge.on("org::invite::decision::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to update organization invite.", + ); + } + }); + bridge.on("org::member::creditUpdated", (payloadData) => { const OrgPortal = window.OrgPortal; if (!OrgPortal || !OrgPortal.store) { @@ -234,6 +329,9 @@ requestDisbandOrg, requestLeaveOrg, requestCreditLine, + requestInvitePlayer, + requestAcceptInvite, + requestDeclineInvite, sendEvent, }; })(); diff --git a/arma/client/addons/org/ui/src/components/portal/membersCard.js b/arma/client/addons/org/ui/src/components/portal/membersCard.js index 6108286..0346786 100644 --- a/arma/client/addons/org/ui/src/components/portal/membersCard.js +++ b/arma/client/addons/org/ui/src/components/portal/membersCard.js @@ -19,6 +19,29 @@ ${scopeSelector} .org-name-list { scrollbar-color: #94a3b8 #e2e8f0; } +${scopeSelector} .org-members-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +${scopeSelector} .org-members-section { + display: flex; + flex-direction: column; + gap: 0.85rem; + margin-bottom: 1.25rem; +} + +${scopeSelector} .org-members-section h4 { + margin: 0; + font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + ${scopeSelector} .org-name-row { display: flex; align-items: center; @@ -39,13 +62,43 @@ ${scopeSelector} .org-name-row button { margin-left: auto; } +${scopeSelector} .org-name-copy { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +${scopeSelector} .org-name-meta { + font-size: 0.8rem; + color: var(--text-muted); +} + +${scopeSelector} .org-inline-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +${scopeSelector} .org-members-empty { + margin: 0; + font-size: 0.9rem; + color: var(--text-muted); +} + @media (max-width: 960px) { + ${scopeSelector} .org-members-head { + flex-direction: column; + align-items: flex-start; + } + ${scopeSelector} .org-name-row { flex-direction: column; align-items: flex-start; } - ${scopeSelector} .org-name-row button { + ${scopeSelector} .org-name-row button, + ${scopeSelector} .org-inline-actions { margin-left: 0; } } @@ -56,6 +109,7 @@ ${scopeSelector} .org-name-row button { OrgPortal.componentFns.MembersCard = function MembersCard() { const PanelCard = window.SharedUI.componentFns.PanelCard; const members = store.getMembers(); + const pendingInvites = store.getPendingInvites(); const allowMemberManagement = getters.canManageMembers(); ensureScopedStyle("portal-members-card", membersCardCss); @@ -68,6 +122,86 @@ ${scopeSelector} .org-name-row button { body: h( "div", { className: "org-name-list" }, + h( + "div", + { className: "org-members-head" }, + h( + "div", + { className: "org-members-section" }, + h("h4", null, "Pending Invites"), + pendingInvites.length === 0 + ? h( + "p", + { className: "org-members-empty" }, + "No incoming organization invites.", + ) + : null, + ), + allowMemberManagement + ? h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.openModal("invite"), + }, + "Invite Player", + ) + : null, + ), + ...pendingInvites.map((invite) => + h( + "article", + { className: "org-name-row" }, + h( + "div", + { className: "org-name-copy" }, + h( + "strong", + null, + invite.orgName || "Unknown Organization", + ), + h( + "span", + { className: "org-name-meta" }, + "Invited by ", + invite.inviterName || "Unknown", + ), + ), + h( + "div", + { className: "org-inline-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.declineInvite( + String(invite.orgId || ""), + ), + }, + "Decline", + ), + h( + "button", + { + type: "button", + onClick: () => + actions.acceptInvite( + String(invite.orgId || ""), + ), + }, + "Accept", + ), + ), + ), + ), + h( + "div", + { className: "org-members-section" }, + h("h4", null, "Roster"), + ), ...members.map((member) => { const canRemoveMember = allowMemberManagement && @@ -76,7 +210,18 @@ ${scopeSelector} .org-name-row button { return h( "article", { className: "org-name-row" }, - h("strong", null, member.name), + h( + "div", + { className: "org-name-copy" }, + h("strong", null, member.name), + member.uid + ? h( + "span", + { className: "org-name-meta" }, + member.uid, + ) + : null, + ), canRemoveMember ? h( "button", diff --git a/arma/client/addons/org/ui/src/components/portal/modalLayer.js b/arma/client/addons/org/ui/src/components/portal/modalLayer.js index db83264..c1fe372 100644 --- a/arma/client/addons/org/ui/src/components/portal/modalLayer.js +++ b/arma/client/addons/org/ui/src/components/portal/modalLayer.js @@ -15,8 +15,11 @@ } const members = store.getMembers(); + const inviteablePlayers = store.getInviteablePlayers(); const memberSelectProps = members.length === 0 ? { disabled: true } : {}; + const inviteSelectProps = + inviteablePlayers.length === 0 ? { disabled: true } : {}; let title = ""; let body = null; @@ -211,6 +214,72 @@ ), ), ); + } else if (modal.type === "invite") { + title = "Invite Player"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Online Player"), + h( + "select", + { + id: "org-invite-player", + ...inviteSelectProps, + }, + ...inviteablePlayers.map((player) => + h( + "option", + { value: player.uid }, + player.name || player.uid, + ), + ), + ), + ), + inviteablePlayers.length === 0 + ? h( + "p", + null, + "No eligible online players are currently available for invites.", + ) + : null, + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...inviteSelectProps, + onClick: () => { + if ( + actions.sendInvite( + String( + actions.getInputValue( + "org-invite-player", + ) || "", + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Send Invite", + ), + ), + ); } else if (modal.type === "disband") { title = "Disband Organization"; body = h( diff --git a/arma/client/addons/org/ui/src/portal/actions.js b/arma/client/addons/org/ui/src/portal/actions.js index d27e99c..1ed89ec 100644 --- a/arma/client/addons/org/ui/src/portal/actions.js +++ b/arma/client/addons/org/ui/src/portal/actions.js @@ -62,6 +62,14 @@ return; } + if (type === "invite" && !getters.canManageMembers()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can invite players.", + ); + return; + } + if (type === "disband" && !getters.canDisbandOrg()) { return; } @@ -294,6 +302,77 @@ amount, }); } + + sendInvite(targetUid) { + if (!getters.canManageMembers()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can invite players.", + ); + return false; + } + + const target = store + .getInviteablePlayers() + .find((entry) => String(entry.uid || "") === String(targetUid)); + + if (!target) { + this.showTreasuryNotice( + "error", + "Select an online player to invite.", + ); + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestInvitePlayer !== "function") { + this.showTreasuryNotice( + "error", + "Organization invite bridge is unavailable.", + ); + return false; + } + + return bridge.requestInvitePlayer({ + targetUid: String(target.uid || ""), + targetName: String(target.name || ""), + }); + } + + acceptInvite(orgId) { + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestAcceptInvite !== "function") { + this.showTreasuryNotice( + "error", + "Organization invite bridge is unavailable.", + ); + return false; + } + + return bridge.requestAcceptInvite({ orgId }); + } + + declineInvite(orgId) { + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestDeclineInvite !== "function") { + this.showTreasuryNotice( + "error", + "Organization invite bridge is unavailable.", + ); + return false; + } + + return bridge.requestDeclineInvite({ orgId }); + } } OrgPortal.actions = new OrgPortalActions(); diff --git a/arma/client/addons/org/ui/src/portal/data.js b/arma/client/addons/org/ui/src/portal/data.js index a544baf..0e7f29d 100644 --- a/arma/client/addons/org/ui/src/portal/data.js +++ b/arma/client/addons/org/ui/src/portal/data.js @@ -74,6 +74,8 @@ reputation: 0, creditLines: [], members: [], + pendingInvites: [], + inviteablePlayers: [], fleet: [], assets: [], activity: [], @@ -126,6 +128,14 @@ this.portalData.members, normalizeCollection(payload.portalData.members), ); + replaceArray( + this.portalData.pendingInvites, + normalizeCollection(payload.portalData.pendingInvites), + ); + replaceArray( + this.portalData.inviteablePlayers, + normalizeCollection(payload.portalData.inviteablePlayers), + ); replaceArray( this.portalData.fleet, normalizeCollection(payload.portalData.fleet), diff --git a/arma/client/addons/org/ui/src/portal/store.js b/arma/client/addons/org/ui/src/portal/store.js index abdb361..fc9b937 100644 --- a/arma/client/addons/org/ui/src/portal/store.js +++ b/arma/client/addons/org/ui/src/portal/store.js @@ -51,6 +51,11 @@ [this.getMembers, this.setMembers] = createSignal([ ...portalData.members, ]); + [this.getPendingInvites, this.setPendingInvites] = createSignal([ + ...portalData.pendingInvites, + ]); + [this.getInviteablePlayers, this.setInviteablePlayers] = + createSignal([...portalData.inviteablePlayers]); [this.getCreditLines, this.setCreditLines] = createSignal([ ...portalData.creditLines, ]); @@ -77,6 +82,12 @@ this.setFunds(nextPortalData.funds || 0); this.setReputation(nextPortalData.reputation || 0); this.setMembers([...normalizeCollection(nextPortalData.members)]); + this.setPendingInvites([ + ...normalizeCollection(nextPortalData.pendingInvites), + ]); + this.setInviteablePlayers([ + ...normalizeCollection(nextPortalData.inviteablePlayers), + ]); this.setCreditLines([ ...normalizeCollection(nextPortalData.creditLines), ]); diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index fcc4c80..77cefde 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -85,6 +85,127 @@ PREP_RECOMPILE_END; ]], _requester] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); +[QGVAR(requestInviteOrgMember), { + params [["_uid", "", [""]], ["_targetUid", "", [""]], ["_targetName", "", [""]]]; + + if (_uid isEqualTo "" || { _targetUid isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Org] Invalid org invite request payload!" + }; + + private _requester = [_uid] call EFUNC(common,getPlayer); + if (_requester isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["inviteMember", [_uid, _targetUid, _targetName]]; + if (_result getOrDefault ["success", false]) then { + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [createHashMap], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach [_uid, _result getOrDefault ["targetUid", _targetUid]]; + + private _targetPlayer = [_result getOrDefault ["targetUid", _targetUid]] call EFUNC(common,getPlayer); + if (_targetPlayer isNotEqualTo objNull) then { + [CRPC(notifications,recieveNotification), [ + "info", + "Organization Invite", + "You received an organization invite. Open the organization portal to accept or decline it.", + 7000 + ], _targetPlayer] call CFUNC(targetEvent); + }; + }; + + [CRPC(org,responseInviteOrg), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to send organization invite."]] + ]], _requester] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestAcceptOrgInvite), { + params [["_uid", "", [""]], ["_orgID", "", [""]]]; + + if (_uid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Org] Invalid accept invite request payload!" + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["acceptInvite", [_uid, _orgID]]; + if (_result getOrDefault ["success", false]) then { + private _actorPatch = _result getOrDefault ["actorPatch", createHashMap]; + if (_actorPatch isNotEqualTo createHashMap) then { + [CRPC(actor,responseSyncActor), [_actorPatch], _player] call CFUNC(targetEvent); + }; + + private _syncTargets = [_uid]; + { + private _orgData = GVAR(OrgStore) call ["loadById", [_x]]; + if !(_orgData isEqualType createHashMap) then { continue; }; + + { + private _memberUid = _y getOrDefault ["uid", ""]; + if (_memberUid isNotEqualTo "") then { + _syncTargets pushBackUnique _memberUid; + }; + } forEach (_orgData getOrDefault ["members", createHashMap]); + } forEach (_result getOrDefault ["affectedOrgIds", []]); + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [createHashMap], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach _syncTargets; + }; + + [CRPC(org,responseInviteDecision), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to accept organization invite."]], + ["action", "accept"] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestDeclineOrgInvite), { + params [["_uid", "", [""]], ["_orgID", "", [""]]]; + + if (_uid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Org] Invalid decline invite request payload!" + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["declineInvite", [_uid, _orgID]]; + if (_result getOrDefault ["success", false]) then { + private _syncTargets = [_uid]; + { + private _orgData = GVAR(OrgStore) call ["loadById", [_x]]; + if !(_orgData isEqualType createHashMap) then { continue; }; + + { + private _memberUid = _y getOrDefault ["uid", ""]; + if (_memberUid isNotEqualTo "") then { + _syncTargets pushBackUnique _memberUid; + }; + } forEach (_orgData getOrDefault ["members", createHashMap]); + } forEach (_result getOrDefault ["affectedOrgIds", []]); + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { + [CRPC(org,responseSyncOrg), [createHashMap], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach _syncTargets; + }; + + [CRPC(org,responseInviteDecision), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to decline organization invite."]], + ["action", "decline"] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + [QGVAR(requestLeaveOrg), { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index dd4b2a0..e1a8dbd 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -36,6 +36,7 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ _org set ["assets", createHashMap]; _org set ["fleet", createHashMap]; _org set ["members", createHashMap]; + _org set ["pending_invites", createHashMap]; _org }], @@ -111,6 +112,13 @@ GVAR(OrgModel) = compileFinal createHashMapObject [[ _org set ["credit_lines", _creditLines]; + private _pendingInvites = _org getOrDefault ["pending_invites", createHashMap]; + if !(_pendingInvites isEqualType createHashMap) then { + _pendingInvites = createHashMap; + }; + + _org set ["pending_invites", _pendingInvites]; + _org }], ["validate", compileFinal { @@ -158,7 +166,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["credit_lines", createHashMap], ["assets", createHashMap], ["fleet", createHashMap], - ["members", createHashMap] + ["members", createHashMap], + ["pending_invites", createHashMap] ]; _defaultOrg }; @@ -173,7 +182,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["credit_lines", createHashMap], ["assets", createHashMap], ["fleet", createHashMap], - ["members", createHashMap] + ["members", createHashMap], + ["pending_invites", createHashMap] ]; private _defaultJson = _self call ["toJSON", [_defaultOrg]]; @@ -191,7 +201,8 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ ["credit_lines", createHashMap], ["assets", createHashMap], ["fleet", createHashMap], - ["members", createHashMap] + ["members", createHashMap], + ["pending_invites", createHashMap] ]; }; @@ -240,6 +251,24 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _data }], + ["callHotOrgArray", compileFinal { + params [["_function", "", [""]], ["_arguments", [], [[]]]]; + + if (_function isEqualTo "") exitWith { [] }; + + [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { [] }; + if !(_result isEqualType "") exitWith { [] }; + if ((_result find "Error:") == 0) exitWith { + ["ERROR", format ["Org extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log); + [] + }; + + private _data = fromJSON _result; + if !(_data isEqualType []) exitWith { [] }; + + _data + }], ["syncHotOrg", compileFinal { params [["_org", createHashMap, [createHashMap]]]; @@ -356,6 +385,203 @@ GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ _self call ["callHotOrg", ["org:hot:ensure_member", [toJSON _context]]] }], + ["listMemberInvites", compileFinal { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { [] }; + + _self call ["callHotOrgArray", ["org:hot:member_invites", [_uid]]] + }], + ["inviteMember", compileFinal { + params [["_requesterUid", "", [""]], ["_targetUid", "", [""]], ["_targetName", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["targetUid", _targetUid], + ["persisted", false], + ["persistenceMessage", ""] + ]; + + if (_requesterUid isEqualTo "" || { _targetUid isEqualTo "" }) exitWith { + _result set ["message", "A valid organization invite target is required."]; + _result + }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _requesterActor = EGVAR(actor,ActorStore) call ["load", [_requesterUid]]; + private _requesterOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; + private _requesterName = _self call ["resolveActorName", [_requesterUid, _requesterPlayer, _requesterActor]]; + private _requesterIsDefaultOrgCeo = ( + _requesterPlayer isNotEqualTo objNull + && { _requesterOrgID isEqualTo "default" } + && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" } + ); + private _targetOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_targetUid]]; + + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["requesterName", _requesterName], + ["orgId", _requesterOrgID], + ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], + ["targetUid", _targetUid], + ["targetName", _targetName], + ["targetOrgId", _targetOrgID] + ]; + + private _envelope = _self call ["callHotOrgEnvelope", ["org:hot:invite_member", [toJSON _context]]]; + if (_envelope isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to send organization invite."]; + _result + }; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Invitation sent."]]; + _result set ["targetUid", _envelope getOrDefault ["targetUid", _targetUid]]; + _self call ["persistMutationResult", [_requesterOrgID, _result, "Organization invite"]] + }], + ["acceptInvite", compileFinal { + params [["_requesterUid", "", [""]], ["_orgID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["actorPatch", createHashMap], + ["affectedOrgIds", []] + ]; + + if (_requesterUid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { + _result set ["message", "A valid invite selection is required."]; + _result + }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _requesterActor = EGVAR(actor,ActorStore) call ["load", [_requesterUid]]; + private _requesterName = _self call ["resolveActorName", [_requesterUid, _requesterPlayer, _requesterActor]]; + private _existingOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["requesterName", _requesterName], + ["orgId", _orgID], + ["existingOrgId", _existingOrgID] + ]; + + ["org:hot:accept_invite", [toJSON _context]] call EFUNC(extension,extCall) params ["_rawResult", "_isSuccess"]; + if !_isSuccess exitWith { + _result set ["message", "Organization invite service was unavailable."]; + _result + }; + if !(_rawResult isEqualType "") exitWith { + _result set ["message", "Organization invite service returned an invalid response."]; + _result + }; + if ((_rawResult find "Error:") == 0) exitWith { + _result set ["message", _rawResult select [7]]; + _result + }; + + private _envelope = fromJSON _rawResult; + if !(_envelope isEqualType createHashMap) exitWith { + _result set ["message", "Organization invite service returned malformed data."]; + _result + }; + + private _invitedOrg = _self call ["syncHotOrg", [_envelope getOrDefault ["invitedOrg", createHashMap]]]; + if (_invitedOrg isNotEqualTo createHashMap) then { + _envelope set ["invitedOrg", _invitedOrg]; + }; + + private _previousOrgData = _envelope getOrDefault ["previousOrg", createHashMap]; + if (_previousOrgData isEqualType createHashMap && { _previousOrgData isNotEqualTo createHashMap }) then { + private _syncedPreviousOrg = _self call ["syncHotOrg", [_previousOrgData]]; + if (_syncedPreviousOrg isNotEqualTo createHashMap) then { + _envelope set ["previousOrg", _syncedPreviousOrg]; + }; + }; + + private _actorOrg = _envelope getOrDefault ["actorOrganization", _orgID]; + private _actorPatch = _self call ["applyActorOrganization", [_requesterUid, _actorOrg, _requesterActor]]; + if (_actorPatch isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to assign the player to the invited organization."]; + _result + }; + + private _affectedOrgIds = [_actorOrg]; + private _previousOrg = _envelope getOrDefault ["previousOrg", createHashMap]; + if (_previousOrg isEqualType createHashMap && { _previousOrg isNotEqualTo createHashMap }) then { + private _previousOrgID = _previousOrg getOrDefault ["id", ""]; + if (_previousOrgID isNotEqualTo "") then { + _affectedOrgIds pushBackUnique _previousOrgID; + }; + }; + + { + _self call ["saveById", [_x]]; + } forEach _affectedOrgIds; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Organization invite accepted."]]; + _result set ["actorPatch", _actorPatch]; + _result set ["affectedOrgIds", _affectedOrgIds]; + _result + }], + ["declineInvite", compileFinal { + params [["_requesterUid", "", [""]], ["_orgID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["affectedOrgIds", []] + ]; + + if (_requesterUid isEqualTo "" || { _orgID isEqualTo "" }) exitWith { + _result set ["message", "A valid invite selection is required."]; + _result + }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + private _requesterActor = EGVAR(actor,ActorStore) call ["load", [_requesterUid]]; + private _requesterName = _self call ["resolveActorName", [_requesterUid, _requesterPlayer, _requesterActor]]; + private _existingOrgID = EGVAR(actor,ActorStore) call ["getOrganization", [_requesterUid]]; + private _context = createHashMapFromArray [ + ["requesterUid", _requesterUid], + ["requesterName", _requesterName], + ["orgId", _orgID], + ["existingOrgId", _existingOrgID] + ]; + + ["org:hot:decline_invite", [toJSON _context]] call EFUNC(extension,extCall) params ["_rawResult", "_isSuccess"]; + if !_isSuccess exitWith { + _result set ["message", "Organization invite service was unavailable."]; + _result + }; + if !(_rawResult isEqualType "") exitWith { + _result set ["message", "Organization invite service returned an invalid response."]; + _result + }; + if ((_rawResult find "Error:") == 0) exitWith { + _result set ["message", _rawResult select [7]]; + _result + }; + + private _envelope = fromJSON _rawResult; + if !(_envelope isEqualType createHashMap) exitWith { + _result set ["message", "Organization invite service returned malformed data."]; + _result + }; + + private _invitedOrg = _self call ["syncHotOrg", [_envelope getOrDefault ["invitedOrg", createHashMap]]]; + if (_invitedOrg isNotEqualTo createHashMap) then { + _envelope set ["invitedOrg", _invitedOrg]; + }; + + _self call ["saveById", [_orgID]]; + + _result set ["success", true]; + _result set ["message", _envelope getOrDefault ["message", "Organization invite declined."]]; + _result set ["affectedOrgIds", [_orgID]]; + _result + }], ["leave", compileFinal { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf index 3e91122..c1a25df 100644 --- a/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf +++ b/arma/server/addons/org/functions/fnc_initPayloadBuilder.sqf @@ -152,6 +152,64 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[ _creditLinesList }], + ["buildPendingInvitesList", compileFinal { + params [["_pendingInvitesRaw", [], [[]]]]; + + private _pendingInvites = []; + { + if !(_x isEqualType createHashMap) then { continue; }; + + _pendingInvites pushBack [ + ["orgId", _x getOrDefault ["orgId", ""]], + ["orgName", _x getOrDefault ["orgName", "Unknown Organization"]], + ["inviterUid", _x getOrDefault ["inviterUid", ""]], + ["inviterName", _x getOrDefault ["inviterName", "Unknown"]], + ["targetUid", _x getOrDefault ["targetUid", ""]], + ["targetName", _x getOrDefault ["targetName", "Unknown"]] + ]; + } forEach _pendingInvitesRaw; + + _pendingInvites + }], + ["buildInviteablePlayers", compileFinal { + params [ + ["_uid", "", [""]], + ["_orgID", "", [""]], + ["_membersRaw", createHashMap, [createHashMap]], + ["_pendingInvitesRaw", createHashMap, [createHashMap]] + ]; + + private _memberUids = []; + { + _memberUids pushBackUnique (_y getOrDefault ["uid", ""]); + } forEach _membersRaw; + + private _pendingInviteUids = []; + { + _pendingInviteUids pushBackUnique (_x); + } forEach _pendingInvitesRaw; + + private _players = []; + { + private _player = _x; + if (isNull _player) then { continue; }; + + private _playerUid = getPlayerUID _player; + if (_playerUid isEqualTo "" || { _playerUid isEqualTo _uid }) then { continue; }; + if (_playerUid in _memberUids || { _playerUid in _pendingInviteUids }) then { continue; }; + + private _playerOrgID = GVAR(OrgStore) call ["resolveOrgIdForUid", [_playerUid]]; + if (_playerOrgID isNotEqualTo "default") then { continue; }; + + _players pushBack [ + ["uid", _playerUid], + ["name", name _player], + ["orgId", _playerOrgID] + ]; + } forEach allPlayers; + + _players + }], ["buildPortalPayload", compileFinal { params [["_uid", "", [""]]]; @@ -177,9 +235,14 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[ private _assetsRaw = _org getOrDefault ["assets", createHashMap]; private _fleetRaw = _org getOrDefault ["fleet", createHashMap]; private _membersRaw = _org getOrDefault ["members", createHashMap]; + private _pendingInvitesRaw = _org getOrDefault ["pending_invites", createHashMap]; private _isDefaultOrg = (_org getOrDefault ["default", false]) || { toLowerANSI _id isEqualTo "default" } || { toLowerANSI _ownerUid isEqualTo "server" }; + private _memberInvites = []; + if (_isDefaultOrg) then { + _memberInvites = GVAR(OrgStore) call ["listMemberInvites", [_uid]]; + }; private _playerName = name _player; private _playerVar = vehicleVarName _player; @@ -209,6 +272,8 @@ GVAR(OrgPayloadBuilder) = createHashMapObject [[ ["reputation", _reputation], ["creditLines", _self call ["buildCreditLinesList", [_creditLinesRaw]]], ["members", _memberShape getOrDefault ["members", []]], + ["pendingInvites", _self call ["buildPendingInvitesList", [_memberInvites]]], + ["inviteablePlayers", _self call ["buildInviteablePlayers", [_uid, _id, _membersRaw, _pendingInvitesRaw]]], ["fleet", _self call ["buildFleetList", [_fleetRaw]]], ["assets", _self call ["buildAssetsList", [_assetsRaw]]], ["activity", []] diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index e0456ad..9b370fb 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -7,8 +7,9 @@ use arma_rs::Group; use forge_models::{ HotOrgRecord, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandResult, - OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgLeaveContext, OrgLeaveResult, - OrgRegisterContext, + OrgEnsureMemberContext, OrgFleetGrantSeed, OrgGrantContext, OrgInviteContext, + OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, OrgInviteResult, + OrgLeaveContext, OrgLeaveResult, OrgRegisterContext, }; use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository}; use forge_services::{OrgHotStateService, OrgService}; @@ -58,7 +59,11 @@ pub fn group() -> Group { .command("get", get_hot_org) .command("override", override_hot_org) .command("ensure_member", ensure_hot_org_member) + .command("member_invites", get_hot_org_member_invites) .command("register", register_hot_org) + .command("invite_member", invite_hot_org_member) + .command("accept_invite", accept_hot_org_invite) + .command("decline_invite", decline_hot_org_invite) .command("assign_credit_line", assign_credit_line_hot_org) .command("repay_credit_line", repay_credit_line_hot_org) .command("charge_checkout", charge_checkout_hot_org) @@ -142,6 +147,13 @@ pub(crate) fn ensure_hot_org_member(json_data: String) -> String { } } +pub(crate) fn get_hot_org_member_invites(member_uid: String) -> String { + match HOT_ORG_SERVICE.get_member_invites(member_uid) { + Ok(invites) => serialize_result::>(&invites, "org invite list"), + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn register_hot_org(json_data: String) -> String { let context: OrgRegisterContext = match serde_json::from_str(&json_data) { Ok(data) => data, @@ -154,6 +166,46 @@ pub(crate) fn register_hot_org(json_data: String) -> String { } } +pub(crate) fn invite_hot_org_member(json_data: String) -> String { + let context: OrgInviteContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org invite JSON: {}", error), + }; + + match HOT_ORG_SERVICE.invite_member(context) { + Ok(result) => serialize_result::(&result, "org invite result"), + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn accept_hot_org_invite(json_data: String) -> String { + let context: OrgInviteDecisionContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org invite decision JSON: {}", error), + }; + + match HOT_ORG_SERVICE.accept_invite(context) { + Ok(result) => { + serialize_result::(&result, "org invite decision result") + } + Err(error) => format!("Error: {}", error), + } +} + +pub(crate) fn decline_hot_org_invite(json_data: String) -> String { + let context: OrgInviteDecisionContext = match serde_json::from_str(&json_data) { + Ok(data) => data, + Err(error) => return format!("Error: Invalid org invite decision JSON: {}", error), + }; + + match HOT_ORG_SERVICE.decline_invite(context) { + Ok(result) => { + serialize_result::(&result, "org invite decision result") + } + Err(error) => format!("Error: {}", error), + } +} + pub(crate) fn assign_credit_line_hot_org(json_data: String) -> String { let context: OrgCreditLineContext = match serde_json::from_str(&json_data) { Ok(data) => data, diff --git a/arma/server/extension/src/transport.rs b/arma/server/extension/src/transport.rs index 6e3b32c..e2679a5 100644 --- a/arma/server/extension/src/transport.rs +++ b/arma/server/extension/src/transport.rs @@ -359,10 +359,26 @@ fn route_command( expect_arg_count(function_name, &arguments, 1)?; Ok(org::ensure_hot_org_member(arguments[0].clone())) } + "org:hot:member_invites" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::get_hot_org_member_invites(arguments[0].clone())) + } "org:hot:register" => { expect_arg_count(function_name, &arguments, 1)?; Ok(org::register_hot_org(arguments[0].clone())) } + "org:hot:invite_member" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::invite_hot_org_member(arguments[0].clone())) + } + "org:hot:accept_invite" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::accept_hot_org_invite(arguments[0].clone())) + } + "org:hot:decline_invite" => { + expect_arg_count(function_name, &arguments, 1)?; + Ok(org::decline_hot_org_invite(arguments[0].clone())) + } "org:hot:assign_credit_line" => { expect_arg_count(function_name, &arguments, 1)?; Ok(org::assign_credit_line_hot_org(arguments[0].clone())) diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index 243a5a5..7c5a6f4 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -27,7 +27,9 @@ pub use org::{ OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult, OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, - OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, + OrgInviteContext, OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, + OrgInviteResult, OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, + OrgRegisterResult, }; pub use store::{ StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index bda9235..5d05592 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -69,6 +69,17 @@ pub struct MemberSummary { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgInviteRecord { + pub org_id: String, + pub org_name: String, + pub inviter_uid: String, + pub inviter_name: String, + pub target_uid: String, + pub target_name: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HotOrgRecord { pub id: String, @@ -84,6 +95,8 @@ pub struct HotOrgRecord { pub fleet: HashMap, #[serde(default)] pub members: HashMap, + #[serde(default)] + pub pending_invites: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -112,6 +125,44 @@ pub struct OrgRegisterResult { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgInviteContext { + pub requester_uid: String, + pub requester_name: String, + pub org_id: String, + pub requester_is_default_org_ceo: bool, + pub target_uid: String, + pub target_name: String, + pub target_org_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgInviteDecisionContext { + pub requester_uid: String, + pub requester_name: String, + pub org_id: String, + pub existing_org_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgInviteResult { + pub org: HotOrgRecord, + pub target_uid: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrgInviteDecisionResult { + pub invited_org: HotOrgRecord, + pub previous_org: Option, + pub actor_organization: String, + pub message: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OrgCreditLineContext { @@ -329,6 +380,7 @@ impl HotOrgRecord { .into_iter() .map(|member| (member.uid.clone(), member)) .collect(), + pending_invites: HashMap::new(), } } diff --git a/lib/repositories/src/org.rs b/lib/repositories/src/org.rs index eef8441..57027ec 100644 --- a/lib/repositories/src/org.rs +++ b/lib/repositories/src/org.rs @@ -66,6 +66,7 @@ pub trait OrgRepository: Send + Sync { pub trait OrgHotRepository: Send + Sync { fn get(&self, id: &str) -> Result, String>; + fn keys(&self) -> Result, String>; fn save(&self, org: &HotOrgRecord) -> Result<(), String>; fn delete(&self, id: &str) -> Result<(), String>; } @@ -89,6 +90,13 @@ impl OrgHotRepository for InMemoryOrgHotRepository { .map_err(|_| "Org hot state lock poisoned.".to_string()) } + fn keys(&self) -> Result, String> { + self.state + .read() + .map(|state| state.keys().cloned().collect()) + .map_err(|_| "Org hot state lock poisoned.".to_string()) + } + fn save(&self, org: &HotOrgRecord) -> Result<(), String> { self.state .write() diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index dfbda93..14ef310 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -10,7 +10,9 @@ use forge_models::{ OrgAssetEntry, OrgAssetGrantSeed, OrgCheckoutContext, OrgCreditLineContext, OrgCreditLineRepaymentContext, OrgCreditLineRepaymentResult, OrgDisbandMemberResult, OrgDisbandResult, OrgEnsureMemberContext, OrgFleetEntry, OrgFleetGrantSeed, OrgGrantContext, - OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, OrgRegisterResult, + OrgInviteContext, OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, + OrgInviteResult, OrgLeaveContext, OrgLeaveResult, OrgMutationResult, OrgRegisterContext, + OrgRegisterResult, }; use forge_repositories::{OrgHotRepository, OrgRepository}; use serde_json::{Value, json}; @@ -365,6 +367,26 @@ impl OrgHotStateService { self.init_org(id) } + pub fn get_member_invites(&self, member_uid: String) -> Result, String> { + if member_uid.trim().is_empty() { + return Ok(Vec::new()); + } + + let mut invites = Vec::new(); + for org_id in self.repository.keys()? { + let Some(org) = self.repository.get(&org_id)? else { + continue; + }; + + if let Some(invite) = org.pending_invites.get(&member_uid) { + invites.push(invite.clone()); + } + } + + invites.sort_by(|left, right| left.org_name.cmp(&right.org_name)); + Ok(invites) + } + pub fn override_org( &self, id: String, @@ -512,6 +534,162 @@ impl OrgHotStateService { }) } + pub fn invite_member(&self, context: OrgInviteContext) -> Result { + if context.requester_uid.trim().is_empty() + || context.target_uid.trim().is_empty() + || context.org_id.trim().is_empty() + { + return Err("A valid organization invite request is required.".to_string()); + } + + let mut org = self.get_org(context.org_id.clone())?; + if !can_manage_treasury( + &org, + &context.requester_uid, + context.requester_is_default_org_ceo, + ) { + return Err( + "Only the organization leader or CEO can send organization invites.".to_string(), + ); + } + if context.target_uid == context.requester_uid { + return Err("You cannot invite yourself to the organization.".to_string()); + } + if org.members.contains_key(&context.target_uid) { + return Err("Selected player is already a member of this organization.".to_string()); + } + if !context.target_org_id.trim().is_empty() + && !context.target_org_id.eq_ignore_ascii_case("default") + { + return Err( + "Selected player must leave their current organization before joining another." + .to_string(), + ); + } + + let target_name = if context.target_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.target_name.clone() + }; + let inviter_name = if context.requester_name.trim().is_empty() { + "Unknown".to_string() + } else { + context.requester_name.clone() + }; + + org.pending_invites.insert( + context.target_uid.clone(), + OrgInviteRecord { + org_id: org.id.clone(), + org_name: org.name.clone(), + inviter_uid: context.requester_uid, + inviter_name, + target_uid: context.target_uid.clone(), + target_name: target_name.clone(), + }, + ); + self.repository.save(&org)?; + + Ok(OrgInviteResult { + org, + target_uid: context.target_uid, + message: format!("Invitation sent to {}.", target_name), + }) + } + + pub fn accept_invite( + &self, + context: OrgInviteDecisionContext, + ) -> Result { + if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() { + return Err("A valid organization invite acceptance is required.".to_string()); + } + if !context.existing_org_id.trim().is_empty() + && !context.existing_org_id.eq_ignore_ascii_case("default") + && !context + .existing_org_id + .eq_ignore_ascii_case(&context.org_id) + { + return Err( + "Leave your current organization before accepting another invite.".to_string(), + ); + } + + let mut invited_org = self.get_org(context.org_id.clone())?; + let invite = invited_org + .pending_invites + .remove(&context.requester_uid) + .ok_or_else(|| "That organization invite is no longer available.".to_string())?; + + if invited_org.members.contains_key(&context.requester_uid) { + self.repository.save(&invited_org)?; + return Ok(OrgInviteDecisionResult { + previous_org: None, + actor_organization: invited_org.id.clone(), + message: "You are already a member of that organization.".to_string(), + invited_org, + }); + } + + let requester_name = if context.requester_name.trim().is_empty() { + invite.target_name + } else { + context.requester_name + }; + + let mut previous_org = None; + if !context.existing_org_id.trim().is_empty() + && !context + .existing_org_id + .eq_ignore_ascii_case(&invited_org.id) + { + let mut current_org = self.init_org(context.existing_org_id.clone())?; + current_org.members.remove(&context.requester_uid); + self.repository.save(¤t_org)?; + previous_org = Some(current_org); + } + + invited_org.members.insert( + context.requester_uid.clone(), + MemberSummary { + uid: context.requester_uid, + name: requester_name, + }, + ); + self.repository.save(&invited_org)?; + + Ok(OrgInviteDecisionResult { + previous_org, + actor_organization: invited_org.id.clone(), + message: format!("You joined {}.", invited_org.name), + invited_org, + }) + } + + pub fn decline_invite( + &self, + context: OrgInviteDecisionContext, + ) -> Result { + if context.requester_uid.trim().is_empty() || context.org_id.trim().is_empty() { + return Err("A valid organization invite decline is required.".to_string()); + } + + let mut invited_org = self.get_org(context.org_id.clone())?; + let invite = invited_org + .pending_invites + .remove(&context.requester_uid) + .ok_or_else(|| "That organization invite is no longer available.".to_string())?; + self.repository.save(&invited_org)?; + + Ok(OrgInviteDecisionResult { + previous_org: None, + actor_organization: context.existing_org_id, + message: format!("Invitation from {} declined.", invite.org_name), + invited_org, + }) + } + pub fn assign_credit_line( &self, context: OrgCreditLineContext, @@ -1017,6 +1195,8 @@ fn current_org_field_value(org: &HotOrgRecord, field: &str) -> Result serde_json::to_value(&org.members) .map_err(|error| format!("Failed to serialize org members: {}", error)), + "pending_invites" => serde_json::to_value(&org.pending_invites) + .map_err(|error| format!("Failed to serialize org invites: {}", error)), _ => Err(format!("Unknown field: {}", field)), } } From d8812df3812725c729865b2d7e408055bc960f2d Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Mon, 6 Apr 2026 06:06:09 -0500 Subject: [PATCH 19/19] Refresh org portal state and treasury interactions - Hydrate portal/session data from login and sync payloads - Add treasury notices for credit, invite, leave, and disband actions - Improve portal cards and modal UI for members, fleet, and treasury --- arma/client/addons/org/ui/_site/org-ui.js | 2 +- .../ui/src/components/portal/membersCard.js | 377 ++++++++++++++---- .../addons/org/ui/src/portal/actions.js | 10 + arma/client/addons/org/ui/src/portal/store.js | 2 + 4 files changed, 313 insertions(+), 78 deletions(-) diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js index a0307e5..36e4ca3 100644 --- a/arma/client/addons/org/ui/_site/org-ui.js +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -1 +1 @@ -!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::invite::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite sent.")}),r.on("org::invite::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to send organization invite.")}),r.on("org::invite::decision::success",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite updated.")}),r.on("org::invite::decision::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to update organization invite.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.availableAmount||e.amount||0,amountDue:e.amountDue||0,approvedAmount:e.approvedAmount||e.availableAmount||e.amount||0,availableAmount:e.availableAmount||e.amount||0,interestRate:e.interestRate||.1,member:e.memberName||"",outstandingPrincipal:e.outstandingPrincipal||0,uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},requestInvitePlayer:function(e){if(t("org::invite::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestAcceptInvite:function(e){if(t("org::invite::accept",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestDeclineInvite:function(e){if(t("org::invite::decline",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],pendingInvites:[],inviteablePlayers:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.pendingInvites,i(e.portalData.pendingInvites)),a(this.portalData.inviteablePlayers,i(e.portalData.inviteablePlayers)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getPendingInvites,this.setPendingInvites]=n([...r.pendingInvites]),[this.getInviteablePlayers,this.setInviteablePlayers]=n([...r.inviteablePlayers]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setPendingInvites([...a(n.pendingInvites)]),this.setInviteablePlayers([...a(n.inviteablePlayers)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?"invite"!==e||t.canManageMembers()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}sendInvite(e){if(!t.canManageMembers())return this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."),!1;const n=r.getInviteablePlayers().find(n=>String(n.uid||"")===String(e));if(!n)return this.showTreasuryNotice("error","Select an online player to invite."),!1;const a=window.RegistryApp?window.RegistryApp.bridge:null;return a&&"function"==typeof a.requestInvitePlayer?a.requestInvitePlayer({targetUid:String(n.uid||""),targetName:String(n.name||"")}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}acceptInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestAcceptInvite?n.requestAcceptInvite({orgId:e}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}declineInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestDeclineInvite?n.requestDeclineInvite({orgId:e}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),b=a.getMembers().length,f=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${b} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",f,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),b=c(),f=g(),w=t.reduce((e,n)=>e+Number(n.availableAmount||n.amount||0),0),v=t.reduce((e,n)=>e+Number(n.amountDue||0),0),h=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),f?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===b?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Available"),n("strong",null,i.formatCurrency(e.availableAmount||e.amount))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount Due"),n("strong",null,i.formatCurrency(e.amountDue))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Interest"),n("strong",null,`${Math.round(100*Number(e.interestRate||0))}%`))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`)),n("div",null,n("span",{className:"org-meta-label"},"Reserved Credit"),n("strong",null,i.formatCurrency(w))),n("div",null,n("span",{className:"org-meta-label"},"Outstanding Due"),n("strong",null,i.formatCurrency(v)))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,h),n("span",null,t.length>0?"Open the Credit Lines tab to review reserved balances, due amounts, and member exposure.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-members-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${s} .org-members-section {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n margin-bottom: 1.25rem;\n}\n\n${s} .org-members-section h4 {\n margin: 0;\n font-size: 0.85rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n${s} .org-name-copy {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .org-name-meta {\n font-size: 0.8rem;\n color: var(--text-muted);\n}\n\n${s} .org-inline-actions {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-left: auto;\n}\n\n${s} .org-members-empty {\n margin: 0;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${s} .org-members-head {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button,\n ${s} .org-inline-actions {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=t.getPendingInvites(),c=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},n("div",{className:"org-members-head"},n("div",{className:"org-members-section"},n("h4",null,"Pending Invites"),0===d.length?n("p",{className:"org-members-empty"},"No incoming organization invites."):null),c?n("button",{type:"button",className:"org-secondary-btn",onClick:()=>o.openModal("invite")},"Invite Player"):null),...d.map(e=>n("article",{className:"org-name-row"},n("div",{className:"org-name-copy"},n("strong",null,e.orgName||"Unknown Organization"),n("span",{className:"org-name-meta"},"Invited by ",e.inviterName||"Unknown")),n("div",{className:"org-inline-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>o.declineInvite(String(e.orgId||""))},"Decline"),n("button",{type:"button",onClick:()=>o.acceptInvite(String(e.orgId||""))},"Accept")))),n("div",{className:"org-members-section"},n("h4",null,"Roster")),...s.map(e=>{const r=c&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("div",{className:"org-name-copy"},n("strong",null,e.name),e.uid?n("span",{className:"org-name-meta"},e.uid):null),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=t.getInviteablePlayers(),l=0===i.length?{disabled:!0}:{},d=0===s.length?{disabled:!0}:{};let c="",m=null;return"payroll"===o.type?(c="Run Payroll",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(c="Send Funds",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...l},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(c="Assign Credit Line",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...l},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"invite"===o.type?(c="Invite Player",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Online Player"),n("select",{id:"org-invite-player",...d},...s.map(e=>n("option",{value:e.uid},e.name||e.uid)))),0===s.length?n("p",null,"No eligible online players are currently available for invites."):null,n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...d,onClick:()=>{a.sendInvite(String(a.getInputValue("org-invite-player")||""))&&a.closeModal()}},"Send Invite")))):"disband"===o.type?(c="Disband Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(c="Leave Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:c,body:m,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let b;return"home"===c?b=s():"create"===c&&(b=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),b),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file +!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::invite::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite sent.")}),r.on("org::invite::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to send organization invite.")}),r.on("org::invite::decision::success",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Organization invite updated.")}),r.on("org::invite::decision::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to update organization invite.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.availableAmount||e.amount||0,amountDue:e.amountDue||0,approvedAmount:e.approvedAmount||e.availableAmount||e.amount||0,availableAmount:e.availableAmount||e.amount||0,interestRate:e.interestRate||.1,member:e.memberName||"",outstandingPrincipal:e.outstandingPrincipal||0,uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},requestInvitePlayer:function(e){if(t("org::invite::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestAcceptInvite:function(e){if(t("org::invite::accept",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},requestDeclineInvite:function(e){if(t("org::invite::decline",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma organization invite bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}function o(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return o(JSON.parse(e))}catch(n){return e}return e}function i(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(o).filter(Boolean)}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],pendingInvites:[],inviteablePlayers:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,i(e.portalData.creditLines)),a(this.portalData.members,i(e.portalData.members)),a(this.portalData.pendingInvites,i(e.portalData.pendingInvites)),a(this.portalData.inviteablePlayers,i(e.portalData.inviteablePlayers)),a(this.portalData.fleet,i(e.portalData.fleet)),a(this.portalData.assets,i(e.portalData.assets)),a(this.portalData.activity,i(e.portalData.activity)),a(this.portalData.roadmap,i(e.portalData.roadmap)),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;function t(e){if(e&&"object"==typeof e&&!Array.isArray(e))return e;if(Array.isArray(e)){if(e.every(e=>Array.isArray(e)&&e.length>=2&&"string"==typeof e[0]))return Object.fromEntries(e)}if("string"==typeof e&&""!==e.trim())try{return t(JSON.parse(e))}catch(n){return e}return e}function a(e){return(Array.isArray(e)?e:e&&"object"==typeof e?Object.values(e):[]).map(t).filter(Boolean)}e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getPendingInvites,this.setPendingInvites]=n([...r.pendingInvites]),[this.getInviteablePlayers,this.setInviteablePlayers]=n([...r.inviteablePlayers]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getInviteMenuOpen,this.setInviteMenuOpen]=n(!1),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...a(n.members)]),this.setPendingInvites([...a(n.pendingInvites)]),this.setInviteablePlayers([...a(n.inviteablePlayers)]),this.setCreditLines([...a(n.creditLines)]),this.setFleet([...a(n.fleet)]),this.setAssets([...a(n.assets)]),this.setActivity([...a(n.activity)])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?"invite"!==e||t.canManageMembers()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}toggleInviteMenu(){r.setInviteMenuOpen(!r.getInviteMenuOpen())}closeInviteMenu(){r.setInviteMenuOpen(!1)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}sendInvite(e){if(!t.canManageMembers())return this.showTreasuryNotice("error","Only the organization leader or CEO can invite players."),!1;const n=r.getInviteablePlayers().find(n=>String(n.uid||"")===String(e));if(!n)return this.showTreasuryNotice("error","Select an online player to invite."),!1;const a=window.RegistryApp?window.RegistryApp.bridge:null;return a&&"function"==typeof a.requestInvitePlayer?a.requestInvitePlayer({targetUid:String(n.uid||""),targetName:String(n.name||"")}):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}acceptInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestAcceptInvite?(this.closeInviteMenu(),n.requestAcceptInvite({orgId:e})):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}declineInvite(e){const n=window.RegistryApp?window.RegistryApp.bridge:null;return n&&"function"==typeof n.requestDeclineInvite?(this.closeInviteMenu(),n.requestDeclineInvite({orgId:e})):(this.showTreasuryNotice("error","Organization invite bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),b=a.getMembers().length,f=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${b} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",f,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),b=c(),f=g(),v=t.reduce((e,n)=>e+Number(n.availableAmount||n.amount||0),0),h=t.reduce((e,n)=>e+Number(n.amountDue||0),0),w=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),f?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===b?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===b?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Available"),n("strong",null,i.formatCurrency(e.availableAmount||e.amount))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount Due"),n("strong",null,i.formatCurrency(e.amountDue))),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Interest"),n("strong",null,`${Math.round(100*Number(e.interestRate||0))}%`))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`)),n("div",null,n("span",{className:"org-meta-label"},"Reserved Credit"),n("strong",null,i.formatCurrency(v))),n("div",null,n("span",{className:"org-meta-label"},"Outstanding Due"),n("strong",null,i.formatCurrency(h)))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,w),n("span",null,t.length>0?"Open the Credit Lines tab to review reserved balances, due amounts, and member exposure.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-members-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n position: relative;\n}\n\n${s} .org-members-copy {\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n}\n\n${s} .org-members-kicker {\n margin: 0;\n font-size: 0.85rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${s} .org-members-subtitle {\n margin: 0;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${s} .org-members-tools {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-left: auto;\n}\n\n${s} .org-tool-btn {\n position: relative;\n width: 2.4rem;\n height: 2.4rem;\n padding: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n${s} .org-tool-badge {\n position: absolute;\n top: -0.25rem;\n right: -0.25rem;\n min-width: 1.1rem;\n height: 1.1rem;\n padding: 0 0.2rem;\n border-radius: 999px;\n background: #b91c1c;\n color: white;\n font-size: 0.68rem;\n font-weight: 700;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n${s} .org-invite-menu {\n position: absolute;\n top: calc(100% + 0.5rem);\n right: 0;\n width: min(24rem, 100%);\n max-height: 22rem;\n overflow: auto;\n padding: 0.75rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: white;\n box-shadow: 0 18px 45px rgb(15 23 42 / 0.18);\n z-index: 4;\n}\n\n${s} .org-invite-menu-head {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 0.75rem;\n}\n\n${s} .org-invite-menu-title {\n margin: 0;\n font-size: 0.85rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-invite-menu-list {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n${s} .org-invite-row,\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n${s} .org-name-copy {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .org-name-meta {\n font-size: 0.8rem;\n color: var(--text-muted);\n}\n\n${s} .org-inline-actions,\n${s} .org-invite-actions {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-left: auto;\n}\n\n${s} .org-members-empty {\n margin: 0;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${s} .org-members-head {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-members-tools {\n margin-left: 0;\n }\n\n ${s} .org-invite-menu {\n left: 0;\n right: auto;\n width: 100%;\n }\n\n ${s} .org-name-row,\n ${s} .org-invite-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button,\n ${s} .org-inline-actions,\n ${s} .org-invite-actions {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=t.getPendingInvites(),c=t.getInviteMenuOpen(),m=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},n("div",{className:"org-members-head"},n("div",{className:"org-members-copy"},n("h4",{className:"org-members-kicker"},"Roster"),n("p",{className:"org-members-subtitle"},"Manage membership and review incoming organization invites.")),n("div",{className:"org-members-tools"},n("button",{type:"button",className:"org-secondary-btn org-icon-btn org-tool-btn",title:"Pending invitations","aria-label":"Pending invitations",onClick:()=>o.toggleInviteMenu()},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"}),n("path",{d:"M9.73 21a2 2 0 0 0 4.54 0"})),d.length>0?n("span",{className:"org-tool-badge"},String(d.length)):null),m?n("button",{type:"button",className:"org-secondary-btn org-icon-btn org-tool-btn",title:"Invite player","aria-label":"Invite player",onClick:()=>o.openModal("invite")},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M12 5v14"}),n("path",{d:"M5 12h14"}))):null,c?n("div",{className:"org-invite-menu"},n("div",{className:"org-invite-menu-head"},n("h4",{className:"org-invite-menu-title"},"Pending Invites"),n("button",{type:"button",className:"org-secondary-btn org-icon-btn org-tool-btn",title:"Close invites","aria-label":"Close invites",onClick:()=>o.closeInviteMenu()},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M18 6 6 18"}),n("path",{d:"m6 6 12 12"})))),0===d.length?n("p",{className:"org-members-empty"},"No incoming organization invites."):n("div",{className:"org-invite-menu-list"},...d.map(e=>n("article",{className:"org-invite-row"},n("div",{className:"org-name-copy"},n("strong",null,e.orgName||"Unknown Organization"),n("span",{className:"org-name-meta"},"Invited by ",e.inviterName||"Unknown")),n("div",{className:"org-invite-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>o.declineInvite(String(e.orgId||""))},"Decline"),n("button",{type:"button",onClick:()=>o.acceptInvite(String(e.orgId||""))},"Accept")))))):null)),...s.map(e=>{const r=m&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("div",{className:"org-name-copy"},n("strong",null,e.name),e.uid?n("span",{className:"org-name-meta"},e.uid):null),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=t.getInviteablePlayers(),l=0===i.length?{disabled:!0}:{},d=0===s.length?{disabled:!0}:{};let c="",m=null;return"payroll"===o.type?(c="Run Payroll",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(c="Send Funds",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...l},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(c="Assign Credit Line",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...l},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...l,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"invite"===o.type?(c="Invite Player",m=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Online Player"),n("select",{id:"org-invite-player",...d},...s.map(e=>n("option",{value:e.uid},e.name||e.uid)))),0===s.length?n("p",null,"No eligible online players are currently available for invites."):null,n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...d,onClick:()=>{a.sendInvite(String(a.getInputValue("org-invite-player")||""))&&a.closeModal()}},"Send Invite")))):"disband"===o.type?(c="Disband Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(c="Leave Organization",m=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:c,body:m,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("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."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let b;return"home"===c?b=s():"create"===c&&(b=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),b),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/org/ui/src/components/portal/membersCard.js b/arma/client/addons/org/ui/src/components/portal/membersCard.js index 0346786..14c3f72 100644 --- a/arma/client/addons/org/ui/src/components/portal/membersCard.js +++ b/arma/client/addons/org/ui/src/components/portal/membersCard.js @@ -25,16 +25,16 @@ ${scopeSelector} .org-members-head { justify-content: space-between; gap: 1rem; margin-bottom: 1rem; + position: relative; } -${scopeSelector} .org-members-section { +${scopeSelector} .org-members-copy { display: flex; flex-direction: column; - gap: 0.85rem; - margin-bottom: 1.25rem; + gap: 0.35rem; } -${scopeSelector} .org-members-section h4 { +${scopeSelector} .org-members-kicker { margin: 0; font-size: 0.85rem; letter-spacing: 0.08em; @@ -42,6 +42,84 @@ ${scopeSelector} .org-members-section h4 { color: var(--text-muted); } +${scopeSelector} .org-members-subtitle { + margin: 0; + font-size: 0.9rem; + color: var(--text-muted); +} + +${scopeSelector} .org-members-tools { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +${scopeSelector} .org-tool-btn { + position: relative; + width: 2.4rem; + height: 2.4rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +${scopeSelector} .org-tool-badge { + position: absolute; + top: -0.25rem; + right: -0.25rem; + min-width: 1.1rem; + height: 1.1rem; + padding: 0 0.2rem; + border-radius: 999px; + background: #b91c1c; + color: white; + font-size: 0.68rem; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +${scopeSelector} .org-invite-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + width: min(24rem, 100%); + max-height: 22rem; + overflow: auto; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: white; + box-shadow: 0 18px 45px rgb(15 23 42 / 0.18); + z-index: 4; +} + +${scopeSelector} .org-invite-menu-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; +} + +${scopeSelector} .org-invite-menu-title { + margin: 0; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-invite-menu-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +${scopeSelector} .org-invite-row, ${scopeSelector} .org-name-row { display: flex; align-items: center; @@ -73,7 +151,8 @@ ${scopeSelector} .org-name-meta { color: var(--text-muted); } -${scopeSelector} .org-inline-actions { +${scopeSelector} .org-inline-actions, +${scopeSelector} .org-invite-actions { display: flex; align-items: center; gap: 0.5rem; @@ -92,13 +171,25 @@ ${scopeSelector} .org-members-empty { align-items: flex-start; } - ${scopeSelector} .org-name-row { + ${scopeSelector} .org-members-tools { + margin-left: 0; + } + + ${scopeSelector} .org-invite-menu { + left: 0; + right: auto; + width: 100%; + } + + ${scopeSelector} .org-name-row, + ${scopeSelector} .org-invite-row { flex-direction: column; align-items: flex-start; } ${scopeSelector} .org-name-row button, - ${scopeSelector} .org-inline-actions { + ${scopeSelector} .org-inline-actions, + ${scopeSelector} .org-invite-actions { margin-left: 0; } } @@ -110,6 +201,7 @@ ${scopeSelector} .org-members-empty { const PanelCard = window.SharedUI.componentFns.PanelCard; const members = store.getMembers(); const pendingInvites = store.getPendingInvites(); + const inviteMenuOpen = store.getInviteMenuOpen(); const allowMemberManagement = getters.canManageMembers(); ensureScopedStyle("portal-members-card", membersCardCss); @@ -127,80 +219,211 @@ ${scopeSelector} .org-members-empty { { className: "org-members-head" }, h( "div", - { className: "org-members-section" }, - h("h4", null, "Pending Invites"), - pendingInvites.length === 0 + { className: "org-members-copy" }, + h("h4", { className: "org-members-kicker" }, "Roster"), + h( + "p", + { className: "org-members-subtitle" }, + "Manage membership and review incoming organization invites.", + ), + ), + h( + "div", + { className: "org-members-tools" }, + h( + "button", + { + type: "button", + className: + "org-secondary-btn org-icon-btn org-tool-btn", + title: "Pending invitations", + "aria-label": "Pending invitations", + onClick: () => actions.toggleInviteMenu(), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { + d: "M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5", + }), + h("path", { d: "M9.73 21a2 2 0 0 0 4.54 0" }), + ), + pendingInvites.length > 0 + ? h( + "span", + { className: "org-tool-badge" }, + String(pendingInvites.length), + ) + : null, + ), + allowMemberManagement ? h( - "p", - { className: "org-members-empty" }, - "No incoming organization invites.", + "button", + { + type: "button", + className: + "org-secondary-btn org-icon-btn org-tool-btn", + title: "Invite player", + "aria-label": "Invite player", + onClick: () => + actions.openModal("invite"), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { d: "M12 5v14" }), + h("path", { d: "M5 12h14" }), + ), + ) + : null, + inviteMenuOpen + ? h( + "div", + { className: "org-invite-menu" }, + h( + "div", + { className: "org-invite-menu-head" }, + h( + "h4", + { + className: + "org-invite-menu-title", + }, + "Pending Invites", + ), + h( + "button", + { + type: "button", + className: + "org-secondary-btn org-icon-btn org-tool-btn", + title: "Close invites", + "aria-label": "Close invites", + onClick: () => + actions.closeInviteMenu(), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { d: "M18 6 6 18" }), + h("path", { d: "m6 6 12 12" }), + ), + ), + ), + pendingInvites.length === 0 + ? h( + "p", + { + className: "org-members-empty", + }, + "No incoming organization invites.", + ) + : h( + "div", + { + className: + "org-invite-menu-list", + }, + ...pendingInvites.map((invite) => + h( + "article", + { + className: + "org-invite-row", + }, + h( + "div", + { + className: + "org-name-copy", + }, + h( + "strong", + null, + invite.orgName || + "Unknown Organization", + ), + h( + "span", + { + className: + "org-name-meta", + }, + "Invited by ", + invite.inviterName || + "Unknown", + ), + ), + h( + "div", + { + className: + "org-invite-actions", + }, + h( + "button", + { + type: "button", + className: + "org-secondary-btn", + onClick: () => + actions.declineInvite( + String( + invite.orgId || + "", + ), + ), + }, + "Decline", + ), + h( + "button", + { + type: "button", + onClick: () => + actions.acceptInvite( + String( + invite.orgId || + "", + ), + ), + }, + "Accept", + ), + ), + ), + ), + ), ) : null, ), - allowMemberManagement - ? h( - "button", - { - type: "button", - className: "org-secondary-btn", - onClick: () => actions.openModal("invite"), - }, - "Invite Player", - ) - : null, - ), - ...pendingInvites.map((invite) => - h( - "article", - { className: "org-name-row" }, - h( - "div", - { className: "org-name-copy" }, - h( - "strong", - null, - invite.orgName || "Unknown Organization", - ), - h( - "span", - { className: "org-name-meta" }, - "Invited by ", - invite.inviterName || "Unknown", - ), - ), - h( - "div", - { className: "org-inline-actions" }, - h( - "button", - { - type: "button", - className: "org-secondary-btn", - onClick: () => - actions.declineInvite( - String(invite.orgId || ""), - ), - }, - "Decline", - ), - h( - "button", - { - type: "button", - onClick: () => - actions.acceptInvite( - String(invite.orgId || ""), - ), - }, - "Accept", - ), - ), - ), - ), - h( - "div", - { className: "org-members-section" }, - h("h4", null, "Roster"), ), ...members.map((member) => { const canRemoveMember = diff --git a/arma/client/addons/org/ui/src/portal/actions.js b/arma/client/addons/org/ui/src/portal/actions.js index 1ed89ec..2ab8a30 100644 --- a/arma/client/addons/org/ui/src/portal/actions.js +++ b/arma/client/addons/org/ui/src/portal/actions.js @@ -85,6 +85,14 @@ store.setModal(null); } + toggleInviteMenu() { + store.setInviteMenuOpen(!store.getInviteMenuOpen()); + } + + closeInviteMenu() { + store.setInviteMenuOpen(false); + } + removeMember(member) { if (!getters.canManageMembers()) { return false; @@ -355,6 +363,7 @@ return false; } + this.closeInviteMenu(); return bridge.requestAcceptInvite({ orgId }); } @@ -371,6 +380,7 @@ return false; } + this.closeInviteMenu(); return bridge.requestDeclineInvite({ orgId }); } } diff --git a/arma/client/addons/org/ui/src/portal/store.js b/arma/client/addons/org/ui/src/portal/store.js index fc9b937..bc96b18 100644 --- a/arma/client/addons/org/ui/src/portal/store.js +++ b/arma/client/addons/org/ui/src/portal/store.js @@ -73,6 +73,8 @@ text: "", }); [this.getModal, this.setModal] = createSignal(null); + [this.getInviteMenuOpen, this.setInviteMenuOpen] = + createSignal(false); [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); }