Compare commits

...

5 Commits

Author SHA1 Message Date
Jacob Schmidt
4f54edf467 Backport framework docs and store filter updates 2026-06-03 22:48:59 -05:00
Jacob Schmidt
d61cb86d3a Move service pricing into economy config 2026-06-03 17:47:02 -05:00
Jacob Schmidt
bfb317eb5c Clarify store and starting equipment guides 2026-06-03 17:42:02 -05:00
Jacob Schmidt
623f718caf Update framework mission configuration systems 2026-06-03 17:36:13 -05:00
Jacob Schmidt
6229f56ba4 Backport framework updates without mission files 2026-06-03 05:59:56 -05:00
97 changed files with 3195 additions and 879 deletions

View File

@ -79,14 +79,31 @@ switch (_event) do {
hint "Transport destination is no longer available.";
};
private _transportSetting = {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
};
private _options = createHashMapFromArray [
["label", _data getOrDefault ["label", "Transport"]],
["nodePrefix", _data getOrDefault ["nodePrefix", "transport"]],
["vehiclePrefix", _data getOrDefault ["vehiclePrefix", "transport_vehicle"]],
["arrivalPrefix", _data getOrDefault ["arrivalPrefix", "transport_arrival"]],
["maxIndexedNodes", _data getOrDefault ["maxIndexedNodes", 10]],
["baseFare", _data getOrDefault ["baseFare", 100]],
["pricePerKm", _data getOrDefault ["pricePerKm", 50]],
["baseFare", _data getOrDefault ["baseFare", ["transportBaseFare", 100] call _transportSetting]],
["pricePerKm", _data getOrDefault ["pricePerKm", ["transportPricePerKm", 50] call _transportSetting]],
["cargoRadius", _data getOrDefault ["cargoRadius", 25]],
["includeCargo", _data getOrDefault ["includeCargo", true]]
];

View File

@ -142,8 +142,24 @@ GVAR(ActorRepositoryBaseClass) = compileFinal createHashMapFromArray [
if (_isTransport) then {
private _fromTransportNode = _x;
private _maxIndexedNodes = _x getVariable ["transportMaxIndexedNodes", 10];
private _baseFare = _x getVariable ["transportBaseFare", 100];
private _pricePerKm = _x getVariable ["transportPricePerKm", 50];
private _transportSetting = {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
};
private _baseFare = _x getVariable ["transportBaseFare", ["transportBaseFare", 100] call _transportSetting];
private _pricePerKm = _x getVariable ["transportPricePerKm", ["transportPricePerKm", 50] call _transportSetting];
private _vehiclePrefix = _x getVariable ["transportVehiclePrefix", format ["%1_vehicle", _transportPrefix]];
private _arrivalPrefix = _x getVariable ["transportArrivalPrefix", format ["%1_arrival", _transportPrefix]];
private _nodeNames = [_transportPrefix];

View File

@ -151,10 +151,22 @@ GVAR(MissionSetupRepositoryBaseClass) = compileFinal createHashMapFromArray [
private _paramOrDefault = {
params ["_varName", "_default"];
private _value = missionNamespace getVariable [_varName, _default];
private _paramValue = [_varName, _default] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_varName, _paramValue];
if (_value isEqualType "") exitWith { parseNumber _value };
_value
};
private _serviceDefault = {
params ["_varName", "_default"];
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _varName)) exitWith {
getNumber (_serviceConfig >> _varName)
};
_default
};
private _factions = [];
{
@ -196,7 +208,15 @@ GVAR(MissionSetupRepositoryBaseClass) = compileFinal createHashMapFromArray [
["penaltyMin", ["penaltyMin", -5] call _paramOrDefault],
["penaltyMax", ["penaltyMax", -25] call _paramOrDefault],
["timeLimitMin", ["timeLimitMin", 600] call _paramOrDefault],
["timeLimitMax", ["timeLimitMax", 900] call _paramOrDefault]
["timeLimitMax", ["timeLimitMax", 900] call _paramOrDefault],
["medicalSpawnCost", ["medicalSpawnCost", ["medicalSpawnCost", 100] call _serviceDefault] call _paramOrDefault],
["medicalHealCost", ["medicalHealCost", ["medicalHealCost", 100] call _serviceDefault] call _paramOrDefault],
["serviceRepairCost", ["serviceRepairCost", ["serviceRepairCost", 500] call _serviceDefault] call _paramOrDefault],
["serviceRearmCost", ["serviceRearmCost", ["serviceRearmCost", 500] call _serviceDefault] call _paramOrDefault],
["fuelCost", ["fuelCost", ["fuelCost", 5] call _serviceDefault] call _paramOrDefault],
["transportBaseFare", ["transportBaseFare", ["transportBaseFare", 100] call _serviceDefault] call _paramOrDefault],
["transportPricePerKm", ["transportPricePerKm", ["transportPricePerKm", 50] call _serviceDefault] call _paramOrDefault],
["generatorProvider", GETMVAR(forge_server_task_generatorProvider,"builtin")]
]]
]
}]

View File

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

View File

@ -12,8 +12,17 @@
reputationMax: 100,
penaltyMin: -5,
penaltyMax: -25,
timeLimitEnabled: true,
timeLimitMin: 600,
timeLimitMax: 900,
medicalSpawnCost: 100,
medicalHealCost: 100,
serviceRepairCost: 500,
serviceRearmCost: 500,
fuelCost: 5,
transportBaseFare: 100,
transportPricePerKm: 50,
generatorProvider: "builtin",
},
error: "",
};
@ -33,6 +42,8 @@
}
function readSettings() {
const timeLimitEnabled = document.getElementById("timeLimitEnabled")?.checked !== false;
return {
enemyFaction: String(document.getElementById("enemyFaction")?.value || "IND_G_F"),
maxConcurrentMissions: fieldNumber("maxConcurrentMissions"),
@ -44,8 +55,17 @@
reputationMax: fieldNumber("reputationMax"),
penaltyMin: fieldNumber("penaltyMin"),
penaltyMax: fieldNumber("penaltyMax"),
timeLimitMin: fieldNumber("timeLimitMin"),
timeLimitMax: fieldNumber("timeLimitMax"),
timeLimitEnabled,
timeLimitMin: timeLimitEnabled ? fieldNumber("timeLimitMin") : 0,
timeLimitMax: timeLimitEnabled ? fieldNumber("timeLimitMax") : 0,
medicalSpawnCost: fieldNumber("medicalSpawnCost"),
medicalHealCost: fieldNumber("medicalHealCost"),
serviceRepairCost: fieldNumber("serviceRepairCost"),
serviceRearmCost: fieldNumber("serviceRearmCost"),
fuelCost: fieldNumber("fuelCost"),
transportBaseFare: fieldNumber("transportBaseFare"),
transportPricePerKm: fieldNumber("transportPricePerKm"),
generatorProvider: document.getElementById("generatorProviderCustom")?.checked ? "custom" : "builtin",
};
}
@ -58,6 +78,16 @@
.replace(/'/g, "'");
}
function normalizeSettings(settings) {
const next = Object.assign({}, settings);
next.timeLimitMin = Number(next.timeLimitMin || 0);
next.timeLimitMax = Number(next.timeLimitMax || 0);
if (typeof next.timeLimitEnabled !== "boolean") {
next.timeLimitEnabled = next.timeLimitMax > 0;
}
return next;
}
function apply() {
const settings = readSettings();
if (settings.moneyMax < settings.moneyMin) {
@ -78,8 +108,31 @@
return;
}
if (settings.timeLimitMax < settings.timeLimitMin) {
state.error = "Time limit max must be greater than or equal to time limit min.";
if (settings.timeLimitEnabled) {
if (settings.timeLimitMin < 1 || settings.timeLimitMax < 1) {
state.error = "Time limits must be positive seconds when task timers are enabled.";
render();
return;
}
if (settings.timeLimitMax < settings.timeLimitMin) {
state.error = "Time limit max must be greater than or equal to time limit min.";
render();
return;
}
}
const costFields = [
settings.medicalSpawnCost,
settings.medicalHealCost,
settings.serviceRepairCost,
settings.serviceRearmCost,
settings.fuelCost,
settings.transportBaseFare,
settings.transportPricePerKm,
];
if (costFields.some((value) => value < 0)) {
state.error = "Service pricing cannot use negative values.";
render();
return;
}
@ -101,6 +154,17 @@
const settings = state.settings;
const faction = state.factions.find((item) => item.faction === settings.enemyFaction);
const factionLabel = faction ? faction.display : settings.enemyFaction;
const generatorProviderLabel = settings.generatorProvider === "custom" ? "Custom" : "Built-in";
const generatorProviderChecked = settings.generatorProvider === "custom" ? " checked" : "";
const timeLimitEnabled = settings.timeLimitEnabled !== false;
const timeLimitChecked = timeLimitEnabled ? " checked" : "";
const timeLimitDisabled = timeLimitEnabled ? "" : " disabled";
const timeLimitLabel = timeLimitEnabled ? "Enabled" : "No Limit";
const timeLimitMinValue = timeLimitEnabled ? settings.timeLimitMin : 600;
const timeLimitMaxValue = timeLimitEnabled ? settings.timeLimitMax : 900;
const timeLimitSummary = timeLimitEnabled
? `${settings.timeLimitMin}s - ${settings.timeLimitMax}s`
: "No limit";
document.getElementById("app").innerHTML = `
<div class="shell">
@ -120,7 +184,7 @@
<h1>Operation Settings</h1>
</div>
<div class="form">
<div class="field">
<div class="field wide">
<label for="enemyFaction">Opposing Faction</label>
<select id="enemyFaction">${state.factions.map(option).join("")}</select>
</div>
@ -128,6 +192,17 @@
<label for="locationReuseCooldown">Location Cooldown</label>
<input id="locationReuseCooldown" type="number" min="0" step="60" value="${settings.locationReuseCooldown}" />
</div>
<div class="field">
<label for="generatorProviderCustom">Mission Generator</label>
<label class="provider-toggle" for="generatorProviderCustom">
<input id="generatorProviderCustom" type="checkbox"${generatorProviderChecked} />
<span class="switch" aria-hidden="true"></span>
<span class="provider-copy">
<strong>${generatorProviderLabel}</strong>
<small>Mission Generators</small>
</span>
</label>
</div>
<div class="field">
<label for="maxConcurrentMissions">Concurrent Missions</label>
<input id="maxConcurrentMissions" type="number" min="1" max="50" value="${settings.maxConcurrentMissions}" />
@ -160,17 +235,67 @@
<label for="penaltyMax">Max Rep Hit</label>
<input id="penaltyMax" type="number" max="0" step="1" value="${settings.penaltyMax}" />
</div>
<div class="field">
<label for="timeLimitMin">Min Time</label>
<input id="timeLimitMin" type="number" min="1" step="60" value="${settings.timeLimitMin}" />
</div>
<div class="field">
<label for="timeLimitMax">Max Time</label>
<input id="timeLimitMax" type="number" min="1" step="60" value="${settings.timeLimitMax}" />
<div class="timer-row wide">
<div class="field">
<label for="timeLimitEnabled">Task Timer</label>
<label class="provider-toggle" for="timeLimitEnabled">
<input id="timeLimitEnabled" type="checkbox"${timeLimitChecked} />
<span class="switch" aria-hidden="true"></span>
<span class="provider-copy">
<strong>${timeLimitLabel}</strong>
<small>Time Limits</small>
</span>
</label>
</div>
<div class="field">
<label for="timeLimitMin">Min Time</label>
<input id="timeLimitMin" type="number" min="1" step="60" value="${timeLimitMinValue}"${timeLimitDisabled} />
</div>
<div class="field">
<label for="timeLimitMax">Max Time</label>
<input id="timeLimitMax" type="number" min="1" step="60" value="${timeLimitMaxValue}"${timeLimitDisabled} />
</div>
</div>
</div>
</section>
<aside class="panel">
<div class="panel-head">
<span class="kicker">Service Pricing</span>
<h2>Economy Settings</h2>
</div>
<div class="form compact">
<div class="field">
<label for="medicalSpawnCost">Medical Respawn</label>
<input id="medicalSpawnCost" type="number" min="0" step="50" value="${settings.medicalSpawnCost}" />
</div>
<div class="field">
<label for="medicalHealCost">Medical Heal</label>
<input id="medicalHealCost" type="number" min="0" step="50" value="${settings.medicalHealCost}" />
</div>
<div class="field">
<label for="serviceRepairCost">Repair</label>
<input id="serviceRepairCost" type="number" min="0" step="50" value="${settings.serviceRepairCost}" />
</div>
<div class="field">
<label for="serviceRearmCost">Rearm</label>
<input id="serviceRearmCost" type="number" min="0" step="50" value="${settings.serviceRearmCost}" />
</div>
<div class="field">
<label for="fuelCost">Fuel / Liter</label>
<input id="fuelCost" type="number" min="0" step="1" value="${settings.fuelCost}" />
</div>
<div class="field">
<label for="transportBaseFare">Transport Base</label>
<input id="transportBaseFare" type="number" min="0" step="25" value="${settings.transportBaseFare}" />
</div>
<div class="field wide">
<label for="transportPricePerKm">Transport / KM</label>
<input id="transportPricePerKm" type="number" min="0" step="25" value="${settings.transportPricePerKm}" />
</div>
</div>
</aside>
<aside class="panel">
<div class="panel-head">
<span class="kicker">Current Selection</span>
@ -178,13 +303,17 @@
</div>
<div class="summary">
<div class="summary-row"><span>Faction</span><strong>${escapeHtml(factionLabel)}</strong></div>
<div class="summary-row"><span>Generator</span><strong>${generatorProviderLabel}</strong></div>
<div class="summary-row"><span>Mission Cap</span><strong>${settings.maxConcurrentMissions}</strong></div>
<div class="summary-row"><span>Interval</span><strong>${settings.missionInterval}s</strong></div>
<div class="summary-row"><span>Location Cooldown</span><strong>${settings.locationReuseCooldown}s</strong></div>
<div class="summary-row"><span>Reward Range</span><strong>$${Number(settings.moneyMin).toLocaleString()} - $${Number(settings.moneyMax).toLocaleString()}</strong></div>
<div class="summary-row"><span>Reputation</span><strong>${settings.reputationMin} - ${settings.reputationMax}</strong></div>
<div class="summary-row"><span>Reputation Hit</span><strong>${settings.penaltyMin} to ${settings.penaltyMax}</strong></div>
<div class="summary-row"><span>Time Limit</span><strong>${settings.timeLimitMin}s - ${settings.timeLimitMax}s</strong></div>
<div class="summary-row"><span>Time Limit</span><strong>${timeLimitSummary}</strong></div>
<div class="summary-row"><span>Repair / Rearm</span><strong>$${Number(settings.serviceRepairCost).toLocaleString()} / $${Number(settings.serviceRearmCost).toLocaleString()}</strong></div>
<div class="summary-row"><span>Fuel</span><strong>$${Number(settings.fuelCost).toLocaleString()} / L</strong></div>
<div class="summary-row"><span>Medical Billing</span><strong>$${Number(settings.medicalSpawnCost).toLocaleString()} respawn / $${Number(settings.medicalHealCost).toLocaleString()} heal</strong></div>
${state.error ? `<div class="notice">${state.error}</div>` : ""}
</div>
</aside>
@ -230,7 +359,7 @@
return true;
});
state.factions = factions;
state.settings = Object.assign({}, state.settings, payload.data?.settings || {});
state.settings = normalizeSettings(Object.assign({}, state.settings, payload.data?.settings || {}));
render();
return true;
}

File diff suppressed because one or more lines are too long

View File

@ -571,7 +571,7 @@ ${scopeSelector} .store-toast.is-error {
{ className: "filter-placeholder" },
selectedPaymentSource
? selectedPaymentSource.label
: "Cash",
: "Select Payment",
),
),
),
@ -645,7 +645,7 @@ ${scopeSelector} .store-toast.is-error {
h(
"span",
{ className: "footer-copy" },
"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.",
"Uniforms, protective gear, weapon slots, vehicles, units, ammunition groups, and general support inventory.",
),
),
h(

View File

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

View File

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

View File

@ -112,8 +112,8 @@
return {
eyebrow: "Supply Categories",
title: "Procurement Dashboard",
copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.",
badge: "8 Categories",
copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display live product inventory inside the runtime store architecture.",
badge: "11 Categories",
};
}

View File

@ -36,6 +36,7 @@
const payload = {
items: [],
vehicles: [],
units: [],
totalPrice,
paymentMethod,
};
@ -57,6 +58,20 @@
return;
}
if (normalizedItem.entryKind === "unit") {
for (
let index = 0;
index < normalizedItem.quantity;
index += 1
) {
payload.units.push({
classname: normalizedItem.classname,
category: "units",
});
}
return;
}
payload.items.push({
classname: normalizedItem.classname,
category: normalizedItem.category,

View File

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

View File

@ -25,13 +25,38 @@ life state, phone number, email, organization, and holster state.
## Runtime Behavior
- Missing persistent actors can be created from live player snapshots.
- Newly created actors receive a Field Commander job orientation email, two
- Newly created actors receive their starting loadout from mission
`CfgStartingEquipment`, plus a Field Commander job orientation email, two
Field Commander text messages, and a `$2,000` starting credit in their bank
account.
- Hot actor reads are migrated and hydrated before use.
- `saveHotState` in the main addon snapshots and saves actor state on player
disconnect and mission end.
## Starting Equipment
Missions can include `CfgStartingEquipment.hpp` from `description.ext` to
override starter actor gear without recompiling the addon or extension.
```cpp
class CfgStartingEquipment {
loadout[] = {
{},
{},
{},
{"U_BG_Guerrilla_6_1", {{"FirstAidKit", 2}}},
{},
{},
"H_Cap_blk_ION",
"",
{},
{"ItemMap", "ItemGPS", "ItemRadio", "ItemCompass", "ItemWatch", ""}
};
};
```
The Rust actor model no longer hardcodes a starter loadout. SQF supplies the
mission-configured loadout when it creates a missing actor record.
## Event Surface
The addon handles server events for actor init, get, set, multi-set, save, and
remove requests, then replies to the requesting player through client actor RPCs.

View File

@ -4,7 +4,7 @@
* File: fnc_initActorStore.sqf
* Author: IDSolutions
* Date: 2025-12-17
* Last Update: 2026-05-16
* Last Update: 2026-06-03
* Public: Yes
*
* Description:
@ -25,12 +25,23 @@
#pragma hemtt ignore_variables ["_self"]
GVAR(ActorModel) = compileFinal createHashMapObject [[
["#type", "ActorModel"],
["getStartingConfig", compileFinal {
missionConfigFile >> "CfgStartingEquipment"
}],
["getDefaultLoadout", compileFinal {
private _config = _self call ["getStartingConfig", []];
private _loadoutConfig = _config >> "loadout";
if (isArray _loadoutConfig) exitWith { getArray _loadoutConfig };
[[],[],["hgun_P07_F","","","",["16Rnd_9x21_Mag",4,17],[],""],["U_BG_Guerrilla_6_1",[["FirstAidKit", 2],["ACE_EarPlugs",1]]],["V_Rangemaster_belt",[["16Rnd_9x21_Mag",4]]],[],"H_Cap_blk_ION","",["Binocular","","","",[],[],""],["ItemMap","ItemGPS","ItemRadio","ItemCompass","ItemWatch",""]]
}],
["defaults", compileFinal {
private _actor = createHashMap;
_actor set ["uid", ""];
_actor set ["name", ""];
_actor set ["loadout", [[],[],[],["U_BG_Guerrilla_6_1",[["FirstAidKit", 2]]],[],[],"H_Cap_blk_ION","",[],["ItemMap","ItemGPS","ItemRadio","ItemCompass","ItemWatch",""]]];
_actor set ["loadout", _self call ["getDefaultLoadout", []]];
_actor set ["position", [0,0,0]];
_actor set ["direction", 0];
_actor set ["stance", "STAND"];
@ -105,13 +116,11 @@ GVAR(ActorModel) = compileFinal createHashMapObject [[
}]
]];
GVAR(ActorBaseStore) = compileFinal ([
EGVAR(common,BaseStore),
createHashMapFromArray [
GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [
["#base", EGVAR(common,BaseStore)],
["#type", "ActorBaseStore"],
["#create", compileFinal {
["INFO", "Actor Store Initialized!"] call EFUNC(common,log);
true
}],
["cacheActor", compileFinal {
params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]]];
@ -563,13 +572,7 @@ GVAR(ActorBaseStore) = compileFinal ([
_self call ["override", [_uid, _finalActor, false]]
}]
]] call {
params ["_base", "_child"];
];
private _merged = +_base;
{ _merged set [_x, _y]; } forEach _child;
_merged
});
GVAR(ActorStore) = createHashMapObject [GVAR(ActorBaseStore), []];
true
GVAR(ActorStore) = createHashMapObject [GVAR(ActorBaseStore)];
GVAR(ActorStore)

View File

@ -91,12 +91,12 @@ call FUNC(registerEventListeners);
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
};
if (isNil QEFUNC(task,requestMissionTask)) exitWith {
_result set ["message", "Framework generated mission requests are unavailable."];
if (isNil QEGVAR(task,MissionGeneratorProviderRegistry)) exitWith {
_result set ["message", "Generated mission provider registry is unavailable."];
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);
};
_result = [_taskType, _metadata, _uid] call EFUNC(task,requestMissionTask);
_result = EGVAR(task,MissionGeneratorProviderRegistry) call ["requestMissionTask", [_taskType, _metadata, _uid]];
if !(_result getOrDefault ["success", false]) exitWith {
[CRPC(cad,responseCadRequest), [_result], _player] call CFUNC(targetEvent);

View File

@ -300,31 +300,10 @@ GVAR(CadStoreBaseClass) = compileFinal createHashMapFromArray [
private _permissionService = _self get "PermissionService";
private _groupRepository = _self get "GroupRepository";
private _generatedTaskTypes = [];
if (missionNamespace getVariable [QEGVAR(task,enableGenerator), false]) then {
_generatedTaskTypes = [
createHashMapFromArray [["value", "attack"], ["label", "Attack"]],
createHashMapFromArray [["value", "defend"], ["label", "Defend"]],
createHashMapFromArray [["value", "defuse"], ["label", "Defuse"]],
createHashMapFromArray [["value", "delivery"], ["label", "Delivery"]],
createHashMapFromArray [["value", "destroy"], ["label", "Destroy"]],
createHashMapFromArray [["value", "hostage"], ["label", "Hostage"]],
createHashMapFromArray [["value", "hvtkill"], ["label", "Kill HVT"]],
createHashMapFromArray [["value", "hvtcapture"], ["label", "Capture HVT"]]
];
["INFO", "CAD hydrate using framework generator fallback type list while checking task mission manager."] call EFUNC(common,log);
if (isNil QEGVAR(task,MissionManager) && { !(isNil QEFUNC(task,missionManager)) }) then {
call EFUNC(task,missionManager);
};
if !(isNil QEGVAR(task,MissionManager)) then {
_generatedTaskTypes = EGVAR(task,MissionManager) call ["getGeneratedTaskTypes", []];
["INFO", format ["CAD hydrate using task mission manager generated types: %1", _generatedTaskTypes apply { _x getOrDefault ["value", ""] }]] call EFUNC(common,log);
} else {
["INFO", "CAD hydrate task mission manager is not ready; sending fallback generated task types."] call EFUNC(common,log);
};
if !(isNil QEGVAR(task,MissionGeneratorProviderRegistry)) then {
_generatedTaskTypes = EGVAR(task,MissionGeneratorProviderRegistry) call ["getGeneratedTaskTypes", []];
} else {
["INFO", "CAD hydrate generated task types disabled by forge_server_task_enableGenerator."] call EFUNC(common,log);
["INFO", "CAD hydrate generated task types unavailable because the task provider registry is not ready."] call EFUNC(common,log);
};
private _groupID = _groupRepository call ["getPlayerGroupId", [_uid]];

View File

@ -0,0 +1,16 @@
/*
* Framework fallback prices for service/economy interactions.
*
* Mission-local CfgServicePricing overrides this config. Mission Params with
* matching names override config defaults before the setup UI opens, and
* submitted setup UI values override both.
*/
class CfgServicePricing {
medicalSpawnCost = 100;
medicalHealCost = 100;
serviceRepairCost = 500;
serviceRearmCost = 500;
fuelCost = 5;
transportBaseFare = 100;
transportPricePerKm = 50;
};

View File

@ -9,6 +9,21 @@ inventory handling.
Current stores cover fuel tracking, medical service behavior, and service
charges such as repairs and rearming.
## Configurable Prices
Service prices are read dynamically from mission namespace values so the
framework mission setup UI can override them at startup. If the UI is cancelled
or unavailable, mission `Params` with matching names are used as the backup;
if no param is defined, `CfgServicePricing` provides the fallback.
Supported setting names:
- `medicalSpawnCost` - best-effort medical respawn charge; default `100`
- `medicalHealCost` - heal charge; default `100`
- `serviceRepairCost` - default repair service charge; default `500`
- `serviceRearmCost` - default rearm service charge; default `500`
- `fuelCost` - refuel price per liter; default `5`
- `transportBaseFare` - transport fare base price; default `100`
- `transportPricePerKm` - transport distance price; default `50`
## Dependencies
- `forge_server_main`
- `forge_server_common` for logging, formatting, and player lookup
@ -23,7 +38,8 @@ Note: Bank and Org are runtime-only dependencies (not compile-time requiredAddon
totals, charges the player's organization through `OrgStore`, syncs the org
patch, and rolls fuel back to the starting level when organization funds
cannot cover the refuel.
- `fnc_initMEconomyStore.sqf` manages medical spawn occupancy, healing charges,
- `fnc_initMEconomyStore.sqf` manages medical spawn occupancy, medical spawn
billing, healing charges,
respawn placement, death inventory handling, and body-bag transfer. Medical
charges use player bank/cash first, then organization funds with repayable
member debt only when the player cannot cover the service.

View File

@ -18,3 +18,4 @@ class CfgPatches {
};
#include "CfgEventHandlers.hpp"
#include "CfgServicePricing.hpp"

View File

@ -32,6 +32,22 @@ GVAR(FEconomyStore) = createHashMapObject [[
["INFO", "Fuel Store Initialized!", nil, nil] call EFUNC(common,log);
}],
["numberSetting", {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
}],
["start", {
params ["_source", "_target", "_unit"];
@ -100,7 +116,7 @@ GVAR(FEconomyStore) = createHashMapObject [[
if (_fuelCapacity <= 0) then { _fuelCapacity = 100; };
private _totalLiters = _missingFuel * _fuelCapacity;
private _totalCost = _totalLiters * GVAR(FuelCost);
private _totalCost = _totalLiters * (_self call ["numberSetting", ["fuelCost", GVAR(FuelCost)]]);
private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _totalCost, "Refueling"]];
if !(_chargeResult getOrDefault ["success", false]) exitWith {
_self call ["notify", [_unit, "danger", "Refueling", _chargeResult getOrDefault ["message", "Organization funds cannot cover this refuel. Refueling was not completed."]]];
@ -130,7 +146,7 @@ GVAR(FEconomyStore) = createHashMapObject [[
private _player = [_uid] call EFUNC(common,getPlayer);
private _totalLiters = GETVAR(_target,liters,0);
private _totalCost = _totalLiters * GVAR(FuelCost);
private _totalCost = _totalLiters * (_self call ["numberSetting", ["fuelCost", GVAR(FuelCost)]]);
private _formattedTotalCost = [_totalCost] call EFUNC(common,formatNumber);
private _formattedTotalLiters = _totalLiters toFixed 2;

View File

@ -4,7 +4,7 @@
* File: fnc_initMEconomyStore.sqf
* Author: IDSolutions
* Date: 2025-12-20
* Last Update: 2026-05-15
* Last Update: 2026-06-03
* Public: No
*
* Description:
@ -30,8 +30,25 @@ GVAR(MEconomyStore) = createHashMapObject [[
_self set ["mSpawns", createHashMap];
GVAR(occupancyTriggers) = [];
GVAR(SpawnCost) = 100;
["INFO", "Medical Store Initialized!", nil, nil] call EFUNC(common,log);
}],
["numberSetting", {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
}],
["init", {
private _mSpawns = (_self get "mSpawns");
private _prefix = "med_spawn";
@ -166,40 +183,61 @@ GVAR(MEconomyStore) = createHashMapObject [[
_result set ["message", ""];
_result
}],
["onHealed", {
params [["_unit", objNull, [objNull]]];
if (isNull _unit) exitWith { ["WARNING", format ["Invalid unit provided: %1", (name _unit)], nil, nil] call EFUNC(common,log); };
["chargeMedicalService", {
params [
["_unit", objNull, [objNull]],
["_amount", 0, [0]],
["_serviceLabel", "Medical service", [""]],
["_requirePayment", true, [true]]
];
if (isNull _unit) exitWith {
["WARNING", format ["Invalid unit provided: %1", (name _unit)], nil, nil] call EFUNC(common,log);
false
};
private _uid = getPlayerUID _unit;
if (_uid isEqualTo "") exitWith { ["WARNING", "Unable to charge medical service for unit without UID.", nil, nil] call EFUNC(common,log); };
if (_uid isEqualTo "") exitWith {
["WARNING", "Unable to charge medical service for unit without UID.", nil, nil] call EFUNC(common,log);
!_requirePayment
};
private _healCost = 100;
if (_amount <= 0) exitWith { true };
private _personalCharge = _self call ["chargePlayer", [_uid, _healCost]];
private _personalCharge = _self call ["chargePlayer", [_uid, _amount]];
if (_personalCharge getOrDefault ["success", false]) exitWith {
private _sourceLabel = ["cash", "bank"] select ((_personalCharge getOrDefault ["source", "bank"]) isEqualTo "bank");
_self call ["notify", [_unit, "info", "Medical Billing", format ["Medical service charged $%1 from your %2.", [_healCost] call EFUNC(common,formatNumber), _sourceLabel]]];
[CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent);
_self call ["notify", [_unit, "info", "Medical Billing", format ["%1 charged $%2 from your %3.", _serviceLabel, [_amount] call EFUNC(common,formatNumber), _sourceLabel]]];
true
};
if !(_personalCharge getOrDefault ["fallbackEligible", false]) exitWith {
private _message = _personalCharge getOrDefault ["message", "Personal funds could not be charged for medical service."];
_self call ["notify", [_unit, "danger", "Medical Billing", _message]];
!_requirePayment
};
if (isNil QGVAR(SEconomyStore)) exitWith {
["ERROR", "Service economy store unavailable for medical organization fallback charge.", nil, nil] call EFUNC(common,log);
_self call ["notify", [_unit, "danger", "Medical Billing", "Organization billing is unavailable. Medical service cannot complete."]];
!_requirePayment
};
private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _healCost, "Medical", true]];
private _chargeResult = GVAR(SEconomyStore) call ["chargeOrg", [_unit, _amount, "Medical", true]];
if !(_chargeResult getOrDefault ["success", false]) exitWith {
private _message = _chargeResult getOrDefault ["message", "Organization funds cannot cover this medical service."];
_self call ["notify", [_unit, "danger", "Medical Billing", _message]];
!_requirePayment
};
_self call ["notify", [_unit, "info", "Medical Billing", format ["Personal funds could not cover medical service. Organization charged $%1; repay it through your organization credit line.", [_healCost] call EFUNC(common,formatNumber)]]];
_self call ["notify", [_unit, "info", "Medical Billing", format ["Personal funds could not cover %1. Organization charged $%2; repay it through your organization credit line.", _serviceLabel, [_amount] call EFUNC(common,formatNumber)]]];
true
}],
["onHealed", {
params [["_unit", objNull, [objNull]]];
private _healCost = _self call ["numberSetting", ["medicalHealCost", 100]];
if !(_self call ["chargeMedicalService", [_unit, _healCost, "Medical service", true]]) exitWith {};
[CRPC(actor,onActorHealed), [], _unit] call CFUNC(targetEvent);
}],
["onRespawn", {
@ -214,6 +252,8 @@ GVAR(MEconomyStore) = createHashMapObject [[
deleteVehicle _corpse;
private _player = [_uid] call EFUNC(common,getPlayer);
private _spawnCost = _self call ["numberSetting", ["medicalSpawnCost", GVAR(SpawnCost)]];
_self call ["chargeMedicalService", [_player, _spawnCost, "Medical spawn", false]];
[CRPC(actor,onActorRespawn), [_loadout, _medSpawnPos, _medSpawnDir], _player] call CFUNC(targetEvent);
}],
["onKilled", {

View File

@ -30,6 +30,22 @@ GVAR(SEconomyStore) = createHashMapObject [[
GVAR(ServiceRearmCost) = 500;
["INFO", "Service Store Initialized!", nil, nil] call EFUNC(common,log);
}],
["numberSetting", {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
}],
["notify", {
params [["_unit", objNull, [objNull]], ["_type", "info", [""]], ["_title", "Service", [""]], ["_message", "", [""]]];
@ -148,7 +164,7 @@ GVAR(SEconomyStore) = createHashMapObject [[
if (isNull _target || { isNull _unit }) exitWith { false };
private _repairCost = [_cost, GVAR(ServiceRepairCost)] select (_cost < 0);
private _repairCost = [_cost, _self call ["numberSetting", ["serviceRepairCost", GVAR(ServiceRepairCost)]]] select (_cost < 0);
private _charge = _self call ["chargeOrg", [_unit, _repairCost, "Repair"]];
if !(_charge getOrDefault ["success", false]) exitWith {
_self call ["notify", [_unit, "danger", "Repair", _charge getOrDefault ["message", "Organization funds cannot cover this repair."]]];
@ -164,7 +180,7 @@ GVAR(SEconomyStore) = createHashMapObject [[
if (isNull _target || { isNull _unit }) exitWith { false };
private _rearmCost = [_cost, GVAR(ServiceRearmCost)] select (_cost < 0);
private _rearmCost = [_cost, _self call ["numberSetting", ["serviceRearmCost", GVAR(ServiceRearmCost)]]] select (_cost < 0);
private _charge = _self call ["chargeOrg", [_unit, _rearmCost, "Rearm"]];
if !(_charge getOrDefault ["success", false]) exitWith {
_self call ["notify", [_unit, "danger", "Rearm", _charge getOrDefault ["message", "Organization funds cannot cover this rearm."]]];

View File

@ -34,3 +34,26 @@ Garage listens for sync events through the event bus:
- `notification.requested` - storage and vehicle modification alerts
The store module emits these events when granting vehicles; garage applies the changes to player state.
## Starting Unlocks
Missions can include `CfgStartingEquipment.hpp` from `description.ext` to
configure initial virtual garage unlocks for new players.
```cpp
class CfgStartingEquipment {
class Unlocks {
class Garage {
cars[] = {"B_Quadbike_01_F"};
armor[] = {};
helis[] = {};
planes[] = {};
naval[] = {};
other[] = {};
};
};
};
```
The extension virtual garage default is intentionally empty. The server addon
seeds `CfgStartingEquipment` unlocks only when a player does not already have a
persistent owner-scoped garage record.

View File

@ -24,15 +24,28 @@
#pragma hemtt ignore_variables ["_self"]
GVAR(VGarageModel) = compileFinal createHashMapObject [[
["#type", "VGarageModel"],
["getStartingUnlocksConfig", compileFinal {
missionConfigFile >> "CfgStartingEquipment" >> "Unlocks" >> "Garage"
}],
["getStartingUnlocks", compileFinal {
params [["_category", "", [""]], ["_fallback", [], [[]]]];
private _config = _self call ["getStartingUnlocksConfig", []];
private _categoryConfig = _config >> _category;
if (isArray _categoryConfig) exitWith { getArray _categoryConfig };
+_fallback
}],
["defaults", compileFinal {
private _vGarage = createHashMap;
_vGarage set ["armor", []];
_vGarage set ["cars", ["B_Quadbike_01_F"]];
_vGarage set ["helis", []];
_vGarage set ["naval", []];
_vGarage set ["other", []];
_vGarage set ["planes", []];
_vGarage set ["armor", _self call ["getStartingUnlocks", ["armor", []]]];
_vGarage set ["cars", _self call ["getStartingUnlocks", ["cars", ["B_Quadbike_01_F"]]]];
_vGarage set ["helis", _self call ["getStartingUnlocks", ["helis", []]]];
_vGarage set ["naval", _self call ["getStartingUnlocks", ["naval", []]]];
_vGarage set ["other", _self call ["getStartingUnlocks", ["other", []]]];
_vGarage set ["planes", _self call ["getStartingUnlocks", ["planes", []]]];
_vGarage
}]
@ -71,17 +84,46 @@ GVAR(VGBaseStore) = compileFinal ([
private _command = ["owned:garage:hot:fetch", "owned:garage:hot:init"] select _initialize;
_self call ["callHotVGarage", [_command, [_uid]]]
}],
["isPersistentVGarageInitialized", compileFinal {
params [["_uid", "", [""]]];
if (_uid isEqualTo "") exitWith { false };
["owned:garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"];
_isSuccess && { _result isEqualTo "true" }
}],
["seedStartingUnlocks", compileFinal {
params [["_uid", "", [""]], ["_garage", createHashMap, [createHashMap]]];
if (_uid isEqualTo "" || { _garage isEqualTo createHashMap }) exitWith { _garage };
private _defaults = GVAR(VGarageModel) call ["defaults", []];
private _seeded = +_garage;
{
_seeded set [_x, +_y];
} forEach _defaults;
private _updated = _self call ["callHotVGarage", ["owned:garage:hot:override", [_uid, toJSON _seeded]]];
if (_updated isEqualTo createHashMap) exitWith { _seeded };
_self call ["callHotVGarage", ["owned:garage:hot:save", [_uid]]];
_updated
}],
["init", compileFinal {
params [["_uid", "", [""]]];
private _player = [_uid] call EFUNC(common,getPlayer);
if (isNull _player) exitWith { createHashMap };
private _hasPersistentGarage = _self call ["isPersistentVGarageInitialized", [_uid]];
private _garage = _self call ["loadHotVGarage", [_uid, true]];
if (_garage isEqualTo createHashMap) then {
_garage = GVAR(VGarageModel) call ["defaults", []];
["ERROR", format ["Failed to initialize virtual garage for %1! Using fallback virtual garage.", _uid]] call EFUNC(common,log);
};
if !(_hasPersistentGarage) then {
_garage = _self call ["seedStartingUnlocks", [_uid, _garage]];
};
[CRPC(garage,responseInitVG), [_garage], _player] call CFUNC(targetEvent);
_garage

View File

@ -35,3 +35,24 @@ Locker listens for sync events through the event bus:
- `notification.requested` - storage and item modification alerts
The store module emits these events when granting items; locker applies the changes to player state.
## Starting Unlocks
Missions can include `CfgStartingEquipment.hpp` from `description.ext` to
configure initial virtual arsenal unlocks for new players.
```cpp
class CfgStartingEquipment {
class Unlocks {
class Locker {
items[] = {"FirstAidKit", "ItemMap", "ItemCompass"};
weapons[] = {"hgun_P07_F"};
magazines[] = {"16Rnd_9x21_Mag"};
backpacks[] = {};
};
};
};
```
The extension virtual locker default is intentionally empty. The server addon
seeds `CfgStartingEquipment` unlocks only when a player does not already have a
persistent owner-scoped locker record.

View File

@ -24,13 +24,26 @@
#pragma hemtt ignore_variables ["_self"]
GVAR(VArsenalModel) = compileFinal createHashMapObject [[
["#type", "VArsenalModel"],
["getStartingUnlocksConfig", compileFinal {
missionConfigFile >> "CfgStartingEquipment" >> "Unlocks" >> "Locker"
}],
["getStartingUnlocks", compileFinal {
params [["_category", "", [""]], ["_fallback", [], [[]]]];
private _config = _self call ["getStartingUnlocksConfig", []];
private _categoryConfig = _config >> _category;
if (isArray _categoryConfig) exitWith { getArray _categoryConfig };
+_fallback
}],
["defaults", compileFinal {
private _vArsenal = createHashMap;
_vArsenal set ["backpacks", ["B_AssaultPack_rgr"]];
_vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_BG_Guerrilla_6_1", "V_TacVest_oli", "ACE_EarPlugs"]];
_vArsenal set ["magazines", ["16Rnd_9x21_Mag", "30Rnd_65x39_caseless_black_mag", "Chemlight_blue", "Chemlight_green", "Chemlight_red", "Chemlight_yellow", "HandGrenade", "SmokeShell", "SmokeShellBlue", "SmokeShellGreen", "SmokeShellOrange", "SmokeShellPurple", "SmokeShellRed", "SmokeShellYellow"]];
_vArsenal set ["weapons", ["arifle_MX_F", "hgun_P07_F"]];
_vArsenal set ["backpacks", _self call ["getStartingUnlocks", ["backpacks", ["B_AssaultPack_rgr"]]]];
_vArsenal set ["items", _self call ["getStartingUnlocks", ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_BG_Guerrilla_6_1", "V_TacVest_oli", "ACE_EarPlugs"]]]];
_vArsenal set ["magazines", _self call ["getStartingUnlocks", ["magazines", ["16Rnd_9x21_Mag", "30Rnd_65x39_caseless_black_mag", "Chemlight_blue", "Chemlight_green", "Chemlight_red", "Chemlight_yellow", "HandGrenade", "SmokeShell", "SmokeShellBlue", "SmokeShellGreen", "SmokeShellOrange", "SmokeShellPurple", "SmokeShellRed", "SmokeShellYellow"]]]];
_vArsenal set ["weapons", _self call ["getStartingUnlocks", ["weapons", ["arifle_MX_F", "hgun_P07_F"]]]];
_vArsenal
}]
@ -69,17 +82,46 @@ GVAR(VABaseStore) = compileFinal ([
private _command = ["owned:locker:hot:fetch", "owned:locker:hot:init"] select _initialize;
_self call ["callHotVArsenal", [_command, [_uid]]]
}],
["isPersistentVArsenalInitialized", compileFinal {
params [["_uid", "", [""]]];
if (_uid isEqualTo "") exitWith { false };
["owned:locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"];
_isSuccess && { _result isEqualTo "true" }
}],
["seedStartingUnlocks", compileFinal {
params [["_uid", "", [""]], ["_arsenal", createHashMap, [createHashMap]]];
if (_uid isEqualTo "" || { _arsenal isEqualTo createHashMap }) exitWith { _arsenal };
private _defaults = GVAR(VArsenalModel) call ["defaults", []];
private _seeded = +_arsenal;
{
_seeded set [_x, +_y];
} forEach _defaults;
private _updated = _self call ["callHotVArsenal", ["owned:locker:hot:override", [_uid, toJSON _seeded]]];
if (_updated isEqualTo createHashMap) exitWith { _seeded };
_self call ["callHotVArsenal", ["owned:locker:hot:save", [_uid]]];
_updated
}],
["init", compileFinal {
params [["_uid", "", [""]]];
private _player = [_uid] call EFUNC(common,getPlayer);
if (isNull _player) exitWith { createHashMap };
private _hasPersistentArsenal = _self call ["isPersistentVArsenalInitialized", [_uid]];
private _arsenal = _self call ["loadHotVArsenal", [_uid, true]];
if (_arsenal isEqualTo createHashMap) then {
_arsenal = GVAR(VArsenalModel) call ["defaults", []];
["ERROR", format ["Failed to initialize virtual arsenal for %1! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log);
};
if !(_hasPersistentArsenal) then {
_arsenal = _self call ["seedStartingUnlocks", [_uid, _arsenal]];
};
[CRPC(locker,responseInitVA), [_arsenal], _player] call CFUNC(targetEvent);
_arsenal

View File

@ -4,10 +4,10 @@ PREP_RECOMPILE_START;
#include "XEH_PREP.hpp"
PREP_RECOMPILE_END;
GVAR(PlayerBootstrapRegistry) = createHashMap;
if (isServer) then { "forge_server" callExtension ["surreal:reconnect", []]; };
GVAR(PlayerBootstrapRegistry) = createHashMap;
["forge_icom_event", {
params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]]];

View File

@ -19,10 +19,83 @@ extension owns authoritative checkout calculation through `store:checkout`.
- `fnc_initStore.sqf` marks editor-placed store objects with `isStore = true`.
- `fnc_initCatalogService.sqf` scans live Arma config categories, builds
catalog responses, resolves checkout entries, and calculates authoritative
catalog prices.
catalog prices. It also applies the optional mission `CfgStore` filter and
overrides before payloads or checkout validation use catalog entries.
- `fnc_initStorefrontStore.sqf` builds hydrate payloads, validates checkout
requests, calls `store:checkout`, syncs client patches, and coordinates
related bank/org persistence.
related bank/org persistence. Purchased units are fulfilled by spawning the
granted unit classes at discovered `unit_spawn` markers after the backend
charge succeeds.
## Mission Catalog Filter
Missions can include `CfgStore.hpp` from `description.ext` to control the
generated catalog without changing the addon.
```cpp
class CfgStore {
mode = "allowlist"; // dynamic, allowlist, or denylist
modMode = "dynamic"; // dynamic, allowlist, or denylist
mods[] = {}; // ModSources class names used when modMode is not dynamic
class ModSources {
class rhs {
patches[] = {"rhs_main", "rhsusf_main"};
addons[] = {"rhs_", "rhsusf_", "rhsgref_", "rhsafrf_"};
prefixes[] = {"rhs_", "rhsusf_", "rhsgref_", "rhsafrf_"};
};
class ace3 {
patches[] = {"ace_main"};
addons[] = {"ace_"};
prefixes[] = {"ace_"};
};
};
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.
`modMode` applies before category filtering. `dynamic` means no mod-source
filtering. `allowlist` only keeps generated entries that match one of the
configured `mods[]`; `denylist` removes matching entries. Each `ModSources`
child can define `patches[]` to detect whether the mod is loaded, `addons[]`
for exact config source addon/source mod names, and `prefixes[]` for classname,
source addon, or source mod prefixes. If a mod source defines no patches, it is
treated as available and only the source/prefix checks are used.
`units[]` follows the same `dynamic`, `allowlist`, and `denylist` behavior as
item and vehicle categories. Unit purchases are immediate spawn grants, not
durable virtual garage unlocks.
The filter is currently global for the mission. Revisit per-store profile
support if individual vendors need different inventories.
## Unit Spawn Markers
Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
`unit_spawn_2`, and so on. The store resolves the closest initialized store
object to the requesting player, scans `allMapMarkers` at fulfillment time, and
uses the closest matching marker within 25 meters of that store.
If no matching marker exists within 25 meters, the store falls back to spawning
units around the store object. If no store object can be resolved, it falls back
to the requesting player so checkout still completes.
## Editor Entities
`fnc_initStore` matches non-null mission namespace objects whose variable names
@ -31,8 +104,8 @@ contain `store`, mirroring the garage entity initialization pattern.
## Checkout Flow
Store checkout can charge cash, bank balance, organization funds, or approved
credit lines depending on the hydrated session context. Checkout results can
grant locker assets, organization assets, and fleet vehicles through the
related domain stores.
grant locker assets, organization assets, fleet vehicles, and immediate unit
spawns through the related domain stores and Arma server runtime.
Checkout results emit notifications and syncs through the event bus:
- `notification.requested` - receipt and transaction alerts

View File

@ -17,6 +17,226 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_self set ["catalogCache", createHashMap];
["INFO", "Store catalog service initialized!"] call EFUNC(common,log);
}],
["getMissionStoreConfig", compileFinal {
missionConfigFile >> "CfgStore"
}],
["getMissionStoreMode", compileFinal {
private _storeConfig = _self call ["getMissionStoreConfig", []];
private _mode = toLowerANSI getText (_storeConfig >> "mode");
if !(_mode in ["allowlist", "denylist", "dynamic"]) then { _mode = "dynamic"; };
_mode
}],
["getMissionStoreModMode", compileFinal {
private _storeConfig = _self call ["getMissionStoreConfig", []];
private _mode = toLowerANSI getText (_storeConfig >> "modMode");
if !(_mode in ["allowlist", "denylist", "dynamic"]) then { _mode = "dynamic"; };
_mode
}],
["getMissionStoreModList", compileFinal {
private _storeConfig = _self call ["getMissionStoreConfig", []];
private _mods = [];
if (isArray (_storeConfig >> "mods")) then {
_mods = getArray (_storeConfig >> "mods");
};
_mods apply {
private _modID = "";
if (_x isEqualType "") then {
_modID = _x;
} else {
_modID = str _x;
};
toLowerANSI _modID
}
}],
["getMissionStoreModSourceValues", compileFinal {
params [["_modID", "", [""]], ["_key", "", [""]]];
private _storeConfig = _self call ["getMissionStoreConfig", []];
private _sourceConfig = _storeConfig >> "ModSources" >> _modID;
private _values = [];
if (isArray (_sourceConfig >> _key)) then {
_values = getArray (_sourceConfig >> _key);
};
_values apply {
if (_x isEqualType "") then {
_x
} else {
str _x
}
}
}],
["isMissionStoreModLoaded", compileFinal {
params [["_modID", "", [""]]];
private _patches = _self call ["getMissionStoreModSourceValues", [_modID, "patches"]];
if (_patches isEqualTo []) exitWith { true };
private _loaded = false;
{
if (isClass (configFile >> "CfgPatches" >> _x)) exitWith { _loaded = true; };
} forEach _patches;
_loaded
}],
["doesValueMatchAnyPrefix", compileFinal {
params [["_value", "", [""]], ["_prefixes", [], [[]]]];
private _normalizedValue = toLowerANSI _value;
private _matches = false;
{
private _prefix = toLowerANSI _x;
if (_prefix isEqualTo "") then { continue; };
if ((_normalizedValue select [0, count _prefix]) isEqualTo _prefix) exitWith { _matches = true; };
} forEach _prefixes;
_matches
}],
["doesItemMatchMissionStoreMod", compileFinal {
params [["_item", createHashMap, [createHashMap]], ["_modID", "", [""]]];
if (_item isEqualTo createHashMap || { _modID isEqualTo "" }) exitWith { false };
if !(_self call ["isMissionStoreModLoaded", [_modID]]) exitWith { false };
private _className = _item getOrDefault ["className", ""];
private _sourceAddons = (_item getOrDefault ["sourceAddons", []]) apply { toLowerANSI _x };
private _sourceMod = _item getOrDefault ["sourceMod", ""];
private _addons = (_self call ["getMissionStoreModSourceValues", [_modID, "addons"]]) apply { toLowerANSI _x };
private _prefixes = (_self call ["getMissionStoreModSourceValues", [_modID, "prefixes"]]) apply { toLowerANSI _x };
private _matchPrefixes = _addons + _prefixes;
private _sourceModLower = toLowerANSI _sourceMod;
if (_sourceModLower in _addons) exitWith { true };
private _sourceAddonMatched = false;
{
if (_x in _addons) exitWith { _sourceAddonMatched = true; };
if (_self call ["doesValueMatchAnyPrefix", [_x, _matchPrefixes]]) exitWith { _sourceAddonMatched = true; };
} forEach _sourceAddons;
if (_sourceAddonMatched) exitWith { true };
if (_self call ["doesValueMatchAnyPrefix", [_className, _matchPrefixes]]) exitWith { true };
if (_self call ["doesValueMatchAnyPrefix", [_sourceMod, _matchPrefixes]]) exitWith { true };
false
}],
["doesItemMatchMissionStoreMods", compileFinal {
params [["_item", createHashMap, [createHashMap]], ["_mods", [], [[]]]];
private _matches = false;
{
if (_self call ["doesItemMatchMissionStoreMod", [_item, _x]]) exitWith { _matches = true; };
} forEach _mods;
_matches
}],
["applyMissionStoreModFilter", compileFinal {
params [["_items", [], [[]]]];
private _mode = _self call ["getMissionStoreModMode", []];
private _mods = _self call ["getMissionStoreModList", []];
if (_mode isEqualTo "dynamic" || { _mods isEqualTo [] }) exitWith { +_items };
switch (_mode) do {
case "allowlist": {
_items select { _self call ["doesItemMatchMissionStoreMods", [_x, _mods]] }
};
case "denylist": {
_items select { !(_self call ["doesItemMatchMissionStoreMods", [_x, _mods]]) }
};
default {
+_items
};
}
}],
["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 = _self call ["applyMissionStoreModFilter", [_items]];
switch (_mode) do {
case "allowlist": {
_filteredItems = _filteredItems select {
(toLowerANSI (_x getOrDefault ["className", ""])) in _classNames
};
};
case "denylist": {
_filteredItems = _filteredItems select {
!((toLowerANSI (_x getOrDefault ["className", ""])) in _classNames)
};
};
};
_filteredItems apply { _self call ["applyMissionStoreOverrides", [_x]] }
}],
["formatCurrency", compileFinal {
params [["_amount", 0, [0]]];
@ -82,6 +302,8 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _className = configName _cfg;
private _displayName = getText (_cfg >> "displayName");
private _sourceAddons = configSourceAddonList _cfg;
private _sourceMod = configSourceMod _cfg;
private _picture = getText (_cfg >> _imageField);
if (_picture isEqualTo "" && { _imageField isNotEqualTo "picture" }) then {
_picture = getText (_cfg >> "picture");
@ -97,7 +319,9 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["price", _self call ["formatCurrency", [_priceValue]]],
["priceValue", _priceValue],
["image", _picture],
["type", _typeLabel]
["type", _typeLabel],
["sourceAddons", _sourceAddons],
["sourceMod", _sourceMod]
]
}],
["appendCfgWeaponsByItemInfoType", compileFinal {
@ -196,6 +420,22 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_items
}],
["appendCfgUnits", compileFinal {
params [["_items", [], [[]]], ["_typeLabel", "Unit", [""]], ["_fallbackDescription", "", [""]]];
{
private _cfg = _x;
private _className = configName _cfg;
if (
_self call ["isVisibleConfig", [_cfg]]
&& { _className isKindOf ["CAManBase", configFile >> "CfgVehicles"] }
) then {
_items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]);
};
} forEach ("true" configClasses (configFile >> "CfgVehicles"));
_items
}],
["isBackpackConfig", compileFinal {
params [["_cfg", configNull, [configNull]]];
@ -270,6 +510,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
case "helis": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]]; };
case "planes": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]]; };
case "naval": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]]; };
case "units": { _items = _self call ["appendCfgUnits", [_items, "Unit", "Live unit entry generated from the game inventory."]]; };
case "other": {
{
private _cfg = _x;
@ -305,10 +546,16 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
(toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"]
}],
["isUnitCategory", compileFinal {
params [["_category", "", [""]]];
(toLowerANSI _category) isEqualTo "units"
}],
["buildPayloadCategory", compileFinal {
params [["_category", "", [""]]];
switch (toLowerANSI _category) do {
case "units": { "units" };
case "backpacks": { "backpack" };
case "attachments": { "attachment" };
case "ammo": { "magazine" };
@ -327,7 +574,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
["isSupportedCategory", compileFinal {
params [["_category", "", [""]]];
(_self call ["normalizeCategoryKey", [_category]]) in ["uniforms", "headgear", "vests", "backpacks", "attachments", "facewear", "ammo", "misc", "primary", "handgun", "secondary", "cars", "armor", "helis", "planes", "naval", "other"]
(_self call ["normalizeCategoryKey", [_category]]) in ["uniforms", "headgear", "vests", "backpacks", "attachments", "facewear", "ammo", "misc", "primary", "handgun", "secondary", "cars", "armor", "helis", "planes", "naval", "other", "units"]
}],
["buildCategoryItems", compileFinal {
params [["_category", "", [""]]];
@ -340,13 +587,17 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _items = _self call ["scanCategoryItems", [_categoryKey]];
private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]];
private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]);
private _entryKind = "item";
if (_self call ["isVehicleCategory", [_categoryKey]]) then { _entryKind = "vehicle"; };
if (_self call ["isUnitCategory", [_categoryKey]]) then { _entryKind = "unit"; };
{
_x set ["category", _payloadCategory];
_x set ["entryKind", _entryKind];
} forEach _items;
_items = _self call ["applyMissionStoreFilter", [_categoryKey, _items]];
_catalogCache set [_categoryKey, _items];
_self set ["catalogCache", _catalogCache];
@ -376,6 +627,7 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
private _category = toLowerANSI (_entry getOrDefault ["category", ""]);
if (_entryKind isEqualTo "vehicle") exitWith { ["cars", "armor", "helis", "planes", "naval", "other"] };
if (_entryKind isEqualTo "unit" || { _category isEqualTo "units" }) exitWith { ["units"] };
if (_category isEqualTo "weapon") exitWith { ["primary", "handgun", "secondary"] };
if (_category isEqualTo "backpack") exitWith { ["backpacks"] };
if (_category isEqualTo "attachment") exitWith { ["attachments"] };
@ -400,19 +652,21 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_resolved
}],
["buildCheckoutRequest", compileFinal {
params [["_items", [], [[]]], ["_vehicles", [], [[]]]];
params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
private _result = createHashMapFromArray [
["success", false],
["total", 0],
["message", "Checkout total must be greater than zero."],
["items", []],
["vehicles", []]
["vehicles", []],
["units", []]
];
private _total = 0;
private _message = "";
private _resolvedItems = [];
private _resolvedVehicles = [];
private _resolvedUnits = [];
{
if (_message isEqualTo "") then {
@ -463,6 +717,29 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
};
} forEach _vehicles;
{
if (_message isEqualTo "") then {
private _className = _x getOrDefault ["classname", ""];
if (_className isEqualTo "") then {
_message = "Checkout contains an invalid unit entry.";
} else {
private _catalogEntry = _self call ["resolveCheckoutCatalogEntry", [createHashMapFromArray [["classname", _className], ["category", "units"], ["entryKind", "unit"]]]];
if (_catalogEntry isEqualTo createHashMap) then {
_message = format ["Unsupported store unit: %1", _className];
} else {
private _priceValue = _catalogEntry getOrDefault ["priceValue", 0];
_total = _total + _priceValue;
_resolvedUnits pushBack (createHashMapFromArray [
["classname", _className],
["category", "units"],
["priceValue", _priceValue]
]);
};
};
};
} forEach _units;
if (_message isNotEqualTo "") exitWith {
_result set ["message", _message];
_result
@ -475,12 +752,13 @@ GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [
_result set ["message", ""];
_result set ["items", _resolvedItems];
_result set ["vehicles", _resolvedVehicles];
_result set ["units", _resolvedUnits];
_result
}],
["calculateCheckoutTotal", compileFinal {
params [["_items", [], [[]]], ["_vehicles", [], [[]]]];
params [["_items", [], [[]]], ["_vehicles", [], [[]]], ["_units", [], [[]]]];
private _checkout = _self call ["buildCheckoutRequest", [_items, _vehicles]];
private _checkout = _self call ["buildCheckoutRequest", [_items, _vehicles, _units]];
createHashMapFromArray [
["success", _checkout getOrDefault ["success", false]],
["total", _checkout getOrDefault ["total", 0]],

View File

@ -155,6 +155,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["chargedTotal", 0],
["lockerGranted", []],
["vehicleGranted", []],
["unitGranted", []],
["bankPatch", createHashMap],
["orgPatch", createHashMap],
["orgTargetUids", []],
@ -168,6 +169,71 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
format ["$%1", [_amount max 0] call EFUNC(common,formatNumber)]
}],
["getUnitSpawnMarkers", compileFinal {
private _markers = allMapMarkers select {
private _markerName = toLowerANSI _x;
_markerName isEqualTo "unit_spawn" || { (_markerName find "unit_spawn_") == 0 }
};
_markers sort true;
_markers
}],
["getStoreObjects", compileFinal {
(allVariables missionNamespace) apply { missionNamespace getVariable [_x, objNull] } select {
_x isEqualType objNull
&& { !isNull _x }
&& { _x getVariable ["isStore", false] }
}
}],
["getClosestStoreObject", compileFinal {
params [["_origin", objNull, [objNull]]];
if (isNull _origin) exitWith { objNull };
private _stores = _self call ["getStoreObjects", []];
if (_stores isEqualTo []) exitWith { objNull };
private _closestStore = objNull;
private _closestDistance = 1e12;
{
private _distance = _origin distance2D _x;
if (_distance < _closestDistance) then {
_closestDistance = _distance;
_closestStore = _x;
};
} forEach _stores;
_closestStore
}],
["getClosestUnitSpawnMarker", compileFinal {
params [["_origin", objNull, [objNull, []]], ["_maxDistance", -1, [0]]];
private _markers = _self call ["getUnitSpawnMarkers", []];
if (_markers isEqualTo []) exitWith { "" };
private _originPosition = if (_origin isEqualType objNull) then {
getPosATL _origin
} else {
_origin
};
if (_maxDistance >= 0) then {
_markers = _markers select { ((getMarkerPos _x) distance2D _originPosition) <= _maxDistance };
if (_markers isEqualTo []) exitWith { "" };
};
private _closestMarker = "";
private _closestDistance = 1e12;
{
private _distance = _originPosition distance2D (getMarkerPos _x);
if (_distance < _closestDistance) then {
_closestDistance = _distance;
_closestMarker = _x;
};
} forEach _markers;
_closestMarker
}],
["callCheckoutBackendEnvelope", compileFinal {
params [["_context", createHashMap, [createHashMap]]];
@ -207,7 +273,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["_player", objNull, [objNull]],
["_paymentMethod", "cash", [""]],
["_items", [], [[]]],
["_vehicles", [], [[]]]
["_vehicles", [], [[]]],
["_units", [], [[]]]
];
if (_uid isEqualTo "" || { isNull _player }) exitWith { createHashMap };
@ -225,9 +292,58 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
["requesterIsDefaultOrgCeo", _requesterIsDefaultOrgCeo],
["paymentMethod", toLowerANSI _paymentMethod],
["items", _items],
["vehicles", _vehicles]
["vehicles", _vehicles],
["units", _units]
]
}],
["spawnPurchasedUnits", compileFinal {
params [["_player", objNull, [objNull]], ["_units", [], [[]]]];
private _result = createHashMapFromArray [
["spawned", []],
["failed", []]
];
if (isNull _player || { _units isEqualTo [] }) exitWith { _result };
private _group = group _player;
private _store = _self call ["getClosestStoreObject", [_player]];
private _spawnAnchor = [objNull, _store] select !(isNull _store);
if (isNull _spawnAnchor) then { _spawnAnchor = _player; };
private _spawnMarker = "";
if !(isNull _store) then {
_spawnMarker = _self call ["getClosestUnitSpawnMarker", [_store, 25]];
};
{
private _className = _x getOrDefault ["classname", ""];
if (_className isEqualTo "" || { !(isClass (configFile >> "CfgVehicles" >> _className)) }) then {
(_result get "failed") pushBack _className;
} else {
private _basePosition = getPosATL _spawnAnchor;
private _baseDirection = getDir _spawnAnchor;
if (_spawnMarker isNotEqualTo "") then {
_basePosition = getMarkerPos _spawnMarker;
_baseDirection = markerDir _spawnMarker;
};
private _spawnPos = _basePosition findEmptyPosition [0, 18 + (_forEachIndex min 12), _className];
if (_spawnPos isEqualTo []) then {
_spawnPos = _basePosition getPos [3 + _forEachIndex, _baseDirection + 90];
};
private _unit = _group createUnit [_className, _spawnPos, [], 0, "NONE"];
if (isNull _unit) then {
(_result get "failed") pushBack _className;
} else {
_unit setDir _baseDirection;
[_unit] joinSilent _group;
(_result get "spawned") pushBack _className;
};
};
} forEach _units;
_result
}],
["syncCheckoutResult", compileFinal {
params [["_player", objNull, [objNull]], ["_result", createHashMap, [createHashMap]]];
@ -238,6 +354,7 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _vgPatch = _result getOrDefault ["vgaragePatch", createHashMap];
private _bankPatch = _result getOrDefault ["bankPatch", createHashMap];
private _orgPatch = _result getOrDefault ["orgPatch", createHashMap];
private _unitGranted = _result getOrDefault ["unitGranted", []];
private _uid = getPlayerUID _player;
if (keys _lockerPatch isNotEqualTo []) then {
@ -320,6 +437,14 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
};
};
if (_unitGranted isNotEqualTo []) then {
private _unitSpawnResult = _self call ["spawnPurchasedUnits", [_player, _unitGranted]];
private _failedUnits = _unitSpawnResult getOrDefault ["failed", []];
if (_failedUnits isNotEqualTo []) then {
["ERROR", format ["Store checkout unit spawn failed for %1: %2", _uid, _failedUnits joinString ", "]] call EFUNC(common,log);
};
};
true
}],
["persistCheckoutState", compileFinal {
@ -398,19 +523,20 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
private _paymentMethod = toLowerANSI (_payload getOrDefault ["paymentMethod", "cash"]);
private _items = _payload getOrDefault ["items", []];
private _vehicles = _payload getOrDefault ["vehicles", []];
private _units = _payload getOrDefault ["units", []];
if (isNil QGVAR(StoreCatalogService)) exitWith {
_result set ["message", "Store catalog service is unavailable."];
_result
};
private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles]];
private _checkoutRequest = GVAR(StoreCatalogService) call ["buildCheckoutRequest", [_items, _vehicles, _units]];
private _totalPrice = _checkoutRequest getOrDefault ["total", 0];
_result set ["paymentMethod", _paymentMethod];
_result set ["chargedTotal", _totalPrice];
if (_items isEqualTo [] && { _vehicles isEqualTo [] }) exitWith {
if (_items isEqualTo [] && { _vehicles isEqualTo [] } && { _units isEqualTo [] }) exitWith {
_result set ["message", "Add at least one item before checkout."];
_result
};
@ -425,7 +551,8 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_player,
_paymentMethod,
_checkoutRequest getOrDefault ["items", []],
_checkoutRequest getOrDefault ["vehicles", []]
_checkoutRequest getOrDefault ["vehicles", []],
_checkoutRequest getOrDefault ["units", []]
]];
if (_checkoutContext isEqualTo createHashMap) exitWith {
_result set ["message", "Checkout request context was invalid."];
@ -451,13 +578,15 @@ GVAR(StorefrontBaseStore) = compileFinal createHashMapFromArray [
_result set ["success", true];
_result set ["message", _backendResult getOrDefault ["message", format [
"Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s).",
"Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s), %4 unit grant(s).",
_self call ["formatCurrency", [_totalPrice]],
count (_backendResult getOrDefault ["lockerGranted", []]),
count (_backendResult getOrDefault ["vehicleGranted", []])
count (_backendResult getOrDefault ["vehicleGranted", []]),
count (_backendResult getOrDefault ["unitGranted", []])
]]];
_result set ["lockerGranted", _backendResult getOrDefault ["lockerGranted", []]];
_result set ["vehicleGranted", _backendResult getOrDefault ["vehicleGranted", []]];
_result set ["unitGranted", _backendResult getOrDefault ["unitGranted", []]];
_result set ["persistenceSucceeded", _persistenceResult getOrDefault ["success", false]];
_result set ["persistenceFailures", _persistenceResult getOrDefault ["failures", []]];
_result set ["persistenceMessage", _persistenceResult getOrDefault ["message", ""]];

View File

@ -226,10 +226,10 @@ If you want the accepting player's org to own the task rewards, use `fnc_handler
- compiles functions
- initializes `TaskStore`
- initializes task instance and entity controller classes
- initializes generated mission provider objects and registers the built-in provider
- registers task lifecycle log and notification listeners with the event bus
- `XEH_postInit.sqf`
- registers task lifecycle event listeners with the event bus
- handles task reward, notification, and rating events
- syncs org and bank state through event bus listeners
- registers CBA server events for provider registration and mission setup requests
- registers the ACE defuse event hook
## Events Emitted
@ -246,7 +246,8 @@ Task module emits the following events to the event bus:
## Notes
- the dynamic mission manager in `fnc_missionManager.sqf` is initialized during task post-init; timer-based mission generation only runs when the `forge_server_task_enableGenerator` CBA setting is enabled
- CAD can request a specific generated mission type through `fnc_requestMissionTask.sqf`
- CAD hydrates generated mission types and requests generated missions through `MissionGeneratorProviderRegistry`
- custom generated mission providers register through the `forge_server_task_registerMissionGeneratorProvider` CBA server event
- it starts server-owned tasks through `fnc_handler.sqf` and binds them to the `default` org
- task lifecycle for the mission manager is tracked through `TaskStore` status entries
- task backend state is intentionally transient and resets with the active server/mission lifecycle

View File

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

View File

@ -3,6 +3,15 @@
if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); true };
if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService); };
[SRPC(task,registerMissionGeneratorProvider), {
params [
["_providerId", "", [""]],
["_provider", createHashMap, [createHashMap]]
];
GVAR(MissionGeneratorProviderRegistry) call ["registerProvider", [_providerId, _provider]];
}] call CFUNC(addEventHandler);
[SRPC(task,requestOpenMissionSetup), {
params [
["_requester", objNull, [objNull]]
@ -100,116 +109,10 @@ if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService);
]] call EFUNC(common,log);
};
if (isNil QGVAR(MissionSetupService)) then { call FUNC(initMissionSetupService); };
["INFO", format ["Mission setup apply request accepted. Requester=%1 VarName=%2", _requester, _requesterVar]] call EFUNC(common,log);
GVAR(MissionSetupService) call ["apply", [_overrides]];
}] call CFUNC(addEventHandler);
if (isNil QGVAR(TaskLifecycleEventLogTokens)) then {
private _logTaskLifecycleEvent = {
params ["_event"];
if !(GETGVAR(enableEventLogs,false)) exitWith {};
["INFO", format [
"Task lifecycle event: %1 taskID=%2 taskType=%3 status=%4 participants=%5",
_event getOrDefault ["event", ""],
_event getOrDefault ["taskID", ""],
_event getOrDefault ["taskType", ""],
_event getOrDefault ["status", ""],
_event getOrDefault ["participants", []]
]] call EFUNC(common,log);
};
private _logTaskRewardEvent = {
params ["_event"];
if !(GETGVAR(enableEventLogs,false)) exitWith {};
["INFO", format [
"Task reward event: %1 taskID=%2 success=%3 message=%4",
_event getOrDefault ["event", ""],
_event getOrDefault ["taskID", ""],
!((_event getOrDefault ["event", ""]) in ["task.reward.failed", "task.rating.failed"]),
_event getOrDefault ["message", ""]
]] call EFUNC(common,log);
};
GVAR(TaskLifecycleEventLogTokens) = [
EGVAR(common,EventBus) call ["on", ["task.created", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.started", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.completed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.failed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.cleared", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.requested", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.applied", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.failed", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.rating.applied", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.rating.failed", _logTaskRewardEvent, "task.reward.log"]]
];
};
if (isNil QGVAR(TaskNotificationEventTokens)) then {
private _sendTaskNotification = {
params ["_event"];
private _type = _event getOrDefault ["notificationType", "info"];
private _title = _event getOrDefault ["title", "Tasks"];
private _message = _event getOrDefault ["message", ""];
private _participantUids = +(_event getOrDefault ["participantUids", []]);
if (_message isEqualTo "" || { _participantUids isEqualTo [] }) exitWith {};
{
private _player = [_x] call EFUNC(common,getPlayer);
if (isNull _player) then { continue; };
[CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
} forEach _participantUids;
if (GETGVAR(enableEventLogs,false)) then {
["INFO", format [
"Task notification event: taskID=%1 type=%2 recipients=%3 message=%4",
_event getOrDefault ["taskID", ""],
_type,
_participantUids,
_message
]] call EFUNC(common,log);
};
};
private _sendRewardNotification = {
params ["_event"];
private _type = _event getOrDefault ["notificationType", "info"];
private _title = _event getOrDefault ["title", "Tasks"];
private _message = _event getOrDefault ["message", ""];
private _memberUids = +(_event getOrDefault ["memberUids", []]);
if (_message isEqualTo "" || { _memberUids isEqualTo [] }) exitWith {};
{
private _player = [_x] call EFUNC(common,getPlayer);
if (isNull _player) then { continue; };
[CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
} forEach _memberUids;
if (GETGVAR(enableEventLogs,false)) then {
["INFO", format [
"Task reward notification event: taskID=%1 type=%2 recipients=%3 message=%4",
_event getOrDefault ["taskID", ""],
_type,
_memberUids,
_message
]] call EFUNC(common,log);
};
};
GVAR(TaskNotificationEventTokens) = [
EGVAR(common,EventBus) call ["on", ["task.notification.requested", _sendTaskNotification, "task.notification.send"]],
EGVAR(common,EventBus) call ["on", ["task.reward.notification.requested", _sendRewardNotification, "task.reward.notification.send"]]
];
};
["ace_explosives_defuse", {
private _taskID = "";
private _explosive = objNull;

View File

@ -8,30 +8,37 @@ private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)];
#include "initSettings.inc.sqf"
[] call FUNC(TaskStateGateway);
[] call FUNC(TaskLifecycleReporter);
[] call FUNC(TaskCatalogStore);
[] call FUNC(TaskEntityRegistry);
[] call FUNC(TaskParticipantTracker);
[] call FUNC(TaskRewardService);
[] call FUNC(TaskInstanceBaseClass);
[] call FUNC(EntityControllerBaseClass);
[] call FUNC(AttackTaskBaseClass);
[] call FUNC(HostageTaskBaseClass);
[] call FUNC(HostageEntityController);
[] call FUNC(TargetEntityController);
[] call FUNC(ShooterEntityController);
[] call FUNC(HVTEntityController);
[] call FUNC(CargoEntityController);
[] call FUNC(ProtectedEntityController);
[] call FUNC(IEDEntityController);
[] call FUNC(DefenseEnemyController);
[] call FUNC(DefuseTaskBaseClass);
[] call FUNC(DestroyTaskBaseClass);
[] call FUNC(DeliveryTaskBaseClass);
[] call FUNC(HVTTaskBaseClass);
[] call FUNC(DefendTaskBaseClass);
call FUNC(TaskStateGateway);
call FUNC(TaskLifecycleReporter);
call FUNC(TaskCatalogStore);
call FUNC(TaskEntityRegistry);
call FUNC(TaskParticipantTracker);
call FUNC(TaskRewardService);
call FUNC(TaskNotificationService);
call FUNC(MissionGeneratorProviderRegistry);
call FUNC(BuiltinMissionGeneratorProvider);
call FUNC(TaskInstanceBaseClass);
call FUNC(EntityControllerBaseClass);
call FUNC(AttackTaskBaseClass);
call FUNC(HostageTaskBaseClass);
call FUNC(HostageEntityController);
call FUNC(TargetEntityController);
call FUNC(ShooterEntityController);
call FUNC(HVTEntityController);
call FUNC(CargoEntityController);
call FUNC(ProtectedEntityController);
call FUNC(IEDEntityController);
call FUNC(DefenseEnemyController);
call FUNC(DefuseTaskBaseClass);
call FUNC(DestroyTaskBaseClass);
call FUNC(DeliveryTaskBaseClass);
call FUNC(HVTTaskBaseClass);
call FUNC(DefendTaskBaseClass);
call FUNC(initTaskStore);
call FUNC(initMissionSetupService);
GVAR(MissionGeneratorProviderRegistry) call ["registerProvider", ["builtin", GVAR(BuiltinMissionGeneratorProvider)]];
GVAR(TaskLifecycleReporter) call ["registerEventLogListeners", []];
GVAR(TaskNotificationService) call ["registerEventListeners", []];
if !(isNil QGVAR(TaskStore)) then { GVAR(TaskStore) call ["resetMissionState", []]; };

View File

@ -23,6 +23,13 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
};
_missionConfig
}],
["getServicePricingConfig", compileFinal {
private _pricingConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _pricingConfig) then {
_pricingConfig = configFile >> "CfgServicePricing";
};
_pricingConfig
}],
["numberOrDefault", compileFinal {
params ["_value", "_default"];
@ -80,7 +87,18 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
_overrides getOrDefault [_varName, _default]
};
missionNamespace getVariable [_varName, _default]
private _paramValue = [_varName, _default] call BIS_fnc_getParamValue;
missionNamespace getVariable [_varName, _paramValue]
};
private _serviceDefault = {
params ["_varName", "_default"];
private _serviceConfig = _self call ["getServicePricingConfig", []];
if (isNumber (_serviceConfig >> _varName)) exitWith {
getNumber (_serviceConfig >> _varName)
};
_default
};
private _maxConcurrent = [
@ -104,6 +122,17 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
private _penMax = [["penaltyMax", -25, _overrides] call _paramOrDefault, -25] call (_self get "numberOrDefault");
private _timeMin = [["timeLimitMin", 600, _overrides] call _paramOrDefault, 600] call (_self get "numberOrDefault");
private _timeMax = [["timeLimitMax", 900, _overrides] call _paramOrDefault, 900] call (_self get "numberOrDefault");
private _medicalSpawnCost = [["medicalSpawnCost", ["medicalSpawnCost", 100] call _serviceDefault, _overrides] call _paramOrDefault, 100] call (_self get "numberOrDefault");
private _medicalHealCost = [["medicalHealCost", ["medicalHealCost", 100] call _serviceDefault, _overrides] call _paramOrDefault, 100] call (_self get "numberOrDefault");
private _serviceRepairCost = [["serviceRepairCost", ["serviceRepairCost", 500] call _serviceDefault, _overrides] call _paramOrDefault, 500] call (_self get "numberOrDefault");
private _serviceRearmCost = [["serviceRearmCost", ["serviceRearmCost", 500] call _serviceDefault, _overrides] call _paramOrDefault, 500] call (_self get "numberOrDefault");
private _fuelCost = [["fuelCost", ["fuelCost", 5] call _serviceDefault, _overrides] call _paramOrDefault, 5] call (_self get "numberOrDefault");
private _transportBaseFare = [["transportBaseFare", ["transportBaseFare", 100] call _serviceDefault, _overrides] call _paramOrDefault, 100] call (_self get "numberOrDefault");
private _transportPricePerKm = [["transportPricePerKm", ["transportPricePerKm", 50] call _serviceDefault, _overrides] call _paramOrDefault, 50] call (_self get "numberOrDefault");
private _generatorProvider = _overrides getOrDefault ["generatorProvider", GETGVAR(generatorProvider,"builtin")];
if !(_generatorProvider isEqualType "") then { _generatorProvider = str _generatorProvider; };
_generatorProvider = toLowerANSI _generatorProvider;
if !(_generatorProvider in ["builtin", "custom"]) then { _generatorProvider = "builtin"; };
private _enemyFaction = _overrides getOrDefault [
"enemyFaction",
@ -125,8 +154,15 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
_penMin = _penMin min 0;
_penMax = _penMax min 0;
_timeMin = _timeMin max 1;
_timeMin = _timeMin max 0;
_timeMax = _timeMax max _timeMin;
_medicalSpawnCost = _medicalSpawnCost max 0;
_medicalHealCost = _medicalHealCost max 0;
_serviceRepairCost = _serviceRepairCost max 0;
_serviceRearmCost = _serviceRearmCost max 0;
_fuelCost = _fuelCost max 0;
_transportBaseFare = _transportBaseFare max 0;
_transportPricePerKm = _transportPricePerKm max 0;
private _settings = createHashMapFromArray [
["useMenuSettings", true],
@ -141,11 +177,31 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
["penaltyMax", _penMax],
["timeLimitMin", _timeMin],
["timeLimitMax", _timeMax],
["enemyFaction", _enemyFaction]
["medicalSpawnCost", _medicalSpawnCost],
["medicalHealCost", _medicalHealCost],
["serviceRepairCost", _serviceRepairCost],
["serviceRearmCost", _serviceRearmCost],
["fuelCost", _fuelCost],
["transportBaseFare", _transportBaseFare],
["transportPricePerKm", _transportPricePerKm],
["enemyFaction", _enemyFaction],
["generatorProvider", _generatorProvider]
];
SETMPVAR(GVAR(missionSetup_settings),_settings);
SETMPVAR(GVAR(missionSetup_settingsApplied),true);
SETMPVAR(GVAR(generatorProvider),_generatorProvider);
{
missionNamespace setVariable [_x, _settings getOrDefault [_x, 0], true];
} forEach [
"medicalSpawnCost",
"medicalHealCost",
"serviceRepairCost",
"serviceRearmCost",
"fuelCost",
"transportBaseFare",
"transportPricePerKm"
];
private _side = _self call ["resolveFactionSide", [_enemyFaction, east]];
ENEMY_SIDE = _side;
@ -153,11 +209,12 @@ GVAR(MissionSetupServiceBaseClass) = compileFinal createHashMapFromArray [
publicVariable "ENEMY_SIDE";
["INFO", format [
"Framework mission setup applied. Faction=%1, Side=%2, MaxConcurrent=%3, Interval=%4",
"Framework mission setup applied. Faction=%1, Side=%2, MaxConcurrent=%3, Interval=%4, GeneratorProvider=%5",
_enemyFaction,
_side,
_maxConcurrent,
_interval
_interval,
_generatorProvider
]] call EFUNC(common,log);
if !(isNil QEGVAR(common,EventBus)) then {

View File

@ -56,6 +56,9 @@ GVAR(TaskStore) = createHashMapObject [[
["isTaskCompleted", compileFinal {
GVAR(TaskCatalogStore) call ["isTaskCompleted", _this]
}],
["isTerminalStatus", compileFinal {
GVAR(TaskCatalogStore) call ["isTerminalStatus", _this]
}],
["areTaskPrerequisitesSatisfied", compileFinal {
GVAR(TaskCatalogStore) call ["areTaskPrerequisitesSatisfied", _this]
}],

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

@ -96,10 +96,10 @@ GVAR(AttackTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["tick", compileFinal {
private _startedAt = _self getOrDefault ["startedAt", -1];
@ -139,7 +139,7 @@ GVAR(AttackTaskBaseClass) merge [createHashMapFromArray [
_self call ["refreshTargetsFromStore", []];
private _targets = _self getOrDefault ["targets", []];
GVAR(TaskStore) call ["trackParticipants", [_taskID, _targets, "", 300]];
count _targets > 0
!(_self call ["isTaskStoreOpen", []]) || { count _targets > 0 }
};
} else {
waitUntil {
@ -148,10 +148,20 @@ GVAR(AttackTaskBaseClass) merge [createHashMapFromArray [
};
};
_self call ["waitForAssignment", []];
if !(_self call ["isTaskStoreOpen", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before targets registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
private _targets = _self getOrDefault ["targets", []];
if (_useTaskStore) then {
@ -186,10 +196,8 @@ GVAR(AttackTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _targets = _self getOrDefault ["targets", []];
{ deleteVehicle _x } forEach _targets;
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
if (_useTaskStore) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
@ -202,10 +210,9 @@ GVAR(AttackTaskBaseClass) merge [createHashMapFromArray [
};
if (_endFail) then { "EveryoneLost" call BFUNC(endMissionServer); };
} else {
private _targets = _self getOrDefault ["targets", []];
{ deleteVehicle _x } forEach _targets;
};
if (_finalStatus isEqualTo "succeeded") then {
if (_useTaskStore) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];

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

@ -38,6 +38,8 @@ GVAR(CargoEntityController) merge [createHashMapFromArray [
private _taskID = _unit getVariable ["assignedTask", _unit getVariable [QGVAR(assignedTask), ""]];
if (_taskID isEqualTo "") exitWith {};
private _taskStatus = GVAR(TaskStore) call ["getTaskStatus", [_taskID]];
if (GVAR(TaskStore) call ["isTerminalStatus", [_taskStatus]]) exitWith {};
if (_unit getVariable [QGVAR(cargoDamageWarned), false]) exitWith {};
_unit setVariable [QGVAR(cargoDamageWarned), true];
@ -70,7 +72,13 @@ GVAR(CargoEntityController) merge [createHashMapFromArray [
waitUntil {
sleep 1;
private _entity = _self getOrDefault ["entity", objNull];
isNull _entity || { !alive _entity } || { damage _entity >= (_self getOrDefault ["damageThreshold", 0.7]) }
!(_self call ["isAssignedTaskOpen", []]) || { isNull _entity } || { !alive _entity } || { damage _entity >= (_self getOrDefault ["damageThreshold", 0.7]) }
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];

View File

@ -51,10 +51,10 @@ GVAR(DefendTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["countBluforInZone", compileFinal {
private _defenseZone = _self getOrDefault ["defenseZone", ""];
@ -68,6 +68,7 @@ GVAR(DefendTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
_self call ["trackParticipants", []];
if !(_self call ["isTaskStoreOpen", []]) exitWith { true };
private _ready = (_self call ["countBluforInZone", []]) >= _minBlufor;
if (_ready) then {
@ -82,7 +83,7 @@ GVAR(DefendTaskBaseClass) merge [createHashMapFromArray [
_ready
};
true
_self call ["isTaskStoreOpen", []]
}],
["tick", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
@ -186,10 +187,18 @@ GVAR(DefendTaskBaseClass) merge [createHashMapFromArray [
false
};
_self call ["waitForAssignment", []];
_self call ["waitForDefenseStart", []];
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForDefenseStart", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before defense started."]];
_self call ["cleanup", []];
false
};
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -204,9 +213,12 @@ GVAR(DefendTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -42,7 +42,13 @@ GVAR(DefenseEnemyController) merge [createHashMapFromArray [
_self call ["markActive", []];
waitUntil {
sleep 1;
!(_self call ["isEntityUsable", []])
!(_self call ["isAssignedTaskOpen", []]) || { !(_self call ["isEntityUsable", []]) }
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];

View File

@ -103,7 +103,7 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
_self call ["refreshEntitiesFromStore", []];
count (_self getOrDefault ["ieds", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["ieds", []]) > 0 }
};
} else {
waitUntil {
@ -112,6 +112,8 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
};
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
true
}],
["waitForAssignment", compileFinal {
@ -121,10 +123,10 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["startIedControllers", compileFinal {
if ((_self getOrDefault ["iedControllers", []]) isNotEqualTo []) exitWith { true };
@ -194,15 +196,10 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
}],
["handleFailureOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _ieds = _self getOrDefault ["ieds", []];
private _protected = _self getOrDefault ["protected", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingFail = _rewardData getOrDefault ["ratingFail", 0];
private _endFail = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endFail", false];
{ deleteVehicle _x } forEach _ieds;
{ deleteVehicle _x } forEach _protected;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
@ -219,16 +216,11 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
}],
["handleSuccessOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _ieds = _self getOrDefault ["ieds", []];
private _protected = _self getOrDefault ["protected", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingSuccess = _rewardData getOrDefault ["ratingSuccess", 0];
private _funds = _rewardData getOrDefault ["funds", 0];
private _endSuccess = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endSuccess", false];
{ deleteVehicle _x } forEach _ieds;
{ deleteVehicle _x } forEach _protected;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];
@ -245,12 +237,20 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
true
}],
["runLoop", compileFinal {
_self call ["waitForRequiredEntities", []];
_self call ["waitForAssignment", []];
if !(_self call ["waitForRequiredEntities", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before required entities registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["startIedControllers", []];
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -265,9 +265,12 @@ GVAR(DefuseTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -57,7 +57,7 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
_self call ["refreshEntitiesFromStore", []];
_self call ["trackParticipants", []];
count (_self getOrDefault ["cargo", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["cargo", []]) > 0 }
};
} else {
waitUntil {
@ -66,6 +66,8 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
};
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
private _cargo = _self getOrDefault ["cargo", []];
private _taskParams = _self getOrDefault ["taskParams", createHashMap];
private _requiredDelivered = _taskParams getOrDefault ["limitSuccess", -1];
@ -85,10 +87,10 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["countDeliveredCargo", compileFinal {
private _deliveryZone = _self getOrDefault ["deliveryZone", ""];
@ -126,13 +128,10 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
}],
["handleFailureOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _cargo = _self getOrDefault ["cargo", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingFail = _rewardData getOrDefault ["ratingFail", 0];
private _endFail = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endFail", false];
{ deleteVehicle _x } forEach _cargo;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
@ -149,14 +148,11 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
}],
["handleSuccessOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _cargo = _self getOrDefault ["cargo", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingSuccess = _rewardData getOrDefault ["ratingSuccess", 0];
private _funds = _rewardData getOrDefault ["funds", 0];
private _endSuccess = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endSuccess", false];
{ deleteVehicle _x } forEach _cargo;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];
@ -173,11 +169,19 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
true
}],
["runLoop", compileFinal {
_self call ["waitForRequiredEntities", []];
_self call ["waitForAssignment", []];
if !(_self call ["waitForRequiredEntities", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before required entities registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -192,9 +196,12 @@ GVAR(DeliveryTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -52,7 +52,7 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
_self call ["refreshEntitiesFromStore", []];
_self call ["trackParticipants", []];
count (_self getOrDefault ["targets", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["targets", []]) > 0 }
};
} else {
waitUntil {
@ -61,6 +61,8 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
};
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
private _targets = _self getOrDefault ["targets", []];
private _taskParams = _self getOrDefault ["taskParams", createHashMap];
private _requiredDestroyed = _taskParams getOrDefault ["limitSuccess", -1];
@ -76,10 +78,10 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["countDestroyedTargets", compileFinal {
private _targets = _self getOrDefault ["targets", []];
@ -106,13 +108,10 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
}],
["handleFailureOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _targets = _self getOrDefault ["targets", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingFail = _rewardData getOrDefault ["ratingFail", 0];
private _endFail = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endFail", false];
{ deleteVehicle _x } forEach _targets;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
@ -129,14 +128,11 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
}],
["handleSuccessOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _targets = _self getOrDefault ["targets", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingSuccess = _rewardData getOrDefault ["ratingSuccess", 0];
private _funds = _rewardData getOrDefault ["funds", 0];
private _endSuccess = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endSuccess", false];
{ deleteVehicle _x } forEach _targets;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];
@ -153,11 +149,19 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
true
}],
["runLoop", compileFinal {
_self call ["waitForRequiredEntities", []];
_self call ["waitForAssignment", []];
if !(_self call ["waitForRequiredEntities", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before required entities registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -172,9 +176,12 @@ GVAR(DestroyTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -76,6 +76,20 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _entity = _self getOrDefault ["entity", objNull];
!isNull _entity && { alive _entity }
}],
["isTerminalStatus", compileFinal {
params [["_status", "", [""]]];
(toLowerANSI _status) in ["failed", "succeeded"]
}],
["isAssignedTaskOpen", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
if (_taskID isEqualTo "" || { isNil QGVAR(TaskStore) }) exitWith { true };
private _status = GVAR(TaskStore) call ["getTaskStatus", [_taskID]];
if (_status isEqualTo "") exitWith { true };
!(_self call ["isTerminalStatus", [_status]])
}],
["assignTaskVariable", compileFinal {
private _entity = _self getOrDefault ["entity", objNull];
private _taskID = _self getOrDefault ["taskID", ""];
@ -105,9 +119,9 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap];
private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry set [_registryKey, _self];
missionNamespace setVariable [QGVAR(ObjectControllerInstances), _registry];
SETMVAR(GVAR(ObjectControllerInstances),_registry);
missionNamespace setVariable [_registryKey, _self];
true
}],
@ -115,7 +129,7 @@ GVAR(EntityControllerBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectControllerInstances), createHashMap];
private _registry = GETMVAR(GVAR(ObjectControllerInstances),createHashMap);
_registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil];
true

View File

@ -52,12 +52,19 @@ GVAR(HVTEntityController) merge [createHashMapFromArray [
private _capturer = objNull;
waitUntil {
sleep 1;
if !(_self call ["isAssignedTaskOpen", []]) exitWith { true };
if !(_self call ["isEntityUsable", []]) exitWith { true };
_capturer = _self call ["findNearbyCapturer", []];
!isNull _capturer
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
if !(_self call ["isEntityUsable", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];

View File

@ -70,7 +70,7 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
_self call ["refreshEntitiesFromStore", []];
_self call ["trackParticipants", []];
count (_self getOrDefault ["hvts", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["hvts", []]) > 0 }
};
} else {
waitUntil {
@ -79,6 +79,8 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
};
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
private _hvts = _self getOrDefault ["hvts", []];
private _taskParams = _self getOrDefault ["taskParams", createHashMap];
private _required = _taskParams getOrDefault ["limitSuccess", -1];
@ -122,10 +124,10 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["tick", compileFinal {
private _startedAt = _self getOrDefault ["startedAt", -1];
@ -161,13 +163,10 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
}],
["handleFailureOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _hvts = _self getOrDefault ["hvts", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingFail = _rewardData getOrDefault ["ratingFail", 0];
private _endFail = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endFail", false];
{ deleteVehicle _x } forEach _hvts;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
@ -184,14 +183,11 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
}],
["handleSuccessOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _hvts = _self getOrDefault ["hvts", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingSuccess = _rewardData getOrDefault ["ratingSuccess", 0];
private _funds = _rewardData getOrDefault ["funds", 0];
private _endSuccess = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endSuccess", false];
{ deleteVehicle _x } forEach _hvts;
if (_self getOrDefault ["useTaskStore", false]) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];
@ -208,12 +204,20 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
true
}],
["runLoop", compileFinal {
_self call ["waitForRequiredEntities", []];
_self call ["waitForAssignment", []];
if !(_self call ["waitForRequiredEntities", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before required entities registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["startHvtControllers", []];
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -228,9 +232,12 @@ GVAR(HVTTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -103,12 +103,19 @@ GVAR(HostageEntityController) merge [createHashMapFromArray [
waitUntil {
sleep 1;
if !(_self call ["isAssignedTaskOpen", []]) exitWith { true };
if (isNull _entity || { !alive _entity }) exitWith { true };
_rescuer = _self call ["findNearbyRescuer", []];
!isNull _rescuer
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
if (isNull _entity || { !alive _entity }) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];

View File

@ -150,14 +150,15 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
_self call ["refreshEntitiesFromStore", []];
count (_self getOrDefault ["hostages", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["hostages", []]) > 0 }
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
waitUntil {
sleep 1;
_self call ["refreshEntitiesFromStore", []];
_self call ["trackParticipants", []];
count (_self getOrDefault ["shooters", []]) > 0
!(_self call ["isTaskStoreOpen", []]) || { count (_self getOrDefault ["shooters", []]) > 0 }
};
} else {
waitUntil {
@ -171,6 +172,8 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
};
};
if !(_self call ["isTaskStoreOpen", []]) exitWith { false };
private _hostages = _self getOrDefault ["hostages", []];
private _taskParams = _self getOrDefault ["taskParams", createHashMap];
private _requiredRescues = _taskParams getOrDefault ["limitSuccess", -1];
@ -190,10 +193,10 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isTaskStoreOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isTaskStoreOpen", []]
}],
["countFreedHostages", compileFinal {
private _playerGroups = allPlayers apply { group _x };
@ -290,11 +293,9 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
sleep 5;
};
{ deleteVehicle _x } forEach _hostages;
{ deleteVehicle _x } forEach _shooters;
if (_useTaskStore) then {
[_taskID, "FAILED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "failed"]];
sleep 1;
@ -308,17 +309,12 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
}],
["handleSuccessOutcome", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
private _hostages = _self getOrDefault ["hostages", []];
private _shooters = _self getOrDefault ["shooters", []];
private _rewardData = _self getOrDefault ["rewardData", createHashMap];
private _ratingSuccess = _rewardData getOrDefault ["ratingSuccess", 0];
private _funds = _rewardData getOrDefault ["funds", 0];
private _endSuccess = (_self getOrDefault ["taskParams", createHashMap]) getOrDefault ["endSuccess", false];
private _useTaskStore = _self getOrDefault ["useTaskStore", false];
{ deleteVehicle _x } forEach _hostages;
{ deleteVehicle _x } forEach _shooters;
if (_useTaskStore) then {
[_taskID, "SUCCEEDED"] call BFUNC(taskSetState);
GVAR(TaskStore) call ["setTaskStatus", [_taskID, "succeeded"]];
@ -335,12 +331,20 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
true
}],
["runLoop", compileFinal {
_self call ["waitForRequiredEntities", []];
_self call ["waitForAssignment", []];
if !(_self call ["waitForRequiredEntities", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before required entities registered."]];
_self call ["cleanup", []];
false
};
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", ["Task reached terminal status before assignment."]];
_self call ["cleanup", []];
false
};
_self call ["startHostageControllers", []];
_self call ["markActive", []];
while { (_self call ["getStatus", []]) isEqualTo "active" } do {
while { _self call ["isTaskLoopActive", []] } do {
_self call ["trackParticipants", []];
private _snapshot = _self call ["tick", []];
@ -355,9 +359,12 @@ GVAR(HostageTaskBaseClass) merge [createHashMapFromArray [
sleep 1;
};
if ((_self call ["getStatus", []]) isEqualTo "failed") then {
private _finalStatus = _self call ["getStatus", []];
if (_finalStatus isEqualTo "failed") then {
_self call ["handleFailureOutcome", []];
} else {
};
if (_finalStatus isEqualTo "succeeded") then {
_self call ["handleSuccessOutcome", []];
};

View File

@ -29,10 +29,10 @@ GVAR(IEDEntityController) merge [createHashMapFromArray [
waitUntil {
sleep 1;
GVAR(TaskStore) call ["isTaskAccepted", [_taskID]]
!(_self call ["isAssignedTaskOpen", []]) || { GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] }
};
true
_self call ["isAssignedTaskOpen", []]
}],
["playCountdownSound", compileFinal {
params [["_timeRemaining", 0, [0]]];
@ -67,20 +67,35 @@ GVAR(IEDEntityController) merge [createHashMapFromArray [
false
};
_self call ["waitForAssignment", []];
if !(_self call ["waitForAssignment", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markActive", []];
while { (_self call ["isEntityUsable", []]) && { _countdown > 0 } } do {
while { (_self call ["isAssignedTaskOpen", []]) && { (_self call ["isEntityUsable", []]) && { _countdown > 0 } } } do {
_self call ["playCountdownSound", [_countdown]];
_countdown = _countdown - 1;
_self set ["countdown", _countdown];
sleep 1;
};
if ((_self call ["isEntityUsable", []]) && { _countdown <= 0 }) then {
if ((_self call ["isAssignedTaskOpen", []]) && { (_self call ["isEntityUsable", []]) && { _countdown <= 0 } }) then {
_self call ["detonate", []];
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];
_self call ["cleanup", []];
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

@ -31,7 +31,13 @@ GVAR(ProtectedEntityController) merge [createHashMapFromArray [
_self call ["markActive", []];
waitUntil {
sleep 1;
!(_self call ["isEntityUsable", []])
!(_self call ["isAssignedTaskOpen", []]) || { !(_self call ["isEntityUsable", []]) }
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];

View File

@ -31,7 +31,13 @@ GVAR(ShooterEntityController) merge [createHashMapFromArray [
_self call ["markActive", []];
waitUntil {
sleep 1;
!(_self call ["isEntityUsable", []])
!(_self call ["isAssignedTaskOpen", []]) || { !(_self call ["isEntityUsable", []]) }
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];

View File

@ -31,7 +31,13 @@ GVAR(TargetEntityController) merge [createHashMapFromArray [
_self call ["markActive", []];
waitUntil {
sleep 1;
!(_self call ["isEntityUsable", []])
!(_self call ["isAssignedTaskOpen", []]) || { !(_self call ["isEntityUsable", []]) }
};
if !(_self call ["isAssignedTaskOpen", []]) exitWith {
_self call ["markAborted", []];
_self call ["cleanup", []];
false
};
_self call ["markFinished", []];

View File

@ -123,6 +123,11 @@ GVAR(TaskCatalogStore) = createHashMapObject [[
(_self call ["getTaskStatus", [_taskID]]) isEqualTo "succeeded"
}],
["isTerminalStatus", compileFinal {
params [["_status", "", [""]]];
(toLowerANSI _status) in ["failed", "succeeded"]
}],
["areTaskPrerequisitesSatisfied", compileFinal {
params [["_taskID", "", [""]], ["_entry", createHashMap, [createHashMap]]];
@ -359,6 +364,28 @@ GVAR(TaskCatalogStore) = createHashMapObject [[
if (_taskID isEqualTo "" || { _status isEqualTo "" }) exitWith { false };
private _normalizedStatus = toLowerANSI _status;
private _currentStatus = toLowerANSI (_self call ["getTaskStatus", [_taskID]]);
private _currentIsTerminal = _self call ["isTerminalStatus", [_currentStatus]];
private _nextIsTerminal = _self call ["isTerminalStatus", [_normalizedStatus]];
if (_currentIsTerminal && { _currentStatus isNotEqualTo _normalizedStatus }) exitWith {
["WARNING", format [
"Task status transition blocked for %1: terminal status %2 cannot be changed to %3 without clearing task state first.",
_taskID,
_currentStatus,
_normalizedStatus
]] call EFUNC(common,log);
false
};
if (_currentIsTerminal && { _nextIsTerminal }) exitWith {
if (_normalizedStatus isEqualTo "succeeded") then {
_self call ["markTaskCompleted", [_taskID]];
_self call ["unlockDependentTasks", [_taskID]];
};
true
};
private _runtimeCatalogRegistry = _self getOrDefault ["runtimeCatalogRegistry", createHashMap];
private _runtimeEntry = +(_runtimeCatalogRegistry getOrDefault [_taskID, createHashMap]);
if (_runtimeEntry isNotEqualTo createHashMap) then {

View File

@ -83,6 +83,50 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
["getStatus", compileFinal {
_self getOrDefault ["status", "created"]
}],
["isTerminalStatus", compileFinal {
params [["_status", "", [""]]];
(toLowerANSI _status) in ["failed", "succeeded"]
}],
["getStoreStatus", compileFinal {
private _taskID = _self getOrDefault ["taskID", ""];
if (_taskID isEqualTo "" || { !(_self getOrDefault ["useTaskStore", false]) } || { isNil QGVAR(TaskStore) }) exitWith { "" };
GVAR(TaskStore) call ["getTaskStatus", [_taskID]]
}],
["canTransitionToTerminal", compileFinal {
params [["_nextStatus", "", [""]]];
private _normalizedNext = toLowerANSI _nextStatus;
if !(_self call ["isTerminalStatus", [_normalizedNext]]) exitWith { true };
private _currentStatus = toLowerANSI (_self getOrDefault ["status", "created"]);
if ((_self call ["isTerminalStatus", [_currentStatus]]) && { _currentStatus isNotEqualTo _normalizedNext }) exitWith { false };
private _storeStatus = toLowerANSI (_self call ["getStoreStatus", []]);
if ((_self call ["isTerminalStatus", [_storeStatus]]) && { _storeStatus isNotEqualTo _normalizedNext }) exitWith { false };
true
}],
["isTaskLoopActive", compileFinal {
if ((_self call ["getStatus", []]) isNotEqualTo "active") exitWith { false };
private _storeStatus = toLowerANSI (_self call ["getStoreStatus", []]);
if (_storeStatus isEqualTo "") exitWith { true };
if (_self call ["isTerminalStatus", [_storeStatus]]) exitWith {
_self call ["markAborted", [format ["Task store reached terminal status '%1'.", _storeStatus]]];
false
};
true
}],
["isTaskStoreOpen", compileFinal {
private _storeStatus = toLowerANSI (_self call ["getStoreStatus", []]);
if (_storeStatus isEqualTo "") exitWith { true };
!(_self call ["isTerminalStatus", [_storeStatus]])
}],
["getRewardData", compileFinal {
_self getOrDefault ["rewardData", createHashMap]
}],
@ -93,9 +137,9 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap];
private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry set [_registryKey, _self];
missionNamespace setVariable [QGVAR(ObjectTaskInstances), _registry];
SETMVAR(GVAR(ObjectTaskInstances),_registry);
missionNamespace setVariable [_registryKey, _self];
true
}],
@ -103,7 +147,7 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
private _registryKey = _self call ["getRegistryKey", []];
if (_registryKey isEqualTo "") exitWith { false };
private _registry = missionNamespace getVariable [QGVAR(ObjectTaskInstances), createHashMap];
private _registry = GETMVAR(GVAR(ObjectTaskInstances),createHashMap);
_registry deleteAt _registryKey;
missionNamespace setVariable [_registryKey, nil];
true
@ -162,6 +206,8 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
["markSucceeded", compileFinal {
params [["_resultSnapshot", createHashMap, [createHashMap]]];
if !(_self call ["canTransitionToTerminal", ["succeeded"]]) exitWith { false };
_self set ["status", "succeeded"];
_self set ["finishedAt", serverTime];
_self set ["resultSnapshot", _resultSnapshot];
@ -173,6 +219,8 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
["markFailed", compileFinal {
params [["_reason", "", [""]], ["_resultSnapshot", createHashMap, [createHashMap]]];
if !(_self call ["canTransitionToTerminal", ["failed"]]) exitWith { false };
_self set ["status", "failed"];
_self set ["finishedAt", serverTime];
_self set ["failureReason", _reason];
@ -182,6 +230,14 @@ GVAR(TaskInstanceBaseClass) = createHashMapFromArray [
};
true
}],
["markAborted", compileFinal {
params [["_reason", "", [""]]];
_self set ["status", "aborted"];
_self set ["finishedAt", serverTime];
_self set ["failureReason", _reason];
true
}],
["cleanup", compileFinal {
_self call ["unregisterInstance", []]
}],

View File

@ -122,6 +122,53 @@ GVAR(TaskLifecycleReporter) = createHashMapObject [[
_self call ["buildTaskLifecycleEventPayload", [_taskID, _status, _extra]],
createHashMapFromArray [["source", "task"]]
]]
}],
["registerEventLogListeners", compileFinal {
if !(isNil QGVAR(TaskLifecycleEventLogTokens)) exitWith { GVAR(TaskLifecycleEventLogTokens) };
private _logTaskLifecycleEvent = {
params ["_event"];
if !(GETGVAR(enableEventLogs,false)) exitWith {};
["INFO", format [
"Task lifecycle event: %1 taskID=%2 taskType=%3 status=%4 participants=%5",
_event getOrDefault ["event", ""],
_event getOrDefault ["taskID", ""],
_event getOrDefault ["taskType", ""],
_event getOrDefault ["status", ""],
_event getOrDefault ["participants", []]
]] call EFUNC(common,log);
};
private _logTaskRewardEvent = {
params ["_event"];
if !(GETGVAR(enableEventLogs,false)) exitWith {};
["INFO", format [
"Task reward event: %1 taskID=%2 success=%3 message=%4",
_event getOrDefault ["event", ""],
_event getOrDefault ["taskID", ""],
!((_event getOrDefault ["event", ""]) in ["task.reward.failed", "task.rating.failed"]),
_event getOrDefault ["message", ""]
]] call EFUNC(common,log);
};
GVAR(TaskLifecycleEventLogTokens) = [
EGVAR(common,EventBus) call ["on", ["task.created", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.started", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.completed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.failed", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.cleared", _logTaskLifecycleEvent, "task.lifecycle.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.requested", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.applied", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.reward.failed", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.rating.applied", _logTaskRewardEvent, "task.reward.log"]],
EGVAR(common,EventBus) call ["on", ["task.rating.failed", _logTaskRewardEvent, "task.reward.log"]]
];
GVAR(TaskLifecycleEventLogTokens)
}]
]];

View File

@ -0,0 +1,103 @@
#include "..\script_component.hpp"
/*
* Author: IDSolutions
* Dispatches task notification events to client notification UIs.
*
* Arguments:
* None
*
* Return Value:
* Task notification service object <HASHMAP OBJECT>
*
* Public: No
*/
#pragma hemtt ignore_variables ["_self"]
GVAR(TaskNotificationService) = createHashMapObject [[
["#type", "TaskNotificationService"],
["sendToPlayers", compileFinal {
params [
["_uids", [], [[]]],
["_type", "info", [""]],
["_title", "Tasks", [""]],
["_message", "", [""]]
];
if (_message isEqualTo "" || { _uids isEqualTo [] }) exitWith { false };
{
private _player = [_x] call EFUNC(common,getPlayer);
if (isNull _player) then { continue; };
[CRPC(notifications,recieveNotification), [_type, _title, _message], _player] call CFUNC(targetEvent);
} forEach _uids;
true
}],
["handleTaskNotification", compileFinal {
params ["_event"];
private _type = _event getOrDefault ["notificationType", "info"];
private _title = _event getOrDefault ["title", "Tasks"];
private _message = _event getOrDefault ["message", ""];
private _participantUids = +(_event getOrDefault ["participantUids", []]);
if !(_self call ["sendToPlayers", [_participantUids, _type, _title, _message]]) exitWith { false };
if (GETGVAR(enableEventLogs,false)) then {
["INFO", format [
"Task notification event: taskID=%1 type=%2 recipients=%3 message=%4",
_event getOrDefault ["taskID", ""],
_type,
_participantUids,
_message
]] call EFUNC(common,log);
};
true
}],
["handleRewardNotification", compileFinal {
params ["_event"];
private _type = _event getOrDefault ["notificationType", "info"];
private _title = _event getOrDefault ["title", "Tasks"];
private _message = _event getOrDefault ["message", ""];
private _memberUids = +(_event getOrDefault ["memberUids", []]);
if !(_self call ["sendToPlayers", [_memberUids, _type, _title, _message]]) exitWith { false };
if (GETGVAR(enableEventLogs,false)) then {
["INFO", format [
"Task reward notification event: taskID=%1 type=%2 recipients=%3 message=%4",
_event getOrDefault ["taskID", ""],
_type,
_memberUids,
_message
]] call EFUNC(common,log);
};
true
}],
["registerEventListeners", compileFinal {
if !(isNil QGVAR(TaskNotificationEventTokens)) exitWith { GVAR(TaskNotificationEventTokens) };
private _sendTaskNotification = {
params ["_event"];
GVAR(TaskNotificationService) call ["handleTaskNotification", [_event]];
};
private _sendRewardNotification = {
params ["_event"];
GVAR(TaskNotificationService) call ["handleRewardNotification", [_event]];
};
GVAR(TaskNotificationEventTokens) = [
EGVAR(common,EventBus) call ["on", ["task.notification.requested", _sendTaskNotification, "task.notification.send"]],
EGVAR(common,EventBus) call ["on", ["task.reward.notification.requested", _sendRewardNotification, "task.reward.notification.send"]]
];
GVAR(TaskNotificationEventTokens)
}]
]];
GVAR(TaskNotificationService)

View File

@ -39,6 +39,22 @@ GVAR(TransportServiceBase) = compileFinal createHashMapFromArray [
["INFO", "Transport Service Initialized!"] call EFUNC(common,log);
true
}],
["numberSetting", compileFinal {
params [["_name", "", [""]], ["_default", 0, [0]]];
private _configDefault = _default;
private _serviceConfig = missionConfigFile >> "CfgServicePricing";
if !(isClass _serviceConfig) then { _serviceConfig = configFile >> "CfgServicePricing"; };
if (isNumber (_serviceConfig >> _name)) then {
_configDefault = getNumber (_serviceConfig >> _name);
};
private _paramValue = [_name, _configDefault] call BIS_fnc_getParamValue;
private _value = missionNamespace getVariable [_name, _paramValue];
if (_value isEqualType "") exitWith { (parseNumber _value) max 0 };
if (_value isEqualType 0) exitWith { _value max 0 };
_configDefault
}],
["notify", compileFinal {
params [["_unit", objNull, [objNull]], ["_type", "info", [""]], ["_title", "Transport", [""]], ["_message", "", [""]]];
@ -120,8 +136,10 @@ GVAR(TransportServiceBase) = compileFinal createHashMapFromArray [
["getCost", compileFinal {
params [["_fromNode", objNull, [objNull]], ["_toNode", objNull, [objNull]], ["_options", createHashMap, [createHashMap]]];
private _baseFare = _options getOrDefault ["baseFare", _self getOrDefault ["baseFare", 100]];
private _pricePerKm = _options getOrDefault ["pricePerKm", _self getOrDefault ["pricePerKm", 50]];
private _baseFareDefault = _self call ["numberSetting", ["transportBaseFare", _self getOrDefault ["baseFare", 100]]];
private _pricePerKmDefault = _self call ["numberSetting", ["transportPricePerKm", _self getOrDefault ["pricePerKm", 50]]];
private _baseFare = _options getOrDefault ["baseFare", _baseFareDefault];
private _pricePerKm = _options getOrDefault ["pricePerKm", _pricePerKmDefault];
private _distanceMeters = _fromNode distance2D _toNode;
round (_baseFare + ((_distanceMeters / 1000) * _pricePerKm))

View File

@ -19,6 +19,8 @@ pub type SurrealDb = Surreal<Client>;
const CLIENT_READY_TIMEOUT: Duration = Duration::from_secs(30);
const CLIENT_READY_POLL_INTERVAL: Duration = Duration::from_millis(25);
const INIT_MAX_ATTEMPTS: usize = 5;
const INIT_RETRY_BASE_DELAY: Duration = Duration::from_millis(150);
static SURREAL_DB: LazyLock<StdRwLock<Option<Arc<SurrealDb>>>> =
LazyLock::new(|| StdRwLock::new(None));
@ -27,6 +29,8 @@ static SURREAL_CONNECTION_STATE: LazyLock<StdRwLock<SurrealConnectionState>> =
static SURREAL_FAILURE_REASON: LazyLock<StdRwLock<Option<String>>> =
LazyLock::new(|| StdRwLock::new(None));
static SURREAL_INIT_GENERATION: AtomicU64 = AtomicU64::new(0);
static SURREAL_INIT_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
#[derive(Clone, Copy, PartialEq, Eq)]
enum SurrealConnectionState {
@ -42,6 +46,7 @@ pub fn prepare() {
}
pub async fn initialize(config: SurrealConfig) {
let _init_guard = SURREAL_INIT_LOCK.lock().await;
let generation = SURREAL_INIT_GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
prepare();
@ -55,7 +60,7 @@ pub async fn initialize(config: SurrealConfig) {
);
let timeout_duration = Duration::from_millis(config.connect_timeout_ms.unwrap_or(5000));
let connection = timeout(timeout_duration, connect(config)).await;
let connection = timeout(timeout_duration, connect_with_retries(config)).await;
let db = match connection {
Err(_) => {
@ -98,7 +103,7 @@ pub async fn initialize(config: SurrealConfig) {
}
log::log("surreal", "DEBUG", "Applying SurrealDB schemas");
if let Err(error) = schema::apply_all(&db).await {
if let Err(error) = apply_schemas_with_retries(&db).await {
if !is_current_generation(generation) {
return;
}
@ -159,6 +164,70 @@ async fn connect(config: SurrealConfig) -> Result<SurrealDb, String> {
Ok(db)
}
async fn connect_with_retries(config: SurrealConfig) -> Result<SurrealDb, String> {
let mut last_error = String::new();
for attempt in 1..=INIT_MAX_ATTEMPTS {
match connect(config.clone()).await {
Ok(db) => return Ok(db),
Err(error) => {
if !is_retryable_surreal_error(&error) || attempt == INIT_MAX_ATTEMPTS {
return Err(error);
}
last_error = error;
log::log(
"surreal",
"WARNING",
&format!(
"SurrealDB connection attempt {} failed with retryable error: {}",
attempt, last_error
),
);
sleep(init_retry_delay(attempt)).await;
}
}
}
Err(last_error)
}
async fn apply_schemas_with_retries(db: &SurrealDb) -> Result<(), String> {
let mut last_error = String::new();
for attempt in 1..=INIT_MAX_ATTEMPTS {
match schema::apply_all(db).await {
Ok(()) => return Ok(()),
Err(error) => {
if !is_retryable_surreal_error(&error) || attempt == INIT_MAX_ATTEMPTS {
return Err(error);
}
last_error = error;
log::log(
"surreal",
"WARNING",
&format!(
"SurrealDB schema bootstrap attempt {} failed with retryable error: {}",
attempt, last_error
),
);
sleep(init_retry_delay(attempt)).await;
}
}
}
Err(last_error)
}
fn is_retryable_surreal_error(error: &str) -> bool {
error.contains("Transaction conflict") || error.contains("Resource busy")
}
fn init_retry_delay(attempt: usize) -> Duration {
INIT_RETRY_BASE_DELAY * attempt as u32
}
pub async fn client() -> Result<Arc<SurrealDb>, String> {
if let Some(db) = SURREAL_DB.read().unwrap().clone() {
return Ok(db);
@ -203,6 +272,10 @@ pub fn status() -> String {
}
pub fn reconnect() -> String {
if *SURREAL_CONNECTION_STATE.read().unwrap() == SurrealConnectionState::Initializing {
return "reconnect skipped: connection already initializing".to_string();
}
let surreal_config = config::load().surreal.clone();
prepare();
RUNTIME.spawn(async move {

View File

@ -1,3 +1,8 @@
@echo off
call "%~dp0UpdateMe.bat"
setlocal EnableExtensions
set "FORGE_SURREALDB_VERSION=%~1"
if not defined FORGE_SURREALDB_VERSION set "FORGE_SURREALDB_VERSION=3"
call "%~dp0UpdateMe.bat" "%FORGE_SURREALDB_VERSION%"
if errorlevel 1 exit /b %errorlevel%
call "%~dp0RunMe.bat"

View File

@ -10,12 +10,36 @@ firewall, TLS, backup, and upgrade policy before exposing the database.
## Windows
Install or update SurrealDB:
Install or update SurrealDB to the newest compatible SurrealDB 3.x release:
```bat
UpdateMe.bat
```
Install a specific SurrealDB release:
```bat
UpdateMe.bat v3.1.2
```
Install the latest stable SurrealDB release, including newer major versions:
```bat
UpdateMe.bat latest
```
`latest` requires confirmation because a newer SurrealDB major version can
require rebuilding the Forge server extension from source with a compatible
`surrealdb` Rust crate.
The PowerShell entry point exposes the same behavior:
```powershell
.\UpdateSurrealDB.ps1
.\UpdateSurrealDB.ps1 -Version v3.1.2
.\UpdateSurrealDB.ps1 -Version latest
```
If this is the first install and the terminal cannot find `surreal` after the
script finishes, open a new terminal so Windows reloads `PATH`.
@ -25,12 +49,21 @@ Start Forge's local database:
RunMe.bat
```
Or start it directly with PowerShell:
```powershell
.\RunSurrealDB.ps1
```
Install and start in one step:
```bat
AllInOne.bat
```
`AllInOne.bat` also defaults to the newest compatible SurrealDB 3.x release.
Pass the same version argument as `UpdateMe.bat` to override it.
## Linux or macOS
Install SurrealDB:

View File

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

View File

@ -0,0 +1,16 @@
param(
[string]$User = "root",
[string]$Pass = "root",
[string]$Bind = "127.0.0.1:8000",
[string]$DatabasePath = "forge.db"
)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
if (-not (Get-Command surreal -ErrorAction SilentlyContinue)) {
throw "The 'surreal' command was not found. Run UpdateSurrealDB.ps1 first, then open a new terminal if PATH was updated."
}
surreal start --user $User --pass $Pass --bind $Bind "rocksdb://$DatabasePath"

View File

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

View File

@ -0,0 +1,120 @@
param(
[string]$Version = "3",
[switch]$Force
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$VersionUrl = "https://version.surrealdb.com"
$DownloadBaseUrl = "https://download.surrealdb.com"
$Architecture = "windows-amd64"
function Normalize-Version {
param([string]$Value)
$trimmed = $Value.Trim()
if ($trimmed -match "(?i)^latest$") {
return "latest"
}
if ($trimmed -match "^v?\d+$") {
return $trimmed.TrimStart("v")
}
if ($trimmed -match "^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$") {
return "v$($trimmed.TrimStart("v"))"
}
throw "Unsupported SurrealDB version '$Value'. Use a major version like '3', an exact version like 'v3.1.2', or 'latest'."
}
function Get-Latest-Version {
return (Invoke-WebRequest $VersionUrl -UseBasicParsing).Content.Trim()
}
function Resolve-Version {
param([string]$Target)
$normalized = Normalize-Version $Target
if ($normalized -eq "latest") {
return Get-Latest-Version
}
if ($normalized -match "^\d+$") {
$latest = Get-Latest-Version
if ($latest -notmatch "^v?$normalized\.") {
throw "Latest SurrealDB is $latest, not $normalized.x. Pass an exact $normalized.x version or use 'latest' after confirming Forge compatibility."
}
return $latest
}
return $normalized
}
function Confirm-Latest {
if ($Force) {
return
}
Write-Host ""
Write-Host "WARNING: This will install the latest stable SurrealDB release, even if it is newer"
Write-Host "than the Forge server extension was compiled and tested against."
Write-Host ""
Write-Host "The Forge server extension currently targets SurrealDB 3.x. A newer major"
Write-Host "SurrealDB release can require rebuilding Forge from source with a compatible"
Write-Host "surrealdb Rust crate before the extension works correctly."
Write-Host ""
$answer = Read-Host "Install latest SurrealDB anyway? [Y/N]"
if ($answer -notmatch "^(?i)y(es)?$") {
exit 1
}
}
function Get-Install-Path {
$existing = Get-Command surreal -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $existing -and $existing.Source -and (Split-Path -Leaf $existing.Source) -ieq "surreal.exe") {
return $existing.Source
}
$installDirectory = Join-Path $env:LOCALAPPDATA "SurrealDB"
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
return Join-Path $installDirectory "surreal.exe"
}
function Ensure-User-Path {
param([string]$Directory)
$pathParts = $env:Path -split ";" | Where-Object { $_ }
if ($pathParts -notcontains $Directory) {
$env:Path = "$Directory;$env:Path"
}
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathParts = $userPath -split ";" | Where-Object { $_ }
if ($userPathParts -notcontains $Directory) {
$newUserPath = if ([string]::IsNullOrWhiteSpace($userPath)) { $Directory } else { "$Directory;$userPath" }
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
Write-Host "Added $Directory to the user PATH. Open a new terminal if 'surreal' is not found later."
}
}
$normalizedTarget = Normalize-Version $Version
if ($normalizedTarget -eq "latest") {
Confirm-Latest
}
$resolvedVersion = Resolve-Version $Version
$installPath = Get-Install-Path
$installDirectory = Split-Path -Parent $installPath
New-Item -ItemType Directory -Force -Path $installDirectory | Out-Null
$downloadUrl = "$DownloadBaseUrl/$resolvedVersion/surreal-$resolvedVersion.$Architecture.exe"
$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "surreal-$resolvedVersion.$Architecture.exe"
Write-Host "Installing SurrealDB $resolvedVersion from $downloadUrl"
Invoke-WebRequest $downloadUrl -OutFile $tempPath -UseBasicParsing
Move-Item -Force -Path $tempPath -Destination $installPath
Ensure-User-Path $installDirectory
& $installPath version

View File

@ -71,27 +71,27 @@ Common generated IDs:
## Generated Mission Requests
Dispatchers can request framework-generated mission tasks from the CAD
dispatcher board. The server hydrates the available generated task types from
the task mission manager as `generatedTaskTypes`; the client uses that hydrated
list for the dropdown.
Dispatchers can request generated mission tasks from the CAD dispatcher board.
The server hydrates the available generated task types from the selected task
provider as `generatedTaskTypes`; the client uses that hydrated list for the
dropdown.
Generated mission requests are controlled by the server CBA setting
Built-in generated mission requests are controlled by the server CBA setting
`forge_server_task_enableGenerator`:
- Enabled: CAD receives the generated task type list and dispatchers can request
a specific generator type.
- Disabled: CAD receives an empty generated task type list, the task request UI
is disabled, and server-side request handling rejects any manual request.
- Enabled: CAD can receive the built-in generated task type list and dispatchers
can request a specific built-in generator type.
- Disabled: the built-in provider returns no task types and rejects built-in
manual requests.
The framework-owned request entry point is
`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework
handler directly; it does not call mission-local generator functions.
Server CAD routes generated mission requests through the task provider registry.
The selected provider handles the request and returns the CAD response payload.
Custom mission generators can still create CAD-visible tasks directly by
registering task catalog entries and task statuses. See
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for the supported
integration path and the current generated-task provider limitation.
Custom mission generators can register a provider with the
`forge_server_task_registerMissionGeneratorProvider` CBA server event or create
CAD-visible tasks directly by registering task catalog entries and task
statuses. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md) for
the supported integration path.
## Submit a Support Request

View File

@ -104,13 +104,13 @@ The dispatcher-generated task dropdown is hydrated from the server
`generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
request control, which is how `forge_server_task_enableGenerator = false` is surfaced
client-side.
request control. For the built-in provider, this is how
`forge_server_task_enableGenerator = false` is surfaced client-side.
Custom mission generators can still publish tasks into CAD by using the server
task catalog. The generated-task dropdown itself currently needs a framework
provider extension point before custom providers can replace the built-in list
cleanly. See [Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
Custom mission generators can publish tasks into CAD by using the server task
catalog or by registering a task provider that supplies `generatedTaskTypes` and
handles generated task requests. See
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
## Authorization Notes

View File

@ -5,9 +5,8 @@ foundation that communities build on top of. Custom mission generators should
integrate through the same task, CAD, and event surfaces that the built-in
mission manager uses.
This guide documents the supported integration path today and calls out the
current CAD generated-task provider limitation that should be addressed by a
small framework extension point.
This guide documents the supported integration path for custom generators,
including the provider registry used by CAD/manual generated task requests.
## Recommended Architecture
@ -35,13 +34,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types.
This does not prevent custom code from creating CAD-visible tasks directly.
It only disables the built-in generator request list and the framework-owned
manual request entry point.
This does not prevent custom code from creating CAD-visible tasks directly or
from serving CAD/manual generated task requests through a registered custom
provider.
The mission setup UI does not override this setting. Generated mission
enablement is mission/server policy and should stay in CBA settings until a
provider selection extension point exists.
enablement for the built-in provider is mission/server policy and stays in CBA
settings.
The mission setup UI can capture a generator provider preference:
- `builtin` for Forge's built-in generated mission provider
- `custom` for mission/community-owned generated mission providers
That preference is stored in `forge_server_task_generatorProvider` and mirrored
inside `forge_server_task_missionSetup_settings`. It is intentionally separate
from `forge_server_task_enableGenerator`; the CBA setting only gates Forge's
built-in provider. A registered custom provider can still publish generated task
types and handle CAD/manual requests when selected.
## Framework Mission Setup UI
@ -68,6 +78,7 @@ missionNamespace setVariable [
The UI configures:
- opposing faction
- generator provider preference
- max concurrent generated missions
- mission interval
- location reuse cooldown
@ -100,6 +111,75 @@ actor interaction entry is hidden once clients receive the public applied flag,
and direct or stale open requests receive a notification explaining that setup
has already been applied.
## Provider Registry
Custom providers register on the server through the server-side CBA event:
```sqf
[
"forge_server_task_registerMissionGeneratorProvider",
["custom", _provider]
] call CBA_fnc_serverEvent;
```
This event is intentionally fire-and-forget. The task module validates provider
shape server-side and logs registration failures.
The provider is a hashMap/hashMapObject with two required methods:
| Method | Arguments | Return |
| --- | --- | --- |
| `getGeneratedTaskTypes` | none | Array of hashMaps with `value` and `label` |
| `requestMissionTask` | `_taskType`, `_metadata`, `_requesterUid` | Result hashMap |
The request result should include:
| Key | Type | Notes |
| --- | --- | --- |
| `success` | Boolean | `true` when a task was generated |
| `message` | String | User-facing CAD response |
| `taskID` | String | Created task ID, or empty on failure |
| `taskType` | String | Resolved generated task type |
Example provider:
```sqf
private _provider = createHashMapObject [[
["#type", "CommunityMissionGeneratorProvider"],
["getGeneratedTaskTypes", {
[
createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]],
createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]]
]
}],
["requestMissionTask", {
params ["_taskType", "_metadata", "_requesterUid"];
private _taskID = format ["custom_%1_%2", _taskType, floor random 100000];
// Create/spawn the mission here, then publish it through Forge's task
// catalog/status contract so CAD can assign and track it.
createHashMapFromArray [
["success", true],
["message", format ["Generated custom %1 task %2.", _taskType, _taskID]],
["taskID", _taskID],
["taskType", _taskType]
]
}]
]];
[
"forge_server_task_registerMissionGeneratorProvider",
["custom", _provider]
] call CBA_fnc_serverEvent;
```
When the setup UI provider toggle is set to `custom`, CAD hydrates task types
from the registered `custom` provider and CAD/manual requests call that
provider's `requestMissionTask` method. If no custom provider is registered,
Forge logs a warning and falls back to the built-in provider.
## CAD-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom
@ -290,78 +370,33 @@ when relevant changes occur. Custom generators usually only need to emit task
status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners.
## Generated Task Dropdown Limitation
## Generated Task Provider Behavior
The current CAD generated-task dropdown is owned by the framework task mission
manager. CAD hydrates `generatedTaskTypes` from the built-in manager when
`forge_server_task_enableGenerator` is enabled. When that setting is disabled,
the generated-task request control is disabled.
CAD hydrates generated task types and requests generated tasks through the task
provider registry. The selected provider comes from
`forge_server_task_generatorProvider`, defaulting to `builtin`.
The current CAD request handler calls `forge_server_task_fnc_requestMissionTask`
directly. It no longer falls back to mission-local generator request functions,
so third-party generated-task providers should create CAD-visible tasks directly
until a framework provider extension point is added.
Use one of these supported patterns:
Until a provider extension point is added, use one of these supported patterns:
1. Run custom generators from mission/server code and create CAD-visible tasks
1. Register a custom provider so CAD/manual generated task requests route to
community code.
2. Run custom generators from mission/server code and create CAD-visible tasks
directly.
2. Use CAD support requests or dispatch orders to let players request custom
3. Use CAD support requests or dispatch orders to let players request custom
work, then have mission code convert approved requests into tasks.
3. Keep the built-in generator enabled only if the community intentionally
4. Keep the built-in generator enabled only if the community intentionally
wants the framework dropdown and request handler.
## Planned Provider Extension Point
## Provider Extension Details
A future code change should make CAD generator providers explicit. The desired
shape is:
The implemented provider shape is intentionally small:
- built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers
- mission designers or developers can select or toggle the active generator
provider when a mission includes custom generators
- a framework-hosted mission setup UI can display the active provider and, when
supported by the mission, allow choosing between built-in and custom
providers
Candidate SQF hooks:
```sqf
forge_custom_fnc_getGeneratedTaskTypes
forge_custom_fnc_requestMissionTask
```
or mission namespace variables:
```sqf
missionNamespace setVariable ["forge_generatorProvider_getTypes", {
[
createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]],
createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]]
]
}];
missionNamespace setVariable ["forge_generatorProvider_requestTask", {
params ["_taskType", "_metadata", "_requesterUid"];
createHashMapFromArray [
["success", true],
["message", "Generated custom task."],
["taskID", "custom_task_01"],
["taskType", _taskType]
]
}];
```
The exact API should be implemented in the framework code before communities
depend on it.
Implementation note: the provider selection should be separate from
`forge_server_task_enableGenerator`. That CBA setting should continue to gate
the built-in Forge generator, while a new provider option can decide whether
CAD/manual requests use the built-in provider, a custom provider, both, or no
provider at all.
- mission designers or developers can select or toggle the active provider from
the framework mission setup UI when a mission includes custom generators
## Validation Checklist

View File

@ -29,6 +29,10 @@ calculates missing fuel from the vehicle config `fuelCapacity`, charges the
player's organization, and fills the vehicle only after the organization charge
succeeds.
The refuel price per liter is controlled by `fuelCost`. The mission setup UI
can override it at startup; otherwise a mission `Params` entry named
`fuelCost` or `CfgServicePricing >> fuelCost` is used.
## Repair
Repair is organization-funded.
@ -45,6 +49,9 @@ The target is only repaired after the organization charge succeeds.
The client garage UI forwards selected nearby vehicle repair requests through
the same event.
The default repair charge is controlled by `serviceRepairCost`. A direct
service event can still pass a concrete `_cost` to override that request.
## Rearm
Rearm is organization-funded.
@ -63,6 +70,9 @@ turrets, so the service broadcasts the ammo reset after billing succeeds.
The client garage UI forwards selected nearby vehicle rearm requests through
the same event.
The default rearm charge is controlled by `serviceRearmCost`. A direct service
event can still pass a concrete `_cost` to override that request.
## Medical
Medical is player-funded first.
@ -79,6 +89,15 @@ The heal only completes after one of those charges succeeds. If personal
billing is unavailable, the heal does not fall back to organization funds
because the server cannot verify that the player is unable to cover the fee.
Medical pricing uses:
- `medicalHealCost` for heal billing.
- `medicalSpawnCost` for medical respawn billing. Respawn billing is
best-effort so a failed charge does not block the respawn flow.
Both values can be set in the mission setup UI, mission `Params`, or
`CfgServicePricing`.
## Medical Debt Repayment
Medical fallback debt uses the existing organization credit-line repayment

View File

@ -164,9 +164,13 @@ airports, bus stops, teleport terminals, or any other mission transport system.
The framework owns the menu, billing, cargo scan, and movement logic. The
mission only needs placed objects and optional arrival markers.
![Placeholder: Eden transport node object placement](images/eden/transport_node_obj.svg)
![Eden transport location one](images/eden/transport_loc_1.jpg)
![Placeholder: Eden transport node variable name](images/eden/transport_node_var.svg)
![Eden transport location two](images/eden/transport_loc_2.jpg)
![Eden transport node object placement](images/eden/transport_obj_1.jpg)
![Eden transport node variable name](images/eden/transport_obj_1_var.jpg)
Place transport node objects with these variable names:
@ -188,7 +192,9 @@ transport_arrival_2
transport_arrival_10
```
![Placeholder: Eden transport arrival marker placement](images/eden/transport_arrival_marker.svg)
![Eden transport arrival marker placement](images/eden/transport_arrival_mrkr.jpg)
![Eden transport arrival marker variable name](images/eden/transport_arrival_mrkr_var.jpg)
Objects that should be excluded from the nearby cargo scan, such as the actual
boat or transport vehicle used as set dressing, should use:
@ -201,7 +207,9 @@ transport_vehicle_2
transport_vehicle_10
```
![Placeholder: Eden transport vehicle exclusion object variable name](images/eden/transport_vehicle_var.svg)
![Eden transport vehicle exclusion object placement](images/eden/transport_veh_obj.jpg)
![Eden transport vehicle exclusion object variable name](images/eden/transport_veh_obj_var.jpg)
Minimum Eden setup:
@ -754,19 +762,31 @@ CAD dispatcher-requested generation.
The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown,
reward ranges, reputation ranges, penalty ranges, and time limits. It does not
enable or disable generated missions; use the CBA setting for that policy.
reward ranges, reputation ranges, penalty ranges, time limits, and a generator
provider preference. It also exposes service pricing for medical spawn, heal,
repair, rearm, refuel, and transport defaults. It does not enable or disable
generated missions; use the CBA setting for that policy.
Task time limits can be disabled from the setup UI by turning off the task
timer. That stores `timeLimitMin = 0` and `timeLimitMax = 0`, which generated
tasks treat as no timer. Positive min/max values enable task timers and are
rolled in seconds.
If mission setup is enabled, the mission manager waits until the setup operator
applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened.
Future custom-generator support should add an explicit provider option so
mission designers or developers can select or toggle a mission/community-owned
generator without relying on mission-local fallback functions. Until then,
custom generators should create CAD-visible tasks directly through the task
catalog/status contract described in
Service pricing fallback values live in mission-local `CfgServicePricing.hpp`.
Mission `Params` with matching names, such as `medicalHealCost`,
`serviceRepairCost`, `serviceRearmCost`, `fuelCost`, `transportBaseFare`, and
`transportPricePerKm`, are read before the setup UI hydrates so mission makers
can keep a non-UI backup.
The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
generated task requests use the task provider registry and route to the selected
provider. Custom generators should register a provider or create CAD-visible
tasks directly through the task catalog/status contract described in
[Custom Mission Generators](./CUSTOM_MISSION_GENERATORS.md).
The dynamic mission generator avoids rectangle and ellipse area markers whose

View File

@ -80,6 +80,25 @@ New owned garages are created with default unlocks from the Rust model.
| `owned:garage:delete` | `uid` | `OK`. |
| `owned:garage:exists` | `uid` | `true` or `false`. |
## Starting Equipment And Unlocks
Missions can include `CfgStartingEquipment.hpp` from `description.ext` to set
new-player loadout and initial virtual arsenal or garage unlocks without
recompiling the framework.
```cpp
#include "CfgStartingEquipment.hpp"
```
`loadout[]` uses the standard Arma loadout array shape. `Unlocks.Locker`
supports `items[]`, `weapons[]`, `magazines[]`, and `backpacks[]`.
`Unlocks.Garage` supports `cars[]`, `armor[]`, `helis[]`, `planes[]`,
`naval[]`, and `other[]`.
The extension defaults are intentionally empty. The server seeds mission
starting unlocks only when a player does not already have persistent owned
locker or garage records.
## Add Virtual Arsenal Unlocks
```sqf

View File

@ -227,7 +227,7 @@ Player workflow:
1. Stand near a transport point.
2. Open the actor interaction menu.
3. Select Transport.
4. Select a destination from the transport submenu, or select Back to return
4. Select a destination from the transport submenu, or select Close to return
to the default interaction menu.
![Transport destination submenu](images/player/transport_destination_menu.jpg)

View File

@ -2,7 +2,8 @@
The store module processes checkout requests. It charges a payment source and
grants purchased items to the player locker, virtual arsenal locker, and
virtual garage unlocks.
virtual garage unlocks. Unit purchases are fulfilled as immediate server-side
spawn grants at discovered `unit_spawn` markers.
## Server SQF Module
@ -20,6 +21,90 @@ post-init. The initializer matches non-null mission namespace objects whose
variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities.
## Mission Catalog Filter
The store catalog is generated from loaded Arma config classes, then an
optional mission `CfgStore` filter can allow or deny classnames per category.
Include `CfgStore.hpp` from `description.ext`:
```cpp
#include "CfgStore.hpp"
```
```cpp
class CfgStore {
mode = "allowlist"; // dynamic, allowlist, or denylist
modMode = "dynamic"; // dynamic, allowlist, or denylist
mods[] = {}; // ModSources child class names used when modMode is not dynamic
class ModSources {
class rhs {
patches[] = {"rhs_main", "rhsusf_main"};
addons[] = {"rhs_", "rhsusf_", "rhsgref_", "rhsafrf_"};
prefixes[] = {"rhs_", "rhsusf_", "rhsgref_", "rhsafrf_"};
};
class ace3 {
patches[] = {"ace_main"};
addons[] = {"ace_"};
prefixes[] = {"ace_"};
};
};
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.
`modMode` applies before category filtering. `dynamic` means no mod-source
filtering. `allowlist` only keeps generated entries that match one of the
configured `mods[]`; `denylist` removes matching entries. Each `ModSources`
child can define `patches[]` to detect whether the mod is loaded, `addons[]`
for exact config source addon/source mod names, and `prefixes[]` for classname,
source addon, or source mod prefixes. If a mod source defines no patches, it is
treated as available and only the source/prefix checks are used.
For example, to show only RHS-sourced generated inventory:
```cpp
modMode = "allowlist";
mods[] = {"rhs"};
```
The matching `class rhs` must exist under `ModSources`. Category `mode` is still
applied afterward, so leave `mode = "dynamic"` if the mod filter should be the
only inventory filter.
The current filter is global for the mission. Revisit per-store profile support
if individual vendors need different inventories.
## Unit Spawn Markers
Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
`unit_spawn_2`, and so on. The store resolves the closest initialized store
object to the requesting player, scans `allMapMarkers` when checkout fulfillment
runs, and uses the closest matching marker within 25 meters of that store.
If no matching marker exists within 25 meters, the store falls back to spawning
units around the store object. If no store object can be resolved, it falls back
to the requesting player.
## Checkout Model
`store:checkout` accepts one JSON context.
@ -45,6 +130,13 @@ pattern used by garage entities.
"category": "cars",
"priceValue": 1500
}
],
"units": [
{
"classname": "B_Soldier_F",
"category": "units",
"priceValue": 2500
}
]
}
```
@ -52,12 +144,13 @@ pattern used by garage entities.
Rules validated by the Rust service:
- `requesterUid` is required.
- At least one item or vehicle is required.
- At least one item, vehicle, or unit is required.
- The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`.
- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org
@ -73,11 +166,12 @@ Rules validated by the Rust service:
```json
{
"chargedTotal": 2000.0,
"chargedTotal": 4500.0,
"paymentMethod": "bank",
"message": "Checkout completed. $2,000 charged, 1 locker grant(s), 1 vehicle unlock(s).",
"message": "Checkout completed. $4,500 charged, 1 locker grant(s), 1 vehicle unlock(s), 1 unit grant(s).",
"lockerGranted": [],
"vehicleGranted": [],
"unitGranted": [],
"lockerPatch": {},
"vaPatch": {},
"vgaragePatch": {},
@ -108,7 +202,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"],
["items", [_item]],
["vehicles", []]
["vehicles", []],
["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@ -133,7 +228,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"],
["items", []],
["vehicles", [_vehicle]]
["vehicles", [_vehicle]],
["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];

View File

@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
default it installs or updates to the newest compatible SurrealDB 3.x release
reported by SurrealDB's official version endpoint. You can also pin an exact
release:
```powershell
.\UpdateMe.bat v3.1.2
.\UpdateSurrealDB.ps1 -Version v3.1.2
```
To intentionally install the latest stable SurrealDB release regardless of
major version, run:
```powershell
.\UpdateMe.bat latest
```
The `latest` option prompts for confirmation because a newer SurrealDB major
version can require rebuilding the Forge server extension from source with a
compatible `surrealdb` Rust crate.
`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
Forge database with the same defaults shown below.
On Linux or macOS:
```bash

View File

@ -188,19 +188,27 @@ server-side.
The mission setup UI does not enable or disable generated missions. It applies
runtime tuning such as faction, caps, intervals, reward ranges, rating ranges,
penalties, and time limits. Generator enablement remains controlled by the CBA
setting above.
penalties, time limits, service pricing, and a generator provider preference.
Generator enablement remains controlled by the CBA setting above.
When `forge_server_task_enableMissionSetup` is enabled, the mission manager
waits for setup settings before starting. There is no timeout auto-apply.
Pressing Cancel, X, or Escape applies default values from CBA, mission
parameters, and `CfgMissions`.
Planned custom-generator work should add an explicit provider option for
mission designers or developers who want to select or toggle a custom mission
generator. That provider option should be separate from the built-in generator
CBA gate so disabling Forge's built-in generator does not prevent custom
providers from publishing CAD-visible work.
Service price defaults are stored in `CfgServicePricing`. Mission
`Params` with matching names override those defaults before the UI opens, and
submitted UI values override both. The supported names are
`medicalSpawnCost`, `medicalHealCost`, `serviceRepairCost`,
`serviceRearmCost`, `fuelCost`, `transportBaseFare`, and
`transportPricePerKm`.
The setup UI stores the provider preference in
`forge_server_task_generatorProvider` as `builtin` or `custom`. CAD/manual
generated task requests use the task provider registry and route to the selected
provider. That provider option stays separate from the built-in generator CBA
gate so disabling Forge's built-in generator does not prevent custom providers
from publishing CAD-visible work.
## CAD Compatibility
@ -623,6 +631,10 @@ Task time limits use `0` for no limit:
Positive values are measured in seconds. Do not pass `-1` as a no-limit value;
the task runtime treats any non-zero task time limit as active.
The mission setup UI uses the same rule. Turning off the task timer stores
`timeLimitMin = 0` and `timeLimitMax = 0`; turning it on uses the configured
positive min/max range for generated tasks.
Defuse IED timers are different. `iedTimer` must be greater than `0`, because
IEDs are expected to have an active countdown. The Eden defuse module defaults
to `300` seconds.

View File

@ -2,7 +2,7 @@
The transport service provides paid point-to-point travel for players and
nearby vehicles or passengers. It is framework-owned: missions only need placed
transport objects and arrival markers with the expected variable names.
transport objects and optional arrival markers with the expected variable names.
## Mission Contract
@ -89,6 +89,12 @@ nearby vehicles, ships, aircraft, and player units. The scan ignores:
Use `transport_vehicle*` names for the actual boat, ferry, aircraft, or set
dressing object that should not be moved as cargo.
## Pricing
Default transport pricing comes from the mission setup UI or matching mission
`Params` entries named `transportBaseFare` and `transportPricePerKm`. If neither
is set, `CfgServicePricing` provides the fallback.
## Optional Per-Node Overrides
The default naming convention should cover normal missions. If a specific
@ -108,11 +114,12 @@ this setVariable ["transportCargoRadius", 25, true];
this setVariable ["transportIncludeCargo", true, true];
```
Only use overrides when the default `transport*` convention is not appropriate.
Only use overrides when the default `transport*` convention or mission-level
pricing is not appropriate.
## Reference Images
## Image Checklist
These screenshots show the default transport setup and player workflow:
Replace these placeholder image references after screenshots are captured:
![Eden transport location one](images/eden/transport_loc_1.jpg)
@ -135,3 +142,9 @@ These screenshots show the default transport setup and player workflow:
![Player transport destination submenu](images/player/transport_destination_menu.jpg)
![Player transport completion notification](images/player/transport_complete.jpg)
## Mission-Side Code Requirement
No mission-side transport service, addAction script, or server event bridge is
required. The framework handles menu discovery, destination selection, pricing,
billing, cargo movement, and EventBus notifications.

View File

@ -44,6 +44,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
default it installs or updates to the newest compatible SurrealDB 3.x release
reported by SurrealDB's official version endpoint. You can also pin an exact
release:
```powershell
.\UpdateMe.bat v3.1.2
.\UpdateSurrealDB.ps1 -Version v3.1.2
```
To intentionally install the latest stable SurrealDB release regardless of
major version, run:
```powershell
.\UpdateMe.bat latest
```
The `latest` option prompts for confirmation because a newer SurrealDB major
version can require rebuilding the Forge server extension from source with a
compatible `surrealdb` Rust crate.
`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
Forge database with the same defaults shown below.
On Linux or macOS:
```bash

View File

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

View File

@ -164,9 +164,13 @@ airports, bus stops, teleport terminals, or any other mission transport system.
The framework owns the menu, billing, cargo scan, and movement logic. The
mission only needs placed objects and optional arrival markers.
![Placeholder: Eden transport node object placement](images/eden/transport_node_obj.svg)
![Eden transport location one](images/eden/transport_loc_1.jpg)
![Placeholder: Eden transport node variable name](images/eden/transport_node_var.svg)
![Eden transport location two](images/eden/transport_loc_2.jpg)
![Eden transport node object placement](images/eden/transport_obj_1.jpg)
![Eden transport node variable name](images/eden/transport_obj_1_var.jpg)
Place transport node objects with these variable names:
@ -188,7 +192,9 @@ transport_arrival_2
transport_arrival_10
```
![Placeholder: Eden transport arrival marker placement](images/eden/transport_arrival_marker.svg)
![Eden transport arrival marker placement](images/eden/transport_arrival_mrkr.jpg)
![Eden transport arrival marker variable name](images/eden/transport_arrival_mrkr_var.jpg)
Objects that should be excluded from the nearby cargo scan, such as the actual
boat or transport vehicle used as set dressing, should use:
@ -201,7 +207,9 @@ transport_vehicle_2
transport_vehicle_10
```
![Placeholder: Eden transport vehicle exclusion object variable name](images/eden/transport_vehicle_var.svg)
![Eden transport vehicle exclusion object placement](images/eden/transport_veh_obj.jpg)
![Eden transport vehicle exclusion object variable name](images/eden/transport_veh_obj_var.jpg)
Minimum Eden setup:
@ -754,19 +762,19 @@ CAD dispatcher-requested generation.
The optional framework mission setup UI lets the setup operator choose runtime
tuning such as opposing faction, mission cap, interval, location cooldown,
reward ranges, reputation ranges, penalty ranges, and time limits. It does not
enable or disable generated missions; use the CBA setting for that policy.
reward ranges, reputation ranges, penalty ranges, time limits, and a generator
provider preference. It does not enable or disable generated missions; use the
CBA setting for that policy.
If mission setup is enabled, the mission manager waits until the setup operator
applies settings. Cancel, X, and Escape apply default values from CBA, mission
parameters, and `CfgMissions`. There is no timeout that auto-applies defaults.
After settings are applied, the setup UI cannot be reopened.
Future custom-generator support should add an explicit provider option so
mission designers or developers can select or toggle a mission/community-owned
generator without relying on mission-local fallback functions. Until then,
custom generators should create CAD-visible tasks directly through the task
catalog/status contract described in
The setup UI stores the provider preference as `builtin` or `custom`. CAD/manual
generated task requests use the task provider registry and route to the selected
provider. Custom generators should register a provider or create CAD-visible
tasks directly through the task catalog/status contract described in
[Custom Mission Generators](/getting-started/custom-mission-generators).
The dynamic mission generator avoids rectangle and ellipse area markers whose

View File

@ -226,7 +226,7 @@ Player workflow:
1. Stand near a transport point.
2. Open the actor interaction menu.
3. Select Transport.
4. Select a destination from the transport submenu, or select Back to return
4. Select a destination from the transport submenu, or select Close to return
to the default interaction menu.
![Transport destination submenu](images/player/transport_destination_menu.jpg)

View File

@ -43,6 +43,30 @@ cd arma/server/surrealdb
.\RunMe.bat
```
On Windows, `UpdateMe.bat` is a wrapper around `UpdateSurrealDB.ps1`. By
default it installs or updates to the newest compatible SurrealDB 3.x release
reported by SurrealDB's official version endpoint. You can also pin an exact
release:
```powershell
.\UpdateMe.bat v3.1.2
.\UpdateSurrealDB.ps1 -Version v3.1.2
```
To intentionally install the latest stable SurrealDB release regardless of
major version, run:
```powershell
.\UpdateMe.bat latest
```
The `latest` option prompts for confirmation because a newer SurrealDB major
version can require rebuilding the Forge server extension from source with a
compatible `surrealdb` Rust crate.
`RunMe.bat` is a wrapper around `RunSurrealDB.ps1`, which starts the local
Forge database with the same defaults shown below.
On Linux or macOS:
```bash

View File

@ -3,9 +3,8 @@ title: "Custom Mission Generators"
description: "Forge can be used as a complete out-of-box PMC mission framework, or as a foundation that communities build on top of. Custom mission generators should integrate through the same task, CAD, and event surfaces that the built-in mission manager uses."
---
This guide documents the supported integration path today and calls out the
current CAD generated-task provider limitation that should be addressed by a
small framework extension point.
This guide documents the supported integration path for custom generators,
including the provider registry used by CAD/manual generated task requests.
## Recommended Architecture
@ -33,13 +32,24 @@ forge_server_task_enableGenerator = false;
When disabled, Forge does not run timer-based generated missions and CAD
hydrates no built-in generated task types.
This does not prevent custom code from creating CAD-visible tasks directly.
It only disables the built-in generator request list and the framework-owned
manual request entry point.
This does not prevent custom code from creating CAD-visible tasks directly or
from serving CAD/manual generated task requests through a registered custom
provider.
The mission setup UI does not override this setting. Generated mission
enablement is mission/server policy and should stay in CBA settings until a
provider selection extension point exists.
enablement for the built-in provider is mission/server policy and stays in CBA
settings.
The mission setup UI can capture a generator provider preference:
- `builtin` for Forge's built-in generated mission provider
- `custom` for mission/community-owned generated mission providers
That preference is stored in `forge_server_task_generatorProvider` and mirrored
inside `forge_server_task_missionSetup_settings`. It is intentionally separate
from `forge_server_task_enableGenerator`; the CBA setting only gates Forge's
built-in provider. A registered custom provider can still publish generated task
types and handle CAD/manual requests when selected.
## Framework Mission Setup UI
@ -66,6 +76,7 @@ missionNamespace setVariable [
The UI configures:
- opposing faction
- generator provider preference
- max concurrent generated missions
- mission interval
- location reuse cooldown
@ -98,6 +109,75 @@ actor interaction entry is hidden once clients receive the public applied flag,
and direct or stale open requests receive a notification explaining that setup
has already been applied.
## Provider Registry
Custom providers register on the server through the server-side CBA event:
```sqf
[
"forge_server_task_registerMissionGeneratorProvider",
["custom", _provider]
] call CBA_fnc_serverEvent;
```
This event is intentionally fire-and-forget. The task module validates provider
shape server-side and logs registration failures.
The provider is a hashMap/hashMapObject with two required methods:
| Method | Arguments | Return |
| --- | --- | --- |
| `getGeneratedTaskTypes` | none | Array of hashMaps with `value` and `label` |
| `requestMissionTask` | `_taskType`, `_metadata`, `_requesterUid` | Result hashMap |
The request result should include:
| Key | Type | Notes |
| --- | --- | --- |
| `success` | Boolean | `true` when a task was generated |
| `message` | String | User-facing CAD response |
| `taskID` | String | Created task ID, or empty on failure |
| `taskType` | String | Resolved generated task type |
Example provider:
```sqf
private _provider = createHashMapObject [[
["#type", "CommunityMissionGeneratorProvider"],
["getGeneratedTaskTypes", {
[
createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]],
createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]]
]
}],
["requestMissionTask", {
params ["_taskType", "_metadata", "_requesterUid"];
private _taskID = format ["custom_%1_%2", _taskType, floor random 100000];
// Create/spawn the mission here, then publish it through Forge's task
// catalog/status contract so CAD can assign and track it.
createHashMapFromArray [
["success", true],
["message", format ["Generated custom %1 task %2.", _taskType, _taskID]],
["taskID", _taskID],
["taskType", _taskType]
]
}]
]];
[
"forge_server_task_registerMissionGeneratorProvider",
["custom", _provider]
] call CBA_fnc_serverEvent;
```
When the setup UI provider toggle is set to `custom`, CAD hydrates task types
from the registered `custom` provider and CAD/manual requests call that
provider's `requestMissionTask` method. If no custom provider is registered,
Forge logs a warning and falls back to the built-in provider.
## CAD-Visible Task Contract
CAD reads assignable contracts from `TaskStore.getActiveTaskCatalog`. A custom
@ -288,78 +368,33 @@ when relevant changes occur. Custom generators usually only need to emit task
status changes through TaskStore or extension commands; CAD refresh follows
from the existing listeners.
## Generated Task Dropdown Limitation
## Generated Task Provider Behavior
The current CAD generated-task dropdown is owned by the framework task mission
manager. CAD hydrates `generatedTaskTypes` from the built-in manager when
`forge_server_task_enableGenerator` is enabled. When that setting is disabled,
the generated-task request control is disabled.
CAD hydrates generated task types and requests generated tasks through the task
provider registry. The selected provider comes from
`forge_server_task_generatorProvider`, defaulting to `builtin`.
The current CAD request handler calls `forge_server_task_fnc_requestMissionTask`
directly. It no longer falls back to mission-local generator request functions,
so third-party generated-task providers should create CAD-visible tasks directly
until a framework provider extension point is added.
Use one of these supported patterns:
Until a provider extension point is added, use one of these supported patterns:
1. Run custom generators from mission/server code and create CAD-visible tasks
1. Register a custom provider so CAD/manual generated task requests route to
community code.
2. Run custom generators from mission/server code and create CAD-visible tasks
directly.
2. Use CAD support requests or dispatch orders to let players request custom
3. Use CAD support requests or dispatch orders to let players request custom
work, then have mission code convert approved requests into tasks.
3. Keep the built-in generator enabled only if the community intentionally
4. Keep the built-in generator enabled only if the community intentionally
wants the framework dropdown and request handler.
## Planned Provider Extension Point
## Provider Extension Details
A future code change should make CAD generator providers explicit. The desired
shape is:
The implemented provider shape is intentionally small:
- built-in Forge provider remains the default out-of-box behavior
- mission/community providers can supply their own `generatedTaskTypes`
- mission/community providers can handle generated-task requests
- disabling the built-in provider does not disable custom providers
- mission designers or developers can select or toggle the active generator
provider when a mission includes custom generators
- a framework-hosted mission setup UI can display the active provider and, when
supported by the mission, allow choosing between built-in and custom
providers
Candidate SQF hooks:
```sqf
forge_custom_fnc_getGeneratedTaskTypes
forge_custom_fnc_requestMissionTask
```
or mission namespace variables:
```sqf
missionNamespace setVariable ["forge_generatorProvider_getTypes", {
[
createHashMapFromArray [["value", "supply_drop"], ["label", "Supply Drop"]],
createHashMapFromArray [["value", "pvp_hold"], ["label", "PvP Hold Area"]]
]
}];
missionNamespace setVariable ["forge_generatorProvider_requestTask", {
params ["_taskType", "_metadata", "_requesterUid"];
createHashMapFromArray [
["success", true],
["message", "Generated custom task."],
["taskID", "custom_task_01"],
["taskType", _taskType]
]
}];
```
The exact API should be implemented in the framework code before communities
depend on it.
Implementation note: the provider selection should be separate from
`forge_server_task_enableGenerator`. That CBA setting should continue to gate
the built-in Forge generator, while a new provider option can decide whether
CAD/manual requests use the built-in provider, a custom provider, both, or no
provider at all.
- mission designers or developers can select or toggle the active provider from
the framework mission setup UI when a mission includes custom generators
## Validation Checklist

View File

@ -0,0 +1,158 @@
---
title: "Git Workflow"
description: "This repository uses `master` as the clean framework branch. Mission folders are kept off `master` so the framework can be versioned without bundling local test missions or playable mission copies."
---
## Workflow Helper
The repository includes a small helper for the common branch checks and branch
switching commands:
```powershell
npm run workflow -- status
npm run workflow -- doctor
npm run workflow -- switch dev
npm run workflow -- switch missions
npm run workflow -- start-feature cad-task-request
npm run workflow -- release-check
```
The helper refuses branch switches and feature branch creation when the working
tree has uncommitted changes. Use the manual Git commands below when you need
more control.
## Branch Roles
- `master`: framework source, addon code, Rust extension code, docs, tooling,
and release tags.
- `missions/local-mission-copies`: local mission folders used for testing and
mission iteration. This branch is not pushed unless intentionally needed.
- `archive/pre-v0.1-history`: read-only archive of the previous full `master`
history before the `v0.1.0` baseline cleanup.
## Daily Framework Work
Start from the clean framework branch.
```powershell
git switch master
git pull
git status --short --branch
```
Create a short-lived feature branch for framework work.
```powershell
git switch -c feature/garage-marker-selection
```
Make the change, validate it, then commit.
```powershell
git status --short --branch
git add arma/client/addons/garage/functions/fnc_initContextService.sqf
git commit -m "Improve garage spawn marker selection"
```
Merge the work back into `master`. Squash merges keep future `master` history
compact.
```powershell
git switch master
git merge --squash feature/garage-marker-selection
git commit -m "Improve garage spawn marker selection"
git push
```
Remove the local feature branch when it is no longer needed.
```powershell
git branch -D feature/garage-marker-selection
```
## Mission Work
Switch to the local mission branch before editing mission folders.
```powershell
git switch missions/local-mission-copies
git status --short --branch
```
Mission folders currently tracked on that branch:
```text
arma/forge_framework.Malden
arma/forge_pmc_simulator.Tanoa
arma/forge_pmc_simulator_v2.Tanoa
```
Commit mission-only changes on the mission branch.
```powershell
git add arma/forge_pmc_simulator.Tanoa
git commit -m "Update PMC simulator mission setup"
```
Do not merge the mission branch into `master`. If a mission change becomes
framework code, copy only the reusable files or logic onto a framework feature
branch created from `master`.
Example:
```powershell
git switch master
git switch -c feature/cad-on-demand-task-request
# Bring over only the framework files needed from the mission branch.
git checkout missions/local-mission-copies -- arma/client/addons/cad/functions/fnc_initUIBridge.sqf
git checkout missions/local-mission-copies -- arma/server/addons/cad/XEH_preInit.sqf
git add arma/client/addons/cad/functions/fnc_initUIBridge.sqf arma/server/addons/cad/XEH_preInit.sqf
git commit -m "Add CAD on-demand mission task request bridge"
```
## Release Versioning
Use tags to mark framework releases.
Version guideline:
- Patch, such as `v0.1.1`: fixes and small compatible changes.
- Minor, such as `v0.2.0`: new modules or features.
- Major, such as `v1.0.0`: stable release line or breaking changes.
Create a release tag from `master`.
```powershell
git switch master
git pull
git status --short --branch
git tag -a v0.1.1 -m "v0.1.1"
git push origin master
git push origin v0.1.1
```
## Safety Checks
Before committing on `master`, check that no mission folders are staged.
```powershell
git status --short --branch
```
On `master`, these paths should not appear:
```text
arma/forge_framework.Malden
arma/forge_pmc_simulator.Tanoa
arma/forge_pmc_simulator_v2.Tanoa
```
If mission files appear while on `master`, stop and switch to the mission
branch before continuing.
```powershell
git switch missions/local-mission-copies
```

View File

@ -1,6 +1,6 @@
---
title: "Store Usage Guide"
description: "The store module processes checkout requests. It charges a payment source and grants purchased items to the player locker, virtual arsenal locker, and virtual garage unlocks."
description: "The store module processes checkout requests. It charges a payment source and grants purchased items to the player locker, virtual arsenal locker, virtual garage unlocks, and immediate unit spawn grants."
---
## Server SQF Module
@ -9,16 +9,70 @@ The server addon uses two long-lived module objects:
- `StorefrontStore` is the storefront workflow facade. It builds hydrate
payloads, validates checkout requests, calls the Rust `store:checkout`
command, syncs UI patches, and asks related module stores to save hot state.
command, syncs UI patches, asks related module stores to save hot state, and
spawns purchased units at discovered `unit_spawn` markers after the backend
charge succeeds.
- `StoreCatalogService` scans configured item and vehicle categories, builds
catalog responses, resolves checkout entries, and calculates authoritative
prices.
prices. It also applies the optional mission `CfgStore` filter and overrides
before payloads or checkout validation use catalog entries.
Editor-placed store entities are initialized by `fnc_initStore` during store
post-init. The initializer matches non-null mission namespace objects whose
variable names contain `store` and sets `isStore = true`, following the same
pattern used by garage entities.
## Mission Catalog Filter
The store catalog is generated from loaded Arma config classes, then an
optional mission `CfgStore` filter can allow or deny classnames per category.
Include `CfgStore.hpp` from `description.ext`:
```cpp
#include "CfgStore.hpp"
```
```cpp
class CfgStore {
mode = "allowlist"; // dynamic, allowlist, or denylist
class Categories {
primary[] = {"arifle_MX_F", "arifle_MXC_F"};
cars[] = {"B_MRAP_01_F"};
units[] = {"B_Soldier_F"};
};
class Overrides {
class arifle_MX_F {
price = 2500;
displayName = "MX Rifle";
description = "Approved PMC service rifle.";
};
};
};
```
`dynamic` keeps the full generated catalog. `allowlist` only shows classnames
listed for each category. `denylist` removes listed classnames. Overrides are
server-side and are used by both the UI payload and checkout validation.
`units[]` follows the same filter behavior and is fulfilled as an immediate
server-side unit spawn at a discovered `unit_spawn` marker after checkout
succeeds.
The current filter is global for the mission. Revisit per-store profile support
if individual vendors need different inventories.
## Unit Spawn Markers
Purchased units spawn at mission markers named `unit_spawn`, `unit_spawn_1`,
`unit_spawn_2`, and so on. The store resolves the closest initialized store
object to the requesting player, scans `allMapMarkers` when checkout fulfillment
runs, and uses the closest matching marker within 25 meters of that store.
If no matching marker exists within 25 meters, the store falls back to spawning
units around the store object. If no store object can be resolved, it falls back
to the requesting player.
## Checkout Model
`store:checkout` accepts one JSON context.
@ -44,6 +98,13 @@ pattern used by garage entities.
"category": "cars",
"priceValue": 1500
}
],
"units": [
{
"classname": "B_Soldier_F",
"category": "units",
"priceValue": 2500
}
]
}
```
@ -51,12 +112,13 @@ pattern used by garage entities.
Rules validated by the Rust service:
- `requesterUid` is required.
- At least one item or vehicle is required.
- At least one item, vehicle, or unit is required.
- The checkout total must be greater than zero.
- Item categories must be `item`, `attachment`, `weapon`, `magazine`, or
`backpack`.
- Vehicle categories must be `cars`, `armor`, `helis`, `planes`, `naval`, or
`other`.
- Unit categories must be `units` or `unit`.
- Payment method must be `cash`, `bank`, `org_funds`, or `credit_line`.
- Player locker capacity cannot exceed 25 unique items after checkout.
- Organization funds can only be charged by the org owner or the default org
@ -72,11 +134,12 @@ Rules validated by the Rust service:
```json
{
"chargedTotal": 2000.0,
"chargedTotal": 4500.0,
"paymentMethod": "bank",
"message": "Checkout completed. $2,000 charged, 1 locker grant(s), 1 vehicle unlock(s).",
"message": "Checkout completed. $4,500 charged, 1 locker grant(s), 1 vehicle unlock(s), 1 unit grant(s).",
"lockerGranted": [],
"vehicleGranted": [],
"unitGranted": [],
"lockerPatch": {},
"vaPatch": {},
"vgaragePatch": {},
@ -107,7 +170,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "bank"],
["items", [_item]],
["vehicles", []]
["vehicles", []],
["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];
@ -132,7 +196,8 @@ private _checkout = createHashMapFromArray [
["requesterIsDefaultOrgCeo", false],
["paymentMethod", "org_funds"],
["items", []],
["vehicles", [_vehicle]]
["vehicles", [_vehicle]],
["units", []]
];
private _result = "forge_server" callExtension ["store:checkout", [toJSON _checkout]];

View File

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

View File

@ -1,6 +1,6 @@
---
title: "Transport Service Guide"
description: "The transport service provides paid point-to-point travel for players and nearby vehicles or passengers. It is framework-owned: missions only need placed transport objects and arrival markers with the expected variable names."
description: "The transport service provides paid point-to-point travel for players and nearby vehicles or passengers. It is framework-owned: missions only need placed transport objects and optional arrival markers with the expected variable names."
---
## Mission Contract
@ -109,9 +109,9 @@ this setVariable ["transportIncludeCargo", true, true];
Only use overrides when the default `transport*` convention is not appropriate.
## Reference Images
## Image Checklist
These screenshots show the default transport setup and player workflow:
Replace these placeholder image references after screenshots are captured:
![Eden transport location one](images/eden/transport_loc_1.jpg)
@ -134,3 +134,9 @@ These screenshots show the default transport setup and player workflow:
![Player transport destination submenu](images/player/transport_destination_menu.jpg)
![Player transport completion notification](images/player/transport_complete.jpg)
## Mission-Side Code Requirement
No mission-side transport service, addAction script, or server event bridge is
required. The framework handles menu discovery, destination selection, pricing,
billing, cargo movement, and EventBus notifications.

View File

@ -69,27 +69,27 @@ Common generated IDs:
## Generated Mission Requests
Dispatchers can request framework-generated mission tasks from the CAD
dispatcher board. The server hydrates the available generated task types from
the task mission manager as `generatedTaskTypes`; the client uses that hydrated
list for the dropdown.
Dispatchers can request generated mission tasks from the CAD dispatcher board.
The server hydrates the available generated task types from the selected task
provider as `generatedTaskTypes`; the client uses that hydrated list for the
dropdown.
Generated mission requests are controlled by the server CBA setting
Built-in generated mission requests are controlled by the server CBA setting
`forge_server_task_enableGenerator`:
- Enabled: CAD receives the generated task type list and dispatchers can request
a specific generator type.
- Disabled: CAD receives an empty generated task type list, the task request UI
is disabled, and server-side request handling rejects any manual request.
- Enabled: CAD can receive the built-in generated task type list and dispatchers
can request a specific built-in generator type.
- Disabled: the built-in provider returns no task types and rejects built-in
manual requests.
The framework-owned request entry point is
`forge_server_task_fnc_requestMissionTask`. Server CAD uses this framework
handler directly; it does not call mission-local generator functions.
Server CAD routes generated mission requests through the task provider registry.
The selected provider handles the request and returns the CAD response payload.
Custom mission generators can still create CAD-visible tasks directly by
registering task catalog entries and task statuses. See
[Custom Mission Generators](/getting-started/custom-mission-generators) for the supported
integration path and the current generated-task provider limitation.
Custom mission generators can register a provider with the
`forge_server_task_registerMissionGeneratorProvider` CBA server event or create
CAD-visible tasks directly by registering task catalog entries and task
statuses. See [Custom Mission Generators](/getting-started/custom-mission-generators) for
the supported integration path.
## Submit a Support Request

View File

@ -103,13 +103,13 @@ The dispatcher-generated task dropdown is hydrated from the server
`generatedTaskTypes` payload. The UI has a built-in fallback list for loading or
older payload compatibility, but any hydrate payload that includes
`generatedTaskTypes` replaces that fallback. An empty hydrated list disables the
request control, which is how `forge_server_task_enableGenerator = false` is surfaced
client-side.
request control. For the built-in provider, this is how
`forge_server_task_enableGenerator = false` is surfaced client-side.
Custom mission generators can still publish tasks into CAD by using the server
task catalog. The generated-task dropdown itself currently needs a framework
provider extension point before custom providers can replace the built-in list
cleanly. See [Custom Mission Generators](/getting-started/custom-mission-generators).
Custom mission generators can publish tasks into CAD by using the server task
catalog or by registering a task provider that supplies `generatedTaskTypes` and
handles generated task requests. See
[Custom Mission Generators](/getting-started/custom-mission-generators).
## Authorization Notes

View File

@ -1,7 +1,4 @@
use arma_rs::{
FromArma, IntoArma,
loadout::{AssignedItems, InventoryItem, Loadout as ArmaLoadout},
};
use arma_rs::{FromArma, IntoArma, loadout::Loadout as ArmaLoadout};
use forge_shared::{
ActorValidationError, arma_value_to_json, generate_email, generate_phone_number,
};
@ -128,26 +125,7 @@ impl Actor {
}
fn default_loadout_json() -> serde_json::Value {
let mut loadout = ArmaLoadout::default();
let uniform = loadout.uniform_mut();
uniform.set_class("U_BG_Guerrilla_6_1".to_string());
let uniform_items = uniform.items_mut().unwrap();
uniform_items.push(InventoryItem::new_item("FirstAidKit".to_string(), 1));
loadout.set_headgear("H_Cap_blk_ION".to_string());
let mut items = AssignedItems::default();
items.set_map("ItemMap".to_string());
items.set_terminal("ItemGPS".to_string());
items.set_radio("ItemRadio".to_string());
items.set_compass("ItemCompass".to_string());
items.set_watch("ItemWatch".to_string());
loadout.set_assigned_items(items);
let arma_value = loadout.to_arma();
arma_value_to_json(&arma_value)
serde_json::Value::Array(Vec::new())
}
pub fn get_loadout(&self) -> Result<ArmaLoadout, String> {

View File

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

View File

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

View File

@ -23,12 +23,8 @@ pub struct VGarage {
impl VGarage {
pub fn new() -> Self {
Self::default_unlocks()
}
fn default_unlocks() -> Self {
Self {
cars: vec!["B_Quadbike_01_F".to_string()],
cars: Vec::new(),
armor: Vec::new(),
helis: Vec::new(),
planes: Vec::new(),

View File

@ -19,43 +19,11 @@ pub struct VLocker {
impl VLocker {
pub fn new() -> Self {
Self::default_unlocks()
}
fn default_unlocks() -> Self {
Self {
items: vec![
"FirstAidKit".to_string(),
"G_Combat".to_string(),
"H_Cap_blk_ION".to_string(),
"H_HelmetB".to_string(),
"ACE_EarPlugs".to_string(),
"ItemCompass".to_string(),
"ItemGPS".to_string(),
"ItemMap".to_string(),
"ItemRadio".to_string(),
"ItemWatch".to_string(),
"U_BG_Guerrilla_6_1".to_string(),
"V_TacVest_oli".to_string(),
],
weapons: vec!["arifle_MX_F".to_string(), "hgun_P07_F".to_string()],
magazines: vec![
"16Rnd_9x21_Mag".to_string(),
"30Rnd_65x39_caseless_black_mag".to_string(),
"Chemlight_blue".to_string(),
"Chemlight_green".to_string(),
"Chemlight_red".to_string(),
"Chemlight_yellow".to_string(),
"HandGrenade".to_string(),
"SmokeShell".to_string(),
"SmokeShellBlue".to_string(),
"SmokeShellGreen".to_string(),
"SmokeShellOrange".to_string(),
"SmokeShellPurple".to_string(),
"SmokeShellRed".to_string(),
"SmokeShellYellow".to_string(),
],
backpacks: vec!["B_AssaultPack_rgr".to_string()],
items: Vec::new(),
weapons: Vec::new(),
magazines: Vec::new(),
backpacks: Vec::new(),
}
}

View File

@ -1,6 +1,6 @@
use forge_models::{
Bank, BankCheckoutContext, BankMutationResult, EquipmentCategory, HotOrgRecord, Item, Locker,
OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem,
OrgFleetEntry, StoreCheckoutContext, StoreCheckoutResult, StoreGrantedItem, StoreGrantedUnit,
StoreGrantedVehicle, VGarage, VLocker, VehicleCategory,
};
use forge_repositories::{
@ -229,7 +229,7 @@ where
if context.requester_uid.trim().is_empty() {
return Err("A valid requester UID is required.".to_string());
}
if context.items.is_empty() && context.vehicles.is_empty() {
if context.items.is_empty() && context.vehicles.is_empty() && context.units.is_empty() {
return Err("Add at least one item before checkout.".to_string());
}
@ -254,6 +254,7 @@ where
let mut vgarage_patch = HashMap::new();
let mut locker_granted = Vec::new();
let mut vehicle_granted = Vec::new();
let mut unit_granted = Vec::new();
let mut va_categories_changed: Vec<&str> = Vec::new();
let mut vgarage_categories_changed: Vec<&str> = Vec::new();
@ -374,6 +375,22 @@ where
});
}
for unit_seed in &context.units {
if unit_seed.classname.trim().is_empty() {
return Err("Unit checkout entry was missing a classname.".to_string());
}
let unit_category = unit_seed.category.trim().to_ascii_lowercase();
if unit_category != "units" && unit_category != "unit" {
return Err(format!("Unit category '{}' is unsupported.", unit_category));
}
unit_granted.push(StoreGrantedUnit {
classname: unit_seed.classname.clone(),
category: "units".to_string(),
});
}
for category in vgarage_categories_changed {
match category {
"cars" => {
@ -550,13 +567,15 @@ where
charged_total,
payment_method,
message: format!(
"Checkout completed. {} charged, {} locker grant(s), {} vehicle unlock(s).",
"Checkout completed. {} charged, {} locker grant(s), {} vehicle unlock(s), {} unit grant(s).",
format_currency(charged_total),
locker_granted.len(),
vehicle_granted.len()
vehicle_granted.len(),
unit_granted.len()
),
locker_granted,
vehicle_granted,
unit_granted,
locker_patch,
va_patch,
vgarage_patch,
@ -578,8 +597,13 @@ fn checkout_total(context: &StoreCheckoutContext) -> f64 {
.iter()
.map(|entry| entry.price_value.max(0.0))
.sum::<f64>();
let unit_total = context
.units
.iter()
.map(|entry| entry.price_value.max(0.0))
.sum::<f64>();
(item_total + vehicle_total).floor()
(item_total + vehicle_total + unit_total).floor()
}
fn resolve_locker_category(category: &str) -> Result<&'static str, String> {

View File

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