Compare commits

..

3 Commits

Author SHA1 Message Date
Jacob Schmidt
582dd39640 Replace transport docs placeholders with screenshots 2026-05-25 14:33:39 -05:00
Jacob Schmidt
89e3f794dc Fix transport personal payment fallback 2026-05-25 13:59:12 -05:00
Jacob Schmidt
c0dd782103 Add framework transport service 2026-05-25 13:27:34 -05:00
138 changed files with 1465 additions and 7 deletions

View File

@ -58,6 +58,37 @@ switch (_event) do {
case "actor::open::phone": { [] spawn EFUNC(phone,openUI); };
case "actor::open::iplayer": { hint "Player interaction is not yet implemented." };
case "actor::open::store": { [] spawn EFUNC(store,openUI); };
case "actor::request::transport": {
if !(_data isEqualType createHashMap) exitWith {
hint "Invalid transport request.";
};
private _destination = _data getOrDefault ["destination", createHashMap];
if !(_destination isEqualType createHashMap) exitWith {
hint "Invalid transport destination.";
};
private _fromNode = objectFromNetId (_data getOrDefault ["netId", ""]);
private _toNode = objectFromNetId (_destination getOrDefault ["netId", ""]);
if (isNull _fromNode || { isNull _toNode }) exitWith {
hint "Transport destination is no longer available.";
};
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]],
["cargoRadius", _data getOrDefault ["cargoRadius", 25]],
["includeCargo", _data getOrDefault ["includeCargo", true]]
];
[SRPC(transport,requestTransport), [player, _fromNode, _toNode, _options]] call CFUNC(serverEvent);
};
default { hint format ["Unhandled UI event: %1", _event]; };
};

View File

@ -121,6 +121,12 @@ GVAR(ActorRepositoryBaseClass) = compileFinal createHashMapFromArray [
];
private _deviceType = _x getVariable ["deviceType", ""];
private _isPlayer = _x isKindOf "Man" && isPlayer _x;
private _objectName = vehicleVarName _x;
private _transportPrefix = _x getVariable ["transportNodePrefix", "transport"];
private _isTransport = _x getVariable ["isTransport", false];
if (!_isTransport && { _objectName isNotEqualTo "" }) then {
_isTransport = _objectName isEqualTo _transportPrefix || { (_objectName find format ["%1_", _transportPrefix]) == 0 };
};
if (_isStore) then { _nearbyActions pushBack ["store", true]; };
if (_isAtm) then { _nearbyActions pushBack ["atm", true]; };
@ -129,6 +135,55 @@ GVAR(ActorRepositoryBaseClass) = compileFinal createHashMapFromArray [
if (_isGarage) then { _nearbyActions pushBack ["garage", _garageContext]; };
if (_isGarage && GVAR(enableVG)) then { _nearbyActions pushBack ["vg", _garageContext]; };
if (_deviceType isNotEqualTo "") then { _nearbyActions pushBack ["device", _deviceType]; };
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 _vehiclePrefix = _x getVariable ["transportVehiclePrefix", format ["%1_vehicle", _transportPrefix]];
private _arrivalPrefix = _x getVariable ["transportArrivalPrefix", format ["%1_arrival", _transportPrefix]];
private _nodeNames = [_transportPrefix];
for "_i" from 1 to _maxIndexedNodes do {
_nodeNames pushBack format ["%1_%2", _transportPrefix, _i];
};
private _destinations = [];
{
private _node = missionNamespace getVariable [_x, objNull];
if (!isNull _node && { _node isNotEqualTo _fromTransportNode }) then {
private _nodeLabel = _node getVariable ["transportLabel", vehicleVarName _node];
if (_nodeLabel isEqualTo "") then { _nodeLabel = "Transport Point"; };
private _distanceMeters = _fromTransportNode distance2D _node;
private _cost = round (_baseFare + ((_distanceMeters / 1000) * _pricePerKm));
_destinations pushBack createHashMapFromArray [
["netId", netId _node],
["name", vehicleVarName _node],
["label", _nodeLabel],
["cost", _cost]
];
};
} forEach _nodeNames;
if (_destinations isNotEqualTo []) then {
private _transportContext = createHashMapFromArray [
["netId", netId _x],
["name", _objectName],
["label", _x getVariable ["transportLabel", "Transport"]],
["nodePrefix", _transportPrefix],
["vehiclePrefix", _vehiclePrefix],
["arrivalPrefix", _arrivalPrefix],
["maxIndexedNodes", _maxIndexedNodes],
["baseFare", _baseFare],
["pricePerKm", _pricePerKm],
["cargoRadius", _x getVariable ["transportCargoRadius", 25]],
["includeCargo", _x getVariable ["transportIncludeCargo", true]],
["destinations", _destinations]
];
_nearbyActions pushBack ["transport", _transportContext];
};
};
if (_isPlayer && { _x isNotEqualTo player }) then { _nearbyActions pushBack ["player", name _x]; };
} forEach (player nearObjects 5);

View File

@ -193,12 +193,19 @@ const actionDefinitions = {
description: "Access your virtual garage",
action: "actor::open::vgarage",
},
transport: {
id: "transport",
title: "Transport",
description: "Show available travel destinations",
action: "actor::show::transport",
},
};
const initialState = {
availableActions: [],
menuItems: [...baseMenuItems],
baseMenuItems: [...baseMenuItems],
defaultMenuItems: [...baseMenuItems],
actionDefinitions: { ...actionDefinitions },
};
@ -244,6 +251,7 @@ function actorReducer(state = initialState, action) {
...state,
availableActions: action.payload,
menuItems: newMenuItems,
defaultMenuItems: newMenuItems,
};
case ActionTypes.SET_MENU_ITEMS:
@ -426,6 +434,43 @@ function RadialMenu() {
const handleItemClick = (item) => {
console.log("Menu item clicked:", item);
if (item.action === "actor::show::default") {
store.dispatch(
actions.setMenuItems(state.defaultMenuItems || state.baseMenuItems),
);
return;
}
if (item.action === "actor::show::transport") {
const context = item.context || {};
const destinations = Array.isArray(context.destinations)
? context.destinations
: [];
const transportItems = [
{
id: "transport-back",
title: "Back",
description: "Return to the default interaction menu",
action: "actor::show::default",
},
...destinations.map((destination, index) => ({
id: `transport-destination-${index}`,
title: destination.cost
? `${destination.label || destination.name || "Destination"} - $${destination.cost}`
: destination.label || destination.name || "Destination",
description: "Request transport to this destination",
action: "actor::request::transport",
context: {
...context,
destination,
},
})),
];
store.dispatch(actions.setMenuItems(transportItems));
return;
}
const alert = {
event: item.action,
data: item.context || {},

View File

@ -0,0 +1 @@
forge\forge_server\addons\transport

View File

@ -0,0 +1,17 @@
class Extended_PreStart_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_preStart));
};
};
class Extended_PreInit_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_preInit));
};
};
class Extended_PostInit_EventHandlers {
class ADDON {
init = QUOTE(call COMPILE_SCRIPT(XEH_postInit));
};
};

View File

@ -0,0 +1,2 @@
PREP(initTransportService);
PREP(requestTransport);

View File

@ -0,0 +1,4 @@
#include "script_component.hpp"
if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); };
if (isNil QGVAR(TransportService)) then { call FUNC(initTransportService); };

View File

@ -0,0 +1,22 @@
#include "script_component.hpp"
PREP_RECOMPILE_START;
#include "XEH_PREP.hpp"
PREP_RECOMPILE_END;
[QGVAR(requestTransport), {
params [
["_unit", objNull, [objNull]],
["_fromNode", objNull, [objNull]],
["_toNode", objNull, [objNull]],
["_options", createHashMap, [createHashMap]]
];
if (isNull _unit || { isNull _fromNode || { isNull _toNode } }) exitWith {};
if (isNil QGVAR(TransportService)) then {
call FUNC(initTransportService);
};
GVAR(TransportService) call ["requestTransport", [_unit, _fromNode, _toNode, _options]];
}] call CFUNC(addEventHandler);

View File

@ -0,0 +1 @@
#include "script_component.hpp"

View File

@ -0,0 +1,22 @@
#include "script_component.hpp"
class CfgPatches {
class ADDON {
author = AUTHOR;
authors[] = {"J.Schmidt"};
url = ECSTRING(main,url);
name = COMPONENT_NAME;
requiredVersion = REQUIRED_VERSION;
requiredAddons[] = {
"forge_server_main",
"forge_server_common",
"forge_server_bank",
"forge_server_economy"
};
units[] = {};
weapons[] = {};
VERSION_CONFIG;
};
};
#include "CfgEventHandlers.hpp"

View File

@ -0,0 +1,408 @@
#include "..\script_component.hpp"
/*
* File: fnc_initTransportService.sqf
* Author: IDSolutions
* Date: 2026-05-25
* Public: No
*
* Description:
* Initializes the server-side paid transport service for player and vehicle
* transfers between mission-placed transport nodes.
*
* Arguments:
* None
*
* Return Value:
* Transport service object [HASHMAP OBJECT]
*
* Example:
* call forge_server_transport_fnc_initTransportService
*/
if !(isServer) exitWith { objNull };
if !(isNil QGVAR(TransportService)) exitWith { GVAR(TransportService) };
if (isNil QEGVAR(common,EventBus)) then { call EFUNC(common,eventBus); };
#pragma hemtt ignore_variables ["_self"]
GVAR(TransportServiceBase) = compileFinal createHashMapFromArray [
["#type", "TransportService"],
["#create", compileFinal {
_self set ["baseFare", 100];
_self set ["pricePerKm", 50];
_self set ["cargoRadius", 25];
_self set ["nodePrefix", "transport"];
_self set ["vehiclePrefix", "transport_vehicle"];
_self set ["arrivalPrefix", "transport_arrival"];
_self set ["maxIndexedNodes", 10];
_self set ["eventTokens", []];
["INFO", "Transport Service Initialized!"] call EFUNC(common,log);
true
}],
["notify", compileFinal {
params [["_unit", objNull, [objNull]], ["_type", "info", [""]], ["_title", "Transport", [""]], ["_message", "", [""]]];
if (isNull _unit || { _message isEqualTo "" }) exitWith { false };
private _uid = getPlayerUID _unit;
if (_uid isEqualTo "") exitWith {
[_message] remoteExecCall ["systemChat", _unit];
true
};
if (isNil QEGVAR(common,EventBus)) exitWith {
[_message] remoteExecCall ["systemChat", _unit];
true
};
EGVAR(common,EventBus) call ["emit", [
"notification.requested",
createHashMapFromArray [
["uids", [_uid]],
["notificationType", _type],
["title", _title],
["message", _message]
],
createHashMapFromArray [["source", "transport"]]
]];
true
}],
["emit", compileFinal {
params [["_eventName", "", [""]], ["_payload", createHashMap, [createHashMap]]];
if (_eventName isEqualTo "" || { isNil QEGVAR(common,EventBus) }) exitWith { createHashMap };
EGVAR(common,EventBus) call ["emit", [
_eventName,
_payload,
createHashMapFromArray [["source", "transport"]]
]]
}],
["getIndexedNames", compileFinal {
params [["_prefix", "", [""]], ["_maxIndex", 10, [0]]];
private _names = [_prefix];
for "_i" from 1 to _maxIndex do {
_names pushBack format ["%1_%2", _prefix, _i];
};
_names
}],
["getNodes", compileFinal {
params [["_options", createHashMap, [createHashMap]]];
private _nodeNames = +(_options getOrDefault ["nodeNames", []]);
if (_nodeNames isEqualTo []) then {
private _prefix = _options getOrDefault ["nodePrefix", _self getOrDefault ["nodePrefix", "transport"]];
private _maxIndex = _options getOrDefault ["maxIndexedNodes", _self getOrDefault ["maxIndexedNodes", 10]];
_nodeNames = _self call ["getIndexedNames", [_prefix, _maxIndex]];
};
private _nodes = _nodeNames apply { missionNamespace getVariable [_x, objNull] };
_nodes select { !isNull _x }
}],
["getExclusionObjects", compileFinal {
params [["_options", createHashMap, [createHashMap]]];
private _excluded = +(_options getOrDefault ["excludedObjects", []]);
_excluded append (_self call ["getNodes", [_options]]);
private _vehicleNames = +(_options getOrDefault ["vehicleNames", []]);
if (_vehicleNames isEqualTo []) then {
private _prefix = _options getOrDefault ["vehiclePrefix", _self getOrDefault ["vehiclePrefix", "transport_vehicle"]];
private _maxIndex = _options getOrDefault ["maxIndexedNodes", _self getOrDefault ["maxIndexedNodes", 10]];
_vehicleNames = _self call ["getIndexedNames", [_prefix, _maxIndex]];
};
private _vehicles = _vehicleNames apply { missionNamespace getVariable [_x, objNull] };
_excluded append (_vehicles select { !isNull _x });
_excluded
}],
["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 _distanceMeters = _fromNode distance2D _toNode;
round (_baseFare + ((_distanceMeters / 1000) * _pricePerKm))
}],
["getArrivalMarker", compileFinal {
params [["_toNode", objNull, [objNull]], ["_options", createHashMap, [createHashMap]]];
private _explicitMarker = _options getOrDefault ["arrivalMarker", ""];
if (_explicitMarker isNotEqualTo "") exitWith { _explicitMarker };
private _nodeName = vehicleVarName _toNode;
private _nodePrefix = _options getOrDefault ["nodePrefix", _self getOrDefault ["nodePrefix", "transport"]];
private _arrivalPrefix = _options getOrDefault ["arrivalPrefix", _self getOrDefault ["arrivalPrefix", "transport_arrival"]];
if (_nodeName isEqualTo _nodePrefix) exitWith { _arrivalPrefix };
private _prefixWithSeparator = format ["%1_", _nodePrefix];
if ((_nodeName find _prefixWithSeparator) != 0) exitWith { "" };
private _suffix = _nodeName select [count _prefixWithSeparator];
if (_suffix isEqualTo "") exitWith { "" };
format ["%1_%2", _arrivalPrefix, _suffix]
}],
["getArrivalPosition", compileFinal {
params [["_toNode", objNull, [objNull]], ["_index", -1, [0]], ["_options", createHashMap, [createHashMap]]];
private _marker = _self call ["getArrivalMarker", [_toNode, _options]];
private _basePos = if (_marker in allMapMarkers) then {
getMarkerPos _marker
} else {
ASLToATL (_toNode modelToWorldWorld [0, -8, 1.2])
};
if (_index < 0) exitWith { _basePos };
private _spacingX = _options getOrDefault ["cargoSpacingX", 5];
private _spacingY = _options getOrDefault ["cargoSpacingY", 7];
private _columns = _options getOrDefault ["cargoColumns", 3];
private _xOffset = ((_index % _columns) - floor (_columns / 2)) * _spacingX;
private _yOffset = floor (_index / _columns) * _spacingY;
_basePos vectorAdd [_xOffset, _yOffset, 0]
}],
["chargePassenger", compileFinal {
params [["_unit", objNull, [objNull]], ["_amount", 0, [0]], ["_label", "Transport", [""]]];
private _result = createHashMapFromArray [
["success", false],
["message", format ["Unable to charge %1 fare.", _label]],
["source", ""]
];
if (isNull _unit) exitWith { _result };
if (_amount <= 0) exitWith {
_result set ["success", true];
_result set ["message", ""];
_result
};
private _uid = getPlayerUID _unit;
if (_uid isEqualTo "") exitWith {
_result set ["message", "A valid player UID is required for transport billing."];
_result
};
private _personalSourceAttempted = false;
private _allowOrgFallback = false;
if !(isNil QEGVAR(bank,BankStore)) then {
private _account = EGVAR(bank,BankStore) call ["get", [_uid, ""]];
if (_account isEqualTo createHashMap) then {
_account = EGVAR(bank,BankStore) call ["init", [_uid]];
};
if (_account isNotEqualTo createHashMap) then {
private _bankBalance = _account getOrDefault ["bank", 0];
private _cashBalance = _account getOrDefault ["cash", 0];
private _source = ["", "bank"] select (_bankBalance >= _amount);
if (_source isEqualTo "" && { _cashBalance >= _amount }) then {
_source = "cash";
};
if (_source isNotEqualTo "") then {
_personalSourceAttempted = true;
private _charge = EGVAR(bank,BankStore) call ["chargeCheckout", [_uid, _source, _amount, true]];
if (_charge getOrDefault ["success", false]) then {
EGVAR(bank,BankStore) call ["save", [_uid]];
_result set ["success", true];
_result set ["source", _source];
_result set ["message", format ["%1 charged $%2 from your %3.", _label, [_amount] call EFUNC(common,formatNumber), _source]];
} else {
_result set ["message", _charge getOrDefault ["message", format ["Unable to charge %1 from your %2.", _label, _source]]];
};
} else {
_allowOrgFallback = true;
};
} else {
_result set ["message", "Bank account could not be loaded for transport billing."];
};
} else {
_result set ["message", "Bank service is unavailable for transport billing."];
};
if (!(_result getOrDefault ["success", false]) && { !_personalSourceAttempted } && { _allowOrgFallback } && { !(isNil QEGVAR(economy,SEconomyStore)) }) then {
private _orgCharge = EGVAR(economy,SEconomyStore) call ["chargeOrg", [_unit, _amount, _label, true]];
if (_orgCharge getOrDefault ["success", false]) exitWith {
_result set ["success", true];
_result set ["source", "org_credit"];
_result set ["message", format [
"Personal funds could not cover %1. Organization charged $%2 and added it to your credit line.",
_label,
[_amount] call EFUNC(common,formatNumber)
]];
};
_result set ["message", _orgCharge getOrDefault ["message", format ["You cannot afford %1.", _label]]];
};
_result
}],
["getNearbyCargo", compileFinal {
params [
["_fromNode", objNull, [objNull]],
["_unit", objNull, [objNull]],
["_options", createHashMap, [createHashMap]]
];
private _radius = _options getOrDefault ["cargoRadius", _self getOrDefault ["cargoRadius", 25]];
if (_radius <= 0) exitWith { [] };
private _nearby = nearestObjects [
_fromNode,
["LandVehicle", "Air", "Ship", "CAManBase"],
_radius,
true
];
private _excluded = _self call ["getExclusionObjects", [_options]];
_nearby select {
!isNull _x
&& { _x isNotEqualTo _fromNode }
&& { !(_x in _excluded) }
&& { _x isNotEqualTo _unit }
&& { alive _x }
&& {
(_x isKindOf "LandVehicle")
|| { _x isKindOf "Air" }
|| { _x isKindOf "Ship" }
|| { _x isKindOf "CAManBase" && { isPlayer _x } }
}
}
}],
["moveCargo", compileFinal {
params [["_cargo", [], [[]]], ["_toNode", objNull, [objNull]], ["_options", createHashMap, [createHashMap]]];
private _moved = [];
{
private _entity = _x;
if (isNull _entity) then { continue; };
private _pos = _self call ["getArrivalPosition", [_toNode, _forEachIndex, _options]];
if (_entity isKindOf "CAManBase") then {
[_entity, _pos] remoteExecCall ["setPosATL", _entity];
} else {
_entity setPosATL _pos;
_entity setDir (getDir _toNode);
};
_moved pushBack _entity;
} forEach _cargo;
_moved
}],
["requestTransport", compileFinal {
params [
["_unit", objNull, [objNull]],
["_fromNode", objNull, [objNull]],
["_toNode", objNull, [objNull]],
["_options", createHashMap, [createHashMap]]
];
private _result = createHashMapFromArray [
["success", false],
["message", "Transport request failed."],
["cost", 0],
["movedCargo", []]
];
if (isNull _unit || { !isPlayer _unit }) exitWith { _result };
if (isNull _fromNode || { isNull _toNode }) exitWith { _result };
if (_fromNode isEqualTo _toNode) exitWith {
_result set ["message", "Origin and destination are the same."];
_result
};
private _nodes = _self call ["getNodes", [_options]];
if !(_fromNode in _nodes && { _toNode in _nodes }) exitWith {
_result set ["message", "Transport route is unavailable."];
_result
};
private _label = _options getOrDefault ["label", "Transport"];
private _cost = _self call ["getCost", [_fromNode, _toNode, _options]];
_result set ["cost", _cost];
_self call ["emit", [
"transport.requested",
createHashMapFromArray [
["unit", _unit],
["uid", getPlayerUID _unit],
["from", _fromNode],
["to", _toNode],
["cost", _cost],
["label", _label]
]
]];
private _charge = _self call ["chargePassenger", [_unit, _cost, _label]];
if !(_charge getOrDefault ["success", false]) exitWith {
private _message = _charge getOrDefault ["message", "Transport payment failed."];
_result set ["message", _message];
_self call ["notify", [_unit, "danger", _label, _message]];
_self call ["emit", ["transport.failed", +_result]];
_result
};
private _cargo = if (_options getOrDefault ["includeCargo", true]) then {
_self call ["getNearbyCargo", [_fromNode, _unit, _options]]
} else {
[]
};
private _destination = _self call ["getArrivalPosition", [_toNode, -1, _options]];
private _movedCargo = _self call ["moveCargo", [_cargo, _toNode, _options]];
[_unit, _destination] remoteExecCall ["setPosATL", _unit];
_self call ["notify", [_unit, "info", _label, _charge getOrDefault ["message", format ["%1 paid.", _label]]]];
if (_movedCargo isNotEqualTo []) then {
_self call ["notify", [_unit, "info", _label, format ["Moved %1 nearby passenger/vehicle item(s).", count _movedCargo]]];
};
_result set ["success", true];
_result set ["message", "Transport completed."];
_result set ["movedCargo", _movedCargo];
_result set ["paymentSource", _charge getOrDefault ["source", ""]];
_self call ["emit", ["transport.completed", +_result]];
_result
}],
["registerEventHandlers", compileFinal {
if (isNil QEGVAR(common,EventBus)) exitWith { false };
if ((_self getOrDefault ["eventTokens", []]) isNotEqualTo []) exitWith { true };
private _handleRequest = {
params ["_event"];
private _unit = _event getOrDefault ["unit", objNull];
private _from = _event getOrDefault ["from", objNull];
private _to = _event getOrDefault ["to", objNull];
private _options = _event getOrDefault ["options", createHashMap];
if (isNil QGVAR(TransportService)) exitWith {};
GVAR(TransportService) call ["requestTransport", [_unit, _from, _to, _options]];
};
_self set ["eventTokens", [
EGVAR(common,EventBus) call ["on", ["transport.request", _handleRequest, "transport.request"]]
]];
true
}],
["#delete", compileFinal {
if !(isNil QEGVAR(common,EventBus)) then {
{
EGVAR(common,EventBus) call ["off", [_x]];
} forEach (_self getOrDefault ["eventTokens", []]);
};
_self set ["eventTokens", []];
}]
];
GVAR(TransportService) = createHashMapObject [GVAR(TransportServiceBase), []];
GVAR(TransportService) call ["registerEventHandlers", []];
GVAR(TransportService)

View File

@ -0,0 +1,33 @@
#include "..\script_component.hpp"
/*
* File: fnc_requestTransport.sqf
* Author: IDSolutions
* Date: 2026-05-25
* Public: No
*
* Description:
* Requests a paid transport transfer for a player and nearby cargo.
*
* Arguments:
* 0: Player unit <OBJECT>
* 1: Origin node <OBJECT>
* 2: Destination node <OBJECT>
* 3: Options <HASHMAP> (optional)
*
* Return Value:
* Result [HASHMAP]
*
* Example:
* [player, transport, transport_1] call forge_server_transport_fnc_requestTransport
*/
params [
["_unit", objNull, [objNull]],
["_fromNode", objNull, [objNull]],
["_toNode", objNull, [objNull]],
["_options", createHashMap, [createHashMap]]
];
if (isNil QGVAR(TransportService)) then { call FUNC(initTransportService); };
GVAR(TransportService) call ["requestTransport", [_unit, _fromNode, _toNode, _options]]

View File

@ -0,0 +1,9 @@
#define COMPONENT transport
#define COMPONENT_BEAUTIFIED Transport
#include "\forge\forge_server\addons\main\script_mod.hpp"
// #define DEBUG_MODE_FULL
// #define DISABLE_COMPILE_CACHE
// #define ENABLE_PERFORMANCE_COUNTERS
#include "\forge\forge_server\addons\main\script_macros.hpp"

View File

@ -6,7 +6,7 @@ handlers, and optional browser UI.
## Runtime Flow
![Architectural Flow Diagram](architecture-flow.svg)
![Architectural Flow Diagram](images/architecture-flow.svg)
```text
Arma client UI or SQF action

View File

@ -27,6 +27,42 @@ Rules validated by the Rust service:
- `locker:get`, `locker:patch`, and `locker:remove` require an existing locker.
- `locker:remove` takes the classname directly, not a JSON object.
## Multiple Locker Objects
Editor-placed locker objects are templates and access points, not separate
durable inventories. During server post-init, any mission namespace object
whose variable name contains `locker` is hidden globally. During client setup,
each client reads the hidden object's classname, ASL position, vector direction,
and vector up, then creates a matching local locker object with
`createVehicleLocal`.
The local clone is the object the player actually opens. On `ContainerOpened`,
the client clears the clone and fills it from the player's UID-owned locker
state. On `ContainerClosed`, the client reads the clone's cargo and sends a
full locker override back to the server.
There is no explicit maximum number of editor-placed locker access points. The
practical limit is mission performance and how many local container objects are
reasonable for the scenario.
All locker access points load and save the same player locker, keyed by player
UID. Opening `locker`, `locker_hq`, or `locker_outpost_1` does not create
separate persistent inventories; those objects are separate local access clones
for the same underlying player locker.
## Store Grants and Duplicate Inventory
The store checkout path grants items to the UID-owned locker hot state, not to a
specific placed locker object. Item grants are merged by classname:
- buying a new classname adds one new locker entry
- buying an existing classname increases that entry's amount
- checkout fails if the result would exceed 25 unique classnames
Having more than one locker object on the map does not duplicate store grants.
Duplicate quantities can only come from repeated checkout requests or repeated
manual locker writes, not from the number of placed locker access points.
## Commands
All commands are called on the `locker` group.

View File

@ -30,6 +30,7 @@ case-sensitive in some initializers, so use lower-case names.
| Store | name contains `store` | `isStore = true` | Store UI | Store catalog and checkout behavior are configured server-side. |
| Garage | name contains `garage` | `isGarage = true` | Garage UI and virtual garage | Include a garage category in the name or set `garageType` manually. |
| Locker | name contains `locker` | local `isLocker = true` | Virtual arsenal action | The server hides the editor object; each client creates a local locker at the same position. |
| Transport | `transport`, `transport_1` through `transport_10` | discovered by variable name or `isTransport = true` | Transport destination menu | Paid player and cargo transfer between named transport nodes. |
Recommended object names:
@ -38,6 +39,8 @@ atm
bank
store
locker
transport
transport_1
garage_hq
garage_hq_2
```
@ -60,6 +63,7 @@ _atmTerminal setVariable ["isAtm", true, true];
_storeCounter setVariable ["isStore", true, true];
_garageTerminal setVariable ["isGarage", true, true];
_garageTerminal setVariable ["garageType", "cars", true];
_transportNode setVariable ["isTransport", true, true];
```
Supported garage types are:
@ -142,6 +146,75 @@ Minimum Eden setup:
2. Set its Eden variable name to something containing `store`.
3. Test that the actor menu shows the store action within 5 meters.
## Transport Setup
Transport nodes are generic paid travel points. They can represent ferries,
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.
![Eden transport location one](images/eden/transport_loc_1.jpg)
![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:
```text
transport
transport_1
transport_2
...
transport_10
```
Place optional arrival markers with matching suffixes:
```text
transport_arrival
transport_arrival_1
transport_arrival_2
...
transport_arrival_10
```
![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:
```text
transport_vehicle
transport_vehicle_1
transport_vehicle_2
...
transport_vehicle_10
```
![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:
1. Place at least two transport node objects.
2. Name them `transport`, `transport_1`, and so on.
3. Place matching `transport_arrival*` markers where players and cargo should
appear.
4. Name any set-dressing transport vehicles `transport_vehicle*` so they are
not moved as cargo.
5. Test that the actor menu shows Transport within 5 meters of a node.
The default fare is `$100 + distance in kilometers * $50`. The server charges
player bank first, player cash second, then organization credit line fallback.
See [Transport Service Guide](./TRANSPORT_SERVICE_GUIDE.md) for override
variables and implementation details.
## Bank and ATM Setup
Bank and ATM objects intentionally expose different workflows.
@ -179,7 +252,7 @@ Minimum Eden setup:
Locker objects are slightly different from other interaction objects. The
server finds editor-placed objects whose variable names contain `locker`, hides
those global objects, and each client creates a local locker object at the same
position.
position using the placed object's classname and orientation.
![Locker object placement](images/eden/locker_obj.jpg)
@ -192,6 +265,11 @@ Minimum Eden setup:
3. Do not use `forge_locker_box`.
4. Test that the local locker appears and opens the virtual arsenal action.
There is no editor-side maximum number of locker access points. Multiple locker
objects on a map create multiple local access clones, but all of those clones
load and save the same UID-owned player locker state. They do not create
separate persistent lockers or cause store grants to duplicate by themselves.
## Medical Spawn Setup
The medical economy store discovers up to eleven medical spawn objects by exact

View File

@ -214,6 +214,32 @@ physical vehicle into the player's 5-slot garage. Use the virtual garage to
spawn an unlocked vehicle, and use the garage to store or retrieve live world
vehicles.
## Transport
Transport points let players pay to travel between configured mission locations.
They may represent ferries, terminals, air shuttles, or other mission-specific
travel points.
![Actor menu Transport action](images/player/transport_menu_action.jpg)
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 Close to return
to the default interaction menu.
![Transport destination submenu](images/player/transport_destination_menu.jpg)
The destination price is based on distance. The server charges player bank
first, player cash second, then organization credit line fallback when
available. If payment succeeds, the player is moved to the selected arrival
point. Nearby eligible vehicles or passengers may be moved with the player when
the mission has configured the transport point for cargo movement.
![Transport completion notification](images/player/transport_complete.jpg)
## Locker and Virtual Arsenal
The locker is personal item storage.
@ -225,6 +251,10 @@ Locker rules:
- Up to 25 items can be stored.
- The locker saves when the locker container is closed.
- Over-capacity storage can warn or fail depending on server handling.
- Multiple locker access points on the map open local copies of the locker
object, but all of them use the same personal locker inventory.
- Store purchases merge granted items into the same personal locker by
classname; extra locker objects on the map do not duplicate store grants.
The virtual arsenal is locked down. Players only see gear they have been
granted or have unlocked through systems such as the store. The virtual arsenal

View File

@ -0,0 +1,143 @@
# Transport Service Guide
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
By default the framework discovers transport nodes by exact mission namespace
variable name:
```text
transport
transport_1
transport_2
...
transport_10
```
Each node is an Eden-placed object players can stand near. When a player opens
the actor interaction menu within 5 meters of a node, the menu shows a
Transport action. Selecting Transport opens destination choices for the other
configured nodes.
Arrival markers use the same suffix:
```text
transport_arrival
transport_arrival_1
transport_arrival_2
...
transport_arrival_10
```
Object names used only to exclude parked ferry/transport vehicles from cargo
pickup scans use this convention:
```text
transport_vehicle
transport_vehicle_1
transport_vehicle_2
...
transport_vehicle_10
```
The suffix mapping is direct:
- `transport` arrives at `transport_arrival`
- `transport_1` arrives at `transport_arrival_1`
- `transport_10` arrives at `transport_arrival_10`
If an arrival marker is missing, the framework falls back to a position behind
the destination node object.
## Pricing and Payment
The default fare is:
```text
base fare + distance in kilometers * price per kilometer
```
Current defaults:
- base fare: `$100`
- price per kilometer: `$50`
- cargo scan radius: `25` meters
- max indexed nodes: `10`
Payment is server-authoritative. The transport service attempts payment in this
order:
1. Player bank balance.
2. Player cash.
3. Organization credit line fallback.
The player and cargo are moved only after payment succeeds.
## Cargo and Vehicle Transfer
When a player requests transport, the server scans near the origin node for
nearby vehicles, ships, aircraft, and player units. The scan ignores:
- the origin and destination transport nodes
- objects named with the `transport_vehicle` prefix
- the requesting player
- dead entities
Use `transport_vehicle*` names for the actual boat, ferry, aircraft, or set
dressing object that should not be moved as cargo.
## Optional Per-Node Overrides
The default naming convention should cover normal missions. If a specific
mission needs another prefix or different pricing, set variables on the
transport node object:
```sqf
this setVariable ["isTransport", true, true];
this setVariable ["transportLabel", "North Dock", true];
this setVariable ["transportNodePrefix", "dock", true];
this setVariable ["transportVehiclePrefix", "dock_vehicle", true];
this setVariable ["transportArrivalPrefix", "dock_arrival", true];
this setVariable ["transportMaxIndexedNodes", 4, true];
this setVariable ["transportBaseFare", 150, true];
this setVariable ["transportPricePerKm", 75, true];
this setVariable ["transportCargoRadius", 25, true];
this setVariable ["transportIncludeCargo", true, true];
```
Only use overrides when the default `transport*` convention is not appropriate.
## Reference Images
These screenshots show the default transport setup and player workflow:
![Eden transport location one](images/eden/transport_loc_1.jpg)
![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 field](images/eden/transport_obj_1_var.jpg)
![Eden transport arrival marker placement](images/eden/transport_arrival_mrkr.jpg)
![Eden transport arrival marker variable name](images/eden/transport_arrival_mrkr_var.jpg)
![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)
![Player actor menu transport action](images/player/transport_menu_action.jpg)
![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.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1 @@

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1013 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 793 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Some files were not shown because too many files have changed in this diff Show More