Separate CAD task assignment from task acceptance

- Reserve tasks as `available`/`assigned` before leader acknowledgement
- Update CAD and task lifecycle handling and docs to reflect the new flow
- Remove startup heartbeat reset and reset task backend explicitly in preInit
This commit is contained in:
Jacob Schmidt 2026-05-17 10:12:15 -05:00
parent e49b4e4dd9
commit 9b31184f0c
11 changed files with 115 additions and 126 deletions

View File

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

View File

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

View File

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

View File

@ -1,77 +0,0 @@
#include "..\script_component.hpp"
/*
* Author: IDSolutions
* Registers Entity and starts heartbeat
*
* Arguments:
* 0: The entity <OBJECT>
* 1: Type of the entity <STRING>
* 2: The countdown timer <NUMBER>
*
* 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 };
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,7 +49,7 @@ impl<R: TaskRepository> TaskStateService<R> {
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<R: TaskRepository> TaskStateService<R> {
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"));
}
}