Faction${escapeHtml(factionLabel)}
+
Generator${generatorProviderLabel}
Mission Cap${settings.maxConcurrentMissions}
Interval${settings.missionInterval}s
Location Cooldown${settings.locationReuseCooldown}s
diff --git a/arma/client/addons/store/ui/_site/store-ui.js b/arma/client/addons/store/ui/_site/store-ui.js
index ffa5a91..a3c974a 100644
--- a/arma/client/addons/store/ui/_site/store-ui.js
+++ b/arma/client/addons/store/ui/_site/store-ui.js
@@ -1 +1 @@
-!function(){const e=window.ForgeWebUI;(window.StorefrontApp=window.StorefrontApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.runtime,[n,r]=t.createSignal(0),a=Object.create(null),o=Object.create(null),s=[],i=Object.create(null),c=Object.create(null),l=new WeakSet;let d=0,m=null,u=null,g=0;function p(e){let t=String(e||"").trim();if(!t)return"";for(;t.startsWith("\\")||t.startsWith("/");)t=t.slice(1);return/\.[A-Za-z0-9]+$/.test(t)||(t+=".paa"),t}function b(e){const t=String(e||"").trim().toLowerCase();return t.startsWith("data:image/")||t.startsWith("blob:")||t.startsWith("http://")||t.startsWith("https://")}function h(e,t){a[e]=t,function(){if(g)return;g=window.setTimeout(()=>{g=0,r(e=>e+1)},48)}()}function y(){if("undefined"!=typeof A3API&&"function"==typeof A3API.RequestTexture)for(;d<6&&s.length>0;){const e=s.shift();delete i[e],e&&void 0===a[e]&&!o[e]&&(d+=1,o[e]=Promise.resolve(A3API.RequestTexture(e,512)).then(t=>{const n=String(t||"").trim();b(n)?h(e,n):(console.warn("[Store UI] Ignoring unsupported texture response.",e,n),h(e,""))}).catch(t=>{console.warn("[Store UI] Failed to resolve texture.",e,t),h(e,"")}).finally(()=>{d=Math.max(0,d-1),delete o[e],y()}))}}function f(e){!e||i[e]||o[e]||(i[e]=!0,s.push(e),y())}function v(e){const t=p(e);t&&!c[t]&&(c[t]=!0,b(a[t])||o[t]||f(t))}function S(){const e=document.querySelectorAll("[data-store-texture-path]");if(0===e.length)return;const t=function(){const e=document.querySelector(".catalog-grid");return"function"!=typeof IntersectionObserver?null:(m&&u===e||(m&&m.disconnect(),u=e,m=new IntersectionObserver(e=>{e.forEach(e=>{e.isIntersecting&&(v(e.target.getAttribute("data-store-texture-path")),m.unobserve(e.target))})},{root:e,rootMargin:"240px 0px",threshold:.01})),m)}();e.forEach(e=>{if(l.has(e))return;l.add(e);const n=e.getAttribute("data-store-texture-path");t?t.observe(e):v(n)})}e.media={getTextureState:function(e){n();const t=p(e);return{path:t,isVisible:Boolean(t&&c[t]),isLoaded:Boolean(t&&a[t]&&b(a[t]))}},getTextureSource:function(e){n();const t=p(e);return t?b(e)?(a[t]=String(e).trim(),a[t]):void 0!==a[t]?a[t]:c[t]?(f(t),""):"":""},scheduleTextureObservation:function(){window.requestAnimationFrame(()=>{S()})}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t={actorName:"",actorUid:"",approval:"Field Access",orgId:"",orgName:"",orgLeader:!1,defaultOrgCeo:!1,canUseOrgFunds:!1},n={budget:5e4,creditLine:0,availability:"In-Stock",moduleState:"Preview",searchTags:["Attachment","Grenade","Medical","Consumable","Static","Scope","Item","Misc"],paymentSources:[{id:"cash",label:"Cash",balance:0,enabled:!1,detail:"Use on-hand cash carried by the player."},{id:"bank",label:"Bank",balance:0,enabled:!1,detail:"Charge the player bank account."},{id:"org_funds",label:"Org Funds",balance:0,enabled:!1,detail:"Only organization leaders or the default-org CEO can use treasury funds."},{id:"credit_line",label:"Credit Line",balance:0,enabled:!1,detail:"No approved credit line is assigned to this member."}],defaultPaymentSource:"cash"};function r(e,t){var n;Object.keys(e).forEach(t=>delete e[t]),Object.assign(e,(n=t,JSON.parse(JSON.stringify(n))))}e.data={catalog:{categoryCards:[{id:"uniforms",label:"Uniforms"},{id:"headgear",label:"Headgear"},{id:"facewear",label:"Facewear"},{id:"vests",label:"Vests"},{id:"backpacks",label:"Backpacks"},{id:"attachments",label:"Attachments"},{id:"weapons",label:"Weapons"},{id:"ammo",label:"Ammo"},{id:"misc",label:"Misc"},{id:"vehicles",label:"Vehicles"}],vehicleCards:[{id:"cars",label:"Cars"},{id:"armor",label:"Armor"},{id:"helis",label:"Helicopters"},{id:"planes",label:"Planes"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],weaponCards:[{id:"primary",label:"Primary"},{id:"secondary",label:"Secondary"},{id:"handgun",label:"Handgun"}],previewItems:{uniforms:[],headgear:[],facewear:[],vests:[],backpacks:[],attachments:[],ammo:[],misc:[],primary:[],secondary:[],handgun:[],cars:[],armor:[],helis:[],planes:[],naval:[],other:[]}},session:Object.assign({},t),storeConfig:Object.assign({},n),applyHydratePayload(e){r(this.session,Object.assign({},t,e?.session||{})),r(this.storeConfig,Object.assign({},n,e?.storeConfig||{}))}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{createSignal:t}=e.runtime,n=window.SharedLogic=window.SharedLogic||{};n.createStorefrontStore=function({createSignal:e}){function t(e){return{className:String(e?.className||e?.code||""),code:String(e?.code||e?.className||""),name:String(e?.name||e?.displayName||""),description:String(e?.description||""),price:String(e?.price||""),image:String(e?.image||""),type:String(e?.type||""),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(0,Number(e?.quantity||0))}}function n(e){return{code:String(e?.code||""),name:String(e?.name||""),price:String(e?.price||"$0"),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(1,Number(e?.quantity||1))}}return new class{constructor(){[this.getView,this.setView]=e("categories"),[this.getSelectedCategory,this.setSelectedCategory]=e(""),[this.getSelectedWeaponSlot,this.setSelectedWeaponSlot]=e(""),[this.getSelectedVehicleSlot,this.setSelectedVehicleSlot]=e(""),[this.getCartOpen,this.setCartOpen]=e(!1),[this.getSearchQuery,this.setSearchQuery]=e(""),[this.getCartItems,this.setCartItems]=e([]),[this.getCatalogItemsByKey,this.setCatalogItemsByKey]=e({}),[this.getIsCatalogLoading,this.setIsCatalogLoading]=e(!1),[this.getCatalogRequestKey,this.setCatalogRequestKey]=e(""),[this.getCatalogPage,this.setCatalogPage]=e(1),[this.getNotice,this.setNotice]=e({type:"",text:""}),[this.getIsCheckingOut,this.setIsCheckingOut]=e(!1),[this.getSelectedPaymentSource,this.setSelectedPaymentSource]=e("cash")}resetToCategories(){this.setView("categories"),this.setSelectedCategory(""),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openWeaponsRoot(){this.setView("weapons"),this.setSelectedCategory("weapons"),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openVehiclesRoot(){this.setView("vehicles"),this.setSelectedCategory("vehicles"),this.setSelectedVehicleSlot(""),this.setSelectedWeaponSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}resetCatalogPage(){this.setCatalogPage(1)}setCatalogPageNumber(e){const t=Math.max(1,Number(e||1));this.setCatalogPage(t)}selectCategory(e){this.setSelectedCategory(e),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setCatalogPage(1),"weapons"!==e?"vehicles"!==e?this.setView("items"):this.openVehiclesRoot():this.openWeaponsRoot()}selectSubcategory(e,t){"vehicle"===t?(this.setSelectedVehicleSlot(e),this.setSelectedWeaponSlot("")):(this.setSelectedWeaponSlot(e),this.setSelectedVehicleSlot("")),this.setCatalogPage(1),this.setView("items")}startCategoryRequest(e){const t=String(e||"").trim().toLowerCase();return!!t&&(this.setCatalogRequestKey(t),this.setIsCatalogLoading(!0),!0)}finishCategoryRequest(e){const t=String(e||"").trim().toLowerCase(),n=String(this.getCatalogRequestKey()||"").trim().toLowerCase();t&&n&&n!==t||(this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1))}hydrateCategoryItems(e){const n=String(e?.category||"").trim().toLowerCase(),r=Array.isArray(e?.items)?e.items:[];if(!n)return this.setCatalogRequestKey(""),void this.setIsCatalogLoading(!1);this.setCatalogItemsByKey(e=>Object.assign({},e,{[n]:r.map(t)})),this.finishCategoryRequest(n)}ensureSelectedPaymentSource(e){const t=Array.isArray(e?.paymentSources)?e.paymentSources:[],n=String(this.getSelectedPaymentSource()||"").trim(),r=String(e?.defaultPaymentSource||"").trim(),a=t.map(e=>String(e?.id||"").trim()),o=t.find(e=>e&&!1!==e.enabled),s=r&&a.includes(r)?t.find(e=>String(e?.id||"").trim()===r):null;n&&a.includes(n)&&t.some(e=>String(e?.id||"").trim()===n&&!1!==e?.enabled)||(s&&!1!==s.enabled?this.setSelectedPaymentSource(r):o?this.setSelectedPaymentSource(String(o.id||"cash")):this.setSelectedPaymentSource(r||"cash"))}navigateToBreadcrumb(e){switch(e){case"categories":return this.resetToCategories(),!0;case"weapons":return this.openWeaponsRoot(),!0;case"vehicles":return this.openVehiclesRoot(),!0;default:return!1}}hydrateFromPayload(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.setCatalogItemsByKey({}),this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1),this.setCatalogPage(1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}hydrateStoreConfig(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}}},e.store=n.createStorefrontStore({createSignal:t})}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{};function t(e){return e.selectedWeaponSlot||e.selectedVehicleSlot||e.selectedCategory}function n(e,t){if(!e)return!0;const n=String(e).trim().toLowerCase();return!n||t.some(e=>String(e||"").toLowerCase().includes(n))}function r(e){const t=Number(String(e||"0").replace(/[^0-9.-]+/g,""));return Number.isFinite(t)?t:0}function a(e){const t=String(e||"").trim().toLowerCase();return["items","misc"].includes(t)?"Misc":String(e||"").replace(/[-_]+/g," ").split(/\s+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join(" ")}function o(e,r){const a=t(e),o=String(a||"").trim().toLowerCase(),s=e.catalogItemsByKey||{};return(Array.isArray(s[o])?s[o]:[]).filter(t=>n(e.searchQuery,[t.className,t.code,t.name,t.description,t.price,t.type]))}function s(e,t){const n=o(e).length,r=Math.max(1,Math.ceil(n/6)),a=Math.min(r,Math.max(1,Number(e.catalogPage||1)));return{pageSize:6,totalItems:n,totalPages:r,currentPage:a,startIndex:0===n?0:6*(a-1)+1,endIndex:Math.min(6*a,n)}}function i(e){return(Array.isArray(e?.paymentSources)?e.paymentSources:[]).map(e=>({id:String(e?.id||"").trim(),label:String(e?.label||e?.id||"").trim(),balance:Number(e?.balance||0),enabled:!1!==e?.enabled,detail:String(e?.detail||"").trim()}))}e.getters={formatTitle:a,formatCurrency:function(e){return`$${Number(e||0).toLocaleString()}`},parsePrice:r,getSelectionKey:t,getStoreState:function(e){return{view:e.getView(),selectedCategory:e.getSelectedCategory(),selectedWeaponSlot:e.getSelectedWeaponSlot(),selectedVehicleSlot:e.getSelectedVehicleSlot(),selectedPaymentSource:e.getSelectedPaymentSource(),cartOpen:e.getCartOpen(),searchQuery:e.getSearchQuery(),cartItems:e.getCartItems(),catalogItemsByKey:e.getCatalogItemsByKey(),isCatalogLoading:e.getIsCatalogLoading(),catalogRequestKey:e.getCatalogRequestKey(),catalogPage:e.getCatalogPage(),isCheckingOut:e.getIsCheckingOut()}},getStoreHeader:function(e){if("weapons"===e.view)return{eyebrow:"Weapons Division",title:"Weapon Categories",copy:"Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.",badge:"3 Slots"};if("vehicles"===e.view)return{eyebrow:"Vehicle Motorpool",title:"Vehicle Categories",copy:"Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.",badge:"6 Classes"};if("items"===e.view){const n=t(e)||"catalog",r=e.searchQuery?` Filtered by "${e.searchQuery}".`:"",o=e.isCatalogLoading?" Pulling live inventory from the game engine.":"";return{eyebrow:"Catalog Preview",title:a(n),copy:`Live category inventory generated from the game engine for the selected department.${r}${o}`,badge:"Preview Items"}}return{eyebrow:"Supply Categories",title:"Procurement Dashboard",copy:"Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.",badge:"8 Categories"}},getStoreBreadcrumbs:function(e){const t=[{id:"categories",label:"Supply Exchange"}];if("weapons"===e.view)return t.push({id:"weapons",label:"Weapons"}),t;if("vehicles"===e.view)return t.push({id:"vehicles",label:"Vehicles"}),t;if("items"===e.view){if(e.selectedWeaponSlot)return t.push({id:"weapons",label:"Weapons"}),t.push({id:"weapon-slot",label:a(e.selectedWeaponSlot)}),t;if(e.selectedVehicleSlot)return t.push({id:"vehicles",label:"Vehicles"}),t.push({id:"vehicle-slot",label:a(e.selectedVehicleSlot)}),t;e.selectedCategory&&t.push({id:"category",label:a(e.selectedCategory)})}return t},getVisibleCategoryCards:function(e,t){return t.categoryCards.filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleSubcategoryCards:function(e,t){return("vehicles"===e.view?t.vehicleCards:t.weaponCards).filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleItems:o,getVisibleItemsPage:function(e,t){const n=o(e),r=s(e),a=(r.currentPage-1)*r.pageSize;return n.slice(a,a+r.pageSize)},getCatalogPagination:s,summarizeCart:function(e){const t=e.reduce((e,t)=>e+Number(t.quantity||0),0),n=e.reduce((e,t)=>e+r(t.price)*Number(t.quantity||0),0);return{lineCount:e.length,itemCount:t,subtotal:n,total:n}},getPaymentSources:i,getPaymentSourceById:function(e,t){const n=String(t||"").trim();return i(e).find(e=>e.id===n)}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=window.ForgeWebUI.createBridge({closeEvent:"store::close",globalName:"StoreUIBridge",readyEvent:"store::ready"});n.on("store::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateFromPayload(n)}),n.on("store::config::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateStoreConfig(n)}),n.on("store::checkout::success",n=>{t.setIsCheckingOut(!1),t.setCartItems([]),t.setCartOpen(!1),e.actions&&e.actions.showNotice("success",n.message||"Checkout completed.")}),n.on("store::category::hydrate",e=>{t.hydrateCategoryItems(e)}),n.on("store::category::failure",n=>{t.finishCategoryRequest(n.category||""),e.actions&&e.actions.showNotice("error",n.message||"Category request failed.")}),n.on("store::checkout::failure",n=>{t.setIsCheckingOut(!1),e.actions&&e.actions.showNotice("error",n.message||"Checkout failed.")}),e.bridge={close:n.close,requestClose:function(){return n.close({})},requestCheckout:function(e){return n.send("store::checkout::request",e)},requestCategory:function(e){return n.send("store::category::request",e)},notifyReady:function(){return n.ready({loaded:!0})},receive:n.receive,sendEvent:n.send}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=e.getters,{storeConfig:r,session:a}=e.data;let o=null;function s(e,n){t.setNotice({type:e,text:n}),o&&clearTimeout(o),o=setTimeout(()=>{t.setNotice({type:"",text:""}),o=null},3200)}function i(e,t,n){const r={items:[],vehicles:[],totalPrice:n,paymentMethod:t};return e.forEach(e=>{const t=function(e){return{classname:String(e?.code||"").trim(),category:String(e?.category||"").trim().toLowerCase(),entryKind:String(e?.entryKind||"item").trim().toLowerCase(),quantity:Math.max(1,Number(e?.quantity||1))}}(e);if("vehicle"!==t.entryKind)r.items.push({classname:t.classname,category:t.category,quantity:t.quantity});else for(let e=0;e
!e)},closeCart:function(){t.setCartOpen(!1)},closeStore:function(){const t=e.bridge;if(t&&"function"==typeof t.requestClose){if(t.requestClose())return!0}return s("error","Store bridge is unavailable."),!1},navigateToBreadcrumb:function(e){return t.navigateToBreadcrumb(e)},selectCategory:function(e){t.selectCategory(e),c(),["weapons","vehicles"].includes(String(e||""))||d(e)},selectSubcategory:function(e,n){t.selectSubcategory(e,n),c(),d(e)},goToCatalogPage:l,goToNextCatalogPage:function(e){const n=Number(t.getCatalogPage()||1);return!(n>=Math.max(1,Number(e||1)))&&(l(n+1),!0)},goToPreviousCatalogPage:function(){const e=Number(t.getCatalogPage()||1);return!(e<=1)&&(l(e-1),!0)},addToCart:function(e){t.setCartItems(t=>{const n=t.findIndex(t=>t.code===e.code);if(-1===n)return[...t,{code:e.code,name:e.name,price:e.price,category:e.category,entryKind:e.entryKind,quantity:1}];const r=[...t];return r[n]=Object.assign({},r[n],{category:e.category,entryKind:e.entryKind,quantity:r[n].quantity+1}),r}),s("success",`${e.name} added to the acquisition queue.`)},incrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:t.quantity+1}):t))},decrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:Math.max(0,t.quantity-1)}):t).filter(e=>e.quantity>0))},removeCartItem:function(e){t.setCartItems(t=>t.filter(t=>t.code!==e))},selectPaymentSource:function(e){const a=String(e||"").trim(),o=n.getPaymentSources(r).find(e=>e.id===a);return o?!1===o.enabled?(s("error",o.detail||"Selected payment source is not available."),!1):(t.setSelectedPaymentSource(a),!0):(s("error","Selected payment source is unavailable."),!1)},requestCheckout:function(){const a=t.getCartItems();if(0===a.length)return s("error","Add at least one item before checkout."),!1;const o=n.summarizeCart(a),c=n.getPaymentSourceById(r,t.getSelectedPaymentSource());if(!c)return s("error","Select a payment source before checkout."),!1;if(!1===c.enabled)return s("error",c.detail||"Selected payment source is unavailable."),!1;if(o.total>Number(c.balance||0))return s("error",`${c.label} cannot cover this checkout total.`),!1;const l=e.bridge;if(!l||"function"!=typeof l.requestCheckout)return s("error","Checkout bridge is unavailable."),!1;t.setIsCheckingOut(!0);const d=i(a,c.id,o.total);return!!l.requestCheckout({checkoutJson:JSON.stringify(d)})||(t.setIsCheckingOut(!1),s("error","Checkout bridge is unavailable."),!1)},formatTitle:n.formatTitle,formatCurrency:n.formatCurrency}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=window.SharedUI.componentFns.WindowTitleBar,a=e.store,o=e.getters,s=e.actions,{catalog:i,session:c,storeConfig:l}=e.data,d="data-ui-store-app-shell",m=`[${d}]`,u=`\n${m} {\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: var(--store-shell-bg);\n}\n\n${m} .footer-title,\n${m} .eyebrow {\n font-size: 0.68rem;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .module-header,\n${m} .store-panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${m} .store-app {\n flex: 1;\n min-height: 0;\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: 308px minmax(0, 1fr);\n gap: 1.25rem;\n padding: 1.25rem;\n}\n\n${m} .store-sidebar,\n${m} .store-main {\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${m} .store-main {\n position: relative;\n overflow: hidden;\n}\n\n${m} .module-card,\n${m} .store-panel {\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n border: 1px solid var(--store-border);\n border-radius: 1.35rem;\n}\n\n${m} .module-card {\n padding: 1rem;\n}\n\n${m} .store-panel {\n min-height: 0;\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n width: min(100%, 1280px);\n overflow: hidden;\n}\n\n${m} .module-header {\n margin-bottom: 0.85rem;\n}\n\n${m} .store-panel-header {\n padding: 1rem 1rem 0;\n}\n\n${m} .section-title {\n margin: 0;\n font-size: 1.1rem;\n font-weight: 700;\n letter-spacing: -0.02em;\n color: var(--store-text-main);\n}\n\n${m} .section-copy,\n${m} .footer-copy {\n margin: 0.2rem 0 0;\n font-size: 0.9rem;\n line-height: 1.45;\n color: var(--store-text-muted);\n}\n\n${m} .pill {\n padding: 0.48rem 0.8rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.74rem;\n font-weight: 700;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n}\n\n${m} .search-module {\n display: flex;\n flex-direction: column;\n gap: 0.8rem;\n}\n\n${m} .search-form {\n display: grid;\n gap: 0.7rem;\n}\n\n${m} .search-input {\n width: 100%;\n height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.75);\n color: var(--store-text-main);\n}\n\n${m} .quick-tags {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n\n${m} .quick-tag {\n padding: 0.55rem 0.72rem;\n border-radius: 999px;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.52);\n color: var(--store-text-muted);\n font-size: 0.75rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${m} .filter-stack {\n display: grid;\n gap: 0.85rem;\n}\n\n${m} .filter-group {\n padding: 0.95rem;\n border-radius: 0.8rem;\n background: rgb(255 255 255 / 0.48);\n border: 1px solid var(--store-border);\n}\n\n${m} .filter-label {\n display: block;\n margin-bottom: 0.55rem;\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .filter-value {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n color: var(--store-text-main);\n font-size: 0.92rem;\n font-weight: 600;\n}\n\n${m} .filter-placeholder {\n color: var(--store-text-muted);\n font-weight: 500;\n}\n\n${m} .store-panel-intro {\n padding: 0 1rem 1rem;\n border-bottom: 1px solid var(--store-accent-line);\n}\n\n${m} .store-footer-bar {\n width: 100%;\n border-top: 1px solid rgb(18 54 93 / 0.1);\n background: transparent;\n}\n\n${m} .store-footer {\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n padding: 0.95rem 1.25rem 1.15rem;\n}\n\n${m} .footer-block {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n}\n\n${m} .store-toast-stack {\n position: fixed;\n top: 1.2rem;\n right: 1.5rem;\n z-index: 10;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${m} .store-toast {\n max-width: 24rem;\n padding: 0.85rem 1rem;\n border-radius: 0.9rem;\n border: 1px solid var(--store-border);\n background: #fff;\n box-shadow: 0 14px 28px rgb(16 34 56 / 0.14);\n font-size: 0.92rem;\n}\n\n${m} .store-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n}\n\n${m} .store-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 1440px) {\n ${m} .store-app {\n grid-template-columns: 284px minmax(0, 1fr);\n }\n}\n\n@media (max-width: 1120px) {\n ${m} .store-app {\n grid-template-columns: 1fr;\n overflow: auto;\n }\n\n ${m} .store-sidebar,\n ${m} .store-main {\n min-height: auto;\n }\n\n ${m} .store-main {\n overflow: visible;\n }\n\n ${m} .store-footer {\n grid-template-columns: 1fr;\n }\n\n ${m} .store-toast-stack {\n right: 1rem;\n left: 1rem;\n }\n}\n`;e.components=e.components||{},e.componentFns=e.componentFns||{},e.components.App=function(){const m=e.componentFns.Navbar,g=e.componentFns.Cart,p=o.getStoreState(a),b=o.getStoreHeader(p),h=a.getNotice(),y=p.searchQuery,f=o.getPaymentSources(l).filter(e=>!1!==e.enabled).length,v="items"===p.view?s.formatTitle(o.getSelectionKey(p)||"Catalog"):s.formatTitle(p.view),S=o.getPaymentSourceById(l,p.selectedPaymentSource)||null;return n("storefront-app-shell",u),t("div",{[d]:""},r({kicker:"FORGE Logistics",title:"Supply Exchange",onClose:()=>s.closeStore(),closeLabel:"Close store interface"}),h.text?t("div",{className:"store-toast-stack"},t("div",{className:"error"===h.type?"store-toast is-error":"store-toast is-success"},h.text)):null,t("div",{className:"store-app"},t("aside",{className:"store-sidebar"},t("section",{className:"module-card search-module"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Search"),t("h2",{className:"section-title"},"Inventory Search")),t("span",{className:"pill"},"Live")),t("div",{className:"search-form"},t("input",{id:"store-search-input",type:"text",className:"search-input",placeholder:"Search inventory, classes, or suppliers",value:y}),t("div",{style:{display:"flex",gap:"0.65rem"}},t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("store-search-input")?.value||"")},"Apply Search"),t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:()=>s.clearSearch()},"Clear"))),t("div",{className:"quick-tags"},(l.searchTags||[]).map(e=>t("span",{className:"quick-tag"},e)))),t("section",{className:"module-card"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Filter"),t("h2",{className:"section-title"},"Procurement Filters")),t("span",{className:"pill"},l.moduleState)),t("div",{className:"filter-stack"},t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Department"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},v))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Availability"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},l.availability))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Payment"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},S?S.label:"Cash")))))),t("main",{className:"store-main"},t("section",{className:"store-panel"},m(),t("div",{className:"store-panel-header"},t("div",null,t("span",{className:"eyebrow"},b.eyebrow),t("h1",{className:"section-title"},b.title)),t("span",{className:"pill"},b.badge)),t("div",{className:"store-panel-intro"},t("p",{className:"section-copy"},b.copy)),function(t){const{CategoryCard:n,SubcategoryCard:r,ProductCard:a,EmptyStateCard:c,CategoryGrid:l,SubcategoryGrid:d,ProductGrid:m,CatalogPager:u}=e.componentFns;if("weapons"===t.view||"vehicles"===t.view){const e="vehicles"===t.view?"vehicle":"weapon",n=o.getVisibleSubcategoryCards(t,i);return d(n.length>0?n.map(t=>r(t,e)):c({title:"No matching slots",copy:"Try a different search query or clear the current filter.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}if("items"===t.view){const e=o.getVisibleItems(t,i),n=o.getVisibleItemsPage(t,i),r=o.getCatalogPagination(t,i),l=t.cartItems.reduce((e,t)=>(e[t.code]=t.quantity,e),{}),d=String(o.getSelectionKey(t)||"").toLowerCase();return[m(t.isCatalogLoading&&t.catalogRequestKey===d&&0===e.length?c({title:"Loading inventory",copy:"Pulling live category items from the game engine."}):e.length>0?n.map(e=>a(e,l[e.code]||0)):c({title:"No category items",copy:t.searchQuery?"Your search filter excluded the live inventory returned for this category.":"The game engine did not return any items for this category yet.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()})),e.length>0?u(r):null]}const g=o.getVisibleCategoryCards(t,i);return l(g.length>0?g.map(e=>n(e)):c({title:"No matching departments",copy:"Your search filter excluded every top-level department.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}(p)),g())),t("footer",{className:"store-footer-bar"},t("div",{className:"store-footer"},t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Procurement Desk"),t("span",{className:"footer-copy"},"Authorized supply browsing for personnel loadout preparation and mission staging.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Catalog Scope"),t("span",{className:"footer-copy"},"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Purchase Access"),t("span",{className:"footer-copy"},`${c.approval} approval. ${f} payment source(s) currently available${c.orgName?` for ${c.orgName}.`:"."}`)))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.actions,a=e.media,o="data-ui-store-cards",s=`[${o}]`,i=`\n${s}.catalog-grid-shell {\n flex: 1;\n min-height: 0;\n display: flex;\n}\n\n${s}.catalog-pager-shell {\n display: block;\n}\n\n${s} .catalog-grid {\n flex: 1;\n min-height: 0;\n width: 100%;\n padding: 1rem;\n display: grid;\n gap: 1rem;\n align-content: start;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid::-webkit-scrollbar {\n width: 12px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.45);\n border-radius: 999px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid.is-categories,\n${s} .catalog-grid.is-products {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n}\n\n${s} .catalog-grid.is-subcategories {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n${s} .card-button,\n${s} .product-card,\n${s} .empty-state {\n border: 1px solid var(--store-border);\n border-radius: 1.15rem;\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%),\n var(--store-surface-strong);\n color: var(--store-accent);\n box-shadow:\n inset 0 1px 0 rgb(255 255 255 / 0.8),\n 0 10px 24px rgb(16 34 56 / 0.06);\n}\n\n${s} .card-button {\n min-height: 12.5rem;\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 0.75rem;\n padding: 1.35rem;\n text-align: left;\n transition:\n transform 120ms ease,\n box-shadow 120ms ease,\n border-color 120ms ease;\n}\n\n${s} .card-button:hover,\n${s} .product-card:hover {\n transform: translateY(-2px);\n border-color: rgb(18 54 93 / 0.32);\n box-shadow:\n 0 16px 28px rgb(16 34 56 / 0.11),\n inset 0 1px 0 rgb(255 255 255 / 0.88);\n}\n\n${s} .card-kicker,\n${s} .product-code,\n${s} .empty-state-kicker {\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${s} .card-label {\n font-size: 1.08rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n${s} .card-copy,\n${s} .product-copy,\n${s} .empty-state-copy {\n margin: 0;\n color: var(--store-text-muted);\n line-height: 1.45;\n}\n\n${s} .product-copy {\n white-space: pre-line;\n}\n\n${s} .product-card {\n min-height: 15.5rem;\n padding: 0.8rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .product-image {\n height: 5.9rem;\n border-radius: 0.95rem;\n border: 1px dashed rgb(18 54 93 / 0.24);\n background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%);\n display: flex;\n align-items: center;\n justify-content: center;\n color: var(--store-text-subtle);\n font-size: 0.78rem;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n overflow: hidden;\n}\n\n${s} .product-image-asset {\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n${s} .product-meta {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .product-name {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-text-main);\n line-height: 1.3;\n}\n\n${s} .product-footer {\n margin-top: auto;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${s} .product-price {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-success);\n}\n\n${s} .product-qty {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.85rem;\n height: 1.85rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.76rem;\n font-weight: 700;\n}\n\n${s} .empty-state {\n padding: 1.35rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .catalog-pager {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.9rem;\n padding: 0.55rem 0.9rem 0.75rem;\n border-top: 1px solid var(--store-accent-line);\n}\n\n${s} .catalog-pager-meta {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n}\n\n${s} .catalog-pager-summary {\n font-size: 0.86rem;\n color: var(--store-text-muted);\n}\n\n${s} .catalog-pager-actions {\n display: inline-flex;\n align-items: center;\n gap: 0.6rem;\n}\n\n${s} .catalog-pager-page {\n min-width: 5.75rem;\n text-align: center;\n font-size: 0.82rem;\n font-weight: 700;\n color: var(--store-accent);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${s} .product-copy {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n}\n\n@media (max-width: 1440px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 1120px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-subcategories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: 1fr;\n }\n}\n`;function c(e,r){return n("storefront-cards",i),"is-products"===e&&a&&"function"==typeof a.scheduleTextureObservation&&a.scheduleTextureObservation(),t("div",{[o]:"",className:"catalog-grid-shell"},t("div",{className:`catalog-grid ${e}`,"data-preserve-scroll-id":"catalog-grid"},r))}e.componentFns=e.componentFns||{},e.componentFns.CategoryCard=function(e){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectCategory(e.id)},t("span",{className:"card-kicker"},"Department"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open this department and move into staged inventory browsing."))},e.componentFns.SubcategoryCard=function(e,n){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectSubcategory(e.id,n)},t("span",{className:"card-kicker"},"vehicle"===n?"Vehicle Class":"Weapon Slot"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open the next tier and review product previews for this selection."))},e.componentFns.ProductCard=function(e,n){const o=a&&"function"==typeof a.getTextureState?a.getTextureState(e.image):{isVisible:!0},s=a&&"function"==typeof a.getTextureSource?a.getTextureSource(e.image):"",i=function(e,t){const n=String(e||"").trim();if(!n)return t;const r=n.replace(/<\s*br\s*\/?\s*>/gi,"\n").replace(/<\/\s*p\s*>/gi,"\n").replace(/<\s*li\s*>/gi,"- ").replace(/<\/\s*li\s*>/gi,"\n"),a=document.createElement("div");return a.innerHTML=r,String(a.textContent||a.innerText||"").replace(/\u00a0/g," ").replace(/[ \t]+\n/g,"\n").replace(/\n{3,}/g,"\n\n").trim()||t}(e.description,e.className||e.code);return t("article",{className:"product-card"},t("div",{className:"product-image","data-store-texture-path":e.image||""},s?t("img",{className:"product-image-asset",src:s,alt:e.name,loading:"lazy"}):o.isVisible?"Loading Image":"Image Placeholder"),t("div",{className:"product-meta"},t("span",{className:"product-code"},e.type||e.code||e.className),t("strong",{className:"product-name"},e.name)),t("p",{className:"product-copy"},i),t("div",{className:"product-footer"},t("span",{className:"product-price"},e.price||"Pending"),t("div",{style:{display:"flex",alignItems:"center",gap:"0.55rem"}},n>0?t("span",{className:"product-qty"},n):null,t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>r.addToCart(e)},"Add to Cart"))))},e.componentFns.EmptyStateCard=function({title:e,copy:n,actionLabel:r,onAction:a}){return t("article",{className:"empty-state"},t("span",{className:"empty-state-kicker"},"No Results"),t("strong",{className:"card-label"},e),t("p",{className:"empty-state-copy"},n),r&&"function"==typeof a?t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:a},r):null)},e.componentFns.CategoryGrid=function(e){return c("is-categories",e)},e.componentFns.SubcategoryGrid=function(e){return c("is-subcategories",e)},e.componentFns.ProductGrid=function(e){return c("is-products",e)},e.componentFns.CatalogPager=function({currentPage:e,totalPages:a,startIndex:s,endIndex:c,totalItems:l}){return n("storefront-cards",i),t("div",{[o]:"",className:"catalog-pager-shell"},t("div",{className:"catalog-pager"},t("div",{className:"catalog-pager-meta"},t("span",{className:"card-kicker"},"Catalog Page"),t("span",{className:"catalog-pager-summary"},l>0?`Showing ${s}-${c} of ${l} items`:"No items available")),t("div",{className:"catalog-pager-actions"},t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e<=1,onClick:()=>r.goToPreviousCatalogPage()},"Previous"),t("span",{className:"catalog-pager-page"},`Page ${e} / ${a}`),t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e>=a,onClick:()=>r.goToNextCatalogPage(a)},"Next"))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.store,a=e.getters,o=e.actions,{storeConfig:s}=e.data,i="data-ui-store-cart",c=`[${i}]`,l=`\n${c} {\n position: absolute;\n inset: 0;\n z-index: 4;\n pointer-events: none;\n}\n\n${c}.is-open {\n pointer-events: auto;\n}\n\n${c} .store-cart {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n bottom: 0.5rem;\n width: min(24rem, calc(100% - 1rem));\n transform: translateX(calc(100% + 1rem));\n transition: transform 180ms ease;\n}\n\n${c}.is-open .store-cart {\n transform: translateX(0);\n}\n\n${c} .cart-card {\n height: 100%;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding: 1rem;\n border-radius: 1.5rem;\n border: 1px solid var(--store-border);\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n box-shadow:\n 0 18px 40px rgb(11 27 46 / 0.16),\n 0 4px 12px rgb(11 27 46 / 0.08);\n}\n\n${c} .cart-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${c} .cart-close {\n min-width: 2.1rem;\n height: 2.1rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border-radius: 0.6rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-accent);\n font-size: 0.92rem;\n font-weight: 800;\n line-height: 1;\n box-shadow: 0 6px 16px rgb(18 54 93 / 0.08);\n}\n\n${c} .cart-close:hover {\n background: var(--store-accent-soft);\n border-color: rgb(18 54 93 / 0.24);\n color: var(--store-accent);\n}\n\n${c} .cart-close:focus-visible {\n outline: 2px solid rgb(18 54 93 / 0.25);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n border-radius: 0.95rem;\n background: rgb(255 255 255 / 0.58);\n border: 1px solid var(--store-border);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n padding: 0.95rem;\n}\n\n${c} .cart-kpi {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0.75rem;\n}\n\n${c} .kpi-label {\n display: block;\n margin-bottom: 0.3rem;\n font-size: 0.68rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${c} .kpi-value {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .cart-lines {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-lines::-webkit-scrollbar {\n width: 12px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.55);\n border-radius: 999px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-line {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n${c} .cart-line-copy {\n min-width: 0;\n display: grid;\n gap: 0.18rem;\n}\n\n${c} .cart-line-top,\n${c} .cart-line-controls,\n${c} .summary-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .cart-line-title {\n font-size: 0.92rem;\n font-weight: 700;\n line-height: 1.32;\n overflow-wrap: anywhere;\n word-break: break-word;\n}\n\n${c} .qty-controls {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n}\n\n${c} .qty-badge {\n min-width: 1.9rem;\n text-align: center;\n font-weight: 700;\n}\n\n${c} .qty-btn,\n${c} .remove-btn {\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.65rem;\n}\n\n${c} .cart-summary {\n padding-top: 0.25rem;\n border-top: 1px solid var(--store-accent-line);\n display: grid;\n gap: 0.7rem;\n}\n\n${c} .payment-source-field {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .payment-source-select {\n width: 100%;\n min-height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-text-main);\n}\n\n${c} .payment-source-meta,\n${c} .payment-source-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .payment-source-meta {\n padding: 0.85rem 0.9rem;\n border-radius: 0.95rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.44);\n}\n\n${c} .payment-source-detail {\n margin: 0.2rem 0 0;\n font-size: 0.82rem;\n line-height: 1.4;\n color: var(--store-text-muted);\n}\n\n${c} .payment-source-label {\n font-weight: 700;\n color: var(--store-text-main);\n}\n\n${c} .payment-source-balance {\n font-weight: 700;\n color: var(--store-success);\n}\n\n${c} .payment-source-state {\n font-size: 0.7rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n}\n\n${c} .summary-row.total {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .summary-label,\n${c} .cart-line-meta {\n color: var(--store-text-muted);\n}\n\n${c} .summary-value {\n font-weight: 700;\n}\n\n${c} .summary-actions {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .cart-empty {\n padding: 1rem;\n border-radius: 0.95rem;\n border: 1px dashed var(--store-border);\n color: var(--store-text-muted);\n background: rgb(255 255 255 / 0.38);\n}\n\n@media (max-width: 1120px) {\n ${c} .store-cart {\n top: 0;\n right: 0;\n bottom: 0;\n width: min(24rem, 100%);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Cart=function(){const e=a.getStoreState(r),c=a.summarizeCart(e.cartItems),d=a.getPaymentSources(s),m=a.getPaymentSourceById(s,e.selectedPaymentSource)||d[0]||null,u=d.filter(e=>!1!==e.enabled).length,g=m?m.label:"Unavailable",p=m?Number(m.balance||0):0,b=Math.max(0,p-c.total);return n("storefront-cart",l),t("div",{className:e.cartOpen?"is-open":"",[i]:"","aria-hidden":e.cartOpen?"false":"true"},t("aside",{className:"store-cart"},t("section",{className:"cart-card"},t("div",{className:"cart-header"},t("div",null,t("span",{className:"eyebrow"},"Cart"),t("h2",{className:"section-title"},"Acquisition Queue")),t("button",{type:"button",className:"cart-close","aria-label":"Close cart",title:"Close cart",onClick:()=>o.closeCart()},"X")),t("div",{className:"cart-kpi"},t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Items"),t("span",{className:"kpi-value"},c.lineCount)),t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Payment"),t("span",{className:"kpi-value"},g))),t("div",{className:"cart-status"},t("span",{className:"eyebrow"},"Payment Source"),t("div",{className:"payment-source-field"},t("select",{className:"payment-source-select",value:e.selectedPaymentSource,onChange:e=>o.selectPaymentSource(e.target.value)},d.map(e=>t("option",{value:e.id,disabled:!1===e.enabled},!1===e.enabled?`${e.label} (Locked)`:e.label))),m?t("div",{className:"payment-source-meta"},t("div",null,t("div",{className:"payment-source-row"},t("span",{className:"payment-source-label"},m.label),t("span",{className:"payment-source-balance"},a.formatCurrency(m.balance))),t("p",{className:"payment-source-detail"},m.detail)),t("span",{className:"payment-source-state"},u>0?!1===m.enabled?"Locked":"Available":"Unavailable")):null)),t("div",{className:"cart-lines","data-preserve-scroll-id":"cart-lines"},c.lineCount>0?e.cartItems.map(e=>t("div",{className:"cart-line"},t("div",{className:"cart-line-top"},t("div",{className:"cart-line-copy"},t("div",{className:"cart-line-title"},e.name)),t("strong",null,a.formatCurrency(a.parsePrice(e.price)*e.quantity))),t("div",{className:"cart-line-controls"},t("div",{className:"qty-controls"},t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.decrementCartItem(e.code)},"-"),t("span",{className:"qty-badge"},e.quantity),t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.incrementCartItem(e.code)},"+")),t("button",{type:"button",className:"store-btn store-btn-secondary remove-btn",onClick:()=>o.removeCartItem(e.code)},"Remove")))):t("div",{className:"cart-empty"},"No items are queued yet. Add products from the catalog to build a checkout payload.")),t("div",{className:"cart-summary"},t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Items"),t("span",{className:"summary-value"},c.itemCount)),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Subtotal"),t("span",{className:"summary-value"},a.formatCurrency(c.subtotal))),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Remaining Source"),t("span",{className:"summary-value"},a.formatCurrency(b))),t("div",{className:"summary-row total"},t("span",{className:"summary-label"},"Total"),t("span",{className:"summary-value"},a.formatCurrency(c.total)))),t("div",{className:"summary-actions"},t("button",{type:"button",className:"store-btn store-btn-primary",disabled:0===c.lineCount||e.isCheckingOut,onClick:()=>o.requestCheckout()},e.isCheckingOut?"Submitting Request...":"Submit Checkout")))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.getters,a=e.store,o=e.actions,s="data-ui-store-navbar",i=`[${s}]`,c=`\n${i} {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 0.9rem 1rem;\n margin-bottom: 0.95rem;\n border-bottom: 1px solid var(--store-accent-line);\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%),\n linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%);\n}\n\n${i} .store-breadcrumbs {\n display: flex;\n align-items: center;\n gap: 0.55rem;\n min-width: 0;\n flex-wrap: wrap;\n}\n\n${i} .breadcrumb-link,\n${i} .breadcrumb-current,\n${i} .breadcrumb-separator {\n font-size: 0.78rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n font-weight: 700;\n}\n\n${i} .breadcrumb-link {\n padding: 0;\n border: 0;\n background: transparent;\n color: var(--store-text-subtle);\n}\n\n${i} .breadcrumb-link:hover {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-current {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-separator {\n color: rgb(124 138 155 / 0.72);\n}\n\n${i} .store-cart-btn {\n position: relative;\n width: 2.6rem;\n height: 2.6rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n border-radius: 0.7rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.68);\n color: var(--store-accent);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);\n}\n\n${i} .store-cart-btn:hover {\n background: rgb(219 231 243 / 0.88);\n}\n\n${i} .cart-toggle-icon {\n position: relative;\n width: 0.95rem;\n height: 0.8rem;\n border: 1.5px solid currentColor;\n border-radius: 0.16rem 0.16rem 0.24rem 0.24rem;\n}\n\n${i} .cart-toggle-icon::before {\n content: "";\n position: absolute;\n top: -0.34rem;\n left: 0.2rem;\n width: 0.5rem;\n height: 0.3rem;\n border: 1.5px solid currentColor;\n border-bottom: 0;\n border-radius: 0.35rem 0.35rem 0 0;\n}\n\n${i} .cart-count {\n position: absolute;\n top: -0.35rem;\n right: -0.35rem;\n min-width: 1.25rem;\n height: 1.25rem;\n padding: 0 0.3rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 999px;\n background: var(--store-accent);\n color: #fff;\n font-size: 0.68rem;\n font-weight: 700;\n}\n\n@media (max-width: 1120px) {\n ${i} {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function(){const e=r.getStoreState(a),i=r.getStoreBreadcrumbs(e),l=r.summarizeCart(e.cartItems);return n("storefront-navbar",c),t("nav",{[s]:""},t("div",{className:"store-breadcrumbs","aria-label":"Store navigation"},i.map((e,n)=>n===i.length-1?t("span",{className:"breadcrumb-current"},e.label):[t("button",{type:"button",className:"breadcrumb-link",onClick:()=>o.navigateToBreadcrumb(e.id)},e.label),t("span",{className:"breadcrumb-separator"},"/")])),t("button",{type:"button",className:"store-cart-btn",onClick:()=>o.toggleCart(),title:e.cartOpen?"Close cart":"Open cart","aria-label":e.cartOpen?"Close cart":"Open cart"},t("span",{className:"cart-toggle-icon","aria-hidden":"true"}),l.itemCount>0?t("span",{className:"cart-count"},l.itemCount):null))}}(),function(){const e=window.ForgeWebUI,t=window.StorefrontApp;e.createApp({name:"store",root:"#app",setup({root:n}){e.mount(n,()=>t.components.App(),{preserveScroll:!1}),t.bridge&&t.bridge.notifyReady()}}).start()}();
\ No newline at end of file
+!function(){const e=window.ForgeWebUI;(window.StorefrontApp=window.StorefrontApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.runtime,[n,r]=t.createSignal(0),a=Object.create(null),o=Object.create(null),s=[],i=Object.create(null),c=Object.create(null),l=new WeakSet;let d=0,m=null,u=null,g=0;function p(e){let t=String(e||"").trim();if(!t)return"";for(;t.startsWith("\\")||t.startsWith("/");)t=t.slice(1);return/\.[A-Za-z0-9]+$/.test(t)||(t+=".paa"),t}function b(e){const t=String(e||"").trim().toLowerCase();return t.startsWith("data:image/")||t.startsWith("blob:")||t.startsWith("http://")||t.startsWith("https://")}function h(e,t){a[e]=t,function(){if(g)return;g=window.setTimeout(()=>{g=0,r(e=>e+1)},48)}()}function y(){if("undefined"!=typeof A3API&&"function"==typeof A3API.RequestTexture)for(;d<6&&s.length>0;){const e=s.shift();delete i[e],e&&void 0===a[e]&&!o[e]&&(d+=1,o[e]=Promise.resolve(A3API.RequestTexture(e,512)).then(t=>{const n=String(t||"").trim();b(n)?h(e,n):(console.warn("[Store UI] Ignoring unsupported texture response.",e,n),h(e,""))}).catch(t=>{console.warn("[Store UI] Failed to resolve texture.",e,t),h(e,"")}).finally(()=>{d=Math.max(0,d-1),delete o[e],y()}))}}function f(e){!e||i[e]||o[e]||(i[e]=!0,s.push(e),y())}function v(e){const t=p(e);t&&!c[t]&&(c[t]=!0,b(a[t])||o[t]||f(t))}function S(){const e=document.querySelectorAll("[data-store-texture-path]");if(0===e.length)return;const t=function(){const e=document.querySelector(".catalog-grid");return"function"!=typeof IntersectionObserver?null:(m&&u===e||(m&&m.disconnect(),u=e,m=new IntersectionObserver(e=>{e.forEach(e=>{e.isIntersecting&&(v(e.target.getAttribute("data-store-texture-path")),m.unobserve(e.target))})},{root:e,rootMargin:"240px 0px",threshold:.01})),m)}();e.forEach(e=>{if(l.has(e))return;l.add(e);const n=e.getAttribute("data-store-texture-path");t?t.observe(e):v(n)})}e.media={getTextureState:function(e){n();const t=p(e);return{path:t,isVisible:Boolean(t&&c[t]),isLoaded:Boolean(t&&a[t]&&b(a[t]))}},getTextureSource:function(e){n();const t=p(e);return t?b(e)?(a[t]=String(e).trim(),a[t]):void 0!==a[t]?a[t]:c[t]?(f(t),""):"":""},scheduleTextureObservation:function(){window.requestAnimationFrame(()=>{S()})}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t={actorName:"",actorUid:"",approval:"Field Access",orgId:"",orgName:"",orgLeader:!1,defaultOrgCeo:!1,canUseOrgFunds:!1},n={budget:5e4,creditLine:0,availability:"In-Stock",moduleState:"Preview",searchTags:["Attachment","Grenade","Medical","Consumable","Static","Scope","Item","Misc"],paymentSources:[{id:"cash",label:"Cash",balance:0,enabled:!1,detail:"Use on-hand cash carried by the player."},{id:"bank",label:"Bank",balance:0,enabled:!1,detail:"Charge the player bank account."},{id:"org_funds",label:"Org Funds",balance:0,enabled:!1,detail:"Only organization leaders or the default-org CEO can use treasury funds."},{id:"credit_line",label:"Credit Line",balance:0,enabled:!1,detail:"No approved credit line is assigned to this member."}],defaultPaymentSource:"cash"};function r(e,t){var n;Object.keys(e).forEach(t=>delete e[t]),Object.assign(e,(n=t,JSON.parse(JSON.stringify(n))))}e.data={catalog:{categoryCards:[{id:"uniforms",label:"Uniforms"},{id:"headgear",label:"Headgear"},{id:"facewear",label:"Facewear"},{id:"vests",label:"Vests"},{id:"backpacks",label:"Backpacks"},{id:"attachments",label:"Attachments"},{id:"weapons",label:"Weapons"},{id:"ammo",label:"Ammo"},{id:"misc",label:"Misc"},{id:"vehicles",label:"Vehicles"},{id:"units",label:"Units"}],vehicleCards:[{id:"cars",label:"Cars"},{id:"armor",label:"Armor"},{id:"helis",label:"Helicopters"},{id:"planes",label:"Planes"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],weaponCards:[{id:"primary",label:"Primary"},{id:"secondary",label:"Secondary"},{id:"handgun",label:"Handgun"}],previewItems:{uniforms:[],headgear:[],facewear:[],vests:[],backpacks:[],attachments:[],ammo:[],misc:[],primary:[],secondary:[],handgun:[],cars:[],armor:[],helis:[],planes:[],naval:[],other:[],units:[]}},session:Object.assign({},t),storeConfig:Object.assign({},n),applyHydratePayload(e){r(this.session,Object.assign({},t,e?.session||{})),r(this.storeConfig,Object.assign({},n,e?.storeConfig||{}))}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{createSignal:t}=e.runtime,n=window.SharedLogic=window.SharedLogic||{};n.createStorefrontStore=function({createSignal:e}){function t(e){return{className:String(e?.className||e?.code||""),code:String(e?.code||e?.className||""),name:String(e?.name||e?.displayName||""),description:String(e?.description||""),price:String(e?.price||""),image:String(e?.image||""),type:String(e?.type||""),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(0,Number(e?.quantity||0))}}function n(e){return{code:String(e?.code||""),name:String(e?.name||""),price:String(e?.price||"$0"),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(1,Number(e?.quantity||1))}}return new class{constructor(){[this.getView,this.setView]=e("categories"),[this.getSelectedCategory,this.setSelectedCategory]=e(""),[this.getSelectedWeaponSlot,this.setSelectedWeaponSlot]=e(""),[this.getSelectedVehicleSlot,this.setSelectedVehicleSlot]=e(""),[this.getCartOpen,this.setCartOpen]=e(!1),[this.getSearchQuery,this.setSearchQuery]=e(""),[this.getCartItems,this.setCartItems]=e([]),[this.getCatalogItemsByKey,this.setCatalogItemsByKey]=e({}),[this.getIsCatalogLoading,this.setIsCatalogLoading]=e(!1),[this.getCatalogRequestKey,this.setCatalogRequestKey]=e(""),[this.getCatalogPage,this.setCatalogPage]=e(1),[this.getNotice,this.setNotice]=e({type:"",text:""}),[this.getIsCheckingOut,this.setIsCheckingOut]=e(!1),[this.getSelectedPaymentSource,this.setSelectedPaymentSource]=e("")}resetToCategories(){this.setView("categories"),this.setSelectedCategory(""),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openWeaponsRoot(){this.setView("weapons"),this.setSelectedCategory("weapons"),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openVehiclesRoot(){this.setView("vehicles"),this.setSelectedCategory("vehicles"),this.setSelectedVehicleSlot(""),this.setSelectedWeaponSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}resetCatalogPage(){this.setCatalogPage(1)}setCatalogPageNumber(e){const t=Math.max(1,Number(e||1));this.setCatalogPage(t)}selectCategory(e){this.setSelectedCategory(e),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setCatalogPage(1),"weapons"!==e?"vehicles"!==e?this.setView("items"):this.openVehiclesRoot():this.openWeaponsRoot()}selectSubcategory(e,t){"vehicle"===t?(this.setSelectedVehicleSlot(e),this.setSelectedWeaponSlot("")):(this.setSelectedWeaponSlot(e),this.setSelectedVehicleSlot("")),this.setCatalogPage(1),this.setView("items")}startCategoryRequest(e){const t=String(e||"").trim().toLowerCase();return!!t&&(this.setCatalogRequestKey(t),this.setIsCatalogLoading(!0),!0)}finishCategoryRequest(e){const t=String(e||"").trim().toLowerCase(),n=String(this.getCatalogRequestKey()||"").trim().toLowerCase();t&&n&&n!==t||(this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1))}hydrateCategoryItems(e){const n=String(e?.category||"").trim().toLowerCase(),r=Array.isArray(e?.items)?e.items:[];if(!n)return this.setCatalogRequestKey(""),void this.setIsCatalogLoading(!1);this.setCatalogItemsByKey(e=>Object.assign({},e,{[n]:r.map(t)})),this.finishCategoryRequest(n)}ensureSelectedPaymentSource(e){const t=Array.isArray(e?.paymentSources)?e.paymentSources:[],n=String(this.getSelectedPaymentSource()||"").trim(),r=t.map(e=>String(e?.id||"").trim());n&&r.includes(n)&&t.some(e=>String(e?.id||"").trim()===n&&!1!==e?.enabled)||this.setSelectedPaymentSource("")}navigateToBreadcrumb(e){switch(e){case"categories":return this.resetToCategories(),!0;case"weapons":return this.openWeaponsRoot(),!0;case"vehicles":return this.openVehiclesRoot(),!0;default:return!1}}hydrateFromPayload(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.setCatalogItemsByKey({}),this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1),this.setCatalogPage(1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}hydrateStoreConfig(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}}},e.store=n.createStorefrontStore({createSignal:t})}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{};function t(e){return e.selectedWeaponSlot||e.selectedVehicleSlot||e.selectedCategory}function n(e,t){if(!e)return!0;const n=String(e).trim().toLowerCase();return!n||t.some(e=>String(e||"").toLowerCase().includes(n))}function r(e){const t=Number(String(e||"0").replace(/[^0-9.-]+/g,""));return Number.isFinite(t)?t:0}function a(e){const t=String(e||"").trim().toLowerCase();return["items","misc"].includes(t)?"Misc":String(e||"").replace(/[-_]+/g," ").split(/\s+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join(" ")}function o(e,r){const a=t(e),o=String(a||"").trim().toLowerCase(),s=e.catalogItemsByKey||{};return(Array.isArray(s[o])?s[o]:[]).filter(t=>n(e.searchQuery,[t.className,t.code,t.name,t.description,t.price,t.type]))}function s(e,t){const n=o(e).length,r=Math.max(1,Math.ceil(n/6)),a=Math.min(r,Math.max(1,Number(e.catalogPage||1)));return{pageSize:6,totalItems:n,totalPages:r,currentPage:a,startIndex:0===n?0:6*(a-1)+1,endIndex:Math.min(6*a,n)}}function i(e){return(Array.isArray(e?.paymentSources)?e.paymentSources:[]).map(e=>({id:String(e?.id||"").trim(),label:String(e?.label||e?.id||"").trim(),balance:Number(e?.balance||0),enabled:!1!==e?.enabled,detail:String(e?.detail||"").trim()}))}e.getters={formatTitle:a,formatCurrency:function(e){return`$${Number(e||0).toLocaleString()}`},parsePrice:r,getSelectionKey:t,getStoreState:function(e){return{view:e.getView(),selectedCategory:e.getSelectedCategory(),selectedWeaponSlot:e.getSelectedWeaponSlot(),selectedVehicleSlot:e.getSelectedVehicleSlot(),selectedPaymentSource:e.getSelectedPaymentSource(),cartOpen:e.getCartOpen(),searchQuery:e.getSearchQuery(),cartItems:e.getCartItems(),catalogItemsByKey:e.getCatalogItemsByKey(),isCatalogLoading:e.getIsCatalogLoading(),catalogRequestKey:e.getCatalogRequestKey(),catalogPage:e.getCatalogPage(),isCheckingOut:e.getIsCheckingOut()}},getStoreHeader:function(e){if("weapons"===e.view)return{eyebrow:"Weapons Division",title:"Weapon Categories",copy:"Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.",badge:"3 Slots"};if("vehicles"===e.view)return{eyebrow:"Vehicle Motorpool",title:"Vehicle Categories",copy:"Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.",badge:"6 Classes"};if("items"===e.view){const n=t(e)||"catalog",r=e.searchQuery?` Filtered by "${e.searchQuery}".`:"",o=e.isCatalogLoading?" Pulling live inventory from the game engine.":"";return{eyebrow:"Catalog Preview",title:a(n),copy:`Live category inventory generated from the game engine for the selected department.${r}${o}`,badge:"Preview Items"}}return{eyebrow:"Supply Categories",title:"Procurement Dashboard",copy:"Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display live product inventory inside the runtime store architecture.",badge:"11 Categories"}},getStoreBreadcrumbs:function(e){const t=[{id:"categories",label:"Supply Exchange"}];if("weapons"===e.view)return t.push({id:"weapons",label:"Weapons"}),t;if("vehicles"===e.view)return t.push({id:"vehicles",label:"Vehicles"}),t;if("items"===e.view){if(e.selectedWeaponSlot)return t.push({id:"weapons",label:"Weapons"}),t.push({id:"weapon-slot",label:a(e.selectedWeaponSlot)}),t;if(e.selectedVehicleSlot)return t.push({id:"vehicles",label:"Vehicles"}),t.push({id:"vehicle-slot",label:a(e.selectedVehicleSlot)}),t;e.selectedCategory&&t.push({id:"category",label:a(e.selectedCategory)})}return t},getVisibleCategoryCards:function(e,t){return t.categoryCards.filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleSubcategoryCards:function(e,t){return("vehicles"===e.view?t.vehicleCards:t.weaponCards).filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleItems:o,getVisibleItemsPage:function(e,t){const n=o(e),r=s(e),a=(r.currentPage-1)*r.pageSize;return n.slice(a,a+r.pageSize)},getCatalogPagination:s,summarizeCart:function(e){const t=e.reduce((e,t)=>e+Number(t.quantity||0),0),n=e.reduce((e,t)=>e+r(t.price)*Number(t.quantity||0),0);return{lineCount:e.length,itemCount:t,subtotal:n,total:n}},getPaymentSources:i,getPaymentSourceById:function(e,t){const n=String(t||"").trim();return i(e).find(e=>e.id===n)}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=window.ForgeWebUI.createBridge({closeEvent:"store::close",globalName:"StoreUIBridge",readyEvent:"store::ready"});n.on("store::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateFromPayload(n)}),n.on("store::config::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateStoreConfig(n)}),n.on("store::checkout::success",n=>{t.setIsCheckingOut(!1),t.setCartItems([]),t.setCartOpen(!1),e.actions&&e.actions.showNotice("success",n.message||"Checkout completed.")}),n.on("store::category::hydrate",e=>{t.hydrateCategoryItems(e)}),n.on("store::category::failure",n=>{t.finishCategoryRequest(n.category||""),e.actions&&e.actions.showNotice("error",n.message||"Category request failed.")}),n.on("store::checkout::failure",n=>{t.setIsCheckingOut(!1),e.actions&&e.actions.showNotice("error",n.message||"Checkout failed.")}),e.bridge={close:n.close,requestClose:function(){return n.close({})},requestCheckout:function(e){return n.send("store::checkout::request",e)},requestCategory:function(e){return n.send("store::category::request",e)},notifyReady:function(){return n.ready({loaded:!0})},receive:n.receive,sendEvent:n.send}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=e.getters,{storeConfig:r,session:a}=e.data;let o=null;function s(e,n){t.setNotice({type:e,text:n}),o&&clearTimeout(o),o=setTimeout(()=>{t.setNotice({type:"",text:""}),o=null},3200)}function i(e,t,n){const r={items:[],vehicles:[],units:[],totalPrice:n,paymentMethod:t};return e.forEach(e=>{const t=function(e){return{classname:String(e?.code||"").trim(),category:String(e?.category||"").trim().toLowerCase(),entryKind:String(e?.entryKind||"item").trim().toLowerCase(),quantity:Math.max(1,Number(e?.quantity||1))}}(e);if("vehicle"!==t.entryKind)if("unit"!==t.entryKind)r.items.push({classname:t.classname,category:t.category,quantity:t.quantity});else for(let e=0;e!e)},closeCart:function(){t.setCartOpen(!1)},closeStore:function(){const t=e.bridge;if(t&&"function"==typeof t.requestClose){if(t.requestClose())return!0}return s("error","Store bridge is unavailable."),!1},navigateToBreadcrumb:function(e){return t.navigateToBreadcrumb(e)},selectCategory:function(e){t.selectCategory(e),c(),["weapons","vehicles"].includes(String(e||""))||d(e)},selectSubcategory:function(e,n){t.selectSubcategory(e,n),c(),d(e)},goToCatalogPage:l,goToNextCatalogPage:function(e){const n=Number(t.getCatalogPage()||1);return!(n>=Math.max(1,Number(e||1)))&&(l(n+1),!0)},goToPreviousCatalogPage:function(){const e=Number(t.getCatalogPage()||1);return!(e<=1)&&(l(e-1),!0)},addToCart:function(e){t.setCartItems(t=>{const n=t.findIndex(t=>t.code===e.code);if(-1===n)return[...t,{code:e.code,name:e.name,price:e.price,category:e.category,entryKind:e.entryKind,quantity:1}];const r=[...t];return r[n]=Object.assign({},r[n],{category:e.category,entryKind:e.entryKind,quantity:r[n].quantity+1}),r}),s("success",`${e.name} added to the acquisition queue.`)},incrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:t.quantity+1}):t))},decrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:Math.max(0,t.quantity-1)}):t).filter(e=>e.quantity>0))},removeCartItem:function(e){t.setCartItems(t=>t.filter(t=>t.code!==e))},selectPaymentSource:function(e){const a=String(e||"").trim(),o=n.getPaymentSources(r).find(e=>e.id===a);return o?!1===o.enabled?(s("error",o.detail||"Selected payment source is not available."),!1):(t.setSelectedPaymentSource(a),!0):(s("error","Selected payment source is unavailable."),!1)},requestCheckout:function(){const a=t.getCartItems();if(0===a.length)return s("error","Add at least one item before checkout."),!1;const o=n.summarizeCart(a),c=n.getPaymentSourceById(r,t.getSelectedPaymentSource());if(!c)return s("error","Select a payment source before checkout."),!1;if(!1===c.enabled)return s("error",c.detail||"Selected payment source is unavailable."),!1;if(o.total>Number(c.balance||0))return s("error",`${c.label} cannot cover this checkout total.`),!1;const l=e.bridge;if(!l||"function"!=typeof l.requestCheckout)return s("error","Checkout bridge is unavailable."),!1;t.setIsCheckingOut(!0);const d=i(a,c.id,o.total);return!!l.requestCheckout({checkoutJson:JSON.stringify(d)})||(t.setIsCheckingOut(!1),s("error","Checkout bridge is unavailable."),!1)},formatTitle:n.formatTitle,formatCurrency:n.formatCurrency}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=window.SharedUI.componentFns.WindowTitleBar,a=e.store,o=e.getters,s=e.actions,{catalog:i,session:c,storeConfig:l}=e.data,d="data-ui-store-app-shell",m=`[${d}]`,u=`\n${m} {\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: var(--store-shell-bg);\n}\n\n${m} .footer-title,\n${m} .eyebrow {\n font-size: 0.68rem;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .module-header,\n${m} .store-panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${m} .store-app {\n flex: 1;\n min-height: 0;\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: 308px minmax(0, 1fr);\n gap: 1.25rem;\n padding: 1.25rem;\n}\n\n${m} .store-sidebar,\n${m} .store-main {\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${m} .store-main {\n position: relative;\n overflow: hidden;\n}\n\n${m} .module-card,\n${m} .store-panel {\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n border: 1px solid var(--store-border);\n border-radius: 1.35rem;\n}\n\n${m} .module-card {\n padding: 1rem;\n}\n\n${m} .store-panel {\n min-height: 0;\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n width: min(100%, 1280px);\n overflow: hidden;\n}\n\n${m} .module-header {\n margin-bottom: 0.85rem;\n}\n\n${m} .store-panel-header {\n padding: 1rem 1rem 0;\n}\n\n${m} .section-title {\n margin: 0;\n font-size: 1.1rem;\n font-weight: 700;\n letter-spacing: -0.02em;\n color: var(--store-text-main);\n}\n\n${m} .section-copy,\n${m} .footer-copy {\n margin: 0.2rem 0 0;\n font-size: 0.9rem;\n line-height: 1.45;\n color: var(--store-text-muted);\n}\n\n${m} .pill {\n padding: 0.48rem 0.8rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.74rem;\n font-weight: 700;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n}\n\n${m} .search-module {\n display: flex;\n flex-direction: column;\n gap: 0.8rem;\n}\n\n${m} .search-form {\n display: grid;\n gap: 0.7rem;\n}\n\n${m} .search-input {\n width: 100%;\n height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.75);\n color: var(--store-text-main);\n}\n\n${m} .quick-tags {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n\n${m} .quick-tag {\n padding: 0.55rem 0.72rem;\n border-radius: 999px;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.52);\n color: var(--store-text-muted);\n font-size: 0.75rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${m} .filter-stack {\n display: grid;\n gap: 0.85rem;\n}\n\n${m} .filter-group {\n padding: 0.95rem;\n border-radius: 0.8rem;\n background: rgb(255 255 255 / 0.48);\n border: 1px solid var(--store-border);\n}\n\n${m} .filter-label {\n display: block;\n margin-bottom: 0.55rem;\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .filter-value {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n color: var(--store-text-main);\n font-size: 0.92rem;\n font-weight: 600;\n}\n\n${m} .filter-placeholder {\n color: var(--store-text-muted);\n font-weight: 500;\n}\n\n${m} .store-panel-intro {\n padding: 0 1rem 1rem;\n border-bottom: 1px solid var(--store-accent-line);\n}\n\n${m} .store-footer-bar {\n width: 100%;\n border-top: 1px solid rgb(18 54 93 / 0.1);\n background: transparent;\n}\n\n${m} .store-footer {\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n padding: 0.95rem 1.25rem 1.15rem;\n}\n\n${m} .footer-block {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n}\n\n${m} .store-toast-stack {\n position: fixed;\n top: 1.2rem;\n right: 1.5rem;\n z-index: 10;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${m} .store-toast {\n max-width: 24rem;\n padding: 0.85rem 1rem;\n border-radius: 0.9rem;\n border: 1px solid var(--store-border);\n background: #fff;\n box-shadow: 0 14px 28px rgb(16 34 56 / 0.14);\n font-size: 0.92rem;\n}\n\n${m} .store-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n}\n\n${m} .store-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 1440px) {\n ${m} .store-app {\n grid-template-columns: 284px minmax(0, 1fr);\n }\n}\n\n@media (max-width: 1120px) {\n ${m} .store-app {\n grid-template-columns: 1fr;\n overflow: auto;\n }\n\n ${m} .store-sidebar,\n ${m} .store-main {\n min-height: auto;\n }\n\n ${m} .store-main {\n overflow: visible;\n }\n\n ${m} .store-footer {\n grid-template-columns: 1fr;\n }\n\n ${m} .store-toast-stack {\n right: 1rem;\n left: 1rem;\n }\n}\n`;e.components=e.components||{},e.componentFns=e.componentFns||{},e.components.App=function(){const m=e.componentFns.Navbar,g=e.componentFns.Cart,p=o.getStoreState(a),b=o.getStoreHeader(p),h=a.getNotice(),y=p.searchQuery,f=o.getPaymentSources(l).filter(e=>!1!==e.enabled).length,v="items"===p.view?s.formatTitle(o.getSelectionKey(p)||"Catalog"):s.formatTitle(p.view),S=o.getPaymentSourceById(l,p.selectedPaymentSource)||null;return n("storefront-app-shell",u),t("div",{[d]:""},r({kicker:"FORGE Logistics",title:"Supply Exchange",onClose:()=>s.closeStore(),closeLabel:"Close store interface"}),h.text?t("div",{className:"store-toast-stack"},t("div",{className:"error"===h.type?"store-toast is-error":"store-toast is-success"},h.text)):null,t("div",{className:"store-app"},t("aside",{className:"store-sidebar"},t("section",{className:"module-card search-module"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Search"),t("h2",{className:"section-title"},"Inventory Search")),t("span",{className:"pill"},"Live")),t("div",{className:"search-form"},t("input",{id:"store-search-input",type:"text",className:"search-input",placeholder:"Search inventory, classes, or suppliers",value:y}),t("div",{style:{display:"flex",gap:"0.65rem"}},t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("store-search-input")?.value||"")},"Apply Search"),t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:()=>s.clearSearch()},"Clear"))),t("div",{className:"quick-tags"},(l.searchTags||[]).map(e=>t("span",{className:"quick-tag"},e)))),t("section",{className:"module-card"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Filter"),t("h2",{className:"section-title"},"Procurement Filters")),t("span",{className:"pill"},l.moduleState)),t("div",{className:"filter-stack"},t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Department"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},v))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Availability"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},l.availability))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Payment"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},S?S.label:"Select Payment")))))),t("main",{className:"store-main"},t("section",{className:"store-panel"},m(),t("div",{className:"store-panel-header"},t("div",null,t("span",{className:"eyebrow"},b.eyebrow),t("h1",{className:"section-title"},b.title)),t("span",{className:"pill"},b.badge)),t("div",{className:"store-panel-intro"},t("p",{className:"section-copy"},b.copy)),function(t){const{CategoryCard:n,SubcategoryCard:r,ProductCard:a,EmptyStateCard:c,CategoryGrid:l,SubcategoryGrid:d,ProductGrid:m,CatalogPager:u}=e.componentFns;if("weapons"===t.view||"vehicles"===t.view){const e="vehicles"===t.view?"vehicle":"weapon",n=o.getVisibleSubcategoryCards(t,i);return d(n.length>0?n.map(t=>r(t,e)):c({title:"No matching slots",copy:"Try a different search query or clear the current filter.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}if("items"===t.view){const e=o.getVisibleItems(t,i),n=o.getVisibleItemsPage(t,i),r=o.getCatalogPagination(t,i),l=t.cartItems.reduce((e,t)=>(e[t.code]=t.quantity,e),{}),d=String(o.getSelectionKey(t)||"").toLowerCase();return[m(t.isCatalogLoading&&t.catalogRequestKey===d&&0===e.length?c({title:"Loading inventory",copy:"Pulling live category items from the game engine."}):e.length>0?n.map(e=>a(e,l[e.code]||0)):c({title:"No category items",copy:t.searchQuery?"Your search filter excluded the live inventory returned for this category.":"The game engine did not return any items for this category yet.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()})),e.length>0?u(r):null]}const g=o.getVisibleCategoryCards(t,i);return l(g.length>0?g.map(e=>n(e)):c({title:"No matching departments",copy:"Your search filter excluded every top-level department.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}(p)),g())),t("footer",{className:"store-footer-bar"},t("div",{className:"store-footer"},t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Procurement Desk"),t("span",{className:"footer-copy"},"Authorized supply browsing for personnel loadout preparation and mission staging.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Catalog Scope"),t("span",{className:"footer-copy"},"Uniforms, protective gear, weapon slots, vehicles, units, ammunition groups, and general support inventory.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Purchase Access"),t("span",{className:"footer-copy"},`${c.approval} approval. ${f} payment source(s) currently available${c.orgName?` for ${c.orgName}.`:"."}`)))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.actions,a=e.media,o="data-ui-store-cards",s=`[${o}]`,i=`\n${s}.catalog-grid-shell {\n flex: 1;\n min-height: 0;\n display: flex;\n}\n\n${s}.catalog-pager-shell {\n display: block;\n}\n\n${s} .catalog-grid {\n flex: 1;\n min-height: 0;\n width: 100%;\n padding: 1rem;\n display: grid;\n gap: 1rem;\n align-content: start;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid::-webkit-scrollbar {\n width: 12px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.45);\n border-radius: 999px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid.is-categories,\n${s} .catalog-grid.is-products {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n}\n\n${s} .catalog-grid.is-subcategories {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n${s} .card-button,\n${s} .product-card,\n${s} .empty-state {\n border: 1px solid var(--store-border);\n border-radius: 1.15rem;\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%),\n var(--store-surface-strong);\n color: var(--store-accent);\n box-shadow:\n inset 0 1px 0 rgb(255 255 255 / 0.8),\n 0 10px 24px rgb(16 34 56 / 0.06);\n}\n\n${s} .card-button {\n min-height: 12.5rem;\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 0.75rem;\n padding: 1.35rem;\n text-align: left;\n transition:\n transform 120ms ease,\n box-shadow 120ms ease,\n border-color 120ms ease;\n}\n\n${s} .card-button:hover,\n${s} .product-card:hover {\n transform: translateY(-2px);\n border-color: rgb(18 54 93 / 0.32);\n box-shadow:\n 0 16px 28px rgb(16 34 56 / 0.11),\n inset 0 1px 0 rgb(255 255 255 / 0.88);\n}\n\n${s} .card-kicker,\n${s} .product-code,\n${s} .empty-state-kicker {\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${s} .card-label {\n font-size: 1.08rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n${s} .card-copy,\n${s} .product-copy,\n${s} .empty-state-copy {\n margin: 0;\n color: var(--store-text-muted);\n line-height: 1.45;\n}\n\n${s} .product-copy {\n white-space: pre-line;\n}\n\n${s} .product-card {\n min-height: 15.5rem;\n padding: 0.8rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .product-image {\n height: 5.9rem;\n border-radius: 0.95rem;\n border: 1px dashed rgb(18 54 93 / 0.24);\n background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%);\n display: flex;\n align-items: center;\n justify-content: center;\n color: var(--store-text-subtle);\n font-size: 0.78rem;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n overflow: hidden;\n}\n\n${s} .product-image-asset {\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n${s} .product-meta {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .product-name {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-text-main);\n line-height: 1.3;\n}\n\n${s} .product-footer {\n margin-top: auto;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${s} .product-price {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-success);\n}\n\n${s} .product-qty {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.85rem;\n height: 1.85rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.76rem;\n font-weight: 700;\n}\n\n${s} .empty-state {\n padding: 1.35rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .catalog-pager {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.9rem;\n padding: 0.55rem 0.9rem 0.75rem;\n border-top: 1px solid var(--store-accent-line);\n}\n\n${s} .catalog-pager-meta {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n}\n\n${s} .catalog-pager-summary {\n font-size: 0.86rem;\n color: var(--store-text-muted);\n}\n\n${s} .catalog-pager-actions {\n display: inline-flex;\n align-items: center;\n gap: 0.6rem;\n}\n\n${s} .catalog-pager-page {\n min-width: 5.75rem;\n text-align: center;\n font-size: 0.82rem;\n font-weight: 700;\n color: var(--store-accent);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${s} .product-copy {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n}\n\n@media (max-width: 1440px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 1120px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-subcategories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: 1fr;\n }\n}\n`;function c(e,r){return n("storefront-cards",i),"is-products"===e&&a&&"function"==typeof a.scheduleTextureObservation&&a.scheduleTextureObservation(),t("div",{[o]:"",className:"catalog-grid-shell"},t("div",{className:`catalog-grid ${e}`,"data-preserve-scroll-id":"catalog-grid"},r))}e.componentFns=e.componentFns||{},e.componentFns.CategoryCard=function(e){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectCategory(e.id)},t("span",{className:"card-kicker"},"Department"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open this department and move into staged inventory browsing."))},e.componentFns.SubcategoryCard=function(e,n){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectSubcategory(e.id,n)},t("span",{className:"card-kicker"},"vehicle"===n?"Vehicle Class":"Weapon Slot"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open the next tier and review product previews for this selection."))},e.componentFns.ProductCard=function(e,n){const o=a&&"function"==typeof a.getTextureState?a.getTextureState(e.image):{isVisible:!0},s=a&&"function"==typeof a.getTextureSource?a.getTextureSource(e.image):"",i=function(e,t){const n=String(e||"").trim();if(!n)return t;const r=n.replace(/<\s*br\s*\/?\s*>/gi,"\n").replace(/<\/\s*p\s*>/gi,"\n").replace(/<\s*li\s*>/gi,"- ").replace(/<\/\s*li\s*>/gi,"\n"),a=document.createElement("div");return a.innerHTML=r,String(a.textContent||a.innerText||"").replace(/\u00a0/g," ").replace(/[ \t]+\n/g,"\n").replace(/\n{3,}/g,"\n\n").trim()||t}(e.description,e.className||e.code);return t("article",{className:"product-card"},t("div",{className:"product-image","data-store-texture-path":e.image||""},s?t("img",{className:"product-image-asset",src:s,alt:e.name,loading:"lazy"}):o.isVisible?"Loading Image":"Image Placeholder"),t("div",{className:"product-meta"},t("span",{className:"product-code"},e.type||e.code||e.className),t("strong",{className:"product-name"},e.name)),t("p",{className:"product-copy"},i),t("div",{className:"product-footer"},t("span",{className:"product-price"},e.price||"Pending"),t("div",{style:{display:"flex",alignItems:"center",gap:"0.55rem"}},n>0?t("span",{className:"product-qty"},n):null,t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>r.addToCart(e)},"Add to Cart"))))},e.componentFns.EmptyStateCard=function({title:e,copy:n,actionLabel:r,onAction:a}){return t("article",{className:"empty-state"},t("span",{className:"empty-state-kicker"},"No Results"),t("strong",{className:"card-label"},e),t("p",{className:"empty-state-copy"},n),r&&"function"==typeof a?t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:a},r):null)},e.componentFns.CategoryGrid=function(e){return c("is-categories",e)},e.componentFns.SubcategoryGrid=function(e){return c("is-subcategories",e)},e.componentFns.ProductGrid=function(e){return c("is-products",e)},e.componentFns.CatalogPager=function({currentPage:e,totalPages:a,startIndex:s,endIndex:c,totalItems:l}){return n("storefront-cards",i),t("div",{[o]:"",className:"catalog-pager-shell"},t("div",{className:"catalog-pager"},t("div",{className:"catalog-pager-meta"},t("span",{className:"card-kicker"},"Catalog Page"),t("span",{className:"catalog-pager-summary"},l>0?`Showing ${s}-${c} of ${l} items`:"No items available")),t("div",{className:"catalog-pager-actions"},t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e<=1,onClick:()=>r.goToPreviousCatalogPage()},"Previous"),t("span",{className:"catalog-pager-page"},`Page ${e} / ${a}`),t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e>=a,onClick:()=>r.goToNextCatalogPage(a)},"Next"))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.store,a=e.getters,o=e.actions,{storeConfig:s}=e.data,i="data-ui-store-cart",c=`[${i}]`,l=`\n${c} {\n position: absolute;\n inset: 0;\n z-index: 4;\n pointer-events: none;\n}\n\n${c}.is-open {\n pointer-events: auto;\n}\n\n${c} .store-cart {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n bottom: 0.5rem;\n width: min(24rem, calc(100% - 1rem));\n transform: translateX(calc(100% + 1rem));\n transition: transform 180ms ease;\n}\n\n${c}.is-open .store-cart {\n transform: translateX(0);\n}\n\n${c} .cart-card {\n height: 100%;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding: 1rem;\n border-radius: 1.5rem;\n border: 1px solid var(--store-border);\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n box-shadow:\n 0 18px 40px rgb(11 27 46 / 0.16),\n 0 4px 12px rgb(11 27 46 / 0.08);\n}\n\n${c} .cart-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${c} .cart-close {\n min-width: 2.1rem;\n height: 2.1rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border-radius: 0.6rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-accent);\n font-size: 0.92rem;\n font-weight: 800;\n line-height: 1;\n box-shadow: 0 6px 16px rgb(18 54 93 / 0.08);\n}\n\n${c} .cart-close:hover {\n background: var(--store-accent-soft);\n border-color: rgb(18 54 93 / 0.24);\n color: var(--store-accent);\n}\n\n${c} .cart-close:focus-visible {\n outline: 2px solid rgb(18 54 93 / 0.25);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n border-radius: 0.95rem;\n background: rgb(255 255 255 / 0.58);\n border: 1px solid var(--store-border);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n padding: 0.95rem;\n}\n\n${c} .cart-kpi {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0.75rem;\n}\n\n${c} .kpi-label {\n display: block;\n margin-bottom: 0.3rem;\n font-size: 0.68rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${c} .kpi-value {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .cart-lines {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-lines::-webkit-scrollbar {\n width: 12px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.55);\n border-radius: 999px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-line {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n${c} .cart-line-copy {\n min-width: 0;\n display: grid;\n gap: 0.18rem;\n}\n\n${c} .cart-line-top,\n${c} .cart-line-controls,\n${c} .summary-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .cart-line-title {\n font-size: 0.92rem;\n font-weight: 700;\n line-height: 1.32;\n overflow-wrap: anywhere;\n word-break: break-word;\n}\n\n${c} .qty-controls {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n}\n\n${c} .qty-badge {\n min-width: 1.9rem;\n text-align: center;\n font-weight: 700;\n}\n\n${c} .qty-btn,\n${c} .remove-btn {\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.65rem;\n}\n\n${c} .cart-summary {\n padding-top: 0.25rem;\n border-top: 1px solid var(--store-accent-line);\n display: grid;\n gap: 0.7rem;\n}\n\n${c} .payment-source-field {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .payment-source-select {\n width: 100%;\n min-height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-text-main);\n}\n\n${c} .payment-source-meta,\n${c} .payment-source-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .payment-source-meta {\n padding: 0.85rem 0.9rem;\n border-radius: 0.95rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.44);\n}\n\n${c} .payment-source-detail {\n margin: 0.2rem 0 0;\n font-size: 0.82rem;\n line-height: 1.4;\n color: var(--store-text-muted);\n}\n\n${c} .payment-source-label {\n font-weight: 700;\n color: var(--store-text-main);\n}\n\n${c} .payment-source-balance {\n font-weight: 700;\n color: var(--store-success);\n}\n\n${c} .payment-source-state {\n font-size: 0.7rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n}\n\n${c} .summary-row.total {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .summary-label,\n${c} .cart-line-meta {\n color: var(--store-text-muted);\n}\n\n${c} .summary-value {\n font-weight: 700;\n}\n\n${c} .summary-actions {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .cart-empty {\n padding: 1rem;\n border-radius: 0.95rem;\n border: 1px dashed var(--store-border);\n color: var(--store-text-muted);\n background: rgb(255 255 255 / 0.38);\n}\n\n@media (max-width: 1120px) {\n ${c} .store-cart {\n top: 0;\n right: 0;\n bottom: 0;\n width: min(24rem, 100%);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Cart=function(){const e=a.getStoreState(r),c=a.summarizeCart(e.cartItems),d=a.getPaymentSources(s),m=a.getPaymentSourceById(s,e.selectedPaymentSource)||null,u=d.filter(e=>!1!==e.enabled).length,g=m?m.label:"Select Payment",p=m?Number(m.balance||0):0,b=Math.max(0,p-c.total);return n("storefront-cart",l),t("div",{className:e.cartOpen?"is-open":"",[i]:"","aria-hidden":e.cartOpen?"false":"true"},t("aside",{className:"store-cart"},t("section",{className:"cart-card"},t("div",{className:"cart-header"},t("div",null,t("span",{className:"eyebrow"},"Cart"),t("h2",{className:"section-title"},"Acquisition Queue")),t("button",{type:"button",className:"cart-close","aria-label":"Close cart",title:"Close cart",onClick:()=>o.closeCart()},"X")),t("div",{className:"cart-kpi"},t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Items"),t("span",{className:"kpi-value"},c.lineCount)),t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Payment"),t("span",{className:"kpi-value"},g))),t("div",{className:"cart-status"},t("span",{className:"eyebrow"},"Payment Source"),t("div",{className:"payment-source-field"},t("select",{className:"payment-source-select",value:e.selectedPaymentSource||"",onChange:e=>o.selectPaymentSource(e.target.value)},t("option",{value:"",disabled:!0},"Select Payment"),d.map(e=>t("option",{value:e.id,disabled:!1===e.enabled},!1===e.enabled?`${e.label} (Locked)`:e.label))),m?t("div",{className:"payment-source-meta"},t("div",null,t("div",{className:"payment-source-row"},t("span",{className:"payment-source-label"},m.label),t("span",{className:"payment-source-balance"},a.formatCurrency(m.balance))),t("p",{className:"payment-source-detail"},m.detail)),t("span",{className:"payment-source-state"},u>0?!1===m.enabled?"Locked":"Available":"Unavailable")):t("div",{className:"payment-source-meta"},t("span",{className:"payment-source-label"},"Select Payment"),t("span",{className:"payment-source-state"},u>0?"Required":"Unavailable")))),t("div",{className:"cart-lines","data-preserve-scroll-id":"cart-lines"},c.lineCount>0?e.cartItems.map(e=>t("div",{className:"cart-line"},t("div",{className:"cart-line-top"},t("div",{className:"cart-line-copy"},t("div",{className:"cart-line-title"},e.name)),t("strong",null,a.formatCurrency(a.parsePrice(e.price)*e.quantity))),t("div",{className:"cart-line-controls"},t("div",{className:"qty-controls"},t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.decrementCartItem(e.code)},"-"),t("span",{className:"qty-badge"},e.quantity),t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.incrementCartItem(e.code)},"+")),t("button",{type:"button",className:"store-btn store-btn-secondary remove-btn",onClick:()=>o.removeCartItem(e.code)},"Remove")))):t("div",{className:"cart-empty"},"No items are queued yet. Add products from the catalog to build a checkout payload.")),t("div",{className:"cart-summary"},t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Items"),t("span",{className:"summary-value"},c.itemCount)),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Subtotal"),t("span",{className:"summary-value"},a.formatCurrency(c.subtotal))),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Remaining Source"),t("span",{className:"summary-value"},a.formatCurrency(b))),t("div",{className:"summary-row total"},t("span",{className:"summary-label"},"Total"),t("span",{className:"summary-value"},a.formatCurrency(c.total)))),t("div",{className:"summary-actions"},t("button",{type:"button",className:"store-btn store-btn-primary",disabled:0===c.lineCount||!m||e.isCheckingOut,onClick:()=>o.requestCheckout()},e.isCheckingOut?"Submitting Request...":"Submit Checkout")))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.getters,a=e.store,o=e.actions,s="data-ui-store-navbar",i=`[${s}]`,c=`\n${i} {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 0.9rem 1rem;\n margin-bottom: 0.95rem;\n border-bottom: 1px solid var(--store-accent-line);\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%),\n linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%);\n}\n\n${i} .store-breadcrumbs {\n display: flex;\n align-items: center;\n gap: 0.55rem;\n min-width: 0;\n flex-wrap: wrap;\n}\n\n${i} .breadcrumb-link,\n${i} .breadcrumb-current,\n${i} .breadcrumb-separator {\n font-size: 0.78rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n font-weight: 700;\n}\n\n${i} .breadcrumb-link {\n padding: 0;\n border: 0;\n background: transparent;\n color: var(--store-text-subtle);\n}\n\n${i} .breadcrumb-link:hover {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-current {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-separator {\n color: rgb(124 138 155 / 0.72);\n}\n\n${i} .store-cart-btn {\n position: relative;\n width: 2.6rem;\n height: 2.6rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n border-radius: 0.7rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.68);\n color: var(--store-accent);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);\n}\n\n${i} .store-cart-btn:hover {\n background: rgb(219 231 243 / 0.88);\n}\n\n${i} .cart-toggle-icon {\n position: relative;\n width: 0.95rem;\n height: 0.8rem;\n border: 1.5px solid currentColor;\n border-radius: 0.16rem 0.16rem 0.24rem 0.24rem;\n}\n\n${i} .cart-toggle-icon::before {\n content: "";\n position: absolute;\n top: -0.34rem;\n left: 0.2rem;\n width: 0.5rem;\n height: 0.3rem;\n border: 1.5px solid currentColor;\n border-bottom: 0;\n border-radius: 0.35rem 0.35rem 0 0;\n}\n\n${i} .cart-count {\n position: absolute;\n top: -0.35rem;\n right: -0.35rem;\n min-width: 1.25rem;\n height: 1.25rem;\n padding: 0 0.3rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 999px;\n background: var(--store-accent);\n color: #fff;\n font-size: 0.68rem;\n font-weight: 700;\n}\n\n@media (max-width: 1120px) {\n ${i} {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function(){const e=r.getStoreState(a),i=r.getStoreBreadcrumbs(e),l=r.summarizeCart(e.cartItems);return n("storefront-navbar",c),t("nav",{[s]:""},t("div",{className:"store-breadcrumbs","aria-label":"Store navigation"},i.map((e,n)=>n===i.length-1?t("span",{className:"breadcrumb-current"},e.label):[t("button",{type:"button",className:"breadcrumb-link",onClick:()=>o.navigateToBreadcrumb(e.id)},e.label),t("span",{className:"breadcrumb-separator"},"/")])),t("button",{type:"button",className:"store-cart-btn",onClick:()=>o.toggleCart(),title:e.cartOpen?"Close cart":"Open cart","aria-label":e.cartOpen?"Close cart":"Open cart"},t("span",{className:"cart-toggle-icon","aria-hidden":"true"}),l.itemCount>0?t("span",{className:"cart-count"},l.itemCount):null))}}(),function(){const e=window.ForgeWebUI,t=window.StorefrontApp;e.createApp({name:"store",root:"#app",setup({root:n}){e.mount(n,()=>t.components.App(),{preserveScroll:!1}),t.bridge&&t.bridge.notifyReady()}}).start()}();
\ No newline at end of file
diff --git a/arma/client/addons/store/ui/src/components/AppShell.js b/arma/client/addons/store/ui/src/components/AppShell.js
index 810a7e5..5e993cc 100644
--- a/arma/client/addons/store/ui/src/components/AppShell.js
+++ b/arma/client/addons/store/ui/src/components/AppShell.js
@@ -571,7 +571,7 @@ ${scopeSelector} .store-toast.is-error {
{ className: "filter-placeholder" },
selectedPaymentSource
? selectedPaymentSource.label
- : "Cash",
+ : "Select Payment",
),
),
),
@@ -645,7 +645,7 @@ ${scopeSelector} .store-toast.is-error {
h(
"span",
{ className: "footer-copy" },
- "Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
+ "Uniforms, protective gear, weapon slots, vehicles, units, ammunition groups, and general support inventory.",
),
),
h(
diff --git a/arma/client/addons/store/ui/src/components/cart.js b/arma/client/addons/store/ui/src/components/cart.js
index 4249d59..04cef7a 100644
--- a/arma/client/addons/store/ui/src/components/cart.js
+++ b/arma/client/addons/store/ui/src/components/cart.js
@@ -300,15 +300,13 @@ ${scopeSelector} .cart-empty {
getters.getPaymentSourceById(
storeConfig,
state.selectedPaymentSource,
- ) ||
- paymentSources[0] ||
- null;
+ ) || null;
const availablePaymentSourceCount = paymentSources.filter(
(source) => source.enabled !== false,
).length;
const selectedPaymentLabel = selectedPaymentSource
? selectedPaymentSource.label
- : "Unavailable";
+ : "Select Payment";
const selectedPaymentBalance = selectedPaymentSource
? Number(selectedPaymentSource.balance || 0)
: 0;
@@ -392,12 +390,20 @@ ${scopeSelector} .cart-empty {
"select",
{
className: "payment-source-select",
- value: state.selectedPaymentSource,
+ value: state.selectedPaymentSource || "",
onChange: (event) =>
actions.selectPaymentSource(
event.target.value,
),
},
+ h(
+ "option",
+ {
+ value: "",
+ disabled: true,
+ },
+ "Select Payment",
+ ),
paymentSources.map((source) =>
h(
"option",
@@ -467,7 +473,28 @@ ${scopeSelector} .cart-empty {
: "Unavailable",
),
)
- : null,
+ : h(
+ "div",
+ {
+ className: "payment-source-meta",
+ },
+ h(
+ "span",
+ {
+ className: "payment-source-label",
+ },
+ "Select Payment",
+ ),
+ h(
+ "span",
+ {
+ className: "payment-source-state",
+ },
+ availablePaymentSourceCount > 0
+ ? "Required"
+ : "Unavailable",
+ ),
+ ),
),
),
h(
@@ -630,6 +657,7 @@ ${scopeSelector} .cart-empty {
className: "store-btn store-btn-primary",
disabled:
summary.lineCount === 0 ||
+ !selectedPaymentSource ||
state.isCheckingOut,
onClick: () => actions.requestCheckout(),
},
diff --git a/arma/client/addons/store/ui/src/data.js b/arma/client/addons/store/ui/src/data.js
index 5132da7..797c23e 100644
--- a/arma/client/addons/store/ui/src/data.js
+++ b/arma/client/addons/store/ui/src/data.js
@@ -81,6 +81,7 @@
{ id: "ammo", label: "Ammo" },
{ id: "misc", label: "Misc" },
{ id: "vehicles", label: "Vehicles" },
+ { id: "units", label: "Units" },
],
vehicleCards: [
{ id: "cars", label: "Cars" },
@@ -113,6 +114,7 @@
planes: [],
naval: [],
other: [],
+ units: [],
},
};
diff --git a/arma/client/addons/store/ui/src/pages/StoreView.js b/arma/client/addons/store/ui/src/pages/StoreView.js
index c1ec182..81c20c8 100644
--- a/arma/client/addons/store/ui/src/pages/StoreView.js
+++ b/arma/client/addons/store/ui/src/pages/StoreView.js
@@ -112,8 +112,8 @@
return {
eyebrow: "Supply Categories",
title: "Procurement Dashboard",
- copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.",
- badge: "8 Categories",
+ copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display live product inventory inside the runtime store architecture.",
+ badge: "11 Categories",
};
}
diff --git a/arma/client/addons/store/ui/src/registry/events.js b/arma/client/addons/store/ui/src/registry/events.js
index 14cabc9..816ca97 100644
--- a/arma/client/addons/store/ui/src/registry/events.js
+++ b/arma/client/addons/store/ui/src/registry/events.js
@@ -36,6 +36,7 @@
const payload = {
items: [],
vehicles: [],
+ units: [],
totalPrice,
paymentMethod,
};
@@ -57,6 +58,20 @@
return;
}
+ if (normalizedItem.entryKind === "unit") {
+ for (
+ let index = 0;
+ index < normalizedItem.quantity;
+ index += 1
+ ) {
+ payload.units.push({
+ classname: normalizedItem.classname,
+ category: "units",
+ });
+ }
+ return;
+ }
+
payload.items.push({
classname: normalizedItem.classname,
category: normalizedItem.category,
diff --git a/arma/client/addons/store/ui/src/registry/store.js b/arma/client/addons/store/ui/src/registry/store.js
index e8b608e..ed19794 100644
--- a/arma/client/addons/store/ui/src/registry/store.js
+++ b/arma/client/addons/store/ui/src/registry/store.js
@@ -58,7 +58,7 @@
[this.getIsCheckingOut, this.setIsCheckingOut] =
createSignal(false);
[this.getSelectedPaymentSource, this.setSelectedPaymentSource] =
- createSignal("cash");
+ createSignal("");
}
resetToCategories() {
@@ -191,23 +191,9 @@
const currentSource = String(
this.getSelectedPaymentSource() || "",
).trim();
- const defaultSource = String(
- storeConfig?.defaultPaymentSource || "",
- ).trim();
const sourceIds = paymentSources.map((source) =>
String(source?.id || "").trim(),
);
- const enabledSource = paymentSources.find(
- (source) => source && source.enabled !== false,
- );
- const defaultAvailable =
- defaultSource && sourceIds.includes(defaultSource)
- ? paymentSources.find(
- (source) =>
- String(source?.id || "").trim() ===
- defaultSource,
- )
- : null;
if (
currentSource &&
@@ -221,19 +207,7 @@
return;
}
- if (defaultAvailable && defaultAvailable.enabled !== false) {
- this.setSelectedPaymentSource(defaultSource);
- return;
- }
-
- if (enabledSource) {
- this.setSelectedPaymentSource(
- String(enabledSource.id || "cash"),
- );
- return;
- }
-
- this.setSelectedPaymentSource(defaultSource || "cash");
+ this.setSelectedPaymentSource("");
}
navigateToBreadcrumb(target) {
diff --git a/arma/server/addons/cad/XEH_preInit.sqf b/arma/server/addons/cad/XEH_preInit.sqf
index fb63b0d..774b91a 100644
--- a/arma/server/addons/cad/XEH_preInit.sqf
+++ b/arma/server/addons/cad/XEH_preInit.sqf
@@ -91,12 +91,12 @@ call FUNC(registerEventListeners);
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
};
- if (isNil QEFUNC(task,requestMissionTask)) exitWith {
- _result set ["message", "Framework generated mission requests are unavailable."];
+ if (isNil QEGVAR(task,MissionGeneratorProviderRegistry)) exitWith {
+ _result set ["message", "Generated mission provider registry is unavailable."];
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
};
- _result = [_taskType, _metadata, _uid] call EFUNC(task,requestMissionTask);
+ _result = EGVAR(task,MissionGeneratorProviderRegistry) call ["requestMissionTask", [_taskType, _metadata, _uid]];
if !(_result getOrDefault ["success", false]) exitWith {
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
diff --git a/arma/server/addons/cad/functions/fnc_initCadStore.sqf b/arma/server/addons/cad/functions/fnc_initCadStore.sqf
index 95151d0..b47c2bc 100644
--- a/arma/server/addons/cad/functions/fnc_initCadStore.sqf
+++ b/arma/server/addons/cad/functions/fnc_initCadStore.sqf
@@ -300,31 +300,10 @@ GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [
private _permissionService = _self get "PermissionService";
private _groupRepository = _self get "GroupRepository";
private _generatedTaskTypes = [];
- if (missionNamespace getVariable [QEGVAR(task,enableGenerator), false]) then {
- _generatedTaskTypes = [
- createHashMapFromArray [["value", "attack"], ["label", "Attack"]],
- createHashMapFromArray [["value", "defend"], ["label", "Defend"]],
- createHashMapFromArray [["value", "defuse"], ["label", "Defuse"]],
- createHashMapFromArray [["value", "delivery"], ["label", "Delivery"]],
- createHashMapFromArray [["value", "destroy"], ["label", "Destroy"]],
- createHashMapFromArray [["value", "hostage"], ["label", "Hostage"]],
- createHashMapFromArray [["value", "hvtkill"], ["label", "Kill HVT"]],
- createHashMapFromArray [["value", "hvtcapture"], ["label", "Capture HVT"]]
- ];
- ["INFO", "CAD hydrate using framework generator fallback type list while checking task mission manager."] call EFUNC(common,log);
-
- if (isNil QEGVAR(task,MissionManager) && { !(isNil QEFUNC(task,missionManager)) }) then {
- call EFUNC(task,missionManager);
- };
-
- if !(isNil QEGVAR(task,MissionManager)) then {
- _generatedTaskTypes = EGVAR(task,MissionManager) call ["getGeneratedTaskTypes", []];
- ["INFO", format ["CAD hydrate using task mission manager generated types: %1", _generatedTaskTypes apply { _x getOrDefault ["value", ""] }]] call EFUNC(common,log);
- } else {
- ["INFO", "CAD hydrate task mission manager is not ready; sending fallback generated task types."] call EFUNC(common,log);
- };
+ if !(isNil QEGVAR(task,MissionGeneratorProviderRegistry)) then {
+ _generatedTaskTypes = EGVAR(task,MissionGeneratorProviderRegistry) call ["getGeneratedTaskTypes", []];
} else {
- ["INFO", "CAD hydrate generated task types disabled by forge_server_task_enableGenerator."] call EFUNC(common,log);
+ ["INFO", "CAD hydrate generated task types unavailable because the task provider registry is not ready."] call EFUNC(common,log);
};
private _groupID = _groupRepository call ["getPlayerGroupId", [_uid]];
diff --git a/arma/server/addons/main/XEH_preInit.sqf b/arma/server/addons/main/XEH_preInit.sqf
index 59e4eb1..929f1ff 100644
--- a/arma/server/addons/main/XEH_preInit.sqf
+++ b/arma/server/addons/main/XEH_preInit.sqf
@@ -6,8 +6,6 @@ PREP_RECOMPILE_END;
GVAR(PlayerBootstrapRegistry) = createHashMap;
-if (isServer) then { "forge_server" callExtension ["surreal:reconnect", []]; };
-
["forge_icom_event", {
params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]]];
diff --git a/arma/server/addons/store/README.md b/arma/server/addons/store/README.md
index 8518890..04b481a 100644
--- a/arma/server/addons/store/README.md
+++ b/arma/server/addons/store/README.md
@@ -19,10 +19,59 @@ extension owns authoritative checkout calculation through `store:checkout`.
- `fnc_initStore.sqf` marks editor-placed store objects with `isStore = true`.
- `fnc_initCatalogService.sqf` scans live Arma config categories, builds
catalog responses, resolves checkout entries, and calculates authoritative
- catalog prices.
+ catalog prices. It also applies the optional mission `CfgStore` filter and
+ overrides before payloads or checkout validation use catalog entries.
- `fnc_initStorefrontStore.sqf` builds hydrate payloads, validates checkout
requests, calls `store:checkout`, syncs client patches, and coordinates
- related bank/org persistence.
+ related bank/org persistence. Purchased units are fulfilled by spawning the
+ granted unit classes at discovered `unit_spawn` markers after the backend
+ charge succeeds.
+
+## Mission Catalog Filter
+Missions can include `CfgStore.hpp` from `description.ext` to control the
+generated catalog without changing the addon.
+
+```cpp
+class CfgStore {
+ mode = "allowlist"; // dynamic, allowlist, or denylist
+
+ class Categories {
+ primary[] = {"arifle_MX_F", "arifle_MXC_F"};
+ cars[] = {"B_MRAP_01_F"};
+ units[] = {"B_Soldier_F"};
+ };
+
+ class Overrides {
+ class arifle_MX_F {
+ price = 2500;
+ displayName = "MX Rifle";
+ description = "Approved PMC service rifle.";
+ };
+ };
+};
+```
+
+`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
+listed for the requested category. `denylist` removes listed classnames from the
+generated category. Overrides are applied server-side, so checkout validation
+uses the same prices and descriptions the UI displays.
+
+`units[]` follows the same `dynamic`, `allowlist`, and `denylist` behavior as
+item and vehicle categories. Unit purchases are immediate spawn grants, not
+durable virtual garage unlocks.
+
+The filter is currently global for the mission. Revisit per-store profile
+support if individual vendors need different inventories.
+
+## Unit Spawn Markers
+Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
+`unit_spawn_2`, and so on. The store resolves the closest initialized store
+object to the requesting player, scans `allMapMarkers` at fulfillment time, and
+uses the closest matching marker within 25 meters of that store.
+
+If no matching marker exists within 25 meters, the store falls back to spawning
+units around the store object. If no store object can be resolved, it falls back
+to the requesting player so checkout still completes.
## Editor Entities
`fnc_initStore` matches non-null mission namespace objects whose variable names
@@ -31,8 +80,8 @@ contain `store`, mirroring the garage entity initialization pattern.
## Checkout Flow
Store checkout can charge cash, bank balance, organization funds, or approved
credit lines depending on the hydrated session context. Checkout results can
-grant locker assets, organization assets, and fleet vehicles through the
-related domain stores.
+grant locker assets, organization assets, fleet vehicles, and immediate unit
+spawns through the related domain stores and Arma server runtime.
Checkout results emit notifications and syncs through the event bus:
- `notification.requested` - receipt and transaction alerts
diff --git a/arma/server/addons/store/functions/fnc_initCatalogService.sqf b/arma/server/addons/store/functions/fnc_initCatalogService.sqf
index a27f364..12587c4 100644
--- a/arma/server/addons/store/functions/fnc_initCatalogService.sqf
+++ b/arma/server/addons/store/functions/fnc_initCatalogService.sqf
@@ -17,6 +17,99 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_self set ["catalogCache", createHashMap];
["INFO", "Store catalog service initialized!"] call EFUNC(common,log);
}],
+ ["getMissionStoreConfig", compileFinal {
+ missionConfigFile >> "CfgStore"
+ }],
+ ["getMissionStoreMode", compileFinal {
+ private _storeConfig = _self call ["getMissionStoreConfig", []];
+ private _mode = toLowerANSI getText (_storeConfig >> "mode");
+
+ if !(_mode in ["allowlist", "denylist", "dynamic"]) then { _mode = "dynamic"; };
+
+ _mode
+ }],
+ ["getMissionStoreCategoryList", compileFinal {
+ params [["_category", "", [""]]];
+
+ private _storeConfig = _self call ["getMissionStoreConfig", []];
+ private _categoryKey = _self call ["normalizeCategoryKey", [_category]];
+ private _categoryConfig = _storeConfig >> "Categories" >> _categoryKey;
+ private _classNames = [];
+
+ if (isArray _categoryConfig) then {
+ _classNames = getArray _categoryConfig;
+ };
+
+ _classNames apply {
+ private _className = "";
+ if (_x isEqualType "") then {
+ _className = _x;
+ } else {
+ _className = str _x;
+ };
+
+ toLowerANSI _className
+ }
+ }],
+ ["applyMissionStoreOverrides", compileFinal {
+ params [["_item", createHashMap, [createHashMap]]];
+
+ if (_item isEqualTo createHashMap) exitWith { _item };
+
+ private _className = _item getOrDefault ["className", ""];
+ if (_className isEqualTo "") exitWith { _item };
+
+ private _override = (_self call ["getMissionStoreConfig", []]) >> "Overrides" >> _className;
+ if !(isClass _override) exitWith { _item };
+
+ if (isText (_override >> "displayName")) then {
+ private _displayName = getText (_override >> "displayName");
+ if (_displayName isNotEqualTo "") then { _item set ["name", _displayName]; };
+ };
+
+ if (isText (_override >> "description")) then {
+ _item set ["description", getText (_override >> "description")];
+ };
+
+ if (isText (_override >> "image")) then {
+ _item set ["image", getText (_override >> "image")];
+ };
+
+ if (isText (_override >> "type")) then {
+ private _typeLabel = getText (_override >> "type");
+ if (_typeLabel isNotEqualTo "") then { _item set ["type", _typeLabel]; };
+ };
+
+ if (isNumber (_override >> "price")) then {
+ private _priceValue = floor (getNumber (_override >> "price") max 0);
+ _item set ["priceValue", _priceValue];
+ _item set ["price", _self call ["formatCurrency", [_priceValue]]];
+ };
+
+ _item
+ }],
+ ["applyMissionStoreFilter", compileFinal {
+ params [["_category", "", [""]], ["_items", [], [[]]]];
+
+ private _mode = _self call ["getMissionStoreMode", []];
+ private _classNames = _self call ["getMissionStoreCategoryList", [_category]];
+ private _filteredItems = +_items;
+
+ switch (_mode) do {
+ case "allowlist": {
+ _filteredItems = _items select {
+ (toLowerANSI (_x getOrDefault ["className", ""])) in _classNames
+ };
+ };
+ case "denylist": {
+ _filteredItems = _items select {
+ !((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames)
+ };
+ };
+ };
+
+ _filteredItems apply { _self call ["applyMissionStoreOverrides", [_x]] }
+ }],
["formatCurrency", compileFinal {
params [["_amount", 0, [0]]];
@@ -196,6 +289,22 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_items
}],
+ ["appendCfgUnits", compileFinal {
+ params [["_items", [], [[]]], ["_typeLabel", "Unit", [""]], ["_fallbackDescription", "", [""]]];
+
+ {
+ private _cfg = _x;
+ private _className = configName _cfg;
+ if (
+ _self call ["isVisibleConfig", [_cfg]]
+ && { _className isKindOf ["CAManBase", configFile >> "CfgVehicles"] }
+ ) then {
+ _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]);
+ };
+ } forEach ("true" configClasses (configFile >> "CfgVehicles"));
+
+ _items
+ }],
["isBackpackConfig", compileFinal {
params [["_cfg", configNull, [configNull]]];
@@ -270,6 +379,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
case "helis": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]]; };
case "planes": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]]; };
case "naval": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]]; };
+ case "units": { _items = _self call ["appendCfgUnits", [_items, "Unit", "Live unit entry generated from the game inventory."]]; };
case "other": {
{
private _cfg = _x;
@@ -305,10 +415,16 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
(toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"]
}],
+ ["isUnitCategory", compileFinal {
+ params [["_category", "", [""]]];
+
+ (toLowerANSI _category) isEqualTo "units"
+ }],
["buildPayloadCategory", compileFinal {
params [["_category", "", [""]]];
switch (toLowerANSI _category) do {
+ case "units": { "units" };
case "backpacks": { "backpack" };
case "attachments": { "attachment" };
case "ammo": { "magazine" };
@@ -327,7 +443,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["isSupportedCategory", compileFinal {
params [["_category", "", [""]]];
- (_self call ["normalizeCategoryKey", [_category]]) in ["uniforms", "headgear", "vests", "backpacks", "attachments", "facewear", "ammo", "misc", "primary", "handgun", "secondary", "cars", "armor", "helis", "planes", "naval", "other"]
+ (_self call ["normalizeCategoryKey", [_category]]) in ["uniforms", "headgear", "vests", "backpacks", "attachments", "facewear", "ammo", "misc", "primary", "handgun", "secondary", "cars", "armor", "helis", "planes", "naval", "other", "units"]
}],
["buildCategoryItems", compileFinal {
params [["_category", "", [""]]];
@@ -340,13 +456,17 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _items = _self call ["scanCategoryItems", [_categoryKey]];
private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]];
- private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]);
+ private _entryKind = "item";
+ if (_self call ["isVehicleCategory", [_categoryKey]]) then { _entryKind = "vehicle"; };
+ if (_self call ["isUnitCategory", [_categoryKey]]) then { _entryKind = "unit"; };
{
_x set ["category", _payloadCategory];
_x set ["entryKind", _entryKind];
} forEach _items;
+ _items = _self call ["applyMissionStoreFilter", [_categoryKey, _items]];
+
_catalogCache set [_categoryKey, _items];
_self set ["catalogCache", _catalogCache];
@@ -376,6 +496,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _category = toLowerANSI (_entry getOrDefault ["category", ""]);
if (_entryKind isEqualTo "vehicle") exitWith { ["cars", "armor", "helis", "planes", "naval", "other"] };
+ if (_entryKind isEqualTo "unit" || { _category isEqualTo "units" }) exitWith { ["units"] };
if (_category isEqualTo "weapon") exitWith { ["primary", "handgun", "secondary"] };
if (_category isEqualTo "backpack") exitWith { ["backpacks"] };
if (_category isEqualTo "attachment") exitWith { ["attachments"] };
@@ -400,19 +521,21 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_resolved
}],
["buildCheckoutRequest", compileFinal {
- params [["_items", [], [[]]], ["_vehicles", [], [[]]]];
+ params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
private _result = createHashMapFromArray [
["success", false],
["total", 0],
["message", "Checkout total must be greater than zero."],
["items", []],
- ["vehicles", []]
+ ["vehicles", []],
+ ["units", []]
];
private _total = 0;
private _message = "";
private _resolvedItems = [];
private _resolvedVehicles = [];
+ private _resolvedUnits = [];
{
if (_message isEqualTo "") then {
@@ -463,6 +586,29 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
};
} forEach _vehicles;
+ {
+ if (_message isEqualTo "") then {
+ private _className = _x getOrDefault ["classname", ""];
+ if (_className isEqualTo "") then {
+ _message = "Checkout contains an invalid unit entry.";
+ } else {
+ private _catalogEntry = _self call ["resolveCheckoutCatalogEntry", [createHashMapFromArray [["classname", _className], ["category", "units"], ["entryKind", "unit"]]]];
+
+ if (_catalogEntry isEqualTo createHashMap) then {
+ _message = format ["Unsupported store unit: %1", _className];
+ } else {
+ private _priceValue = _catalogEntry getOrDefault ["priceValue", 0];
+ _total = _total + _priceValue;
+ _resolvedUnits pushBack (createHashMapFromArray [
+ ["classname", _className],
+ ["category", "units"],
+ ["priceValue", _priceValue]
+ ]);
+ };
+ };
+ };
+ } forEach _units;
+
if (_message isNotEqualTo "") exitWith {
_result set ["message", _message];
_result
@@ -475,12 +621,13 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_result set ["message", ""];
_result set ["items", _resolvedItems];
_result set ["vehicles", _resolvedVehicles];
+ _result set ["units", _resolvedUnits];
_result
}],
["calculateCheckoutTotal", compileFinal {
- params [["_items", [], [[]]], ["_vehicles", [], [[]]]];
+ params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
- private _checkout = _self call ["buildCheckoutRequest", [_items, _vehicles]];
+ private _checkout = _self call ["buildCheckoutRequest", [_items, _vehicles, _units]];
createHashMapFromArray [
["success", _checkout getOrDefault ["success", false]],
["total", _checkout getOrDefault ["total", 0]],
diff --git a/arma/server/addons/store/functions/fnc_initStorefrontStore.sqf b/arma/server/addons/store/functions/fnc_initStorefrontStore.sqf
index 7b0edea..8aa46bb 100644
--- a/arma/server/addons/store/functions/fnc_initStorefrontStore.sqf
+++ b/arma/server/addons/store/functions/fnc_initStorefrontStore.sqf
@@ -155,6 +155,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["chargedTotal", 0],
["lockerGranted", []],
["vehicleGranted", []],
+ ["unitGranted", []],
["bankPatch", createHashMap],
["orgPatch", createHashMap],
["orgTargetUids", []],
@@ -168,6 +169,71 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)]
}],
+ ["getUnitSpawnMarkers", compileFinal {
+ private _markers = allMapMarkers select {
+ private _markerName = toLowerANSI _x;
+ _markerName isEqualTo "unit_spawn" || { (_markerName find "unit_spawn_") == 0 }
+ };
+
+ _markers sort true;
+ _markers
+ }],
+ ["getStoreObjects", compileFinal {
+ (allVariables missionNamespace) apply { missionNamespace getVariable [_x, objNull] } select {
+ _x isEqualType objNull
+ && { !isNull _x }
+ && { _x getVariable ["isStore", false] }
+ }
+ }],
+ ["getClosestStoreObject", compileFinal {
+ params [["_origin", objNull, [objNull]]];
+
+ if (isNull _origin) exitWith { objNull };
+
+ private _stores = _self call ["getStoreObjects", []];
+ if (_stores isEqualTo []) exitWith { objNull };
+
+ private _closestStore = objNull;
+ private _closestDistance = 1e12;
+ {
+ private _distance = _origin distance2D _x;
+ if (_distance < _closestDistance) then {
+ _closestDistance = _distance;
+ _closestStore = _x;
+ };
+ } forEach _stores;
+
+ _closestStore
+ }],
+ ["getClosestUnitSpawnMarker", compileFinal {
+ params [["_origin", objNull, [objNull, []]], ["_maxDistance", -1, [0]]];
+
+ private _markers = _self call ["getUnitSpawnMarkers", []];
+ if (_markers isEqualTo []) exitWith { "" };
+
+ private _originPosition = if (_origin isEqualType objNull) then {
+ getPosATL _origin
+ } else {
+ _origin
+ };
+
+ if (_maxDistance >= 0) then {
+ _markers = _markers select { ((getMarkerPos _x) distance2D _originPosition) <= _maxDistance };
+ if (_markers isEqualTo []) exitWith { "" };
+ };
+
+ private _closestMarker = "";
+ private _closestDistance = 1e12;
+ {
+ private _distance = _originPosition distance2D (getMarkerPos _x);
+ if (_distance < _closestDistance) then {
+ _closestDistance = _distance;
+ _closestMarker = _x;
+ };
+ } forEach _markers;
+
+ _closestMarker
+ }],
["callCheckoutBackendEnvelope", compileFinal {
params [["_context", createHashMap, [createHashMap]]];
@@ -207,7 +273,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["_player", objNull, [objNull]],
["_paymentMethod", "cash", [""]],
["_items", [], [[]]],
- ["_vehicles", [], [[]]]
+ ["_vehicles", [], [[]]],
+ ["_units", [], [[]]]
];
if (_uid isEqualTo "" || { isNull _player }) exitWith { createHashMap };
@@ -225,9 +292,58 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo],
["paymentMethod", toLowerANSI _paymentMethod],
["items", _items],
- ["vehicles", _vehicles]
+ ["vehicles", _vehicles],
+ ["units", _units]
]
}],
+ ["spawnPurchasedUnits", compileFinal {
+ params [["_player", objNull, [objNull]], ["_units", [], [[]]]];
+
+ private _result = createHashMapFromArray [
+ ["spawned", []],
+ ["failed", []]
+ ];
+ if (isNull _player || { _units isEqualTo [] }) exitWith { _result };
+
+ private _group = group _player;
+ private _store = _self call ["getClosestStoreObject", [_player]];
+ private _spawnAnchor = [objNull, _store] select !(isNull _store);
+ if (isNull _spawnAnchor) then { _spawnAnchor = _player; };
+
+ private _spawnMarker = "";
+ if !(isNull _store) then {
+ _spawnMarker = _self call ["getClosestUnitSpawnMarker", [_store, 25]];
+ };
+ {
+ private _className = _x getOrDefault ["classname", ""];
+ if (_className isEqualTo "" || { !(isClass (configFile >> "CfgVehicles" >> _className)) }) then {
+ (_result get "failed") pushBack _className;
+ } else {
+ private _basePosition = getPosATL _spawnAnchor;
+ private _baseDirection = getDir _spawnAnchor;
+ if (_spawnMarker isNotEqualTo "") then {
+ _basePosition = getMarkerPos _spawnMarker;
+ _baseDirection = markerDir _spawnMarker;
+ };
+
+ private _spawnPos = _basePosition findEmptyPosition [0, 18 + (_forEachIndex min 12), _className];
+ if (_spawnPos isEqualTo []) then {
+ _spawnPos = _basePosition getPos [3 + _forEachIndex, _baseDirection + 90];
+ };
+
+ private _unit = _group createUnit [_className, _spawnPos, [], 0, "NONE"];
+ if (isNull _unit) then {
+ (_result get "failed") pushBack _className;
+ } else {
+ _unit setDir _baseDirection;
+ [_unit] joinSilent _group;
+ (_result get "spawned") pushBack _className;
+ };
+ };
+ } forEach _units;
+
+ _result
+ }],
["syncCheckoutResult", compileFinal {
params [["_player", objNull, [objNull]], ["_result", createHashMap, [createHashMap]]];
@@ -238,6 +354,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _vgPatch = _result getOrDefault ["vgaragePatch", createHashMap];
private _bankPatch = _result getOrDefault ["bankPatch", createHashMap];
private _orgPatch = _result getOrDefault ["orgPatch", createHashMap];
+ private _unitGranted = _result getOrDefault ["unitGranted", []];
private _uid = getPlayerUID _player;
if (keys _lockerPatch isNotEqualTo []) then {
@@ -320,6 +437,14 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
};
};
+ if (_unitGranted isNotEqualTo []) then {
+ private _unitSpawnResult = _self call ["spawnPurchasedUnits", [_player, _unitGranted]];
+ private _failedUnits = _unitSpawnResult getOrDefault ["failed", []];
+ if (_failedUnits isNotEqualTo []) then {
+ ["ERROR", format ["Store checkout unit spawn failed for %1: %2", _uid, _failedUnits joinString ", "]] call EFUNC(common,log);
+ };
+ };
+
true
}],
["persistCheckoutState", compileFinal {
@@ -398,19 +523,20 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _paymentMethod = toLowerANSI (_payload getOrDefault ["paymentMethod", "cash"]);
private _items = _payload getOrDefault ["items", []];
private _vehicles = _payload getOrDefault ["vehicles", []];
+ private _units = _payload getOrDefault ["units", []];
if (isNil QGVAR(StoreCatalogService)) exitWith {
_result set ["message", "Store catalog service is unavailable."];
_result
};
- private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles]];
+ private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles, _units]];
private _totalPrice = _checkoutRequest getOrDefault ["total", 0];
_result set ["paymentMethod", _paymentMethod];
_result set ["chargedTotal", _totalPrice];
- if (_items isEqualTo [] && { _vehicles isEqualTo [] }) exitWith {
+ if (_items isEqualTo [] && { _vehicles isEqualTo [] } && { _units isEqualTo [] }) exitWith {
_result set ["message", "Add at least one item before checkout."];
_result
};
@@ -425,7 +551,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_player,
_paymentMethod,
_checkoutRequest getOrDefault ["items", []],
- _checkoutRequest getOrDefault ["vehicles", []]
+ _checkoutRequest getOrDefault ["vehicles", []],
+ _checkoutRequest getOrDefault ["units", []]
]];
if (_checkoutContext isEqualTo createHashMap) exitWith {
_result set ["message", "Checkout request context was invalid."];
@@ -451,13 +578,15 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_result set ["success", true];
_result set ["message", _backendResult getOrDefault ["message", format [
- "Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s).",
+ "Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s), %4 unit grant(s).",
_self call ["formatCurrency", [_totalPrice]],
count (_backendResult getOrDefault ["lockerGranted", []]),
- count (_backendResult getOrDefault ["vehicleGranted", []])
+ count (_backendResult getOrDefault ["vehicleGranted", []]),
+ count (_backendResult getOrDefault ["unitGranted", []])
]]];
_result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]];
_result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]];
+ _result set ["unitGranted", _backendResult getOrDefault ["unitGranted", []]];
_result set ["persistenceSucceeded", _persistenceResult getOrDefault ["success", false]];
_result set ["persistenceFailures", _persistenceResult getOrDefault ["failures", []]];
_result set ["persistenceMessage", _persistenceResult getOrDefault ["message", ""]];
diff --git a/arma/server/addons/task/README.md b/arma/server/addons/task/README.md
index 93acf2f..60507ba 100644
--- a/arma/server/addons/task/README.md
+++ b/arma/server/addons/task/README.md
@@ -226,10 +226,10 @@ If you want the accepting player's org to own the task rewards, use `fnc_handler
- compiles functions
- initializes `TaskStore`
- initializes task instance and entity controller classes
+ - initializes generated mission provider objects and registers the built-in provider
+ - registers task lifecycle log and notification listeners with the event bus
- `XEH_postInit.sqf`
- - registers task lifecycle event listeners with the event bus
- - handles task reward, notification, and rating events
- - syncs org and bank state through event bus listeners
+ - registers CBA server events for provider registration and mission setup requests
- registers the ACE defuse event hook
## Events Emitted
@@ -246,7 +246,8 @@ Task module emits the following events to the event bus:
## Notes
- the dynamic mission manager in `fnc_missionManager.sqf` is initialized during task post-init; timer-based mission generation only runs when the `forge_server_task_enableGenerator` CBA setting is enabled
-- CAD can request a specific generated mission type through `fnc_requestMissionTask.sqf`
+- CAD hydrates generated mission types and requests generated missions through `MissionGeneratorProviderRegistry`
+- custom generated mission providers register through the `forge_server_task_registerMissionGeneratorProvider` CBA server event
- it starts server-owned tasks through `fnc_handler.sqf` and binds them to the `default` org
- task lifecycle for the mission manager is tracked through `TaskStore` status entries
- task backend state is intentionally transient and resets with the active server/mission lifecycle
diff --git a/arma/server/addons/task/XEH_PREP.hpp b/arma/server/addons/task/XEH_PREP.hpp
index 9af00b7..784f8b0 100644
--- a/arma/server/addons/task/XEH_PREP.hpp
+++ b/arma/server/addons/task/XEH_PREP.hpp
@@ -15,7 +15,6 @@ PREP(makeObject);
PREP(makeShooter);
PREP(makeTarget);
PREP(missionManager);
-PREP(requestMissionTask);
PREP(initTaskStore);
PREP_SUBDIR(generators,attackMissionGenerator);
@@ -56,6 +55,9 @@ PREP_SUBDIR(objects,TaskCatalogStore);
PREP_SUBDIR(objects,TaskEntityRegistry);
PREP_SUBDIR(objects,TaskParticipantTracker);
PREP_SUBDIR(objects,TaskRewardService);
+PREP_SUBDIR(objects,TaskNotificationService);
+PREP_SUBDIR(objects,MissionGeneratorProviderRegistry);
+PREP_SUBDIR(objects,BuiltinMissionGeneratorProvider);
PREP_SUBDIR(objects,EntityControllerBaseClass);
PREP_SUBDIR(objects,AttackTaskBaseClass);
PREP_SUBDIR(objects,HostageTaskBaseClass);
diff --git a/arma/server/addons/task/XEH_postInit.sqf b/arma/server/addons/task/XEH_postInit.sqf
index 50d5f8b..873e353 100644
--- a/arma/server/addons/task/XEH_postInit.sqf
+++ b/arma/server/addons/task/XEH_postInit.sqf
@@ -3,6 +3,15 @@
if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); true };
if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService); };
+[SRPC(task,registerMissionGeneratorProvider), {
+ params [
+ ["_providerId", "", [""]],
+ ["_provider", createHashMap, [createHashMap]]
+ ];
+
+ GVAR(MissionGeneratorProviderRegistry) call ["registerProvider", [_providerId, _provider]];
+}] call CFUNC(addEventHandler);
+
[SRPC(task,requestOpenMissionSetup), {
params [
["_requester", objNull, [objNull]]
@@ -100,116 +109,10 @@ if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService);
]] call EFUNC(common,log);
};
- if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService); };
["INFO", format ["Mission setup apply request accepted. Requester=%1 VarName=%2", _requester, _requesterVar]] call EFUNC(common,log);
GVAR(MissionSetupService) call ["apply", [_overrides]];
}] call CFUNC(addEventHandler);
-if (isNil QGVAR(TaskLifecycleEventLogTokens)) then {
- private _logTaskLifecycleEvent = {
- params ["_event"];
-
- if !(GETGVAR(enableEventLogs,false)) exitWith {};
-
- ["INFO", format [
- "Task lifecycle event: %1 taskID=%2 taskType=%3 status=%4 participants=%5",
- _event getOrDefault ["event", ""],
- _event getOrDefault ["taskID", ""],
- _event getOrDefault ["taskType", ""],
- _event getOrDefault ["status", ""],
- _event getOrDefault ["participants", []]
- ]] call EFUNC(common,log);
- };
-
- private _logTaskRewardEvent = {
- params ["_event"];
-
- if !(GETGVAR(enableEventLogs,false)) exitWith {};
-
- ["INFO", format [
- "Task reward event: %1 taskID=%2 success=%3 message=%4",
- _event getOrDefault ["event", ""],
- _event getOrDefault ["taskID", ""],
- !((_event getOrDefault ["event", ""]) in ["task.reward.failed", "task.rating.failed"]),
- _event getOrDefault ["message", ""]
- ]] call EFUNC(common,log);
- };
-
- GVAR(TaskLifecycleEventLogTokens) = [
- EGVAR(common,EventBus) call ["on", ["task.created", _logTaskLifecycleEvent, "task.lifecycle.log"]],
- EGVAR(common,EventBus) call ["on", ["task.started", _logTaskLifecycleEvent, "task.lifecycle.log"]],
- EGVAR(common,EventBus) call ["on", ["task.completed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
- EGVAR(common,EventBus) call ["on", ["task.failed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
- EGVAR(common,EventBus) call ["on", ["task.cleared", _logTaskLifecycleEvent, "task.lifecycle.log"]],
- EGVAR(common,EventBus) call ["on", ["task.reward.requested", _logTaskRewardEvent, "task.reward.log"]],
- EGVAR(common,EventBus) call ["on", ["task.reward.applied", _logTaskRewardEvent, "task.reward.log"]],
- EGVAR(common,EventBus) call ["on", ["task.reward.failed", _logTaskRewardEvent, "task.reward.log"]],
- EGVAR(common,EventBus) call ["on", ["task.rating.applied", _logTaskRewardEvent, "task.reward.log"]],
- EGVAR(common,EventBus) call ["on", ["task.rating.failed", _logTaskRewardEvent, "task.reward.log"]]
- ];
-};
-
-if (isNil QGVAR(TaskNotificationEventTokens)) then {
- private _sendTaskNotification = {
- params ["_event"];
-
- private _type = _event getOrDefault ["notificationType", "info"];
- private _title = _event getOrDefault ["title", "Tasks"];
- private _message = _event getOrDefault ["message", ""];
- private _participantUids = +(_event getOrDefault ["participantUids", []]);
-
- if (_message isEqualTo "" || { _participantUids isEqualTo [] }) exitWith {};
-
- {
- private _player = [_x] call EFUNC(common,getPlayer);
- if (isNull _player) then { continue; };
- [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
- } forEach _participantUids;
-
- if (GETGVAR(enableEventLogs,false)) then {
- ["INFO", format [
- "Task notification event: taskID=%1 type=%2 recipients=%3 message=%4",
- _event getOrDefault ["taskID", ""],
- _type,
- _participantUids,
- _message
- ]] call EFUNC(common,log);
- };
- };
-
- private _sendRewardNotification = {
- params ["_event"];
-
- private _type = _event getOrDefault ["notificationType", "info"];
- private _title = _event getOrDefault ["title", "Tasks"];
- private _message = _event getOrDefault ["message", ""];
- private _memberUids = +(_event getOrDefault ["memberUids", []]);
-
- if (_message isEqualTo "" || { _memberUids isEqualTo [] }) exitWith {};
-
- {
- private _player = [_x] call EFUNC(common,getPlayer);
- if (isNull _player) then { continue; };
- [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
- } forEach _memberUids;
-
- if (GETGVAR(enableEventLogs,false)) then {
- ["INFO", format [
- "Task reward notification event: taskID=%1 type=%2 recipients=%3 message=%4",
- _event getOrDefault ["taskID", ""],
- _type,
- _memberUids,
- _message
- ]] call EFUNC(common,log);
- };
- };
-
- GVAR(TaskNotificationEventTokens) = [
- EGVAR(common,EventBus) call ["on", ["task.notification.requested", _sendTaskNotification, "task.notification.send"]],
- EGVAR(common,EventBus) call ["on", ["task.reward.notification.requested", _sendRewardNotification, "task.reward.notification.send"]]
- ];
-};
-
["ace_explosives_defuse", {
private _taskID = "";
private _explosive = objNull;
diff --git a/arma/server/addons/task/XEH_preInit.sqf b/arma/server/addons/task/XEH_preInit.sqf
index bf1872f..b31ac2b 100644
--- a/arma/server/addons/task/XEH_preInit.sqf
+++ b/arma/server/addons/task/XEH_preInit.sqf
@@ -8,30 +8,37 @@ private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)];
#include "initSettings.inc.sqf"
-[] call FUNC(TaskStateGateway);
-[] call FUNC(TaskLifecycleReporter);
-[] call FUNC(TaskCatalogStore);
-[] call FUNC(TaskEntityRegistry);
-[] call FUNC(TaskParticipantTracker);
-[] call FUNC(TaskRewardService);
-[] call FUNC(TaskInstanceBaseClass);
-[] call FUNC(EntityControllerBaseClass);
-[] call FUNC(AttackTaskBaseClass);
-[] call FUNC(HostageTaskBaseClass);
-[] call FUNC(HostageEntityController);
-[] call FUNC(TargetEntityController);
-[] call FUNC(ShooterEntityController);
-[] call FUNC(HVTEntityController);
-[] call FUNC(CargoEntityController);
-[] call FUNC(ProtectedEntityController);
-[] call FUNC(IEDEntityController);
-[] call FUNC(DefenseEnemyController);
-[] call FUNC(DefuseTaskBaseClass);
-[] call FUNC(DestroyTaskBaseClass);
-[] call FUNC(DeliveryTaskBaseClass);
-[] call FUNC(HVTTaskBaseClass);
-[] call FUNC(DefendTaskBaseClass);
+call FUNC(TaskStateGateway);
+call FUNC(TaskLifecycleReporter);
+call FUNC(TaskCatalogStore);
+call FUNC(TaskEntityRegistry);
+call FUNC(TaskParticipantTracker);
+call FUNC(TaskRewardService);
+call FUNC(TaskNotificationService);
+call FUNC(MissionGeneratorProviderRegistry);
+call FUNC(BuiltinMissionGeneratorProvider);
+call FUNC(TaskInstanceBaseClass);
+call FUNC(EntityControllerBaseClass);
+call FUNC(AttackTaskBaseClass);
+call FUNC(HostageTaskBaseClass);
+call FUNC(HostageEntityController);
+call FUNC(TargetEntityController);
+call FUNC(ShooterEntityController);
+call FUNC(HVTEntityController);
+call FUNC(CargoEntityController);
+call FUNC(ProtectedEntityController);
+call FUNC(IEDEntityController);
+call FUNC(DefenseEnemyController);
+call FUNC(DefuseTaskBaseClass);
+call FUNC(DestroyTaskBaseClass);
+call FUNC(DeliveryTaskBaseClass);
+call FUNC(HVTTaskBaseClass);
+call FUNC(DefendTaskBaseClass);
call FUNC(initTaskStore);
call FUNC(initMissionSetupService);
+
+GVAR(MissionGeneratorProviderRegistry) call ["registerProvider", ["builtin", GVAR(BuiltinMissionGeneratorProvider)]];
+GVAR(TaskLifecycleReporter) call ["registerEventLogListeners", []];
+GVAR(TaskNotificationService) call ["registerEventListeners", []];
if !(isNil QGVAR(TaskStore)) then { GVAR(TaskStore) call ["resetMissionState", []]; };
diff --git a/arma/server/addons/task/functions/fnc_initMissionSetupService.sqf b/arma/server/addons/task/functions/fnc_initMissionSetupService.sqf
index 994871a..5313ad7 100644
--- a/arma/server/addons/task/functions/fnc_initMissionSetupService.sqf
+++ b/arma/server/addons/task/functions/fnc_initMissionSetupService.sqf
@@ -104,6 +104,10 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
private _penMax = [["penaltyMax", -25, _overrides] call _paramOrDefault, -25] call (_self get "numberOrDefault");
private _timeMin = [["timeLimitMin", 600, _overrides] call _paramOrDefault, 600] call (_self get "numberOrDefault");
private _timeMax = [["timeLimitMax", 900, _overrides] call _paramOrDefault, 900] call (_self get "numberOrDefault");
+ private _generatorProvider = _overrides getOrDefault ["generatorProvider", GETGVAR(generatorProvider,"builtin")];
+ if !(_generatorProvider isEqualType "") then { _generatorProvider = str _generatorProvider; };
+ _generatorProvider = toLowerANSI _generatorProvider;
+ if !(_generatorProvider in ["builtin", "custom"]) then { _generatorProvider = "builtin"; };
private _enemyFaction = _overrides getOrDefault [
"enemyFaction",
@@ -141,11 +145,13 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
["penaltyMax", _penMax],
["timeLimitMin", _timeMin],
["timeLimitMax", _timeMax],
- ["enemyFaction", _enemyFaction]
+ ["enemyFaction", _enemyFaction],
+ ["generatorProvider", _generatorProvider]
];
SETMPVAR(GVAR(missionSetup_settings),_settings);
SETMPVAR(GVAR(missionSetup_settingsApplied),true);
+ SETMPVAR(GVAR(generatorProvider),_generatorProvider);
private _side = _self call ["resolveFactionSide", [_enemyFaction, east]];
ENEMY_SIDE = _side;
@@ -153,11 +159,12 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
publicVariable "ENEMY_SIDE";
["INFO", format [
- "Framework mission setup applied. Faction=%1, Side=%2, MaxConcurrent=%3, Interval=%4",
+ "Framework mission setup applied. Faction=%1, Side=%2, MaxConcurrent=%3, Interval=%4, GeneratorProvider=%5",
_enemyFaction,
_side,
_maxConcurrent,
- _interval
+ _interval,
+ _generatorProvider
]] call EFUNC(common,log);
if !(isNil QEGVAR(common,EventBus)) then {
diff --git a/arma/server/addons/task/functions/fnc_requestMissionTask.sqf b/arma/server/addons/task/functions/fnc_requestMissionTask.sqf
deleted file mode 100644
index f6ce408..0000000
--- a/arma/server/addons/task/functions/fnc_requestMissionTask.sqf
+++ /dev/null
@@ -1,120 +0,0 @@
-#include "..\script_component.hpp"
-
-/*
- * Author: IDSolutions
- * Framework-owned on-demand dynamic mission request entry point for CAD and
- * other server-side dispatchers.
- *
- * Arguments:
- * 0: Generator type
- * 1: Request metadata (Default: createHashMap)
- * 2: Requesting player UID (Default: "")
- *
- * Return Value:
- * Request result with success, message, taskID, and taskType keys
- *
- * Public: No
- */
-
-if !(isServer) exitWith {
- createHashMapFromArray [
- ["success", false],
- ["message", "Generated task requests must run on the server."]
- ]
-};
-
-params [
- ["_requestedType", "", [""]],
- ["_metadata", createHashMap, [createHashMap]],
- ["_requesterUid", "", [""]]
-];
-
-private _result = createHashMapFromArray [
- ["success", false],
- ["message", "Generated task request failed."],
- ["taskID", ""],
- ["taskType", _requestedType]
-];
-
-if !(GVAR(enableGenerator)) exitWith {
- _result set ["message", "Generated task requests are disabled by server settings."];
- _result
-};
-
-private _typeAliases = createHashMapFromArray [
- ["attack", "attack"],
- ["defend", "defend"],
- ["defense", "defend"],
- ["delivery", "delivery"],
- ["deliver", "delivery"],
- ["destroy", "destroy"],
- ["defuse", "defuse"],
- ["hostage", "hostage"],
- ["hvt", "hvtkill"],
- ["hvtkill", "hvtkill"],
- ["killhvt", "hvtkill"],
- ["kill_hvt", "hvtkill"],
- ["hvtcapture", "hvtcapture"],
- ["capturehvt", "hvtcapture"],
- ["capture_hvt", "hvtcapture"]
-];
-
-private _generatorType = _typeAliases getOrDefault [toLowerANSI _requestedType, ""];
-if (_generatorType isEqualTo "") exitWith {
- _result set ["message", format ["Unknown generated task type: %1", _requestedType]];
- _result
-};
-_result set ["taskType", _generatorType];
-
-if (isNil QGVAR(TaskStore)) exitWith {
- _result set ["message", "Task store is not ready yet."];
- _result
-};
-
-if (isNil QGVAR(MissionManager)) then {
- call FUNC(missionManager);
-};
-
-if (isNil QGVAR(MissionManager)) exitWith {
- _result set ["message", "Mission manager is not ready yet."];
- _result
-};
-
-GVAR(MissionManager) call ["cleanupCompletedMissions", []];
-
-private _activeCount = count (GVAR(MissionManager) call ["getActiveMissionIds", []]);
-private _maxConcurrent = GVAR(MissionManager) call ["getMaxConcurrentMissions", []];
-if (_activeCount >= _maxConcurrent) exitWith {
- _result set ["message", format [
- "Mission cap reached (%1/%2 active). Close or complete a task before requesting another.",
- _activeCount,
- _maxConcurrent
- ]];
- _result
-};
-
-private _generator = GVAR(MissionManager) call ["getGeneratorByType", [_generatorType]];
-if (_generator isEqualTo createHashMap) exitWith {
- _result set ["message", format ["Generated task type is unavailable: %1", _generatorType]];
- _result
-};
-
-private _taskID = _generator call ["startMission", [GVAR(MissionManager)]];
-if (_taskID isEqualTo "") exitWith {
- _result set ["message", format ["Mission generator failed to start task type: %1", _generatorType]];
- _result
-};
-
-GVAR(MissionManager) set ["lastMissionGenerationAt", diag_tickTime];
-
-["INFO", format [
- "Dispatcher %1 requested generated %2 mission %3.",
- _requesterUid,
- _generatorType,
- _taskID
-]] call EFUNC(common,log);
-
-_result set ["success", true];
-_result set ["message", format ["Generated %1 task %2.", _generatorType, _taskID]];
-_result set ["taskID", _taskID];
-_result
diff --git a/arma/server/addons/task/functions/objects/fnc_BuiltinMissionGeneratorProvider.sqf b/arma/server/addons/task/functions/objects/fnc_BuiltinMissionGeneratorProvider.sqf
new file mode 100644
index 0000000..7a691f7
--- /dev/null
+++ b/arma/server/addons/task/functions/objects/fnc_BuiltinMissionGeneratorProvider.sqf
@@ -0,0 +1,151 @@
+#include "..\script_component.hpp"
+
+/*
+ * Author: IDSolutions
+ * Built-in generated mission provider adapter around the framework mission
+ * manager.
+ *
+ * Arguments:
+ * None
+ *
+ * Return Value:
+ * Built-in mission generator provider object
+ *
+ * Public: No
+ */
+
+if !(isServer) exitWith { createHashMap };
+
+#pragma hemtt ignore_variables ["_self"]
+GVAR(BuiltinMissionGeneratorProviderBaseClass) = compileFinal createHashMapFromArray [
+ ["#type", "BuiltinMissionGeneratorProviderBaseClass"],
+ ["emptyResult", compileFinal {
+ params [
+ ["_message", "Generated task request failed.", [""]],
+ ["_taskType", "", [""]]
+ ];
+
+ createHashMapFromArray [
+ ["success", false],
+ ["message", _message],
+ ["taskID", ""],
+ ["taskType", _taskType]
+ ]
+ }],
+ ["resolveGeneratorType", compileFinal {
+ params [["_requestedType", "", [""]]];
+
+ private _typeAliases = createHashMapFromArray [
+ ["attack", "attack"],
+ ["defend", "defend"],
+ ["defense", "defend"],
+ ["delivery", "delivery"],
+ ["deliver", "delivery"],
+ ["destroy", "destroy"],
+ ["defuse", "defuse"],
+ ["hostage", "hostage"],
+ ["hvt", "hvtkill"],
+ ["hvtkill", "hvtkill"],
+ ["killhvt", "hvtkill"],
+ ["kill_hvt", "hvtkill"],
+ ["hvtcapture", "hvtcapture"],
+ ["capturehvt", "hvtcapture"],
+ ["capture_hvt", "hvtcapture"]
+ ];
+
+ _typeAliases getOrDefault [toLowerANSI _requestedType, ""]
+ }],
+ ["ensureMissionManager", compileFinal {
+ if (isNil QGVAR(MissionManager)) then {
+ call FUNC(missionManager);
+ };
+
+ !(isNil QGVAR(MissionManager))
+ }],
+ ["getGeneratedTaskTypes", compileFinal {
+ if !(GVAR(enableGenerator)) exitWith {
+ ["INFO", "Built-in generated task types disabled by forge_server_task_enableGenerator."] call EFUNC(common,log);
+ []
+ };
+
+ if !(_self call ["ensureMissionManager", []]) exitWith {
+ ["INFO", "Built-in generated task types unavailable because mission manager is not ready."] call EFUNC(common,log);
+ []
+ };
+
+ GVAR(MissionManager) call ["getGeneratedTaskTypes", []]
+ }],
+ ["requestMissionTask", compileFinal {
+ params [
+ ["_requestedType", "", [""]],
+ ["_metadata", createHashMap, [createHashMap]],
+ ["_requesterUid", "", [""]]
+ ];
+
+ private _result = _self call ["emptyResult", ["Generated task request failed.", _requestedType]];
+
+ if !(GVAR(enableGenerator)) exitWith {
+ _result set ["message", "Built-in generated task requests are disabled by server settings."];
+ _result
+ };
+
+ private _generatorType = _self call ["resolveGeneratorType", [_requestedType]];
+ if (_generatorType isEqualTo "") exitWith {
+ _result set ["message", format ["Unknown built-in generated task type: %1", _requestedType]];
+ _result
+ };
+ _result set ["taskType", _generatorType];
+
+ if (isNil QGVAR(TaskStore)) exitWith {
+ _result set ["message", "Task store is not ready yet."];
+ _result
+ };
+
+ if !(_self call ["ensureMissionManager", []]) exitWith {
+ _result set ["message", "Mission manager is not ready yet."];
+ _result
+ };
+
+ GVAR(MissionManager) call ["cleanupCompletedMissions", []];
+
+ private _activeCount = count (GVAR(MissionManager) call ["getActiveMissionIds", []]);
+ private _maxConcurrent = GVAR(MissionManager) call ["getMaxConcurrentMissions", []];
+ if (_activeCount >= _maxConcurrent) exitWith {
+ _result set ["message", format [
+ "Mission cap reached (%1/%2 active). Close or complete a task before requesting another.",
+ _activeCount,
+ _maxConcurrent
+ ]];
+ _result
+ };
+
+ private _generator = GVAR(MissionManager) call ["getGeneratorByType", [_generatorType]];
+ if (_generator isEqualTo createHashMap) exitWith {
+ _result set ["message", format ["Built-in generated task type is unavailable: %1", _generatorType]];
+ _result
+ };
+
+ private _taskID = _generator call ["startMission", [GVAR(MissionManager)]];
+ if (_taskID isEqualTo "") exitWith {
+ _result set ["message", format ["Built-in mission generator failed to start task type: %1", _generatorType]];
+ _result
+ };
+
+ GVAR(MissionManager) set ["lastMissionGenerationAt", diag_tickTime];
+
+ ["INFO", format [
+ "Dispatcher %1 requested built-in generated %2 mission %3.",
+ _requesterUid,
+ _generatorType,
+ _taskID
+ ]] call EFUNC(common,log);
+
+ _result set ["success", true];
+ _result set ["message", format ["Generated %1 task %2.", _generatorType, _taskID]];
+ _result set ["taskID", _taskID];
+ _result
+ }]
+];
+
+GVAR(BuiltinMissionGeneratorProvider) = createHashMapObject [GVAR(BuiltinMissionGeneratorProviderBaseClass)];
+GVAR(BuiltinMissionGeneratorProvider)
diff --git a/arma/server/addons/task/functions/objects/fnc_EntityControllerBaseClass.sqf b/arma/server/addons/task/functions/objects/fnc_EntityControllerBaseClass.sqf
index 4b951f2..88cf092 100644
--- a/arma/server/addons/task/functions/objects/fnc_EntityControllerBaseClass.sqf
+++ b/arma/server/addons/task/functions/objects/fnc_EntityControllerBaseClass.sqf
@@ -105,9 +105,9 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
- private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap];
+ private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry set [_registryKey, _self];
- missionNamespace setVariable [QGVAR(ObjectControllerInstances), _registry];
+ SETMVAR(GVAR(ObjectControllerInstances),_registry);
missionNamespace setVariable [_registryKey, _self];
true
}],
@@ -115,7 +115,7 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
- private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap];
+ private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil];
true
diff --git a/arma/server/addons/task/functions/objects/fnc_MissionGeneratorProviderRegistry.sqf b/arma/server/addons/task/functions/objects/fnc_MissionGeneratorProviderRegistry.sqf
new file mode 100644
index 0000000..57212aa
--- /dev/null
+++ b/arma/server/addons/task/functions/objects/fnc_MissionGeneratorProviderRegistry.sqf
@@ -0,0 +1,139 @@
+#include "..\script_component.hpp"
+
+/*
+ * Author: IDSolutions
+ * Registry object for generated mission providers used by CAD/manual requests.
+ *
+ * Arguments:
+ * None
+ *
+ * Return Value:
+ * Mission generator provider registry object
+ *
+ * Public: No
+ */
+
+if !(isServer) exitWith { createHashMap };
+
+#pragma hemtt ignore_variables ["_self"]
+GVAR(MissionGeneratorProviderRegistryBaseClass) = compileFinal createHashMapFromArray [
+ ["#type", "MissionGeneratorProviderRegistryBaseClass"],
+ ["#create", compileFinal {
+ _self set ["providers", createHashMap];
+ }],
+ ["emptyResult", compileFinal {
+ params [
+ ["_message", "Generated task request failed.", [""]],
+ ["_taskType", "", [""]]
+ ];
+
+ createHashMapFromArray [
+ ["success", false],
+ ["message", _message],
+ ["taskID", ""],
+ ["taskType", _taskType]
+ ]
+ }],
+ ["normalizeProviderId", compileFinal {
+ params [["_providerId", "builtin", [""]]];
+
+ _providerId = toLowerANSI _providerId;
+ if (_providerId isEqualTo "") then { _providerId = "builtin"; };
+ _providerId
+ }],
+ ["registerProvider", compileFinal {
+ params [
+ ["_providerId", "", [""]],
+ ["_provider", createHashMap, [createHashMap]]
+ ];
+
+ _providerId = _self call ["normalizeProviderId", [_providerId]];
+ if (_provider isEqualTo createHashMap) exitWith {
+ ["WARNING", format ["Generated mission provider registration ignored: provider '%1' was empty.", _providerId]] call EFUNC(common,log);
+ false
+ };
+ if !("getGeneratedTaskTypes" in _provider) exitWith {
+ ["WARNING", format ["Generated mission provider registration ignored: provider '%1' has no getGeneratedTaskTypes method.", _providerId]] call EFUNC(common,log);
+ false
+ };
+ if !("requestMissionTask" in _provider) exitWith {
+ ["WARNING", format ["Generated mission provider registration ignored: provider '%1' has no requestMissionTask method.", _providerId]] call EFUNC(common,log);
+ false
+ };
+
+ (_self get "providers") set [_providerId, _provider];
+ ["INFO", format ["Generated mission provider registered. Provider=%1", _providerId]] call EFUNC(common,log);
+ true
+ }],
+ ["hasProvider", compileFinal {
+ params [["_providerId", "builtin", [""]]];
+
+ _providerId = _self call ["normalizeProviderId", [_providerId]];
+ _providerId in (_self getOrDefault ["providers", createHashMap])
+ }],
+ ["getProvider", compileFinal {
+ params [["_providerId", "builtin", [""]]];
+
+ _providerId = _self call ["normalizeProviderId", [_providerId]];
+ (_self getOrDefault ["providers", createHashMap]) getOrDefault [_providerId, createHashMap]
+ }],
+ ["getSelectedProviderId", compileFinal {
+ private _providerId = _self call ["normalizeProviderId", [GETGVAR(generatorProvider,"builtin")]];
+ if (_self call ["hasProvider", [_providerId]]) exitWith { _providerId };
+
+ ["WARNING", format [
+ "Generated mission provider '%1' is selected but not registered; falling back to builtin provider.",
+ _providerId
+ ]] call EFUNC(common,log);
+ "builtin"
+ }],
+ ["getActiveProvider", compileFinal {
+ _self call ["getProvider", [_self call ["getSelectedProviderId", []]]]
+ }],
+ ["getGeneratedTaskTypes", compileFinal {
+ private _providerId = _self call ["getSelectedProviderId", []];
+ private _provider = _self call ["getProvider", [_providerId]];
+ if (_provider isEqualTo createHashMap) exitWith { [] };
+
+ private _types = _provider call ["getGeneratedTaskTypes", []];
+ if !(_types isEqualType []) exitWith {
+ ["WARNING", format ["Generated mission provider '%1' returned invalid task types.", _providerId]] call EFUNC(common,log);
+ []
+ };
+
+ ["INFO", format [
+ "Generated mission provider '%1' returned task types: %2",
+ _providerId,
+ _types apply { _x getOrDefault ["value", ""] }
+ ]] call EFUNC(common,log);
+ _types
+ }],
+ ["requestMissionTask", compileFinal {
+ params [
+ ["_requestedType", "", [""]],
+ ["_metadata", createHashMap, [createHashMap]],
+ ["_requesterUid", "", [""]]
+ ];
+
+ private _providerId = _self call ["getSelectedProviderId", []];
+ private _provider = _self call ["getProvider", [_providerId]];
+ if (_provider isEqualTo createHashMap) exitWith {
+ _self call ["emptyResult", [format ["Generated mission provider is unavailable: %1", _providerId], _requestedType]]
+ };
+
+ private _result = _provider call ["requestMissionTask", [_requestedType, _metadata, _requesterUid]];
+ if !(_result isEqualType createHashMap) exitWith {
+ _self call ["emptyResult", [format ["Generated mission provider '%1' returned an invalid request result.", _providerId], _requestedType]]
+ };
+
+ if !("taskType" in _result) then { _result set ["taskType", _requestedType]; };
+ if !("taskID" in _result) then { _result set ["taskID", ""]; };
+ if !("success" in _result) then { _result set ["success", false]; };
+ if !("message" in _result) then { _result set ["message", "Generated task request completed."]; };
+ _result set ["provider", _providerId];
+ _result
+ }]
+];
+
+GVAR(MissionGeneratorProviderRegistry) = createHashMapObject [GVAR(MissionGeneratorProviderRegistryBaseClass)];
+GVAR(MissionGeneratorProviderRegistry)
diff --git a/arma/server/addons/task/functions/objects/fnc_TaskInstanceBaseClass.sqf b/arma/server/addons/task/functions/objects/fnc_TaskInstanceBaseClass.sqf
index 305b565..f035f79 100644
--- a/arma/server/addons/task/functions/objects/fnc_TaskInstanceBaseClass.sqf
+++ b/arma/server/addons/task/functions/objects/fnc_TaskInstanceBaseClass.sqf
@@ -93,9 +93,9 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
- private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap];
+ private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry set [_registryKey, _self];
- missionNamespace setVariable [QGVAR(ObjectTaskInstances), _registry];
+ SETMVAR(GVAR(ObjectTaskInstances),_registry);
missionNamespace setVariable [_registryKey, _self];
true
}],
@@ -103,7 +103,7 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
- private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap];
+ private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil];
true
diff --git a/arma/server/addons/task/functions/objects/fnc_TaskLifecycleReporter.sqf b/arma/server/addons/task/functions/objects/fnc_TaskLifecycleReporter.sqf
index e11a795..14cd390 100644
--- a/arma/server/addons/task/functions/objects/fnc_TaskLifecycleReporter.sqf
+++ b/arma/server/addons/task/functions/objects/fnc_TaskLifecycleReporter.sqf
@@ -122,6 +122,53 @@ GVAR(TaskLifecycleReporter) = createHashMapObject [[
_self call ["buildTaskLifecycleEventPayload", [_taskID, _status, _extra]],
createHashMapFromArray [["source", "task"]]
]]
+ }],
+ ["registerEventLogListeners", compileFinal {
+ if !(isNil QGVAR(TaskLifecycleEventLogTokens)) exitWith { GVAR(TaskLifecycleEventLogTokens) };
+
+ private _logTaskLifecycleEvent = {
+ params ["_event"];
+
+ if !(GETGVAR(enableEventLogs,false)) exitWith {};
+
+ ["INFO", format [
+ "Task lifecycle event: %1 taskID=%2 taskType=%3 status=%4 participants=%5",
+ _event getOrDefault ["event", ""],
+ _event getOrDefault ["taskID", ""],
+ _event getOrDefault ["taskType", ""],
+ _event getOrDefault ["status", ""],
+ _event getOrDefault ["participants", []]
+ ]] call EFUNC(common,log);
+ };
+
+ private _logTaskRewardEvent = {
+ params ["_event"];
+
+ if !(GETGVAR(enableEventLogs,false)) exitWith {};
+
+ ["INFO", format [
+ "Task reward event: %1 taskID=%2 success=%3 message=%4",
+ _event getOrDefault ["event", ""],
+ _event getOrDefault ["taskID", ""],
+ !((_event getOrDefault ["event", ""]) in ["task.reward.failed", "task.rating.failed"]),
+ _event getOrDefault ["message", ""]
+ ]] call EFUNC(common,log);
+ };
+
+ GVAR(TaskLifecycleEventLogTokens) = [
+ EGVAR(common,EventBus) call ["on", ["task.created", _logTaskLifecycleEvent, "task.lifecycle.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.started", _logTaskLifecycleEvent, "task.lifecycle.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.completed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.failed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.cleared", _logTaskLifecycleEvent, "task.lifecycle.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.reward.requested", _logTaskRewardEvent, "task.reward.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.reward.applied", _logTaskRewardEvent, "task.reward.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.reward.failed", _logTaskRewardEvent, "task.reward.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.rating.applied", _logTaskRewardEvent, "task.reward.log"]],
+ EGVAR(common,EventBus) call ["on", ["task.rating.failed", _logTaskRewardEvent, "task.reward.log"]]
+ ];
+
+ GVAR(TaskLifecycleEventLogTokens)
}]
]];
diff --git a/arma/server/addons/task/functions/objects/fnc_TaskNotificationService.sqf b/arma/server/addons/task/functions/objects/fnc_TaskNotificationService.sqf
new file mode 100644
index 0000000..1c00463
--- /dev/null
+++ b/arma/server/addons/task/functions/objects/fnc_TaskNotificationService.sqf
@@ -0,0 +1,103 @@
+#include "..\script_component.hpp"
+
+/*
+ * Author: IDSolutions
+ * Dispatches task notification events to client notification UIs.
+ *
+ * Arguments:
+ * None
+ *
+ * Return Value:
+ * Task notification service object
+ *
+ * Public: No
+ */
+
+#pragma hemtt ignore_variables ["_self"]
+GVAR(TaskNotificationService) = createHashMapObject [[
+ ["#type", "TaskNotificationService"],
+ ["sendToPlayers", compileFinal {
+ params [
+ ["_uids", [], [[]]],
+ ["_type", "info", [""]],
+ ["_title", "Tasks", [""]],
+ ["_message", "", [""]]
+ ];
+
+ if (_message isEqualTo "" || { _uids isEqualTo [] }) exitWith { false };
+
+ {
+ private _player = [_x] call EFUNC(common,getPlayer);
+ if (isNull _player) then { continue; };
+ [CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
+ } forEach _uids;
+
+ true
+ }],
+ ["handleTaskNotification", compileFinal {
+ params ["_event"];
+
+ private _type = _event getOrDefault ["notificationType", "info"];
+ private _title = _event getOrDefault ["title", "Tasks"];
+ private _message = _event getOrDefault ["message", ""];
+ private _participantUids = +(_event getOrDefault ["participantUids", []]);
+
+ if !(_self call ["sendToPlayers", [_participantUids, _type, _title, _message]]) exitWith { false };
+
+ if (GETGVAR(enableEventLogs,false)) then {
+ ["INFO", format [
+ "Task notification event: taskID=%1 type=%2 recipients=%3 message=%4",
+ _event getOrDefault ["taskID", ""],
+ _type,
+ _participantUids,
+ _message
+ ]] call EFUNC(common,log);
+ };
+
+ true
+ }],
+ ["handleRewardNotification", compileFinal {
+ params ["_event"];
+
+ private _type = _event getOrDefault ["notificationType", "info"];
+ private _title = _event getOrDefault ["title", "Tasks"];
+ private _message = _event getOrDefault ["message", ""];
+ private _memberUids = +(_event getOrDefault ["memberUids", []]);
+
+ if !(_self call ["sendToPlayers", [_memberUids, _type, _title, _message]]) exitWith { false };
+
+ if (GETGVAR(enableEventLogs,false)) then {
+ ["INFO", format [
+ "Task reward notification event: taskID=%1 type=%2 recipients=%3 message=%4",
+ _event getOrDefault ["taskID", ""],
+ _type,
+ _memberUids,
+ _message
+ ]] call EFUNC(common,log);
+ };
+
+ true
+ }],
+ ["registerEventListeners", compileFinal {
+ if !(isNil QGVAR(TaskNotificationEventTokens)) exitWith { GVAR(TaskNotificationEventTokens) };
+
+ private _sendTaskNotification = {
+ params ["_event"];
+ GVAR(TaskNotificationService) call ["handleTaskNotification", [_event]];
+ };
+
+ private _sendRewardNotification = {
+ params ["_event"];
+ GVAR(TaskNotificationService) call ["handleRewardNotification", [_event]];
+ };
+
+ GVAR(TaskNotificationEventTokens) = [
+ EGVAR(common,EventBus) call ["on", ["task.notification.requested", _sendTaskNotification, "task.notification.send"]],
+ EGVAR(common,EventBus) call ["on", ["task.reward.notification.requested", _sendRewardNotification, "task.reward.notification.send"]]
+ ];
+
+ GVAR(TaskNotificationEventTokens)
+ }]
+]];
+
+GVAR(TaskNotificationService)
diff --git a/arma/server/extension/src/surreal.rs b/arma/server/extension/src/surreal.rs
index 012b5d2..d0dfbfe 100644
--- a/arma/server/extension/src/surreal.rs
+++ b/arma/server/extension/src/surreal.rs
@@ -19,6 +19,8 @@ pub type SurrealDb = Surreal;
const CLIENT_READY_TIMEOUT: Duration = Duration::from_secs(30);
const CLIENT_READY_POLL_INTERVAL: Duration = Duration::from_millis(25);
+const INIT_MAX_ATTEMPTS: usize = 5;
+const INIT_RETRY_BASE_DELAY: Duration = Duration::from_millis(150);
static SURREAL_DB: LazyLock>>> =
LazyLock::new(|| StdRwLock::new(None));
@@ -27,6 +29,8 @@ static SURREAL_CONNECTION_STATE: LazyLock> =
static SURREAL_FAILURE_REASON: LazyLock>> =
LazyLock::new(|| StdRwLock::new(None));
static SURREAL_INIT_GENERATION: AtomicU64 = AtomicU64::new(0);
+static SURREAL_INIT_LOCK: LazyLock> =
+ LazyLock::new(|| tokio::sync::Mutex::new(()));
#[derive(Clone, Copy, PartialEq, Eq)]
enum SurrealConnectionState {
@@ -42,6 +46,7 @@ pub fn prepare() {
}
pub async fn initialize(config: SurrealConfig) {
+ let _init_guard = SURREAL_INIT_LOCK.lock().await;
let generation = SURREAL_INIT_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
prepare();
@@ -55,7 +60,7 @@ pub async fn initialize(config: SurrealConfig) {
);
let timeout_duration = Duration::from_millis(config.connect_timeout_ms.unwrap_or(5000));
- let connection = timeout(timeout_duration, connect(config)).await;
+ let connection = timeout(timeout_duration, connect_with_retries(config)).await;
let db = match connection {
Err(_) => {
@@ -98,7 +103,7 @@ pub async fn initialize(config: SurrealConfig) {
}
log::log("surreal", "DEBUG", "Applying SurrealDB schemas");
- if let Err(error) = schema::apply_all(&db).await {
+ if let Err(error) = apply_schemas_with_retries(&db).await {
if !is_current_generation(generation) {
return;
}
@@ -159,6 +164,70 @@ async fn connect(config: SurrealConfig) -> Result {
Ok(db)
}
+async fn connect_with_retries(config: SurrealConfig) -> Result {
+ let mut last_error = String::new();
+
+ for attempt in 1..=INIT_MAX_ATTEMPTS {
+ match connect(config.clone()).await {
+ Ok(db) => return Ok(db),
+ Err(error) => {
+ if !is_retryable_surreal_error(&error) || attempt == INIT_MAX_ATTEMPTS {
+ return Err(error);
+ }
+
+ last_error = error;
+ log::log(
+ "surreal",
+ "WARNING",
+ &format!(
+ "SurrealDB connection attempt {} failed with retryable error: {}",
+ attempt, last_error
+ ),
+ );
+ sleep(init_retry_delay(attempt)).await;
+ }
+ }
+ }
+
+ Err(last_error)
+}
+
+async fn apply_schemas_with_retries(db: &SurrealDb) -> Result<(), String> {
+ let mut last_error = String::new();
+
+ for attempt in 1..=INIT_MAX_ATTEMPTS {
+ match schema::apply_all(db).await {
+ Ok(()) => return Ok(()),
+ Err(error) => {
+ if !is_retryable_surreal_error(&error) || attempt == INIT_MAX_ATTEMPTS {
+ return Err(error);
+ }
+
+ last_error = error;
+ log::log(
+ "surreal",
+ "WARNING",
+ &format!(
+ "SurrealDB schema bootstrap attempt {} failed with retryable error: {}",
+ attempt, last_error
+ ),
+ );
+ sleep(init_retry_delay(attempt)).await;
+ }
+ }
+ }
+
+ Err(last_error)
+}
+
+fn is_retryable_surreal_error(error: &str) -> bool {
+ error.contains("Transaction conflict") || error.contains("Resource busy")
+}
+
+fn init_retry_delay(attempt: usize) -> Duration {
+ INIT_RETRY_BASE_DELAY * attempt as u32
+}
+
pub async fn client() -> Result, String> {
if let Some(db) = SURREAL_DB.read().unwrap().clone() {
return Ok(db);
@@ -203,6 +272,10 @@ pub fn status() -> String {
}
pub fn reconnect() -> String {
+ if *SURREAL_CONNECTION_STATE.read().unwrap() == SurrealConnectionState::Initializing {
+ return "reconnect skipped: connection already initializing".to_string();
+ }
+
let surreal_config = config::load().surreal.clone();
prepare();
RUNTIME.spawn(async move {
diff --git a/arma/server/surrealdb/AllInOne.bat b/arma/server/surrealdb/AllInOne.bat
index 5a1cb80..d5dfa1d 100644
--- a/arma/server/surrealdb/AllInOne.bat
+++ b/arma/server/surrealdb/AllInOne.bat
@@ -1,3 +1,8 @@
@echo off
-call "%~dp0UpdateMe.bat"
+setlocal EnableExtensions
+set "FORGE_SURREALDB_VERSION=%~1"
+if not defined FORGE_SURREALDB_VERSION set "FORGE_SURREALDB_VERSION=3"
+
+call "%~dp0UpdateMe.bat" "%FORGE_SURREALDB_VERSION%"
+if errorlevel 1 exit /b %errorlevel%
call "%~dp0RunMe.bat"
diff --git a/arma/server/surrealdb/README.md b/arma/server/surrealdb/README.md
index d1d8a16..07b5e86 100644
--- a/arma/server/surrealdb/README.md
+++ b/arma/server/surrealdb/README.md
@@ -10,12 +10,36 @@ firewall, TLS, backup, and upgrade policy before exposing the database.
## Windows
-Install or update SurrealDB:
+Install or update SurrealDB to the newest compatible SurrealDB 3.x release:
```bat
UpdateMe.bat
```
+Install a specific SurrealDB release:
+
+```bat
+UpdateMe.bat v3.1.2
+```
+
+Install the latest stable SurrealDB release, including newer major versions:
+
+```bat
+UpdateMe.bat latest
+```
+
+`latest` requires confirmation because a newer SurrealDB major version can
+require rebuilding the Forge server extension from source with a compatible
+`surrealdb` Rust crate.
+
+The PowerShell entry point exposes the same behavior:
+
+```powershell
+.\UpdateSurrealDB.ps1
+.\UpdateSurrealDB.ps1 -Version v3.1.2
+.\UpdateSurrealDB.ps1 -Version latest
+```
+
If this is the first install and the terminal cannot find `surreal` after the
script finishes, open a new terminal so Windows reloads `PATH`.
@@ -25,12 +49,21 @@ Start Forge's local database:
RunMe.bat
```
+Or start it directly with PowerShell:
+
+```powershell
+.\RunSurrealDB.ps1
+```
+
Install and start in one step:
```bat
AllInOne.bat
```
+`AllInOne.bat` also defaults to the newest compatible SurrealDB 3.x release.
+Pass the same version argument as `UpdateMe.bat` to override it.
+
## Linux or macOS
Install SurrealDB:
diff --git a/arma/server/surrealdb/RunMe.bat b/arma/server/surrealdb/RunMe.bat
index b9f1310..15be415 100644
--- a/arma/server/surrealdb/RunMe.bat
+++ b/arma/server/surrealdb/RunMe.bat
@@ -1,3 +1,2 @@
@echo off
-cd /d "%~dp0"
-surreal start --user root --pass root --bind 127.0.0.1:8000 rocksdb://forge.db
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0RunSurrealDB.ps1"
diff --git a/arma/server/surrealdb/RunSurrealDB.ps1 b/arma/server/surrealdb/RunSurrealDB.ps1
new file mode 100644
index 0000000..14207df
--- /dev/null
+++ b/arma/server/surrealdb/RunSurrealDB.ps1
@@ -0,0 +1,16 @@
+param(
+ [string]$User = "root",
+ [string]$Pass = "root",
+ [string]$Bind = "127.0.0.1:8000",
+ [string]$DatabasePath = "forge.db"
+)
+
+$ErrorActionPreference = "Stop"
+
+Set-Location $PSScriptRoot
+
+if (-not (Get-Command surreal -ErrorAction SilentlyContinue)) {
+ throw "The 'surreal' command was not found. Run UpdateSurrealDB.ps1 first, then open a new terminal if PATH was updated."
+}
+
+surreal start --user $User --pass $Pass --bind $Bind "rocksdb://$DatabasePath"
diff --git a/arma/server/surrealdb/UpdateMe.bat b/arma/server/surrealdb/UpdateMe.bat
index 4d21b22..4b36ef0 100644
--- a/arma/server/surrealdb/UpdateMe.bat
+++ b/arma/server/surrealdb/UpdateMe.bat
@@ -1,14 +1,10 @@
@echo off
-where surreal >nul 2>nul
-if %errorlevel% equ 0 (
- surreal upgrade
- surreal version
-) else (
- powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr https://windows.surrealdb.com -useb | iex"
- where surreal >nul 2>nul
- if %errorlevel% equ 0 (
- surreal version
- ) else (
- echo SurrealDB install finished. Open a new terminal if the surreal command is not available yet.
- )
-)
+setlocal EnableExtensions
+set "DEFAULT_SURREALDB_VERSION=3"
+set "TARGET_SURREALDB_VERSION=%~1"
+
+if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%FORGE_SURREALDB_VERSION%"
+if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%DEFAULT_SURREALDB_VERSION%"
+
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0UpdateSurrealDB.ps1" -Version "%TARGET_SURREALDB_VERSION%"
+exit /b %errorlevel%
diff --git a/arma/server/surrealdb/UpdateSurrealDB.ps1 b/arma/server/surrealdb/UpdateSurrealDB.ps1
new file mode 100644
index 0000000..6090c16
--- /dev/null
+++ b/arma/server/surrealdb/UpdateSurrealDB.ps1
@@ -0,0 +1,120 @@
+param(
+ [string]$Version = "3",
+ [switch]$Force
+)
+
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+
+$VersionUrl = "https://version.surrealdb.com"
+$DownloadBaseUrl = "https://download.surrealdb.com"
+$Architecture = "windows-amd64"
+
+function Normalize-Version {
+ param([string]$Value)
+
+ $trimmed = $Value.Trim()
+ if ($trimmed -match "(?i)^latest$") {
+ return "latest"
+ }
+ if ($trimmed -match "^v?\d+$") {
+ return $trimmed.TrimStart("v")
+ }
+ if ($trimmed -match "^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$") {
+ return "v$($trimmed.TrimStart("v"))"
+ }
+
+ throw "Unsupported SurrealDB version '$Value'. Use a major version like '3', an exact version like 'v3.1.2', or 'latest'."
+}
+
+function Get-Latest-Version {
+ return (Invoke-WebRequest $VersionUrl -UseBasicParsing).Content.Trim()
+}
+
+function Resolve-Version {
+ param([string]$Target)
+
+ $normalized = Normalize-Version $Target
+ if ($normalized -eq "latest") {
+ return Get-Latest-Version
+ }
+
+ if ($normalized -match "^\d+$") {
+ $latest = Get-Latest-Version
+ if ($latest -notmatch "^v?$normalized\.") {
+ throw "Latest SurrealDB is $latest, not $normalized.x. Pass an exact $normalized.x version or use 'latest' after confirming Forge compatibility."
+ }
+
+ return $latest
+ }
+
+ return $normalized
+}
+
+function Confirm-Latest {
+ if ($Force) {
+ return
+ }
+
+ Write-Host ""
+ Write-Host "WARNING: This will install the latest stable SurrealDB release, even if it is newer"
+ Write-Host "than the Forge server extension was compiled and tested against."
+ Write-Host ""
+ Write-Host "The Forge server extension currently targets SurrealDB 3.x. A newer major"
+ Write-Host "SurrealDB release can require rebuilding Forge from source with a compatible"
+ Write-Host "surrealdb Rust crate before the extension works correctly."
+ Write-Host ""
+
+ $answer = Read-Host "Install latest SurrealDB anyway? [Y/N]"
+ if ($answer -notmatch "^(?i)y(es)?$") {
+ exit 1
+ }
+}
+
+function Get-Install-Path {
+ $existing = Get-Command surreal -ErrorAction SilentlyContinue | Select-Object -First 1
+ if ($null -ne $existing -and $existing.Source -and (Split-Path -Leaf $existing.Source) -ieq "surreal.exe") {
+ return $existing.Source
+ }
+
+ $installDirectory = Join-Path $env:LOCALAPPDATA "SurrealDB"
+ New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
+ return Join-Path $installDirectory "surreal.exe"
+}
+
+function Ensure-User-Path {
+ param([string]$Directory)
+
+ $pathParts = $env:Path -split ";" | Where-Object { $_ }
+ if ($pathParts -notcontains $Directory) {
+ $env:Path = "$Directory;$env:Path"
+ }
+
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+ $userPathParts = $userPath -split ";" | Where-Object { $_ }
+ if ($userPathParts -notcontains $Directory) {
+ $newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Directory } else { "$Directory;$userPath" }
+ [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
+ Write-Host "Added $Directory to the user PATH. Open a new terminal if 'surreal' is not found later."
+ }
+}
+
+$normalizedTarget = Normalize-Version $Version
+if ($normalizedTarget -eq "latest") {
+ Confirm-Latest
+}
+
+$resolvedVersion = Resolve-Version $Version
+$installPath = Get-Install-Path
+$installDirectory = Split-Path -Parent $installPath
+New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
+
+$downloadUrl = "$DownloadBaseUrl/$resolvedVersion/surreal-$resolvedVersion.$Architecture.exe"
+$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "surreal-$resolvedVersion.$Architecture.exe"
+
+Write-Host "Installing SurrealDB $resolvedVersion from $downloadUrl"
+Invoke-WebRequest $downloadUrl -OutFile $tempPath -UseBasicParsing
+Move-Item -Force -Path $tempPath -Destination $installPath
+Ensure-User-Path $installDirectory
+
+& $installPath version
diff --git a/docs/CAD_USAGE_GUIDE.md b/docs/CAD_USAGE_GUIDE.md
index aa8e1f3..03fcb8e 100644
--- a/docs/CAD_USAGE_GUIDE.md
+++ b/docs/CAD_USAGE_GUIDE.md
@@ -71,27 +71,27 @@ Common generated IDs:
## Generated Mission Requests
-Dispatchers can request framework-generated mission tasks from the CAD
-dispatcher board. The server hydrates the available generated task types from
-the task mission manager as `generatedTaskTypes`; the client uses that hydrated
-list for the dropdown.
+Dispatchers can request generated mission tasks from the CAD dispatcher board.
+The server hydrates the available generated task types from the selected task
+provider as `generatedTaskTypes`; the client uses that hydrated list for the
+dropdown.
-Generated mission requests are controlled by the server CBA setting
+Built-in generated mission requests are controlled by the server CBA setting
`forge_server_task_enableGenerator`:
-- Enabled: CAD receives the generated task type list and dispatchers can request
- a specific generator type.
-- Disabled: CAD receives an empty generated task type list, the task request UI
- is disabled, and server-side request handling rejects any manual request.
+- Enabled: CAD can receive the built-in generated task type list and dispatchers
+ can request a specific built-in generator type.
+- Disabled: the built-in provider returns no task types and rejects built-in
+ manual requests.
-The framework-owned request entry point is
-`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework
-handler directly; it does not call mission-local generator functions.
+Server CAD routes generated mission requests through the task provider registry.
+The selected provider handles the request and returns the CAD response payload.
-Custom mission generators can still create CAD-visible tasks directly by
-registering task catalog entries and task statuses. See
-[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for the supported
-integration path and the current generated-task provider limitation.
+Custom mission generators can register a provider with the
+`forge_server_task_registerMissionGeneratorProvider` CBA server event or create
+CAD-visible tasks directly by registering task catalog entries and task
+statuses. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for
+the supported integration path.
## Submit a Support Request
diff --git a/docs/CLIENT_CAD_USAGE_GUIDE.md b/docs/CLIENT_CAD_USAGE_GUIDE.md
index 2531959..9e0de0b 100644
--- a/docs/CLIENT_CAD_USAGE_GUIDE.md
+++ b/docs/CLIENT_CAD_USAGE_GUIDE.md
@@ -104,13 +104,13 @@ The dispatcher-generated task dropdown is hydrated from the server
`generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
-request control, which is how `forge_server_task_enableGenerator = false` is surfaced
-client-side.
+request control. For the built-in provider, this is how
+`forge_server_task_enableGenerator = false` is surfaced client-side.
-Custom mission generators can still publish tasks into CAD by using the server
-task catalog. The generated-task dropdown itself currently needs a framework
-provider extension point before custom providers can replace the built-in list
-cleanly. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
+Custom mission generators can publish tasks into CAD by using the server task
+catalog or by registering a task provider that supplies `generatedTaskTypes` and
+handles generated task requests. See
+[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
## Authorization Notes
diff --git a/docs/CUSTOM_MISSION_GENERATORS.md b/docs/CUSTOM_MISSION_GENERATORS.md
index 45b5d97..a786f47 100644
--- a/docs/CUSTOM_MISSION_GENERATORS.md
+++ b/docs/CUSTOM_MISSION_GENERATORS.md
@@ -5,9 +5,8 @@ foundation that communities build on top of. Custom mission generators should
integrate through the same task, CAD, and event surfaces that the built-in
mission manager uses.
-This guide documents the supported integration path today and calls out the
-current CAD generated-task provider limitation that should be addressed by a
-small framework extension point.
+This guide documents the supported integration path for custom generators,
+including the provider registry used by CAD/manual generated task requests.
## Recommended Architecture
@@ -35,13 +34,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types.
-This does not prevent custom code from creating CAD-visible tasks directly.
-It only disables the built-in generator request list and the framework-owned
-manual request entry point.
+This does not prevent custom code from creating CAD-visible tasks directly or
+from serving CAD/manual generated task requests through a registered custom
+provider.
The mission setup UI does not override this setting. Generated mission
-enablement is mission/server policy and should stay in CBA settings until a
-provider selection extension point exists.
+enablement for the built-in provider is mission/server policy and stays in CBA
+settings.
+
+The mission setup UI can capture a generator provider preference:
+
+- `builtin` for Forge's built-in generated mission provider
+- `custom` for mission/community-owned generated mission providers
+
+That preference is stored in `forge_server_task_generatorProvider` and mirrored
+inside `forge_server_task_missionSetup_settings`. It is intentionally separate
+from `forge_server_task_enableGenerator`; the CBA setting only gates Forge's
+built-in provider. A registered custom provider can still publish generated task
+types and handle CAD/manual requests when selected.
## Framework Mission Setup UI
@@ -68,6 +78,7 @@ missionNamespace setVariable [
The UI configures:
- opposing faction
+- generator provider preference
- max concurrent generated missions
- mission interval
- location reuse cooldown
@@ -100,6 +111,75 @@ actor interaction entry is hidden once clients receive the public applied flag,
and direct or stale open requests receive a notification explaining that setup
has already been applied.
+## Provider Registry
+
+Custom providers register on the server through the server-side CBA event:
+
+```sqf
+[
+ "forge_server_task_registerMissionGeneratorProvider",
+ ["custom", _provider]
+] call CBA_fnc_serverEvent;
+```
+
+This event is intentionally fire-and-forget. The task module validates provider
+shape server-side and logs registration failures.
+
+The provider is a hashMap/hashMapObject with two required methods:
+
+| Method | Arguments | Return |
+| --- | --- | --- |
+| `getGeneratedTaskTypes` | none | Array of hashMaps with `value` and `label` |
+| `requestMissionTask` | `_taskType`, `_metadata`, `_requesterUid` | Result hashMap |
+
+The request result should include:
+
+| Key | Type | Notes |
+| --- | --- | --- |
+| `success` | Boolean | `true` when a task was generated |
+| `message` | String | User-facing CAD response |
+| `taskID` | String | Created task ID, or empty on failure |
+| `taskType` | String | Resolved generated task type |
+
+Example provider:
+
+```sqf
+private _provider = createHashMapObject [[
+ ["#type", "CommunityMissionGeneratorProvider"],
+ ["getGeneratedTaskTypes", {
+ [
+ createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]],
+ createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]]
+ ]
+ }],
+ ["requestMissionTask", {
+ params ["_taskType", "_metadata", "_requesterUid"];
+
+ private _taskID = format ["custom_%1_%2", _taskType, floor random 100000];
+
+ // Create/spawn the mission here, then publish it through Forge's task
+ // catalog/status contract so CAD can assign and track it.
+
+ createHashMapFromArray [
+ ["success", true],
+ ["message", format ["Generated custom %1 task %2.", _taskType, _taskID]],
+ ["taskID", _taskID],
+ ["taskType", _taskType]
+ ]
+ }]
+]];
+
+[
+ "forge_server_task_registerMissionGeneratorProvider",
+ ["custom", _provider]
+] call CBA_fnc_serverEvent;
+```
+
+When the setup UI provider toggle is set to `custom`, CAD hydrates task types
+from the registered `custom` provider and CAD/manual requests call that
+provider's `requestMissionTask` method. If no custom provider is registered,
+Forge logs a warning and falls back to the built-in provider.
+
## CAD-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom
@@ -290,78 +370,33 @@ when relevant changes occur. Custom generators usually only need to emit task
status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners.
-## Generated Task Dropdown Limitation
+## Generated Task Provider Behavior
-The current CAD generated-task dropdown is owned by the framework task mission
-manager. CAD hydrates `generatedTaskTypes` from the built-in manager when
-`forge_server_task_enableGenerator` is enabled. When that setting is disabled,
-the generated-task request control is disabled.
+CAD hydrates generated task types and requests generated tasks through the task
+provider registry. The selected provider comes from
+`forge_server_task_generatorProvider`, defaulting to `builtin`.
-The current CAD request handler calls `forge_server_task_fnc_requestMissionTask`
-directly. It no longer falls back to mission-local generator request functions,
-so third-party generated-task providers should create CAD-visible tasks directly
-until a framework provider extension point is added.
+Use one of these supported patterns:
-Until a provider extension point is added, use one of these supported patterns:
-
-1. Run custom generators from mission/server code and create CAD-visible tasks
+1. Register a custom provider so CAD/manual generated task requests route to
+ community code.
+2. Run custom generators from mission/server code and create CAD-visible tasks
directly.
-2. Use CAD support requests or dispatch orders to let players request custom
+3. Use CAD support requests or dispatch orders to let players request custom
work, then have mission code convert approved requests into tasks.
-3. Keep the built-in generator enabled only if the community intentionally
+4. Keep the built-in generator enabled only if the community intentionally
wants the framework dropdown and request handler.
-## Planned Provider Extension Point
+## Provider Extension Details
-A future code change should make CAD generator providers explicit. The desired
-shape is:
+The implemented provider shape is intentionally small:
- built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers
-- mission designers or developers can select or toggle the active generator
- provider when a mission includes custom generators
-- a framework-hosted mission setup UI can display the active provider and, when
- supported by the mission, allow choosing between built-in and custom
- providers
-
-Candidate SQF hooks:
-
-```sqf
-forge_custom_fnc_getGeneratedTaskTypes
-forge_custom_fnc_requestMissionTask
-```
-
-or mission namespace variables:
-
-```sqf
-missionNamespace setVariable ["forge_generatorProvider_getTypes", {
- [
- createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]],
- createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]]
- ]
-}];
-
-missionNamespace setVariable ["forge_generatorProvider_requestTask", {
- params ["_taskType", "_metadata", "_requesterUid"];
- createHashMapFromArray [
- ["success", true],
- ["message", "Generated custom task."],
- ["taskID", "custom_task_01"],
- ["taskType", _taskType]
- ]
-}];
-```
-
-The exact API should be implemented in the framework code before communities
-depend on it.
-
-Implementation note: the provider selection should be separate from
-`forge_server_task_enableGenerator`. That CBA setting should continue to gate
-the built-in Forge generator, while a new provider option can decide whether
-CAD/manual requests use the built-in provider, a custom provider, both, or no
-provider at all.
+- mission designers or developers can select or toggle the active provider from
+ the framework mission setup UI when a mission includes custom generators
## Validation Checklist
diff --git a/docs/MISSION_DESIGNER_GUIDE.md b/docs/MISSION_DESIGNER_GUIDE.md
index 9d0882f..8876901 100644
--- a/docs/MISSION_DESIGNER_GUIDE.md
+++ b/docs/MISSION_DESIGNER_GUIDE.md
@@ -754,19 +754,19 @@ CAD dispatcher-requested generation.
The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown,
-reward ranges, reputation ranges, penalty ranges, and time limits. It does not
-enable or disable generated missions; use the CBA setting for that policy.
+reward ranges, reputation ranges, penalty ranges, time limits, and a generator
+provider preference. It does not enable or disable generated missions; use the
+CBA setting for that policy.
If mission setup is enabled, the mission manager waits until the setup operator
applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened.
-Future custom-generator support should add an explicit provider option so
-mission designers or developers can select or toggle a mission/community-owned
-generator without relying on mission-local fallback functions. Until then,
-custom generators should create CAD-visible tasks directly through the task
-catalog/status contract described in
+The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
+generated task requests use the task provider registry and route to the selected
+provider. Custom generators should register a provider or create CAD-visible
+tasks directly through the task catalog/status contract described in
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
The dynamic mission generator avoids rectangle and ellipse area markers whose
diff --git a/docs/STORE_USAGE_GUIDE.md b/docs/STORE_USAGE_GUIDE.md
index f947863..e8095ce 100644
--- a/docs/STORE_USAGE_GUIDE.md
+++ b/docs/STORE_USAGE_GUIDE.md
@@ -2,7 +2,8 @@
The store module processes checkout requests. It charges a payment source and
grants purchased items to the player locker, virtual arsenal locker, and
-virtual garage unlocks.
+virtual garage unlocks. Unit purchases are fulfilled as immediate server-side
+spawn grants at discovered `unit_spawn` markers.
## Server SQF Module
@@ -20,6 +21,55 @@ post-init. The initializer matches non-null mission namespace objects whose
variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities.
+## Mission Catalog Filter
+
+The store catalog is generated from loaded Arma config classes, then an
+optional mission `CfgStore` filter can allow or deny classnames per category.
+Include `CfgStore.hpp` from `description.ext`:
+
+```cpp
+#include "CfgStore.hpp"
+```
+
+```cpp
+class CfgStore {
+ mode = "allowlist"; // dynamic, allowlist, or denylist
+
+ class Categories {
+ primary[] = {"arifle_MX_F", "arifle_MXC_F"};
+ cars[] = {"B_MRAP_01_F"};
+ units[] = {"B_Soldier_F"};
+ };
+
+ class Overrides {
+ class arifle_MX_F {
+ price = 2500;
+ displayName = "MX Rifle";
+ description = "Approved PMC service rifle.";
+ };
+ };
+};
+```
+
+`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
+listed for each category. `denylist` removes listed classnames. Overrides are
+server-side and are used by both the UI payload and checkout validation.
+`units[]` uses the same filter behavior as every other category.
+
+The current filter is global for the mission. Revisit per-store profile support
+if individual vendors need different inventories.
+
+## Unit Spawn Markers
+
+Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
+`unit_spawn_2`, and so on. The store resolves the closest initialized store
+object to the requesting player, scans `allMapMarkers` when checkout fulfillment
+runs, and uses the closest matching marker within 25 meters of that store.
+
+If no matching marker exists within 25 meters, the store falls back to spawning
+units around the store object. If no store object can be resolved, it falls back
+to the requesting player.
+
## Checkout Model
`store:checkout` accepts one JSON context.
@@ -45,6 +95,13 @@ pattern used by garage entities.
"category": "cars",
"priceValue": 1500
}
+ ],
+ "units": [
+ {
+ "classname": "B_Soldier_F",
+ "category": "units",
+ "priceValue": 2500
+ }
]
}
```
@@ -52,12 +109,13 @@ pattern used by garage entities.
Rules validated by the Rust service:
- `requesterUid` is required.
-- At least one item or vehicle is required.
+- At least one item, vehicle, or unit is required.
- The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`.
+- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org
@@ -73,11 +131,12 @@ Rules validated by the Rust service:
```json
{
- "chargedTotal": 2000.0,
+ "chargedTotal": 4500.0,
"paymentMethod": "bank",
- "message": "Checkout completed. $2,000 charged, 1 locker grant(s), 1 vehicle unlock(s).",
+ "message": "Checkout completed. $4,500 charged, 1 locker grant(s), 1 vehicle unlock(s), 1 unit grant(s).",
"lockerGranted": [],
"vehicleGranted": [],
+ "unitGranted": [],
"lockerPatch": {},
"vaPatch": {},
"vgaragePatch": {},
@@ -108,7 +167,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"],
["items", [_item]],
- ["vehicles", []]
+ ["vehicles", []],
+ ["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@@ -133,7 +193,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"],
["items", []],
- ["vehicles", [_vehicle]]
+ ["vehicles", [_vehicle]],
+ ["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
diff --git a/docs/SURREALDB_SETUP.md b/docs/SURREALDB_SETUP.md
index b26119c..26d0826 100644
--- a/docs/SURREALDB_SETUP.md
+++ b/docs/SURREALDB_SETUP.md
@@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
+On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
+default it installs or updates to the newest compatible SurrealDB 3.x release
+reported by SurrealDB's official version endpoint. You can also pin an exact
+release:
+
+```powershell
+.\UpdateMe.bat v3.1.2
+.\UpdateSurrealDB.ps1 -Version v3.1.2
+```
+
+To intentionally install the latest stable SurrealDB release regardless of
+major version, run:
+
+```powershell
+.\UpdateMe.bat latest
+```
+
+The `latest` option prompts for confirmation because a newer SurrealDB major
+version can require rebuilding the Forge server extension from source with a
+compatible `surrealdb` Rust crate.
+
+`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
+Forge database with the same defaults shown below.
+
On Linux or macOS:
```bash
diff --git a/docs/TASK_USAGE_GUIDE.md b/docs/TASK_USAGE_GUIDE.md
index 7cfe7b2..d4c1a36 100644
--- a/docs/TASK_USAGE_GUIDE.md
+++ b/docs/TASK_USAGE_GUIDE.md
@@ -188,19 +188,20 @@ server-side.
The mission setup UI does not enable or disable generated missions. It applies
runtime tuning such as faction, caps, intervals, reward ranges, rating ranges,
-penalties, and time limits. Generator enablement remains controlled by the CBA
-setting above.
+penalties, time limits, and a generator provider preference. Generator
+enablement remains controlled by the CBA setting above.
When `forge_server_task_enableMissionSetup` is enabled, the mission manager
waits for setup settings before starting. There is no timeout auto-apply.
Pressing Cancel, X, or Escape applies default values from CBA, mission
parameters, and `CfgMissions`.
-Planned custom-generator work should add an explicit provider option for
-mission designers or developers who want to select or toggle a custom mission
-generator. That provider option should be separate from the built-in generator
-CBA gate so disabling Forge's built-in generator does not prevent custom
-providers from publishing CAD-visible work.
+The setup UI stores the provider preference in
+`forge_server_task_generatorProvider` as `builtin` or `custom`. CAD/manual
+generated task requests use the task provider registry and route to the selected
+provider. That provider option stays separate from the built-in generator CBA
+gate so disabling Forge's built-in generator does not prevent custom providers
+from publishing CAD-visible work.
## CAD Compatibility
diff --git a/docs/surrealdb-setup.md b/docs/surrealdb-setup.md
index b26119c..26d0826 100644
--- a/docs/surrealdb-setup.md
+++ b/docs/surrealdb-setup.md
@@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
+On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
+default it installs or updates to the newest compatible SurrealDB 3.x release
+reported by SurrealDB's official version endpoint. You can also pin an exact
+release:
+
+```powershell
+.\UpdateMe.bat v3.1.2
+.\UpdateSurrealDB.ps1 -Version v3.1.2
+```
+
+To intentionally install the latest stable SurrealDB release regardless of
+major version, run:
+
+```powershell
+.\UpdateMe.bat latest
+```
+
+The `latest` option prompts for confirmation because a newer SurrealDB major
+version can require rebuilding the Forge server extension from source with a
+compatible `surrealdb` Rust crate.
+
+`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
+Forge database with the same defaults shown below.
+
On Linux or macOS:
```bash
diff --git a/docus/content/1.getting-started/0.index.md b/docus/content/1.getting-started/0.index.md
index 196507d..832c585 100644
--- a/docus/content/1.getting-started/0.index.md
+++ b/docus/content/1.getting-started/0.index.md
@@ -79,8 +79,8 @@ npm run build:webui
title: Custom Mission Generators
to: /getting-started/custom-mission-generators
---
- Create CAD-visible custom generated missions and understand the current
- provider extension point.
+ Create CAD-visible custom generated missions and register custom generator
+ providers.
:::
:::u-page-card
diff --git a/docus/content/1.getting-started/4.mission-designer.md b/docus/content/1.getting-started/4.mission-designer.md
index 6e7707b..7c36aba 100644
--- a/docus/content/1.getting-started/4.mission-designer.md
+++ b/docus/content/1.getting-started/4.mission-designer.md
@@ -754,19 +754,19 @@ CAD dispatcher-requested generation.
The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown,
-reward ranges, reputation ranges, penalty ranges, and time limits. It does not
-enable or disable generated missions; use the CBA setting for that policy.
+reward ranges, reputation ranges, penalty ranges, time limits, and a generator
+provider preference. It does not enable or disable generated missions; use the
+CBA setting for that policy.
If mission setup is enabled, the mission manager waits until the setup operator
applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened.
-Future custom-generator support should add an explicit provider option so
-mission designers or developers can select or toggle a mission/community-owned
-generator without relying on mission-local fallback functions. Until then,
-custom generators should create CAD-visible tasks directly through the task
-catalog/status contract described in
+The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
+generated task requests use the task provider registry and route to the selected
+provider. Custom generators should register a provider or create CAD-visible
+tasks directly through the task catalog/status contract described in
[Custom Mission Generators](/getting-started/custom-mission-generators).
The dynamic mission generator avoids rectangle and ellipse area markers whose
diff --git a/docus/content/1.getting-started/6.surrealdb-setup.md b/docus/content/1.getting-started/6.surrealdb-setup.md
index abf2272..d63d7bc 100644
--- a/docus/content/1.getting-started/6.surrealdb-setup.md
+++ b/docus/content/1.getting-started/6.surrealdb-setup.md
@@ -43,6 +43,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
+On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
+default it installs or updates to the newest compatible SurrealDB 3.x release
+reported by SurrealDB's official version endpoint. You can also pin an exact
+release:
+
+```powershell
+.\UpdateMe.bat v3.1.2
+.\UpdateSurrealDB.ps1 -Version v3.1.2
+```
+
+To intentionally install the latest stable SurrealDB release regardless of
+major version, run:
+
+```powershell
+.\UpdateMe.bat latest
+```
+
+The `latest` option prompts for confirmation because a newer SurrealDB major
+version can require rebuilding the Forge server extension from source with a
+compatible `surrealdb` Rust crate.
+
+`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
+Forge database with the same defaults shown below.
+
On Linux or macOS:
```bash
diff --git a/docus/content/1.getting-started/7.custom-mission-generators.md b/docus/content/1.getting-started/7.custom-mission-generators.md
index 26e0a78..536b5f8 100644
--- a/docus/content/1.getting-started/7.custom-mission-generators.md
+++ b/docus/content/1.getting-started/7.custom-mission-generators.md
@@ -3,9 +3,8 @@ title: "Custom Mission Generators"
description: "Forge can be used as a complete out-of-box PMC mission framework, or as a foundation that communities build on top of. Custom mission generators should integrate through the same task, CAD, and event surfaces that the built-in mission manager uses."
---
-This guide documents the supported integration path today and calls out the
-current CAD generated-task provider limitation that should be addressed by a
-small framework extension point.
+This guide documents the supported integration path for custom generators,
+including the provider registry used by CAD/manual generated task requests.
## Recommended Architecture
@@ -33,13 +32,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types.
-This does not prevent custom code from creating CAD-visible tasks directly.
-It only disables the built-in generator request list and the framework-owned
-manual request entry point.
+This does not prevent custom code from creating CAD-visible tasks directly or
+from serving CAD/manual generated task requests through a registered custom
+provider.
The mission setup UI does not override this setting. Generated mission
-enablement is mission/server policy and should stay in CBA settings until a
-provider selection extension point exists.
+enablement for the built-in provider is mission/server policy and stays in CBA
+settings.
+
+The mission setup UI can capture a generator provider preference:
+
+- `builtin` for Forge's built-in generated mission provider
+- `custom` for mission/community-owned generated mission providers
+
+That preference is stored in `forge_server_task_generatorProvider` and mirrored
+inside `forge_server_task_missionSetup_settings`. It is intentionally separate
+from `forge_server_task_enableGenerator`; the CBA setting only gates Forge's
+built-in provider. A registered custom provider can still publish generated task
+types and handle CAD/manual requests when selected.
## Framework Mission Setup UI
@@ -66,6 +76,7 @@ missionNamespace setVariable [
The UI configures:
- opposing faction
+- generator provider preference
- max concurrent generated missions
- mission interval
- location reuse cooldown
@@ -98,6 +109,75 @@ actor interaction entry is hidden once clients receive the public applied flag,
and direct or stale open requests receive a notification explaining that setup
has already been applied.
+## Provider Registry
+
+Custom providers register on the server through the server-side CBA event:
+
+```sqf
+[
+ "forge_server_task_registerMissionGeneratorProvider",
+ ["custom", _provider]
+] call CBA_fnc_serverEvent;
+```
+
+This event is intentionally fire-and-forget. The task module validates provider
+shape server-side and logs registration failures.
+
+The provider is a hashMap/hashMapObject with two required methods:
+
+| Method | Arguments | Return |
+| --- | --- | --- |
+| `getGeneratedTaskTypes` | none | Array of hashMaps with `value` and `label` |
+| `requestMissionTask` | `_taskType`, `_metadata`, `_requesterUid` | Result hashMap |
+
+The request result should include:
+
+| Key | Type | Notes |
+| --- | --- | --- |
+| `success` | Boolean | `true` when a task was generated |
+| `message` | String | User-facing CAD response |
+| `taskID` | String | Created task ID, or empty on failure |
+| `taskType` | String | Resolved generated task type |
+
+Example provider:
+
+```sqf
+private _provider = createHashMapObject [[
+ ["#type", "CommunityMissionGeneratorProvider"],
+ ["getGeneratedTaskTypes", {
+ [
+ createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]],
+ createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]]
+ ]
+ }],
+ ["requestMissionTask", {
+ params ["_taskType", "_metadata", "_requesterUid"];
+
+ private _taskID = format ["custom_%1_%2", _taskType, floor random 100000];
+
+ // Create/spawn the mission here, then publish it through Forge's task
+ // catalog/status contract so CAD can assign and track it.
+
+ createHashMapFromArray [
+ ["success", true],
+ ["message", format ["Generated custom %1 task %2.", _taskType, _taskID]],
+ ["taskID", _taskID],
+ ["taskType", _taskType]
+ ]
+ }]
+]];
+
+[
+ "forge_server_task_registerMissionGeneratorProvider",
+ ["custom", _provider]
+] call CBA_fnc_serverEvent;
+```
+
+When the setup UI provider toggle is set to `custom`, CAD hydrates task types
+from the registered `custom` provider and CAD/manual requests call that
+provider's `requestMissionTask` method. If no custom provider is registered,
+Forge logs a warning and falls back to the built-in provider.
+
## CAD-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom
@@ -288,78 +368,33 @@ when relevant changes occur. Custom generators usually only need to emit task
status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners.
-## Generated Task Dropdown Limitation
+## Generated Task Provider Behavior
-The current CAD generated-task dropdown is owned by the framework task mission
-manager. CAD hydrates `generatedTaskTypes` from the built-in manager when
-`forge_server_task_enableGenerator` is enabled. When that setting is disabled,
-the generated-task request control is disabled.
+CAD hydrates generated task types and requests generated tasks through the task
+provider registry. The selected provider comes from
+`forge_server_task_generatorProvider`, defaulting to `builtin`.
-The current CAD request handler calls `forge_server_task_fnc_requestMissionTask`
-directly. It no longer falls back to mission-local generator request functions,
-so third-party generated-task providers should create CAD-visible tasks directly
-until a framework provider extension point is added.
+Use one of these supported patterns:
-Until a provider extension point is added, use one of these supported patterns:
-
-1. Run custom generators from mission/server code and create CAD-visible tasks
+1. Register a custom provider so CAD/manual generated task requests route to
+ community code.
+2. Run custom generators from mission/server code and create CAD-visible tasks
directly.
-2. Use CAD support requests or dispatch orders to let players request custom
+3. Use CAD support requests or dispatch orders to let players request custom
work, then have mission code convert approved requests into tasks.
-3. Keep the built-in generator enabled only if the community intentionally
+4. Keep the built-in generator enabled only if the community intentionally
wants the framework dropdown and request handler.
-## Planned Provider Extension Point
+## Provider Extension Details
-A future code change should make CAD generator providers explicit. The desired
-shape is:
+The implemented provider shape is intentionally small:
- built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers
-- mission designers or developers can select or toggle the active generator
- provider when a mission includes custom generators
-- a framework-hosted mission setup UI can display the active provider and, when
- supported by the mission, allow choosing between built-in and custom
- providers
-
-Candidate SQF hooks:
-
-```sqf
-forge_custom_fnc_getGeneratedTaskTypes
-forge_custom_fnc_requestMissionTask
-```
-
-or mission namespace variables:
-
-```sqf
-missionNamespace setVariable ["forge_generatorProvider_getTypes", {
- [
- createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]],
- createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]]
- ]
-}];
-
-missionNamespace setVariable ["forge_generatorProvider_requestTask", {
- params ["_taskType", "_metadata", "_requesterUid"];
- createHashMapFromArray [
- ["success", true],
- ["message", "Generated custom task."],
- ["taskID", "custom_task_01"],
- ["taskType", _taskType]
- ]
-}];
-```
-
-The exact API should be implemented in the framework code before communities
-depend on it.
-
-Implementation note: the provider selection should be separate from
-`forge_server_task_enableGenerator`. That CBA setting should continue to gate
-the built-in Forge generator, while a new provider option can decide whether
-CAD/manual requests use the built-in provider, a custom provider, both, or no
-provider at all.
+- mission designers or developers can select or toggle the active provider from
+ the framework mission setup UI when a mission includes custom generators
## Validation Checklist
diff --git a/docus/content/3.server-modules/10.store.md b/docus/content/3.server-modules/10.store.md
index 4148d87..051bb83 100644
--- a/docus/content/3.server-modules/10.store.md
+++ b/docus/content/3.server-modules/10.store.md
@@ -1,6 +1,6 @@
---
title: "Store Usage Guide"
-description: "The store module processes checkout requests. It charges a payment source and grants purchased items to the player locker, virtual arsenal locker, and virtual garage unlocks."
+description: "The store module processes checkout requests. It charges a payment source and grants purchased items to the player locker, virtual arsenal locker, virtual garage unlocks, and immediate unit spawn grants."
---
## Server SQF Module
@@ -9,16 +9,70 @@ The server addon uses two long-lived module objects:
- `StorefrontStore` is the storefront workflow facade. It builds hydrate
payloads, validates checkout requests, calls the Rust `store:checkout`
- command, syncs UI patches, and asks related module stores to save hot state.
+ command, syncs UI patches, asks related module stores to save hot state, and
+ spawns purchased units at discovered `unit_spawn` markers after the backend
+ charge succeeds.
- `StoreCatalogService` scans configured item and vehicle categories, builds
catalog responses, resolves checkout entries, and calculates authoritative
- prices.
+ prices. It also applies the optional mission `CfgStore` filter and overrides
+ before payloads or checkout validation use catalog entries.
Editor-placed store entities are initialized by `fnc_initStore` during store
post-init. The initializer matches non-null mission namespace objects whose
variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities.
+## Mission Catalog Filter
+
+The store catalog is generated from loaded Arma config classes, then an
+optional mission `CfgStore` filter can allow or deny classnames per category.
+Include `CfgStore.hpp` from `description.ext`:
+
+```cpp
+#include "CfgStore.hpp"
+```
+
+```cpp
+class CfgStore {
+ mode = "allowlist"; // dynamic, allowlist, or denylist
+
+ class Categories {
+ primary[] = {"arifle_MX_F", "arifle_MXC_F"};
+ cars[] = {"B_MRAP_01_F"};
+ units[] = {"B_Soldier_F"};
+ };
+
+ class Overrides {
+ class arifle_MX_F {
+ price = 2500;
+ displayName = "MX Rifle";
+ description = "Approved PMC service rifle.";
+ };
+ };
+};
+```
+
+`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
+listed for each category. `denylist` removes listed classnames. Overrides are
+server-side and are used by both the UI payload and checkout validation.
+`units[]` follows the same filter behavior and is fulfilled as an immediate
+server-side unit spawn at a discovered `unit_spawn` marker after checkout
+succeeds.
+
+The current filter is global for the mission. Revisit per-store profile support
+if individual vendors need different inventories.
+
+## Unit Spawn Markers
+
+Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
+`unit_spawn_2`, and so on. The store resolves the closest initialized store
+object to the requesting player, scans `allMapMarkers` when checkout fulfillment
+runs, and uses the closest matching marker within 25 meters of that store.
+
+If no matching marker exists within 25 meters, the store falls back to spawning
+units around the store object. If no store object can be resolved, it falls back
+to the requesting player.
+
## Checkout Model
`store:checkout` accepts one JSON context.
@@ -44,6 +98,13 @@ pattern used by garage entities.
"category": "cars",
"priceValue": 1500
}
+ ],
+ "units": [
+ {
+ "classname": "B_Soldier_F",
+ "category": "units",
+ "priceValue": 2500
+ }
]
}
```
@@ -51,12 +112,13 @@ pattern used by garage entities.
Rules validated by the Rust service:
- `requesterUid` is required.
-- At least one item or vehicle is required.
+- At least one item, vehicle, or unit is required.
- The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`.
+- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org
@@ -72,11 +134,12 @@ Rules validated by the Rust service:
```json
{
- "chargedTotal": 2000.0,
+ "chargedTotal": 4500.0,
"paymentMethod": "bank",
- "message": "Checkout completed. $2,000 charged, 1 locker grant(s), 1 vehicle unlock(s).",
+ "message": "Checkout completed. $4,500 charged, 1 locker grant(s), 1 vehicle unlock(s), 1 unit grant(s).",
"lockerGranted": [],
"vehicleGranted": [],
+ "unitGranted": [],
"lockerPatch": {},
"vaPatch": {},
"vgaragePatch": {},
@@ -107,7 +170,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"],
["items", [_item]],
- ["vehicles", []]
+ ["vehicles", []],
+ ["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@@ -132,7 +196,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"],
["items", []],
- ["vehicles", [_vehicle]]
+ ["vehicles", [_vehicle]],
+ ["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
diff --git a/docus/content/3.server-modules/11.task.md b/docus/content/3.server-modules/11.task.md
index 16de70b..f1b662b 100644
--- a/docus/content/3.server-modules/11.task.md
+++ b/docus/content/3.server-modules/11.task.md
@@ -187,19 +187,20 @@ server-side.
The mission setup UI does not enable or disable generated missions. It applies
runtime tuning such as faction, caps, intervals, reward ranges, rating ranges,
-penalties, and time limits. Generator enablement remains controlled by the CBA
-setting above.
+penalties, time limits, and a generator provider preference. Generator
+enablement remains controlled by the CBA setting above.
When `forge_server_task_enableMissionSetup` is enabled, the mission manager
waits for setup settings before starting. There is no timeout auto-apply.
Pressing Cancel, X, or Escape applies default values from CBA, mission
parameters, and `CfgMissions`.
-Planned custom-generator work should add an explicit provider option for
-mission designers or developers who want to select or toggle a custom mission
-generator. That provider option should be separate from the built-in generator
-CBA gate so disabling Forge's built-in generator does not prevent custom
-providers from publishing CAD-visible work.
+The setup UI stores the provider preference in
+`forge_server_task_generatorProvider` as `builtin` or `custom`. CAD/manual
+generated task requests use the task provider registry and route to the selected
+provider. That provider option stays separate from the built-in generator CBA
+gate so disabling Forge's built-in generator does not prevent custom providers
+from publishing CAD-visible work.
## CAD Compatibility
diff --git a/docus/content/3.server-modules/3.cad.md b/docus/content/3.server-modules/3.cad.md
index e5b9c28..7057e65 100644
--- a/docus/content/3.server-modules/3.cad.md
+++ b/docus/content/3.server-modules/3.cad.md
@@ -69,27 +69,27 @@ Common generated IDs:
## Generated Mission Requests
-Dispatchers can request framework-generated mission tasks from the CAD
-dispatcher board. The server hydrates the available generated task types from
-the task mission manager as `generatedTaskTypes`; the client uses that hydrated
-list for the dropdown.
+Dispatchers can request generated mission tasks from the CAD dispatcher board.
+The server hydrates the available generated task types from the selected task
+provider as `generatedTaskTypes`; the client uses that hydrated list for the
+dropdown.
-Generated mission requests are controlled by the server CBA setting
+Built-in generated mission requests are controlled by the server CBA setting
`forge_server_task_enableGenerator`:
-- Enabled: CAD receives the generated task type list and dispatchers can request
- a specific generator type.
-- Disabled: CAD receives an empty generated task type list, the task request UI
- is disabled, and server-side request handling rejects any manual request.
+- Enabled: CAD can receive the built-in generated task type list and dispatchers
+ can request a specific built-in generator type.
+- Disabled: the built-in provider returns no task types and rejects built-in
+ manual requests.
-The framework-owned request entry point is
-`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework
-handler directly; it does not call mission-local generator functions.
+Server CAD routes generated mission requests through the task provider registry.
+The selected provider handles the request and returns the CAD response payload.
-Custom mission generators can still create CAD-visible tasks directly by
-registering task catalog entries and task statuses. See
-[Custom Mission Generators](/getting-started/custom-mission-generators) for the supported
-integration path and the current generated-task provider limitation.
+Custom mission generators can register a provider with the
+`forge_server_task_registerMissionGeneratorProvider` CBA server event or create
+CAD-visible tasks directly by registering task catalog entries and task
+statuses. See [Custom Mission Generators](/getting-started/custom-mission-generators) for
+the supported integration path.
## Submit a Support Request
diff --git a/docus/content/4.client-addons/5.cad.md b/docus/content/4.client-addons/5.cad.md
index 3abdb14..56ed390 100644
--- a/docus/content/4.client-addons/5.cad.md
+++ b/docus/content/4.client-addons/5.cad.md
@@ -103,13 +103,13 @@ The dispatcher-generated task dropdown is hydrated from the server
`generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
-request control, which is how `forge_server_task_enableGenerator = false` is surfaced
-client-side.
+request control. For the built-in provider, this is how
+`forge_server_task_enableGenerator = false` is surfaced client-side.
-Custom mission generators can still publish tasks into CAD by using the server
-task catalog. The generated-task dropdown itself currently needs a framework
-provider extension point before custom providers can replace the built-in list
-cleanly. See [Custom Mission Generators](/getting-started/custom-mission-generators).
+Custom mission generators can publish tasks into CAD by using the server task
+catalog or by registering a task provider that supplies `generatedTaskTypes` and
+handles generated task requests. See
+[Custom Mission Generators](/getting-started/custom-mission-generators).
## Authorization Notes
diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs
index 3d968c1..c9d6765 100644
--- a/lib/models/src/lib.rs
+++ b/lib/models/src/lib.rs
@@ -34,8 +34,8 @@ pub use org::{
};
pub use phone::{PhoneEmail, PhoneMessage, PhonePayload};
pub use store::{
- StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed,
- StoreGrantedItem, StoreGrantedVehicle,
+ StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutUnitSeed,
+ StoreCheckoutVehicleSeed, StoreGrantedItem, StoreGrantedUnit, StoreGrantedVehicle,
};
pub use task::{
TaskJsonMap, TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext,
diff --git a/lib/models/src/store.rs b/lib/models/src/store.rs
index 9665c5c..4b2dc56 100644
--- a/lib/models/src/store.rs
+++ b/lib/models/src/store.rs
@@ -18,6 +18,14 @@ pub struct StoreCheckoutVehicleSeed {
pub price_value: f64,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StoreCheckoutUnitSeed {
+ pub classname: String,
+ pub category: String,
+ pub price_value: f64,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreCheckoutContext {
@@ -30,6 +38,8 @@ pub struct StoreCheckoutContext {
pub items: Vec,
#[serde(default)]
pub vehicles: Vec,
+ #[serde(default)]
+ pub units: Vec,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -47,6 +57,13 @@ pub struct StoreGrantedVehicle {
pub category: String,
}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StoreGrantedUnit {
+ pub classname: String,
+ pub category: String,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreCheckoutResult {
@@ -58,6 +75,8 @@ pub struct StoreCheckoutResult {
#[serde(default)]
pub vehicle_granted: Vec,
#[serde(default)]
+ pub unit_granted: Vec,
+ #[serde(default)]
pub locker_patch: HashMap,
#[serde(default)]
pub va_patch: HashMap,
diff --git a/lib/services/src/store.rs b/lib/services/src/store.rs
index d7656d0..4f17eb3 100644
--- a/lib/services/src/store.rs
+++ b/lib/services/src/store.rs
@@ -1,6 +1,6 @@
use forge_models::{
Bank, BankCheckoutContext, BankMutationResult, EquipmentCategory, HotOrgRecord, Item, Locker,
- OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem,
+ OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem, StoreGrantedUnit,
StoreGrantedVehicle, VGarage, VLocker, VehicleCategory,
};
use forge_repositories::{
@@ -229,7 +229,7 @@ where
if context.requester_uid.trim().is_empty() {
return Err("A valid requester UID is required.".to_string());
}
- if context.items.is_empty() && context.vehicles.is_empty() {
+ if context.items.is_empty() && context.vehicles.is_empty() && context.units.is_empty() {
return Err("Add at least one item before checkout.".to_string());
}
@@ -254,6 +254,7 @@ where
let mut vgarage_patch = HashMap::new();
let mut locker_granted = Vec::new();
let mut vehicle_granted = Vec::new();
+ let mut unit_granted = Vec::new();
let mut va_categories_changed: Vec<&str> = Vec::new();
let mut vgarage_categories_changed: Vec<&str> = Vec::new();
@@ -374,6 +375,22 @@ where
});
}
+ for unit_seed in &context.units {
+ if unit_seed.classname.trim().is_empty() {
+ return Err("Unit checkout entry was missing a classname.".to_string());
+ }
+
+ let unit_category = unit_seed.category.trim().to_ascii_lowercase();
+ if unit_category != "units" && unit_category != "unit" {
+ return Err(format!("Unit category '{}' is unsupported.", unit_category));
+ }
+
+ unit_granted.push(StoreGrantedUnit {
+ classname: unit_seed.classname.clone(),
+ category: "units".to_string(),
+ });
+ }
+
for category in vgarage_categories_changed {
match category {
"cars" => {
@@ -550,13 +567,15 @@ where
charged_total,
payment_method,
message: format!(
- "Checkout completed. {} charged, {} locker grant(s), {} vehicle unlock(s).",
+ "Checkout completed. {} charged, {} locker grant(s), {} vehicle unlock(s), {} unit grant(s).",
format_currency(charged_total),
locker_granted.len(),
- vehicle_granted.len()
+ vehicle_granted.len(),
+ unit_granted.len()
),
locker_granted,
vehicle_granted,
+ unit_granted,
locker_patch,
va_patch,
vgarage_patch,
@@ -578,8 +597,13 @@ fn checkout_total(context: &StoreCheckoutContext) -> f64 {
.iter()
.map(|entry| entry.price_value.max(0.0))
.sum::();
+ let unit_total = context
+ .units
+ .iter()
+ .map(|entry| entry.price_value.max(0.0))
+ .sum::();
- (item_total + vehicle_total).floor()
+ (item_total + vehicle_total + unit_total).floor()
}
fn resolve_locker_category(category: &str) -> Result<&'static str, String> {
diff --git a/tools/sync-docus-docs.mjs b/tools/sync-docus-docs.mjs
index ad21db2..47f1488 100644
--- a/tools/sync-docus-docs.mjs
+++ b/tools/sync-docus-docs.mjs
@@ -460,8 +460,8 @@ npm run build:webui
title: Custom Mission Generators
to: /getting-started/custom-mission-generators
---
- Create CAD-visible custom generated missions and understand the current
- provider extension point.
+ Create CAD-visible custom generated missions and register custom generator
+ providers.
:::
:::u-page-card