From 53bc8db7d0be1f445939b33795d81b7c38aebd2c Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 09:10:12 -0500 Subject: [PATCH] Handle bank account sync in UI bridge - Route bank sync payloads through the client bridge - Refresh account state without rebuilding the full session - Split CAD dispatcher UI into modular source files --- .../client/addons/bank/XEH_postInitClient.sqf | 4 +- .../bank/functions/fnc_initUIBridge.sqf | 7 + arma/client/addons/bank/ui/_site/bank-ui.js | 2 +- arma/client/addons/bank/ui/src/bridge.js | 7 +- arma/client/addons/bank/ui/src/data.js | 7 + .../addons/bank/ui/src/registry/store.js | 5 + .../addons/cad/ui/_site/cad-dispatcher.js | 2 +- .../addons/cad/ui/_site/dispatcher.html | 2 +- arma/client/addons/cad/ui/src/dispatcher.html | 7 + arma/client/addons/cad/ui/src/dispatcher.js | 822 --------------- .../cad/ui/src/dispatcher/formatters.js | 103 ++ .../addons/cad/ui/src/dispatcher/index.js | 255 +++++ .../addons/cad/ui/src/dispatcher/modals.js | 268 +++++ .../addons/cad/ui/src/dispatcher/render.js | 325 ++++++ arma/client/addons/cad/ui/ui.config.mjs | 7 +- .../actor/functions/fnc_initActorStore.sqf | 222 +++- arma/server/addons/bank/XEH_PREP.hpp | 2 +- arma/server/addons/bank/XEH_preInit.sqf | 65 +- .../bank/functions/fnc_initMessenger.sqf | 10 +- .../addons/bank/functions/fnc_initModel.sqf | 26 +- .../bank/functions/fnc_initPayloadBuilder.sqf | 105 ++ .../bank/functions/fnc_initSessionManager.sqf | 14 +- .../addons/bank/functions/fnc_initStore.sqf | 458 +++++---- .../bank/functions/fnc_initValidator.sqf | 259 ----- .../functions/fnc_initPermissionService.sqf | 2 +- .../functions/fnc_initMEconomyStore.sqf | 7 +- arma/server/addons/extension/XEH_PREP.hpp | 1 + .../extension/functions/fnc_extCall.sqf | 172 +++- .../extension/functions/fnc_transport.sqf | 115 +++ arma/server/addons/garage/XEH_preInit.sqf | 38 +- .../garage/functions/fnc_initGarageStore.sqf | 161 ++- .../garage/functions/fnc_initVGStore.sqf | 167 ++- arma/server/addons/locker/XEH_preInit.sqf | 22 +- .../locker/functions/fnc_initLockerStore.sqf | 159 ++- .../locker/functions/fnc_initVAStore.sqf | 166 ++- arma/server/addons/main/XEH_PREP.hpp | 1 + arma/server/addons/main/XEH_preInit.sqf | 12 + .../addons/main/functions/fnc_initStores.sqf | 2 +- .../main/functions/fnc_saveHotState.sqf | 84 ++ arma/server/addons/org/XEH_preInit.sqf | 21 +- .../addons/org/functions/fnc_initOrgStore.sqf | 364 ++++--- .../org/functions/fnc_memberService.sqf | 40 +- .../org/functions/fnc_treasuryService.sqf | 8 +- .../store/functions/fnc_initStoreStore.sqf | 2 +- .../task/functions/fnc_handleTaskRewards.sqf | 9 +- .../addons/task/functions/fnc_handler.sqf | 6 +- .../task/functions/fnc_initTaskStore.sqf | 63 +- arma/server/extension/src/actor.rs | 92 +- arma/server/extension/src/bank.rs | 296 +++++- arma/server/extension/src/cad.rs | 52 +- arma/server/extension/src/garage.rs | 154 ++- arma/server/extension/src/lib.rs | 2 + arma/server/extension/src/locker.rs | 89 +- arma/server/extension/src/org.rs | 69 +- arma/server/extension/src/transport.rs | 951 ++++++++++++++++++ arma/server/extension/src/v_garage.rs | 191 +++- arma/server/extension/src/v_locker.rs | 115 ++- lib/models/src/bank.rs | 38 + lib/models/src/lib.rs | 7 +- lib/models/src/org.rs | 52 + lib/models/src/v_locker.rs | 1 + lib/repositories/src/actor.rs | 44 + lib/repositories/src/bank.rs | 44 + lib/repositories/src/garage.rs | 43 + lib/repositories/src/lib.rs | 24 +- lib/repositories/src/locker.rs | 43 + lib/repositories/src/org.rs | 45 +- lib/repositories/src/v_garage.rs | 44 + lib/repositories/src/v_locker.rs | 44 + lib/services/src/actor.rs | 60 +- lib/services/src/bank.rs | 374 ++++++- lib/services/src/garage.rs | 90 +- lib/services/src/lib.rs | 14 +- lib/services/src/locker.rs | 63 +- lib/services/src/org.rs | 99 +- lib/services/src/v_garage.rs | 96 +- lib/services/src/v_locker.rs | 72 +- 77 files changed, 6026 insertions(+), 1888 deletions(-) delete mode 100644 arma/client/addons/cad/ui/src/dispatcher.js create mode 100644 arma/client/addons/cad/ui/src/dispatcher/formatters.js create mode 100644 arma/client/addons/cad/ui/src/dispatcher/index.js create mode 100644 arma/client/addons/cad/ui/src/dispatcher/modals.js create mode 100644 arma/client/addons/cad/ui/src/dispatcher/render.js create mode 100644 arma/server/addons/bank/functions/fnc_initPayloadBuilder.sqf delete mode 100644 arma/server/addons/bank/functions/fnc_initValidator.sqf create mode 100644 arma/server/addons/extension/functions/fnc_transport.sqf create mode 100644 arma/server/addons/main/functions/fnc_saveHotState.sqf create mode 100644 arma/server/extension/src/transport.rs diff --git a/arma/client/addons/bank/XEH_postInitClient.sqf b/arma/client/addons/bank/XEH_postInitClient.sqf index 3ec1fc8..4a79659 100644 --- a/arma/client/addons/bank/XEH_postInitClient.sqf +++ b/arma/client/addons/bank/XEH_postInitClient.sqf @@ -12,7 +12,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; GVAR(BankRepository) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { - GVAR(BankUIBridge) call ["refreshSession", []]; + GVAR(BankUIBridge) call ["handleAccountSyncResponse", [_data]]; }; }] call CFUNC(addEventHandler); @@ -21,7 +21,7 @@ if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; GVAR(BankRepository) call ["markLoaded", []]; if !(isNil QGVAR(BankUIBridge)) then { - GVAR(BankUIBridge) call ["refreshSession", []]; + GVAR(BankUIBridge) call ["handleAccountSyncResponse", [_data]]; }; }] call CFUNC(addEventHandler); diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf index 9788f08..40df814 100644 --- a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -70,6 +70,13 @@ GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ _self call ["sendEvent", [_event, _data, _self call ["getActiveBrowserControl", []]]] }], + ["handleAccountSyncResponse", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + if !(_self call ["hasOpenScreen", []]) exitWith { false }; + + _self call ["sendEvent", ["bank::sync", _data, _self call ["getActiveBrowserControl", []]]] + }], ["handleNoticeResponse", compileFinal { params [["_type", "error", [""]], ["_message", "", [""]]]; diff --git a/arma/client/addons/bank/ui/_site/bank-ui.js b/arma/client/addons/bank/ui/_site/bank-ui.js index ace527e..2922620 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",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,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 diff --git a/arma/client/addons/bank/ui/src/bridge.js b/arma/client/addons/bank/ui/src/bridge.js index 02d3113..425cfd6 100644 --- a/arma/client/addons/bank/ui/src/bridge.js +++ b/arma/client/addons/bank/ui/src/bridge.js @@ -12,8 +12,13 @@ store.hydrateFromPayload(payloadData); } + function syncAccount(payloadData) { + BankApp.data.applyAccountPatch(payloadData); + store.syncAccountPatch(); + } + bridge.on("bank::hydrate", hydrate); - bridge.on("bank::sync", hydrate); + bridge.on("bank::sync", syncAccount); bridge.on("bank::notice", (payloadData) => { store.finishAction(); if (BankApp.actions) { diff --git a/arma/client/addons/bank/ui/src/data.js b/arma/client/addons/bank/ui/src/data.js index c3558b8..50e0df3 100644 --- a/arma/client/addons/bank/ui/src/data.js +++ b/arma/client/addons/bank/ui/src/data.js @@ -30,6 +30,13 @@ BankApp.data = { account: Object.assign({}, defaultAccount), session: Object.assign({}, defaultSession), + applyAccountPatch(patch) { + const nextAccount = Object.assign({}, this.account, patch || {}); + replaceObject( + this.account, + Object.assign({}, defaultAccount, nextAccount), + ); + }, applyHydratePayload(payload) { replaceObject( this.session, diff --git a/arma/client/addons/bank/ui/src/registry/store.js b/arma/client/addons/bank/ui/src/registry/store.js index 2acf40b..d3ff565 100644 --- a/arma/client/addons/bank/ui/src/registry/store.js +++ b/arma/client/addons/bank/ui/src/registry/store.js @@ -60,6 +60,11 @@ this.setAtmView("dashboard"); } + syncAccountPatch() { + this.setPendingAction(""); + this.setAccountVersion(this.getAccountVersion() + 1); + } + resetAtm() { this.setEnteredPin(""); this.setCustomAmount(""); diff --git a/arma/client/addons/cad/ui/_site/cad-dispatcher.js b/arma/client/addons/cad/ui/_site/cad-dispatcher.js index 6994fd7..6f86b75 100644 --- a/arma/client/addons/cad/ui/_site/cad-dispatcher.js +++ b/arma/client/addons/cad/ui/_site/cad-dispatcher.js @@ -1 +1 @@ -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 +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 diff --git a/arma/client/addons/cad/ui/_site/dispatcher.html b/arma/client/addons/cad/ui/_site/dispatcher.html index 680ae6a..a3b7124 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
Open Requests 0
Groups In Danger 0

Available Contracts

Assigned Contracts

Group Board

Requests & Activity

\ 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) + } +}