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], ["penaltyMin", ["penaltyMin", -5] call _paramOrDefault],
["penaltyMax", ["penaltyMax", -25] call _paramOrDefault], ["penaltyMax", ["penaltyMax", -25] call _paramOrDefault],
["timeLimitMin", ["timeLimitMin", 600] 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 { .titlebar {
min-height: 3.25rem; min-height: 2.75rem;
padding: 0 1.6rem; padding: 0 1.35rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -98,8 +98,8 @@ option {
.content { .content {
min-height: 0; min-height: 0;
padding: 1.5rem; padding: 1rem 1.25rem;
overflow: auto; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
} }
@ -120,27 +120,27 @@ option {
} }
.panel-head { .panel-head {
padding: 1.15rem 1.25rem; padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.panel-head h1, .panel-head h1,
.panel-head h2 { .panel-head h2 {
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
font-size: 1.45rem; font-size: 1.18rem;
letter-spacing: 0; letter-spacing: 0;
} }
.form { .form {
padding: 1.25rem; padding: 0.9rem 1rem 1rem;
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 1rem; gap: 0.68rem;
} }
.field { .field {
display: grid; display: grid;
gap: 0.45rem; gap: 0.28rem;
} }
.wide { .wide {
@ -149,7 +149,7 @@ option {
label { label {
color: var(--text-subtle); color: var(--text-subtle);
font-size: 0.78rem; font-size: 0.68rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@ -171,11 +171,86 @@ label {
min-height: 1rem; 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, input,
select { select {
width: 100%; width: 100%;
min-height: 2.65rem; min-height: 2.25rem;
padding: 0 0.85rem; padding: 0 0.75rem;
border: 1px solid var(--border); border: 1px solid var(--border);
background: rgba(24, 31, 40, 0.9); background: rgba(24, 31, 40, 0.9);
color: var(--text-main); color: var(--text-main);
@ -189,16 +264,16 @@ button:focus-visible {
} }
.summary { .summary {
padding: 1.25rem; padding: 0.9rem 1rem 1rem;
display: grid; display: grid;
gap: 0.8rem; gap: 0.55rem;
} }
.summary-row { .summary-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
padding-bottom: 0.8rem; padding-bottom: 0.55rem;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
@ -219,7 +294,7 @@ button:focus-visible {
} }
.actions { .actions {
padding: 1rem 1.5rem; padding: 0.75rem 1.25rem;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
@ -228,8 +303,8 @@ button:focus-visible {
} }
.btn { .btn {
min-height: 2.75rem; min-height: 2.25rem;
padding: 0.72rem 1rem; padding: 0.55rem 0.9rem;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
background: rgba(24, 31, 40, 0.9); background: rgba(24, 31, 40, 0.9);
color: var(--text-main); color: var(--text-main);

View File

@ -14,6 +14,7 @@
penaltyMax: -25, penaltyMax: -25,
timeLimitMin: 600, timeLimitMin: 600,
timeLimitMax: 900, timeLimitMax: 900,
generatorProvider: "builtin",
}, },
error: "", error: "",
}; };
@ -46,6 +47,7 @@
penaltyMax: fieldNumber("penaltyMax"), penaltyMax: fieldNumber("penaltyMax"),
timeLimitMin: fieldNumber("timeLimitMin"), timeLimitMin: fieldNumber("timeLimitMin"),
timeLimitMax: fieldNumber("timeLimitMax"), timeLimitMax: fieldNumber("timeLimitMax"),
generatorProvider: document.getElementById("generatorProviderCustom")?.checked ? "custom" : "builtin",
}; };
} }
@ -101,6 +103,8 @@
const settings = state.settings; const settings = state.settings;
const faction = state.factions.find((item) => item.faction === settings.enemyFaction); const faction = state.factions.find((item) => item.faction === settings.enemyFaction);
const factionLabel = faction ? faction.display : 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 = ` document.getElementById("app").innerHTML = `
<div class="shell"> <div class="shell">
@ -120,7 +124,7 @@
<h1>Operation Settings</h1> <h1>Operation Settings</h1>
</div> </div>
<div class="form"> <div class="form">
<div class="field"> <div class="field wide">
<label for="enemyFaction">Opposing Faction</label> <label for="enemyFaction">Opposing Faction</label>
<select id="enemyFaction">${state.factions.map(option).join("")}</select> <select id="enemyFaction">${state.factions.map(option).join("")}</select>
</div> </div>
@ -128,6 +132,17 @@
<label for="locationReuseCooldown">Location Cooldown</label> <label for="locationReuseCooldown">Location Cooldown</label>
<input id="locationReuseCooldown" type="number" min="0" step="60" value="${settings.locationReuseCooldown}" /> <input id="locationReuseCooldown" type="number" min="0" step="60" value="${settings.locationReuseCooldown}" />
</div> </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"> <div class="field">
<label for="maxConcurrentMissions">Concurrent Missions</label> <label for="maxConcurrentMissions">Concurrent Missions</label>
<input id="maxConcurrentMissions" type="number" min="1" max="50" value="${settings.maxConcurrentMissions}" /> <input id="maxConcurrentMissions" type="number" min="1" max="50" value="${settings.maxConcurrentMissions}" />
@ -178,6 +193,7 @@
</div> </div>
<div class="summary"> <div class="summary">
<div class="summary-row"><span>Faction</span><strong>${escapeHtml(factionLabel)}</strong></div> <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>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>Interval</span><strong>${settings.missionInterval}s</strong></div>
<div class="summary-row"><span>Location Cooldown</span><strong>${settings.locationReuseCooldown}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" }, { className: "filter-placeholder" },
selectedPaymentSource selectedPaymentSource
? selectedPaymentSource.label ? selectedPaymentSource.label
: "Cash", : "Select Payment",
), ),
), ),
), ),
@ -645,7 +645,7 @@ ${scopeSelector} .store-toast.is-error {
h( h(
"span", "span",
{ className: "footer-copy" }, { 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( h(

View File

@ -300,15 +300,13 @@ ${scopeSelector} .cart-empty {
getters.getPaymentSourceById( getters.getPaymentSourceById(
storeConfig, storeConfig,
state.selectedPaymentSource, state.selectedPaymentSource,
) || ) || null;
paymentSources[0] ||
null;
const availablePaymentSourceCount = paymentSources.filter( const availablePaymentSourceCount = paymentSources.filter(
(source) => source.enabled !== false, (source) => source.enabled !== false,
).length; ).length;
const selectedPaymentLabel = selectedPaymentSource const selectedPaymentLabel = selectedPaymentSource
? selectedPaymentSource.label ? selectedPaymentSource.label
: "Unavailable"; : "Select Payment";
const selectedPaymentBalance = selectedPaymentSource const selectedPaymentBalance = selectedPaymentSource
? Number(selectedPaymentSource.balance || 0) ? Number(selectedPaymentSource.balance || 0)
: 0; : 0;
@ -392,12 +390,20 @@ ${scopeSelector} .cart-empty {
"select", "select",
{ {
className: "payment-source-select", className: "payment-source-select",
value: state.selectedPaymentSource, value: state.selectedPaymentSource || "",
onChange: (event) => onChange: (event) =>
actions.selectPaymentSource( actions.selectPaymentSource(
event.target.value, event.target.value,
), ),
}, },
h(
"option",
{
value: "",
disabled: true,
},
"Select Payment",
),
paymentSources.map((source) => paymentSources.map((source) =>
h( h(
"option", "option",
@ -467,7 +473,28 @@ ${scopeSelector} .cart-empty {
: "Unavailable", : "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( h(
@ -630,6 +657,7 @@ ${scopeSelector} .cart-empty {
className: "store-btn store-btn-primary", className: "store-btn store-btn-primary",
disabled: disabled:
summary.lineCount === 0 || summary.lineCount === 0 ||
!selectedPaymentSource ||
state.isCheckingOut, state.isCheckingOut,
onClick: () => actions.requestCheckout(), onClick: () => actions.requestCheckout(),
}, },

View File

@ -81,6 +81,7 @@
{ id: "ammo", label: "Ammo" }, { id: "ammo", label: "Ammo" },
{ id: "misc", label: "Misc" }, { id: "misc", label: "Misc" },
{ id: "vehicles", label: "Vehicles" }, { id: "vehicles", label: "Vehicles" },
{ id: "units", label: "Units" },
], ],
vehicleCards: [ vehicleCards: [
{ id: "cars", label: "Cars" }, { id: "cars", label: "Cars" },
@ -113,6 +114,7 @@
planes: [], planes: [],
naval: [], naval: [],
other: [], other: [],
units: [],
}, },
}; };

View File

@ -112,8 +112,8 @@
return { return {
eyebrow: "Supply Categories", eyebrow: "Supply Categories",
title: "Procurement Dashboard", 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.", 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: "8 Categories", badge: "11 Categories",
}; };
} }

View File

@ -36,6 +36,7 @@
const payload = { const payload = {
items: [], items: [],
vehicles: [], vehicles: [],
units: [],
totalPrice, totalPrice,
paymentMethod, paymentMethod,
}; };
@ -57,6 +58,20 @@
return; 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({ payload.items.push({
classname: normalizedItem.classname, classname: normalizedItem.classname,
category: normalizedItem.category, category: normalizedItem.category,

View File

@ -58,7 +58,7 @@
[this.getIsCheckingOut, this.setIsCheckingOut] = [this.getIsCheckingOut, this.setIsCheckingOut] =
createSignal(false); createSignal(false);
[this.getSelectedPaymentSource, this.setSelectedPaymentSource] = [this.getSelectedPaymentSource, this.setSelectedPaymentSource] =
createSignal("cash"); createSignal("");
} }
resetToCategories() { resetToCategories() {
@ -191,23 +191,9 @@
const currentSource = String( const currentSource = String(
this.getSelectedPaymentSource() || "", this.getSelectedPaymentSource() || "",
).trim(); ).trim();
const defaultSource = String(
storeConfig?.defaultPaymentSource || "",
).trim();
const sourceIds = paymentSources.map((source) => const sourceIds = paymentSources.map((source) =>
String(source?.id || "").trim(), 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 ( if (
currentSource && currentSource &&
@ -221,19 +207,7 @@
return; return;
} }
if (defaultAvailable && defaultAvailable.enabled !== false) { this.setSelectedPaymentSource("");
this.setSelectedPaymentSource(defaultSource);
return;
}
if (enabledSource) {
this.setSelectedPaymentSource(
String(enabledSource.id || "cash"),
);
return;
}
this.setSelectedPaymentSource(defaultSource || "cash");
} }
navigateToBreadcrumb(target) { navigateToBreadcrumb(target) {

View File

@ -91,12 +91,12 @@ call FUNC(registerEventListeners);
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent); [CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
}; };
if (isNil QEFUNC(task,requestMissionTask)) exitWith { if (isNil QEGVAR(task,MissionGeneratorProviderRegistry)) exitWith {
_result set ["message", "Framework generated mission requests are unavailable."]; _result set ["message", "Generated mission provider registry is unavailable."];
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent); [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 { if !(_result getOrDefault ["success", false]) exitWith {
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent); [CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);

View File

@ -300,31 +300,10 @@ GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [
private _permissionService = _self get "PermissionService"; private _permissionService = _self get "PermissionService";
private _groupRepository = _self get "GroupRepository"; private _groupRepository = _self get "GroupRepository";
private _generatedTaskTypes = []; private _generatedTaskTypes = [];
if (missionNamespace getVariable [QEGVAR(task,enableGenerator), false]) then { if !(isNil QEGVAR(task,MissionGeneratorProviderRegistry)) then {
_generatedTaskTypes = [ _generatedTaskTypes = EGVAR(task,MissionGeneratorProviderRegistry) call ["getGeneratedTaskTypes", []];
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);
};
} else { } 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]]; private _groupID = _groupRepository call ["getPlayerGroupId", [_uid]];

View File

@ -6,8 +6,6 @@ PREP_RECOMPILE_END;
GVAR(PlayerBootstrapRegistry) = createHashMap; GVAR(PlayerBootstrapRegistry) = createHashMap;
if (isServer) then { "forge_server" callExtension ["surreal:reconnect", []]; };
["forge_icom_event", { ["forge_icom_event", {
params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]]]; 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_initStore.sqf` marks editor-placed store objects with `isStore = true`.
- `fnc_initCatalogService.sqf` scans live Arma config categories, builds - `fnc_initCatalogService.sqf` scans live Arma config categories, builds
catalog responses, resolves checkout entries, and calculates authoritative 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 - `fnc_initStorefrontStore.sqf` builds hydrate payloads, validates checkout
requests, calls `store:checkout`, syncs client patches, and coordinates 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 ## Editor Entities
`fnc_initStore` matches non-null mission namespace objects whose variable names `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 ## Checkout Flow
Store checkout can charge cash, bank balance, organization funds, or approved Store checkout can charge cash, bank balance, organization funds, or approved
credit lines depending on the hydrated session context. Checkout results can credit lines depending on the hydrated session context. Checkout results can
grant locker assets, organization assets, and fleet vehicles through the grant locker assets, organization assets, fleet vehicles, and immediate unit
related domain stores. spawns through the related domain stores and Arma server runtime.
Checkout results emit notifications and syncs through the event bus: Checkout results emit notifications and syncs through the event bus:
- `notification.requested` - receipt and transaction alerts - `notification.requested` - receipt and transaction alerts

View File

@ -17,6 +17,99 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_self set ["catalogCache", createHashMap]; _self set ["catalogCache", createHashMap];
["INFO", "Store catalog service initialized!"] call EFUNC(common,log); ["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 { ["formatCurrency", compileFinal {
params [["_amount", 0, [0]]]; params [["_amount", 0, [0]]];
@ -196,6 +289,22 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_items _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 { ["isBackpackConfig", compileFinal {
params [["_cfg", configNull, [configNull]]]; 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 "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 "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 "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": { case "other": {
{ {
private _cfg = _x; private _cfg = _x;
@ -305,10 +415,16 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
(toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"] (toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"]
}], }],
["isUnitCategory", compileFinal {
params [["_category", "", [""]]];
(toLowerANSI _category) isEqualTo "units"
}],
["buildPayloadCategory", compileFinal { ["buildPayloadCategory", compileFinal {
params [["_category", "", [""]]]; params [["_category", "", [""]]];
switch (toLowerANSI _category) do { switch (toLowerANSI _category) do {
case "units": { "units" };
case "backpacks": { "backpack" }; case "backpacks": { "backpack" };
case "attachments": { "attachment" }; case "attachments": { "attachment" };
case "ammo": { "magazine" }; case "ammo": { "magazine" };
@ -327,7 +443,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["isSupportedCategory", compileFinal { ["isSupportedCategory", compileFinal {
params [["_category", "", [""]]]; 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 { ["buildCategoryItems", compileFinal {
params [["_category", "", [""]]]; params [["_category", "", [""]]];
@ -340,13 +456,17 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _items = _self call ["scanCategoryItems", [_categoryKey]]; private _items = _self call ["scanCategoryItems", [_categoryKey]];
private _payloadCategory = _self call ["buildPayloadCategory", [_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 ["category", _payloadCategory];
_x set ["entryKind", _entryKind]; _x set ["entryKind", _entryKind];
} forEach _items; } forEach _items;
_items = _self call ["applyMissionStoreFilter", [_categoryKey, _items]];
_catalogCache set [_categoryKey, _items]; _catalogCache set [_categoryKey, _items];
_self set ["catalogCache", _catalogCache]; _self set ["catalogCache", _catalogCache];
@ -376,6 +496,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _category = toLowerANSI (_entry getOrDefault ["category", ""]); private _category = toLowerANSI (_entry getOrDefault ["category", ""]);
if (_entryKind isEqualTo "vehicle") exitWith { ["cars", "armor", "helis", "planes", "naval", "other"] }; 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 "weapon") exitWith { ["primary", "handgun", "secondary"] };
if (_category isEqualTo "backpack") exitWith { ["backpacks"] }; if (_category isEqualTo "backpack") exitWith { ["backpacks"] };
if (_category isEqualTo "attachment") exitWith { ["attachments"] }; if (_category isEqualTo "attachment") exitWith { ["attachments"] };
@ -400,19 +521,21 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_resolved _resolved
}], }],
["buildCheckoutRequest", compileFinal { ["buildCheckoutRequest", compileFinal {
params [["_items", [], [[]]], ["_vehicles", [], [[]]]]; params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
private _result = createHashMapFromArray [ private _result = createHashMapFromArray [
["success", false], ["success", false],
["total", 0], ["total", 0],
["message", "Checkout total must be greater than zero."], ["message", "Checkout total must be greater than zero."],
["items", []], ["items", []],
["vehicles", []] ["vehicles", []],
["units", []]
]; ];
private _total = 0; private _total = 0;
private _message = ""; private _message = "";
private _resolvedItems = []; private _resolvedItems = [];
private _resolvedVehicles = []; private _resolvedVehicles = [];
private _resolvedUnits = [];
{ {
if (_message isEqualTo "") then { if (_message isEqualTo "") then {
@ -463,6 +586,29 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
}; };
} forEach _vehicles; } 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 { if (_message isNotEqualTo "") exitWith {
_result set ["message", _message]; _result set ["message", _message];
_result _result
@ -475,12 +621,13 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_result set ["message", ""]; _result set ["message", ""];
_result set ["items", _resolvedItems]; _result set ["items", _resolvedItems];
_result set ["vehicles", _resolvedVehicles]; _result set ["vehicles", _resolvedVehicles];
_result set ["units", _resolvedUnits];
_result _result
}], }],
["calculateCheckoutTotal", compileFinal { ["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 [ createHashMapFromArray [
["success", _checkout getOrDefault ["success", false]], ["success", _checkout getOrDefault ["success", false]],
["total", _checkout getOrDefault ["total", 0]], ["total", _checkout getOrDefault ["total", 0]],

View File

@ -155,6 +155,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["chargedTotal", 0], ["chargedTotal", 0],
["lockerGranted", []], ["lockerGranted", []],
["vehicleGranted", []], ["vehicleGranted", []],
["unitGranted", []],
["bankPatch", createHashMap], ["bankPatch", createHashMap],
["orgPatch", createHashMap], ["orgPatch", createHashMap],
["orgTargetUids", []], ["orgTargetUids", []],
@ -168,6 +169,71 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)] 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 { ["callCheckoutBackendEnvelope", compileFinal {
params [["_context", createHashMap, [createHashMap]]]; params [["_context", createHashMap, [createHashMap]]];
@ -207,7 +273,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["_player", objNull, [objNull]], ["_player", objNull, [objNull]],
["_paymentMethod", "cash", [""]], ["_paymentMethod", "cash", [""]],
["_items", [], [[]]], ["_items", [], [[]]],
["_vehicles", [], [[]]] ["_vehicles", [], [[]]],
["_units", [], [[]]]
]; ];
if (_uid isEqualTo "" || { isNull _player }) exitWith { createHashMap }; if (_uid isEqualTo "" || { isNull _player }) exitWith { createHashMap };
@ -225,9 +292,58 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo], ["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo],
["paymentMethod", toLowerANSI _paymentMethod], ["paymentMethod", toLowerANSI _paymentMethod],
["items", _items], ["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 { ["syncCheckoutResult", compileFinal {
params [["_player", objNull, [objNull]], ["_result", createHashMap, [createHashMap]]]; params [["_player", objNull, [objNull]], ["_result", createHashMap, [createHashMap]]];
@ -238,6 +354,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _vgPatch = _result getOrDefault ["vgaragePatch", createHashMap]; private _vgPatch = _result getOrDefault ["vgaragePatch", createHashMap];
private _bankPatch = _result getOrDefault ["bankPatch", createHashMap]; private _bankPatch = _result getOrDefault ["bankPatch", createHashMap];
private _orgPatch = _result getOrDefault ["orgPatch", createHashMap]; private _orgPatch = _result getOrDefault ["orgPatch", createHashMap];
private _unitGranted = _result getOrDefault ["unitGranted", []];
private _uid = getPlayerUID _player; private _uid = getPlayerUID _player;
if (keys _lockerPatch isNotEqualTo []) then { 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 true
}], }],
["persistCheckoutState", compileFinal { ["persistCheckoutState", compileFinal {
@ -398,19 +523,20 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _paymentMethod = toLowerANSI (_payload getOrDefault ["paymentMethod", "cash"]); private _paymentMethod = toLowerANSI (_payload getOrDefault ["paymentMethod", "cash"]);
private _items = _payload getOrDefault ["items", []]; private _items = _payload getOrDefault ["items", []];
private _vehicles = _payload getOrDefault ["vehicles", []]; private _vehicles = _payload getOrDefault ["vehicles", []];
private _units = _payload getOrDefault ["units", []];
if (isNil QGVAR(StoreCatalogService)) exitWith { if (isNil QGVAR(StoreCatalogService)) exitWith {
_result set ["message", "Store catalog service is unavailable."]; _result set ["message", "Store catalog service is unavailable."];
_result _result
}; };
private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles]]; private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles, _units]];
private _totalPrice = _checkoutRequest getOrDefault ["total", 0]; private _totalPrice = _checkoutRequest getOrDefault ["total", 0];
_result set ["paymentMethod", _paymentMethod]; _result set ["paymentMethod", _paymentMethod];
_result set ["chargedTotal", _totalPrice]; _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 set ["message", "Add at least one item before checkout."];
_result _result
}; };
@ -425,7 +551,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_player, _player,
_paymentMethod, _paymentMethod,
_checkoutRequest getOrDefault ["items", []], _checkoutRequest getOrDefault ["items", []],
_checkoutRequest getOrDefault ["vehicles", []] _checkoutRequest getOrDefault ["vehicles", []],
_checkoutRequest getOrDefault ["units", []]
]]; ]];
if (_checkoutContext isEqualTo createHashMap) exitWith { if (_checkoutContext isEqualTo createHashMap) exitWith {
_result set ["message", "Checkout request context was invalid."]; _result set ["message", "Checkout request context was invalid."];
@ -451,13 +578,15 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_result set ["success", true]; _result set ["success", true];
_result set ["message", _backendResult getOrDefault ["message", format [ _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]], _self call ["formatCurrency", [_totalPrice]],
count (_backendResult getOrDefault ["lockerGranted", []]), count (_backendResult getOrDefault ["lockerGranted", []]),
count (_backendResult getOrDefault ["vehicleGranted", []]) count (_backendResult getOrDefault ["vehicleGranted", []]),
count (_backendResult getOrDefault ["unitGranted", []])
]]]; ]]];
_result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]]; _result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]];
_result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]]; _result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]];
_result set ["unitGranted", _backendResult getOrDefault ["unitGranted", []]];
_result set ["persistenceSucceeded", _persistenceResult getOrDefault ["success", false]]; _result set ["persistenceSucceeded", _persistenceResult getOrDefault ["success", false]];
_result set ["persistenceFailures", _persistenceResult getOrDefault ["failures", []]]; _result set ["persistenceFailures", _persistenceResult getOrDefault ["failures", []]];
_result set ["persistenceMessage", _persistenceResult getOrDefault ["message", ""]]; _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 - compiles functions
- initializes `TaskStore` - initializes `TaskStore`
- initializes task instance and entity controller classes - 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` - `XEH_postInit.sqf`
- registers task lifecycle event listeners with the event bus - registers CBA server events for provider registration and mission setup requests
- handles task reward, notification, and rating events
- syncs org and bank state through event bus listeners
- registers the ACE defuse event hook - registers the ACE defuse event hook
## Events Emitted ## Events Emitted
@ -246,7 +246,8 @@ Task module emits the following events to the event bus:
## Notes ## 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 - 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 - 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 lifecycle for the mission manager is tracked through `TaskStore` status entries
- task backend state is intentionally transient and resets with the active server/mission lifecycle - task backend state is intentionally transient and resets with the active server/mission lifecycle

View File

@ -15,7 +15,6 @@ PREP(makeObject);
PREP(makeShooter); PREP(makeShooter);
PREP(makeTarget); PREP(makeTarget);
PREP(missionManager); PREP(missionManager);
PREP(requestMissionTask);
PREP(initTaskStore); PREP(initTaskStore);
PREP_SUBDIR(generators,attackMissionGenerator); PREP_SUBDIR(generators,attackMissionGenerator);
@ -56,6 +55,9 @@ PREP_SUBDIR(objects,TaskCatalogStore);
PREP_SUBDIR(objects,TaskEntityRegistry); PREP_SUBDIR(objects,TaskEntityRegistry);
PREP_SUBDIR(objects,TaskParticipantTracker); PREP_SUBDIR(objects,TaskParticipantTracker);
PREP_SUBDIR(objects,TaskRewardService); PREP_SUBDIR(objects,TaskRewardService);
PREP_SUBDIR(objects,TaskNotificationService);
PREP_SUBDIR(objects,MissionGeneratorProviderRegistry);
PREP_SUBDIR(objects,BuiltinMissionGeneratorProvider);
PREP_SUBDIR(objects,EntityControllerBaseClass); PREP_SUBDIR(objects,EntityControllerBaseClass);
PREP_SUBDIR(objects,AttackTaskBaseClass); PREP_SUBDIR(objects,AttackTaskBaseClass);
PREP_SUBDIR(objects,HostageTaskBaseClass); PREP_SUBDIR(objects,HostageTaskBaseClass);

View File

@ -3,6 +3,15 @@
if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); true }; if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); true };
if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService); }; 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), { [SRPC(task,requestOpenMissionSetup), {
params [ params [
["_requester", objNull, [objNull]] ["_requester", objNull, [objNull]]
@ -100,116 +109,10 @@ if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService);
]] call EFUNC(common,log); ]] 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); ["INFO", format ["Mission setup apply request accepted. Requester=%1 VarName=%2", _requester, _requesterVar]] call EFUNC(common,log);
GVAR(MissionSetupService) call ["apply", [_overrides]]; GVAR(MissionSetupService) call ["apply", [_overrides]];
}] call CFUNC(addEventHandler); }] 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", { ["ace_explosives_defuse", {
private _taskID = ""; private _taskID = "";
private _explosive = objNull; private _explosive = objNull;

View File

@ -8,30 +8,37 @@ private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)];
#include "initSettings.inc.sqf" #include "initSettings.inc.sqf"
[] call FUNC(TaskStateGateway); call FUNC(TaskStateGateway);
[] call FUNC(TaskLifecycleReporter); call FUNC(TaskLifecycleReporter);
[] call FUNC(TaskCatalogStore); call FUNC(TaskCatalogStore);
[] call FUNC(TaskEntityRegistry); call FUNC(TaskEntityRegistry);
[] call FUNC(TaskParticipantTracker); call FUNC(TaskParticipantTracker);
[] call FUNC(TaskRewardService); call FUNC(TaskRewardService);
[] call FUNC(TaskInstanceBaseClass); call FUNC(TaskNotificationService);
[] call FUNC(EntityControllerBaseClass); call FUNC(MissionGeneratorProviderRegistry);
[] call FUNC(AttackTaskBaseClass); call FUNC(BuiltinMissionGeneratorProvider);
[] call FUNC(HostageTaskBaseClass); call FUNC(TaskInstanceBaseClass);
[] call FUNC(HostageEntityController); call FUNC(EntityControllerBaseClass);
[] call FUNC(TargetEntityController); call FUNC(AttackTaskBaseClass);
[] call FUNC(ShooterEntityController); call FUNC(HostageTaskBaseClass);
[] call FUNC(HVTEntityController); call FUNC(HostageEntityController);
[] call FUNC(CargoEntityController); call FUNC(TargetEntityController);
[] call FUNC(ProtectedEntityController); call FUNC(ShooterEntityController);
[] call FUNC(IEDEntityController); call FUNC(HVTEntityController);
[] call FUNC(DefenseEnemyController); call FUNC(CargoEntityController);
[] call FUNC(DefuseTaskBaseClass); call FUNC(ProtectedEntityController);
[] call FUNC(DestroyTaskBaseClass); call FUNC(IEDEntityController);
[] call FUNC(DeliveryTaskBaseClass); call FUNC(DefenseEnemyController);
[] call FUNC(HVTTaskBaseClass); call FUNC(DefuseTaskBaseClass);
[] call FUNC(DefendTaskBaseClass); call FUNC(DestroyTaskBaseClass);
call FUNC(DeliveryTaskBaseClass);
call FUNC(HVTTaskBaseClass);
call FUNC(DefendTaskBaseClass);
call FUNC(initTaskStore); call FUNC(initTaskStore);
call FUNC(initMissionSetupService); 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", []]; }; 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 _penMax = [["penaltyMax", -25, _overrides] call _paramOrDefault, -25] call (_self get "numberOrDefault");
private _timeMin = [["timeLimitMin", 600, _overrides] call _paramOrDefault, 600] 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 _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 [ private _enemyFaction = _overrides getOrDefault [
"enemyFaction", "enemyFaction",
@ -141,11 +145,13 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
["penaltyMax", _penMax], ["penaltyMax", _penMax],
["timeLimitMin", _timeMin], ["timeLimitMin", _timeMin],
["timeLimitMax", _timeMax], ["timeLimitMax", _timeMax],
["enemyFaction", _enemyFaction] ["enemyFaction", _enemyFaction],
["generatorProvider", _generatorProvider]
]; ];
SETMPVAR(GVAR(missionSetup_settings),_settings); SETMPVAR(GVAR(missionSetup_settings),_settings);
SETMPVAR(GVAR(missionSetup_settingsApplied),true); SETMPVAR(GVAR(missionSetup_settingsApplied),true);
SETMPVAR(GVAR(generatorProvider),_generatorProvider);
private _side = _self call ["resolveFactionSide", [_enemyFaction, east]]; private _side = _self call ["resolveFactionSide", [_enemyFaction, east]];
ENEMY_SIDE = _side; ENEMY_SIDE = _side;
@ -153,11 +159,12 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
publicVariable "ENEMY_SIDE"; publicVariable "ENEMY_SIDE";
["INFO", format [ ["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, _enemyFaction,
_side, _side,
_maxConcurrent, _maxConcurrent,
_interval _interval,
_generatorProvider
]] call EFUNC(common,log); ]] call EFUNC(common,log);
if !(isNil QEGVAR(common,EventBus)) then { 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", []]; private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false }; if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap]; private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry set [_registryKey, _self]; _registry set [_registryKey, _self];
missionNamespace setVariable [QGVAR(ObjectControllerInstances), _registry]; SETMVAR(GVAR(ObjectControllerInstances),_registry);
missionNamespace setVariable [_registryKey, _self]; missionNamespace setVariable [_registryKey, _self];
true true
}], }],
@ -115,7 +115,7 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []]; private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false }; if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap]; private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry deleteAt _registryKey; _registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil]; missionNamespace setVariable [_registryKey, nil];
true 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", []]; private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false }; if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap]; private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry set [_registryKey, _self]; _registry set [_registryKey, _self];
missionNamespace setVariable [QGVAR(ObjectTaskInstances), _registry]; SETMVAR(GVAR(ObjectTaskInstances),_registry);
missionNamespace setVariable [_registryKey, _self]; missionNamespace setVariable [_registryKey, _self];
true true
}], }],
@ -103,7 +103,7 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []]; private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false }; if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap]; private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry deleteAt _registryKey; _registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil]; missionNamespace setVariable [_registryKey, nil];
true true

View File

@ -122,6 +122,53 @@ GVAR(TaskLifecycleReporter) = createHashMapObject [[
_self call ["buildTaskLifecycleEventPayload", [_taskID, _status, _extra]], _self call ["buildTaskLifecycleEventPayload", [_taskID, _status, _extra]],
createHashMapFromArray [["source", "task"]] 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_TIMEOUT: Duration = Duration::from_secs(30);
const CLIENT_READY_POLL_INTERVAL: Duration = Duration::from_millis(25); 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>>>> = static SURREAL_DB: LazyLock<StdRwLock<Option<Arc<SurrealDb>>>> =
LazyLock::new(|| StdRwLock::new(None)); LazyLock::new(|| StdRwLock::new(None));
@ -27,6 +29,8 @@ static SURREAL_CONNECTION_STATE: LazyLock<StdRwLock<SurrealConnectionState>> =
static SURREAL_FAILURE_REASON: LazyLock<StdRwLock<Option<String>>> = static SURREAL_FAILURE_REASON: LazyLock<StdRwLock<Option<String>>> =
LazyLock::new(|| StdRwLock::new(None)); LazyLock::new(|| StdRwLock::new(None));
static SURREAL_INIT_GENERATION: AtomicU64 = AtomicU64::new(0); 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)] #[derive(Clone, Copy, PartialEq, Eq)]
enum SurrealConnectionState { enum SurrealConnectionState {
@ -42,6 +46,7 @@ pub fn prepare() {
} }
pub async fn initialize(config: SurrealConfig) { 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; let generation = SURREAL_INIT_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
prepare(); 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 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 { let db = match connection {
Err(_) => { Err(_) => {
@ -98,7 +103,7 @@ pub async fn initialize(config: SurrealConfig) {
} }
log::log("surreal", "DEBUG", "Applying SurrealDB schemas"); 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) { if !is_current_generation(generation) {
return; return;
} }
@ -159,6 +164,70 @@ async fn connect(config: SurrealConfig) -> Result<SurrealDb, String> {
Ok(db) 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> { pub async fn client() -> Result<Arc<SurrealDb>, String> {
if let Some(db) = SURREAL_DB.read().unwrap().clone() { if let Some(db) = SURREAL_DB.read().unwrap().clone() {
return Ok(db); return Ok(db);
@ -203,6 +272,10 @@ pub fn status() -> String {
} }
pub fn reconnect() -> 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(); let surreal_config = config::load().surreal.clone();
prepare(); prepare();
RUNTIME.spawn(async move { RUNTIME.spawn(async move {

View File

@ -1,3 +1,8 @@
@echo off @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" call "%~dp0RunMe.bat"

View File

@ -10,12 +10,36 @@ firewall, TLS, backup, and upgrade policy before exposing the database.
## Windows ## Windows
Install or update SurrealDB: Install or update SurrealDB to the newest compatible SurrealDB 3.x release:
```bat ```bat
UpdateMe.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 If this is the first install and the terminal cannot find `surreal` after the
script finishes, open a new terminal so Windows reloads `PATH`. script finishes, open a new terminal so Windows reloads `PATH`.
@ -25,12 +49,21 @@ Start Forge's local database:
RunMe.bat RunMe.bat
``` ```
Or start it directly with PowerShell:
```powershell
.\RunSurrealDB.ps1
```
Install and start in one step: Install and start in one step:
```bat ```bat
AllInOne.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 ## Linux or macOS
Install SurrealDB: Install SurrealDB:

View File

@ -1,3 +1,2 @@
@echo off @echo off
cd /d "%~dp0" powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0RunSurrealDB.ps1"
surreal start --user root --pass root --bind 127.0.0.1:8000 rocksdb://forge.db

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 @echo off
where surreal >nul 2>nul setlocal EnableExtensions
if %errorlevel% equ 0 ( set "DEFAULT_SURREALDB_VERSION=3"
surreal upgrade set "TARGET_SURREALDB_VERSION=%~1"
surreal version
) else ( if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%FORGE_SURREALDB_VERSION%"
powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr https://windows.surrealdb.com -useb | iex" if not defined TARGET_SURREALDB_VERSION set "TARGET_SURREALDB_VERSION=%DEFAULT_SURREALDB_VERSION%"
where surreal >nul 2>nul
if %errorlevel% equ 0 ( powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0UpdateSurrealDB.ps1" -Version "%TARGET_SURREALDB_VERSION%"
surreal version exit /b %errorlevel%
) else (
echo SurrealDB install finished. Open a new terminal if the surreal command is not available yet.
)
)

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 ## Generated Mission Requests
Dispatchers can request framework-generated mission tasks from the CAD Dispatchers can request generated mission tasks from the CAD dispatcher board.
dispatcher board. The server hydrates the available generated task types from The server hydrates the available generated task types from the selected task
the task mission manager as `generatedTaskTypes`; the client uses that hydrated provider as `generatedTaskTypes`; the client uses that hydrated list for the
list for the dropdown. 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`: `forge_server_task_enableGenerator`:
- Enabled: CAD receives the generated task type list and dispatchers can request - Enabled: CAD can receive the built-in generated task type list and dispatchers
a specific generator type. can request a specific built-in generator type.
- Disabled: CAD receives an empty generated task type list, the task request UI - Disabled: the built-in provider returns no task types and rejects built-in
is disabled, and server-side request handling rejects any manual request. manual requests.
The framework-owned request entry point is Server CAD routes generated mission requests through the task provider registry.
`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework The selected provider handles the request and returns the CAD response payload.
handler directly; it does not call mission-local generator functions.
Custom mission generators can still create CAD-visible tasks directly by Custom mission generators can register a provider with the
registering task catalog entries and task statuses. See `forge_server_task_registerMissionGeneratorProvider` CBA server event or create
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for the supported CAD-visible tasks directly by registering task catalog entries and task
integration path and the current generated-task provider limitation. statuses. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for
the supported integration path.
## Submit a Support Request ## 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 `generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the `generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
request control, which is how `forge_server_task_enableGenerator = false` is surfaced request control. For the built-in provider, this is how
client-side. `forge_server_task_enableGenerator = false` is surfaced client-side.
Custom mission generators can still publish tasks into CAD by using the server Custom mission generators can publish tasks into CAD by using the server task
task catalog. The generated-task dropdown itself currently needs a framework catalog or by registering a task provider that supplies `generatedTaskTypes` and
provider extension point before custom providers can replace the built-in list handles generated task requests. See
cleanly. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md). [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
## Authorization Notes ## 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 integrate through the same task, CAD, and event surfaces that the built-in
mission manager uses. mission manager uses.
This guide documents the supported integration path today and calls out the This guide documents the supported integration path for custom generators,
current CAD generated-task provider limitation that should be addressed by a including the provider registry used by CAD/manual generated task requests.
small framework extension point.
## Recommended Architecture ## Recommended Architecture
@ -35,13 +34,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types. hydrates no built-in generated task types.
This does not prevent custom code from creating CAD-visible tasks directly. This does not prevent custom code from creating CAD-visible tasks directly or
It only disables the built-in generator request list and the framework-owned from serving CAD/manual generated task requests through a registered custom
manual request entry point. provider.
The mission setup UI does not override this setting. Generated mission The mission setup UI does not override this setting. Generated mission
enablement is mission/server policy and should stay in CBA settings until a enablement for the built-in provider is mission/server policy and stays in CBA
provider selection extension point exists. 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 ## Framework Mission Setup UI
@ -68,6 +78,7 @@ missionNamespace setVariable [
The UI configures: The UI configures:
- opposing faction - opposing faction
- generator provider preference
- max concurrent generated missions - max concurrent generated missions
- mission interval - mission interval
- location reuse cooldown - 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 and direct or stale open requests receive a notification explaining that setup
has already been applied. 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-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom 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 status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners. 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 CAD hydrates generated task types and requests generated tasks through the task
manager. CAD hydrates `generatedTaskTypes` from the built-in manager when provider registry. The selected provider comes from
`forge_server_task_enableGenerator` is enabled. When that setting is disabled, `forge_server_task_generatorProvider`, defaulting to `builtin`.
the generated-task request control is disabled.
The current CAD request handler calls `forge_server_task_fnc_requestMissionTask` Use one of these supported patterns:
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.
Until a provider extension point is added, use one of these supported patterns: 1. Register a custom provider so CAD/manual generated task requests route to
community code.
1. Run custom generators from mission/server code and create CAD-visible tasks 2. Run custom generators from mission/server code and create CAD-visible tasks
directly. 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. 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. 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 The implemented provider shape is intentionally small:
shape is:
- built-in Forge provider remains the default out-of-box behavior - built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes` - mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests - mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers - disabling the built-in provider does not disable custom providers
- mission designers or developers can select or toggle the active generator - mission designers or developers can select or toggle the active provider from
provider when a mission includes custom generators the framework mission setup UI 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.
## Validation Checklist ## Validation Checklist

View File

@ -754,19 +754,19 @@ CAD dispatcher-requested generation.
The optional framework mission setup UI lets the setup operator choose runtime The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown, tuning such as opposing faction, mission cap, interval, location cooldown,
reward ranges, reputation ranges, penalty ranges, and time limits. It does not reward ranges, reputation ranges, penalty ranges, time limits, and a generator
enable or disable generated missions; use the CBA setting for that policy. 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 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 applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults. parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened. After settings are applied, the setup UI cannot be reopened.
Future custom-generator support should add an explicit provider option so The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
mission designers or developers can select or toggle a mission/community-owned generated task requests use the task provider registry and route to the selected
generator without relying on mission-local fallback functions. Until then, provider. Custom generators should register a provider or create CAD-visible
custom generators should create CAD-visible tasks directly through the task tasks directly through the task catalog/status contract described in
catalog/status contract described in
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md). [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
The dynamic mission generator avoids rectangle and ellipse area markers whose 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 The store module processes checkout requests. It charges a payment source and
grants purchased items to the player locker, virtual arsenal locker, 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 ## 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 variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities. 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 ## Checkout Model
`store:checkout` accepts one JSON context. `store:checkout` accepts one JSON context.
@ -45,6 +95,13 @@ pattern used by garage entities.
"category": "cars", "category": "cars",
"priceValue": 1500 "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: Rules validated by the Rust service:
- `requesterUid` is required. - `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. - The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or - Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`. `backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or - Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`. `other`.
- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`. - Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout. - Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org - 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 ```json
{ {
"chargedTotal": 2000.0, "chargedTotal": 4500.0,
"paymentMethod": "bank", "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": [], "lockerGranted": [],
"vehicleGranted": [], "vehicleGranted": [],
"unitGranted": [],
"lockerPatch": {}, "lockerPatch": {},
"vaPatch": {}, "vaPatch": {},
"vgaragePatch": {}, "vgaragePatch": {},
@ -108,7 +167,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false], ["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"], ["paymentMethod", "bank"],
["items", [_item]], ["items", [_item]],
["vehicles", []] ["vehicles", []],
["units", []]
]; ];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]]; private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@ -133,7 +193,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false], ["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"], ["paymentMethod", "org_funds"],
["items", []], ["items", []],
["vehicles", [_vehicle]] ["vehicles", [_vehicle]],
["units", []]
]; ];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]]; private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];

View File

@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat .\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: On Linux or macOS:
```bash ```bash

View File

@ -188,19 +188,20 @@ server-side.
The mission setup UI does not enable or disable generated missions. It applies The mission setup UI does not enable or disable generated missions. It applies
runtime tuning such as faction, caps, intervals, reward ranges, rating ranges, runtime tuning such as faction, caps, intervals, reward ranges, rating ranges,
penalties, and time limits. Generator enablement remains controlled by the CBA penalties, time limits, and a generator provider preference. Generator
setting above. enablement remains controlled by the CBA setting above.
When `forge_server_task_enableMissionSetup` is enabled, the mission manager When `forge_server_task_enableMissionSetup` is enabled, the mission manager
waits for setup settings before starting. There is no timeout auto-apply. waits for setup settings before starting. There is no timeout auto-apply.
Pressing Cancel, X, or Escape applies default values from CBA, mission Pressing Cancel, X, or Escape applies default values from CBA, mission
parameters, and `CfgMissions`. parameters, and `CfgMissions`.
Planned custom-generator work should add an explicit provider option for The setup UI stores the provider preference in
mission designers or developers who want to select or toggle a custom mission `forge_server_task_generatorProvider` as `builtin` or `custom`. CAD/manual
generator. That provider option should be separate from the built-in generator generated task requests use the task provider registry and route to the selected
CBA gate so disabling Forge's built-in generator does not prevent custom provider. That provider option stays separate from the built-in generator CBA
providers from publishing CAD-visible work. gate so disabling Forge's built-in generator does not prevent custom providers
from publishing CAD-visible work.
## CAD Compatibility ## CAD Compatibility

View File

@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat .\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: On Linux or macOS:
```bash ```bash

View File

@ -79,8 +79,8 @@ npm run build:webui
title: Custom Mission Generators title: Custom Mission Generators
to: /getting-started/custom-mission-generators to: /getting-started/custom-mission-generators
--- ---
Create CAD-visible custom generated missions and understand the current Create CAD-visible custom generated missions and register custom generator
provider extension point. providers.
::: :::
:::u-page-card :::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 The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown, tuning such as opposing faction, mission cap, interval, location cooldown,
reward ranges, reputation ranges, penalty ranges, and time limits. It does not reward ranges, reputation ranges, penalty ranges, time limits, and a generator
enable or disable generated missions; use the CBA setting for that policy. 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 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 applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults. parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened. After settings are applied, the setup UI cannot be reopened.
Future custom-generator support should add an explicit provider option so The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
mission designers or developers can select or toggle a mission/community-owned generated task requests use the task provider registry and route to the selected
generator without relying on mission-local fallback functions. Until then, provider. Custom generators should register a provider or create CAD-visible
custom generators should create CAD-visible tasks directly through the task tasks directly through the task catalog/status contract described in
catalog/status contract described in
[Custom Mission Generators](/getting-started/custom-mission-generators). [Custom Mission Generators](/getting-started/custom-mission-generators).
The dynamic mission generator avoids rectangle and ellipse area markers whose The dynamic mission generator avoids rectangle and ellipse area markers whose

View File

@ -43,6 +43,30 @@ cd arma/server/surrealdb
.\RunMe.bat .\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: On Linux or macOS:
```bash ```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." 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 This guide documents the supported integration path for custom generators,
current CAD generated-task provider limitation that should be addressed by a including the provider registry used by CAD/manual generated task requests.
small framework extension point.
## Recommended Architecture ## Recommended Architecture
@ -33,13 +32,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types. hydrates no built-in generated task types.
This does not prevent custom code from creating CAD-visible tasks directly. This does not prevent custom code from creating CAD-visible tasks directly or
It only disables the built-in generator request list and the framework-owned from serving CAD/manual generated task requests through a registered custom
manual request entry point. provider.
The mission setup UI does not override this setting. Generated mission The mission setup UI does not override this setting. Generated mission
enablement is mission/server policy and should stay in CBA settings until a enablement for the built-in provider is mission/server policy and stays in CBA
provider selection extension point exists. 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 ## Framework Mission Setup UI
@ -66,6 +76,7 @@ missionNamespace setVariable [
The UI configures: The UI configures:
- opposing faction - opposing faction
- generator provider preference
- max concurrent generated missions - max concurrent generated missions
- mission interval - mission interval
- location reuse cooldown - 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 and direct or stale open requests receive a notification explaining that setup
has already been applied. 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-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom 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 status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners. 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 CAD hydrates generated task types and requests generated tasks through the task
manager. CAD hydrates `generatedTaskTypes` from the built-in manager when provider registry. The selected provider comes from
`forge_server_task_enableGenerator` is enabled. When that setting is disabled, `forge_server_task_generatorProvider`, defaulting to `builtin`.
the generated-task request control is disabled.
The current CAD request handler calls `forge_server_task_fnc_requestMissionTask` Use one of these supported patterns:
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.
Until a provider extension point is added, use one of these supported patterns: 1. Register a custom provider so CAD/manual generated task requests route to
community code.
1. Run custom generators from mission/server code and create CAD-visible tasks 2. Run custom generators from mission/server code and create CAD-visible tasks
directly. 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. 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. 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 The implemented provider shape is intentionally small:
shape is:
- built-in Forge provider remains the default out-of-box behavior - built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes` - mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests - mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers - disabling the built-in provider does not disable custom providers
- mission designers or developers can select or toggle the active generator - mission designers or developers can select or toggle the active provider from
provider when a mission includes custom generators the framework mission setup UI 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.
## Validation Checklist ## Validation Checklist

View File

@ -1,6 +1,6 @@
--- ---
title: "Store Usage Guide" 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 ## 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 - `StorefrontStore` is the storefront workflow facade. It builds hydrate
payloads, validates checkout requests, calls the Rust `store:checkout` 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 - `StoreCatalogService` scans configured item and vehicle categories, builds
catalog responses, resolves checkout entries, and calculates authoritative 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 Editor-placed store entities are initialized by `fnc_initStore` during store
post-init. The initializer matches non-null mission namespace objects whose post-init. The initializer matches non-null mission namespace objects whose
variable names contain `store` and sets `isStore = true`, following the same variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities. 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 ## Checkout Model
`store:checkout` accepts one JSON context. `store:checkout` accepts one JSON context.
@ -44,6 +98,13 @@ pattern used by garage entities.
"category": "cars", "category": "cars",
"priceValue": 1500 "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: Rules validated by the Rust service:
- `requesterUid` is required. - `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. - The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or - Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`. `backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or - Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`. `other`.
- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`. - Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout. - Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org - 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 ```json
{ {
"chargedTotal": 2000.0, "chargedTotal": 4500.0,
"paymentMethod": "bank", "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": [], "lockerGranted": [],
"vehicleGranted": [], "vehicleGranted": [],
"unitGranted": [],
"lockerPatch": {}, "lockerPatch": {},
"vaPatch": {}, "vaPatch": {},
"vgaragePatch": {}, "vgaragePatch": {},
@ -107,7 +170,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false], ["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"], ["paymentMethod", "bank"],
["items", [_item]], ["items", [_item]],
["vehicles", []] ["vehicles", []],
["units", []]
]; ];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]]; private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@ -132,7 +196,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false], ["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"], ["paymentMethod", "org_funds"],
["items", []], ["items", []],
["vehicles", [_vehicle]] ["vehicles", [_vehicle]],
["units", []]
]; ];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]]; 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 The mission setup UI does not enable or disable generated missions. It applies
runtime tuning such as faction, caps, intervals, reward ranges, rating ranges, runtime tuning such as faction, caps, intervals, reward ranges, rating ranges,
penalties, and time limits. Generator enablement remains controlled by the CBA penalties, time limits, and a generator provider preference. Generator
setting above. enablement remains controlled by the CBA setting above.
When `forge_server_task_enableMissionSetup` is enabled, the mission manager When `forge_server_task_enableMissionSetup` is enabled, the mission manager
waits for setup settings before starting. There is no timeout auto-apply. waits for setup settings before starting. There is no timeout auto-apply.
Pressing Cancel, X, or Escape applies default values from CBA, mission Pressing Cancel, X, or Escape applies default values from CBA, mission
parameters, and `CfgMissions`. parameters, and `CfgMissions`.
Planned custom-generator work should add an explicit provider option for The setup UI stores the provider preference in
mission designers or developers who want to select or toggle a custom mission `forge_server_task_generatorProvider` as `builtin` or `custom`. CAD/manual
generator. That provider option should be separate from the built-in generator generated task requests use the task provider registry and route to the selected
CBA gate so disabling Forge's built-in generator does not prevent custom provider. That provider option stays separate from the built-in generator CBA
providers from publishing CAD-visible work. gate so disabling Forge's built-in generator does not prevent custom providers
from publishing CAD-visible work.
## CAD Compatibility ## CAD Compatibility

View File

@ -69,27 +69,27 @@ Common generated IDs:
## Generated Mission Requests ## Generated Mission Requests
Dispatchers can request framework-generated mission tasks from the CAD Dispatchers can request generated mission tasks from the CAD dispatcher board.
dispatcher board. The server hydrates the available generated task types from The server hydrates the available generated task types from the selected task
the task mission manager as `generatedTaskTypes`; the client uses that hydrated provider as `generatedTaskTypes`; the client uses that hydrated list for the
list for the dropdown. 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`: `forge_server_task_enableGenerator`:
- Enabled: CAD receives the generated task type list and dispatchers can request - Enabled: CAD can receive the built-in generated task type list and dispatchers
a specific generator type. can request a specific built-in generator type.
- Disabled: CAD receives an empty generated task type list, the task request UI - Disabled: the built-in provider returns no task types and rejects built-in
is disabled, and server-side request handling rejects any manual request. manual requests.
The framework-owned request entry point is Server CAD routes generated mission requests through the task provider registry.
`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework The selected provider handles the request and returns the CAD response payload.
handler directly; it does not call mission-local generator functions.
Custom mission generators can still create CAD-visible tasks directly by Custom mission generators can register a provider with the
registering task catalog entries and task statuses. See `forge_server_task_registerMissionGeneratorProvider` CBA server event or create
[Custom Mission Generators](/getting-started/custom-mission-generators) for the supported CAD-visible tasks directly by registering task catalog entries and task
integration path and the current generated-task provider limitation. statuses. See [Custom Mission Generators](/getting-started/custom-mission-generators) for
the supported integration path.
## Submit a Support Request ## 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 `generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the `generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
request control, which is how `forge_server_task_enableGenerator = false` is surfaced request control. For the built-in provider, this is how
client-side. `forge_server_task_enableGenerator = false` is surfaced client-side.
Custom mission generators can still publish tasks into CAD by using the server Custom mission generators can publish tasks into CAD by using the server task
task catalog. The generated-task dropdown itself currently needs a framework catalog or by registering a task provider that supplies `generatedTaskTypes` and
provider extension point before custom providers can replace the built-in list handles generated task requests. See
cleanly. See [Custom Mission Generators](/getting-started/custom-mission-generators). [Custom Mission Generators](/getting-started/custom-mission-generators).
## Authorization Notes ## Authorization Notes

View File

@ -34,8 +34,8 @@ pub use org::{
}; };
pub use phone::{PhoneEmail, PhoneMessage, PhonePayload}; pub use phone::{PhoneEmail, PhoneMessage, PhonePayload};
pub use store::{ pub use store::{
StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed, StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutUnitSeed,
StoreGrantedItem, StoreGrantedVehicle, StoreCheckoutVehicleSeed, StoreGrantedItem, StoreGrantedUnit, StoreGrantedVehicle,
}; };
pub use task::{ pub use task::{
TaskJsonMap, TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext, TaskJsonMap, TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext,

View File

@ -18,6 +18,14 @@ pub struct StoreCheckoutVehicleSeed {
pub price_value: f64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StoreCheckoutContext { pub struct StoreCheckoutContext {
@ -30,6 +38,8 @@ pub struct StoreCheckoutContext {
pub items: Vec<StoreCheckoutItemSeed>, pub items: Vec<StoreCheckoutItemSeed>,
#[serde(default)] #[serde(default)]
pub vehicles: Vec<StoreCheckoutVehicleSeed>, pub vehicles: Vec<StoreCheckoutVehicleSeed>,
#[serde(default)]
pub units: Vec<StoreCheckoutUnitSeed>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -47,6 +57,13 @@ pub struct StoreGrantedVehicle {
pub category: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct StoreCheckoutResult { pub struct StoreCheckoutResult {
@ -58,6 +75,8 @@ pub struct StoreCheckoutResult {
#[serde(default)] #[serde(default)]
pub vehicle_granted: Vec<StoreGrantedVehicle>, pub vehicle_granted: Vec<StoreGrantedVehicle>,
#[serde(default)] #[serde(default)]
pub unit_granted: Vec<StoreGrantedUnit>,
#[serde(default)]
pub locker_patch: HashMap<String, serde_json::Value>, pub locker_patch: HashMap<String, serde_json::Value>,
#[serde(default)] #[serde(default)]
pub va_patch: HashMap<String, serde_json::Value>, pub va_patch: HashMap<String, serde_json::Value>,

View File

@ -1,6 +1,6 @@
use forge_models::{ use forge_models::{
Bank, BankCheckoutContext, BankMutationResult, EquipmentCategory, HotOrgRecord, Item, Locker, Bank, BankCheckoutContext, BankMutationResult, EquipmentCategory, HotOrgRecord, Item, Locker,
OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem, OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem, StoreGrantedUnit,
StoreGrantedVehicle, VGarage, VLocker, VehicleCategory, StoreGrantedVehicle, VGarage, VLocker, VehicleCategory,
}; };
use forge_repositories::{ use forge_repositories::{
@ -229,7 +229,7 @@ where
if context.requester_uid.trim().is_empty() { if context.requester_uid.trim().is_empty() {
return Err("A valid requester UID is required.".to_string()); 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()); return Err("Add at least one item before checkout.".to_string());
} }
@ -254,6 +254,7 @@ where
let mut vgarage_patch = HashMap::new(); let mut vgarage_patch = HashMap::new();
let mut locker_granted = Vec::new(); let mut locker_granted = Vec::new();
let mut vehicle_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 va_categories_changed: Vec<&str> = Vec::new();
let mut vgarage_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 { for category in vgarage_categories_changed {
match category { match category {
"cars" => { "cars" => {
@ -550,13 +567,15 @@ where
charged_total, charged_total,
payment_method, payment_method,
message: format!( 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), format_currency(charged_total),
locker_granted.len(), locker_granted.len(),
vehicle_granted.len() vehicle_granted.len(),
unit_granted.len()
), ),
locker_granted, locker_granted,
vehicle_granted, vehicle_granted,
unit_granted,
locker_patch, locker_patch,
va_patch, va_patch,
vgarage_patch, vgarage_patch,
@ -578,8 +597,13 @@ fn checkout_total(context: &StoreCheckoutContext) -> f64 {
.iter() .iter()
.map(|entry| entry.price_value.max(0.0)) .map(|entry| entry.price_value.max(0.0))
.sum::<f64>(); .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> { fn resolve_locker_category(category: &str) -> Result<&'static str, String> {

View File

@ -460,8 +460,8 @@ npm run build:webui
title: Custom Mission Generators title: Custom Mission Generators
to: /getting-started/custom-mission-generators to: /getting-started/custom-mission-generators
--- ---
Create CAD-visible custom generated missions and understand the current Create CAD-visible custom generated missions and register custom generator
provider extension point. providers.
::: :::
:::u-page-card :::u-page-card