Backport framework updates without mission files

This commit is contained in:
Jacob Schmidt 2026-06-03 05:59:56 -05:00
parent d4d1f251c4
commit 6229f56ba4
55 changed files with 1826 additions and 618 deletions

View File

@ -196,7 +196,8 @@ GVAR(MissionSetupRepositoryBaseClass) = compileFinal createHashMapFromArray [
["penaltyMin", ["penaltyMin", -5] call _paramOrDefault],
["penaltyMax", ["penaltyMax", -25] call _paramOrDefault],
["timeLimitMin", ["timeLimitMin", 600] call _paramOrDefault],
["timeLimitMax", ["timeLimitMax", 900] call _paramOrDefault]
["timeLimitMax", ["timeLimitMax", 900] call _paramOrDefault],
["generatorProvider", GETMVAR(forge_server_task_generatorProvider,"builtin")]
]]
]
}]

View File

@ -54,8 +54,8 @@ button {
}
.titlebar {
min-height: 3.25rem;
padding: 0 1.6rem;
min-height: 2.75rem;
padding: 0 1.35rem;
display: flex;
align-items: center;
justify-content: space-between;
@ -98,8 +98,8 @@ option {
.content {
min-height: 0;
padding: 1.5rem;
overflow: auto;
padding: 1rem 1.25rem;
overflow: hidden;
display: flex;
align-items: center;
}
@ -120,27 +120,27 @@ option {
}
.panel-head {
padding: 1.15rem 1.25rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.panel-head h1,
.panel-head h2 {
margin: 0.2rem 0 0;
font-size: 1.45rem;
font-size: 1.18rem;
letter-spacing: 0;
}
.form {
padding: 1.25rem;
padding: 0.9rem 1rem 1rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
gap: 0.68rem;
}
.field {
display: grid;
gap: 0.45rem;
gap: 0.28rem;
}
.wide {
@ -149,7 +149,7 @@ option {
label {
color: var(--text-subtle);
font-size: 0.78rem;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@ -171,11 +171,86 @@ label {
min-height: 1rem;
}
.provider-toggle {
min-height: 2.25rem;
padding: 0 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--border);
background: rgba(24, 31, 40, 0.9);
color: var(--text-main);
}
.provider-toggle input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.switch {
width: 2.6rem;
height: 1.35rem;
position: relative;
border: 1px solid var(--border-strong);
background: rgba(255, 255, 255, 0.07);
}
.switch::after {
content: "";
width: 0.9rem;
height: 0.9rem;
position: absolute;
top: 0.16rem;
left: 0.18rem;
background: rgba(245, 248, 255, 0.86);
transition: transform 120ms ease, background 120ms ease;
}
.provider-toggle input:checked + .switch {
background: rgba(24, 86, 126, 0.95);
border-color: rgba(104, 196, 255, 0.34);
}
.provider-toggle input:checked + .switch::after {
transform: translateX(1.2rem);
background: #ffffff;
}
.provider-toggle:focus-within {
outline: 2px solid rgba(104, 196, 255, 0.34);
outline-offset: 2px;
}
.provider-copy {
min-width: 0;
display: grid;
gap: 0.1rem;
}
.provider-copy strong,
.provider-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-copy strong {
font-size: 0.86rem;
}
.provider-copy small {
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 700;
}
input,
select {
width: 100%;
min-height: 2.65rem;
padding: 0 0.85rem;
min-height: 2.25rem;
padding: 0 0.75rem;
border: 1px solid var(--border);
background: rgba(24, 31, 40, 0.9);
color: var(--text-main);
@ -189,16 +264,16 @@ button:focus-visible {
}
.summary {
padding: 1.25rem;
padding: 0.9rem 1rem 1rem;
display: grid;
gap: 0.8rem;
gap: 0.55rem;
}
.summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.8rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border);
}
@ -219,7 +294,7 @@ button:focus-visible {
}
.actions {
padding: 1rem 1.5rem;
padding: 0.75rem 1.25rem;
display: flex;
justify-content: flex-end;
gap: 0.75rem;
@ -228,8 +303,8 @@ button:focus-visible {
}
.btn {
min-height: 2.75rem;
padding: 0.72rem 1rem;
min-height: 2.25rem;
padding: 0.55rem 0.9rem;
border: 1px solid var(--border-strong);
background: rgba(24, 31, 40, 0.9);
color: var(--text-main);

View File

@ -14,6 +14,7 @@
penaltyMax: -25,
timeLimitMin: 600,
timeLimitMax: 900,
generatorProvider: "builtin",
},
error: "",
};
@ -46,6 +47,7 @@
penaltyMax: fieldNumber("penaltyMax"),
timeLimitMin: fieldNumber("timeLimitMin"),
timeLimitMax: fieldNumber("timeLimitMax"),
generatorProvider: document.getElementById("generatorProviderCustom")?.checked ? "custom" : "builtin",
};
}
@ -101,6 +103,8 @@
const settings = state.settings;
const faction = state.factions.find((item) => item.faction === settings.enemyFaction);
const factionLabel = faction ? faction.display : settings.enemyFaction;
const generatorProviderLabel = settings.generatorProvider === "custom" ? "Custom" : "Built-in";
const generatorProviderChecked = settings.generatorProvider === "custom" ? " checked" : "";
document.getElementById("app").innerHTML = `
<div class="shell">
@ -120,7 +124,7 @@
<h1>Operation Settings</h1>
</div>
<div class="form">
<div class="field">
<div class="field wide">
<label for="enemyFaction">Opposing Faction</label>
<select id="enemyFaction">${state.factions.map(option).join("")}</select>
</div>
@ -128,6 +132,17 @@
<label for="locationReuseCooldown">Location Cooldown</label>
<input id="locationReuseCooldown" type="number" min="0" step="60" value="${settings.locationReuseCooldown}" />
</div>
<div class="field">
<label for="generatorProviderCustom">Mission Generator</label>
<label class="provider-toggle" for="generatorProviderCustom">
<input id="generatorProviderCustom" type="checkbox"${generatorProviderChecked} />
<span class="switch" aria-hidden="true"></span>
<span class="provider-copy">
<strong>${generatorProviderLabel}</strong>
<small>Mission Generators</small>
</span>
</label>
</div>
<div class="field">
<label for="maxConcurrentMissions">Concurrent Missions</label>
<input id="maxConcurrentMissions" type="number" min="1" max="50" value="${settings.maxConcurrentMissions}" />
@ -178,6 +193,7 @@
</div>
<div class="summary">
<div class="summary-row"><span>Faction</span><strong>${escapeHtml(factionLabel)}</strong></div>
<div class="summary-row"><span>Generator</span><strong>${generatorProviderLabel}</strong></div>
<div class="summary-row"><span>Mission Cap</span><strong>${settings.maxConcurrentMissions}</strong></div>
<div class="summary-row"><span>Interval</span><strong>${settings.missionInterval}s</strong></div>
<div class="summary-row"><span>Location Cooldown</span><strong>${settings.locationReuseCooldown}s</strong></div>

File diff suppressed because one or more lines are too long

View File

@ -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(

View File

@ -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(),
},

View File

@ -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: [],
},
};

View File

@ -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",
};
}

View File

@ -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,

View File

@ -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) {

View File

@ -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);

View File

@ -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]];

View File

@ -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]]];

View File

@ -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

View File

@ -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]],

View File

@ -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", ""]];

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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", []]; };

View File

@ -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 {

View File

@ -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 <STRING>
* 1: Request metadata <HASHMAP> (Default: createHashMap)
* 2: Requesting player UID <STRING> (Default: "")
*
* Return Value:
* Request result with success, message, taskID, and taskType keys <HASHMAP>
*
* 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

View File

@ -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 <HASHMAP 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)

View File

@ -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

View File

@ -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 <HASHMAP 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)

View File

@ -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

View File

@ -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)
}]
]];

View File

@ -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 <HASHMAP 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)

View File

@ -19,6 +19,8 @@ pub type SurrealDb = Surreal<Client>;
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<StdRwLock<Option<Arc<SurrealDb>>>> =
LazyLock::new(|| StdRwLock::new(None));
@ -27,6 +29,8 @@ static SURREAL_CONNECTION_STATE: LazyLock<StdRwLock<SurrealConnectionState>> =
static SURREAL_FAILURE_REASON: LazyLock<StdRwLock<Option<String>>> =
LazyLock::new(|| StdRwLock::new(None));
static SURREAL_INIT_GENERATION: AtomicU64 = AtomicU64::new(0);
static SURREAL_INIT_LOCK: LazyLock<tokio::sync::Mutex<()>> =
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<SurrealDb, String> {
Ok(db)
}
async fn connect_with_retries(config: SurrealConfig) -> Result<SurrealDb, String> {
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<Arc<SurrealDb>, 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 {

View File

@ -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"

View File

@ -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:

View File

@ -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"

View File

@ -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"

View File

@ -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%

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]];

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]];

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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<StoreCheckoutItemSeed>,
#[serde(default)]
pub vehicles: Vec<StoreCheckoutVehicleSeed>,
#[serde(default)]
pub units: Vec<StoreCheckoutUnitSeed>,
}
#[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<StoreGrantedVehicle>,
#[serde(default)]
pub unit_granted: Vec<StoreGrantedUnit>,
#[serde(default)]
pub locker_patch: HashMap<String, serde_json::Value>,
#[serde(default)]
pub va_patch: HashMap<String, serde_json::Value>,

View File

@ -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::<f64>();
let unit_total = context
.units
.iter()
.map(|entry| entry.price_value.max(0.0))
.sum::<f64>();
(item_total + vehicle_total).floor()
(item_total + vehicle_total + unit_total).floor()
}
fn resolve_locker_category(category: &str) -> Result<&'static str, String> {

View File

@ -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