From ee7d1603ef3071533ded9d064432b53155b88e12 Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sat, 18 Apr 2026 14:09:14 -0500 Subject: [PATCH] feat(garage): add refuel and repair service requests - Implemented requestRefuel and requestRepair functions in bridge.js to handle vehicle service requests. - Updated AppShell.js to include buttons for refueling and repairing nearby vehicles, with appropriate state management. - Added requestRefuelSelected and requestRepairSelected actions in events.js to validate and process service requests. - Enhanced economy README and usage guides to document new refuel and repair service functionalities. - Introduced server-side handling for refuel requests in FEconomyStore, ensuring organization billing and fuel management. --- arma/client/addons/garage/README.md | 14 +++- .../garage/functions/fnc_handleUIEvents.sqf | 12 ++- .../functions/fnc_initActionService.sqf | 82 ++++++++++++++++++- .../addons/garage/ui/_site/garage-ui.js | 2 +- arma/client/addons/garage/ui/src/bridge.js | 30 +++++++ .../garage/ui/src/components/AppShell.js | 41 +++++++++- .../addons/garage/ui/src/registry/events.js | 66 +++++++++++++++ arma/server/addons/economy/README.md | 19 ++++- arma/server/addons/economy/XEH_preInit.sqf | 5 ++ .../functions/fnc_initFEconomyStore.sqf | 42 +++++++++- docs/CLIENT_GARAGE_USAGE_GUIDE.md | 19 ++++- docs/ECONOMY_USAGE_GUIDE.md | 8 ++ 12 files changed, 323 insertions(+), 17 deletions(-) diff --git a/arma/client/addons/garage/README.md b/arma/client/addons/garage/README.md index 879df40..2950114 100644 --- a/arma/client/addons/garage/README.md +++ b/arma/client/addons/garage/README.md @@ -2,7 +2,8 @@ ## Overview The garage addon provides player vehicle storage UI, vehicle store/retrieve -actions, and virtual garage state on the client. +actions, selected nearby vehicle service requests, and virtual garage state on +the client. ## Dependencies - `forge_client_common` @@ -17,8 +18,8 @@ actions, and virtual garage state on the client. details. - `fnc_initContextService.sqf` gathers nearby/current vehicle context. - `fnc_initPayloadService.sqf` builds browser hydrate payloads. -- `fnc_initActionService.sqf` sends store/retrieve requests and handles action - responses. +- `fnc_initActionService.sqf` sends store/retrieve requests, forwards selected + nearby vehicle refuel/repair service requests, and handles action responses. - `fnc_initUIBridge.sqf` pushes hydrate/sync events to the browser. - `fnc_openUI.sqf` opens `RscGarage`. - `fnc_openVG.sqf` opens the Arma garage-style virtual garage view. @@ -28,8 +29,15 @@ actions, and virtual garage state on the client. - `garage::refresh` - `garage::vehicle::retrieve::request` - `garage::vehicle::store::request` +- `garage::vehicle::refuel::request` +- `garage::vehicle::repair::request` - `garage::close` ## Runtime Notes The client builds vehicle context and sends requests. The server garage addon and extension own stored vehicle state. + +Refuel and repair buttons are available from the selected vehicle detail panel +for nearby world vehicles. Stored records must be retrieved before they can be +serviced because fuel and repair operate on live vehicle objects. Service +billing is handled by the server economy addon and charges organization funds. diff --git a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf index 94c1ad1..f437877 100644 --- a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf @@ -4,7 +4,7 @@ * File: fnc_handleUIEvents.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2026-01-30 + * Last Update: 2026-04-18 * Public: No * * Description: @@ -53,6 +53,16 @@ switch (_event) do { GVAR(GarageActionService) call ["handleStoreRequest", [_data]]; }; }; + case "garage::vehicle::refuel::request": { + if !(isNil QGVAR(GarageActionService)) then { + GVAR(GarageActionService) call ["handleRefuelRequest", [_data]]; + }; + }; + case "garage::vehicle::repair::request": { + if !(isNil QGVAR(GarageActionService)) then { + GVAR(GarageActionService) call ["handleRepairRequest", [_data]]; + }; + }; case "garage::refresh": { if !(isNil QGVAR(GarageUIBridge)) then { GVAR(GarageUIBridge) call ["refreshGarage", []]; diff --git a/arma/client/addons/garage/functions/fnc_initActionService.sqf b/arma/client/addons/garage/functions/fnc_initActionService.sqf index 408d5a8..bb4fd57 100644 --- a/arma/client/addons/garage/functions/fnc_initActionService.sqf +++ b/arma/client/addons/garage/functions/fnc_initActionService.sqf @@ -4,10 +4,12 @@ * File: fnc_initActionService.sqf * Author: IDSolutions * Date: 2026-03-27 + * Last Update: 2026-04-18 * Public: No * * Description: - * Initializes the garage action service for retrieve and store world actions. + * Initializes the garage action service for retrieve, store, refuel, and + * repair world actions. * * Arguments: * None @@ -26,6 +28,52 @@ GVAR(GarageActionServiceBaseClass) = compileFinal createHashMapFromArray [ _self set ["pendingStoreVehicle", objNull]; _self set ["pendingRetrieve", createHashMap]; }], + ["sendServiceResult", compileFinal { + params [["_action", "", [""]], ["_success", false, [false]], ["_message", "", [""]]]; + + private _event = ["garage::service::failure", "garage::service::success"] select _success; + GVAR(GarageUIBridge) call ["sendEvent", [_event, createHashMapFromArray [["action", _action], ["message", _message]]]]; + }], + ["refreshAfterService", compileFinal { + [] spawn { + sleep 0.75; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + }], + ["resolveServiceVehicle", compileFinal { + params [["_data", createHashMap, [createHashMap]], ["_action", "service", [""]]]; + + private _netId = _data getOrDefault ["netId", ""]; + if (_netId isEqualTo "") exitWith { + _self call ["sendServiceResult", [_action, false, "Select a nearby vehicle first."]]; + objNull + }; + + private _vehicle = objectFromNetId _netId; + if (isNull _vehicle) exitWith { + _self call ["sendServiceResult", [_action, false, "The selected vehicle is no longer available."]]; + objNull + }; + + if !(_vehicle isKindOf "Car" || { _vehicle isKindOf "Tank" } || { _vehicle isKindOf "Air" } || { _vehicle isKindOf "Ship" }) exitWith { + _self call ["sendServiceResult", [_action, false, "Selected object is not a serviceable vehicle."]]; + objNull + }; + + _vehicle + }], + ["vehicleNeedsRepair", compileFinal { + params [["_vehicle", objNull, [objNull]]]; + + if (isNull _vehicle) exitWith { false }; + if ((damage _vehicle) > 0.001) exitWith { true }; + + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointValues = if (_rawHitPoints isEqualType [] && { count _rawHitPoints >= 3 }) then { _rawHitPoints param [2, []] } else { [] }; + ({ _x > 0.001 } count _hitPointValues) > 0 + }], ["handleRetrieveRequest", compileFinal { params [["_data", createHashMap, [createHashMap]]]; @@ -97,6 +145,38 @@ GVAR(GarageActionServiceBaseClass) = compileFinal createHashMapFromArray [ _self set ["pendingStoreVehicle", _vehicle]; [SRPC(garage,requestStoreVehicle), [getPlayerUID player, typeOf _vehicle, fuel _vehicle, damage _vehicle, _hitPointsJson]] call CFUNC(serverEvent); }], + ["handleRefuelRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _vehicle = _self call ["resolveServiceVehicle", [_data, "refuel"]]; + if (isNull _vehicle) exitWith { false }; + + if ((fuel _vehicle) >= 0.999) exitWith { + _self call ["sendServiceResult", ["refuel", false, "Vehicle fuel tank is already full."]]; + false + }; + + [SRPC(economy,RefuelService), [_vehicle, player]] call CFUNC(serverEvent); + _self call ["sendServiceResult", ["refuel", true, "Refuel request sent. Billing result will appear as a notification."]]; + _self call ["refreshAfterService", []]; + true + }], + ["handleRepairRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _vehicle = _self call ["resolveServiceVehicle", [_data, "repair"]]; + if (isNull _vehicle) exitWith { false }; + + if !(_self call ["vehicleNeedsRepair", [_vehicle]]) exitWith { + _self call ["sendServiceResult", ["repair", false, "Vehicle has no reported damage."]]; + false + }; + + [SRPC(economy,RepairService), [_vehicle, player, -1]] call CFUNC(serverEvent); + _self call ["sendServiceResult", ["repair", true, "Repair request sent. Billing result will appear as a notification."]]; + _self call ["refreshAfterService", []]; + true + }], ["handleActionResponse", compileFinal { params [["_payload", createHashMap, [createHashMap]]]; diff --git a/arma/client/addons/garage/ui/_site/garage-ui.js b/arma/client/addons/garage/ui/_site/garage-ui.js index 2f7fb77..05c4afd 100644 --- a/arma/client/addons/garage/ui/_site/garage-ui.js +++ b/arma/client/addons/garage/ui/_site/garage-ui.js @@ -1 +1 @@ -!function(){const e=window.ForgeWebUI;(window.GarageApp=window.GarageApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.GarageApp=window.GarageApp||{},a={garageName:"Vehicle Garage",capacityUsed:0,capacityMax:5,nearbyCount:0,spawnBlocked:!1,spawnStatus:"Ready"},t={vehicles:[]},r={vehicles:[]};function s(e,a){var t;Object.keys(e).forEach(a=>delete e[a]),Object.assign(e,(t=a,JSON.parse(JSON.stringify(t))))}e.data={categories:[{id:"all",label:"All"},{id:"car",label:"Cars"},{id:"armor",label:"Armor"},{id:"air",label:"Air"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],session:Object.assign({},a),garage:Object.assign({},t),nearby:Object.assign({},r),applyHydratePayload(e){s(this.session,Object.assign({},a,e?.session||{})),s(this.garage,Object.assign({},t,e?.garage||{})),s(this.nearby,Object.assign({},r,e?.nearby||{}))}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{createSignal:a}=e.runtime;e.store=new class{constructor(){[this.getSelectedKind,this.setSelectedKind]=a(""),[this.getSelectedId,this.setSelectedId]=a(""),[this.getSearchQuery,this.setSearchQuery]=a(""),[this.getCategoryFilter,this.setCategoryFilter]=a("all"),[this.getPendingAction,this.setPendingAction]=a(""),[this.getNotice,this.setNotice]=a({type:"",text:""})}getSelection(){return{id:this.getSelectedId(),kind:this.getSelectedKind()}}clearSelection(){this.setSelectedKind(""),this.setSelectedId("")}select(e,a){this.setSelectedKind(String(e||"")),this.setSelectedId(String(a||""))}startAction(e){this.setPendingAction(String(e||""))}finishAction(){this.setPendingAction("")}matchesSelection(e){if(!e||"object"!=typeof e)return!1;const a=this.getSelection();return!(!a.kind||!a.id)&&("stored"===a.kind?"stored"===e.entryKind&&String(e.plate||"")===a.id:"nearby"===a.kind&&("nearby"===e.entryKind&&String(e.netId||"")===a.id))}ensureSelection(){const a=Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[],t=Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[];if([...a,...t].some(e=>this.matchesSelection(e)))return;const r=a[0]||null;if(r)return void this.select("stored",r.plate||"");const s=t[0]||null;s?this.select("nearby",s.netId||""):this.clearSelection()}hydrateFromPayload(){this.finishAction(),this.ensureSelection()}}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store,t=window.ForgeWebUI.createBridge({closeEvent:"garage::close",globalName:"ForgeBridge",readyEvent:"garage::ready"});function r(t){e.data.applyHydratePayload(t),a.hydrateFromPayload(t)}t.on("garage::hydrate",r),t.on("garage::sync",r),t.on("garage::retrieve::success",t=>{a.finishAction(),e.actions&&e.actions.showNotice("success",t.message||"Vehicle retrieved from the garage.")}),t.on("garage::retrieve::failure",t=>{a.finishAction(),e.actions&&e.actions.showNotice("error",t.message||"Unable to retrieve vehicle.")}),t.on("garage::store::success",t=>{a.finishAction(),e.actions&&e.actions.showNotice("success",t.message||"Vehicle stored in the garage.")}),t.on("garage::store::failure",t=>{a.finishAction(),e.actions&&e.actions.showNotice("error",t.message||"Unable to store vehicle.")}),e.bridge={notifyReady:function(){return t.ready({loaded:!0})},receive:t.receive,requestClose:function(){return t.close({})},requestRefresh:function(){return t.send("garage::refresh",{})},requestRetrieve:function(e){return t.send("garage::vehicle::retrieve::request",e)},requestStore:function(e){return t.send("garage::vehicle::store::request",e)},sendEvent:t.send}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store;let t=null;function r(){const t=a.getSelection();return"stored"===t.kind?(Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[]).find(e=>String(e.plate||"")===t.id)||null:"nearby"===t.kind&&(Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[]).find(e=>String(e.netId||"")===t.id)||null}function s(e,r){a.setNotice({type:e,text:r}),t&&clearTimeout(t),t=setTimeout(()=>{a.setNotice({type:"",text:""}),t=null},3200)}e.actions={showNotice:s,closeGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestClose){if(a.requestClose())return!0}return s("error","Garage bridge is unavailable."),!1},refreshGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestRefresh){if(a.requestRefresh())return!0}return s("error","Garage refresh bridge is unavailable."),!1},applySearchQuery:function(e){a.setSearchQuery(String(e||"").trim())},clearSearch:function(){a.setSearchQuery("")},selectCategory:function(e){a.setCategoryFilter(String(e||"all").trim()||"all")},selectEntry:function(e,t){a.select(e,t)},getSelectedEntry:r,requestRetrieveSelected:function(){const t=r();if(!t||"stored"!==t.entryKind)return s("error","Select a stored vehicle to retrieve."),!1;if(e.data?.session?.spawnBlocked)return s("error","The garage spawn area is blocked."),!1;const i=e.bridge;return i&&"function"==typeof i.requestRetrieve?(a.startAction("retrieve"),!!i.requestRetrieve({plate:t.plate||""})||(a.finishAction(),s("error","Garage retrieve bridge is unavailable."),!1)):(s("error","Garage retrieve bridge is unavailable."),!1)},requestStoreSelected:function(){const t=r();if(!t||"nearby"!==t.entryKind)return s("error","Select a nearby vehicle to store."),!1;if(!1===t.isEmpty)return s("error","All crew must exit the vehicle before storing it."),!1;const i=e.bridge;return i&&"function"==typeof i.requestStore?(a.startAction("store"),!!i.requestStore({netId:t.netId||""})||(a.finishAction(),s("error","Garage store bridge is unavailable."),!1)):(s("error","Garage store bridge is unavailable."),!1)}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{h:a}=e.runtime,t=window.SharedUI.componentFns.WindowTitleBar,r=e.store,s=e.actions,{categories:i,garage:n,nearby:c,session:l}=e.data;function o(e){return Math.max(0,Math.min(100,Math.round(100*Number(e||0))))}function g(e){const a=i.find(a=>a.id===String(e||"other").toLowerCase());return a?a.label:"Other"}function d(e){return`${Math.round(Number(e||0))} m`}function u(e){return String(e||"").trim()||"Untracked"}function m(e,a){return(e||[]).filter(e=>("all"===a.categoryFilter||String(e.category||"").toLowerCase()===a.categoryFilter)&&function(e,a){const t=String(e||"").trim().toLowerCase();return!t||a.some(e=>String(e||"").toLowerCase().includes(t))}(a.searchQuery,[e.displayName,e.classname,e.plate,e.netId,e.category]))}function p(e,t,r=""){return a("div",{className:r?`garage-stat-card is-${r}`:"garage-stat-card"},a("span",{className:"garage-stat-label"},e),a("span",{className:"garage-stat-value"},t))}function h(e,t,r){return a("div",{className:"garage-meter"},a("div",{className:"garage-meter-label-row"},a("span",{className:"garage-meter-label"},e),a("span",{className:"garage-meter-value"},`${t}%`)),a("div",{className:"garage-meter-track"},a("span",{className:`garage-meter-fill is-${r}`,style:{width:`${t}%`}})))}function y(e,t,r,i,n){return a("section",{className:"garage-card garage-list-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},t),a("h2",{className:"garage-section-title"},e)),a("span",{className:"garage-pill"},`${i.length} ${1===i.length?"Vehicle":"Vehicles"}`)),a("div",{className:"garage-card-body garage-scroll-body","data-preserve-scroll-id":r},i.length>0?i.map(e=>function(e,t){const r="stored"===e.entryKind?String(e.plate||""):String(e.netId||""),i="nearby"===e.entryKind;return a("button",{type:"button",className:(n=e,c=t,n&&c&&String(n.entryKind||"")===String(c.entryKind||"")&&String(n.plate||"")===String(c.plate||"")&&String(n.netId||"")===String(c.netId||"")?"garage-vehicle-item is-selected":"garage-vehicle-item"),onClick:()=>s.selectEntry(e.entryKind,r)},a("div",{className:"garage-vehicle-item-head"},a("div",{className:"garage-vehicle-copy"},a("span",{className:"garage-vehicle-title"},e.displayName||e.classname||"Vehicle"),a("span",{className:"garage-vehicle-meta"},i?`Nearby ${d(e.distance)}`:`Plate ${u(e.plate)}`)),a("span",{className:i&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},i?!1===e.isEmpty?"Crewed":"Empty":g(e.category))),a("div",{className:"garage-inline-meters"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")));var n,c}(e,n)):a("div",{className:"garage-empty-state"},a("h3",{className:"garage-empty-title"},"No matching vehicles"),a("p",{className:"garage-empty-copy"},"Adjust the current search or category filter to view more records."))))}function b(e){const t=(Array.isArray(e)?e:[]).slice().sort((e,a)=>Number(a.value||0)-Number(e.value||0)).slice(0,6).filter(e=>Number(e.value||0)>0);return 0===t.length?a("div",{className:"garage-empty-inline"},"No subsystem damage reported."):a("div",{className:"garage-hitpoint-grid"},t.map(e=>{return a("div",{className:"garage-hitpoint-row"},a("div",{className:"garage-hitpoint-copy"},a("span",{className:"garage-hitpoint-name"},(t=e.name,String(t||"").replace(/^Hit/i,"").replace(/([a-z])([A-Z])/g,"$1 $2").replace(/_/g," ").trim()||"Subsystem")),e.selection?a("span",{className:"garage-hitpoint-selection"},e.selection):null),a("span",{className:"garage-hitpoint-value"},`${Math.round(100*Number(e.value||0))}%`));var t}))}e.components=e.components||{},e.components.App=function(){const e={categoryFilter:r.getCategoryFilter(),notice:r.getNotice(),pendingAction:r.getPendingAction(),searchQuery:r.getSearchQuery(),selectedId:r.getSelectedId(),selectedKind:r.getSelectedKind()},v=function(e){return"stored"===e.selectedKind?(n.vehicles||[]).find(a=>String(a.plate||"")===e.selectedId)||null:"nearby"===e.selectedKind&&(c.vehicles||[]).find(a=>String(a.netId||"")===e.selectedId)||null}(e),N=m(n.vehicles||[],e),f=m(c.vehicles||[],e),S=e.searchQuery?`Search: ${e.searchQuery}`:"Live";return a("div",{className:"garage-shell"},t({kicker:"FORGE Logistics",title:"Vehicle Garage",onClose:()=>s.closeGarage(),closeLabel:"Close garage interface"}),e.notice.text?a("div",{className:"garage-toast-stack"},a("div",{className:"error"===e.notice.type?"garage-toast is-error":"garage-toast is-success"},e.notice.text)):null,a("div",{className:"garage-layout"},a("aside",{className:"garage-sidebar"},a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Search"),a("h2",{className:"garage-section-title"},"Vehicle Records")),a("span",{className:"garage-pill"},S)),a("div",{className:"garage-search-form"},a("input",{id:"garage-search-input",type:"text",className:"garage-search-input",placeholder:"Search by name, plate, or category",value:e.searchQuery}),a("div",{className:"garage-search-actions"},a("button",{type:"button",className:"garage-btn garage-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("garage-search-input")?.value||"")},"Apply Search"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",onClick:()=>s.clearSearch()},"Clear")))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Filter"),a("h2",{className:"garage-section-title"},"Vehicle Categories"))),a("div",{className:"garage-category-grid"},i.map(t=>a("button",{type:"button",className:e.categoryFilter===t.id?"garage-chip is-active":"garage-chip",onClick:()=>s.selectCategory(t.id)},t.label)))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Status"),a("h2",{className:"garage-section-title"},"Garage Summary")),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:Boolean(e.pendingAction),onClick:()=>s.refreshGarage()},"Refresh")),a("div",{className:"garage-summary-grid"},p("Stored",`${l.capacityUsed}/${l.capacityMax}`),p("Nearby",l.nearbyCount,"accent"),p("Spawn Lane",l.spawnStatus,l.spawnBlocked?"danger":"")))),a("main",{className:"garage-main"},a("section",{className:"garage-panel"},a("div",{className:"garage-panel-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Operations Bay"),a("h1",{className:"garage-title"},l.garageName||"Vehicle Garage")),a("span",{className:"garage-pill"},`${l.capacityUsed}/${l.capacityMax} Stored`)),a("div",{className:"garage-panel-intro"},a("p",{className:"garage-copy"},"Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.")),a("div",{className:"garage-dashboard"},y("Stored Vehicles","Persistent Records","garage-stored-list",N,v),y("Nearby Vehicles","Store Window","garage-nearby-list",f,v),function(e,t){if(!e)return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Selection"),a("h2",{className:"garage-section-title"},"Vehicle Detail"))),a("div",{className:"garage-card-body garage-detail-empty"},a("h3",{className:"garage-empty-title"},"Select a vehicle"),a("p",{className:"garage-empty-copy"},"Choose a stored record to retrieve or a nearby vehicle to store.")));const r="stored"===e.entryKind,i=String(t.pendingAction||""),n="retrieve"===i||"store"===i,c=r&&!l.spawnBlocked&&!n,m=!r&&!1!==e.isEmpty&&!n;return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},r?"Stored Record":"Nearby Vehicle"),a("h2",{className:"garage-section-title"},e.displayName||e.classname||"Vehicle")),a("span",{className:"nearby"===e.entryKind&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},r?`Plate ${u(e.plate)}`:!1===e.isEmpty?"Crewed":"Ready")),a("div",{className:"garage-card-body garage-detail-body"},a("div",{className:"garage-detail-grid"},a("div",{className:"garage-detail-copy"},a("div",{className:"garage-detail-meta"},p("Category",g(e.category)),p("Status",(y=e)?"stored"===y.entryKind?"Stored":!1===y.isEmpty?"Crewed":"Ready":"-","nearby"===e.entryKind&&!1===e.isEmpty?"danger":""),p(r?"Record":"Distance",r?u(e.plate):d(e.distance),r?"":"accent")),a("div",{className:"garage-meter-stack"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")),a("div",{className:"garage-action-row"},r?a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!c,onClick:()=>s.requestRetrieveSelected()},"retrieve"===i?"Retrieving...":"Retrieve Vehicle"):a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!m,onClick:()=>s.requestStoreSelected()},"store"===i?"Storing...":"Store Vehicle"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:n,onClick:()=>s.refreshGarage()},"Refresh")),a("p",{className:"garage-detail-note"},r?l.spawnBlocked?"The garage spawn lane is currently blocked.":"Retrieve this stored vehicle into the active spawn lane.":!1===e.isEmpty?"Only empty nearby vehicles can be stored.":"Store this nearby vehicle back into persistent garage storage.")),a("div",{className:"garage-detail-subsystems"},a("div",{className:"garage-subsystem-header"},a("span",{className:"garage-eyebrow"},"Subsystems"),a("span",{className:"garage-detail-caption"},"Highest damage first")),b(e.hitPoints)))));var y}(v,e))))),a("footer",{className:"garage-footer-bar"},a("div",{className:"garage-footer"},a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Storage Capacity"),a("span",{className:"garage-footer-copy"},`${l.capacityUsed} of ${l.capacityMax} vehicle slot(s) are currently occupied.`)),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Retrieval Window"),a("span",{className:"garage-footer-copy"},l.spawnBlocked?"Spawn lane is blocked. Clear the bay before retrieving another vehicle.":"Spawn lane is clear. Stored vehicles can be retrieved immediately.")),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Store Rules"),a("span",{className:"garage-footer-copy"},"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.")))))}}(),function(){const e=window.ForgeWebUI,a=window.GarageApp;e.createApp({name:"garage",root:"#app",setup({root:t}){e.mount(t,()=>a.components.App(),{preserveScroll:!0}),a.bridge&&a.bridge.notifyReady()}}).start()}(); \ No newline at end of file +!function(){const e=window.ForgeWebUI;(window.GarageApp=window.GarageApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.GarageApp=window.GarageApp||{},a={garageName:"Vehicle Garage",capacityUsed:0,capacityMax:5,nearbyCount:0,spawnBlocked:!1,spawnStatus:"Ready"},r={vehicles:[]},t={vehicles:[]};function s(e,a){var r;Object.keys(e).forEach(a=>delete e[a]),Object.assign(e,(r=a,JSON.parse(JSON.stringify(r))))}e.data={categories:[{id:"all",label:"All"},{id:"car",label:"Cars"},{id:"armor",label:"Armor"},{id:"air",label:"Air"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],session:Object.assign({},a),garage:Object.assign({},r),nearby:Object.assign({},t),applyHydratePayload(e){s(this.session,Object.assign({},a,e?.session||{})),s(this.garage,Object.assign({},r,e?.garage||{})),s(this.nearby,Object.assign({},t,e?.nearby||{}))}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{createSignal:a}=e.runtime;e.store=new class{constructor(){[this.getSelectedKind,this.setSelectedKind]=a(""),[this.getSelectedId,this.setSelectedId]=a(""),[this.getSearchQuery,this.setSearchQuery]=a(""),[this.getCategoryFilter,this.setCategoryFilter]=a("all"),[this.getPendingAction,this.setPendingAction]=a(""),[this.getNotice,this.setNotice]=a({type:"",text:""})}getSelection(){return{id:this.getSelectedId(),kind:this.getSelectedKind()}}clearSelection(){this.setSelectedKind(""),this.setSelectedId("")}select(e,a){this.setSelectedKind(String(e||"")),this.setSelectedId(String(a||""))}startAction(e){this.setPendingAction(String(e||""))}finishAction(){this.setPendingAction("")}matchesSelection(e){if(!e||"object"!=typeof e)return!1;const a=this.getSelection();return!(!a.kind||!a.id)&&("stored"===a.kind?"stored"===e.entryKind&&String(e.plate||"")===a.id:"nearby"===a.kind&&("nearby"===e.entryKind&&String(e.netId||"")===a.id))}ensureSelection(){const a=Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[],r=Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[];if([...a,...r].some(e=>this.matchesSelection(e)))return;const t=a[0]||null;if(t)return void this.select("stored",t.plate||"");const s=r[0]||null;s?this.select("nearby",s.netId||""):this.clearSelection()}hydrateFromPayload(){this.finishAction(),this.ensureSelection()}}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"garage::close",globalName:"ForgeBridge",readyEvent:"garage::ready"});function t(r){e.data.applyHydratePayload(r),a.hydrateFromPayload(r)}r.on("garage::hydrate",t),r.on("garage::sync",t),r.on("garage::retrieve::success",r=>{a.finishAction(),e.actions&&e.actions.showNotice("success",r.message||"Vehicle retrieved from the garage.")}),r.on("garage::retrieve::failure",r=>{a.finishAction(),e.actions&&e.actions.showNotice("error",r.message||"Unable to retrieve vehicle.")}),r.on("garage::store::success",r=>{a.finishAction(),e.actions&&e.actions.showNotice("success",r.message||"Vehicle stored in the garage.")}),r.on("garage::store::failure",r=>{a.finishAction(),e.actions&&e.actions.showNotice("error",r.message||"Unable to store vehicle.")}),r.on("garage::service::success",r=>{a.finishAction(),e.actions&&e.actions.showNotice("success",r.message||"Service request sent.")}),r.on("garage::service::failure",r=>{a.finishAction(),e.actions&&e.actions.showNotice("error",r.message||"Unable to service vehicle.")}),e.bridge={notifyReady:function(){return r.ready({loaded:!0})},receive:r.receive,requestClose:function(){return r.close({})},requestRefresh:function(){return r.send("garage::refresh",{})},requestRefuel:function(e){return r.send("garage::vehicle::refuel::request",e)},requestRepair:function(e){return r.send("garage::vehicle::repair::request",e)},requestRetrieve:function(e){return r.send("garage::vehicle::retrieve::request",e)},requestStore:function(e){return r.send("garage::vehicle::store::request",e)},sendEvent:r.send}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store;let r=null;function t(){const r=a.getSelection();return"stored"===r.kind?(Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[]).find(e=>String(e.plate||"")===r.id)||null:"nearby"===r.kind&&(Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[]).find(e=>String(e.netId||"")===r.id)||null}function s(e,t){a.setNotice({type:e,text:t}),r&&clearTimeout(r),r=setTimeout(()=>{a.setNotice({type:"",text:""}),r=null},3200)}e.actions={showNotice:s,closeGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestClose){if(a.requestClose())return!0}return s("error","Garage bridge is unavailable."),!1},refreshGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestRefresh){if(a.requestRefresh())return!0}return s("error","Garage refresh bridge is unavailable."),!1},applySearchQuery:function(e){a.setSearchQuery(String(e||"").trim())},clearSearch:function(){a.setSearchQuery("")},selectCategory:function(e){a.setCategoryFilter(String(e||"all").trim()||"all")},selectEntry:function(e,r){a.select(e,r)},getSelectedEntry:t,requestRefuelSelected:function(){const r=t();if(!r||"nearby"!==r.entryKind)return s("error","Select a nearby vehicle to refuel."),!1;if(Number(r.fuel||0)>=.999)return s("error","Vehicle fuel tank is already full."),!1;const i=e.bridge;return i&&"function"==typeof i.requestRefuel?(a.startAction("refuel"),!!i.requestRefuel({netId:r.netId||""})||(a.finishAction(),s("error","Garage refuel bridge is unavailable."),!1)):(s("error","Garage refuel bridge is unavailable."),!1)},requestRepairSelected:function(){const r=t();if(!r||"nearby"!==r.entryKind)return s("error","Select a nearby vehicle to repair."),!1;if(Number(r.health||0)>=.999)return s("error","Vehicle has no reported damage."),!1;const i=e.bridge;return i&&"function"==typeof i.requestRepair?(a.startAction("repair"),!!i.requestRepair({netId:r.netId||""})||(a.finishAction(),s("error","Garage repair bridge is unavailable."),!1)):(s("error","Garage repair bridge is unavailable."),!1)},requestRetrieveSelected:function(){const r=t();if(!r||"stored"!==r.entryKind)return s("error","Select a stored vehicle to retrieve."),!1;if(e.data?.session?.spawnBlocked)return s("error","The garage spawn area is blocked."),!1;const i=e.bridge;return i&&"function"==typeof i.requestRetrieve?(a.startAction("retrieve"),!!i.requestRetrieve({plate:r.plate||""})||(a.finishAction(),s("error","Garage retrieve bridge is unavailable."),!1)):(s("error","Garage retrieve bridge is unavailable."),!1)},requestStoreSelected:function(){const r=t();if(!r||"nearby"!==r.entryKind)return s("error","Select a nearby vehicle to store."),!1;if(!1===r.isEmpty)return s("error","All crew must exit the vehicle before storing it."),!1;const i=e.bridge;return i&&"function"==typeof i.requestStore?(a.startAction("store"),!!i.requestStore({netId:r.netId||""})||(a.finishAction(),s("error","Garage store bridge is unavailable."),!1)):(s("error","Garage store bridge is unavailable."),!1)}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{h:a}=e.runtime,r=window.SharedUI.componentFns.WindowTitleBar,t=e.store,s=e.actions,{categories:i,garage:n,nearby:c,session:l}=e.data;function o(e){return Math.max(0,Math.min(100,Math.round(100*Number(e||0))))}function g(e){const a=i.find(a=>a.id===String(e||"other").toLowerCase());return a?a.label:"Other"}function d(e){return`${Math.round(Number(e||0))} m`}function u(e){return String(e||"").trim()||"Untracked"}function p(e,a){return(e||[]).filter(e=>("all"===a.categoryFilter||String(e.category||"").toLowerCase()===a.categoryFilter)&&function(e,a){const r=String(e||"").trim().toLowerCase();return!r||a.some(e=>String(e||"").toLowerCase().includes(r))}(a.searchQuery,[e.displayName,e.classname,e.plate,e.netId,e.category]))}function m(e,r,t=""){return a("div",{className:t?`garage-stat-card is-${t}`:"garage-stat-card"},a("span",{className:"garage-stat-label"},e),a("span",{className:"garage-stat-value"},r))}function h(e,r,t){return a("div",{className:"garage-meter"},a("div",{className:"garage-meter-label-row"},a("span",{className:"garage-meter-label"},e),a("span",{className:"garage-meter-value"},`${r}%`)),a("div",{className:"garage-meter-track"},a("span",{className:`garage-meter-fill is-${t}`,style:{width:`${r}%`}})))}function y(e,r,t,i,n){return a("section",{className:"garage-card garage-list-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},r),a("h2",{className:"garage-section-title"},e)),a("span",{className:"garage-pill"},`${i.length} ${1===i.length?"Vehicle":"Vehicles"}`)),a("div",{className:"garage-card-body garage-scroll-body","data-preserve-scroll-id":t},i.length>0?i.map(e=>function(e,r){const t="stored"===e.entryKind?String(e.plate||""):String(e.netId||""),i="nearby"===e.entryKind;return a("button",{type:"button",className:(n=e,c=r,n&&c&&String(n.entryKind||"")===String(c.entryKind||"")&&String(n.plate||"")===String(c.plate||"")&&String(n.netId||"")===String(c.netId||"")?"garage-vehicle-item is-selected":"garage-vehicle-item"),onClick:()=>s.selectEntry(e.entryKind,t)},a("div",{className:"garage-vehicle-item-head"},a("div",{className:"garage-vehicle-copy"},a("span",{className:"garage-vehicle-title"},e.displayName||e.classname||"Vehicle"),a("span",{className:"garage-vehicle-meta"},i?`Nearby ${d(e.distance)}`:`Plate ${u(e.plate)}`)),a("span",{className:i&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},i?!1===e.isEmpty?"Crewed":"Empty":g(e.category))),a("div",{className:"garage-inline-meters"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")));var n,c}(e,n)):a("div",{className:"garage-empty-state"},a("h3",{className:"garage-empty-title"},"No matching vehicles"),a("p",{className:"garage-empty-copy"},"Adjust the current search or category filter to view more records."))))}function b(e){const r=(Array.isArray(e)?e:[]).slice().sort((e,a)=>Number(a.value||0)-Number(e.value||0)).slice(0,6).filter(e=>Number(e.value||0)>0);return 0===r.length?a("div",{className:"garage-empty-inline"},"No subsystem damage reported."):a("div",{className:"garage-hitpoint-grid"},r.map(e=>{return a("div",{className:"garage-hitpoint-row"},a("div",{className:"garage-hitpoint-copy"},a("span",{className:"garage-hitpoint-name"},(r=e.name,String(r||"").replace(/^Hit/i,"").replace(/([a-z])([A-Z])/g,"$1 $2").replace(/_/g," ").trim()||"Subsystem")),e.selection?a("span",{className:"garage-hitpoint-selection"},e.selection):null),a("span",{className:"garage-hitpoint-value"},`${Math.round(100*Number(e.value||0))}%`));var r}))}e.components=e.components||{},e.components.App=function(){const e={categoryFilter:t.getCategoryFilter(),notice:t.getNotice(),pendingAction:t.getPendingAction(),searchQuery:t.getSearchQuery(),selectedId:t.getSelectedId(),selectedKind:t.getSelectedKind()},v=function(e){return"stored"===e.selectedKind?(n.vehicles||[]).find(a=>String(a.plate||"")===e.selectedId)||null:"nearby"===e.selectedKind&&(c.vehicles||[]).find(a=>String(a.netId||"")===e.selectedId)||null}(e),f=p(n.vehicles||[],e),N=p(c.vehicles||[],e),S=e.searchQuery?`Search: ${e.searchQuery}`:"Live";return a("div",{className:"garage-shell"},r({kicker:"FORGE Logistics",title:"Vehicle Garage",onClose:()=>s.closeGarage(),closeLabel:"Close garage interface"}),e.notice.text?a("div",{className:"garage-toast-stack"},a("div",{className:"error"===e.notice.type?"garage-toast is-error":"garage-toast is-success"},e.notice.text)):null,a("div",{className:"garage-layout"},a("aside",{className:"garage-sidebar"},a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Search"),a("h2",{className:"garage-section-title"},"Vehicle Records")),a("span",{className:"garage-pill"},S)),a("div",{className:"garage-search-form"},a("input",{id:"garage-search-input",type:"text",className:"garage-search-input",placeholder:"Search by name, plate, or category",value:e.searchQuery}),a("div",{className:"garage-search-actions"},a("button",{type:"button",className:"garage-btn garage-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("garage-search-input")?.value||"")},"Apply Search"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",onClick:()=>s.clearSearch()},"Clear")))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Filter"),a("h2",{className:"garage-section-title"},"Vehicle Categories"))),a("div",{className:"garage-category-grid"},i.map(r=>a("button",{type:"button",className:e.categoryFilter===r.id?"garage-chip is-active":"garage-chip",onClick:()=>s.selectCategory(r.id)},r.label)))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Status"),a("h2",{className:"garage-section-title"},"Garage Summary")),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:Boolean(e.pendingAction),onClick:()=>s.refreshGarage()},"Refresh")),a("div",{className:"garage-summary-grid"},m("Stored",`${l.capacityUsed}/${l.capacityMax}`),m("Nearby",l.nearbyCount,"accent"),m("Spawn Lane",l.spawnStatus,l.spawnBlocked?"danger":"")))),a("main",{className:"garage-main"},a("section",{className:"garage-panel"},a("div",{className:"garage-panel-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Operations Bay"),a("h1",{className:"garage-title"},l.garageName||"Vehicle Garage")),a("span",{className:"garage-pill"},`${l.capacityUsed}/${l.capacityMax} Stored`)),a("div",{className:"garage-panel-intro"},a("p",{className:"garage-copy"},"Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.")),a("div",{className:"garage-dashboard"},y("Stored Vehicles","Persistent Records","garage-stored-list",f,v),y("Nearby Vehicles","Store Window","garage-nearby-list",N,v),function(e,r){if(!e)return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Selection"),a("h2",{className:"garage-section-title"},"Vehicle Detail"))),a("div",{className:"garage-card-body garage-detail-empty"},a("h3",{className:"garage-empty-title"},"Select a vehicle"),a("p",{className:"garage-empty-copy"},"Choose a stored record to retrieve or a nearby vehicle to store.")));const t="stored"===e.entryKind,i=String(r.pendingAction||""),n=Boolean(i),c=t&&!l.spawnBlocked&&!n,p=!t&&!1!==e.isEmpty&&!n,y=!t&&Number(e.fuel||0)<.999&&!n,v=!t&&Number(e.health||0)<.999&&!n;return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},t?"Stored Record":"Nearby Vehicle"),a("h2",{className:"garage-section-title"},e.displayName||e.classname||"Vehicle")),a("span",{className:"nearby"===e.entryKind&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},t?`Plate ${u(e.plate)}`:!1===e.isEmpty?"Crewed":"Ready")),a("div",{className:"garage-card-body garage-detail-body"},a("div",{className:"garage-detail-grid"},a("div",{className:"garage-detail-copy"},a("div",{className:"garage-detail-meta"},m("Category",g(e.category)),m("Status",(f=e)?"stored"===f.entryKind?"Stored":!1===f.isEmpty?"Crewed":"Ready":"-","nearby"===e.entryKind&&!1===e.isEmpty?"danger":""),m(t?"Record":"Distance",t?u(e.plate):d(e.distance),t?"":"accent")),a("div",{className:"garage-meter-stack"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")),a("div",{className:"garage-action-row"},t?a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!c,onClick:()=>s.requestRetrieveSelected()},"retrieve"===i?"Retrieving...":"Retrieve Vehicle"):a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!p,onClick:()=>s.requestStoreSelected()},"store"===i?"Storing...":"Store Vehicle"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:!y,onClick:()=>s.requestRefuelSelected()},"refuel"===i?"Refueling...":"Refuel"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:!v,onClick:()=>s.requestRepairSelected()},"repair"===i?"Repairing...":"Repair"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:n,onClick:()=>s.refreshGarage()},"Refresh")),a("p",{className:"garage-detail-note"},t?l.spawnBlocked?"The garage spawn lane is currently blocked.":"Retrieve this stored vehicle into the active spawn lane before refuel or repair service.":!1===e.isEmpty?"Only empty nearby vehicles can be stored.":"Store this nearby vehicle or request organization-billed refuel and repair service.")),a("div",{className:"garage-detail-subsystems"},a("div",{className:"garage-subsystem-header"},a("span",{className:"garage-eyebrow"},"Subsystems"),a("span",{className:"garage-detail-caption"},"Highest damage first")),b(e.hitPoints)))));var f}(v,e))))),a("footer",{className:"garage-footer-bar"},a("div",{className:"garage-footer"},a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Storage Capacity"),a("span",{className:"garage-footer-copy"},`${l.capacityUsed} of ${l.capacityMax} vehicle slot(s) are currently occupied.`)),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Retrieval Window"),a("span",{className:"garage-footer-copy"},l.spawnBlocked?"Spawn lane is blocked. Clear the bay before retrieving another vehicle.":"Spawn lane is clear. Stored vehicles can be retrieved immediately.")),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Store Rules"),a("span",{className:"garage-footer-copy"},"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.")))))}}(),function(){const e=window.ForgeWebUI,a=window.GarageApp;e.createApp({name:"garage",root:"#app",setup({root:r}){e.mount(r,()=>a.components.App(),{preserveScroll:!0}),a.bridge&&a.bridge.notifyReady()}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/garage/ui/src/bridge.js b/arma/client/addons/garage/ui/src/bridge.js index c86b282..0e1c9f8 100644 --- a/arma/client/addons/garage/ui/src/bridge.js +++ b/arma/client/addons/garage/ui/src/bridge.js @@ -23,6 +23,14 @@ return bridge.send("garage::vehicle::store::request", payload); } + function requestRefuel(payload) { + return bridge.send("garage::vehicle::refuel::request", payload); + } + + function requestRepair(payload) { + return bridge.send("garage::vehicle::repair::request", payload); + } + function notifyReady() { return bridge.ready({ loaded: true }); } @@ -75,11 +83,33 @@ } }); + bridge.on("garage::service::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Service request sent.", + ); + } + }); + + bridge.on("garage::service::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to service vehicle.", + ); + } + }); + GarageApp.bridge = { notifyReady, receive: bridge.receive, requestClose, requestRefresh, + requestRefuel, + requestRepair, requestRetrieve, requestStore, sendEvent: bridge.send, diff --git a/arma/client/addons/garage/ui/src/components/AppShell.js b/arma/client/addons/garage/ui/src/components/AppShell.js index 6d00c24..ed57875 100644 --- a/arma/client/addons/garage/ui/src/components/AppShell.js +++ b/arma/client/addons/garage/ui/src/components/AppShell.js @@ -343,11 +343,16 @@ const isStored = currentSelection.entryKind === "stored"; const pendingAction = String(state.pendingAction || ""); - const isBusy = - pendingAction === "retrieve" || pendingAction === "store"; + const isBusy = Boolean(pendingAction); const canRetrieve = isStored && !session.spawnBlocked && !isBusy; const canStore = !isStored && currentSelection.isEmpty !== false && !isBusy; + const canRefuel = + !isStored && Number(currentSelection.fuel || 0) < 0.999 && !isBusy; + const canRepair = + !isStored && + Number(currentSelection.health || 0) < 0.999 && + !isBusy; return h( "section", @@ -461,6 +466,34 @@ ? "Storing..." : "Store Vehicle", ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: !canRefuel, + onClick: () => + actions.requestRefuelSelected(), + }, + pendingAction === "refuel" + ? "Refueling..." + : "Refuel", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: !canRepair, + onClick: () => + actions.requestRepairSelected(), + }, + pendingAction === "repair" + ? "Repairing..." + : "Repair", + ), h( "button", { @@ -479,10 +512,10 @@ isStored ? session.spawnBlocked ? "The garage spawn lane is currently blocked." - : "Retrieve this stored vehicle into the active spawn lane." + : "Retrieve this stored vehicle into the active spawn lane before refuel or repair service." : currentSelection.isEmpty === false ? "Only empty nearby vehicles can be stored." - : "Store this nearby vehicle back into persistent garage storage.", + : "Store this nearby vehicle or request organization-billed refuel and repair service.", ), ), h( diff --git a/arma/client/addons/garage/ui/src/registry/events.js b/arma/client/addons/garage/ui/src/registry/events.js index 3ca41d3..07e47bc 100644 --- a/arma/client/addons/garage/ui/src/registry/events.js +++ b/arma/client/addons/garage/ui/src/registry/events.js @@ -159,6 +159,70 @@ return true; } + function requestRefuelSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "nearby") { + showNotice("error", "Select a nearby vehicle to refuel."); + return false; + } + + if (Number(selectedEntry.fuel || 0) >= 0.999) { + showNotice("error", "Vehicle fuel tank is already full."); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestRefuel !== "function") { + showNotice("error", "Garage refuel bridge is unavailable."); + return false; + } + + store.startAction("refuel"); + const sent = bridge.requestRefuel({ + netId: selectedEntry.netId || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage refuel bridge is unavailable."); + return false; + } + + return true; + } + + function requestRepairSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "nearby") { + showNotice("error", "Select a nearby vehicle to repair."); + return false; + } + + if (Number(selectedEntry.health || 0) >= 0.999) { + showNotice("error", "Vehicle has no reported damage."); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestRepair !== "function") { + showNotice("error", "Garage repair bridge is unavailable."); + return false; + } + + store.startAction("repair"); + const sent = bridge.requestRepair({ + netId: selectedEntry.netId || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage repair bridge is unavailable."); + return false; + } + + return true; + } + GarageApp.actions = { showNotice, closeGarage, @@ -168,6 +232,8 @@ selectCategory, selectEntry, getSelectedEntry, + requestRefuelSelected, + requestRepairSelected, requestRetrieveSelected, requestStoreSelected, }; diff --git a/arma/server/addons/economy/README.md b/arma/server/addons/economy/README.md index f2e567c..988ca10 100644 --- a/arma/server/addons/economy/README.md +++ b/arma/server/addons/economy/README.md @@ -30,9 +30,10 @@ charges such as repairs. shared org-charge helper can also record member debt for medical fallback. ## Event Surface -The addon registers CBA server events for fuel start/tick/stop, repair service, -player killed, player respawn, and healing. Medical store initialization runs -after post-init to discover configured medical spawn objects. +The addon registers CBA server events for fuel start/tick/stop, direct refuel +service, repair service, player killed, player respawn, and healing. Medical +store initialization runs after post-init to discover configured medical spawn +objects. Repair service requests use: @@ -42,6 +43,14 @@ Repair service requests use: `_cost` is optional. Passing `-1` uses the configured service repair cost. +Garage refuel service requests use: + +```sqf +[QEGVAR(economy,RefuelService), [_target, _unit]] call CBA_fnc_serverEvent; +``` + +This fills the selected live vehicle after organization billing succeeds. + ## Billing Rules Economy does not own durable money state. It coordinates Arma-world effects after the relevant hot-cache charge succeeds. @@ -56,6 +65,10 @@ Fuel and repair services are organization-funded: 5. If the charge fails, do not complete the service. Refueling rolls the target back to its starting fuel level; repairs are not applied. +Direct refuel service requests, such as those from the garage UI, calculate +the missing fuel from `fuelCapacity`, charge the organization, and fill the +vehicle only after the charge succeeds. + Medical services are player-funded first: 1. Load the player's bank hot state. diff --git a/arma/server/addons/economy/XEH_preInit.sqf b/arma/server/addons/economy/XEH_preInit.sqf index 7b5bd48..34e561b 100644 --- a/arma/server/addons/economy/XEH_preInit.sqf +++ b/arma/server/addons/economy/XEH_preInit.sqf @@ -33,6 +33,11 @@ if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); }; GVAR(SEconomyStore) call ["repair", [_target, _unit, _cost]]; }] call CFUNC(addEventHandler); +[QGVAR(RefuelService), { + params ["_target", "_unit"]; + GVAR(FEconomyStore) call ["refuel", [_target, _unit]]; +}] call CFUNC(addEventHandler); + [QGVAR(onKilled), { params ["_unit"]; GVAR(MEconomyStore) call ["onKilled", [_unit]]; diff --git a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf index eb820f9..0806220 100644 --- a/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initFEconomyStore.sqf @@ -4,13 +4,14 @@ * File: fnc_initFEconomyStore.sqf * Author: IDSolutions * Date: 2025-12-20 - * Last Update: 2026-01-03 + * Last Update: 2026-04-18 * Public: No * * Description: * Initializes the fuel economy store. Active refueling sessions remain * server-local; payment is routed through the organization extension hot - * cache. + * cache. Garage service refuels use the same organization billing path + * and only fill the vehicle after the charge succeeds. * * Parameter(s): * N/A @@ -53,6 +54,43 @@ GVAR(FEconomyStore) = createHashMapObject [[ SETVAR(_target,liters,0); true }], + ["refuel", { + params [["_target", objNull, [objNull]], ["_unit", objNull, [objNull]]]; + + if (isNull _target || { isNull _unit }) exitWith { false }; + + private _currentFuel = fuel _target; + private _missingFuel = (1 - _currentFuel) max 0 min 1; + if (_missingFuel <= 0.001) exitWith { + [CRPC(notifications,recieveNotification), ["info", "Refueling", "Vehicle fuel tank is already full."], _unit] call CFUNC(targetEvent); + false + }; + + if (isNil QGVAR(SEconomyStore)) exitWith { + ["ERROR", "Service economy store unavailable for garage refueling charge.", nil, nil] call EFUNC(common,log); + [CRPC(notifications,recieveNotification), ["danger", "Refueling", "Organization billing is unavailable. Refueling was not completed."], _unit] call CFUNC(targetEvent); + false + }; + + private _fuelCapacity = getNumber (configOf _target >> "fuelCapacity"); + if (_fuelCapacity <= 0) then { _fuelCapacity = 100; }; + + private _totalLiters = _missingFuel * _fuelCapacity; + private _totalCost = _totalLiters * GVAR(FuelCost); + private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _totalCost, "Refueling"]]; + if !(_chargeResult getOrDefault ["success", false]) exitWith { + [CRPC(notifications,recieveNotification), ["danger", "Refueling", _chargeResult getOrDefault ["message", "Organization funds cannot cover this refuel. Refueling was not completed."]], _unit] call CFUNC(targetEvent); + false + }; + + _target setFuel 1; + SETVAR(_target,liters,0); + + private _formattedTotalCost = [_totalCost] call EFUNC(common,formatNumber); + private _formattedTotalLiters = _totalLiters toFixed 2; + [CRPC(notifications,recieveNotification), ["info", "Refueling", format ["Refueling complete: %1L
Organization charged $%2.", _formattedTotalLiters, _formattedTotalCost]], _unit] call CFUNC(targetEvent); + true + }], ["stop", { params ["_source", "_target"]; diff --git a/docs/CLIENT_GARAGE_USAGE_GUIDE.md b/docs/CLIENT_GARAGE_USAGE_GUIDE.md index 991c34e..c670ff0 100644 --- a/docs/CLIENT_GARAGE_USAGE_GUIDE.md +++ b/docs/CLIENT_GARAGE_USAGE_GUIDE.md @@ -1,7 +1,8 @@ # Client Garage Usage Guide The client garage addon provides player vehicle storage UI, vehicle -store/retrieve actions, vehicle context building, and the virtual garage view. +store/retrieve actions, selected nearby vehicle service requests, vehicle +context building, and the virtual garage view. ## Open Garage UI @@ -31,7 +32,7 @@ available vehicle lists from the virtual garage repository. | `GarageHelperService` | Vehicle names, hit points, and payload helpers. | | `GarageContextService` | Nearby/current vehicle context. | | `GaragePayloadService` | Browser hydrate payload construction. | -| `GarageActionService` | Store/retrieve request handling. | +| `GarageActionService` | Store/retrieve request handling and selected nearby vehicle refuel/repair request forwarding. | | `GarageUIBridge` | Browser ready, hydrate, and sync delivery. | ## Browser Events @@ -42,6 +43,8 @@ available vehicle lists from the virtual garage repository. | `garage::refresh` | Send current garage payload as `garage::sync`. | | `garage::vehicle::retrieve::request` | Forward retrieve request through the action service. | | `garage::vehicle::store::request` | Forward store request through the action service. | +| `garage::vehicle::refuel::request` | Forward selected nearby vehicle refuel request to the server economy service. | +| `garage::vehicle::repair::request` | Forward selected nearby vehicle repair request to the server economy service. | | `garage::close` | Dispose bridge screen state and close the display. | ## Browser Response Events @@ -50,10 +53,22 @@ available vehicle lists from the virtual garage repository. | --- | --- | | `garage::hydrate` | Initial vehicle and session payload. | | `garage::sync` | Refreshed vehicle payload. | +| `garage::service::success` | Browser notice for accepted refuel/repair requests. | +| `garage::service::failure` | Browser notice for rejected refuel/repair requests. | Server action responses are handled by the action service and notification flow. +## Vehicle Service + +The selected vehicle detail panel includes refuel and repair actions for nearby +world vehicles. Stored records must be retrieved first because server economy +services operate on live vehicle objects, not stored garage records. + +Refuel requests use the server economy `RefuelService` event. Repair requests +use the server economy `RepairService` event. Both services are billed by the +server economy addon through organization funds. + ## Mission Setup Garage interactions are normally surfaced through the actor menu when nearby diff --git a/docs/ECONOMY_USAGE_GUIDE.md b/docs/ECONOMY_USAGE_GUIDE.md index 3552e15..88b6181 100644 --- a/docs/ECONOMY_USAGE_GUIDE.md +++ b/docs/ECONOMY_USAGE_GUIDE.md @@ -24,6 +24,11 @@ syncs the organization patch to online members. If organization funds cannot cover the refuel, the vehicle is rolled back to the fuel level it had when the session started. +Garage UI refuel requests use the server `RefuelService` event. The fuel store +calculates missing fuel from the vehicle config `fuelCapacity`, charges the +player's organization, and fills the vehicle only after the organization charge +succeeds. + ## Repair Repair is organization-funded. @@ -37,6 +42,9 @@ Use the repair service event: `_cost` is optional. Passing `-1` uses the configured service repair cost. The target is only repaired after the organization charge succeeds. +The client garage UI forwards selected nearby vehicle repair requests through +the same event. + ## Medical Medical is player-funded first.