diff --git a/arma/server/addons/cad/README.md b/arma/server/addons/cad/README.md index 223d9c1..c5f19f5 100644 --- a/arma/server/addons/cad/README.md +++ b/arma/server/addons/cad/README.md @@ -48,6 +48,16 @@ The addon listens to and emits events through the event bus: Successful mutations may invalidate CAD state globally so clients refresh their views. +## Contract Lifecycle +CAD assignment and task acceptance are intentionally separate. Dispatch +assignment reserves a contract for a group and marks the CAD assignment +`assigned`; it does not start task logic. The assigned group leader must +acknowledge the assignment before the task is accepted and ownership is bound. +If the leader declines, CAD removes the assignment and the contract returns to +the open board. Task status follows the same lifecycle: `available` on +creation, `assigned` after dispatch assignment, and `active` after +acknowledgement. + ## Notes -CAD hydrate payloads include active task catalog entries from `TaskStore` and +CAD hydrate payloads include assignable task catalog entries from `TaskStore` and organization context from `ActorStore`. diff --git a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf index 8a09aa6..4a75cbc 100644 --- a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf +++ b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf @@ -50,9 +50,10 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ if ((_y getOrDefault ["state", ""]) isNotEqualTo "acknowledged") then { continue; }; if ((_y getOrDefault ["acknowledgedByUid", ""]) isEqualTo "") then { continue; }; if ((_dispatchOrderRegistry getOrDefault [_x, createHashMap]) isNotEqualTo createHashMap) then { continue; }; - if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; + if !((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) in ["assigned", "active"]) then { continue; }; - EGVAR(task,TaskStore) call ["bindTaskOwnership", [_x, _y getOrDefault ["acknowledgedByUid", ""]]]; + EGVAR(task,TaskStore) call ["acceptTask", [_x, _y getOrDefault ["acknowledgedByUid", ""]]]; + EGVAR(task,TaskStore) call ["setTaskStatus", [_x, "active"]]; } forEach _assignmentRegistry; _self set ["ownershipHydrated", true]; @@ -77,7 +78,7 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ }; private _status = EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]; - if !(_status in ["active", ""]) then { + if !(_status in ["available", "assigned", "active", ""]) then { _keysToRemove pushBack _x; }; } forEach _assignmentRegistry; @@ -145,7 +146,7 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ private _dispatchOrder = +(_dispatchOrderRegistry getOrDefault [_x, createHashMap]); if (_dispatchOrder isEqualTo createHashMap) then { - if ((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) isNotEqualTo "active") then { continue; }; + if !((EGVAR(task,TaskStore) call ["getTaskStatus", [_x]]) in ["assigned", "active"]) then { continue; }; _taskID = _x; } else { _taskID = _dispatchOrder getOrDefault ["title", _x]; @@ -235,8 +236,8 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ private _dispatchOrderRegistry = _state getOrDefault ["dispatchOrders", createHashMap]; private _isDispatchOrder = (_dispatchOrderRegistry getOrDefault [_taskID, createHashMap]) isNotEqualTo createHashMap; - if (!_isDispatchOrder && { (EGVAR(task,TaskStore) call ["getTaskStatus", [_taskID]]) isNotEqualTo "active" }) exitWith { - _result set ["message", "Task is no longer active."]; + if (!_isDispatchOrder && { (EGVAR(task,TaskStore) call ["getTaskStatus", [_taskID]]) isNotEqualTo "available" }) exitWith { + _result set ["message", "Task is no longer available."]; _result }; @@ -300,23 +301,15 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ _activityRepository call ["appendEntry", [_activityEntry]]; }; + if (!_isDispatchOrder) then { + EGVAR(task,TaskStore) call ["setTaskStatus", [_taskID, "assigned"]]; + }; + _result set ["success", true]; _result set ["message", _assignData getOrDefault ["message", ["Task assigned.", "Dispatch order assigned."] select _isDispatchOrder]]; _result set ["assignment", _assignment]; _result set ["leaderUid", _leaderUid]; _result set ["isDispatchOrder", _isDispatchOrder]; - if (!_isDispatchOrder && { !(isNil QEGVAR(task,TaskStore)) }) then { - private _acceptResult = EGVAR(task,TaskStore) call ["acceptTask", [_taskID, _leaderUid]]; - if !(_acceptResult getOrDefault ["success", false]) then { - ["WARNING", format [ - "CAD assigned task %1 to group %2 but could not mark it accepted for leader %3: %4", - _taskID, - _groupID, - _leaderUid, - _acceptResult getOrDefault ["message", "Unknown error."] - ]] call EFUNC(common,log); - }; - }; if (_isDispatchOrder) then { _result set ["order", +(_dispatchOrderRegistry getOrDefault [_taskID, createHashMap])]; }; @@ -524,16 +517,18 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [ switch (_transition) do { case "acknowledge": { if (!_isDispatchOrder) then { - private _bindResult = EGVAR(task,TaskStore) call ["bindTaskOwnership", [_taskID, _requesterUid]]; - if !(_bindResult getOrDefault ["success", false]) exitWith { - _result set ["message", _bindResult getOrDefault ["message", "Failed to bind task ownership."]]; + private _acceptResult = EGVAR(task,TaskStore) call ["acceptTask", [_taskID, _requesterUid]]; + if !(_acceptResult getOrDefault ["success", false]) exitWith { + _result set ["message", _acceptResult getOrDefault ["message", "Failed to accept task."]]; _result }; + EGVAR(task,TaskStore) call ["setTaskStatus", [_taskID, "active"]]; }; }; case "decline": { if (!_isDispatchOrder) then { EGVAR(task,TaskStore) call ["releaseTaskOwnership", [_taskID]]; + EGVAR(task,TaskStore) call ["setTaskStatus", [_taskID, "available"]]; }; }; }; diff --git a/arma/server/addons/task/README.md b/arma/server/addons/task/README.md index dcd4b28..67949b6 100644 --- a/arma/server/addons/task/README.md +++ b/arma/server/addons/task/README.md @@ -100,7 +100,11 @@ system-generated content rather than a hand-authored task creation path. ### CAD Compatibility CAD hydrates assignable tasks from `TaskStore.getActiveTaskCatalog`. A task must -have a catalog entry and active task status before CAD can show and assign it. +have a catalog entry and a task status of `available`, `assigned`, or `active` +before CAD can show it. +CAD assignment reserves a task for a group, but task logic waits until the +assigned group leader acknowledges the assignment. Declined assignments return +to the open CAD board. CAD-compatible creation paths: - Eden modules: compatible because they delegate to `fnc_startTask.sqf` @@ -111,11 +115,11 @@ CAD-compatible creation paths: Limited or incompatible paths: - `fnc_handler.sqf`: only compatible if a catalog entry was already registered - elsewhere. The handler sets active status and ownership, but it does not + elsewhere. The handler sets available status and ownership, but it does not create the BIS task shown in the map task tab or upsert the catalog entry - direct task function calls: not CAD-compatible by default. They bypass `fnc_startTask.sqf` and usually do not register the task catalog entry or - active status that CAD hydrates from. They also only call + available/assigned/active status that CAD hydrates from. They also only call `BIS_fnc_taskSetState` at completion/failure; they do not create the BIS task first diff --git a/arma/server/addons/task/backup/fnc_heartBeat.sqf b/arma/server/addons/task/backup/fnc_heartBeat.sqf deleted file mode 100644 index bd36a3a..0000000 --- a/arma/server/addons/task/backup/fnc_heartBeat.sqf +++ /dev/null @@ -1,77 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Registers Entity and starts heartbeat - * - * Arguments: - * 0: The entity - * 1: Type of the entity - * 2: The countdown timer - * - * Return Value: - * None - * - * Example: - * [_entity, "entity_type", 30] spawn FUNC(heartBeat); - * - * Public: Yes - */ - -params [["_entity", nil, [objNull, 0, [], sideUnknown, grpNull, ""]], ["_typeOf", "", [""]], ["_time", 0, [0]]]; - -private _nearPlayers = []; - -switch (_typeOf) do { - case "hostage": { - _entity setCaptive true; - _entity enableAIFeature ["MOVE", false]; - _entity playMove "acts_executionvictim_loop"; - - waitUntil { - sleep 1; - _nearPlayers = allPlayers inAreaArray [ASLToAGL getPosASL _entity, 2, 2, 0, false, 2]; - count _nearPlayers > 0 - }; - - private _nearPlayer = _nearPlayers select 0; - - [_entity] joinSilent (group _nearPlayer); - - // Keep rescued hostages protected while they follow the player group. - _entity setCaptive true; - _entity enableAIFeature ["MOVE", true]; - _entity playMove "acts_executionvictim_unbow"; - }; - case "hvt": { - waitUntil { - sleep 1; - _nearPlayers = allPlayers inAreaArray [ASLToAGL getPosASL _entity, 2, 2, 0, false, 2]; - count _nearPlayers > 0 - }; - - _entity setCaptive true; - doStop _entity; - }; - case "ied": { - private _taskID = _entity getVariable ["assignedTask", ""]; - if (_taskID isNotEqualTo "") then { - waitUntil { - sleep 1; - GVAR(TaskStore) call ["isTaskAccepted", [_taskID]] - }; - }; - - while { alive _entity && _time > 0} do { - if (_time > 10) then { _entity say3D "FORGE_timerBeep" }; - if (_time <= 10 && _time > 5) then { _entity say3D "FORGE_timerBeepShort" }; - if (_time <= 5) then { _entity say3D "FORGE_timerEnd" }; - if (_time <= 0) exitWith { _entity setDamage 1 }; - - _time = _time -1; - sleep 1; - }; - - if (alive _entity && _time <= 0) then { _entity setDamage 1 }; - }; -}; diff --git a/arma/server/addons/task/functions/fnc_handler.sqf b/arma/server/addons/task/functions/fnc_handler.sqf index 526f7e7..8644cf2 100644 --- a/arma/server/addons/task/functions/fnc_handler.sqf +++ b/arma/server/addons/task/functions/fnc_handler.sqf @@ -71,7 +71,7 @@ if (_taskID isNotEqualTo "") then { ]] call EFUNC(common,log); }; - GVAR(TaskStore) call ["setTaskStatus", [_taskID, "active"]]; + GVAR(TaskStore) call ["setTaskStatus", [_taskID, "available"]]; }; switch (_taskType) do { diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf index 7efe472..2ffab29 100644 --- a/arma/server/addons/task/functions/fnc_initTaskStore.sqf +++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf @@ -5,9 +5,9 @@ * Initializes the task store for task entity tracking, participant * contribution tracking, and task outcome application. * - * Task metadata is extension-backed but intentionally transient. The - * task backend is reset when this store is created so task/catalog/status - * state starts clean for each server or mission lifecycle. + * Task metadata is extension-backed but intentionally transient. The task + * backend is reset explicitly from task preInit so task/catalog/status state + * starts clean before mission setup repopulates contracts. * * Arguments: * None @@ -37,7 +37,6 @@ GVAR(TaskStore) = createHashMapObject [[ ["targets", createHashMap] ]]; - _self call ["resetMissionState", []]; }], ["resetMissionState", compileFinal { _self set ["participantRegistry", createHashMap]; @@ -62,6 +61,7 @@ GVAR(TaskStore) = createHashMapObject [[ false }; + ["INFO", "Task backend state reset for mission lifecycle."] call EFUNC(common,log); true }], ["callTaskStateEnvelope", compileFinal { diff --git a/docs/CAD_USAGE_GUIDE.md b/docs/CAD_USAGE_GUIDE.md index 44d64d7..e8e3663 100644 --- a/docs/CAD_USAGE_GUIDE.md +++ b/docs/CAD_USAGE_GUIDE.md @@ -126,6 +126,14 @@ private _result = "forge_server" callExtension ["cad:orders:create_from_context" ## Assignment Workflow +Task contracts have two separate phases. Dispatch assignment reserves a +contract for a group and sets the CAD assignment state to `assigned`, but it +does not accept or start the task. The assigned group leader must acknowledge +the assignment before task ownership is bound and task logic starts. If the +leader declines, the CAD assignment is removed and the contract returns to the +open board. Task status follows the same lifecycle: `available` on creation, +`assigned` after dispatch assignment, and `active` after acknowledgement. + ```sqf private _assignment = createHashMapFromArray [ ["groupId", "bravo"], diff --git a/docs/TASK_USAGE_GUIDE.md b/docs/TASK_USAGE_GUIDE.md index 4c0c6a8..bd898b7 100644 --- a/docs/TASK_USAGE_GUIDE.md +++ b/docs/TASK_USAGE_GUIDE.md @@ -145,7 +145,12 @@ system-generated content rather than a hand-authored task creation path. ## CAD Compatibility CAD hydrates assignable tasks from `TaskStore.getActiveTaskCatalog`. A task must -have a catalog entry and active task status before CAD can show and assign it. +have a catalog entry and a task status of `available`, `assigned`, or `active` +before CAD can show it. +CAD assignment only reserves a task for a group. The task is accepted and task +logic starts after the assigned group leader acknowledges the assignment. If +the leader declines, the CAD assignment is removed and the task returns to the +open contract board. CAD-compatible creation paths: @@ -159,7 +164,7 @@ CAD-compatible creation paths: Limited or incompatible paths: - `forge_server_task_fnc_handler`: only compatible if a catalog entry was - already registered elsewhere. The handler sets active status and ownership, + already registered elsewhere. The handler sets available status and ownership, but it does not create the BIS task shown in the map task tab or upsert the catalog entry. - Direct task function calls: not CAD-compatible by default. They bypass @@ -220,7 +225,8 @@ Use these rules for every Forge task: - `CBRNZone` 3. Prefer `RECTANGLE` or `ELLIPSE` markers with real size. 4. Set success and fail limits explicitly instead of relying on defaults. -5. If a task uses a timer, the countdown now waits until the task is assigned. +5. If a task uses a timer, the countdown now waits until the assigned group + leader acknowledges the task. 6. Grouping modules such as `Explosive Entities`, `Protected Entities`, `Cargo`, `Hostages`, and `Shooters` should be synced to real world objects, not other logic modules. @@ -368,7 +374,8 @@ Notes: - Hostages and shooters are filtered to real units only. - Hostages are protected immediately on task registration to avoid startup race conditions. -- The hostage timer now waits until the task is assigned before counting down. +- The hostage timer now waits until the assigned group leader acknowledges the + task before counting down. - `ExtZone` is checked with `inArea`, so it must be an area marker. ### HVT Task @@ -395,7 +402,8 @@ Notes: - Capture mode uses `ExtZone` with `inArea`, so use an area marker. - Elimination mode does not require an extraction zone. -- The HVT timer now waits until the task is assigned before counting down. +- The HVT timer now waits until the assigned group leader acknowledges the task + before counting down. ### Defend Task diff --git a/docus/content/3.server-modules/11.task.md b/docus/content/3.server-modules/11.task.md index 6696147..d13084c 100644 --- a/docus/content/3.server-modules/11.task.md +++ b/docus/content/3.server-modules/11.task.md @@ -144,7 +144,12 @@ system-generated content rather than a hand-authored task creation path. ## CAD Compatibility CAD hydrates assignable tasks from `TaskStore.getActiveTaskCatalog`. A task must -have a catalog entry and active task status before CAD can show and assign it. +have a catalog entry and a task status of `available`, `assigned`, or `active` +before CAD can show it. +CAD assignment only reserves a task for a group. The task is accepted and task +logic starts after the assigned group leader acknowledges the assignment. If +the leader declines, the CAD assignment is removed and the task returns to the +open contract board. CAD-compatible creation paths: @@ -158,7 +163,7 @@ CAD-compatible creation paths: Limited or incompatible paths: - `forge_server_task_fnc_handler`: only compatible if a catalog entry was - already registered elsewhere. The handler sets active status and ownership, + already registered elsewhere. The handler sets available status and ownership, but it does not create the BIS task shown in the map task tab or upsert the catalog entry. - Direct task function calls: not CAD-compatible by default. They bypass @@ -219,7 +224,8 @@ Use these rules for every Forge task: - `CBRNZone` 3. Prefer `RECTANGLE` or `ELLIPSE` markers with real size. 4. Set success and fail limits explicitly instead of relying on defaults. -5. If a task uses a timer, the countdown now waits until the task is assigned. +5. If a task uses a timer, the countdown now waits until the assigned group + leader acknowledges the task. 6. Grouping modules such as `Explosive Entities`, `Protected Entities`, `Cargo`, `Hostages`, and `Shooters` should be synced to real world objects, not other logic modules. @@ -367,7 +373,8 @@ Notes: - Hostages and shooters are filtered to real units only. - Hostages are protected immediately on task registration to avoid startup race conditions. -- The hostage timer now waits until the task is assigned before counting down. +- The hostage timer now waits until the assigned group leader acknowledges the + task before counting down. - `ExtZone` is checked with `inArea`, so it must be an area marker. ### HVT Task @@ -394,7 +401,8 @@ Notes: - Capture mode uses `ExtZone` with `inArea`, so use an area marker. - Elimination mode does not require an extraction zone. -- The HVT timer now waits until the task is assigned before counting down. +- The HVT timer now waits until the assigned group leader acknowledges the task + before counting down. ### Defend Task diff --git a/docus/content/3.server-modules/3.cad.md b/docus/content/3.server-modules/3.cad.md index 53fa3db..c10f700 100644 --- a/docus/content/3.server-modules/3.cad.md +++ b/docus/content/3.server-modules/3.cad.md @@ -124,6 +124,14 @@ private _result = "forge_server" callExtension ["cad:orders:create_from_context" ## Assignment Workflow +Task contracts have two separate phases. Dispatch assignment reserves a +contract for a group and sets the CAD assignment state to `assigned`, but it +does not accept or start the task. The assigned group leader must acknowledge +the assignment before task ownership is bound and task logic starts. If the +leader declines, the CAD assignment is removed and the contract returns to the +open board. Task status follows the same lifecycle: `available` on creation, +`assigned` after dispatch assignment, and `active` after acknowledgement. + ```sqf private _assignment = createHashMapFromArray [ ["groupId", "bravo"], diff --git a/lib/services/src/task.rs b/lib/services/src/task.rs index e66dd94..3899b75 100644 --- a/lib/services/src/task.rs +++ b/lib/services/src/task.rs @@ -49,7 +49,7 @@ impl TaskStateService { let mut active_entries = Vec::new(); for (task_id, status) in active_statuses { - if status != "active" { + if !matches!(status.as_str(), "available" | "assigned" | "active") { continue; } @@ -129,8 +129,11 @@ impl TaskStateService { return Err("Missing task ID or requester UID.".to_string()); } - if self.get_status(entry_id.clone())? != "active" { - return Err("Task is no longer active.".to_string()); + if !matches!( + self.get_status(entry_id.clone())?.as_str(), + "assigned" | "active" + ) { + return Err("Task is not assigned or active.".to_string()); } if let Some(existing) = self.repository.get_ownership(&entry_id)? @@ -381,9 +384,21 @@ mod tests { } #[test] - fn list_active_catalog_only_returns_active_entries() { + fn list_active_catalog_returns_assignable_and_active_entries() { let service = TaskStateService::new(InMemoryTaskRepository::new()); + service + .upsert_catalog_entry( + "task-available".to_string(), + r#"{"title":"Available"}"#.to_string(), + ) + .expect("available catalog upsert should succeed"); + service + .upsert_catalog_entry( + "task-assigned".to_string(), + r#"{"title":"Assigned"}"#.to_string(), + ) + .expect("assigned catalog upsert should succeed"); service .upsert_catalog_entry( "task-active".to_string(), @@ -393,6 +408,12 @@ mod tests { service .upsert_catalog_entry("task-done".to_string(), r#"{"title":"Done"}"#.to_string()) .expect("done catalog upsert should succeed"); + service + .set_status("task-available".to_string(), "available".to_string()) + .expect("available status update should succeed"); + service + .set_status("task-assigned".to_string(), "assigned".to_string()) + .expect("assigned status update should succeed"); service .set_status("task-active".to_string(), "active".to_string()) .expect("active status update should succeed"); @@ -404,10 +425,14 @@ mod tests { .list_active_catalog() .expect("active catalog should build"); - assert_eq!(active_catalog.len(), 1); - assert_eq!( - active_catalog[0].get("taskId").and_then(Value::as_str), - Some("task-active") - ); + let task_ids: Vec<_> = active_catalog + .iter() + .filter_map(|entry| entry.get("taskId").and_then(Value::as_str)) + .collect(); + + assert_eq!(active_catalog.len(), 3); + assert!(task_ids.contains(&"task-available")); + assert!(task_ids.contains(&"task-assigned")); + assert!(task_ids.contains(&"task-active")); } }