diff --git a/.github/workflows/commit-validation.yml b/.github/workflows/commit-validation.yml index 89a29ada..8bbb22e6 100644 --- a/.github/workflows/commit-validation.yml +++ b/.github/workflows/commit-validation.yml @@ -327,6 +327,7 @@ jobs: tools/tests/renderer_vulkan_shadow_compatibility.py \ tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/rigid_body_recovery_diagnostics.py \ + tools/tests/ai_medic_approach_watchdog.py \ tools/tests/savegame_corruption_contract.py \ tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ @@ -714,6 +715,7 @@ jobs: python tools/tests/renderer_vulkan_shadow_compatibility.py python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/rigid_body_recovery_diagnostics.py + python tools/tests/ai_medic_approach_watchdog.py python tools/tests/savegame_corruption_contract.py python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py diff --git a/.github/workflows/push-verification.yml b/.github/workflows/push-verification.yml index 7e3bc51b..2649324b 100644 --- a/.github/workflows/push-verification.yml +++ b/.github/workflows/push-verification.yml @@ -327,6 +327,7 @@ jobs: tools/tests/renderer_vulkan_shadow_compatibility.py \ tools/tests/renderer_vulkan_world_interaction_compatibility.py \ tools/tests/rigid_body_recovery_diagnostics.py \ + tools/tests/ai_medic_approach_watchdog.py \ tools/tests/savegame_corruption_contract.py \ tools/tests/savegame_pointer_width_safety.py \ tools/tests/savegame_v3_contract.py \ @@ -714,6 +715,7 @@ jobs: python tools/tests/renderer_vulkan_shadow_compatibility.py python tools/tests/renderer_vulkan_world_interaction_compatibility.py python tools/tests/rigid_body_recovery_diagnostics.py + python tools/tests/ai_medic_approach_watchdog.py python tools/tests/savegame_corruption_contract.py python tools/tests/savegame_pointer_width_safety.py python tools/tests/savegame_v3_contract.py diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index 07a0c5ff..8014b420 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -10,6 +10,19 @@ completed release material here and publish curated notes in `releases/`. ## Post-0.14.000 corrections +- [ ] Stop medics and techs waiting forever for a player they cannot reach + (#184). Choosing the player as a patient makes the healer passive and blind to + enemies, and the approach only ended beside a visible patient, so a player it + could see but not reach held it in place indefinitely, as reported for Tech Li + after hangar1's lift. `ai_medicApproachTimeout` (15 s, 0 disables) now abandons + an approach that makes no progress and holds back automatic heals for as long + again; a heal the player asks for is unaffected. No save-format change. + `ai_medic_approach_watchdog.py` keeps SP and MP aligned. + +Candidate AI release wording: Medics and techs no longer freeze in place when +they cannot reach you to heal or repair you; after a short wait they carry on +with their orders. + - [x] Complete the stage-one baseline implementation and audit: use a shared high-resolution presentation phase for each SP/MP draw and precise OpenXR smooth-turn deltas; restore imported game regression coverage; reconcile diff --git a/src/game/ai/AI_Medic.cpp b/src/game/ai/AI_Medic.cpp index 36ade65b..c63cde0c 100644 --- a/src/game/ai/AI_Medic.cpp +++ b/src/game/ai/AI_Medic.cpp @@ -39,6 +39,9 @@ rvAIMedic::rvAIMedic ( void ) { wasAware = false; wasIgnoreEnemies = false; healDebounceTime = 0; + approachDistance = 0.0f; + approachProgressTime = 0; + patientRetryTime = 0; } void rvAIMedic::InitSpawnArgsVariables( void ) @@ -143,6 +146,9 @@ void rvAIMedic::Restore( idRestoreGame *savefile ) { mPostHealScript.Restore( savefile ); InitSpawnArgsVariables(); + + approachProgressTime = 0; + patientRetryTime = 0; } /* @@ -282,6 +288,7 @@ void rvAIMedic::TakePatient( idPlayer* pPatient ) ClearEnemy(); lookTarget = patient; healing = false; + approachProgressTime = 0; //healedAmount = 0; if ( pPatient && DistanceTo( pPatient ) > 250.0f && !silent ) @@ -444,6 +451,30 @@ bool rvAIMedic::SituationAllowsPatient( void ) return false; } +/* +================ +rvAIMedic::PatientApproachStalled + +True once the medic has not come closer to its patient than its best distance +so far for ai_medicApproachTimeout seconds. Only a new best restarts the clock, +whichever of them moved: a medic re-pathing back and forth beneath a patient it +cannot reach keeps moving without ever closing in. +================ +*/ +bool rvAIMedic::PatientApproachStalled( void ) { + const float timeoutSec = ai_medicApproachTimeout.GetFloat(); + if ( timeoutSec <= 0.0f || !patient ) { + return false; + } + const float distance = DistanceTo( patient ); + if ( approachProgressTime == 0 || distance < approachDistance - 8.0f ) { + approachDistance = distance; + approachProgressTime = gameLocal.GetTime(); + return false; + } + return gameLocal.GetTime() - approachProgressTime > SEC2MS( timeoutSec ); +} + bool rvAIMedic::AvailableToTakePatient( void ) { if ( !healDisabled ) @@ -481,7 +512,9 @@ void rvAIMedic::Think ( void ) { { emergencyOverride = false; } - if ( AvailableToTakePatient() ) + // A medic that gave up on an approach waits before it volunteers + // again; a heal the player asks for (TalkTo) is not held back. + if ( gameLocal.GetTime() >= patientRetryTime && AvailableToTakePatient() ) { idPlayer* player = gameLocal.GetLocalPlayer(); @@ -616,6 +649,20 @@ stateResult_t rvAIMedic::State_Medic ( const stateParms_t& parms ) { switch ( parms.stage ) { case STAGE_MOVE: + // TakePatient makes the medic passive and blind to enemies, and this + // stage only ends on reaching the patient, so a patient it can see but + // never reach would hold it here for good. Give up once it stops closing + // the distance; the map script and its tether continue as normal. + if ( PatientApproachStalled() ) { + gameLocal.Warning( "medic '%s' made no progress toward '%s' for %g seconds (distance %.0f); giving up on the heal for now", + GetName(), patient->GetName(), ai_medicApproachTimeout.GetFloat(), DistanceTo( patient ) ); + StopMove( MOVE_STATUS_DONE ); + DropPatient(); + patientRetryTime = gameLocal.GetTime() + SEC2MS( ai_medicApproachTimeout.GetFloat() ); + ExecScriptFunction( mPostHealScript ); + return SRESULT_DONE; + } + // Attack when we have either stopped moving or are within melee range if ( move.fl.done ) { diff --git a/src/game/ai/AI_Medic.h b/src/game/ai/AI_Medic.h index b5c46d41..941606fd 100644 --- a/src/game/ai/AI_Medic.h +++ b/src/game/ai/AI_Medic.h @@ -69,6 +69,11 @@ class rvAIMedic : public rvAITactical { bool wasAware; bool wasIgnoreEnemies; + // Approach watchdog. Transient: a restored medic starts measuring afresh. + float approachDistance; + int approachProgressTime; + int patientRetryTime; + void SetHealValues ( idPlayer* player ); void TakePatient ( idPlayer* pPatient ); @@ -76,6 +81,7 @@ class rvAIMedic : public rvAITactical { bool CheckTakePatient ( idPlayer* actor ); bool SituationAllowsPatient ( void ); bool AvailableToTakePatient ( void ); + bool PatientApproachStalled ( void ); stateResult_t State_Medic ( const stateParms_t& parms ); diff --git a/src/game/gamesys/SysCvar.cpp b/src/game/gamesys/SysCvar.cpp index 5f12a02b..b0ef88e2 100644 --- a/src/game/gamesys/SysCvar.cpp +++ b/src/game/gamesys/SysCvar.cpp @@ -287,6 +287,7 @@ idCVar ai_disableCover( "ai_disableCover", "0", CVAR_GAME | CVAR_BOOL, "d //cdr: use new master move functions idCVar ai_useRVMasterMove( "ai_useRVMasterMove", "0", CVAR_GAME | CVAR_BOOL, "changes AI to use new master move function" ); //jshepard: allow out of date AAS files to be used, for testing +idCVar ai_medicApproachTimeout( "ai_medicApproachTimeout", "15", CVAR_GAME | CVAR_FLOAT, "seconds a medic or tech may make no progress toward the player it chose to heal before it gives up and resumes its orders; 0 disables the watchdog" ); idCVar ai_scriptedMoveTimeout( "ai_scriptedMoveTimeout", "30", CVAR_GAME | CVAR_FLOAT, "seconds a scripted move may make no forward progress before it is abandoned so the map script can continue; 0 disables the watchdog" ); idCVar ai_allowOldAAS( "ai_allowOldAAS", "0", CVAR_GAME | CVAR_BOOL, "allows AI to use most recent AAS file, even if it is not up-to-date. Enable only for testing."); // twhitaker: debugging support for eye focus diff --git a/src/game/gamesys/SysCvar.h b/src/game/gamesys/SysCvar.h index 05ecec19..f1e01105 100644 --- a/src/game/gamesys/SysCvar.h +++ b/src/game/gamesys/SysCvar.h @@ -144,6 +144,7 @@ extern idCVar ai_debugHelpers; extern idCVar ai_useRVMasterMove; //jshepard: allow old AAS files extern idCVar ai_scriptedMoveTimeout; +extern idCVar ai_medicApproachTimeout; extern idCVar ai_allowOldAAS; // twhitaker: debugging support for eye focus extern idCVar ai_debugEyeFocus; diff --git a/src/mpgame/ai/AI_Medic.cpp b/src/mpgame/ai/AI_Medic.cpp index c5257e42..0478c6ec 100644 --- a/src/mpgame/ai/AI_Medic.cpp +++ b/src/mpgame/ai/AI_Medic.cpp @@ -39,6 +39,9 @@ rvAIMedic::rvAIMedic ( void ) { wasAware = false; wasIgnoreEnemies = false; healDebounceTime = 0; + approachDistance = 0.0f; + approachProgressTime = 0; + patientRetryTime = 0; } void rvAIMedic::InitSpawnArgsVariables( void ) @@ -143,6 +146,9 @@ void rvAIMedic::Restore( idRestoreGame *savefile ) { mPostHealScript.Restore( savefile ); InitSpawnArgsVariables(); + + approachProgressTime = 0; + patientRetryTime = 0; } /* @@ -282,6 +288,7 @@ void rvAIMedic::TakePatient( idPlayer* pPatient ) ClearEnemy(); lookTarget = patient; healing = false; + approachProgressTime = 0; //healedAmount = 0; if ( pPatient && DistanceTo( pPatient ) > 250.0f && !silent ) @@ -444,6 +451,30 @@ bool rvAIMedic::SituationAllowsPatient( void ) return false; } +/* +================ +rvAIMedic::PatientApproachStalled + +True once the medic has not come closer to its patient than its best distance +so far for ai_medicApproachTimeout seconds. Only a new best restarts the clock, +whichever of them moved: a medic re-pathing back and forth beneath a patient it +cannot reach keeps moving without ever closing in. +================ +*/ +bool rvAIMedic::PatientApproachStalled( void ) { + const float timeoutSec = ai_medicApproachTimeout.GetFloat(); + if ( timeoutSec <= 0.0f || !patient ) { + return false; + } + const float distance = DistanceTo( patient ); + if ( approachProgressTime == 0 || distance < approachDistance - 8.0f ) { + approachDistance = distance; + approachProgressTime = gameLocal.GetTime(); + return false; + } + return gameLocal.GetTime() - approachProgressTime > SEC2MS( timeoutSec ); +} + bool rvAIMedic::AvailableToTakePatient( void ) { if ( !healDisabled ) @@ -481,7 +512,9 @@ void rvAIMedic::Think ( void ) { { emergencyOverride = false; } - if ( AvailableToTakePatient() ) + // A medic that gave up on an approach waits before it volunteers + // again; a heal the player asks for (TalkTo) is not held back. + if ( gameLocal.GetTime() >= patientRetryTime && AvailableToTakePatient() ) { idPlayer* player = gameLocal.GetLocalPlayer(); @@ -616,6 +649,20 @@ stateResult_t rvAIMedic::State_Medic ( const stateParms_t& parms ) { switch ( parms.stage ) { case STAGE_MOVE: + // TakePatient makes the medic passive and blind to enemies, and this + // stage only ends on reaching the patient, so a patient it can see but + // never reach would hold it here for good. Give up once it stops closing + // the distance; the map script and its tether continue as normal. + if ( PatientApproachStalled() ) { + gameLocal.Warning( "medic '%s' made no progress toward '%s' for %g seconds (distance %.0f); giving up on the heal for now", + GetName(), patient->GetName(), ai_medicApproachTimeout.GetFloat(), DistanceTo( patient ) ); + StopMove( MOVE_STATUS_DONE ); + DropPatient(); + patientRetryTime = gameLocal.GetTime() + SEC2MS( ai_medicApproachTimeout.GetFloat() ); + ExecScriptFunction( mPostHealScript ); + return SRESULT_DONE; + } + // Attack when we have either stopped moving or are within melee range if ( move.fl.done ) { diff --git a/src/mpgame/ai/AI_Medic.h b/src/mpgame/ai/AI_Medic.h index b5c46d41..941606fd 100644 --- a/src/mpgame/ai/AI_Medic.h +++ b/src/mpgame/ai/AI_Medic.h @@ -69,6 +69,11 @@ class rvAIMedic : public rvAITactical { bool wasAware; bool wasIgnoreEnemies; + // Approach watchdog. Transient: a restored medic starts measuring afresh. + float approachDistance; + int approachProgressTime; + int patientRetryTime; + void SetHealValues ( idPlayer* player ); void TakePatient ( idPlayer* pPatient ); @@ -76,6 +81,7 @@ class rvAIMedic : public rvAITactical { bool CheckTakePatient ( idPlayer* actor ); bool SituationAllowsPatient ( void ); bool AvailableToTakePatient ( void ); + bool PatientApproachStalled ( void ); stateResult_t State_Medic ( const stateParms_t& parms ); diff --git a/src/mpgame/gamesys/SysCvar.cpp b/src/mpgame/gamesys/SysCvar.cpp index b752bdf1..574843e5 100644 --- a/src/mpgame/gamesys/SysCvar.cpp +++ b/src/mpgame/gamesys/SysCvar.cpp @@ -390,6 +390,7 @@ idCVar ai_disableCover( "ai_disableCover", "0", CVAR_GAME | CVAR_BOOL, "d //cdr: use new master move functions idCVar ai_useRVMasterMove( "ai_useRVMasterMove", "0", CVAR_GAME | CVAR_BOOL, "changes AI to use new master move function" ); //jshepard: allow out of date AAS files to be used, for testing +idCVar ai_medicApproachTimeout( "ai_medicApproachTimeout", "15", CVAR_GAME | CVAR_FLOAT, "seconds a medic or tech may make no progress toward the player it chose to heal before it gives up and resumes its orders; 0 disables the watchdog" ); idCVar ai_scriptedMoveTimeout( "ai_scriptedMoveTimeout", "30", CVAR_GAME | CVAR_FLOAT, "seconds a scripted move may make no forward progress before it is abandoned so the map script can continue; 0 disables the watchdog" ); idCVar ai_allowOldAAS( "ai_allowOldAAS", "0", CVAR_GAME | CVAR_BOOL, "allows AI to use most recent AAS file, even if it is not up-to-date. Enable only for testing."); // twhitaker: debugging support for eye focus diff --git a/src/mpgame/gamesys/SysCvar.h b/src/mpgame/gamesys/SysCvar.h index b06816ba..dcad5c50 100644 --- a/src/mpgame/gamesys/SysCvar.h +++ b/src/mpgame/gamesys/SysCvar.h @@ -143,6 +143,7 @@ extern idCVar ai_debugHelpers; extern idCVar ai_useRVMasterMove; //jshepard: allow old AAS files extern idCVar ai_scriptedMoveTimeout; +extern idCVar ai_medicApproachTimeout; extern idCVar ai_allowOldAAS; // twhitaker: debugging support for eye focus extern idCVar ai_debugEyeFocus; diff --git a/tools/tests/ai_medic_approach_watchdog.py b/tools/tests/ai_medic_approach_watchdog.py new file mode 100644 index 00000000..e79dfc79 --- /dev/null +++ b/tools/tests/ai_medic_approach_watchdog.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""A medic or tech must not wait forever for a patient it cannot reach. + +rvAIMedic::TakePatient makes the healer passive and blind to enemies, and +State_Medic's approach stage ends only when the healer is beside the patient +and can see it. A patient it can see but never reach -- or a stationary healer +whose patient walked off -- therefore held the healer in that stage for good, +standing in its idle pose while the map script and its tether waited (issue +#184, Tech Li on hangar1's lift). + +The watchdog gives up after ai_medicApproachTimeout seconds without progress, +then holds off automatic heals for as long again. This contract checks: + +* SP and MP carry the same watchdog; +* it runs first in the approach stage and leaves through the existing + DropPatient/post-heal-script path; +* only automatic heals are held back, not one the player asks for (TalkTo); +* the watchdog fields stay out of the save, so the payload is unchanged. +""" + +from __future__ import annotations + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +TREES = ("game", "mpgame") + + +def read(path: Path) -> str: + if not path.is_file(): + raise AssertionError(f"Required source file not found: {path}") + return path.read_text(encoding="utf-8") + + +def body(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise AssertionError(f"Missing {signature}") + depth = 0 + for index in range(source.index("{", start), len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return source[start:index + 1] + raise AssertionError(f"Unbalanced body for {signature}") + + +def strip_comments(text: str) -> str: + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + return re.sub(r"//[^\n]*", "", text) + + +def validate_tree(tree: str) -> dict[str, str]: + cpp = read(ROOT / "src" / tree / "ai" / "AI_Medic.cpp") + header = read(ROOT / "src" / tree / "ai" / "AI_Medic.h") + cvars = read(ROOT / "src" / tree / "gamesys" / "SysCvar.cpp") + cvar_header = read(ROOT / "src" / tree / "gamesys" / "SysCvar.h") + context = f"{tree} rvAIMedic" + + if not re.search(r'idCVar ai_medicApproachTimeout\(\s*"ai_medicApproachTimeout",\s*"15"', cvars): + raise AssertionError(f"{tree} does not define ai_medicApproachTimeout with its 15 second default") + if "extern idCVar ai_medicApproachTimeout;" not in cvar_header: + raise AssertionError(f"{tree} does not declare ai_medicApproachTimeout") + for member in ("approachDistance", "approachProgressTime", "patientRetryTime"): + if member not in header: + raise AssertionError(f"{context} lacks watchdog member {member}") + + stalled = strip_comments(body(cpp, "bool rvAIMedic::PatientApproachStalled( void )")) + for token in ( + "ai_medicApproachTimeout.GetFloat()", + "timeoutSec <= 0.0f", + "distance < approachDistance - 8.0f", + "approachProgressTime = gameLocal.GetTime();", + "SEC2MS( timeoutSec )", + ): + if token not in stalled: + raise AssertionError(f"{context} watchdog lacks {token!r}") + # Movement alone is not progress: a medic shuffling beneath an unreachable + # patient moves constantly without ever closing in. + if "GetOrigin" in stalled: + raise AssertionError(f"{context} watchdog must measure closing distance, not movement") + + medic = strip_comments(body(cpp, "stateResult_t rvAIMedic::State_Medic ( const stateParms_t& parms )")) + move_stage = medic[medic.index("case STAGE_MOVE:"):medic.index("case STAGE_PRE_HEAL_ANIM_WAIT:")] + first_statement = move_stage[len("case STAGE_MOVE:"):].lstrip() + if not first_statement.startswith("if ( PatientApproachStalled() )"): + raise AssertionError(f"{context} must check the watchdog before anything else in the approach stage") + giving_up = move_stage[:move_stage.index("return SRESULT_DONE;")] + for token in ("StopMove( MOVE_STATUS_DONE );", "DropPatient();", "patientRetryTime =", "ExecScriptFunction( mPostHealScript );"): + if token not in giving_up: + raise AssertionError(f"{context} gives up without {token!r}") + + think = strip_comments(body(cpp, "void rvAIMedic::Think ( void )")) + if "gameLocal.GetTime() >= patientRetryTime && AvailableToTakePatient()" not in think: + raise AssertionError(f"{context} does not hold back automatic heals after giving up") + talk = strip_comments(body(cpp, "void rvAIMedic::TalkTo( idActor *actor )")) + if "patientRetryTime" in talk: + raise AssertionError(f"{context} must not hold back a heal the player asks for") + + take = strip_comments(body(cpp, "void rvAIMedic::TakePatient( idPlayer* pPatient )")) + if "approachProgressTime = 0;" not in take: + raise AssertionError(f"{context} does not restart the watchdog for a new patient") + restore = strip_comments(body(cpp, "void rvAIMedic::Restore( idRestoreGame *savefile )")) + if "approachProgressTime = 0;" not in restore or "patientRetryTime = 0;" not in restore: + raise AssertionError(f"{context} does not reset the watchdog after a restore") + + save = strip_comments(body(cpp, "void rvAIMedic::Save( idSaveGame *savefile ) const")) + for member in ("approach", "patientRetryTime"): + if member in save or member in restore.replace("approachProgressTime = 0;", "").replace("patientRetryTime = 0;", ""): + raise AssertionError(f"{context} serializes watchdog state; it must stay out of the save") + + return {"PatientApproachStalled": stalled, "State_Medic": medic, "Think": think} + + +def main() -> None: + sp = validate_tree("game") + mp = validate_tree("mpgame") + for name in sp: + if sp[name] != mp[name]: + raise AssertionError(f"SP and MP rvAIMedic::{name} differ") + print("ai_medic_approach_watchdog: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index b7b756da..941fa943 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -599,6 +599,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "renderer_vulkan_shadow_compatibility.py", root / "tools" / "tests" / "renderer_vulkan_world_interaction_compatibility.py", root / "tools" / "tests" / "rigid_body_recovery_diagnostics.py", + root / "tools" / "tests" / "ai_medic_approach_watchdog.py", root / "tools" / "tests" / "savegame_corruption_contract.py", root / "tools" / "tests" / "savegame_pointer_width_safety.py", root / "tools" / "tests" / "savegame_v3_contract.py",